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

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 3374e5d9ee Add Constants hardening, fixes #2239 (#7802)
3374e5d9ee is described below

commit 3374e5d9eece5620465df699fdd312413775a995
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Aug 6 15:09:03 2026 +0200

    Add Constants hardening, fixes #2239 (#7802)
---
 .../hop/pipeline/transforms/constant/Constant.java | 118 +++--
 .../transforms/constant/ConstantDialog.java        |  37 +-
 .../constant/messages/messages_en_US.properties    |   4 +
 .../transforms/constant/ConstantDialogTest.java    | 283 ++++++++++++
 .../transforms/constant/ConstantFieldTest.java     | 162 +++++++
 .../transforms/constant/ConstantMetaTest.java      | 114 +++++
 .../pipeline/transforms/constant/ConstantTest.java | 487 ++++++++++++++++++++-
 7 files changed, 1168 insertions(+), 37 deletions(-)

diff --git 
a/plugins/transforms/constant/src/main/java/org/apache/hop/pipeline/transforms/constant/Constant.java
 
b/plugins/transforms/constant/src/main/java/org/apache/hop/pipeline/transforms/constant/Constant.java
index 0b81f7de76..ed56864a4e 100644
--- 
a/plugins/transforms/constant/src/main/java/org/apache/hop/pipeline/transforms/constant/Constant.java
+++ 
b/plugins/transforms/constant/src/main/java/org/apache/hop/pipeline/transforms/constant/Constant.java
@@ -32,6 +32,7 @@ import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.row.RowDataUtil;
 import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.core.row.value.ValueMetaFactory;
+import org.apache.hop.core.row.value.ValueMetaString;
 import org.apache.hop.core.util.StringUtil;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.i18n.BaseMessages;
@@ -57,12 +58,26 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
   public static final RowMetaAndData buildRow(
       ConstantMeta meta, ConstantData data, List<ICheckResult> remarks) {
     IRowMeta rowMeta = new RowMeta();
-    Object[] rowData = new Object[meta.getFields().size()];
+    // Collected in lockstep with rowMeta: a field that gets skipped below 
must not leave a hole
+    // in the data, or every constant after it ends up in the previous field's 
column.
+    List<Object> rowData = new ArrayList<>();
 
-    for (int i = 0; i < meta.getFields().size(); i++) {
-      ConstantField field = meta.getFields().get(i);
+    int fieldNr = 0;
+    for (ConstantField field : meta.getFields()) {
+      fieldNr++;
       int valtype = ValueMetaFactory.getIdForValueMeta(field.getFieldType());
-      if (field.getFieldName() != null) {
+      // Skip unnamed fields exactly like ConstantMeta.getFields() does. That 
method builds the
+      // transform's output row meta, so keeping a blank-named field here 
would make the constants
+      // row one value wider than the meta describing it.
+      if (StringUtils.isEmpty(field.getFieldName())) {
+        // A field that was filled in but never named can't become a column. 
Say so rather than
+        // dropping it quietly - it is nearly always a forgotten name, not a 
deliberate blank.
+        if (hasContent(field)) {
+          String message =
+              BaseMessages.getString(PKG, 
"Constant.CheckResult.NoFieldNameWarning", fieldNr);
+          remarks.add(new CheckResult(ICheckResult.TYPE_RESULT_WARNING, 
message, null));
+        }
+      } else {
         IValueMeta value = null;
         try {
           value = ValueMetaFactory.createValueMeta(field.getFieldName(), 
valtype);
@@ -74,24 +89,22 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
         value.setLength(field.getFieldLength());
         value.setPrecision(field.getFieldPrecision());
 
+        Object fieldValue = null;
         if (field.isEmptyString()) {
           // Just set empty string
-          rowData[i] = StringUtil.EMPTY_STRING;
+          fieldValue = StringUtil.EMPTY_STRING;
+        } else if (value.getType() == IValueMeta.TYPE_NONE) {
+          // No value type was selected for this field, so there's nothing to 
convert to.
+          String message =
+              BaseMessages.getString(
+                  PKG, "Constant.CheckResult.SpecifyTypeError", 
value.getName(), field.getValue());
+          remarks.add(new CheckResult(ICheckResult.TYPE_RESULT_ERROR, message, 
null));
         } else {
 
           String stringValue = field.getValue();
 
           // If the value is empty: consider it to be NULL.
-          if (Utils.isEmpty(stringValue)) {
-            rowData[i] = null;
-
-            if (value.getType() == IValueMeta.TYPE_NONE) {
-              String message =
-                  BaseMessages.getString(
-                      PKG, "Constant.CheckResult.SpecifyTypeError", 
value.getName(), stringValue);
-              remarks.add(new CheckResult(ICheckResult.TYPE_RESULT_ERROR, 
message, null));
-            }
-          } else {
+          if (!Utils.isEmpty(stringValue)) {
             switch (value.getType()) {
               case IValueMeta.TYPE_NUMBER:
                 try {
@@ -115,7 +128,7 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
                     data.df.setDecimalFormatSymbols(data.dfs);
                   }
 
-                  rowData[i] = data.nf.parse(stringValue).doubleValue();
+                  fieldValue = data.nf.parse(stringValue).doubleValue();
                 } catch (Exception e) {
                   String message =
                       BaseMessages.getString(
@@ -129,7 +142,7 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
                 break;
 
               case IValueMeta.TYPE_STRING:
-                rowData[i] = stringValue;
+                fieldValue = stringValue;
                 break;
 
               case IValueMeta.TYPE_DATE:
@@ -139,7 +152,7 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
                     data.daf.setDateFormatSymbols(data.dafs);
                   }
 
-                  rowData[i] = data.daf.parse(stringValue);
+                  fieldValue = data.daf.parse(stringValue);
                 } catch (Exception e) {
                   String message =
                       BaseMessages.getString(
@@ -154,7 +167,7 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
 
               case IValueMeta.TYPE_INTEGER:
                 try {
-                  rowData[i] = Long.valueOf(stringValue);
+                  fieldValue = Long.valueOf(stringValue);
                 } catch (Exception e) {
                   String message =
                       BaseMessages.getString(
@@ -169,7 +182,7 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
 
               case IValueMeta.TYPE_BIGNUMBER:
                 try {
-                  rowData[i] = new BigDecimal(stringValue);
+                  fieldValue = new BigDecimal(stringValue);
                 } catch (Exception e) {
                   String message =
                       BaseMessages.getString(
@@ -183,17 +196,17 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
                 break;
 
               case IValueMeta.TYPE_BOOLEAN:
-                rowData[i] =
+                fieldValue =
                     "Y".equalsIgnoreCase(stringValue) || 
"TRUE".equalsIgnoreCase(stringValue);
                 break;
 
               case IValueMeta.TYPE_BINARY:
-                rowData[i] = stringValue.getBytes();
+                fieldValue = stringValue.getBytes();
                 break;
 
               case IValueMeta.TYPE_TIMESTAMP:
                 try {
-                  rowData[i] = Timestamp.valueOf(stringValue);
+                  fieldValue = Timestamp.valueOf(stringValue);
                 } catch (Exception e) {
                   String message =
                       BaseMessages.getString(
@@ -208,7 +221,7 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
 
               case IValueMeta.TYPE_INET:
                 try {
-                  rowData[i] = InetAddress.getByName(stringValue);
+                  fieldValue = InetAddress.getByName(stringValue);
                 } catch (Exception e) {
                   String message =
                       BaseMessages.getString(
@@ -222,20 +235,51 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
                 break;
 
               default:
-                String message =
-                    BaseMessages.getString(
-                        PKG, "Constant.CheckResult.SpecifyTypeError", 
value.getName(), stringValue);
-                remarks.add(new CheckResult(ICheckResult.TYPE_RESULT_ERROR, 
message, null));
+                // Any other value type: let the value meta plugin itself do 
the conversion.
+                // This way types like JSON and UUID work without a dedicated 
case here, and any
+                // type that simply can't be built from text reports why 
instead of claiming that
+                // no type was selected.
+                try {
+                  IValueMeta stringMeta = new 
ValueMetaString(field.getFieldName());
+                  stringMeta.setConversionMask(field.getFieldFormat());
+
+                  fieldValue = value.convertData(stringMeta, stringValue);
+                } catch (Exception e) {
+                  String message =
+                      BaseMessages.getString(
+                          PKG,
+                          "Constant.BuildRow.Error.Parsing.Type",
+                          value.getTypeDesc(),
+                          value.getName(),
+                          stringValue,
+                          e.toString());
+                  remarks.add(new CheckResult(ICheckResult.TYPE_RESULT_ERROR, 
message, null));
+                }
             }
           }
         }
         // Now add value to the row!
         // This is in fact a copy from the fields row, but now with data.
         rowMeta.addValueMeta(value);
+        rowData.add(fieldValue);
       } // end if
     } // end for
 
-    return new RowMetaAndData(rowMeta, rowData);
+    return new RowMetaAndData(rowMeta, rowData.toArray());
+  }
+
+  /**
+   * Whether anything was filled in for this field. Used to tell a forgotten 
field name apart from a
+   * leftover blank row, which carries nothing and is not worth reporting.
+   */
+  private static boolean hasContent(ConstantField field) {
+    return field.isEmptyString()
+        || StringUtils.isNotEmpty(field.getValue())
+        || StringUtils.isNotEmpty(field.getFieldType())
+        || StringUtils.isNotEmpty(field.getFieldFormat())
+        || StringUtils.isNotEmpty(field.getCurrency())
+        || StringUtils.isNotEmpty(field.getDecimal())
+        || StringUtils.isNotEmpty(field.getGroup());
   }
 
   @Override
@@ -286,15 +330,21 @@ public class Constant extends BaseTransform<ConstantMeta, 
ConstantData> {
 
     if (super.init()) {
       // Create a row (constants) with all the values in it...
-      List<ICheckResult> remarks = new ArrayList<>(); // stores the errors...
+      List<ICheckResult> remarks = new ArrayList<>(); // stores the errors and 
warnings...
       data.constants = buildRow(meta, data, remarks);
-      if (remarks.isEmpty()) {
-        return true;
-      } else {
-        for (ICheckResult cr : remarks) {
+
+      // Only a genuinely unbuildable constant stops the transform. Warnings - 
a field that was
+      // filled in but never named - are logged and the transform runs without 
that field.
+      boolean initialized = true;
+      for (ICheckResult cr : remarks) {
+        if (cr.getType() == ICheckResult.TYPE_RESULT_ERROR) {
           logError(cr.getText());
+          initialized = false;
+        } else {
+          logMinimal(cr.getText());
         }
       }
+      return initialized;
     }
     return false;
   }
diff --git 
a/plugins/transforms/constant/src/main/java/org/apache/hop/pipeline/transforms/constant/ConstantDialog.java
 
b/plugins/transforms/constant/src/main/java/org/apache/hop/pipeline/transforms/constant/ConstantDialog.java
index 6a40d4937d..3bb3a7e759 100644
--- 
a/plugins/transforms/constant/src/main/java/org/apache/hop/pipeline/transforms/constant/ConstantDialog.java
+++ 
b/plugins/transforms/constant/src/main/java/org/apache/hop/pipeline/transforms/constant/ConstantDialog.java
@@ -27,6 +27,7 @@ import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.BaseMessageDialog;
 import org.apache.hop.ui.core.widget.ColumnInfo;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
@@ -206,6 +207,25 @@ public class ConstantDialog extends BaseTransformDialog {
     wFields.optWidth(true);
   }
 
+  /**
+   * Returns a comma separated list of the 1-based row numbers that hold 
something but no field
+   * name, or null when every filled-in row is named. Only rows the save would 
keep are considered:
+   * completely empty rows are dropped anyway and are not a mistake.
+   */
+  private String findUnnamedRows(int nrFields) {
+    StringBuilder rowNumbers = new StringBuilder();
+    for (int i = 0; i < nrFields; i++) {
+      TableItem item = wFields.getNonEmpty(i);
+      if (Utils.isEmpty(item.getText(1))) {
+        if (rowNumbers.length() > 0) {
+          rowNumbers.append(", ");
+        }
+        rowNumbers.append(wFields.table.indexOf(item) + 1);
+      }
+    }
+    return rowNumbers.length() == 0 ? null : rowNumbers.toString();
+  }
+
   private void cancel() {
     transformName = null;
     input.setChanged(changed);
@@ -217,11 +237,24 @@ public class ConstantDialog extends BaseTransformDialog {
       return;
     }
 
-    transformName = wTransformName.getText(); // return value
-
     int i;
 
     int nrFields = wFields.nrNonEmpty();
+
+    // A row that was filled in but never named cannot become an output field, 
so saving it would
+    // quietly lose what was typed. Point at the row instead of accepting it.
+    String unnamedRows = findUnnamedRows(nrFields);
+    if (unnamedRows != null) {
+      new BaseMessageDialog(
+              shell,
+              BaseMessages.getString(PKG, "ConstantDialog.NoFieldName.Title"),
+              BaseMessages.getString(PKG, 
"ConstantDialog.NoFieldName.Message", unnamedRows))
+          .open();
+      return;
+    }
+
+    transformName = wTransformName.getText(); // return value
+
     List<ConstantField> fields = input.getFields();
     fields.clear();
 
diff --git 
a/plugins/transforms/constant/src/main/resources/org/apache/hop/pipeline/transforms/constant/messages/messages_en_US.properties
 
b/plugins/transforms/constant/src/main/resources/org/apache/hop/pipeline/transforms/constant/messages/messages_en_US.properties
index 2f9efcb609..d5db357cec 100644
--- 
a/plugins/transforms/constant/src/main/resources/org/apache/hop/pipeline/transforms/constant/messages/messages_en_US.properties
+++ 
b/plugins/transforms/constant/src/main/resources/org/apache/hop/pipeline/transforms/constant/messages/messages_en_US.properties
@@ -24,6 +24,8 @@ Constant.BuildRow.Error.Parsing.Integer=Couldn''t parse 
Integer field [{0}] with
 Constant.BuildRow.Error.Parsing.InternetAddress=Couldn''t parse Internet 
Address field [{0}] with value [{1}] --> {2}
 Constant.BuildRow.Error.Parsing.Number=Couldn''t parse Number field [{0}] with 
value [{1}] --> {2}
 Constant.BuildRow.Error.Parsing.Timestamp=Couldn''t parse Timestamp field 
[{0}] with value [{1}] --> {2}
+Constant.BuildRow.Error.Parsing.Type=Couldn''t parse {0} field [{1}] with 
value [{2}] --> {3}
+Constant.CheckResult.NoFieldNameWarning=Constant {0} has no field name and is 
ignored. Give it a name to add it to the output rows.
 Constant.CheckResult.SpecifyTypeError=Please specify the value type of field 
[{0}] with value [{1}]
 Constant.Log.LineNr=LineNr : {0}
 Constant.Log.Wrote.Row=Wrote row {0} : {1}
@@ -35,6 +37,8 @@ ConstantDialog.Format.Column=Format
 ConstantDialog.Group.Column=Group
 ConstantDialog.Length.Column=Length
 ConstantDialog.Name.Column=Name
+ConstantDialog.NoFieldName.Message=Row(s) {0} have a value but no field 
name.\n\nA constant without a name can''t be added to the output rows. Please 
give it a name or clear the row.
+ConstantDialog.NoFieldName.Title=Missing field name
 ConstantDialog.Precision.Column=Precision
 ConstantDialog.Type.Column=Type
 ConstantDialog.Value.Column=Value
diff --git 
a/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantDialogTest.java
 
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantDialogTest.java
new file mode 100644
index 0000000000..fdf3a32b68
--- /dev/null
+++ 
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantDialogTest.java
@@ -0,0 +1,283 @@
+/*
+ * 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.constant;
+
+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.assertTrue;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Consumer;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotTable;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end SWTBot coverage for the Add Constants transform's {@link 
ConstantDialog}, kept next to
+ * the transform it exercises. The dialog runs its own blocking event loop in 
{@code open()}, so
+ * {@link SwtBotTestBase#withDialog} pumps it on the UI thread while the 
assertions drive it from a
+ * worker thread.
+ *
+ * <p>The fields grid is a Hop {@link 
org.apache.hop.ui.core.widget.TableView}, whose cell editors
+ * only materialise on click and are not addressable through SWTBot's table 
API. Cell content is
+ * therefore staged directly on the underlying SWT {@link Table} (on the UI 
thread), after which the
+ * real OK/Cancel buttons are clicked so the dialog's own {@code ok()}/{@code 
cancel()} logic is
+ * what gets exercised.
+ *
+ * <p>Tagged {@code uitest} so it is skipped on headless machines; run with 
{@code mvn -pl
+ * plugins/transforms/constant -Puitest test}.
+ */
+@Tag("uitest")
+class ConstantDialogTest extends SwtBotTestBase {
+
+  private static final String TRANSFORM_NAME = "constant";
+  private static final String DIALOG_TITLE = "Add constants";
+
+  // Resolved the way ConstantDialog resolves them, rather than hardcoding: 
these come out of the
+  // shared System.Combo.* keys and are short forms ("Y"/"N"), not the words.
+  private static final String YES = BaseMessages.getString(ConstantMeta.class, 
"System.Combo.Yes");
+  private static final String NO = BaseMessages.getString(ConstantMeta.class, 
"System.Combo.No");
+
+  // Grid layout: column 0 holds the row number, the ConstantField columns 
follow.
+  private static final int NAME_COLUMN = 1;
+  private static final int TYPE_COLUMN = 2;
+  private static final int FORMAT_COLUMN = 3;
+  private static final int LENGTH_COLUMN = 4;
+  private static final int PRECISION_COLUMN = 5;
+  private static final int CURRENCY_COLUMN = 6;
+  private static final int DECIMAL_COLUMN = 7;
+  private static final int GROUP_COLUMN = 8;
+  private static final int VALUE_COLUMN = 9;
+  private static final int EMPTY_STRING_COLUMN = 10;
+
+  @Test
+  void existingFieldsAreShownInTheGrid() {
+    ConstantField amount = new ConstantField("amount", "Number", "1234.56");
+    amount.setFieldFormat("#.##");
+    amount.setFieldLength(9);
+    amount.setFieldPrecision(2);
+    amount.setCurrency("EUR");
+    amount.setDecimal(".");
+    amount.setGroup(",");
+    ConstantMeta meta = metaWith(amount, new ConstantField("label", "String", 
"hello"));
+
+    withDialog(
+        openerFor(meta),
+        bot -> {
+          SWTBot dialog = dialogBot(bot);
+          SWTBotTable grid = dialog.table();
+
+          assertEquals("amount", grid.cell(0, NAME_COLUMN));
+          assertEquals("Number", grid.cell(0, TYPE_COLUMN));
+          assertEquals("#.##", grid.cell(0, FORMAT_COLUMN));
+          assertEquals("9", grid.cell(0, LENGTH_COLUMN));
+          assertEquals("2", grid.cell(0, PRECISION_COLUMN));
+          assertEquals("EUR", grid.cell(0, CURRENCY_COLUMN));
+          assertEquals(".", grid.cell(0, DECIMAL_COLUMN));
+          assertEquals(",", grid.cell(0, GROUP_COLUMN));
+          assertEquals("1234.56", grid.cell(0, VALUE_COLUMN));
+          assertEquals(NO, grid.cell(0, EMPTY_STRING_COLUMN));
+
+          assertEquals("label", grid.cell(1, NAME_COLUMN));
+          assertEquals("String", grid.cell(1, TYPE_COLUMN));
+          assertEquals("hello", grid.cell(1, VALUE_COLUMN));
+
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+  }
+
+  /**
+   * A JSON constant survives the round trip through the dialog. The Type 
column has always offered
+   * every registered value type, so this is the UI half of issue #2239.
+   */
+  @Test
+  void okWritesTheEditedGridBackToMeta() {
+    ConstantMeta meta = metaWith(new ConstantField("old", "String", "old 
value"));
+
+    withDialog(
+        openerFor(meta),
+        bot -> {
+          SWTBot dialog = dialogBot(bot);
+          assertEquals(TRANSFORM_NAME, dialog.text(0).getText(), "transform 
name field");
+
+          setCells(
+              dialog.table().widget,
+              Map.of(
+                  NAME_COLUMN, "payload",
+                  TYPE_COLUMN, "JSON",
+                  VALUE_COLUMN, "{\"a\":1}",
+                  LENGTH_COLUMN, "12",
+                  PRECISION_COLUMN, "3",
+                  EMPTY_STRING_COLUMN, NO));
+
+          dialog.button(buttonLabel("System.Button.OK")).click();
+        });
+
+    assertEquals(1, meta.getFields().size());
+    ConstantField saved = meta.getFields().get(0);
+    assertEquals("payload", saved.getFieldName());
+    assertEquals("JSON", saved.getFieldType());
+    assertEquals("{\"a\":1}", saved.getValue());
+    assertEquals(12, saved.getFieldLength());
+    assertEquals(3, saved.getFieldPrecision());
+    assertFalse(saved.isEmptyString());
+  }
+
+  /** Ticking "Set empty string?" forces the field to a String with no value. 
*/
+  @Test
+  void okAppliesTheSetEmptyStringFlag() {
+    ConstantMeta meta = metaWith(new ConstantField("old", "Integer", "42"));
+
+    withDialog(
+        openerFor(meta),
+        bot -> {
+          SWTBot dialog = dialogBot(bot);
+          setCells(
+              dialog.table().widget,
+              Map.of(
+                  NAME_COLUMN, "blank",
+                  TYPE_COLUMN, "Integer",
+                  VALUE_COLUMN, "42",
+                  EMPTY_STRING_COLUMN, YES));
+
+          dialog.button(buttonLabel("System.Button.OK")).click();
+        });
+
+    ConstantField saved = meta.getFields().get(0);
+    assertTrue(saved.isEmptyString());
+    assertEquals("String", saved.getFieldType(), "the empty-string flag forces 
the String type");
+    assertEquals("", saved.getValue());
+  }
+
+  /** A non-numeric length or precision falls back to -1 rather than failing 
the dialog. */
+  @Test
+  void okFallsBackToUnsetLengthAndPrecision() {
+    ConstantMeta meta = metaWith(new ConstantField("old", "String", "old 
value"));
+
+    withDialog(
+        openerFor(meta),
+        bot -> {
+          SWTBot dialog = dialogBot(bot);
+          setCells(
+              dialog.table().widget,
+              Map.of(
+                  NAME_COLUMN, "text",
+                  TYPE_COLUMN, "String",
+                  VALUE_COLUMN, "value",
+                  LENGTH_COLUMN, "not a number",
+                  PRECISION_COLUMN, ""));
+
+          dialog.button(buttonLabel("System.Button.OK")).click();
+        });
+
+    ConstantField saved = meta.getFields().get(0);
+    assertEquals(-1, saved.getFieldLength());
+    assertEquals(-1, saved.getFieldPrecision());
+  }
+
+  /**
+   * A row that was filled in but never named can't become an output field, so 
accepting the save
+   * would quietly lose what was typed. The dialog has to say so and stay open 
instead.
+   */
+  @Test
+  void okRefusesToSaveARowWithoutAFieldName() {
+    ConstantMeta meta = metaWith(new ConstantField("kept", "String", "keep 
me"));
+
+    withDialog(
+        openerFor(meta),
+        bot -> {
+          SWTBot dialog = dialogBot(bot);
+          setCells(dialog.table().widget, Map.of(NAME_COLUMN, "", 
VALUE_COLUMN, "no name typed"));
+
+          dialog.button(buttonLabel("System.Button.OK")).click();
+
+          // The save is refused with an explanation and the dialog stays open.
+          SWTBot warning = bot.shell("Missing field name").activate().bot();
+          warning.button(buttonLabel("System.Button.OK")).click();
+          dialogBot(bot).button(buttonLabel("System.Button.Cancel")).click();
+        });
+
+    assertEquals(1, meta.getFields().size());
+    assertEquals("kept", meta.getFields().get(0).getFieldName(), "nothing may 
have been saved");
+  }
+
+  @Test
+  void cancelLeavesMetaUntouched() {
+    ConstantMeta meta = metaWith(new ConstantField("keep", "String", "keep 
me"));
+
+    withDialog(
+        openerFor(meta),
+        bot -> {
+          SWTBot dialog = dialogBot(bot);
+          setCells(
+              dialog.table().widget,
+              Map.of(NAME_COLUMN, "discarded", VALUE_COLUMN, "discarded 
value"));
+
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+
+    assertEquals(1, meta.getFields().size());
+    assertEquals("keep", meta.getFields().get(0).getFieldName());
+    assertEquals("keep me", meta.getFields().get(0).getValue());
+  }
+
+  private SWTBot dialogBot(SWTBot bot) {
+    return bot.shell(DIALOG_TITLE).activate().bot();
+  }
+
+  private Consumer<Shell> openerFor(ConstantMeta meta) {
+    PipelineMeta pipelineMeta = pipelineWith(meta);
+    return parent -> new ConstantDialog(parent, new Variables(), meta, 
pipelineMeta).open();
+  }
+
+  /**
+   * Stages cell text on the grid's first row from the UI thread. The dialog's 
TableView installs
+   * its editors on click, so there is no SWTBot path to type into a cell; 
writing the item text
+   * leaves the same state the editors would, and {@code ok()} then reads it 
for real.
+   */
+  private void setCells(Table table, Map<Integer, String> valuesByColumn) {
+    display.syncExec(
+        () -> valuesByColumn.forEach((column, value) -> 
table.getItem(0).setText(column, value)));
+  }
+
+  private static ConstantMeta metaWith(ConstantField... fields) {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().addAll(Arrays.asList(fields));
+    return meta;
+  }
+
+  private static PipelineMeta pipelineWith(ConstantMeta meta) {
+    String pluginId = 
PluginRegistry.getInstance().getPluginId(TransformPluginType.class, meta);
+    assertNotNull(pluginId, "Add Constants transform must be registered via 
HopEnvironment.init()");
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.addTransform(new TransformMeta(pluginId, TRANSFORM_NAME, 
meta));
+    return pipelineMeta;
+  }
+}
diff --git 
a/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantFieldTest.java
 
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantFieldTest.java
new file mode 100644
index 0000000000..ea7d427e55
--- /dev/null
+++ 
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantFieldTest.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.constant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * {@link ConstantField} is a plain metadata holder, but its {@code 
equals}/{@code hashCode} back
+ * both the serialization round-trip test and the "has this transform 
changed?" checks in the GUI,
+ * so they are worth pinning down.
+ */
+class ConstantFieldTest {
+
+  @Test
+  void testValueConstructorSetsNameTypeAndValue() {
+    ConstantField field = new ConstantField("name", "String", "value");
+
+    assertEquals("name", field.getFieldName());
+    assertEquals("String", field.getFieldType());
+    assertEquals("value", field.getValue());
+    assertFalse(field.isEmptyString());
+  }
+
+  @Test
+  void testEmptyStringConstructorClearsTheValue() {
+    ConstantField field = new ConstantField("name", "String", true);
+
+    assertEquals("name", field.getFieldName());
+    assertEquals("String", field.getFieldType());
+    assertTrue(field.isEmptyString());
+    assertEquals("", field.getValue(), "the empty-string flag replaces any 
value");
+  }
+
+  @Test
+  void testAccessorsRoundTrip() {
+    ConstantField field = new ConstantField();
+
+    field.setFieldName("name");
+    field.setFieldType("Number");
+    field.setFieldFormat("#.##");
+    field.setFieldLength(9);
+    field.setFieldPrecision(2);
+    field.setValue("1.23");
+    field.setCurrency("EUR");
+    field.setDecimal(".");
+    field.setGroup(",");
+    field.setEmptyString(true);
+
+    assertEquals("name", field.getFieldName());
+    assertEquals("Number", field.getFieldType());
+    assertEquals("#.##", field.getFieldFormat());
+    assertEquals(9, field.getFieldLength());
+    assertEquals(2, field.getFieldPrecision());
+    assertEquals("1.23", field.getValue());
+    assertEquals("EUR", field.getCurrency());
+    assertEquals(".", field.getDecimal());
+    assertEquals(",", field.getGroup());
+    assertTrue(field.isEmptyString());
+  }
+
+  @Test
+  void testEqualFieldsShareAHashCode() {
+    ConstantField field = fullyPopulated();
+    ConstantField same = fullyPopulated();
+
+    assertEquals(field, same);
+    assertEquals(field.hashCode(), same.hashCode());
+    assertEquals(field, field, "a field equals itself");
+  }
+
+  @Test
+  void testFieldsDifferingInAnyPropertyAreNotEqual() {
+    assertNotEquals(fullyPopulated(), withName("other"));
+    assertNotEquals(fullyPopulated(), withType("Integer"));
+    assertNotEquals(fullyPopulated(), withValue("other"));
+
+    ConstantField differentFormat = fullyPopulated();
+    differentFormat.setFieldFormat("0.0");
+    assertNotEquals(fullyPopulated(), differentFormat);
+
+    ConstantField differentLength = fullyPopulated();
+    differentLength.setFieldLength(1);
+    assertNotEquals(fullyPopulated(), differentLength);
+
+    ConstantField differentPrecision = fullyPopulated();
+    differentPrecision.setFieldPrecision(1);
+    assertNotEquals(fullyPopulated(), differentPrecision);
+
+    ConstantField differentCurrency = fullyPopulated();
+    differentCurrency.setCurrency("USD");
+    assertNotEquals(fullyPopulated(), differentCurrency);
+
+    ConstantField differentDecimal = fullyPopulated();
+    differentDecimal.setDecimal(",");
+    assertNotEquals(fullyPopulated(), differentDecimal);
+
+    ConstantField differentGroup = fullyPopulated();
+    differentGroup.setGroup(".");
+    assertNotEquals(fullyPopulated(), differentGroup);
+
+    ConstantField differentEmptyString = fullyPopulated();
+    differentEmptyString.setEmptyString(true);
+    assertNotEquals(fullyPopulated(), differentEmptyString);
+  }
+
+  @Test
+  void testNotEqualToNullOrOtherTypes() {
+    ConstantField field = fullyPopulated();
+
+    assertNotEquals(null, field);
+    assertNotEquals("not a ConstantField", field);
+  }
+
+  private static ConstantField fullyPopulated() {
+    ConstantField field = new ConstantField("name", "Number", "1.23");
+    field.setFieldFormat("#.##");
+    field.setFieldLength(9);
+    field.setFieldPrecision(2);
+    field.setCurrency("EUR");
+    field.setDecimal(".");
+    field.setGroup(",");
+    return field;
+  }
+
+  private static ConstantField withName(String name) {
+    ConstantField field = fullyPopulated();
+    field.setFieldName(name);
+    return field;
+  }
+
+  private static ConstantField withType(String type) {
+    ConstantField field = fullyPopulated();
+    field.setFieldType(type);
+    return field;
+  }
+
+  private static ConstantField withValue(String value) {
+    ConstantField field = fullyPopulated();
+    field.setValue(value);
+    return field;
+  }
+}
diff --git 
a/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantMetaTest.java
 
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantMetaTest.java
index b8e524a575..19ab7f73d1 100644
--- 
a/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantMetaTest.java
+++ 
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantMetaTest.java
@@ -17,6 +17,9 @@
 
 package org.apache.hop.pipeline.transforms.constant;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -26,9 +29,17 @@ import java.util.Random;
 import java.util.UUID;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+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.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.pipeline.transforms.loadsave.LoadSaveTester;
 import org.apache.hop.pipeline.transforms.loadsave.initializer.IInitializer;
 import 
org.apache.hop.pipeline.transforms.loadsave.validator.IFieldLoadSaveValidator;
@@ -141,6 +152,109 @@ class ConstantMetaTest implements 
IInitializer<ConstantMeta> {
     loadSaveTester.testSerialization();
   }
 
+  @Test
+  void testGetFieldsAddsAValueMetaPerField() throws Exception {
+    ConstantMeta meta = new ConstantMeta();
+    ConstantField field = new ConstantField("amount", "Number", "1");
+    field.setFieldLength(9);
+    field.setFieldPrecision(2);
+    field.setFieldFormat("#.##");
+    meta.getFields().add(field);
+
+    IRowMeta rowMeta = new RowMeta();
+    meta.getFields(rowMeta, "the transform", null, null, new Variables(), 
null);
+
+    assertEquals(1, rowMeta.size());
+    IValueMeta valueMeta = rowMeta.getValueMeta(0);
+    assertEquals("amount", valueMeta.getName());
+    assertEquals(IValueMeta.TYPE_NUMBER, valueMeta.getType());
+    assertEquals(9, valueMeta.getLength());
+    assertEquals(2, valueMeta.getPrecision());
+    assertEquals("#.##", valueMeta.getConversionMask());
+    assertEquals("the transform", valueMeta.getOrigin());
+  }
+
+  /** Unnamed fields are placeholders in the dialog's grid and must not reach 
the output row. */
+  @Test
+  void testGetFieldsSkipsFieldsWithoutAName() throws Exception {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().add(new ConstantField(null, "String", "no name"));
+    meta.getFields().add(new ConstantField("", "String", "empty name"));
+    meta.getFields().add(new ConstantField("kept", "String", "value"));
+
+    IRowMeta rowMeta = new RowMeta();
+    meta.getFields(rowMeta, "the transform", null, null, new Variables(), 
null);
+
+    assertEquals(1, rowMeta.size());
+    assertEquals("kept", rowMeta.getValueMeta(0).getName());
+  }
+
+  /** A field whose type was never chosen still produces a column, typed as 
String. */
+  @Test
+  void testGetFieldsDefaultsAnUnsetTypeToString() throws Exception {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().add(new ConstantField("untyped", "", "value"));
+
+    IRowMeta rowMeta = new RowMeta();
+    meta.getFields(rowMeta, "the transform", null, null, new Variables(), 
null);
+
+    assertEquals(IValueMeta.TYPE_STRING, rowMeta.getValueMeta(0).getType());
+  }
+
+  @Test
+  void testCheckReportsReceivedFields() {
+    List<ICheckResult> remarks = new ArrayList<>();
+    IRowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("incoming"));
+
+    check(remarks, prev, new ConstantField("string", "String", "value"));
+
+    assertEquals(1, remarks.size());
+    assertEquals(ICheckResult.TYPE_RESULT_OK, remarks.get(0).getType());
+  }
+
+  @Test
+  void testCheckReportsMissingInputFields() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    check(remarks, new RowMeta(), new ConstantField("string", "String", 
"value"));
+
+    assertEquals(1, remarks.size());
+    assertEquals(ICheckResult.TYPE_RESULT_ERROR, remarks.get(0).getType());
+  }
+
+  /** check() also surfaces the per-field problems that would otherwise only 
fail at runtime. */
+  @Test
+  void testCheckReportsUnbuildableFields() {
+    List<ICheckResult> remarks = new ArrayList<>();
+    IRowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("incoming"));
+
+    check(remarks, prev, new ConstantField("integer", "Integer", "not a 
number"));
+
+    assertEquals(2, remarks.size(), "the fields-received remark plus the 
unparsable field");
+    assertEquals(ICheckResult.TYPE_RESULT_ERROR, remarks.get(1).getType());
+  }
+
+  @Test
+  void testCloneCopiesTheFields() {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().add(new ConstantField("string", "String", "value"));
+
+    ConstantMeta clone = (ConstantMeta) meta.clone();
+
+    assertNotSame(meta, clone);
+    assertEquals(meta.getFields(), clone.getFields());
+  }
+
+  private static void check(List<ICheckResult> remarks, IRowMeta prev, 
ConstantField... fields) {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().addAll(Arrays.asList(fields));
+    TransformMeta transformMeta = new TransformMeta("Constant", "constant", 
meta);
+    meta.check(
+        remarks, new PipelineMeta(), transformMeta, prev, null, null, null, 
new Variables(), null);
+  }
+
   public class ConstantFieldLoadSaveValidator implements 
IFieldLoadSaveValidator<ConstantField> {
     final Random rand = new Random();
 
diff --git 
a/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantTest.java
 
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantTest.java
index c6a84d4806..231bdba821 100644
--- 
a/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantTest.java
+++ 
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantTest.java
@@ -17,18 +17,39 @@
 
 package org.apache.hop.pipeline.transforms.constant;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import java.math.BigDecimal;
+import java.net.InetAddress;
+import java.sql.Timestamp;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.RowMetaAndData;
 import org.apache.hop.core.exception.HopPluginException;
 import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaFactory;
 import org.apache.hop.core.row.value.ValueMetaPluginType;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
 import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
 import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
 import org.junit.jupiter.api.AfterEach;
@@ -36,6 +57,7 @@ import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 
 class ConstantTest {
@@ -89,7 +111,7 @@ class ConstantTest {
   }
 
   @Test
-  void testProcessRow_fail() throws Exception {
+  void testProcessRowFail() throws Exception {
 
     doReturn(null).when(constantSpy).getRow();
     doReturn(null).when(constantSpy).getInputRowMeta();
@@ -97,4 +119,467 @@ class ConstantTest {
     boolean success = constantSpy.processRow();
     assertFalse(success);
   }
+
+  /**
+   * The dialog offers every registered value type, so buildRow() has to cope 
with types it has no
+   * dedicated case for. Types the value meta plugin can build from a string 
have to work.
+   */
+  @Test
+  void testBuildRowSupportsJsonType() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row = buildRow(remarks, new ConstantField("json", "JSON", 
"{\"a\":1}"));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals(IValueMeta.TYPE_JSON, 
row.getRowMeta().getValueMeta(0).getType());
+    JsonNode json = assertInstanceOf(JsonNode.class, row.getData()[0]);
+    assertEquals(1, json.get("a").asInt());
+  }
+
+  /** A value that isn't valid for the selected type is reported against that 
type. */
+  @Test
+  void testBuildRowReportsUnparsableValueForJsonType() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, new ConstantField("json", "JSON", "this is not json"));
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("JSON"), textOf(remarks));
+  }
+
+  /**
+   * Types that can't be built from text at all have to say so, instead of 
claiming that no type was
+   * selected (issue #2239).
+   */
+  @Test
+  void testBuildRowReportsUnsupportedTypeByName() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, new ConstantField("avro", "Avro Record", "some value"));
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("Avro Record"), textOf(remarks));
+    assertFalse(textOf(remarks).contains("specify the value type"), 
textOf(remarks));
+  }
+
+  /**
+   * The dialog's Type column is populated from {@code 
ValueMetaFactory.getValueMetaNames()}, so
+   * every name it offers has to reach buildRow() as a real type selection. 
Answering "please
+   * specify the value type" for a type the user did pick is the bug behind 
issue #2239, and it
+   * comes back the moment a new value meta plugin is registered - hence 
driving this off the
+   * factory rather than a hardcoded list.
+   */
+  @Test
+  void testNoTypeOfferedByTheDialogIsTreatedAsUnset() {
+    for (String typeName : ValueMetaFactory.getValueMetaNames()) {
+      List<ICheckResult> remarks = new ArrayList<>();
+
+      buildRow(remarks, new ConstantField("field", typeName, "1"));
+
+      assertFalse(
+          textOf(remarks).contains("specify the value type"),
+          "the dialog offers type '"
+              + typeName
+              + "' but buildRow() reports it as if no type was selected");
+    }
+  }
+
+  /** An actually missing type still asks the user to pick one. */
+  @Test
+  void testBuildRowWithoutTypeAsksForType() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row = buildRow(remarks, new ConstantField("notyped", "", 
"some value"));
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("specify the value type"), 
textOf(remarks));
+    assertNull(row.getData()[0]);
+  }
+
+  /** The same, for a field that has neither a type nor a value. */
+  @Test
+  void testBuildRowWithoutTypeOrValueAsksForType() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, new ConstantField("notyped", "", ""));
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("specify the value type"), 
textOf(remarks));
+  }
+
+  /** The types that have a dedicated case in buildRow() keep working. */
+  @Test
+  void testBuildRowSupportsBuiltInTypes() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row =
+        buildRow(
+            remarks,
+            new ConstantField("string", "String", "a value"),
+            new ConstantField("integer", "Integer", "42"),
+            new ConstantField("boolean", "Boolean", "Y"),
+            new ConstantField("empty", "String", ""));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals("a value", row.getData()[0]);
+    assertEquals(42L, row.getData()[1]);
+    assertEquals(Boolean.TRUE, row.getData()[2]);
+    assertNull(row.getData()[3]);
+  }
+
+  @Test
+  void testBuildRowParsesNumberWithoutFormat() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    // No format/decimal/group/currency set, so buildRow parses with the plain 
NumberFormat.
+    // "42" is locale independent, unlike anything with a decimal or grouping 
separator.
+    RowMetaAndData row = buildRow(remarks, new ConstantField("number", 
"Number", "42"));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals(42.0d, row.getData()[0]);
+  }
+
+  @Test
+  void testBuildRowParsesNumberWithFormatDecimalGroupAndCurrency() {
+    ConstantField number = new ConstantField("number", "Number", "1.234,56");
+    number.setFieldFormat("#,##0.00");
+    number.setDecimal(",");
+    number.setGroup(".");
+    number.setCurrency("EUR");
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row = buildRow(remarks, number);
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals(1234.56d, row.getData()[0]);
+  }
+
+  @Test
+  void testBuildRowReportsUnparsableNumber() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, new ConstantField("number", "Number", "not a number"));
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("Number"), textOf(remarks));
+  }
+
+  @Test
+  void testBuildRowParsesDateWithFormat() throws Exception {
+    ConstantField date = new ConstantField("date", "Date", "2026/08/06");
+    date.setFieldFormat("yyyy/MM/dd");
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row = buildRow(remarks, date);
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    Date parsed = assertInstanceOf(Date.class, row.getData()[0]);
+    // Compare on the formatted value: the parsed Date is midnight in the 
default time zone, so
+    // asserting an absolute epoch would make the test depend on where it runs.
+    assertEquals("2026/08/06", new 
SimpleDateFormat("yyyy/MM/dd").format(parsed));
+  }
+
+  @Test
+  void testBuildRowReportsUnparsableDate() {
+    ConstantField date = new ConstantField("date", "Date", "not a date");
+    date.setFieldFormat("yyyy/MM/dd");
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, date);
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("Date"), textOf(remarks));
+  }
+
+  @Test
+  void testBuildRowReportsUnparsableInteger() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, new ConstantField("integer", "Integer", "4.2"));
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("Integer"), textOf(remarks));
+  }
+
+  @Test
+  void testBuildRowParsesBigNumber() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row =
+        buildRow(remarks, new ConstantField("big", "BigNumber", 
"123456789.123456789"));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals(new BigDecimal("123456789.123456789"), row.getData()[0]);
+  }
+
+  @Test
+  void testBuildRowReportsUnparsableBigNumber() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, new ConstantField("big", "BigNumber", "not a number"));
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("BigNumber"), textOf(remarks));
+  }
+
+  @Test
+  void testBuildRowParsesBooleanValues() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row =
+        buildRow(
+            remarks,
+            new ConstantField("yes", "Boolean", "Y"),
+            new ConstantField("true", "Boolean", "true"),
+            new ConstantField("no", "Boolean", "N"),
+            new ConstantField("other", "Boolean", "whatever"));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals(Boolean.TRUE, row.getData()[0]);
+    assertEquals(Boolean.TRUE, row.getData()[1], "TRUE is accepted next to Y");
+    assertEquals(Boolean.FALSE, row.getData()[2]);
+    assertEquals(Boolean.FALSE, row.getData()[3], "anything that isn't Y/TRUE 
is false");
+  }
+
+  @Test
+  void testBuildRowParsesBinary() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row = buildRow(remarks, new ConstantField("binary", 
"Binary", "hop"));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertArrayEquals("hop".getBytes(), (byte[]) row.getData()[0]);
+  }
+
+  @Test
+  void testBuildRowParsesTimestamp() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row =
+        buildRow(remarks, new ConstantField("ts", "Timestamp", "2026-08-06 
10:11:12.0"));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals(Timestamp.valueOf("2026-08-06 10:11:12.0"), row.getData()[0]);
+  }
+
+  @Test
+  void testBuildRowReportsUnparsableTimestamp() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, new ConstantField("ts", "Timestamp", "not a timestamp"));
+
+    assertEquals(1, remarks.size());
+    assertTrue(textOf(remarks).contains("Timestamp"), textOf(remarks));
+  }
+
+  @Test
+  void testBuildRowParsesInternetAddress() throws Exception {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    // A literal address, so resolving it never touches DNS and the test stays 
hermetic.
+    RowMetaAndData row =
+        buildRow(remarks, new ConstantField("ip", "Internet Address", 
"127.0.0.1"));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals(InetAddress.getByName("127.0.0.1"), row.getData()[0]);
+  }
+
+  /** "Set empty string?" wins over the value, for every type. */
+  @Test
+  void testBuildRowSetEmptyStringOverridesValue() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row =
+        buildRow(
+            remarks,
+            new ConstantField("empty", "String", true),
+            new ConstantField("ignored", "Integer", true));
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals("", row.getData()[0]);
+    assertEquals("", row.getData()[1], "the empty-string flag is applied 
before the type switch");
+  }
+
+  /** Fields carry their length and precision into the generated row meta. */
+  @Test
+  void testBuildRowAppliesLengthAndPrecision() {
+    ConstantField field = new ConstantField("number", "Number", "42");
+    field.setFieldLength(12);
+    field.setFieldPrecision(3);
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row = buildRow(remarks, field);
+
+    assertEquals(12, row.getRowMeta().getValueMeta(0).getLength());
+    assertEquals(3, row.getRowMeta().getValueMeta(0).getPrecision());
+  }
+
+  /** A field without a name contributes nothing to the generated row meta, 
but is reported. */
+  @Test
+  void testBuildRowSkipsFieldWithoutName() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row =
+        buildRow(remarks, new ConstantField(null, "String", "orphan value"), 
namedField());
+
+    assertEquals(1, row.getRowMeta().size(), "only the named field makes it 
into the row meta");
+    assertEquals("named", row.getRowMeta().getValueMeta(0).getName());
+    assertEquals(1, row.getData().length, "the skipped field must not leave a 
hole in the data");
+    assertEquals("value", row.getData()[0]);
+
+    assertEquals(1, remarks.size(), "the dropped field has to be reported");
+    assertEquals(ICheckResult.TYPE_RESULT_WARNING, remarks.get(0).getType());
+    assertTrue(textOf(remarks).contains("Constant 1"), textOf(remarks));
+  }
+
+  /**
+   * A field that was filled in but never named can't become a column, and 
quietly dropping it hides
+   * what is nearly always a forgotten name. It is reported as a warning 
rather than an error so an
+   * existing pipeline carrying one still runs.
+   */
+  @Test
+  void testBuildRowWarnsAboutAFilledInFieldWithoutAName() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    buildRow(remarks, namedField(), new ConstantField("", "String", "forgot 
the name"));
+
+    assertEquals(1, remarks.size());
+    assertEquals(ICheckResult.TYPE_RESULT_WARNING, remarks.get(0).getType());
+    assertTrue(
+        textOf(remarks).contains("Constant 2"), "the warning names the row: " 
+ textOf(remarks));
+  }
+
+  /** A blank leftover row carries nothing, so there is no mistake to report. 
*/
+  @Test
+  void testBuildRowIsSilentAboutAnEntirelyEmptyField() {
+    List<ICheckResult> remarks = new ArrayList<>();
+
+    RowMetaAndData row = buildRow(remarks, new ConstantField("", "", ""), 
namedField());
+
+    assertTrue(remarks.isEmpty(), textOf(remarks));
+    assertEquals(1, row.getRowMeta().size());
+  }
+
+  /** The warning is logged, but an unnamed field does not stop the transform 
from starting. */
+  @Test
+  void testInitSucceedsButReportsAFieldWithoutAName() {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().add(new ConstantField("", "String", "forgot the name"));
+    meta.getFields().add(new ConstantField("kept", "String", "kept value"));
+    ConstantData data = new ConstantData();
+
+    assertTrue(newConstant(meta, data).init(), "a warning must not stop the 
transform");
+
+    assertEquals(1, data.getConstants().getRowMeta().size());
+    assertEquals("kept value", data.getConstants().getData()[0]);
+  }
+
+  /**
+   * A blank (but non-null) name has to be skipped exactly like a null one. 
The transform's output
+   * row meta comes from ConstantMeta.getFields(), which drops null 
<em>and</em> empty names, so a
+   * constants row that keeps blank-named fields is one value longer than the 
meta describing it and
+   * every later constant lands in the wrong column.
+   */
+  @Test
+  void testBuildRowSkipsFieldWithBlankNameLikeTheOutputRowMetaDoes() throws 
Exception {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().add(new ConstantField("", "String", "blank name"));
+    meta.getFields().add(new ConstantField("kept", "String", "kept value"));
+
+    RowMetaAndData constants = Constant.buildRow(meta, new ConstantData(), new 
ArrayList<>());
+    IRowMeta outputRowMeta = new RowMeta();
+    meta.getFields(outputRowMeta, "constant", null, null, new Variables(), 
null);
+
+    assertEquals(outputRowMeta.size(), constants.getRowMeta().size());
+    assertEquals(
+        outputRowMeta.size(),
+        constants.getData().length,
+        "the constants row must be exactly as wide as the row meta describing 
it");
+    assertEquals("kept value", constants.getData()[0]);
+  }
+
+  /** The end-to-end symptom of the above: a constant landing in a different 
field's column. */
+  @Test
+  void testProcessRowPutsEachConstantInItsOwnColumn() throws Exception {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().add(new ConstantField("", "String", "blank name"));
+    meta.getFields().add(new ConstantField("kept", "String", "kept value"));
+    ConstantData data = new ConstantData();
+
+    Constant transform = Mockito.spy(newConstant(meta, data));
+    assertTrue(transform.init());
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("in"));
+    doReturn(new Object[] {"input"}).when(transform).getRow();
+    doReturn(inputRowMeta).when(transform).getInputRowMeta();
+
+    ArgumentCaptor<IRowMeta> rowMetaCaptor = 
ArgumentCaptor.forClass(IRowMeta.class);
+    ArgumentCaptor<Object[]> rowCaptor = 
ArgumentCaptor.forClass(Object[].class);
+    doNothing().when(transform).putRow(rowMetaCaptor.capture(), 
rowCaptor.capture());
+
+    assertTrue(transform.processRow());
+
+    IRowMeta outputRowMeta = rowMetaCaptor.getValue();
+    Object[] outputRow = rowCaptor.getValue();
+    assertEquals("input", outputRowMeta.getString(outputRow, "in", null));
+    assertEquals("kept value", outputRowMeta.getString(outputRow, "kept", 
null));
+  }
+
+  @Test
+  void testInitBuildsTheConstantsRow() {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().add(new ConstantField("string", "String", "a value"));
+    ConstantData data = new ConstantData();
+
+    assertTrue(newConstant(meta, data).init());
+
+    assertEquals("a value", data.getConstants().getData()[0]);
+  }
+
+  /** A field that can't be built fails init() rather than starting with a 
broken row. */
+  @Test
+  void testInitFailsWhenAFieldCannotBeBuilt() {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().add(new ConstantField("integer", "Integer", "not a 
number"));
+
+    assertFalse(newConstant(meta, new ConstantData()).init());
+  }
+
+  @Test
+  void testProcessRowAppendsConstantsToTheInputRow() throws Exception {
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("in"));
+
+    mockHelper.iTransformData.firstRow = true;
+    doReturn(new Object[] {"input"}).when(constantSpy).getRow();
+    doReturn(inputRowMeta).when(constantSpy).getInputRowMeta();
+    doReturn(new Object[] {"constant"}).when(rowMetaAndData).getData();
+    doReturn(true).when(constantSpy).isRowLevel();
+
+    assertTrue(constantSpy.processRow());
+
+    // firstRow is cleared after the output meta has been derived from the 
input row meta.
+    assertFalse(mockHelper.iTransformData.firstRow);
+    assertNotNull(mockHelper.iTransformData.outputMeta);
+  }
+
+  private static ConstantField namedField() {
+    return new ConstantField("named", "String", "value");
+  }
+
+  private Constant newConstant(ConstantMeta meta, ConstantData data) {
+    return new Constant(
+        mockHelper.transformMeta, meta, data, 0, mockHelper.pipelineMeta, 
mockHelper.pipeline);
+  }
+
+  private static RowMetaAndData buildRow(List<ICheckResult> remarks, 
ConstantField... fields) {
+    ConstantMeta meta = new ConstantMeta();
+    meta.getFields().addAll(Arrays.asList(fields));
+    return Constant.buildRow(meta, new ConstantData(), remarks);
+  }
+
+  private static String textOf(List<ICheckResult> remarks) {
+    return remarks.stream().map(ICheckResult::getText).reduce("", (a, b) -> a 
+ b + "\n");
+  }
 }

Reply via email to