This is an automated email from the ASF dual-hosted git repository.
pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 6b7fba5f331 NIFI-15985 - Add per-document Index Field and Timestamp
Field extraction to PutElasticsearchJson (#11299)
6b7fba5f331 is described below
commit 6b7fba5f3312c026e17beb2951474a523f81ef59
Author: agturley <[email protected]>
AuthorDate: Tue Jun 2 10:40:47 2026 -0700
NIFI-15985 - Add per-document Index Field and Timestamp Field extraction to
PutElasticsearchJson (#11299)
---
.../elasticsearch/PutElasticsearchJson.java | 331 +++++++++++++++++++--
.../elasticsearch/PutElasticsearchJsonTest.java | 229 ++++++++++++++
2 files changed, 531 insertions(+), 29 deletions(-)
diff --git
a/nifi-extension-bundles/nifi-elasticsearch-bundle/nifi-elasticsearch-restapi-processors/src/main/java/org/apache/nifi/processors/elasticsearch/PutElasticsearchJson.java
b/nifi-extension-bundles/nifi-elasticsearch-bundle/nifi-elasticsearch-restapi-processors/src/main/java/org/apache/nifi/processors/elasticsearch/PutElasticsearchJson.java
index 2b10871da38..29b05d854c1 100644
---
a/nifi-extension-bundles/nifi-elasticsearch-bundle/nifi-elasticsearch-restapi-processors/src/main/java/org/apache/nifi/processors/elasticsearch/PutElasticsearchJson.java
+++
b/nifi-extension-bundles/nifi-elasticsearch-bundle/nifi-elasticsearch-restapi-processors/src/main/java/org/apache/nifi/processors/elasticsearch/PutElasticsearchJson.java
@@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import org.apache.nifi.annotation.behavior.DynamicProperties;
import org.apache.nifi.annotation.behavior.DynamicProperty;
import org.apache.nifi.annotation.behavior.InputRequirement;
@@ -65,6 +66,7 @@ import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -83,7 +85,10 @@ import java.util.concurrent.atomic.AtomicBoolean;
"NDJSON (one JSON object per line), JSON Array (a top-level array of
objects, streamed for memory efficiency), " +
"and Single JSON (the entire FlowFile is one document). " +
"FlowFiles are accumulated up to the configured Max Batch Size and
flushed to Elasticsearch in _bulk API requests. " +
- "Large files that exceed the batch size are automatically split into
multiple _bulk requests.")
+ "Large files that exceed the batch size are automatically split into
multiple _bulk requests. " +
+ "Records routed to the \"successful\" and \"errors\" relationships
reflect the original FlowFile content, " +
+ "not the transformed document body sent to Elasticsearch
(identifier/index/timestamp field extraction and " +
+ "null suppression are not reapplied), so the records remain suitable
for replay.")
@WritesAttributes({
@WritesAttribute(attribute = "elasticsearch.put.error",
description = "The error message if there is an issue parsing
the FlowFile, sending the parsed document to Elasticsearch or parsing the
Elasticsearch response"),
@@ -245,6 +250,69 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
.dependsOn(INPUT_FORMAT, InputFormat.NDJSON,
InputFormat.JSON_ARRAY)
.build();
+ static final PropertyDescriptor RETAIN_IDENTIFIER_FIELD = new
PropertyDescriptor.Builder()
+ .name("Retain Identifier Field")
+ .description("""
+ Whether to keep the Identifier Field in the document body
after extracting it \
+ for use as the Elasticsearch document ID. \
+ When true (default), the field is left in the document;
set to false to remove it before indexing.\
+ """)
+ .required(true)
+ .allowableValues("true", "false")
+ .defaultValue("true")
+ .dependsOn(IDENTIFIER_FIELD)
+ .build();
+
+ static final PropertyDescriptor INDEX_FIELD = new
PropertyDescriptor.Builder()
+ .name("Index Field")
+ .description("""
+ The name of the field within each document to use as the
Elasticsearch index name. \
+ If the field is not present in a document or this property
is left blank, \
+ the configured Index property value is used as the
fallback.\
+ """)
+ .required(false)
+ .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+ .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+ .build();
+
+ static final PropertyDescriptor RETAIN_INDEX_FIELD = new
PropertyDescriptor.Builder()
+ .name("Retain Index Field")
+ .description("""
+ Whether to keep the Index Field in the document body after
extracting it \
+ for use as the Elasticsearch index name. \
+ When true (default), the field is left in the document;
set to false to remove it before indexing.\
+ """)
+ .required(true)
+ .allowableValues("true", "false")
+ .defaultValue("true")
+ .dependsOn(INDEX_FIELD)
+ .build();
+
+ static final PropertyDescriptor TIMESTAMP_FIELD = new
PropertyDescriptor.Builder()
+ .name("Timestamp Field")
+ .description("""
+ The name of a field within each document whose value will
be written to \
+ Elasticsearch as the @timestamp field. \
+ If the field is absent or this property is left blank, no
@timestamp is set.\
+ """)
+ .required(false)
+ .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT)
+ .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+ .build();
+
+ static final PropertyDescriptor RETAIN_TIMESTAMP_FIELD = new
PropertyDescriptor.Builder()
+ .name("Retain Timestamp Field")
+ .description("""
+ Whether to keep the Timestamp Field in the document body
after copying its \
+ value to @timestamp. \
+ When true (default), the field is left in the document;
set to false to remove it before indexing.\
+ """)
+ .required(true)
+ .allowableValues("true", "false")
+ .defaultValue("true")
+ .dependsOn(TIMESTAMP_FIELD)
+ .build();
+
static final Relationship REL_BULK_REQUEST = new Relationship.Builder()
.name("bulk_request")
.description("When \"Output Bulk Request\" is enabled, the raw
Elasticsearch _bulk API request body is written " +
@@ -274,6 +342,11 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
SUPPRESS_NULLS,
ID_ATTRIBUTE,
IDENTIFIER_FIELD,
+ RETAIN_IDENTIFIER_FIELD,
+ INDEX_FIELD,
+ RETAIN_INDEX_FIELD,
+ TIMESTAMP_FIELD,
+ RETAIN_TIMESTAMP_FIELD,
CHARSET,
MAX_JSON_FIELD_STRING_LENGTH,
CLIENT_SERVICE,
@@ -396,6 +469,18 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
final String documentIdField = inputFormat != InputFormat.SINGLE_JSON
?
context.getProperty(IDENTIFIER_FIELD).evaluateAttributeExpressions().getValue()
: null;
+ final String documentIndexField =
context.getProperty(INDEX_FIELD).evaluateAttributeExpressions().getValue();
+ final String documentTimestampField =
context.getProperty(TIMESTAMP_FIELD).evaluateAttributeExpressions().getValue();
+ // The Retain toggles depend on their source field being set, so only
read them when it is —
+ // reading a property whose dependency is unsatisfied is invalid. When
the source field is
+ // blank no extraction or stripping occurs, so the retain value is
irrelevant anyway.
+ final boolean retainIdentifierField = inputFormat !=
InputFormat.SINGLE_JSON
+ && StringUtils.isNotBlank(documentIdField)
+ && context.getProperty(RETAIN_IDENTIFIER_FIELD).asBoolean();
+ final boolean retainIndexField =
StringUtils.isBlank(documentIndexField)
+ || context.getProperty(RETAIN_INDEX_FIELD).asBoolean();
+ final boolean retainTimestampField =
StringUtils.isBlank(documentTimestampField)
+ || context.getProperty(RETAIN_TIMESTAMP_FIELD).asBoolean();
final int batchSize = InputFormat.SINGLE_JSON == inputFormat
?
context.getProperty(BATCH_SIZE).evaluateAttributeExpressions().asInteger()
: Integer.MAX_VALUE;
@@ -450,17 +535,36 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
final IndexOperationRequest opRequest;
final long docBytes;
if (o == IndexOperationRequest.Operation.Index ||
o == IndexOperationRequest.Operation.Create) {
- final String id = extractId(trimmedLine,
documentIdField, flowFileIdAttribute);
final byte[] rawJsonBytes;
- if (suppressingWriter != null) {
- // Parse to Map so NON_NULL/NON_EMPTY
inclusion filters apply during serialization.
- // JsonNode tree serialization bypasses
JsonInclude filters.
- rawJsonBytes =
suppressingWriter.writeValueAsBytes(mapReader.readValue(trimmedLine));
+ final String id;
+ final String docIndex;
+ final boolean stripId = !retainIdentifierField
&& StringUtils.isNotBlank(documentIdField);
+ final boolean stripIdx = !retainIndexField &&
StringUtils.isNotBlank(documentIndexField);
+ final boolean needsTimestamp =
StringUtils.isNotBlank(documentTimestampField);
+ if (suppressingWriter != null || stripId ||
stripIdx || needsTimestamp) {
+ // Map is needed anyway — extract both
fields from the Map directly.
+ final Map<String, Object> contentMap =
mapReader.readValue(trimmedLine);
+ id = resolveId(contentMap,
documentIdField, flowFileIdAttribute);
+ docIndex = resolveIndex(contentMap,
documentIndexField, index);
+ if (stripId) {
+ contentMap.remove(documentIdField);
+ }
+ if (stripIdx) {
+ contentMap.remove(documentIndexField);
+ }
+ applyTimestamp(contentMap,
documentTimestampField, retainTimestampField);
+ rawJsonBytes = suppressingWriter != null
+ ?
suppressingWriter.writeValueAsBytes(contentMap)
+ :
mapper.writeValueAsBytes(contentMap);
} else {
+ // Raw-bytes path: single streaming scan
finds both fields at once.
+ final String[] extracted =
extractIdAndIndex(trimmedLine, documentIdField, flowFileIdAttribute,
documentIndexField, index);
+ id = extracted[0];
+ docIndex = extracted[1];
rawJsonBytes =
trimmedLine.getBytes(StandardCharsets.UTF_8);
}
opRequest = IndexOperationRequest.builder()
- .index(index)
+ .index(docIndex)
.type(type)
.id(id)
.rawJsonBytes(rawJsonBytes)
@@ -474,8 +578,16 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
} else {
final Map<String, Object> contentMap =
mapReader.readValue(trimmedLine);
final String id = resolveId(contentMap,
documentIdField, flowFileIdAttribute);
+ final String docIndex =
resolveIndex(contentMap, documentIndexField, index);
+ if (!retainIdentifierField &&
StringUtils.isNotBlank(documentIdField)) {
+ contentMap.remove(documentIdField);
+ }
+ if (!retainIndexField &&
StringUtils.isNotBlank(documentIndexField)) {
+ contentMap.remove(documentIndexField);
+ }
+ applyTimestamp(contentMap,
documentTimestampField, retainTimestampField);
opRequest = IndexOperationRequest.builder()
- .index(index)
+ .index(docIndex)
.type(type)
.id(id)
.fields(contentMap)
@@ -527,22 +639,45 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
final long docBytes;
final byte[] rawJsonBytes;
final String id;
+ final String docIndex;
if (suppressingWriter != null) {
// Parse directly to Map so
NON_NULL/NON_EMPTY inclusion filters apply during
// serialization. JsonNode tree
serialization bypasses JsonInclude filters,
// and convertValue(node, Map) adds an
extra serialization cycle.
final Map<String, Object> contentMap =
mapReader.readValue(parser);
docBytes = Math.max(1,
parser.currentLocation().getCharOffset() - startOffset);
- rawJsonBytes =
suppressingWriter.writeValueAsBytes(contentMap);
id = resolveId(contentMap,
documentIdField, flowFileIdAttribute);
+ docIndex = resolveIndex(contentMap,
documentIndexField, index);
+ if (!retainIdentifierField &&
StringUtils.isNotBlank(documentIdField)) {
+ contentMap.remove(documentIdField);
+ }
+ if (!retainIndexField &&
StringUtils.isNotBlank(documentIndexField)) {
+
contentMap.remove(documentIndexField);
+ }
+ applyTimestamp(contentMap,
documentTimestampField, retainTimestampField);
+ rawJsonBytes =
suppressingWriter.writeValueAsBytes(contentMap);
} else {
final JsonNode node =
mapper.readTree(parser);
docBytes = Math.max(1,
parser.currentLocation().getCharOffset() - startOffset);
- rawJsonBytes =
mapper.writeValueAsBytes(node);
id = extractId(node, documentIdField,
flowFileIdAttribute);
+ docIndex = extractIndex(node,
documentIndexField, index);
+ // Field stripping and @timestamp
injection only apply to JSON objects.
+ // Non-object elements (scalars,
arrays, null) are passed through unchanged so
+ // Elasticsearch can reject them
per-document rather than failing the whole FlowFile.
+ if (node.isObject()) {
+ final ObjectNode objectNode =
(ObjectNode) node;
+ if (!retainIdentifierField &&
StringUtils.isNotBlank(documentIdField)) {
+
objectNode.remove(documentIdField);
+ }
+ if (!retainIndexField &&
StringUtils.isNotBlank(documentIndexField)) {
+
objectNode.remove(documentIndexField);
+ }
+ applyTimestamp(objectNode,
documentTimestampField, retainTimestampField);
+ }
+ rawJsonBytes =
mapper.writeValueAsBytes(node);
}
opRequest = IndexOperationRequest.builder()
- .index(index)
+ .index(docIndex)
.type(type)
.id(id)
.rawJsonBytes(rawJsonBytes)
@@ -558,8 +693,16 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
final Map<String, Object> contentMap =
mapReader.readValue(parser);
final long docBytes = Math.max(1,
parser.currentLocation().getCharOffset() - startOffset);
final String id = resolveId(contentMap,
documentIdField, flowFileIdAttribute);
+ final String docIndex =
resolveIndex(contentMap, documentIndexField, index);
+ if (!retainIdentifierField &&
StringUtils.isNotBlank(documentIdField)) {
+ contentMap.remove(documentIdField);
+ }
+ if (!retainIndexField &&
StringUtils.isNotBlank(documentIndexField)) {
+ contentMap.remove(documentIndexField);
+ }
+ applyTimestamp(contentMap,
documentTimestampField, retainTimestampField);
opRequest = IndexOperationRequest.builder()
- .index(index)
+ .index(docIndex)
.type(type)
.id(id)
.fields(contentMap)
@@ -591,8 +734,13 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
try (final InputStream in = session.read(flowFile)) {
final Map<String, Object> contentMap =
mapReader.readValue(in);
final String id =
StringUtils.isNotBlank(flowFileIdAttribute) ? flowFileIdAttribute : null;
+ final String docIndex = resolveIndex(contentMap,
documentIndexField, index);
+ if (!retainIndexField &&
StringUtils.isNotBlank(documentIndexField)) {
+ contentMap.remove(documentIndexField);
+ }
+ applyTimestamp(contentMap, documentTimestampField,
retainTimestampField);
final IndexOperationRequest opRequest =
IndexOperationRequest.builder()
- .index(index)
+ .index(docIndex)
.type(type)
.id(id)
.fields(contentMap)
@@ -881,24 +1029,85 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
}
/**
- * Extracts the document ID from a raw JSON string using a streaming
parser.
- * Stops as soon as the target field is found, avoiding a full tree parse.
- * Used for Index/Create operations to avoid the Map allocation overhead.
+ * Copies the value of {@code timestampField} to {@code @timestamp} in the
Map.
+ * If {@code retain} is false, the source field is removed after copying.
+ * Does nothing when {@code timestampField} is blank or not present in the
document.
*/
- private String extractId(final String rawJson, final String idAttribute,
final String flowFileIdAttribute) throws IOException {
- if (StringUtils.isBlank(idAttribute)) {
- return StringUtils.isNotBlank(flowFileIdAttribute) ?
flowFileIdAttribute : null;
+ private void applyTimestamp(final Map<String, Object> contentMap, final
String timestampField, final boolean retain) {
+ if (StringUtils.isBlank(timestampField)) {
+ return;
+ }
+ final Object value = contentMap.get(timestampField);
+ if (value != null) {
+ if (!retain) {
+ contentMap.remove(timestampField);
+ }
+ contentMap.put("@timestamp", value);
}
+ }
+
+ /**
+ * Copies the value of {@code timestampField} to {@code @timestamp} in the
ObjectNode.
+ * If {@code retain} is false, the source field is removed after copying.
+ * Does nothing when {@code timestampField} is blank or not present in the
document.
+ */
+ private void applyTimestamp(final ObjectNode node, final String
timestampField, final boolean retain) {
+ if (StringUtils.isBlank(timestampField)) {
+ return;
+ }
+ final JsonNode value = node.get(timestampField);
+ if (value != null && !value.isNull()) {
+ if (!retain) {
+ node.remove(timestampField);
+ }
+ node.set("@timestamp", value);
+ }
+ }
+
+ /**
+ * Extracts both document ID and index name from a raw JSON string in a
single streaming pass.
+ * Stops as soon as both fields have been found to avoid scanning the rest
of the document.
+ * Returns a two-element array: {@code [id, docIndex]}.
+ * Used for NDJSON Index/Create when neither suppression nor field-removal
requires a Map parse.
+ */
+ private String[] extractIdAndIndex(
+ final String rawJson,
+ final String idField, final String flowFileIdAttribute,
+ final String indexField, final String fallbackIndex) throws
IOException {
+ final boolean needId = StringUtils.isNotBlank(idField);
+ final boolean needIndex = StringUtils.isNotBlank(indexField);
+
+ String id = needId ? null :
(StringUtils.isNotBlank(flowFileIdAttribute) ? flowFileIdAttribute : null);
+ String docIndex = fallbackIndex;
+
+ if (!needId && !needIndex) {
+ return new String[]{id, docIndex};
+ }
+
+ boolean foundId = !needId;
+ boolean foundIndex = !needIndex;
+
try (final JsonParser p = mapper.getFactory().createParser(rawJson)) {
while (p.nextToken() != null) {
- if (idAttribute.equals(p.currentName()) && p.nextToken() !=
null && !p.currentToken().isStructStart()) {
+ if (foundId && foundIndex) {
+ break;
+ }
+ if (!foundId && idField.equals(p.currentName()) &&
p.nextToken() != null && isScalarValue(p.currentToken())) {
+ final String value = p.getText();
+ id = StringUtils.isNotBlank(value) ? value :
(StringUtils.isNotBlank(flowFileIdAttribute) ? flowFileIdAttribute : null);
+ foundId = true;
+ } else if (!foundIndex && indexField.equals(p.currentName())
&& p.nextToken() != null && isScalarValue(p.currentToken())) {
final String value = p.getText();
- return StringUtils.isNotBlank(value) ? value
- : (StringUtils.isNotBlank(flowFileIdAttribute) ?
flowFileIdAttribute : null);
+ docIndex = StringUtils.isNotBlank(value) ? value :
fallbackIndex;
+ foundIndex = true;
}
}
}
- return StringUtils.isNotBlank(flowFileIdAttribute) ?
flowFileIdAttribute : null;
+
+ if (!foundId) {
+ id = StringUtils.isNotBlank(flowFileIdAttribute) ?
flowFileIdAttribute : null;
+ }
+ return new String[]{id, docIndex};
}
/**
@@ -909,9 +1118,9 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
if (StringUtils.isBlank(idAttribute)) {
return StringUtils.isNotBlank(flowFileIdAttribute) ?
flowFileIdAttribute : null;
}
- final JsonNode idNode = node.get(idAttribute);
- if (idNode != null && !idNode.isNull()) {
- return idNode.asText();
+ final String value = fieldNodeToString(node.get(idAttribute));
+ if (StringUtils.isNotBlank(value)) {
+ return value;
}
return StringUtils.isNotBlank(flowFileIdAttribute) ?
flowFileIdAttribute : null;
}
@@ -924,13 +1133,77 @@ public class PutElasticsearchJson extends
AbstractPutElasticsearch {
if (StringUtils.isBlank(idAttribute)) {
return null;
}
- final Object idObj = contentMap.get(idAttribute);
- if (idObj != null) {
- return idObj.toString();
+ final String value = fieldValueToString(contentMap.get(idAttribute));
+ if (StringUtils.isNotBlank(value)) {
+ return value;
}
return StringUtils.isNotBlank(flowFileIdAttribute) ?
flowFileIdAttribute : null;
}
+ /**
+ * Extracts the index name from a pre-parsed {@link JsonNode}.
+ * Used for JSON Array Index/Create operations where the node is already
available.
+ * Falls back to {@code fallbackIndex} when the field is absent or blank.
+ */
+ private String extractIndex(final JsonNode node, final String indexField,
final String fallbackIndex) {
+ if (StringUtils.isBlank(indexField)) {
+ return fallbackIndex;
+ }
+ final String value = fieldNodeToString(node.get(indexField));
+ return StringUtils.isNotBlank(value) ? value : fallbackIndex;
+ }
+
+ /**
+ * Resolves the index name from an already-parsed content Map.
+ * Used for Update/Delete/Upsert operations and suppression-enabled
Index/Create paths
+ * where the Map is already available. Falls back to {@code fallbackIndex}
when the
+ * field is absent or blank.
+ */
+ private String resolveIndex(final Map<String, Object> contentMap, final
String indexField, final String fallbackIndex) {
+ if (StringUtils.isBlank(indexField)) {
+ return fallbackIndex;
+ }
+ final String value = fieldValueToString(contentMap.get(indexField));
+ return StringUtils.isNotBlank(value) ? value : fallbackIndex;
+ }
+
+ /**
+ * Converts a document field value taken from a parsed content Map into
its string form for use
+ * as a document ID or index name. Returns {@code null} for absent values
and for container
+ * values (JSON objects/arrays, deserialized as {@link Map}/{@link
Collection}), which are not
+ * meaningful as an identifier or index and are treated the same as an
absent field. This mirrors
+ * {@link #fieldNodeToString(JsonNode)} and the streaming {@link
#extractIdAndIndex} path so all
+ * three produce identical results for the same document.
+ */
+ private static String fieldValueToString(final Object value) {
+ if (value == null || value instanceof Map || value instanceof
Collection) {
+ return null;
+ }
+ return value.toString();
+ }
+
+ /**
+ * Converts a document field {@link JsonNode} into its string form for use
as a document ID or
+ * index name. Returns {@code null} for missing, null, and non-scalar
(object/array) nodes,
+ * consistent with {@link #fieldValueToString(Object)} and the streaming
extraction path.
+ */
+ private static String fieldNodeToString(final JsonNode node) {
+ if (node == null || node.isNull() || !node.isValueNode()) {
+ return null;
+ }
+ return node.asText();
+ }
+
+ /**
+ * Whether the streaming-parser token represents a usable scalar value
(text, number, boolean)
+ * rather than a container start ({@code {}/[]}) or a JSON null. Keeps the
streaming ID/index
+ * extraction consistent with the Map and JsonNode paths, which treat
objects/arrays/nulls as
+ * absent fields.
+ */
+ private static boolean isScalarValue(final JsonToken token) {
+ return token != null && !token.isStructStart() && token !=
JsonToken.VALUE_NULL;
+ }
+
/**
* Reads a processor property as a JSON Object string and deserializes it
into a Map.
* Returns an empty Map when the property is blank. Throws {@link
ProcessException} if the
diff --git
a/nifi-extension-bundles/nifi-elasticsearch-bundle/nifi-elasticsearch-restapi-processors/src/test/java/org/apache/nifi/processors/elasticsearch/PutElasticsearchJsonTest.java
b/nifi-extension-bundles/nifi-elasticsearch-bundle/nifi-elasticsearch-restapi-processors/src/test/java/org/apache/nifi/processors/elasticsearch/PutElasticsearchJsonTest.java
index f023920120e..b93fe826720 100644
---
a/nifi-extension-bundles/nifi-elasticsearch-bundle/nifi-elasticsearch-restapi-processors/src/test/java/org/apache/nifi/processors/elasticsearch/PutElasticsearchJsonTest.java
+++
b/nifi-extension-bundles/nifi-elasticsearch-bundle/nifi-elasticsearch-restapi-processors/src/test/java/org/apache/nifi/processors/elasticsearch/PutElasticsearchJsonTest.java
@@ -28,9 +28,11 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
@@ -881,4 +883,231 @@ public class PutElasticsearchJsonTest extends
AbstractPutElasticsearchTest {
runner.assertTransferCount(PutElasticsearchJson.REL_BULK_REQUEST, 0);
}
+
+ //
-------------------------------------------------------------------------
+ // Index Field / Timestamp Field / Retain toggles (NIFI-15985)
+ //
-------------------------------------------------------------------------
+
+ /** Registers a consumer that collects every operation sent to the _bulk
API into the returned list. */
+ private List<IndexOperationRequest> captureOperations() {
+ final List<IndexOperationRequest> captured = new ArrayList<>();
+ clientService.setEvalConsumer((final List<IndexOperationRequest>
items) -> captured.addAll(items));
+ return captured;
+ }
+
+ /** Returns the document body of an operation as a String, from raw JSON
bytes or the fields Map. */
+ private static String docContent(final IndexOperationRequest item) {
+ if (item.getRawJsonBytes() != null) {
+ return new String(item.getRawJsonBytes(), StandardCharsets.UTF_8);
+ }
+ return item.getFields() == null ? "" : item.getFields().toString();
+ }
+
+ @Test
+ void testIndexFieldNdjsonExtractionRetainedByDefault() {
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.NDJSON.getValue());
+ runner.setProperty(PutElasticsearchJson.INDEX_FIELD, "target_index");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+ runner.enqueue("{\"target_index\":\"docs-2026\",\"msg\":\"hello\"}\n");
+ runner.run();
+
+ runner.assertTransferCount(AbstractPutElasticsearch.REL_SUCCESSFUL, 1);
+ assertEquals(1, ops.size());
+ assertEquals("docs-2026", ops.getFirst().getIndex());
+ // Retain Index Field defaults to true → the field stays in the
document body
+ assertTrue(docContent(ops.getFirst()).contains("target_index"));
+ }
+
+ @Test
+ void testIndexFieldNdjsonRemovedWhenRetainFalse() {
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.NDJSON.getValue());
+ runner.setProperty(PutElasticsearchJson.INDEX_FIELD, "target_index");
+ runner.setProperty(PutElasticsearchJson.RETAIN_INDEX_FIELD, "false");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+ runner.enqueue("{\"target_index\":\"docs-2026\",\"msg\":\"hello\"}\n");
+ runner.run();
+
+ assertEquals("docs-2026", ops.getFirst().getIndex());
+ final String content = docContent(ops.getFirst());
+ assertFalse(content.contains("target_index"), "Index Field should be
stripped when Retain Index Field=false");
+ assertTrue(content.contains("hello"), "Other fields should remain");
+ }
+
+ @Test
+ void testIndexFieldJsonArrayExtractionAndStrip() {
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.JSON_ARRAY.getValue());
+ runner.setProperty(PutElasticsearchJson.INDEX_FIELD, "target_index");
+ runner.setProperty(PutElasticsearchJson.RETAIN_INDEX_FIELD, "false");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+
runner.enqueue("[{\"target_index\":\"docs-a\",\"msg\":\"x\"},{\"target_index\":\"docs-b\",\"msg\":\"y\"}]");
+ runner.run();
+
+ assertEquals(2, ops.size());
+ assertEquals("docs-a", ops.get(0).getIndex());
+ assertEquals("docs-b", ops.get(1).getIndex());
+ assertFalse(docContent(ops.get(0)).contains("target_index"));
+ }
+
+ @Test
+ void testIndexFieldSingleJsonExtractionRetainedByDefault() {
+ // SINGLE_JSON is the default Input Content Format
+ runner.setProperty(PutElasticsearchJson.INDEX_FIELD, "target_index");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+ runner.enqueue("{\"target_index\":\"docs-single\",\"msg\":\"x\"}");
+ runner.run();
+
+ assertEquals(1, ops.size());
+ assertEquals("docs-single", ops.getFirst().getIndex());
+ assertTrue(ops.getFirst().getFields().containsKey("target_index"),
"field retained by default");
+ }
+
+ @Test
+ void testIndexFieldFallsBackToIndexPropertyWhenAbsent() {
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.NDJSON.getValue());
+ runner.setProperty(PutElasticsearchJson.INDEX_FIELD, "target_index");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+ runner.enqueue("{\"msg\":\"no index field here\"}\n");
+ runner.run();
+
+ assertEquals("test_index", ops.getFirst().getIndex(), "Should fall
back to the Index property when the field is absent");
+ }
+
+ @Test
+ void testTimestampFieldNdjsonCopiesToAtTimestampAndStrips() {
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.NDJSON.getValue());
+ runner.setProperty(PutElasticsearchJson.TIMESTAMP_FIELD, "event_time");
+ runner.setProperty(PutElasticsearchJson.RETAIN_TIMESTAMP_FIELD,
"false");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+
runner.enqueue("{\"event_time\":\"2026-01-02T03:04:05Z\",\"msg\":\"x\"}\n");
+ runner.run();
+
+ final String content = docContent(ops.getFirst());
+ assertTrue(content.contains("@timestamp"), "@timestamp should be
added");
+ assertTrue(content.contains("2026-01-02T03:04:05Z"));
+ assertFalse(content.contains("event_time"), "source timestamp field
removed when retain=false");
+ }
+
+ @Test
+ void testTimestampFieldJsonArrayStrip() {
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.JSON_ARRAY.getValue());
+ runner.setProperty(PutElasticsearchJson.TIMESTAMP_FIELD, "event_time");
+ runner.setProperty(PutElasticsearchJson.RETAIN_TIMESTAMP_FIELD,
"false");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+
runner.enqueue("[{\"event_time\":\"2026-05-31T00:00:00Z\",\"msg\":\"x\"}]");
+ runner.run();
+
+ final String content = docContent(ops.getFirst());
+ assertTrue(content.contains("@timestamp"));
+ assertFalse(content.contains("event_time"));
+ }
+
+ @Test
+ void testTimestampFieldSingleJsonRetainedByDefault() {
+ runner.setProperty(PutElasticsearchJson.TIMESTAMP_FIELD, "event_time");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+
runner.enqueue("{\"event_time\":\"2026-01-02T03:04:05Z\",\"msg\":\"x\"}");
+ runner.run();
+
+ final Map<String, Object> fields = ops.getFirst().getFields();
+ assertEquals("2026-01-02T03:04:05Z", fields.get("@timestamp"));
+ assertTrue(fields.containsKey("event_time"), "source field retained by
default");
+ }
+
+ @Test
+ void testIdentifierFieldRetainedByDefaultNdjson() {
+ // Backward compatibility: before this feature the Identifier Field
was always kept in the document
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.NDJSON.getValue());
+ runner.setProperty(PutElasticsearchJson.IDENTIFIER_FIELD, "id");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+ runner.enqueue("{\"id\":\"abc\",\"msg\":\"x\"}\n");
+ runner.run();
+
+ assertEquals("abc", ops.getFirst().getId());
+ assertTrue(docContent(ops.getFirst()).contains("\"id\""), "Identifier
Field kept by default for backward compatibility");
+ }
+
+ @Test
+ void testIdentifierFieldRemovedWhenRetainFalseNdjson() {
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.NDJSON.getValue());
+ runner.setProperty(PutElasticsearchJson.IDENTIFIER_FIELD, "id");
+ runner.setProperty(PutElasticsearchJson.RETAIN_IDENTIFIER_FIELD,
"false");
+ runner.assertValid();
+
+ final List<IndexOperationRequest> ops = captureOperations();
+ runner.enqueue("{\"id\":\"abc\",\"msg\":\"x\"}\n");
+ runner.run();
+
+ assertEquals("abc", ops.getFirst().getId());
+ final String content = docContent(ops.getFirst());
+ assertFalse(content.contains("\"id\""), "identifier field removed when
retain=false");
+ assertTrue(content.contains("msg"));
+ }
+
+ @Test
+ void testIndexFieldNumericValueConsistentAcrossFormats() {
+ // The Map (Single JSON), JsonNode (JSON Array), and streaming
(NDJSON) paths must all
+ // resolve a non-string index value to the same string.
+ runner.setProperty(PutElasticsearchJson.INDEX_FIELD, "idx");
+ List<IndexOperationRequest> ops = captureOperations();
+ runner.enqueue("{\"idx\":12345,\"msg\":\"x\"}");
+ runner.run();
+ assertEquals("12345", ops.getFirst().getIndex(), "Single JSON Map
path");
+ runner.clearTransferState();
+
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.JSON_ARRAY.getValue());
+ ops = captureOperations();
+ runner.enqueue("[{\"idx\":12345,\"msg\":\"x\"}]");
+ runner.run();
+ assertEquals("12345", ops.getFirst().getIndex(), "JSON Array JsonNode
path");
+ runner.clearTransferState();
+
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.NDJSON.getValue());
+ ops = captureOperations();
+ runner.enqueue("{\"idx\":12345,\"msg\":\"x\"}\n");
+ runner.run();
+ assertEquals("12345", ops.getFirst().getIndex(), "NDJSON streaming
path");
+ }
+
+ @Test
+ void testIndexFieldNullValueFallsBackConsistently() {
+ // A JSON null index value must fall back to the Index property on
every path —
+ // in particular the NDJSON streaming path must not emit the literal
string "null".
+ runner.setProperty(PutElasticsearchJson.INDEX_FIELD, "idx");
+ List<IndexOperationRequest> ops = captureOperations();
+ runner.enqueue("{\"idx\":null,\"msg\":\"x\"}");
+ runner.run();
+ assertEquals("test_index", ops.getFirst().getIndex(), "Single JSON
null -> fallback");
+ runner.clearTransferState();
+
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.JSON_ARRAY.getValue());
+ ops = captureOperations();
+ runner.enqueue("[{\"idx\":null,\"msg\":\"x\"}]");
+ runner.run();
+ assertEquals("test_index", ops.getFirst().getIndex(), "JSON Array null
-> fallback");
+ runner.clearTransferState();
+
+ runner.setProperty(PutElasticsearchJson.INPUT_FORMAT,
InputFormat.NDJSON.getValue());
+ ops = captureOperations();
+ runner.enqueue("{\"idx\":null,\"msg\":\"x\"}\n");
+ runner.run();
+ assertEquals("test_index", ops.getFirst().getIndex(), "NDJSON null ->
fallback (not literal \"null\")");
+ }
}