This is an automated email from the ASF dual-hosted git repository.

exceptionfactory 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 be995d269fc NIFI-15856 Added Serialized JSON Input Handling to 
JsonRecordSetWriter (#11158)
be995d269fc is described below

commit be995d269fce9ea63cf39cdc7ecb6e58fe0a1fca
Author: Pierre Villard <[email protected]>
AuthorDate: Thu Apr 23 15:53:39 2026 +0200

    NIFI-15856 Added Serialized JSON Input Handling to JsonRecordSetWriter 
(#11158)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../java/org/apache/nifi/json/WriteJsonResult.java |  33 +++++-
 .../org/apache/nifi/json/JsonRecordSetWriter.java  |  25 +++-
 .../org/apache/nifi/json/TestWriteJsonResult.java  | 127 +++++++++++++++++++++
 3 files changed, 183 insertions(+), 2 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-extension-utils/nifi-record-utils/nifi-json-record-utils/src/main/java/org/apache/nifi/json/WriteJsonResult.java
 
b/nifi-extension-bundles/nifi-extension-utils/nifi-record-utils/nifi-json-record-utils/src/main/java/org/apache/nifi/json/WriteJsonResult.java
index e3f10a02f71..3aa27abb8d8 100644
--- 
a/nifi-extension-bundles/nifi-extension-utils/nifi-record-utils/nifi-json-record-utils/src/main/java/org/apache/nifi/json/WriteJsonResult.java
+++ 
b/nifi-extension-bundles/nifi-extension-utils/nifi-record-utils/nifi-json-record-utils/src/main/java/org/apache/nifi/json/WriteJsonResult.java
@@ -67,17 +67,24 @@ public class WriteJsonResult extends 
AbstractRecordSetWriter implements RecordSe
     private final String mimeType;
     private final boolean prettyPrint;
     private final boolean allowScientificNotation;
+    private final boolean serializedInputHandlingEnabled;
 
     private static final ObjectMapper objectMapper = new ObjectMapper();
 
     public WriteJsonResult(final ComponentLog logger, final RecordSchema 
recordSchema, final SchemaAccessWriter schemaAccess, final OutputStream out, 
final boolean prettyPrint,
             final NullSuppression nullSuppression, final OutputGrouping 
outputGrouping, final String dateFormat, final String timeFormat, final String 
timestampFormat) throws IOException {
-        this(logger, recordSchema, schemaAccess, out, prettyPrint, 
nullSuppression, outputGrouping, dateFormat, timeFormat, timestampFormat, 
"application/json", false);
+        this(logger, recordSchema, schemaAccess, out, prettyPrint, 
nullSuppression, outputGrouping, dateFormat, timeFormat, timestampFormat, 
"application/json", false, true);
     }
 
     public WriteJsonResult(final ComponentLog logger, final RecordSchema 
recordSchema, final SchemaAccessWriter schemaAccess, final OutputStream out, 
final boolean prettyPrint,
         final NullSuppression nullSuppression, final OutputGrouping 
outputGrouping, final String dateFormat, final String timeFormat, final String 
timestampFormat,
         final String mimeType, final boolean allowScientificNotation) throws 
IOException {
+        this(logger, recordSchema, schemaAccess, out, prettyPrint, 
nullSuppression, outputGrouping, dateFormat, timeFormat, timestampFormat, 
mimeType, allowScientificNotation, true);
+    }
+
+    public WriteJsonResult(final ComponentLog logger, final RecordSchema 
recordSchema, final SchemaAccessWriter schemaAccess, final OutputStream out, 
final boolean prettyPrint,
+        final NullSuppression nullSuppression, final OutputGrouping 
outputGrouping, final String dateFormat, final String timeFormat, final String 
timestampFormat,
+        final String mimeType, final boolean allowScientificNotation, final 
boolean serializedInputHandlingEnabled) throws IOException {
 
         super(out);
         this.logger = logger;
@@ -87,6 +94,7 @@ public class WriteJsonResult extends AbstractRecordSetWriter 
implements RecordSe
         this.outputGrouping = outputGrouping;
         this.mimeType = mimeType;
         this.allowScientificNotation = allowScientificNotation;
+        this.serializedInputHandlingEnabled = serializedInputHandlingEnabled;
 
         this.dateFormat = dateFormat;
         this.timeFormat = timeFormat;
@@ -170,7 +178,30 @@ public class WriteJsonResult extends 
AbstractRecordSetWriter implements RecordSe
         return WriteResult.of(incrementRecordCount(), attributes);
     }
 
+    /**
+     * Determines whether the record's original serialized JSON bytes can be 
emitted verbatim as a throughput optimization,
+     * bypassing field-by-field re-serialization. All of the following 
conditions must hold for the fast path to apply:
+     * <ol>
+     *   <li>The caller enabled the optimization (the {@code 
serializedInputHandlingEnabled} constructor argument is {@code true}).</li>
+     *   <li>The record carries a {@link SerializedForm} produced by the 
upstream reader. Today this is only set by
+     *       {@code JsonTreeRowRecordReader}; readers such as {@code 
JsonPathRowRecordReader} that transform the input
+     *       cannot reuse their input bytes and therefore never trigger the 
fast path.</li>
+     *   <li>The serialized form's MIME type matches the writer's configured 
MIME type and the reader's record schema is
+     *       equal to the writer's record schema (no projection, no field 
renames, no type coercion).</li>
+     *   <li>The cached bytes are a {@code String}.</li>
+     *   <li>The cached bytes' pretty-print state matches the writer's {@code 
prettyPrint} setting.</li>
+     *   <li>If scientific notation is disabled on the writer, the cached 
bytes do not contain scientific notation.</li>
+     * </ol>
+     * When the fast path is taken, the writer emits the cached bytes via 
{@link JsonGenerator#writeRawValue(String)} and
+     * therefore does <em>not</em> apply the writer's Timestamp Format, Date 
Format, Time Format, or Suppress Null Values
+     * settings to that record. Operators that need those writer-side 
properties to be honored uniformly must construct
+     * this writer with {@code serializedInputHandlingEnabled = false}.
+     */
     private boolean isUseSerializeForm(final Record record, final RecordSchema 
writeSchema) {
+        if (!serializedInputHandlingEnabled) {
+            return false;
+        }
+
         final Optional<SerializedForm> serializedForm = 
record.getSerializedForm();
         if (serializedForm.isEmpty()) {
             return false;
diff --git 
a/nifi-extension-bundles/nifi-standard-services/nifi-record-serialization-services-bundle/nifi-record-serialization-services/src/main/java/org/apache/nifi/json/JsonRecordSetWriter.java
 
b/nifi-extension-bundles/nifi-standard-services/nifi-record-serialization-services-bundle/nifi-record-serialization-services/src/main/java/org/apache/nifi/json/JsonRecordSetWriter.java
index 2d5720dd45c..d3062384e45 100644
--- 
a/nifi-extension-bundles/nifi-standard-services/nifi-record-serialization-services-bundle/nifi-record-serialization-services/src/main/java/org/apache/nifi/json/JsonRecordSetWriter.java
+++ 
b/nifi-extension-bundles/nifi-standard-services/nifi-record-serialization-services-bundle/nifi-record-serialization-services/src/main/java/org/apache/nifi/json/JsonRecordSetWriter.java
@@ -67,6 +67,15 @@ public class JsonRecordSetWriter extends 
DateTimeTextRecordSetWriter implements
     public static final AllowableValue OUTPUT_ONELINE = new 
AllowableValue("output-oneline", "One Line Per Object",
             "Output records with one JSON object per line, delimited by a 
newline character");
 
+    public static final AllowableValue HANDLING_ENABLED = new 
AllowableValue("ENABLED", "Enabled",
+            """
+                    The writer may emit the input reader's original JSON bytes 
verbatim when it can do so safely, as a throughput optimization. \
+                    Timestamp Format, Date Format, Time Format, and Suppress 
Null Values may not be applied to those records.""");
+    public static final AllowableValue HANDLING_DISABLED = new 
AllowableValue("DISABLED", "Disabled",
+            """
+                    The writer re-serializes every record from typed field 
values, so Timestamp Format, Date Format, Time Format, and Suppress Null \
+                    Values are honored uniformly.""");
+
     public static final String COMPRESSION_FORMAT_GZIP = "gzip";
     public static final String COMPRESSION_FORMAT_BZIP2 = "bzip2";
     public static final String COMPRESSION_FORMAT_XZ_LZMA2 = "xz-lzma2";
@@ -123,6 +132,17 @@ public class JsonRecordSetWriter extends 
DateTimeTextRecordSetWriter implements
             .allowableValues("0", "1", "2", "3", "4", "5", "6", "7", "8", "9")
             .dependsOn(COMPRESSION_FORMAT, COMPRESSION_FORMAT_GZIP)
             .build();
+    public static final PropertyDescriptor SERIALIZED_JSON_INPUT_HANDLING = 
new PropertyDescriptor.Builder()
+            .name("Serialized JSON Input Handling")
+            .description("""
+                    When enabled, the writer may emit the input reader's 
original JSON bytes verbatim when it can do so safely, as a \
+                    throughput optimization. In that case, the Timestamp 
Format, Date Format, Time Format, and Suppress Null Values properties may not 
be \
+                    applied to those records. When disabled, the writer 
re-serializes every record so that these properties are honored uniformly.""")
+            .expressionLanguageSupported(ExpressionLanguageScope.NONE)
+            .allowableValues(HANDLING_ENABLED, HANDLING_DISABLED)
+            .defaultValue(HANDLING_ENABLED.getValue())
+            .required(true)
+            .build();
 
     private volatile boolean prettyPrint;
     private volatile boolean allowScientificNotation;
@@ -130,6 +150,7 @@ public class JsonRecordSetWriter extends 
DateTimeTextRecordSetWriter implements
     private volatile OutputGrouping outputGrouping;
     private volatile String compressionFormat;
     private volatile int compressionLevel;
+    private volatile boolean serializedInputHandlingEnabled;
 
     @Override
     protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
@@ -140,6 +161,7 @@ public class JsonRecordSetWriter extends 
DateTimeTextRecordSetWriter implements
         properties.add(OUTPUT_GROUPING);
         properties.add(COMPRESSION_FORMAT);
         properties.add(COMPRESSION_LEVEL);
+        properties.add(SERIALIZED_JSON_INPUT_HANDLING);
         return properties;
     }
 
@@ -197,6 +219,7 @@ public class JsonRecordSetWriter extends 
DateTimeTextRecordSetWriter implements
 
         this.compressionFormat = 
context.getProperty(COMPRESSION_FORMAT).getValue();
         this.compressionLevel = 
context.getProperty(COMPRESSION_LEVEL).asInteger();
+        this.serializedInputHandlingEnabled = 
HANDLING_ENABLED.getValue().equals(context.getProperty(SERIALIZED_JSON_INPUT_HANDLING).getValue());
     }
 
     @Override
@@ -241,7 +264,7 @@ public class JsonRecordSetWriter extends 
DateTimeTextRecordSetWriter implements
         }
 
         return new WriteJsonResult(logger, schema, 
getSchemaAccessWriter(schema, variables), compressionOut, prettyPrint, 
nullSuppression, outputGrouping,
-                getDateFormat().orElse(null), getTimeFormat().orElse(null), 
getTimestampFormat().orElse(null), mimeType, allowScientificNotation);
+                getDateFormat().orElse(null), getTimeFormat().orElse(null), 
getTimestampFormat().orElse(null), mimeType, allowScientificNotation, 
serializedInputHandlingEnabled);
     }
 
 }
diff --git 
a/nifi-extension-bundles/nifi-standard-services/nifi-record-serialization-services-bundle/nifi-record-serialization-services/src/test/java/org/apache/nifi/json/TestWriteJsonResult.java
 
b/nifi-extension-bundles/nifi-standard-services/nifi-record-serialization-services-bundle/nifi-record-serialization-services/src/test/java/org/apache/nifi/json/TestWriteJsonResult.java
index 575286f2dea..35263a8dd70 100644
--- 
a/nifi-extension-bundles/nifi-standard-services/nifi-record-serialization-services-bundle/nifi-record-serialization-services/src/test/java/org/apache/nifi/json/TestWriteJsonResult.java
+++ 
b/nifi-extension-bundles/nifi-standard-services/nifi-record-serialization-services-bundle/nifi-record-serialization-services/src/test/java/org/apache/nifi/json/TestWriteJsonResult.java
@@ -52,6 +52,7 @@ import java.util.List;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class TestWriteJsonResult {
@@ -631,4 +632,130 @@ class TestWriteJsonResult {
         final String output = new String(data, StandardCharsets.UTF_8);
         assertEquals(json, output);
     }
+
+    @Test
+    void testReuseInputSerializationDefaultTrueUsesFastPath() throws 
IOException {
+        final List<RecordField> fields = new ArrayList<>();
+        fields.add(new RecordField("name", 
RecordFieldType.STRING.getDataType()));
+        fields.add(new RecordField("age", RecordFieldType.INT.getDataType()));
+        final RecordSchema schema = new SimpleRecordSchema(fields);
+
+        final Map<String, Object> values = new HashMap<>();
+        values.put("name", "John Doe");
+        values.put("age", 42);
+
+        final String rawForm = "{\"name\":\"John 
Doe\",\"age\":42,\"ignoredExtra\":\"preserved\"}";
+        final SerializedForm serializedForm = SerializedForm.of(rawForm, 
"application/json");
+        final Record record = new MapRecord(schema, values, serializedForm);
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (final WriteJsonResult writer = new 
WriteJsonResult(Mockito.mock(ComponentLog.class), schema, new 
SchemaNameAsAttribute(), baos, false,
+                NullSuppression.NEVER_SUPPRESS, OutputGrouping.OUTPUT_ARRAY, 
RecordFieldType.DATE.getDefaultFormat(),
+                RecordFieldType.TIME.getDefaultFormat(), 
RecordFieldType.TIMESTAMP.getDefaultFormat())) {
+            writer.write(RecordSet.of(schema, record));
+        }
+
+        final String output = baos.toString(StandardCharsets.UTF_8);
+        assertEquals("[{\"name\":\"John 
Doe\",\"age\":42,\"ignoredExtra\":\"preserved\"}]", output);
+    }
+
+    @Test
+    void testReuseInputSerializationFalseForcesReserialization() throws 
IOException {
+        final List<RecordField> fields = new ArrayList<>();
+        fields.add(new RecordField("name", 
RecordFieldType.STRING.getDataType()));
+        fields.add(new RecordField("age", RecordFieldType.INT.getDataType()));
+        final RecordSchema schema = new SimpleRecordSchema(fields);
+
+        final Map<String, Object> values = new HashMap<>();
+        values.put("name", "John Doe");
+        values.put("age", 42);
+
+        final String rawForm = "{\"name\":\"John 
Doe\",\"age\":42,\"ignoredExtra\":\"preserved\"}";
+        final SerializedForm serializedForm = SerializedForm.of(rawForm, 
"application/json");
+        final Record record = new MapRecord(schema, values, serializedForm);
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (final WriteJsonResult writer = new 
WriteJsonResult(Mockito.mock(ComponentLog.class), schema, new 
SchemaNameAsAttribute(), baos, false,
+                NullSuppression.NEVER_SUPPRESS, OutputGrouping.OUTPUT_ARRAY, 
RecordFieldType.DATE.getDefaultFormat(),
+                RecordFieldType.TIME.getDefaultFormat(), 
RecordFieldType.TIMESTAMP.getDefaultFormat(),
+                "application/json", false, false)) {
+            writer.write(RecordSet.of(schema, record));
+        }
+
+        final String output = baos.toString(StandardCharsets.UTF_8);
+        assertFalse(output.contains("ignoredExtra"),
+                "When Serialized JSON Input Handling is DISABLED, the writer 
must re-serialize from typed values and ignore raw bytes");
+        assertEquals("[{\"name\":\"John Doe\",\"age\":42}]", output);
+    }
+
+    @Test
+    void testReuseInputSerializationFalseHonorsTimestampFormat() throws 
IOException {
+        final List<RecordField> fields = new ArrayList<>();
+        fields.add(new RecordField("event", 
RecordFieldType.TIMESTAMP.getDataType()));
+        final RecordSchema schema = new SimpleRecordSchema(fields);
+
+        final Timestamp eventTimestamp = Timestamp.valueOf("2025-03-20 
17:33:11.000");
+        final Map<String, Object> values = new HashMap<>();
+        values.put("event", eventTimestamp);
+
+        final String timestampValue = "2025-03-20T17:33:11.000+0000";
+        final String timestampFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX";
+        final String rawForm = "{\"event\":\"%s\"}".formatted(timestampValue);
+        final SerializedForm serializedForm = SerializedForm.of(rawForm, 
"application/json");
+        final Record record = new MapRecord(schema, values, serializedForm);
+
+        final ByteArrayOutputStream fastPathBaos = new ByteArrayOutputStream();
+        try (final WriteJsonResult writer = new 
WriteJsonResult(Mockito.mock(ComponentLog.class), schema, new 
SchemaNameAsAttribute(), fastPathBaos, false,
+                NullSuppression.NEVER_SUPPRESS, OutputGrouping.OUTPUT_ARRAY, 
RecordFieldType.DATE.getDefaultFormat(),
+                RecordFieldType.TIME.getDefaultFormat(), timestampFormat,
+                "application/json", false, true)) {
+            writer.write(RecordSet.of(schema, record));
+        }
+
+        
assertTrue(fastPathBaos.toString(StandardCharsets.UTF_8).contains(timestampValue),
+                "With Serialized JSON Input Handling ENABLED, raw '+0000' form 
is passed through even though Timestamp Format would normalize to 'Z'");
+
+        final ByteArrayOutputStream slowPathBaos = new ByteArrayOutputStream();
+        try (final WriteJsonResult writer = new 
WriteJsonResult(Mockito.mock(ComponentLog.class), schema, new 
SchemaNameAsAttribute(), slowPathBaos, false,
+                NullSuppression.NEVER_SUPPRESS, OutputGrouping.OUTPUT_ARRAY, 
RecordFieldType.DATE.getDefaultFormat(),
+                RecordFieldType.TIME.getDefaultFormat(), timestampFormat,
+                "application/json", false, false)) {
+            writer.write(RecordSet.of(schema, record));
+        }
+
+        final String slowPathOutput = 
slowPathBaos.toString(StandardCharsets.UTF_8);
+        assertFalse(slowPathOutput.contains("+0000"),
+                "With Serialized JSON Input Handling DISABLED, writer's 
Timestamp Format must be applied even when SerializedForm is present");
+        
assertTrue(slowPathOutput.contains("\"event\":\"2025-03-20T17:33:11.000"),
+                "Re-serialized timestamp should reflect the configured 
format");
+    }
+
+    @Test
+    void testReuseInputSerializationFalseHonorsSuppressNulls() throws 
IOException {
+        final List<RecordField> fields = new ArrayList<>();
+        fields.add(new RecordField("name", 
RecordFieldType.STRING.getDataType()));
+        fields.add(new RecordField("middleName", 
RecordFieldType.STRING.getDataType()));
+        final RecordSchema schema = new SimpleRecordSchema(fields);
+
+        final Map<String, Object> values = new HashMap<>();
+        values.put("name", "John Doe");
+        values.put("middleName", null);
+
+        final String rawForm = "{\"name\":\"John Doe\",\"middleName\":null}";
+        final SerializedForm serializedForm = SerializedForm.of(rawForm, 
"application/json");
+        final Record record = new MapRecord(schema, values, serializedForm);
+
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (final WriteJsonResult writer = new 
WriteJsonResult(Mockito.mock(ComponentLog.class), schema, new 
SchemaNameAsAttribute(), baos, false,
+                NullSuppression.ALWAYS_SUPPRESS, OutputGrouping.OUTPUT_ARRAY, 
RecordFieldType.DATE.getDefaultFormat(),
+                RecordFieldType.TIME.getDefaultFormat(), 
RecordFieldType.TIMESTAMP.getDefaultFormat(),
+                "application/json", false, false)) {
+            writer.write(RecordSet.of(schema, record));
+        }
+
+        final String output = baos.toString(StandardCharsets.UTF_8);
+        assertFalse(output.contains("middleName"),
+                "Suppress Null Values must be honored when Serialized JSON 
Input Handling is DISABLED, even though the input JSON contained the null 
field");
+        assertEquals("[{\"name\":\"John Doe\"}]", output);
+    }
 }

Reply via email to