From a6ae8a36b099dcbdf7b16d159eb20e6e256961a4 Mon Sep 17 00:00:00 2001 From: Hans Van Akelyen Date: Thu, 6 Aug 2026 12:38:10 +0200 Subject: [PATCH] Add Constants hardening, fixes #2239 --- .../transforms/constant/Constant.java | 118 +++-- .../transforms/constant/ConstantDialog.java | 37 +- .../messages/messages_en_US.properties | 4 + .../constant/ConstantDialogTest.java | 283 ++++++++++ .../constant/ConstantFieldTest.java | 162 ++++++ .../transforms/constant/ConstantMetaTest.java | 114 ++++ .../transforms/constant/ConstantTest.java | 487 +++++++++++++++++- 7 files changed, 1168 insertions(+), 37 deletions(-) create mode 100644 plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantDialogTest.java create mode 100644 plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantFieldTest.java 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 0b81f7de763..ed56864a4e8 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.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 Constant( public static final RowMetaAndData buildRow( ConstantMeta meta, ConstantData data, List 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 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 static final RowMetaAndData buildRow( 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 static final RowMetaAndData buildRow( 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 static final RowMetaAndData buildRow( break; case IValueMeta.TYPE_STRING: - rowData[i] = stringValue; + fieldValue = stringValue; break; case IValueMeta.TYPE_DATE: @@ -139,7 +152,7 @@ public static final RowMetaAndData buildRow( 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 static final RowMetaAndData buildRow( 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 static final RowMetaAndData buildRow( 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 static final RowMetaAndData buildRow( 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 static final RowMetaAndData buildRow( 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 static final RowMetaAndData buildRow( 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 boolean init() { if (super.init()) { // Create a row (constants) with all the values in it... - List remarks = new ArrayList<>(); // stores the errors... + List 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 6a40d4937d4..3bb3a7e7594 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.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 void getData() { 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 @@ private void ok() { 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 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 2f9efcb6097..d5db357cece 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 00000000000..fdf3a32b689 --- /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. + * + *

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. + * + *

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 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 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 00000000000..ea7d427e557 --- /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 b8e524a5759..19ab7f73d1d 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.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 @@ void testSerialization() throws HopException { 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 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 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 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 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 { 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 c6a84d48066..231bdba8218 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.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 @@ void testProcessRowSuccess() throws Exception { } @Test - void testProcessRow_fail() throws Exception { + void testProcessRowFail() throws Exception { doReturn(null).when(constantSpy).getRow(); doReturn(null).when(constantSpy).getInputRowMeta(); @@ -97,4 +119,467 @@ void testProcessRow_fail() throws Exception { 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 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 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 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 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 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 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 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 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 remarks = new ArrayList<>(); + + RowMetaAndData row = buildRow(remarks, number); + + assertTrue(remarks.isEmpty(), textOf(remarks)); + assertEquals(1234.56d, row.getData()[0]); + } + + @Test + void testBuildRowReportsUnparsableNumber() { + List 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 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 remarks = new ArrayList<>(); + + buildRow(remarks, date); + + assertEquals(1, remarks.size()); + assertTrue(textOf(remarks).contains("Date"), textOf(remarks)); + } + + @Test + void testBuildRowReportsUnparsableInteger() { + List 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 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 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 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 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 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 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 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 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 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 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 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 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 and 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 rowMetaCaptor = ArgumentCaptor.forClass(IRowMeta.class); + ArgumentCaptor 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 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 remarks) { + return remarks.stream().map(ICheckResult::getText).reduce("", (a, b) -> a + b + "\n"); + } }