This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 926aaff6a3 Fix #7990: JSON Input emits string "null" instead of Hop
null for JSON null values (#7993)
926aaff6a3 is described below
commit 926aaff6a3b2d88cc14fb16b917b13cb76188ca4
Author: kotwal-itpro <[email protected]>
AuthorDate: Tue Aug 18 02:07:28 2026 -0700
Fix #7990: JSON Input emits string "null" instead of Hop null for JSON null
values (#7993)
For an incoming JSON payload such as `{"test": null}` with the `test` output
field declared as type String, the JSON Input transform emitted the four
character literal string "null" rather than a proper Hop null. This diverged
from every other type coercion path in the transform, which treats missing
data as null.
Root cause is in RowOutputConverter.getStringValue(). JsonPath (configured
via JacksonJsonNodeJsonProvider) parses a JSON null into a Jackson NullNode,
which is a non-null Java reference whose toString() returns the string
"null".
The existing switch matched Map and TextNode explicitly but fell through to
`jo.toString()` for NullNode, embedding the literal "null" into the output
row. The downstream `targetMeta.convertDataFromString("null", ...)` then
happily produced the four-character string on the pipeline.
Detect Jackson NullNode and MissingNode explicitly via `JsonNode.isNull()`
and
`JsonNode.isMissingNode()` and return Java null from `getStringValue` in
both
cases so `convertDataFromString(null, ...)` produces a proper Hop null.
MissingNode is included for symmetry with the JsonPath-returned-missing case
even though the primary bug is triggered by NullNode.
`getStringValue` is now package-private and static so
`RowOutputConverterTest` can exercise it without spinning up a transform
context. No other behavior changes: Map, TextNode, plain String, and other
JsonNode subtypes (IntNode etc.) continue to flow through their existing
branches unchanged.
Adds RowOutputConverterTest with seven cases: the NullNode and MissingNode
regressions, the plain Java `null` regression, TextNode unquoted-text path,
plain-String pass-through, IntNode fall-through, and Map -> JSON
serialization.
Full `mvnw test` on plugins/transforms/json passes (117 tests, 0 failures).
Spotless check clean.
---
.../jsoninput/reader/RowOutputConverter.java | 37 ++++++----
.../jsoninput/reader/RowOutputConverterTest.java | 80 ++++++++++++++++++++++
2 files changed, 104 insertions(+), 13 deletions(-)
diff --git
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverter.java
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverter.java
index 475631627a..2fd9bd6e23 100644
---
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverter.java
+++
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverter.java
@@ -17,6 +17,7 @@
package org.apache.hop.pipeline.transforms.jsoninput.reader;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.TextNode;
import java.util.Map;
import net.minidev.json.JSONObject;
@@ -54,20 +55,30 @@ public class RowOutputConverter {
strValue, strConvertMeta, null, null, targetMeta.getTrimType());
}
- private String getStringValue(Object jo) {
- String nodevalue = null;
- if (jo != null) {
- if (jo instanceof Map) {
- Map<String, ?> asStrMap = (Map<String, ?>) jo;
- nodevalue = JSONObject.toJSONString(asStrMap);
- } else if (jo instanceof TextNode jot) {
- // this avoids returning string enclosed by "" if JsonNode
- nodevalue = jot.asText();
- } else {
- nodevalue = jo.toString();
- }
+ // Package-private and static so RowOutputConverterTest can exercise it
directly
+ // without instantiating the surrounding transform.
+ static String getStringValue(Object jo) {
+ if (jo == null) {
+ return null;
+ }
+ // A JSON `null` (e.g. `{"foo": null}`) is parsed by Jackson into a
NullNode -
+ // a non-null Java reference whose toString() returns the four-character
literal
+ // "null". Without this branch it flows to jo.toString() at the bottom and
the
+ // output field ends up containing the string "null" instead of a Hop null.
+ // MissingNode covers the JsonPath-returned-missing case for symmetry.
+ // See Apache Hop #7990.
+ if (jo instanceof JsonNode jsonNode && (jsonNode.isNull() ||
jsonNode.isMissingNode())) {
+ return null;
+ }
+ if (jo instanceof Map) {
+ Map<String, ?> asStrMap = (Map<String, ?>) jo;
+ return JSONObject.toJSONString(asStrMap);
+ }
+ if (jo instanceof TextNode jot) {
+ // this avoids returning string enclosed by "" if JsonNode
+ return jot.asText();
}
- return nodevalue;
+ return jo.toString();
}
public Object[] getRow(Object[] baseOutputRow, Object[] rawPartRow,
JsonInputData data)
diff --git
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverterTest.java
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverterTest.java
new file mode 100644
index 0000000000..f3b478a158
--- /dev/null
+++
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverterTest.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.transforms.jsoninput.reader;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import com.fasterxml.jackson.databind.node.IntNode;
+import com.fasterxml.jackson.databind.node.MissingNode;
+import com.fasterxml.jackson.databind.node.NullNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link RowOutputConverter}. */
+class RowOutputConverterTest {
+
+ /**
+ * Regression test for Apache Hop #7990. A JSON {@code null} value is parsed
by Jackson into a
+ * {@link NullNode}, a non-null Java reference whose {@code toString()}
returns the string literal
+ * "null". Before the fix, this fell through to the generic {@code
jo.toString()} branch and
+ * downstream String output fields contained "null" instead of a Hop null.
+ */
+ @Test
+ void getStringValue_nullNode_returnsNull() {
+ assertNull(RowOutputConverter.getStringValue(NullNode.getInstance()));
+ }
+
+ @Test
+ void getStringValue_missingNode_returnsNull() {
+ assertNull(RowOutputConverter.getStringValue(MissingNode.getInstance()));
+ }
+
+ @Test
+ void getStringValue_javaNull_returnsNull() {
+ assertNull(RowOutputConverter.getStringValue(null));
+ }
+
+ @Test
+ void getStringValue_textNode_returnsUnquotedText() {
+ // Ensures the pre-existing TextNode branch still returns the unquoted
string
+ // rather than the JSON-encoded "\"foo\"" that toString() would produce.
+ assertEquals("foo",
RowOutputConverter.getStringValue(TextNode.valueOf("foo")));
+ }
+
+ @Test
+ void getStringValue_plainString_isPassedThrough() {
+ assertEquals("plain", RowOutputConverter.getStringValue("plain"));
+ }
+
+ @Test
+ void getStringValue_intNode_fallsThroughToToString() {
+ // Non-Text, non-null Jackson nodes intentionally fall through to
toString().
+ assertEquals("42", RowOutputConverter.getStringValue(IntNode.valueOf(42)));
+ }
+
+ @Test
+ void getStringValue_map_isSerializedAsJson() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("a", 1);
+ map.put("b", "two");
+ assertEquals("{\"a\":1,\"b\":\"two\"}",
RowOutputConverter.getStringValue(map));
+ }
+}