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

bamaer 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 d986f4001d Issue #8711 : Correct JSON Input and Enhanced JSON Output 
(#8715)
d986f4001d is described below

commit d986f4001ddd86a2ecc79cb3b6a3d802016a7ce7
Author: Matt Casters <[email protected]>
AuthorDate: Sat Oct 10 09:40:31 2026 +0200

    Issue #8711 : Correct JSON Input and Enhanced JSON Output (#8715)
    
    * Issue #8711 : Document and verify JSON-typed JSON Input sources
    
    * Issue #8711 : Characterize microsecond timestamp conversion for issue 4907
    
    * Issue #8711 : Verify missing timestamp offset fails cleanly
    
    * Issue #8711 : Correct JSON Input timestamp format examples
    
    * Issue #8711 : Describe enhanced JSON array behavior without a fictitious 
version
    
    * Issue #8711 : Clarify JSONPath object keys versus array indexes
    
    * Issue #8711 : Preserve JSON null when empty strings differ from null
    
    * Issue #8711 : Validate enhanced JSON group keys during metadata discovery
    
    * Issue #8711 : Report missing enhanced JSON group keys before runtime 
lookup
    
    * Issue #8711 : Share enhanced JSON key validation without clearing 
metadata on failure
    
    * Issue #8711 : Allow literal JSONPath expressions without changing legacy 
resolution
    
    * Issue #8711 : Verify JSONPath mode survives pipeline serialization
    
    * Issue #8711 : Stream enhanced JSON output to the file
    
    * Issue #8711 : Match enhanced JSON output row width to its metadata
    
    * Issue #8711 : Offer unmapped incoming fields as enhanced JSON group keys
    
    * Issue #8711 : Close enhanced JSON generators when disposal interrupts 
output
    
    * Issue #8711 : Document the enhanced JSON group key Get Fields button
    
    * Issue #8711 : Record the grouped pretty-print sample from issue 2569
    
    * Issue #8711 : Add compact NDJSON output
    
    * Issue #8711 : Split NDJSON on complete records
    
    * Issue #8711 : Wrap each NDJSON group independently
    
    * Issue #8711 : Reject incompatible NDJSON settings
    
    * Issue #8711 : Validate NDJSON append boundaries
    
    * Issue #8711 : Document newline-delimited enhanced JSON output
    
    * Ignore graphify-out folders
    
    * Issue #8711 : Finish parked JSON output, resolve the block name, and 
surface NDJSON append errors
    
    ---------
    
    Co-authored-by: Bart Maertens <[email protected]>
---
 .gitignore                                         |   1 +
 .../hop/core/row/value/ValueMetaTimestampTest.java |   9 +
 .../value/timestamp/SimpleTimestampFormatTest.java |   8 +
 .../pipeline/transforms/enhancedjsonoutput.adoc    |  28 +-
 .../ROOT/pages/pipeline/transforms/jsoninput.adoc  |  18 +-
 .../json/0018-enhanced-json-output-streaming.hpl   |   5 +-
 .../pipeline/transforms/jsoninput/JsonInput.java   |   4 +-
 .../transforms/jsoninput/JsonInputDialog.java      |  27 ++
 .../transforms/jsoninput/JsonInputMeta.java        |  24 +
 .../jsoninput/reader/RowOutputConverter.java       |   6 +-
 .../transforms/jsonoutputenhanced/JsonEOutput.java | 191 ++++++--
 .../jsonoutputenhanced/JsonEOutputDialog.java      | 139 +++++-
 .../jsonoutputenhanced/JsonEOutputMeta.java        |  70 ++-
 .../jsoninput/messages/messages_en_US.properties   |   4 +
 .../messages/messages_en_US.properties             |   8 +
 .../messages/messages_it_IT.properties             |   4 +-
 .../transforms/jsoninput/JsonInputMetaTest.java    |  11 +
 .../jsoninput/JsonInputPathOptionsDialogTest.java  | 162 +++++++
 .../transforms/jsoninput/JsonInputTest.java        |  93 ++++
 .../reader/RowOutputConverterNullTest.java         |  67 +++
 .../JsonEOutputBehaviorTest.java                   | 536 +++++++++++++++++++++
 .../jsonoutputenhanced/JsonEOutputDialogTest.java  | 351 ++++++++++++++
 .../jsonoutputenhanced/JsonEOutputMetaTest.java    |  49 ++
 23 files changed, 1723 insertions(+), 92 deletions(-)

diff --git a/.gitignore b/.gitignore
index 7e674ee2ea..27b47efd29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ build
 target
 release.properties
 pom.xml.releaseBackup
+**/graphify-out
 *.iml
 .idea
 .junie
diff --git 
a/core/src/test/java/org/apache/hop/core/row/value/ValueMetaTimestampTest.java 
b/core/src/test/java/org/apache/hop/core/row/value/ValueMetaTimestampTest.java
index f36de4c9c7..f7fa589115 100644
--- 
a/core/src/test/java/org/apache/hop/core/row/value/ValueMetaTimestampTest.java
+++ 
b/core/src/test/java/org/apache/hop/core/row/value/ValueMetaTimestampTest.java
@@ -243,4 +243,13 @@ class ValueMetaTimestampTest {
     assertEquals(convertedTimestamp.getTime(), timestamp.getTime());
     assertEquals(convertedTimestamp.getNanos(), timestamp.getNanos());
   }
+
+  @Test
+  void parsesTheIssue4907TimestampWithAMatchingMask() throws Exception {
+    ValueMetaTimestamp meta = new ValueMetaTimestamp("created");
+    meta.setConversionMask("yyyy-MM-dd'T'HH:mm:ss.SSSSSS");
+    assertEquals(
+        Timestamp.valueOf("2025-02-13 02:10:14.746582"),
+        meta.convertStringToTimestamp("2025-02-13T02:10:14.746582"));
+  }
 }
diff --git 
a/core/src/test/java/org/apache/hop/core/row/value/timestamp/SimpleTimestampFormatTest.java
 
b/core/src/test/java/org/apache/hop/core/row/value/timestamp/SimpleTimestampFormatTest.java
index dcc7a09048..2647a45cc5 100644
--- 
a/core/src/test/java/org/apache/hop/core/row/value/timestamp/SimpleTimestampFormatTest.java
+++ 
b/core/src/test/java/org/apache/hop/core/row/value/timestamp/SimpleTimestampFormatTest.java
@@ -18,6 +18,7 @@ package org.apache.hop.core.row.value.timestamp;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.sql.Timestamp;
 import java.text.ParseException;
@@ -247,4 +248,11 @@ class SimpleTimestampFormatTest {
     // Should return null (and set error index) instead of throwing NPE
     assertNull(result);
   }
+
+  @Test
+  void missingRequiredOffsetIsAParseFailureNotANullPointer() {
+    SimpleTimestampFormat format =
+        new SimpleTimestampFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US);
+    assertThrows(ParseException.class, () -> 
format.parse("2025-02-13T02:10:14.746582"));
+  }
 }
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/enhancedjsonoutput.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/enhancedjsonoutput.adoc
index 38aa8b3e2a..80a1110739 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/enhancedjsonoutput.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/enhancedjsonoutput.adoc
@@ -71,8 +71,16 @@ If file with specified name exists already, it will be 
replaced by a new one.
 If checked - new JSON output will be appended to the end of existing file.
 Or if file does not exist, it will be created as in previous case.
 
-TIP: If you want to create a ND-JSON (Newline-Delimited JSON) file, you may 
get better results by outputting the JSON rows and then printing them with a 
xref:pipeline/transforms/textfileoutput.adoc[Text file output] transform
-
+NOTE: Without newline-delimited JSON, Append concatenates complete JSON 
documents without adding separators.
+It does not merge arrays or produce one valid JSON document.
+
+|Newline-delimited JSON (NDJSON)|Disabled by default for compatibility.
+When enabled for file output, each input-row item or completed group is 
written as one compact JSON value followed by LF.
+A JSON block name wraps each line separately.
+There is no outer document array; Force arrays applies to the value inside a 
grouped item.
+UTF-8 is required and Pretty Print JSON must be disabled.
+Multiline NDJSON records are not supported; newline characters inside string 
values are escaped.
+Appending requires an empty file or an existing UTF-8 NDJSON file ending in LF.
 |Split JSON after n rows|If this number N is larger than zero, split the 
resulting JSON file into multiple parts of N items: N rows, or N groups when 
group keys are used.
 |Create Parent folder|Check this option to create the folders structure, if 
some of them are missing in the provided path.
 If this option is not checked and the full path cannot be found, the transform 
will fail.
@@ -91,11 +99,14 @@ Same as for 'Include date in filename' option
 
 === Group Key Tab
 
-This tab is used to define the key fields used for grouping the rows in JSON 
fragments.
-
-Rows with the same values in the key fields allow you to generate JSON 
fragments from the row data, and group them in a single JSON array. The key 
fields defined here will also be forwarded to the next transform as they are.
+This tab defines the input fields used to group consecutive rows into JSON 
fragments.
+The input stream must be sorted by all group key fields.
+The key fields are forwarded under their original field names when generated 
JSON is output to the next transform.
+In Write to file mode, the original input rows are forwarded unchanged.
 
-If no group field is defined, all the rows will be grouped in a JSON array and 
the transform output will be a single row and a single column.
+The Get Fields button adds available input fields that are not already listed 
in Fields or Group Key.
+Existing key rows and their element names are retained.
+Fields can still be added manually to both tabs when a group key must also 
appear in the generated fragment.
 
 In the output file, every group is one item: the key fields plus the JSON of 
the group, under the name of the output value.
 For example, with key field `group` and output value `rows`:
@@ -111,8 +122,9 @@ A JSON block name wraps the whole file, not every group: 
`{"data":[{"group":"a",
 |===
 |Option|Description
 |Fieldname|Input transform field name that will contribute to define the input 
transform fields key.
-|Element name|JSON element name.
-For example "A":"B" - A is a element name, B is actual input value mapped for 
this Element name.
+|Element name|The property name for this group key in each grouped file-output 
item.
+It does not rename the downstream Hop field and does not insert the key into 
the generated output-value fragment.
+To include a key in that fragment, also select it on the Fields tab.
 |===
 
 === Fields Tab
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/jsoninput.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/jsoninput.adoc
index 59a4c9ab44..27f2529aa0 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/jsoninput.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/jsoninput.adoc
@@ -40,7 +40,7 @@ Use JSON Input when each column should come from its *own 
JsonPath* (or you are
 
 == Examples
 * For example, connect a REST client to a JSON input transform to read JSON 
into data rows.
-* To read nested JSON elements, use a sequence of JSON Input transforms, where 
the first JSON Input transform reads the nested element as a String field. In 
the second JSON Input transform, use the `Source is from a previous transform` 
option to select the String field you used in the first JSON Input transform to 
read the information from the nested element. 
+* To read a nested JSON value, use either a String field containing serialized 
JSON or a JSON-typed field containing a Jackson JSON value. In the next JSON 
Input transform, enable *Source is from a previous transform* and select that 
field. Leave *Use field as file names* and *Read source as URL* cleared for 
direct JSON content. A filename or URL source remains a String field; an 
arbitrary Java Object is not a supported JSON source.
 
 
 Check the `json-input-nested-elements.hpl` sample in the Samples project for 
an example.
@@ -116,6 +116,10 @@ Results are not limited when set to zero.
 |Add filenames to result|Select to add processed files to the result file list.
 |===
 
+=== JSONPath tab
+
+*Resolve variables in JSONPath* is enabled by default, including for existing 
pipelines. Clear it to use every extraction Path literally. This is required 
for root-array paths such as `$[0].similarity`, whose `$[0]` otherwise has a 
meaning in Hop variable resolution. Clearing it also disables `${VARIABLE}` 
substitution inside extraction paths. File and URL settings continue to resolve 
variables normally.
+
 === Fields Tab
 
 The Fields tab displays field definitions to extract values from the JSON 
structure.
@@ -156,11 +160,9 @@ Click Select fields from snippet button and paste the 
entire JSON text and it wi
 For Hop types of Date and Timestamp, use the Format column to specify the 
incoming format. E.g. for Date: yyyy-MM-dd. You can either select a format from 
the dropdown or enter format text directly.
 See xref:pipeline/formatting-values.adoc[Formatting numbers and dates].
 
-*Examples of Timestamp formats:*
-
-* Format: yyyy-MM-dd'T'HH:mm:ss.SSSZ and Result: 2021-10-26T20:51:43.795+0000
+For Timestamp values, match the fraction width in the Format column. For 
example, `2025-02-13T02:10:14.746582` uses `yyyy-MM-dd'T'HH:mm:ss.SSSSSS`. 
Timestamp preserves the six fractional digits; Date has millisecond precision.
 
-* Format: 2024-04-22T00:00:00.000Z and Result: 2024-04-22T00:00:00.000Z
+`XXX` requires an offset such as `Z` or `+02:00` in the input. Do not use 
`XXX` for a value that contains no offset. A mismatched format must report a 
conversion error, not silently assume an offset or discard precision.
 
 
 *Regex*
@@ -177,6 +179,12 @@ The aggregation functions provided by JayWay can be used 
at the end of a Path ex
 
 A function is applied to the value the Path points to. When the Path points to 
a single value, the function produces a single value and therefore a single 
output row, even when that value is itself a list, as with `keys()`.
 
+*Object keys and array indexes*
+
+A key containing a dot is one key, not a nested path. Use `$['doc.tipo']` to 
retrieve its value. The existing `$.keys()` function returns the object's keys 
as one JSON value; it does not define portable object-member ordering or return 
a key/value pair by ordinal.
+
+Array indexes address array elements. To select the first key/value pair by 
position, make the order explicit in the source, for example 
`[{"name":"doc.tipo","value":"XG"}]`, and use `$[0].name` and `$[0].value` with 
JsonPath variable resolution disabled. The expressions `$.[0].name()` and 
`$.[0]` do not define positional access to object members.
+
 When the Path contains a wildcard before the function, the function is applied 
to every match and produces one row per match. For example, 
`$.orders[*].lines.length()` returns the number of lines of each order. To 
aggregate over all matches instead, use a deep scan or pass the Path as a 
parameter to the function: `$..price.sum()` or `$.sum($.orders[*].price)`. Note 
that `$.orders[*].price.sum()` does not work: the sum is applied to each single 
price.
 
 Combining a single-value function field with a field whose Path matches 
several elements is not supported: the transform reports a structure error 
because the two Paths do not yield the same number of rows. Read such a value 
in a dedicated JSON Input transform when the other fields return multiple rows.
diff --git a/integration-tests/json/0018-enhanced-json-output-streaming.hpl 
b/integration-tests/json/0018-enhanced-json-output-streaming.hpl
index 22ac42c597..c6709759af 100644
--- a/integration-tests/json/0018-enhanced-json-output-streaming.hpl
+++ b/integration-tests/json/0018-enhanced-json-output-streaming.hpl
@@ -63,8 +63,9 @@ limitations under the License.
       <height>60</height>
       <xloc>64</xloc>
       <yloc>144</yloc>
-      <note>Run by main-0018 in a separate hop-run with a small heap. The JSON 
file is several
-times larger than that heap, so it only gets written when the transform 
streams it.</note>
+      <note>Run by main-0018 in a separate hop-run with -Xmx128m. The JSON 
file is about 127MB,
+smaller than that heap, so it catches a retained document rather than a file 
larger than the
+entire heap. One unlimited group is still held until the group ends.</note>
       <width>560</width>
     </notepad>
   </notepads>
diff --git 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInput.java
 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInput.java
index bc05c4c31c..3ee496c9e9 100644
--- 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInput.java
+++ 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInput.java
@@ -630,7 +630,9 @@ public class JsonInput extends 
BaseFileInputTransform<JsonInputMeta, JsonInputDa
     JsonInputField[] inputFields = new JsonInputField[data.nrInputFields];
     for (int i = 0; i < data.nrInputFields; i++) {
       JsonInputField field = new JsonInputField(meta.getInputFields().get(i));
-      field.setPath(resolve(field.getPath()));
+      if (meta.isResolveJsonPaths()) {
+        field.setPath(resolve(field.getPath()));
+      }
       inputFields[i] = field;
     }
     // Instead of putting in the meta.inputFields, we put in our json path 
resolved input fields
diff --git 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputDialog.java
 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputDialog.java
index c6bd71c94f..03bfc3ab45 100644
--- 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputDialog.java
+++ 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputDialog.java
@@ -54,6 +54,8 @@ import org.apache.hop.ui.core.dialog.EnterTextDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.dialog.PreviewRowsDialog;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgetsAdapter;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.ColumnInfo;
 import org.apache.hop.ui.core.widget.NamingSchemeTypes;
@@ -158,6 +160,8 @@ public class JsonInputDialog extends BaseTransformDialog {
 
   private final JsonInputMeta input;
 
+  private GuiCompositeWidgets pathWidgets;
+
   private ModifyListener lsMod;
 
   public JsonInputDialog(
@@ -182,6 +186,8 @@ public class JsonInputDialog extends BaseTransformDialog {
 
     addContentTab();
 
+    addPathOptionsTab();
+
     addFieldsTab();
 
     addAdditionalFieldsTab();
@@ -457,6 +463,26 @@ public class JsonInputDialog extends BaseTransformDialog {
     }
   }
 
+  private void addPathOptionsTab() {
+    CTabItem tab = new CTabItem(wTabFolder, SWT.NONE);
+    tab.setText(BaseMessages.getString(PKG, 
"JsonInputDialog.PathOptions.TabTitle"));
+    Composite parent = new Composite(wTabFolder, SWT.NONE);
+    PropsUi.setLook(parent);
+    parent.setLayout(new FormLayout());
+    pathWidgets =
+        GuiCompositeWidgets.addScrolledComposite(
+            parent, variables, null, null, 
JsonInputMeta.GUI_PLUGIN_ELEMENT_PARENT_ID, input);
+    pathWidgets.setWidgetsListener(
+        new GuiCompositeWidgetsAdapter() {
+          @Override
+          public void widgetModified(
+              GuiCompositeWidgets widgets, Control control, String widgetId) {
+            input.setChanged();
+          }
+        });
+    tab.setControl(parent);
+  }
+
   private void addContentTab() {
     // ////////////////////////
     // START OF CONTENT TAB///
@@ -1295,6 +1321,7 @@ public class JsonInputDialog extends BaseTransformDialog {
 
   /** dialog -&gt; meta */
   private void getInfo(JsonInputMeta in) {
+    pathWidgets.getWidgetsContents(in, 
JsonInputMeta.GUI_PLUGIN_ELEMENT_PARENT_ID);
     transformName = wTransformName.getText(); // return value
 
     in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L));
diff --git 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputMeta.java
 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputMeta.java
index c81632afe0..6ea05987a1 100644
--- 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputMeta.java
+++ 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputMeta.java
@@ -31,6 +31,10 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopTransformException;
 import org.apache.hop.core.fileinput.FileInputList;
 import org.apache.hop.core.fileinput.InputFile;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.row.value.ValueMetaInteger;
@@ -58,11 +62,14 @@ import org.w3c.dom.Node;
     description = "i18n::JsonInput.description",
     keywords = "i18n::JsonInputMeta.keyword",
     categoryDescription = "i18n::JsonInput.category")
+@GuiPlugin
 @Getter
 @Setter
 public class JsonInputMeta extends BaseFileInputMeta<JsonInput, JsonInputData, 
BaseFileInput> {
   private static final Class<?> PKG = JsonInputMeta.class;
 
+  public static final String GUI_PLUGIN_ELEMENT_PARENT_ID = 
"JsonInput.JsonPathOptions";
+
   protected static final String[] RequiredFilesDesc =
       new String[] {
         BaseMessages.getString(PKG, "System.Combo.No"),
@@ -176,6 +183,23 @@ public class JsonInputMeta extends 
BaseFileInputMeta<JsonInput, JsonInputData, B
       injectionKeyDescription = 
"JsonInput.Injection.DEFAULT_PATH_LEAF_TO_NULL")
   private boolean defaultPathLeafToNull;
 
+  @HopMetadataProperty(
+      key = "resolveJsonPaths",
+      defaultBoolean = true,
+      injectionKey = "RESOLVE_JSON_PATHS",
+      injectionKeyDescription = "JsonInput.Injection.RESOLVE_JSON_PATHS")
+  @GuiWidgetElement(
+      id = "resolveJsonPaths",
+      order = "0100",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::JsonInputMeta.ResolveJsonPaths.Label",
+      toolTip = "i18n::JsonInputMeta.ResolveJsonPaths.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "JSONPath",
+      groupOrder = "0100")
+  private boolean resolveJsonPaths = true;
+
   @HopMetadataProperty(inline = true)
   protected BaseFileInputAdditionalFields additionalOutputFields;
 
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 d10b266987..4869feef34 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
@@ -51,11 +51,9 @@ public class RowOutputConverter {
     }
     // convert from string
     String strValue = getStringValue(value);
+    // A JSON null or missing node is already a Hop null. Do not let the 
empty-string
+    // convention turn that null into "".
     if (strValue == null) {
-      // JSON null and a missing path are null. convertDataFromString turns a 
null string into ""
-      // when HOP_EMPTY_STRING_DIFFERS_FROM_NULL is Y, which hides the 
difference between "" and
-      // null. An empty JSON string ("") is not null and still goes through 
conversion below.
-      // See Apache Hop #4373.
       return null;
     }
     return targetMeta.convertDataFromString(
diff --git 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutput.java
 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutput.java
index fb9035e157..282f780002 100644
--- 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutput.java
+++ 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutput.java
@@ -29,8 +29,10 @@ import java.io.BufferedOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
+import java.io.Serial;
 import java.math.BigDecimal;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
 import org.apache.commons.vfs2.FileObject;
@@ -97,9 +99,28 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
           data.isWriteToFile && meta.getKeyFields().isEmpty() && 
!meta.isUseSingleItemPerGroup();
       data.collectGroupItems = data.isOutputValue || (data.isWriteToFile && 
!data.streamFileRows);
 
-      if (data.isWriteToFile) {
-        if (!meta.getFileSettings().isDoNotOpenNewFileInit() && 
!openNewFile()) {
-          logError(BaseMessages.getString(PKG, "JsonOutput.Error.OpenNewFile", 
buildFilename()));
+      if (meta.isNewlineDelimited()
+          && meta.getOperationType() != 
JsonEOutputMeta.OperationType.OUTPUT_VALUE
+          && (meta.isJsonPrettified()
+              || !Const.UTF_8.equalsIgnoreCase(resolve(meta.getEncoding())))) {
+        logError(BaseMessages.getString(PKG, 
"JsonEOutput.Error.NdJsonSettings"));
+        setErrors(1);
+        stopAll();
+        return false;
+      }
+
+      if (data.isWriteToFile && 
!meta.getFileSettings().isDoNotOpenNewFileInit()) {
+        try {
+          if (!openNewFile()) {
+            logError(BaseMessages.getString(PKG, 
"JsonOutput.Error.OpenNewFile", buildFilename()));
+            stopAll();
+            setErrors(1);
+            return false;
+          }
+        } catch (HopTransformException e) {
+          // The NDJSON append check names the boundary. Do not replace it 
with a generic open
+          // failure.
+          logError(e.getSuperMessage());
           stopAll();
           setErrors(1);
           return false;
@@ -339,16 +360,16 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
 
     Object[] keyRow = getKeyValues(rowData);
 
-    Object[] additionalRowFields = new Object[2];
-
-    additionalRowFields[0] = data.jsonSerialized;
-
-    // Fill accessory fields
-    if (!Utils.isEmpty(meta.getJsonSizeFieldName())) {
-      additionalRowFields[1] = data.jsonLength;
-    }
+    Object[] additionalRowFields =
+        Utils.isEmpty(meta.getJsonSizeFieldName())
+            ? new Object[] {data.jsonSerialized}
+            : new Object[] {data.jsonSerialized, data.jsonLength};
 
     Object[] outputRowData = RowDataUtil.addRowData(keyRow, keyRow.length, 
additionalRowFields);
+    // addRowData over-allocates. The published row matches the metadata width.
+    if (outputRowData.length != data.outputRowMeta.size()) {
+      outputRowData = Arrays.copyOf(outputRowData, data.outputRowMeta.size());
+    }
     incrementLinesOutput();
 
     putRow(data.outputRowMeta, outputRowData);
@@ -419,6 +440,10 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
    * forced.
    */
   private void writeFileItem(JsonNode item) throws HopException {
+    if (meta.isNewlineDelimited()) {
+      writeNdJsonItem(item);
+      return;
+    }
     try {
       if (data.fileItemCount == 0) {
         data.pendingFileItem = item;
@@ -455,20 +480,60 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
     if (meta.isJsonPrettified()) {
       data.fileGenerator.setPrettyPrinter(new DefaultPrettyPrinter());
     }
-    if (!Utils.isEmpty(meta.getJsonBloc())) {
+    if (!Utils.isEmpty(data.realBlocName)) {
       data.fileGenerator.writeStartObject();
-      data.fileGenerator.writeFieldName(meta.getJsonBloc());
+      data.fileGenerator.writeFieldName(data.realBlocName);
     }
     if (array) {
       data.fileGenerator.writeStartArray();
     }
   }
 
+  /** One compact JSON value followed by LF. There is no outer document array. 
*/
+  private void writeNdJsonItem(JsonNode item) throws HopException {
+    try {
+      if (!openNewFile()) {
+        throw new HopTransformException(
+            BaseMessages.getString(PKG, "JsonEOutput.Error.OpenNewFile", 
buildFilename()));
+      }
+      if (data.fileGenerator == null) {
+        data.fileGenerator = 
fileMapper.getFactory().createGenerator(data.writer);
+        // The file is closed separately, with its lineage.
+        data.fileGenerator.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
+        data.fileGenerator.setPrettyPrinter(null);
+        data.fileGenerator.setRootValueSeparator(null);
+      }
+      if (!Utils.isEmpty(data.realBlocName)) {
+        data.fileGenerator.writeStartObject();
+        data.fileGenerator.writeFieldName(data.realBlocName);
+      }
+      data.fileGenerator.writeTree(item);
+      if (!Utils.isEmpty(data.realBlocName)) {
+        data.fileGenerator.writeEndObject();
+      }
+      data.fileGenerator.writeRaw('\n');
+    } catch (IOException e) {
+      throw new HopTransformException(BaseMessages.getString(PKG, 
"JsonEOutput.Error.Writing"), e);
+    }
+    data.fileItemCount++;
+    if (!data.isOutputValue) {
+      incrementLinesOutput();
+    }
+    int split = meta.getFileSettings().getSplitOutputAfter();
+    if (split > 0 && data.fileItemCount >= split) {
+      finishFile();
+    }
+  }
+
   /** Close the JSON document and the file, if any item was written to it. */
   private void finishFile() throws HopTransformException {
     if (data.fileItemCount == 0) {
       return;
     }
+    if (meta.isNewlineDelimited()) {
+      closeNdJsonFile();
+      return;
+    }
     try {
       if (data.fileItemCount == 1) {
         startFileDocument(meta.isUseArrayWithSingleInstance());
@@ -477,7 +542,7 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
       if (data.fileItemCount > 1 || meta.isUseArrayWithSingleInstance()) {
         data.fileGenerator.writeEndArray();
       }
-      if (!Utils.isEmpty(meta.getJsonBloc())) {
+      if (!Utils.isEmpty(data.realBlocName)) {
         data.fileGenerator.writeEndObject();
       }
       data.fileGenerator.close();
@@ -490,14 +555,33 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
     closeFile();
   }
 
+  /** Close the NDJSON generator, then the writer. Do not finish an outer 
array or object. */
+  private void closeNdJsonFile() throws HopTransformException {
+    try {
+      if (data.fileGenerator != null) {
+        data.fileGenerator.close();
+      }
+    } catch (IOException e) {
+      throw new HopTransformException(BaseMessages.getString(PKG, 
"JsonEOutput.Error.Writing"), e);
+    }
+    String filename = data.openedFilename;
+    data.fileGenerator = null;
+    data.pendingFileItem = null;
+    data.fileItemCount = 0;
+    if (!closeFile()) {
+      throw new HopTransformException(
+          BaseMessages.getString(PKG, "JsonEOutput.Error.ClosingFile", 
filename));
+    }
+  }
+
   private void serializeJson(List<ObjectNode> jsonItemsList) throws 
HopException {
     ObjectNode theNode = new ObjectNode(nc);
     Object listValue = meta.isUseArrayWithSingleInstance() ? jsonItemsList : 
jsonItemsList.get(0);
     try {
-      if (!Utils.isEmpty(meta.getJsonBloc())) {
+      if (!Utils.isEmpty(data.realBlocName)) {
         // TBD Try to understand if this can have a performance impact and do 
it better...
         theNode.set(
-            meta.getJsonBloc(),
+            data.realBlocName,
             mapper.readTree(
                 mapper.writeValueAsString(jsonItemsList.size() > 1 ? 
jsonItemsList : listValue)));
         if (meta.isJsonPrettified()) {
@@ -534,18 +618,16 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
     first = false;
     data.inputRowMeta = getInputRowMeta();
     data.inputRowMetaSize = data.inputRowMeta.size();
+    data.keysGroupIndexes = meta.resolveKeyFieldIndexes(data.inputRowMeta);
 
     // Init previous row copy to this first row
     prevRow = data.inputRowMeta.cloneRow(r); // copy the row to previous
 
     // Create new structure for output fields
     data.outputRowMeta = new RowMeta();
-    List<JsonEOutputKeyField> keyFields = meta.getKeyFields();
     for (int i = 0; i < meta.getKeyFields().size(); i++) {
-      IValueMeta vmi =
-          data.inputRowMeta.getValueMeta(
-              data.inputRowMeta.indexOfValue(keyFields.get(i).getFieldName()));
-      data.outputRowMeta.addValueMeta(i, vmi);
+      data.outputRowMeta.addValueMeta(
+          data.inputRowMeta.getValueMeta(data.keysGroupIndexes[i]).clone());
     }
 
     // This is JSON block's column
@@ -560,7 +642,6 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
 
     initDataFieldsPositionsArray();
 
-    if (initKeyFieldsPositionArray(r)) return true;
     return false;
   }
 
@@ -591,21 +672,6 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
     }
   }
 
-  private boolean initKeyFieldsPositionArray(Object[] r) {
-    data.keysGroupIndexes = new int[meta.getKeyFields().size()];
-
-    for (int i = 0; i < meta.getKeyFields().size(); i++) {
-      data.keysGroupIndexes[i] =
-          
data.inputRowMeta.indexOfValue(meta.getKeyFields().get(i).getFieldName());
-      if ((r != null) && (data.keysGroupIndexes[i] < 0)) {
-        setErrors(1);
-        stopAll();
-        return true;
-      }
-    }
-    return false;
-  }
-
   @Override
   public void dispose() {
 
@@ -613,17 +679,15 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
       data.jsonKeyGroupItems = null;
     }
 
-    // The file was not finished, for example after an error: flush what was 
written and close any
-    // open array or object, so the partial file is at least well-formed.
-    if (data.fileGenerator != null) {
+    // The parked item is a finished row or group. Write it, then close the 
file. Do not finish
+    // the group that is still being built. End-of-input has already set the 
count to 0.
+    if (data.fileItemCount > 0) {
       try {
-        data.fileGenerator.close();
-      } catch (IOException e) {
-        logError(BaseMessages.getString(PKG, "JsonOutput.Error.ClosingFile", 
e.toString()));
+        finishFile();
+      } catch (HopTransformException e) {
+        logError(BaseMessages.getString(PKG, "JsonEOutput.Error.ClosingFile", 
e.toString()));
         setErrors(1);
       }
-      data.fileGenerator = null;
-      data.pendingFileItem = null;
     }
 
     closeFile();
@@ -679,7 +743,7 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
     }
   }
 
-  public boolean openNewFile() {
+  public boolean openNewFile() throws HopTransformException {
 
     if (data.writer != null) return true;
     boolean retval = false;
@@ -687,6 +751,7 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
 
       String filename = buildFilename();
       createParentFolder(filename);
+      validateNdJsonAppend(filename);
       if (meta.isAddingToResult()) {
         // Add this to the result file names...
         ResultFile resultFile =
@@ -721,6 +786,8 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
 
       retval = true;
 
+    } catch (NdJsonAppendBoundaryException e) {
+      throw new HopTransformException(e.getMessage(), e);
     } catch (Exception e) {
       logError(BaseMessages.getString(PKG, "JsonOutput.Error.OpeningFile", 
e.toString()));
     }
@@ -728,6 +795,29 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
     return retval;
   }
 
+  /** An appended NDJSON file must already end in LF. The check does not read 
the earlier lines. */
+  private void validateNdJsonAppend(String filename) throws Exception {
+    if (!meta.isNewlineDelimited() || 
!meta.getFileSettings().isFileAppended()) {
+      return;
+    }
+    try (FileObject file = HopVfs.getFileObject(filename, variables)) {
+      if (!file.exists()) {
+        return;
+      }
+      long size = file.getContent().getSize();
+      if (size == 0) {
+        return;
+      }
+      try (var stream = HopVfs.getInputStream(file)) {
+        stream.skipNBytes(size - 1);
+        if (stream.read() != '\n') {
+          throw new NdJsonAppendBoundaryException(
+              BaseMessages.getString(PKG, 
"JsonEOutput.Error.NdJsonAppendBoundary", filename));
+        }
+      }
+    }
+  }
+
   public String buildFilename() {
     return meta.getFileSettings()
         .buildFilename(variables, getCopy() + "", getPartitionId(), 
data.splitnr + "", false);
@@ -765,4 +855,13 @@ public class JsonEOutput extends 
BaseTransform<JsonEOutputMeta, JsonEOutputData>
 
     return retval;
   }
+
+  /** Append was refused before the output stream existed, so the existing 
file is unchanged. */
+  private static final class NdJsonAppendBoundaryException extends IOException 
{
+    @Serial private static final long serialVersionUID = 1L;
+
+    private NdJsonAppendBoundaryException(String message) {
+      super(message);
+    }
+  }
 }
diff --git 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputDialog.java
 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputDialog.java
index 85eedd17d6..03ba9a723d 100644
--- 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputDialog.java
+++ 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputDialog.java
@@ -18,7 +18,9 @@
 package org.apache.hop.pipeline.transforms.jsonoutputenhanced;
 
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.exception.HopException;
@@ -35,6 +37,8 @@ import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.dialog.MessageDialogWithToggle;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgetsAdapter;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.ColumnInfo;
 import org.apache.hop.ui.core.widget.ComboVar;
@@ -65,6 +69,10 @@ import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.TableItem;
 
 public class JsonEOutputDialog extends BaseTransformDialog {
+  public static final String GUI_PLUGIN_ELEMENT_PARENT_ID = 
JsonEOutputMeta.FORMAT_GUI_PARENT;
+
+  private GuiCompositeWidgets keyWidgets;
+  private GuiCompositeWidgets formatWidgets;
   private static final Class<?> PKG = JsonEOutputMeta.class; // needed by 
Translator!!
 
   public static final String STRING_SORT_WARNING_PARAMETER = "JSONSortWarning";
@@ -232,8 +240,6 @@ public class JsonEOutputDialog extends BaseTransformDialog {
     Composite wKeyConfigComp = new Composite(wTabFolder, SWT.NONE);
     PropsUi.setLook(wKeyConfigComp);
 
-    final int keyFieldsRows = input.getKeyFields().size();
-
     keyColInf =
         new ColumnInfo[] {
           new ColumnInfo(
@@ -247,24 +253,24 @@ public class JsonEOutputDialog extends 
BaseTransformDialog {
               false)
         };
     keyColInf[1].setUsingVariables(true);
-    wKeyFields =
-        new TableView(
-            variables,
-            wKeyConfigComp,
-            SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
-            keyColInf,
-            keyFieldsRows,
-            lsMod,
-            props);
-
-    FormData fdKeyFields = new FormData();
-    fdKeyFields.left = new FormAttachment(0, 0);
-    fdKeyFields.top = new FormAttachment(0, 0);
-    fdKeyFields.right = new FormAttachment(100, 0);
-    fdKeyFields.bottom = new FormAttachment(100, 0);
-    wKeyFields.setLayoutData(fdKeyFields);
-
     wKeyConfigComp.setLayout(keyConfigLayout);
+    keyWidgets =
+        GuiCompositeWidgets.addScrolledComposite(
+            wKeyConfigComp,
+            variables,
+            null,
+            null,
+            JsonEOutputMeta.KEY_GUI_PARENT,
+            input,
+            widgets -> {
+              keyWidgets = widgets;
+              widgets.registerExtraGroup(
+                  BaseMessages.getString(PKG, 
"JsonEOutputDialog.KeyConfigTab.TabTitle"),
+                  "0100",
+                  null,
+                  this::createKeyFieldsTable);
+            });
+    keyWidgets.setCompositeButtonsListener(source -> 
getKeyFieldsFromPrevious());
     wKeyConfigComp.layout();
     wKeyConfigTab.setControl(wKeyConfigComp);
 
@@ -424,6 +430,24 @@ public class JsonEOutputDialog extends BaseTransformDialog 
{
     wFieldsComp.layout();
     wFieldsTab.setControl(wFieldsComp);
 
+    CTabItem formatTab = new CTabItem(wTabFolder, SWT.NONE);
+    formatTab.setText(BaseMessages.getString(PKG, 
"JsonEOutputDialog.FileFormat.TabTitle"));
+    Composite formatComp = new Composite(wTabFolder, SWT.NONE);
+    PropsUi.setLook(formatComp);
+    formatComp.setLayout(new FormLayout());
+    formatWidgets =
+        GuiCompositeWidgets.addScrolledComposite(
+            formatComp, variables, null, null, GUI_PLUGIN_ELEMENT_PARENT_ID, 
input);
+    formatWidgets.setWidgetsListener(
+        new GuiCompositeWidgetsAdapter() {
+          @Override
+          public void widgetModified(
+              GuiCompositeWidgets widgets, Control control, String widgetId) {
+            input.setChanged();
+          }
+        });
+    formatTab.setControl(formatComp);
+
     FormData fdTabFolder = new FormData();
     fdTabFolder.left = new FormAttachment(0, 0);
     fdTabFolder.top = new FormAttachment(wSpacer, margin);
@@ -1033,6 +1057,7 @@ public class JsonEOutputDialog extends 
BaseTransformDialog {
   }
 
   private void getInfo(JsonEOutputMeta jsometa) {
+    formatWidgets.getWidgetsContents(jsometa, GUI_PLUGIN_ELEMENT_PARENT_ID);
 
     jsometa.setJsonBloc(wBlocName.getText());
     jsometa.setEncoding(wEncoding.getText());
@@ -1186,5 +1211,81 @@ public class JsonEOutputDialog extends 
BaseTransformDialog {
     wlAddToResult.setEnabled(activeFile);
     wAddToResult.setEnabled(activeFile);
     wbShowFiles.setEnabled(activeFile);
+    if (formatWidgets != null) {
+      for (Control control : formatWidgets.getWidgetsMap().values()) {
+        control.setEnabled(activeFile);
+      }
+      for (Control control : formatWidgets.getLabelsMap().values()) {
+        control.setEnabled(activeFile);
+      }
+    }
+  }
+
+  private void createKeyFieldsTable(Composite parent) {
+    wKeyFields =
+        new TableView(
+            variables,
+            parent,
+            SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+            keyColInf,
+            input.getKeyFields().size(),
+            event -> input.setChanged(),
+            props);
+    Control button = 
keyWidgets.getWidgetsMap().get(JsonEOutputMeta.WIDGET_GET_KEYS);
+    FormData fd = new FormData();
+    fd.left = new FormAttachment(0, 0);
+    fd.right = new FormAttachment(100, 0);
+    fd.top = new FormAttachment(button, margin);
+    fd.bottom = new FormAttachment(100, 0);
+    wKeyFields.setLayoutData(fd);
+  }
+
+  static List<String> suggestKeyFieldNames(
+      IRowMeta previous, List<String> outputNames, List<String> existingKeys) {
+    Set<String> excluded = new HashSet<>(outputNames);
+    excluded.addAll(existingKeys);
+    List<String> names = new ArrayList<>();
+    for (int i = 0; i < previous.size(); i++) {
+      String name = previous.getValueMeta(i).getName();
+      if (excluded.add(name)) {
+        names.add(name);
+      }
+    }
+    return names;
+  }
+
+  private void getKeyFieldsFromPrevious() {
+    try {
+      IRowMeta previous = pipelineMeta.getPrevTransformFields(variables, 
transformName);
+      if (previous == null) {
+        return;
+      }
+      List<String> outputNames = new ArrayList<>();
+      for (TableItem item : wFields.getNonEmptyItems()) {
+        outputNames.add(item.getText(1));
+      }
+      List<String> existingKeys = new ArrayList<>();
+      for (TableItem item : wKeyFields.getNonEmptyItems()) {
+        existingKeys.add(item.getText(1));
+      }
+      List<String> additions = suggestKeyFieldNames(previous, outputNames, 
existingKeys);
+      for (String name : additions) {
+        TableItem item = new TableItem(wKeyFields.table, SWT.NONE);
+        item.setText(1, name);
+        item.setText(2, name);
+      }
+      if (!additions.isEmpty()) {
+        wKeyFields.removeEmptyRows();
+        wKeyFields.setRowNums();
+        wKeyFields.optWidth(true);
+        input.setChanged();
+      }
+    } catch (HopException e) {
+      new ErrorDialog(
+          shell,
+          BaseMessages.getString(PKG, "System.Dialog.GetFieldsFailed.Title"),
+          BaseMessages.getString(PKG, "System.Dialog.GetFieldsFailed.Message"),
+          e);
+    }
   }
 }
diff --git 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputMeta.java
 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputMeta.java
index bff6217327..86f3d670bc 100644
--- 
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputMeta.java
+++ 
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputMeta.java
@@ -27,9 +27,12 @@ import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.annotations.Transform;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
 import org.apache.hop.core.injection.Injection;
 import org.apache.hop.core.row.IRowMeta;
-import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.row.value.ValueMetaInteger;
 import org.apache.hop.core.row.value.ValueMetaString;
 import org.apache.hop.core.util.Utils;
@@ -52,16 +55,51 @@ import org.w3c.dom.Node;
     categoryDescription = 
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Output",
     keywords = "i18n::JsonEOutputMeta.keyword",
     documentationUrl = "/pipeline/transforms/enhancedjsonoutput.html")
+@GuiPlugin
 @Getter
 @Setter
 public class JsonEOutputMeta extends BaseTransformMeta<JsonEOutput, 
JsonEOutputData> {
   private static final Class<?> PKG = JsonEOutputMeta.class;
+
+  public static final String KEY_GUI_PARENT = "JsonEOutput.GroupKeys";
+  public static final String FORMAT_GUI_PARENT = "JsonEOutput.FileFormat";
+  public static final String WIDGET_GET_KEYS = "getKeyFields";
   public static final String CONST_SPACES_LONG = "        ";
   public static final String CONST_SPACES = "      ";
   public static final String CONST_OUTPUT_VALUE = "outputValue";
   public static final String CONST_KEY_FIELD = "key_field";
   public static final String CONST_FIELD = "field";
 
+  @GuiWidgetElement(
+      id = WIDGET_GET_KEYS,
+      order = "0100",
+      type = GuiElementType.BUTTON,
+      label = "i18n::JsonEOutputDialog.Get.Button",
+      toolTip = "i18n::JsonEOutputDialog.GetKeys.Tooltip",
+      parentId = KEY_GUI_PARENT,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "i18n::JsonEOutputDialog.KeyConfigTab.TabTitle",
+      groupOrder = "0100")
+  public void getKeyFieldsFromPrevious(Object source) {
+    // The dialog listener fills the live Group Key table. The button invoker 
passes the meta.
+  }
+
+  @HopMetadataProperty(
+      key = "newline_delimited",
+      injectionKey = "NEWLINE_DELIMITED",
+      injectionKeyDescription = "JsonEOutput.Injection.NEWLINE_DELIMITED")
+  @GuiWidgetElement(
+      id = "newlineDelimited",
+      order = "0100",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::JsonEOutputDialog.NdJson.Label",
+      toolTip = "i18n::JsonEOutputDialog.NdJson.Tooltip",
+      parentId = FORMAT_GUI_PARENT,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "i18n::JsonEOutputDialog.FileFormat.TabTitle",
+      groupOrder = "0100")
+  private boolean newlineDelimited;
+
   @Getter
   public enum OperationType implements IEnumHasCodeAndDescription {
     OUTPUT_VALUE(
@@ -184,6 +222,23 @@ public class JsonEOutputMeta extends 
BaseTransformMeta<JsonEOutput, JsonEOutputD
     jsonBloc = "";
   }
 
+  /**
+   * Locate every group key in {@code row}. The row is not modified. A missing 
key is reported
+   * before any caller clears or rewrites its metadata.
+   */
+  public int[] resolveKeyFieldIndexes(IRowMeta row) throws 
HopTransformException {
+    int[] indexes = new int[keyFields.size()];
+    for (int i = 0; i < keyFields.size(); i++) {
+      String name = keyFields.get(i).getFieldName();
+      indexes[i] = row.indexOfValue(name);
+      if (indexes[i] < 0) {
+        throw new HopTransformException(
+            BaseMessages.getString(PKG, 
"JsonEOutput.Error.GroupFieldNotFound", name));
+      }
+    }
+    return indexes;
+  }
+
   @Override
   public void getFields(
       IRowMeta row,
@@ -196,12 +251,10 @@ public class JsonEOutputMeta extends 
BaseTransformMeta<JsonEOutput, JsonEOutputD
 
     if (getOperationType() != OperationType.WRITE_TO_FILE) {
       IRowMeta rowMeta = row.clone();
+      int[] keyIndexes = resolveKeyFieldIndexes(rowMeta);
       row.clear();
-
-      for (int i = 0; i < this.getKeyFields().size(); i++) {
-        JsonEOutputKeyField keyField = this.getKeyFields().get(i);
-        IValueMeta vmi = 
rowMeta.getValueMeta(rowMeta.indexOfValue(keyField.getFieldName()));
-        row.addValueMeta(i, vmi);
+      for (int keyIndex : keyIndexes) {
+        row.addValueMeta(rowMeta.getValueMeta(keyIndex).clone());
       }
 
       ValueMetaString vm = new ValueMetaString(this.getOutputValue());
@@ -247,6 +300,11 @@ public class JsonEOutputMeta extends 
BaseTransformMeta<JsonEOutput, JsonEOutputD
     }
     // Check output fields
     if (prev != null && !prev.isEmpty()) {
+      try {
+        resolveKeyFieldIndexes(prev);
+      } catch (HopTransformException e) {
+        remarks.add(new CheckResult(ICheckResult.TYPE_RESULT_ERROR, 
e.getMessage(), transformMeta));
+      }
       cr =
           new CheckResult(
               ICheckResult.TYPE_RESULT_OK,
diff --git 
a/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsoninput/messages/messages_en_US.properties
 
b/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsoninput/messages/messages_en_US.properties
index 3fe6627064..368d894085 100644
--- 
a/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsoninput/messages/messages_en_US.properties
+++ 
b/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsoninput/messages/messages_en_US.properties
@@ -58,6 +58,10 @@ TextFileInput.Injection.INCLUDE_SUBFOLDERS=Set this flag to 
include subfolders w
 JsonInput.Injection.READ_SOURCE_AS_URL=Set this flag to treat the source as a 
URL instead of a filename.
 JsonInput.Injection.REMOVE_SOURCE_FIELDS=Set this flag to remove the source 
fields from the output of the transform.
 JsonInput.Injection.DEFAULT_PATH_LEAF_TO_NULL=Default path leaf to null
+JsonInput.Injection.RESOLVE_JSON_PATHS=Resolve variables in JSONPath 
expressions? (Y/N)
+JsonInputMeta.ResolveJsonPaths.Label=Resolve variables in JSONPath
+JsonInputMeta.ResolveJsonPaths.Tooltip=Clear this option to use extraction 
paths literally, including root array indexes such as '$[0]'. When selected, 
existing variable expressions such as '${JSON_PATH}' are resolved.
+JsonInputDialog.PathOptions.TabTitle=JSONPath
 JsonInput.Injection.ROW_LIMIT=The maximum number of lines to read.
 JsonInput.Injection.ROW_NUMBER_FIELDNAME=The fieldname to use to output the 
row number.
 JsonInput.Injection.ROW_NUMBER_OUTPUT=Set this flag to include the row number 
in the output.
diff --git 
a/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsonoutputenhanced/messages/messages_en_US.properties
 
b/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsonoutputenhanced/messages/messages_en_US.properties
index 74e00f0234..9b2234adc3 100644
--- 
a/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsonoutputenhanced/messages/messages_en_US.properties
+++ 
b/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsonoutputenhanced/messages/messages_en_US.properties
@@ -22,8 +22,11 @@ EnhancedJsonOutput.name=Enhanced JSON output
 JsonEOutput.Error.Casting=Error casting String to JSON Fragment
 JsonEOutput.Error.ClosingFile=Exception trying to close file: {0}
 JsonEOutput.Error.ErrorCreatingParentFolder=Could not create parent folder 
[{0}]!
+JsonEOutput.Error.GroupFieldNotFound=Group key field ''{0}'' was not found in 
the incoming row.
 JsonEOutput.Error.MissingOutputFieldName=Output fieldname that will contain 
value is empty\!
 JsonEOutput.Error.MissingTargetFilename=Target filename is missing\!
+JsonEOutput.Error.NdJsonAppendBoundary=Cannot append NDJSON to [{0}]: the 
nonempty existing file must end with LF.
+JsonEOutput.Error.NdJsonSettings=NDJSON file output requires UTF-8 encoding 
and Pretty Print JSON disabled.
 JsonEOutput.Error.OpeningFile=Error opening new file : {0}
 JsonEOutput.Error.OpenNewFile=Could not open file [{0}]!
 JsonEOutput.Error.ParentFolderNotExist=Folder parent [{0}] does not exist!
@@ -82,6 +85,11 @@ JsonEOutputDialog.FieldsTab.TabTitle=Fields
 JsonEOutputDialog.Filename.Label=Filename
 JsonEOutputDialog.GeneralTab.TabTitle=General
 JsonEOutputDialog.Get.Button=\ &Get Fields
+JsonEOutputDialog.GetKeys.Tooltip=Add input fields not already listed in 
Fields or Group Key. Existing keys and element names are retained.
+JsonEOutputDialog.FileFormat.TabTitle=File format
+JsonEOutputDialog.NdJson.Label=Newline-delimited JSON (NDJSON)
+JsonEOutputDialog.NdJson.Tooltip=Write one compact JSON item per line. 
Requires UTF-8 and Pretty Print JSON disabled. Applies only to file output.
+JsonEOutput.Injection.NEWLINE_DELIMITED=Write newline-delimited JSON to the 
output file? (Y/N)
 JsonEOutputDialog.Get.Tooltip=Get the fields as defined in previous transforms.
 JsonEOutputDialog.Group.File.Label=Output File
 JsonEOutputDialog.Group.Settings.Label=Settings
diff --git 
a/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsonoutputenhanced/messages/messages_it_IT.properties
 
b/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsonoutputenhanced/messages/messages_it_IT.properties
index cef455f541..3a9cd577af 100644
--- 
a/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsonoutputenhanced/messages/messages_it_IT.properties
+++ 
b/plugins/transforms/json/src/main/resources/org/apache/hop/pipeline/transforms/jsonoutputenhanced/messages/messages_it_IT.properties
@@ -37,7 +37,9 @@ JsonOutputDialog.AddTime.Label=Includere l''orario nel nome 
del file?
 JsonOutputDialog.Append.Label=Accoda
 JsonOutputDialog.Append.Tooltip=Accoda il file (se esiste) altrimenti 
verr\u00E0 sovrascritto
 JsonOutputDialog.BlocName.Label=Nome blocco Json
-JsonOutputDialog.CompatibilityMode.Tooltip=Selezionare quest''opzione per 
generare strutture JSON come se fossero state create con precedenti versioni 
(prima di Apache Hop 4.3.0)
+JsonOutputDialog.CompatibilityMode.Tooltip=Selezionare quest''opzione per 
generare array JSON anche quando la lista contiene un solo elemento
+JsonEOutputDialog.CompatibilityMode.Tooltip=Selezionare quest''opzione per 
generare array JSON anche quando la lista contiene un solo elemento
+JsonEOutputDialog.UseArrayWihSingleInstanceMode.Tooltip=Selezionare 
quest''opzione per generare array JSON anche quando la lista contiene un solo 
elemento
 JsonOutputDialog.CreateParentFolder.Label=Crea cartella padre
 JsonOutputDialog.CreateParentFolder.Tooltip=Selezionare qua per creare la 
cartella padre quando necessario. \nAltrimenti Apache Hop lancer\u00E0 
un''eccezione quando non esiste la cartella padre.
 JsonOutputDialog.DialogTitle=Output JSON
diff --git 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputMetaTest.java
 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputMetaTest.java
index b731a86c96..a1b5ca1819 100644
--- 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputMetaTest.java
+++ 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputMetaTest.java
@@ -108,6 +108,17 @@ class JsonInputMetaTest {
         "valueField must sync acceptingField after XML load");
   }
 
+  @Test
+  void missingResolveJsonPathsPropertyDefaultsToTrue() throws Exception {
+    JsonInputMeta loaded = new JsonInputMeta();
+    XmlMetadataUtil.deSerializeFromXml(
+        XmlHandler.loadXmlString("<transform><fields/></transform>", 
TransformMeta.XML_TAG),
+        JsonInputMeta.class,
+        loaded,
+        new MemoryMetadataProvider());
+    assertTrue(loaded.isResolveJsonPaths());
+  }
+
   private static void validate(JsonInputMeta meta) {
     assertNotNull(meta.getInputFields());
     assertNotNull(meta.getFileInput().getInputFiles());
diff --git 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputPathOptionsDialogTest.java
 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputPathOptionsDialogTest.java
new file mode 100644
index 0000000000..53045d811c
--- /dev/null
+++ 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputPathOptionsDialogTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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;
+
+import static 
org.eclipse.swtbot.swt.finder.matchers.WidgetMatcherFactory.widgetOfType;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.utils.SWTBotPreferences;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("uitest")
+class JsonInputPathOptionsDialogTest extends SwtBotTestBase {
+  /**
+   * JSON Input builds several tables before its event loop starts. SWTBot's 5 
s default expires
+   * while that construction is still on the UI thread, so the JSONPath tab is 
missed.
+   */
+  private static final long DIALOG_TIMEOUT_MS = 30_000L;
+
+  private static long defaultTimeout;
+
+  @BeforeAll
+  static void slowDownSwtBot() {
+    defaultTimeout = SWTBotPreferences.TIMEOUT;
+    SWTBotPreferences.TIMEOUT = DIALOG_TIMEOUT_MS;
+  }
+
+  @AfterAll
+  static void restoreSwtBotTimeout() {
+    SWTBotPreferences.TIMEOUT = defaultTimeout;
+  }
+
+  @BeforeAll
+  static void registerWidgets() {
+    GuiRegistry registry = GuiRegistry.getInstance();
+    for (Field field : JsonInputMeta.class.getDeclaredFields()) {
+      GuiWidgetElement element = field.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        registry.addGuiWidgetElement(JsonInputMeta.class.getName(), element, 
field);
+      }
+    }
+  }
+
+  private PipelineMeta pipeline(JsonInputMeta meta) {
+    PipelineMeta pipeline = new PipelineMeta();
+    pipeline.setMetadataProvider(new MemoryMetadataProvider());
+    pipeline.addTransform(new TransformMeta("JsonInput", "json", meta));
+    return pipeline;
+  }
+
+  @Test
+  void okPersistsLiteralPathMode() {
+    JsonInputMeta meta = new JsonInputMeta();
+    PipelineMeta pipeline = pipeline(meta);
+    withDialog(
+        parent -> new JsonInputDialog(parent, new Variables(), meta, 
pipeline).open(),
+        bot -> {
+          SWTBot dialog =
+              bot.shell(BaseMessages.getString(JsonInputMeta.class, 
"JsonInputDialog.DialogTitle"))
+                  .activate()
+                  .bot();
+          activateTab(dialog, "JSONPath");
+          setResolveJsonPaths(dialog, false);
+          dialog.button(buttonLabel("System.Button.OK")).click();
+        });
+    assertFalse(meta.isResolveJsonPaths());
+  }
+
+  @Test
+  void cancelDoesNotPersistLiteralPathMode() {
+    JsonInputMeta meta = new JsonInputMeta();
+    PipelineMeta pipeline = pipeline(meta);
+    withDialog(
+        parent -> new JsonInputDialog(parent, new Variables(), meta, 
pipeline).open(),
+        bot -> {
+          SWTBot dialog =
+              bot.shell(BaseMessages.getString(JsonInputMeta.class, 
"JsonInputDialog.DialogTitle"))
+                  .activate()
+                  .bot();
+          activateTab(dialog, "JSONPath");
+          setResolveJsonPaths(dialog, false);
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+    assertTrue(meta.isResolveJsonPaths());
+  }
+
+  /**
+   * SWTBot's finder walks controls and does not see the items of a {@code 
CTabFolder}. Select the
+   * tab on the folder, the same way the other transform dialog tests do.
+   */
+  private void activateTab(SWTBot dialog, String title) {
+    CTabFolder tabFolder = (CTabFolder) 
dialog.widget(widgetOfType(CTabFolder.class));
+    display.syncExec(
+        () -> {
+          List<String> titles = new ArrayList<>();
+          for (CTabItem item : tabFolder.getItems()) {
+            String itemTitle = item.getText() == null ? "" : 
item.getText().trim();
+            titles.add(itemTitle);
+            if (title.equals(itemTitle)) {
+              tabFolder.setSelection(item);
+              tabFolder.layout();
+              return;
+            }
+          }
+          throw new AssertionError("no tab titled " + title + " in " + titles);
+        });
+  }
+
+  /**
+   * The annotated checkbox keeps its text on a separate label. The button 
itself has no title, so a
+   * {@code checkBox("Resolve variables in JSONPath")} lookup does not see it.
+   */
+  private void setResolveJsonPaths(SWTBot dialog, boolean selected) {
+    Label label = dialog.label("Resolve variables in JSONPath").widget;
+    display.syncExec(
+        () -> {
+          for (Control child : label.getParent().getChildren()) {
+            if (child instanceof Button button && (button.getStyle() & 
SWT.CHECK) != 0) {
+              button.setSelection(selected);
+              return;
+            }
+          }
+          throw new AssertionError("no checkbox next to Resolve variables in 
JSONPath");
+        });
+  }
+}
diff --git 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputTest.java
 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputTest.java
index da7199790c..ca49eb6752 100644
--- 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputTest.java
+++ 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputTest.java
@@ -751,6 +751,99 @@ class JsonInputTest {
     assertEquals(1, jsonInput.getLinesWritten(), "rows written");
   }
 
+  @Test
+  void jsonTypedSourceFieldExtractsAValue() throws Exception {
+    JsonInputField field = new JsonInputField("value");
+    field.setPath("$.value");
+    field.setType(IValueMeta.TYPE_STRING);
+    JsonInputMeta meta = createSimpleMeta("json", field);
+    meta.setRemoveSourceField(true);
+    JsonNode input = HopJson.newMapper().readTree("{\"value\":\"ok\"}");
+    JsonInput transform = createJsonInputWithJsonNode("json", meta, new 
Object[] {input});
+    List<Object[]> rows = new ArrayList<>();
+    transform.addRowListener(
+        new RowAdapter() {
+          @Override
+          public void rowWrittenEvent(IRowMeta rowMeta, Object[] row) {
+            rows.add(row.clone());
+          }
+        });
+    try {
+      processRows(transform, 3);
+      assertEquals(0, transform.getErrors());
+      assertEquals(1, rows.size());
+      assertEquals("ok", rows.getFirst()[0]);
+    } finally {
+      transform.dispose();
+    }
+  }
+
+  @Test
+  void literalRootArrayPathExtractsTheFirstValue() throws Exception {
+    String path = "$[0].similarity";
+    String input = "[{\"Id\":3902113,\"similarity\":0.95}]";
+    JsonInputField field = new JsonInputField("similarity");
+    field.setPath(path);
+    field.setType(IValueMeta.TYPE_NUMBER);
+    JsonInputMeta meta = createSimpleMeta("json", field);
+    meta.setResolveJsonPaths(false);
+    meta.setRemoveSourceField(true);
+    JsonInput transform = createJsonInput("json", meta, new Object[] {input});
+    List<Object[]> rows = new ArrayList<>();
+    transform.addRowListener(
+        new RowAdapter() {
+          @Override
+          public void rowWrittenEvent(IRowMeta rowMeta, Object[] row) {
+            rows.add(row.clone());
+          }
+        });
+    try {
+      assertEquals(0, transform.getErrors(), "literal JSONPath must compile");
+      processRows(transform, 3);
+      assertEquals(0, transform.getErrors());
+      assertEquals(1, rows.size());
+      assertEquals(0.95d, rows.getFirst()[0]);
+      assertEquals(path, meta.getInputFields().getFirst().getPath());
+    } finally {
+      transform.dispose();
+    }
+  }
+
+  @Test
+  void serializedLiteralPathModeStillExtractsRootArray() throws Exception {
+    JsonInputMeta original = new JsonInputMeta();
+    original.setResolveJsonPaths(false);
+    String xml =
+        "<transform>"
+            + 
org.apache.hop.metadata.serializer.xml.XmlMetadataUtil.serializeObjectToXml(original)
+            + "</transform>";
+    JsonInputMeta loaded = new JsonInputMeta();
+    org.apache.hop.metadata.serializer.xml.XmlMetadataUtil.deSerializeFromXml(
+        org.apache.hop.core.xml.XmlHandler.loadXmlString(
+            xml, org.apache.hop.pipeline.transform.TransformMeta.XML_TAG),
+        JsonInputMeta.class,
+        loaded,
+        new 
org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider());
+    assertFalse(loaded.isResolveJsonPaths());
+    JsonInputField field = new JsonInputField("value");
+    field.setPath("$[0].value");
+    field.setType(IValueMeta.TYPE_STRING);
+    loaded.setInputFields(new ArrayList<>(List.of(field)));
+    loaded.setInFields(true);
+    loaded.setFieldValue("json");
+    loaded.setRemoveSourceField(true);
+    JsonInput transform = createJsonInput("json", loaded, new Object[] 
{"[{\"value\":\"ok\"}]"});
+    RowComparatorListener rows = new RowComparatorListener(new Object[] 
{"ok"});
+    transform.addRowListener(rows);
+    try {
+      processRows(transform, 3);
+      assertEquals(0, transform.getErrors());
+      assertEquals(1, rows.rowNbr);
+    } finally {
+      transform.dispose();
+    }
+  }
+
   @Test
   void testArrayOut() throws Exception {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
diff --git 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverterNullTest.java
 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverterNullTest.java
new file mode 100644
index 0000000000..378f71dd0d
--- /dev/null
+++ 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsoninput/reader/RowOutputConverterNullTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import com.fasterxml.jackson.databind.node.NullNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import java.util.BitSet;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.transforms.jsoninput.JsonInputData;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class RowOutputConverterNullTest {
+  @ParameterizedTest
+  @ValueSource(strings = {"Y", "N"})
+  void jsonNullIsNotConvertedIntoAnEmptyString(String distinguish) throws 
Exception {
+    String before = 
System.getProperty(Const.HOP_EMPTY_STRING_DIFFERS_FROM_NULL);
+    try {
+      System.setProperty(Const.HOP_EMPTY_STRING_DIFFERS_FROM_NULL, 
distinguish);
+      JsonInputData data = new JsonInputData();
+      data.outputRowMeta = new RowMeta();
+      data.outputRowMeta.addValueMeta(new ValueMetaString("nullValue"));
+      data.outputRowMeta.addValueMeta(new ValueMetaString("emptyValue"));
+      data.convertRowMeta = 
data.outputRowMeta.cloneToType(ValueMetaString.TYPE_STRING);
+      data.totalpreviousfields = 0;
+      data.repeatedFields = new BitSet();
+      RowOutputConverter converter = new 
RowOutputConverter(mock(ILogChannel.class));
+      Object[] row =
+          converter.getRow(
+              new Object[0], new Object[] {NullNode.getInstance(), 
TextNode.valueOf("")}, data);
+      assertNull(row[0]);
+      assertEquals("", row[1]);
+      if ("Y".equals(distinguish)) {
+        assertTrue(!data.outputRowMeta.getValueMeta(1).isNull(row[1]));
+      }
+    } finally {
+      if (before == null) {
+        System.clearProperty(Const.HOP_EMPTY_STRING_DIFFERS_FROM_NULL);
+      } else {
+        System.setProperty(Const.HOP_EMPTY_STRING_DIFFERS_FROM_NULL, before);
+      }
+    }
+  }
+}
diff --git 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputBehaviorTest.java
 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputBehaviorTest.java
new file mode 100644
index 0000000000..c16b0d00d0
--- /dev/null
+++ 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputBehaviorTest.java
@@ -0,0 +1,536 @@
+/*
+ * 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.jsonoutputenhanced;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.RowMetaAndData;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.json.HopJson;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class JsonEOutputBehaviorTest {
+  private final String base = "ram:///enhanced-json-" + UUID.randomUUID();
+
+  @BeforeAll
+  static void initialize() throws HopException {
+    HopClientEnvironment.init();
+  }
+
+  @AfterEach
+  void removeFiles() throws Exception {
+    try (FileObject file = HopVfs.getFileObject(base)) {
+      if (file.exists()) {
+        file.deleteAll();
+      }
+    }
+  }
+
+  private JsonEOutputMeta meta(JsonEOutputMeta.OperationType operation) {
+    JsonEOutputMeta meta = new JsonEOutputMeta();
+    meta.setOperationType(operation);
+    meta.setOutputValue("rows");
+    meta.setJsonBloc("");
+    meta.setEncoding("UTF-8");
+    JsonEOutputField field = new JsonEOutputField();
+    field.setFieldName("payload");
+    field.setElementName("payload");
+    meta.getOutputFields().add(field);
+    meta.getFileSettings().setFileName(base + "/out");
+    meta.getFileSettings().setExtension("json");
+    meta.getFileSettings().setCreateParentFolder(true);
+    meta.getFileSettings().setDoNotOpenNewFileInit(true);
+    return meta;
+  }
+
+  private static String read(String path) throws Exception {
+    try (FileObject file = HopVfs.getFileObject(path);
+        var input = HopVfs.getInputStream(file)) {
+      return new String(input.readAllBytes(), StandardCharsets.UTF_8);
+    }
+  }
+
+  private static JsonNode parse(String json) throws Exception {
+    return HopJson.newMapper()
+        .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+        .readTree(json);
+  }
+
+  private static String payload(String value) {
+    return HopJson.newMapper().createObjectNode().put("payload", 
value).toString();
+  }
+
+  private static IRowMeta rowMeta(String... names) {
+    IRowMeta row = new RowMeta();
+    for (String name : names) {
+      row.addValueMeta(new ValueMetaString(name));
+    }
+    return row;
+  }
+
+  private static final class Harness implements AutoCloseable {
+    final TransformMockHelper<JsonEOutputMeta, JsonEOutputData> helper;
+    final List<RowMetaAndData> written = new ArrayList<>();
+    final JsonEOutputData data;
+    final JsonEOutput transform;
+
+    Harness(JsonEOutputMeta meta, IRowMeta inputMeta, Object[]... rows) {
+      helper =
+          new TransformMockHelper<>("enhanced JSON", JsonEOutputMeta.class, 
JsonEOutputData.class);
+      when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+          .thenReturn(helper.iLogChannel);
+      when(helper.pipeline.isRunning()).thenReturn(true);
+      when(helper.transformMeta.getTransform()).thenReturn(meta);
+      var iterator = Arrays.asList(rows).iterator();
+      data = new JsonEOutputData();
+      transform =
+          new JsonEOutput(
+              helper.transformMeta, meta, data, 0, helper.pipelineMeta, 
helper.pipeline) {
+            @Override
+            public Object[] getRow() {
+              return iterator.hasNext() ? iterator.next() : null;
+            }
+
+            @Override
+            public void putRow(IRowMeta rowMeta, Object[] row) {
+              written.add(new RowMetaAndData(rowMeta.clone(), row.clone()));
+            }
+          };
+      transform.setInputRowMeta(inputMeta);
+    }
+
+    void run() throws HopException {
+      assertTrue(transform.init());
+      while (transform.processRow()) {
+        // Drain the real transform through EOF.
+      }
+    }
+
+    @Override
+    public void close() {
+      try {
+        transform.dispose();
+      } finally {
+        helper.cleanUp();
+      }
+    }
+  }
+
+  @Test
+  void missingRuntimeGroupKeyIsAnActionableError() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.OUTPUT_VALUE);
+    meta.getKeyFields().add(new JsonEOutputKeyField("gone"));
+    try (Harness h = new Harness(meta, rowMeta("payload"), new Object[] 
{"x"})) {
+      assertTrue(h.transform.init());
+      HopException error = assertThrows(HopException.class, 
h.transform::processRow);
+      assertTrue(error.getMessage().contains("gone"), error.getMessage());
+    }
+  }
+
+  @Test
+  void missingKeyIsNamedDuringSchemaDiscovery() {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.OUTPUT_VALUE);
+    meta.getKeyFields().add(new JsonEOutputKeyField("gone"));
+    IRowMeta input = rowMeta("payload");
+    HopTransformException error =
+        assertThrows(
+            HopTransformException.class,
+            () -> meta.getFields(input, "json", null, null, new Variables(), 
null));
+    assertTrue(error.getMessage().contains("gone"), error.getMessage());
+    assertEquals("payload", input.getValueMeta(0).getName());
+  }
+
+  @Test
+  void bothKeepsRowShapeAndWritesEveryGroup() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.BOTH);
+    JsonEOutputKeyField key = new JsonEOutputKeyField("grp");
+    key.setElementName("group");
+    meta.getKeyFields().add(key);
+    try (Harness h =
+        new Harness(
+            meta,
+            rowMeta("grp", "payload"),
+            new Object[] {"a", "x"},
+            new Object[] {"a", "y"},
+            new Object[] {"b", "z"})) {
+      h.run();
+      assertEquals(2, h.written.size());
+      for (RowMetaAndData row : h.written) {
+        assertEquals(row.getRowMeta().size(), row.getData().length);
+        assertTrue(row.getRowMeta().indexOfValue("grp") >= 0);
+        assertEquals(-1, row.getRowMeta().indexOfValue("group"));
+      }
+    }
+    JsonNode file = parse(read(base + "/out.json"));
+    assertEquals(2, file.size());
+    assertEquals("a", file.get(0).get("group").asText());
+    assertEquals(2, file.get(0).get("rows").size());
+    assertEquals("z", file.get(1).get("rows").get("payload").asText());
+  }
+
+  @Test
+  void keySuggestionsExcludeLiveOutputAndExistingKeys() {
+    assertEquals(
+        List.of("grp"),
+        JsonEOutputDialog.suggestKeyFieldNames(
+            rowMeta("payload", "grp", "already"), List.of("payload"), 
List.of("already")));
+    assertEquals(
+        List.of(),
+        JsonEOutputDialog.suggestKeyFieldNames(
+            rowMeta("payload", "grp"), List.of("payload"), List.of("grp")));
+  }
+
+  @Test
+  void disposalClosesAnActiveFileGenerator() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    try (Harness h =
+        new Harness(
+            meta, rowMeta("payload"), new Object[] {"a"}, new Object[] {"b"}, 
new Object[] {"c"})) {
+      assertTrue(h.transform.init());
+      assertTrue(h.transform.processRow());
+      assertTrue(h.transform.processRow());
+      var generator = h.data.fileGenerator;
+      assertNotNull(generator);
+      h.transform.dispose();
+      assertTrue(generator.isClosed());
+      assertNull(h.data.fileGenerator);
+      assertNull(h.data.writer);
+    }
+  }
+
+  /**
+   * The pipeline attached to issue #2569: pretty-printed file output, one 
group key with an element
+   * name, a JSON block around the file, and a mix of one-row and many-row 
groups. Parsed structure
+   * is what matters; pretty-print whitespace is not.
+   */
+  @Test
+  void issue2569SampleGroupsPrettyFileByKeyAlias() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.setOutputValue("lvl1Details");
+    meta.setJsonBloc("result");
+    meta.setJsonPrettified(true);
+    meta.getOutputFields().clear();
+    JsonEOutputField field2 = new JsonEOutputField();
+    field2.setFieldName("Field2");
+    field2.setElementName("campo2");
+    JsonEOutputField field3 = new JsonEOutputField();
+    field3.setFieldName("Field3");
+    field3.setElementName("campo3");
+    meta.getOutputFields().add(field2);
+    meta.getOutputFields().add(field3);
+    JsonEOutputKeyField key = new JsonEOutputKeyField("Field1");
+    key.setElementName("recordKey");
+    meta.getKeyFields().add(key);
+
+    RowMeta input = new RowMeta();
+    input.addValueMeta(new ValueMetaString("Field1"));
+    input.addValueMeta(new ValueMetaString("Field2"));
+    input.addValueMeta(new ValueMetaInteger("Field3"));
+    try (Harness h =
+        new Harness(
+            meta,
+            input,
+            new Object[] {"A", "B", 2L},
+            new Object[] {"B", "C", 1L},
+            new Object[] {"B", "C", 2L},
+            new Object[] {"B", "D", 4L},
+            new Object[] {"C", "F", 5L},
+            new Object[] {"C", "F", 6L},
+            new Object[] {"C", "V", 6L},
+            new Object[] {"C", "B", 7L})) {
+      h.run();
+    }
+    String content = read(base + "/out.json");
+    assertTrue(content.contains("\n"), content);
+    JsonNode file = parse(content);
+    JsonNode result = file.get("result");
+    assertEquals(3, result.size());
+    assertEquals("A", result.get(0).get("recordKey").asText());
+    assertEquals("B", result.get(0).get("lvl1Details").get("campo2").asText());
+    assertEquals(2, result.get(0).get("lvl1Details").get("campo3").asInt());
+    assertFalse(result.get(0).get("lvl1Details").isArray());
+    assertEquals(3, result.get(1).get("lvl1Details").size());
+    assertEquals(4, 
result.get(1).get("lvl1Details").get(2).get("campo3").asInt());
+    assertEquals(4, result.get(2).get("lvl1Details").size());
+    assertEquals("B", 
result.get(2).get("lvl1Details").get(3).get("campo2").asText());
+    assertEquals(7, 
result.get(2).get("lvl1Details").get(3).get("campo3").asInt());
+  }
+
+  @Test
+  void ndjsonAppendEscapesEmbeddedNewlines() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.setNewlineDelimited(true);
+    meta.getFileSettings().setFileAppended(true);
+    try (Harness h = new Harness(meta, rowMeta("payload"), new Object[] 
{"a\nb"})) {
+      h.run();
+    }
+    try (Harness h = new Harness(meta, rowMeta("payload"), new Object[] 
{"c\rd"})) {
+      h.run();
+    }
+    String content = read(base + "/out.json");
+    assertEquals(payload("a\nb") + "\n" + payload("c\rd") + "\n", content);
+    List<String> lines = content.lines().toList();
+    assertEquals(2, lines.size());
+    assertEquals("a\nb", parse(lines.get(0)).get("payload").asText());
+    assertEquals("c\rd", parse(lines.get(1)).get("payload").asText());
+  }
+
+  @Test
+  void ndjsonSplitsRecordsWithoutOuterArrays() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.setNewlineDelimited(true);
+    meta.setUseArrayWithSingleInstance(true);
+    meta.getFileSettings().setSplitOutputAfter(2);
+    try (Harness h =
+        new Harness(
+            meta, rowMeta("payload"), new Object[] {"x"}, new Object[] {"y"}, 
new Object[] {"z"})) {
+      h.run();
+      assertEquals(3, h.written.size());
+    }
+    assertEquals(payload("x") + "\n" + payload("y") + "\n", read(base + 
"/out_0.json"));
+    assertEquals(payload("z") + "\n", read(base + "/out_1.json"));
+    try (FileObject extra = HopVfs.getFileObject(base + "/out_2.json")) {
+      assertFalse(extra.exists());
+    }
+  }
+
+  @Test
+  void ndjsonWrapsEachCompleteGroup() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.BOTH);
+    meta.setNewlineDelimited(true);
+    meta.setJsonBloc("data");
+    JsonEOutputKeyField key = new JsonEOutputKeyField("grp");
+    key.setElementName("group");
+    meta.getKeyFields().add(key);
+    try (Harness h =
+        new Harness(
+            meta,
+            rowMeta("grp", "payload"),
+            new Object[] {"a", "x"},
+            new Object[] {"a", "y"},
+            new Object[] {"b", "z"})) {
+      h.run();
+      assertEquals(2, h.written.size());
+    }
+    List<String> lines = read(base + "/out.json").lines().toList();
+    assertEquals(2, lines.size());
+    JsonNode first = parse(lines.get(0)).get("data");
+    JsonNode last = parse(lines.get(1)).get("data");
+    assertEquals("a", first.get("group").asText());
+    assertEquals(2, first.get("rows").size());
+    assertEquals("b", last.get("group").asText());
+    assertEquals("z", last.get("rows").get("payload").asText());
+  }
+
+  @Test
+  void ndjsonRejectsPrettyPrintingBeforeOpeningFile() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.setNewlineDelimited(true);
+    meta.setJsonPrettified(true);
+    meta.getFileSettings().setDoNotOpenNewFileInit(false);
+    try (Harness h = new Harness(meta, rowMeta("payload"), new Object[] 
{"x"})) {
+      assertFalse(h.transform.init());
+    }
+    try (FileObject file = HopVfs.getFileObject(base + "/out.json")) {
+      assertFalse(file.exists());
+    }
+  }
+
+  @Test
+  void ndjsonRejectsNonUtf8BeforeOpeningFile() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.setNewlineDelimited(true);
+    meta.setEncoding("UTF-16");
+    meta.getFileSettings().setDoNotOpenNewFileInit(false);
+    try (Harness h = new Harness(meta, rowMeta("payload"), new Object[] 
{"x"})) {
+      assertFalse(h.transform.init());
+    }
+    try (FileObject file = HopVfs.getFileObject(base + "/out.json")) {
+      assertFalse(file.exists());
+    }
+  }
+
+  @Test
+  void ndjsonRejectsAppendWithoutFinalLf() throws Exception {
+    String existing = payload("old");
+    try (FileObject folder = HopVfs.getFileObject(base)) {
+      folder.createFolder();
+    }
+    try (var out = HopVfs.getOutputStream(base + "/out.json", false)) {
+      out.write(existing.getBytes(StandardCharsets.UTF_8));
+    }
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.setNewlineDelimited(true);
+    meta.getFileSettings().setFileAppended(true);
+    try (Harness h = new Harness(meta, rowMeta("payload"), new Object[] 
{"new"})) {
+      assertTrue(h.transform.init());
+      HopException error = assertThrows(HopException.class, 
h.transform::processRow);
+      assertTrue(error.getMessage().contains("must end with LF"), 
error.getMessage());
+      assertFalse(error.getMessage().contains("Could not open file"), 
error.getMessage());
+      assertFalse(error.getMessage().contains("Error opening new file"), 
error.getMessage());
+      assertFalse(error.getMessage().contains("Error writing"), 
error.getMessage());
+      verify(h.helper.iLogChannel, never())
+          .logError(argThat((String message) -> message.contains("Could not 
open file")));
+    }
+    assertEquals(existing, read(base + "/out.json"));
+  }
+
+  @Test
+  void ndjsonAppendBoundaryIsReportedWhenTheFileIsOpenedAtInit() throws 
Exception {
+    String existing = payload("old");
+    try (FileObject folder = HopVfs.getFileObject(base)) {
+      folder.createFolder();
+    }
+    try (var out = HopVfs.getOutputStream(base + "/out.json", false)) {
+      out.write(existing.getBytes(StandardCharsets.UTF_8));
+    }
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.setNewlineDelimited(true);
+    meta.getFileSettings().setFileAppended(true);
+    meta.getFileSettings().setDoNotOpenNewFileInit(false);
+    try (Harness h = new Harness(meta, rowMeta("payload"), new Object[] 
{"new"})) {
+      assertFalse(h.transform.init());
+      verify(h.helper.iLogChannel)
+          .logError(
+              argThat((String message) -> message != null && 
message.contains("must end with LF")));
+      verify(h.helper.iLogChannel, never())
+          .logError(argThat((String message) -> message.contains("Could not 
open file")));
+      verify(h.helper.iLogChannel, never())
+          .logError(argThat((String message) -> message.contains("Error 
opening new file")));
+    }
+    assertEquals(existing, read(base + "/out.json"));
+  }
+
+  @Test
+  void disposeWritesParkedItemAndSkipsTheOpenGroup() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.getFileSettings().setDoNotOpenNewFileInit(false);
+    JsonEOutputKeyField key = new JsonEOutputKeyField("grp");
+    key.setElementName("group");
+    meta.getKeyFields().add(key);
+    try (Harness h =
+        new Harness(
+            meta, rowMeta("grp", "payload"), new Object[] {"a", "x"}, new 
Object[] {"b", "y"})) {
+      assertTrue(h.transform.init());
+      assertNotNull(h.data.writer);
+      assertTrue(h.transform.processRow());
+      assertEquals(0, h.data.fileItemCount);
+      assertTrue(h.transform.processRow());
+      assertEquals(1, h.data.fileItemCount);
+      assertNotNull(h.data.pendingFileItem);
+      assertNull(h.data.fileGenerator);
+      h.transform.dispose();
+      assertNull(h.data.writer);
+      assertNull(h.data.pendingFileItem);
+      assertEquals(0, h.data.fileItemCount);
+      assertEquals(0L, h.transform.getErrors());
+    }
+    JsonNode file = parse(read(base + "/out.json"));
+    assertEquals("a", file.get("group").asText());
+    assertEquals("x", file.get("rows").get("payload").asText());
+    assertFalse(file.toString().contains("\"b\""), file.toString());
+    assertFalse(file.toString().contains("\"y\""), file.toString());
+  }
+
+  @Test
+  void disposeClosesAFileOpenedAtInitWithNoItems() throws Exception {
+    JsonEOutputMeta meta = meta(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    meta.getFileSettings().setDoNotOpenNewFileInit(false);
+    try (Harness h = new Harness(meta, rowMeta("payload"))) {
+      assertTrue(h.transform.init());
+      assertNotNull(h.data.writer);
+      assertEquals(0, h.data.fileItemCount);
+      h.transform.dispose();
+      assertNull(h.data.writer);
+      assertEquals(0L, h.transform.getErrors());
+    }
+    assertEquals("", read(base + "/out.json"));
+  }
+
+  @Test
+  void resolvedBlockNameMatchesTheFileAndTheOutputField() throws Exception {
+    JsonEOutputMeta regular = meta(JsonEOutputMeta.OperationType.BOTH);
+    regular.setJsonBloc("${BLOC}");
+    regular.getFileSettings().setFileName(base + "/regular");
+    try (Harness h = new Harness(regular, rowMeta("payload"), new Object[] 
{"x"})) {
+      h.transform.setVariable("BLOC", "wrapper");
+      h.run();
+      assertEquals("wrapper", h.data.realBlocName);
+      JsonNode field = parse(h.written.get(0).getString("rows", null));
+      JsonNode file = parse(read(base + "/regular.json"));
+      assertEquals(field, file);
+      assertEquals("x", field.get("wrapper").get("payload").asText());
+      assertNull(field.get("${BLOC}"));
+    }
+
+    JsonEOutputMeta ndjson = meta(JsonEOutputMeta.OperationType.BOTH);
+    ndjson.setJsonBloc("${BLOC}");
+    ndjson.setNewlineDelimited(true);
+    ndjson.getFileSettings().setFileName(base + "/lines");
+    try (Harness h = new Harness(ndjson, rowMeta("payload"), new Object[] 
{"y"})) {
+      h.transform.setVariable("BLOC", "wrapper");
+      h.run();
+      JsonNode field = parse(h.written.get(0).getString("rows", null));
+      List<String> lines = read(base + "/lines.json").lines().toList();
+      assertEquals(1, lines.size());
+      assertEquals(field, parse(lines.get(0)));
+      assertEquals("y", field.get("wrapper").get("payload").asText());
+      assertNull(field.get("${BLOC}"));
+    }
+
+    JsonEOutputMeta literal = meta(JsonEOutputMeta.OperationType.BOTH);
+    literal.setJsonBloc("plain");
+    literal.getFileSettings().setFileName(base + "/literal");
+    try (Harness h = new Harness(literal, rowMeta("payload"), new Object[] 
{"z"})) {
+      h.run();
+      JsonNode field = parse(h.written.get(0).getString("rows", null));
+      assertEquals(field, parse(read(base + "/literal.json")));
+      assertEquals("z", field.get("plain").get("payload").asText());
+    }
+  }
+}
diff --git 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputDialogTest.java
 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputDialogTest.java
new file mode 100644
index 0000000000..00f4b143dc
--- /dev/null
+++ 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputDialogTest.java
@@ -0,0 +1,351 @@
+/*
+ * 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.jsonoutputenhanced;
+
+import static 
org.eclipse.swtbot.swt.finder.matchers.WidgetMatcherFactory.widgetOfType;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.utils.SWTBotPreferences;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotButton;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("uitest")
+class JsonEOutputDialogTest extends SwtBotTestBase {
+  /**
+   * Enhanced JSON output builds several tables before its event loop starts. 
SWTBot's 5 s default
+   * expires while that construction is still on the UI thread.
+   */
+  private static final long DIALOG_TIMEOUT_MS = 30_000L;
+
+  private static long defaultTimeout;
+
+  @BeforeAll
+  static void slowDownSwtBot() {
+    defaultTimeout = SWTBotPreferences.TIMEOUT;
+    SWTBotPreferences.TIMEOUT = DIALOG_TIMEOUT_MS;
+  }
+
+  @AfterAll
+  static void restoreSwtBotTimeout() {
+    SWTBotPreferences.TIMEOUT = defaultTimeout;
+  }
+
+  @BeforeAll
+  static void registerWidgets() {
+    GuiRegistry registry = GuiRegistry.getInstance();
+    Class<JsonEOutputMeta> type = JsonEOutputMeta.class;
+    for (Field field : type.getDeclaredFields()) {
+      GuiWidgetElement element = field.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        registry.addGuiWidgetElement(type.getName(), element, field);
+      }
+    }
+    for (Method method : type.getDeclaredMethods()) {
+      GuiWidgetElement element = method.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        registry.addGuiWidgetElement(element, method, type.getName(), 
type.getClassLoader());
+      }
+    }
+  }
+
+  private static TableView table(JsonEOutputDialog dialog, String name) {
+    try {
+      Field field = JsonEOutputDialog.class.getDeclaredField(name);
+      field.setAccessible(true);
+      return (TableView) field.get(dialog);
+    } catch (ReflectiveOperationException e) {
+      throw new AssertionError(e);
+    }
+  }
+
+  private static Rectangle onDisplay(Control control) {
+    Point point = control.toDisplay(0, 0);
+    Rectangle bounds = control.getBounds();
+    return new Rectangle(point.x, point.y, bounds.width, bounds.height);
+  }
+
+  /**
+   * Upstream fields come from the dialog's {@code 
getPrevTransformFields(variables, transformName)}
+   * call. The string overload is not final, so the test supplies the live row 
without the field
+   * helper from the unmerged dialog-retention pull request.
+   */
+  private PipelineMeta pipeline(JsonEOutputMeta meta, String... fieldNames) {
+    PipelineMeta pipeline =
+        new PipelineMeta() {
+          @Override
+          public IRowMeta getPrevTransformFields(IVariables variables, String 
transformName) {
+            RowMeta row = new RowMeta();
+            for (String fieldName : fieldNames) {
+              row.addValueMeta(new ValueMetaString(fieldName));
+            }
+            return row;
+          }
+        };
+    pipeline.setMetadataProvider(new MemoryMetadataProvider());
+    pipeline.addTransform(new TransformMeta("EnhancedJsonOutput", "json", 
meta));
+    return pipeline;
+  }
+
+  @Test
+  void getKeysUsesLiveSelectionsAndCancelKeepsMetadata() {
+    JsonEOutputMeta meta = new JsonEOutputMeta();
+    meta.setOperationType(JsonEOutputMeta.OperationType.OUTPUT_VALUE);
+    JsonEOutputField payload = new JsonEOutputField();
+    payload.setFieldName("payload");
+    payload.setElementName("payload");
+    meta.getOutputFields().add(payload);
+    JsonEOutputKeyField existing = new JsonEOutputKeyField("already");
+    existing.setElementName("custom_alias");
+    meta.getKeyFields().add(existing);
+    PipelineMeta pipeline = pipeline(meta, "payload", "grp", "already", 
"unsaved");
+    AtomicReference<JsonEOutputDialog> opened = new AtomicReference<>();
+    AtomicReference<List<String>> names = new AtomicReference<>();
+    AtomicReference<List<String>> aliases = new AtomicReference<>();
+    AtomicReference<Rectangle> tableBounds = new AtomicReference<>();
+    AtomicReference<Rectangle> buttonBounds = new AtomicReference<>();
+    String title = BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.DialogTitle");
+    String keyTab =
+        BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.KeyConfigTab.TabTitle");
+    String getButton =
+        BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.Get.Button");
+    withDialog(
+        parent -> {
+          opened.set(new JsonEOutputDialog(parent, new Variables(), meta, 
pipeline));
+          opened.get().open();
+        },
+        bot -> {
+          SWTBot dialog = bot.shell(title).activate().bot();
+          display.syncExec(
+              () -> {
+                TableItem item = new TableItem(table(opened.get(), 
"wFields").table, SWT.NONE);
+                item.setText(1, "unsaved");
+                item.setText(2, "unsaved_alias");
+              });
+          Composite keyTabControl = activateTab(dialog, keyTab);
+          Button getControl = findPushButton(keyTabControl, getButton);
+          new SWTBotButton(getControl).click();
+          new SWTBotButton(getControl).click();
+          var shell = bot.shell(title);
+          display.syncExec(
+              () -> {
+                TableView keys = table(opened.get(), "wKeyFields");
+                names.set(keys.getNonEmptyItems().stream().map(item -> 
item.getText(1)).toList());
+                aliases.set(keys.getNonEmptyItems().stream().map(item -> 
item.getText(2)).toList());
+                shell.widget.setSize(600, 400);
+                shell.widget.layout(true, true);
+                tableBounds.set(onDisplay(keys));
+                buttonBounds.set(onDisplay(getControl));
+              });
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+    assertEquals(List.of("already", "grp"), names.get());
+    assertEquals(List.of("custom_alias", "grp"), aliases.get());
+    assertEquals(1, meta.getKeyFields().size(), "Cancel does not persist table 
additions");
+    assertTrue(tableBounds.get().width > 0 && tableBounds.get().height > 0);
+    assertFalse(
+        tableBounds.get().intersects(buttonBounds.get()),
+        tableBounds.get() + " overlaps " + buttonBounds.get());
+  }
+
+  @Test
+  void ndjsonOptionIsSavedFromTheGroupedTab() {
+    JsonEOutputMeta meta = new JsonEOutputMeta();
+    meta.setOperationType(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    PipelineMeta pipeline = pipeline(meta, "payload");
+    String title = BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.DialogTitle");
+    String formatTab =
+        BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.FileFormat.TabTitle");
+    String ndjson = BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.NdJson.Label");
+    withDialog(
+        parent -> {
+          silenceSortWarning();
+          new JsonEOutputDialog(parent, new Variables(), meta, 
pipeline).open();
+        },
+        bot -> {
+          SWTBot dialog = bot.shell(title).activate().bot();
+          activateTab(dialog, formatTab);
+          Button checkbox = checkBoxNextTo(dialog, ndjson);
+          display.syncExec(() -> checkbox.setSelection(true));
+          dialog.button(buttonLabel("System.Button.OK")).click();
+        });
+    assertTrue(meta.isNewlineDelimited());
+  }
+
+  @Test
+  void ndjsonOptionCancelKeepsMetadata() {
+    JsonEOutputMeta meta = new JsonEOutputMeta();
+    meta.setOperationType(JsonEOutputMeta.OperationType.WRITE_TO_FILE);
+    PipelineMeta pipeline = pipeline(meta, "payload");
+    String title = BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.DialogTitle");
+    String formatTab =
+        BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.FileFormat.TabTitle");
+    String ndjson = BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.NdJson.Label");
+    withDialog(
+        parent -> new JsonEOutputDialog(parent, new Variables(), meta, 
pipeline).open(),
+        bot -> {
+          SWTBot dialog = bot.shell(title).activate().bot();
+          activateTab(dialog, formatTab);
+          Button checkbox = checkBoxNextTo(dialog, ndjson);
+          display.syncExec(() -> checkbox.setSelection(true));
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+    assertFalse(meta.isNewlineDelimited());
+  }
+
+  @Test
+  void ndjsonOptionIsDisabledForOutputValue() {
+    JsonEOutputMeta meta = new JsonEOutputMeta();
+    meta.setOperationType(JsonEOutputMeta.OperationType.OUTPUT_VALUE);
+    PipelineMeta pipeline = pipeline(meta, "payload");
+    String title = BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.DialogTitle");
+    String formatTab =
+        BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.FileFormat.TabTitle");
+    String ndjson = BaseMessages.getString(JsonEOutputMeta.class, 
"JsonEOutputDialog.NdJson.Label");
+    AtomicBoolean enabled = new AtomicBoolean(true);
+    withDialog(
+        parent -> new JsonEOutputDialog(parent, new Variables(), meta, 
pipeline).open(),
+        bot -> {
+          SWTBot dialog = bot.shell(title).activate().bot();
+          activateTab(dialog, formatTab);
+          Button checkbox = checkBoxNextTo(dialog, ndjson);
+          display.syncExec(() -> enabled.set(checkbox.getEnabled()));
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+    assertFalse(enabled.get());
+  }
+
+  private static void silenceSortWarning() {
+    
PropsUi.getInstance().setCustomParameter(JsonEOutputDialog.STRING_SORT_WARNING_PARAMETER,
 "N");
+  }
+
+  /** Annotated checkboxes keep their title on a separate label. */
+  private Button checkBoxNextTo(SWTBot dialog, String labelText) {
+    Label label = dialog.label(labelText).widget;
+    AtomicReference<Button> found = new AtomicReference<>();
+    display.syncExec(
+        () -> {
+          for (Control child : label.getParent().getChildren()) {
+            if (child instanceof Button button && (button.getStyle() & 
SWT.CHECK) != 0) {
+              found.set(button);
+              return;
+            }
+          }
+          throw new AssertionError("no checkbox next to " + labelText);
+        });
+    if (found.get() == null) {
+      throw new AssertionError("no checkbox next to " + labelText);
+    }
+    return found.get();
+  }
+
+  /**
+   * SWTBot's finder walks controls and does not see the items of a {@code 
CTabFolder}. Select the
+   * tab on the folder. The Fields tab has another button with the same Get 
Fields label, so the
+   * click stays inside this tab.
+   */
+  private Composite activateTab(SWTBot dialog, String title) {
+    CTabFolder tabFolder = (CTabFolder) 
dialog.widget(widgetOfType(CTabFolder.class));
+    AtomicReference<Composite> selected = new AtomicReference<>();
+    display.syncExec(
+        () -> {
+          List<String> titles = new ArrayList<>();
+          for (CTabItem item : tabFolder.getItems()) {
+            String itemTitle = item.getText() == null ? "" : 
item.getText().trim();
+            titles.add(itemTitle);
+            if (title.equals(itemTitle)) {
+              tabFolder.setSelection(item);
+              tabFolder.layout(true, true);
+              selected.set((Composite) item.getControl());
+              return;
+            }
+          }
+          throw new AssertionError("no tab titled " + title + " in " + titles);
+        });
+    return selected.get();
+  }
+
+  private Button findPushButton(Composite root, String message) {
+    AtomicReference<Button> found = new AtomicReference<>();
+    List<String> seen = new ArrayList<>();
+    display.syncExec(() -> found.set(searchPushButton(root, message, seen)));
+    if (found.get() == null) {
+      throw new AssertionError("no button [" + message + "] among " + seen);
+    }
+    return found.get();
+  }
+
+  private static Button searchPushButton(Control control, String message, 
List<String> seen) {
+    if (control instanceof Button button && (button.getStyle() & SWT.PUSH) != 
0) {
+      seen.add(button.getText());
+      if (sameButtonLabel(button.getText(), message)) {
+        return button;
+      }
+    }
+    if (control instanceof Composite composite) {
+      for (Control child : composite.getChildren()) {
+        Button match = searchPushButton(child, message, seen);
+        if (match != null) {
+          return match;
+        }
+      }
+    }
+    return null;
+  }
+
+  private static boolean sameButtonLabel(String widgetText, String message) {
+    String widget = widgetText == null ? "" : widgetText.replace("&", "");
+    String expected = message == null ? "" : message.replace("&", "");
+    return widget.equals(expected);
+  }
+}
diff --git 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputMetaTest.java
 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputMetaTest.java
index 9f0391fd2d..4c0e316ed1 100644
--- 
a/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputMetaTest.java
+++ 
b/plugins/transforms/json/src/test/java/org/apache/hop/pipeline/transforms/jsonoutputenhanced/JsonEOutputMetaTest.java
@@ -22,13 +22,21 @@ import static org.apache.hop.core.util.Utils.isEmpty;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Objects;
 import org.apache.hop.core.Const;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
 import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
 import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
@@ -150,4 +158,45 @@ class JsonEOutputMetaTest {
     assertEquals("f3", f3.getFieldName());
     assertEquals("element3", f3.getElementName());
   }
+
+  @Test
+  void missingGroupKeyNamesTheMissingField() {
+    JsonEOutputMeta meta = new JsonEOutputMeta();
+    meta.setOperationType(JsonEOutputMeta.OperationType.OUTPUT_VALUE);
+    meta.setKeyFields(List.of(new JsonEOutputKeyField("Field1")));
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("Field2"));
+    HopTransformException error =
+        assertThrows(
+            HopTransformException.class,
+            () ->
+                meta.getFields(
+                    row, "json", null, null, new Variables(), new 
MemoryMetadataProvider()));
+    assertTrue(error.getMessage().contains("Field1"));
+  }
+
+  @Test
+  void validationReportsAMissingGroupKey() {
+    JsonEOutputMeta meta = new JsonEOutputMeta();
+    meta.setKeyFields(List.of(new JsonEOutputKeyField("gone")));
+    RowMeta previous = new RowMeta();
+    previous.addValueMeta(new ValueMetaString("payload"));
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        null,
+        new TransformMeta("EnhancedJsonOutput", "json", meta),
+        previous,
+        new String[] {"upstream"},
+        new String[0],
+        null,
+        new Variables(),
+        new MemoryMetadataProvider());
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                result ->
+                    result.getType() == ICheckResult.TYPE_RESULT_ERROR
+                        && result.getText().contains("gone")));
+  }
 }

Reply via email to