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");
+ }
}