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 213f992093 align value mapper behaviour between new or existing field, 
fixes #5586 (#7013)
213f992093 is described below

commit 213f9920939b6dcacf7c3a7d00c1971a8bae67bc
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Apr 16 20:12:14 2026 +0200

    align value mapper behaviour between new or existing field, fixes #5586 
(#7013)
---
 .../pages/pipeline/transforms/valuemapper.adoc     |  45 ++++--
 .../transforms/valuemapper/ValueMapper.java        |  21 ++-
 .../transforms/valuemapper/ValueMapperDialog.java  |  41 ++++-
 .../transforms/valuemapper/ValueMapperMeta.java    |  41 ++++-
 .../valuemapper/messages/messages_en_US.properties |   3 +
 .../valuemapper/ValueMapperMetaInjectionTest.java  |   1 +
 .../valuemapper/ValueMapperMetaTest.java           |  36 ++++-
 .../transforms/valuemapper/ValueMapperTest.java    | 175 +++++++++++++++++++++
 8 files changed, 339 insertions(+), 24 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/valuemapper.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/valuemapper.adoc
index 02d1ddd284..d136a6ab22 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/valuemapper.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/valuemapper.adoc
@@ -59,21 +59,44 @@ The following properties are used to define the mappings:
 |===
 |Option|Description
 |Transform Name|Name of the transform this name has to be unique in a single 
pipeline.
-|Fieldname to use|Field to use as the mapping source
-|Target field name|Field to use as the mapping Target
-|Default upon non-matching|Defines a default value for situations where the 
source value is not empty, but there is no match
-|Field values table|Contains the mapping of source value to converted target 
value.
-|Target field type |Specifies the data type of the mapped (target) values.
-If not set, the transform falls back to:
+|Fieldname to use|Field to use as the mapping source.
+|Target field name|Field to write mapped values to.
+Leave empty to overwrite the source field in place.
+|Keep original value on non-match|When enabled, a source value that does not 
match any row in the field values table keeps the **original source value** 
(when overwriting in place) or copies it into the **new target field**.
+In that case **Default upon non-matching** is disabled in the dialog and 
ignored at runtime.
+
+When disabled, a non-match uses **Default upon non-matching** if it is set; 
otherwise the output is **null** (for both overwrite and new-field modes).
+|Default upon non-matching|Defines a default value when the source value is 
not empty but does not match any mapping row.
+Disabled when **Keep original value on non-match** is enabled.
+|Target field type|Specifies the data type of the mapped (target) values.
+If not set, the transform uses **String** (including when overwriting an 
existing field in place, so the output column type matches string mapped 
values).
+|Field values table|Table with one row per mapping: **Source value**, **Target 
value**, and **Empty string equals null** (Yes/No per row).
+See <<empty-string-equals-null,Empty string equals null>> below.
 
-- String when writing to a new target field.
+|===
 
-- The same type as the source field when overwriting the source field.
-It fails if the values are incompatible.
+[[keep-original-on-non-match]]
+=== Keep original value on non-match
 
-|===
+Use this when unmatched values should stay as the incoming data instead of 
becoming null or the default.
+
+* Overwrite mode (no target field): the source field is left unchanged for 
non-matches (except normal storage-type handling where applicable).
+* New field mode: the new column receives the source value converted to the 
target field type.
+
+When the option is on, **Default upon non-matching** is not used.
+
+[[empty-string-equals-null]]
+=== Empty string equals null (field values)
+
+Each mapping row has an **Empty string equals null** column (Yes/No).
+
+* **Yes** (default for new rows): after variables are resolved, if the 
**target** value for that row is blank, the transform stores **null** as the 
mapped result instead of converting an empty string to the target type.
+* **No**: an empty target is converted like any other value (for example an 
empty string for a string-typed target).
+
+This applies both to normal source-to-target rows and to the special row used 
to map null/empty source values when <<mapping-null-values,mapping null and 
empty source values>> is configured.
 
+[[mapping-null-values]]
 === Mapping NULL values
 If there is an empty source value defined, NULLs and empty strings are mapped 
to the corresponding target value. Only one empty mapping is allowed.
 
-If the input stream contains NULL values, and no mapping for NULLs is defined, 
NULL is returned (and not the default target)
+If the input stream contains NULL values, and no mapping for NULLs is defined, 
NULL is returned (and not the default target).
diff --git 
a/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapper.java
 
b/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapper.java
index fc63af53e4..f7195c6c2a 100644
--- 
a/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapper.java
+++ 
b/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapper.java
@@ -101,6 +101,7 @@ public class ValueMapper extends 
BaseTransform<ValueMapperMeta, ValueMapperData>
     Object sourceData = 
data.sourceValueMeta.convertToNormalStorageType(sourceValue);
     Object target = null;
     boolean mapped = false;
+    boolean keepOriginalOnNonMatch = meta.isKeepOriginalValueOnNonMatch();
 
     if (data.emptySourceMappingDefined && (r[data.keynr] == null || sourceData 
== null)) {
       target = data.emptyFieldValue;
@@ -116,21 +117,25 @@ public class ValueMapper extends 
BaseTransform<ValueMapperMeta, ValueMapperData>
     }
 
     if (!Utils.isEmpty(meta.getTargetField())) {
-      // room for the target
+      int lastIdx = data.outputMeta.size() - 1;
       r = RowDataUtil.resizeArray(r, data.outputMeta.size());
-      // Did we find anything to map to?
-      r[data.outputMeta.size() - 1] = target;
+      if (mapped) {
+        r[lastIdx] = target;
+      } else if (keepOriginalOnNonMatch) {
+        r[lastIdx] = data.outputValueMeta.convertData(data.sourceValueMeta, 
sourceValue);
+      } else {
+        r[lastIdx] = null;
+      }
     } else {
       if (mapped) {
         r[data.keynr] = target;
-      } else {
-        // Convert to normal storage type.
-        // Otherwise we're going to be mixing storage types.
-        //
+      } else if (keepOriginalOnNonMatch) {
         if (data.sourceValueMeta.isStorageBinaryString()) {
           Object normal = 
data.sourceValueMeta.convertToNormalStorageType(r[data.keynr]);
           r[data.keynr] = normal;
         }
+      } else {
+        r[data.keynr] = null;
       }
     }
     putRow(data.outputMeta, r);
@@ -160,7 +165,7 @@ public class ValueMapper extends 
BaseTransform<ValueMapperMeta, ValueMapperData>
     // --- Default for non-match --------------
     //
     try {
-      if (!Utils.isEmpty(meta.getNonMatchDefault())) {
+      if (!meta.isKeepOriginalValueOnNonMatch() && 
!Utils.isEmpty(meta.getNonMatchDefault())) {
         nonMatchActivated = true;
         String nonMatchStr = resolve(meta.getNonMatchDefault());
         data.nonMatchDefault = keyFromString(data.targetValueMeta, 
stringValueMeta, nonMatchStr);
diff --git 
a/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperDialog.java
 
b/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperDialog.java
index e6462c834d..b4132beb12 100644
--- 
a/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperDialog.java
+++ 
b/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperDialog.java
@@ -36,9 +36,12 @@ import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CCombo;
 import org.eclipse.swt.events.FocusEvent;
 import org.eclipse.swt.events.FocusListener;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
 import org.eclipse.swt.graphics.Cursor;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Label;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.TableItem;
@@ -53,6 +56,8 @@ public class ValueMapperDialog extends BaseTransformDialog {
 
   private TextVar wNonMatchDefault;
 
+  private Label wlNonMatchDefault;
+
   private TableView wFields;
 
   private final ValueMapperMeta input;
@@ -61,6 +66,8 @@ public class ValueMapperDialog extends BaseTransformDialog {
 
   private CCombo wTargetType;
 
+  private Button wKeepOriginalOnNonMatch;
+
   public ValueMapperDialog(
       Shell parent,
       IVariables variables,
@@ -129,21 +136,40 @@ public class ValueMapperDialog extends 
BaseTransformDialog {
     fdTargetFieldname.right = new FormAttachment(100, 0);
     wTargetFieldName.setLayoutData(fdTargetFieldname);
 
+    wKeepOriginalOnNonMatch = new Button(shell, SWT.CHECK);
+    wKeepOriginalOnNonMatch.setText(
+        BaseMessages.getString(PKG, 
"ValueMapperDialog.KeepOriginalOnNonMatch.Label"));
+    wKeepOriginalOnNonMatch.setToolTipText(
+        BaseMessages.getString(PKG, 
"ValueMapperDialog.KeepOriginalOnNonMatch.Tooltip"));
+    PropsUi.setLook(wKeepOriginalOnNonMatch);
+    FormData fdKeepOriginal = new FormData();
+    fdKeepOriginal.left = new FormAttachment(middle, 0);
+    fdKeepOriginal.top = new FormAttachment(wTargetFieldName, margin);
+    fdKeepOriginal.right = new FormAttachment(100, 0);
+    wKeepOriginalOnNonMatch.setLayoutData(fdKeepOriginal);
+    wKeepOriginalOnNonMatch.addSelectionListener(
+        new SelectionAdapter() {
+          @Override
+          public void widgetSelected(SelectionEvent e) {
+            updateNonMatchDefaultEnabled();
+          }
+        });
+
     // Non match default line
-    Label wlNonMatchDefault = new Label(shell, SWT.RIGHT);
+    wlNonMatchDefault = new Label(shell, SWT.RIGHT);
     wlNonMatchDefault.setText(
         BaseMessages.getString(PKG, 
"ValueMapperDialog.NonMatchDefault.Label"));
     PropsUi.setLook(wlNonMatchDefault);
     FormData fdlNonMatchDefault = new FormData();
     fdlNonMatchDefault.left = new FormAttachment(0, 0);
     fdlNonMatchDefault.right = new FormAttachment(middle, -margin);
-    fdlNonMatchDefault.top = new FormAttachment(wTargetFieldName, margin);
+    fdlNonMatchDefault.top = new FormAttachment(wKeepOriginalOnNonMatch, 
margin);
     wlNonMatchDefault.setLayoutData(fdlNonMatchDefault);
     wNonMatchDefault = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
     PropsUi.setLook(wNonMatchDefault);
     FormData fdNonMatchDefault = new FormData();
     fdNonMatchDefault.left = new FormAttachment(middle, 0);
-    fdNonMatchDefault.top = new FormAttachment(wTargetFieldName, margin);
+    fdNonMatchDefault.top = new FormAttachment(wKeepOriginalOnNonMatch, 
margin);
     fdNonMatchDefault.right = new FormAttachment(100, 0);
     wNonMatchDefault.setLayoutData(fdNonMatchDefault);
 
@@ -260,6 +286,8 @@ public class ValueMapperDialog extends BaseTransformDialog {
     if (input.getTargetType() != null) {
       wTargetType.setText(input.getTargetType());
     }
+    
wKeepOriginalOnNonMatch.setSelection(input.isKeepOriginalValueOnNonMatch());
+    updateNonMatchDefaultEnabled();
 
     int i = 0;
     for (Values v : input.getValues()) {
@@ -296,6 +324,7 @@ public class ValueMapperDialog extends BaseTransformDialog {
     input.setFieldToUse(wFieldName.getText());
     input.setTargetField(wTargetFieldName.getText());
     input.setNonMatchDefault(wNonMatchDefault.getText());
+    
input.setKeepOriginalValueOnNonMatch(wKeepOriginalOnNonMatch.getSelection());
     input.setTargetType(wTargetType.getText());
 
     input.getValues().clear();
@@ -310,4 +339,10 @@ public class ValueMapperDialog extends BaseTransformDialog 
{
 
     dispose();
   }
+
+  private void updateNonMatchDefaultEnabled() {
+    boolean keep = wKeepOriginalOnNonMatch.getSelection();
+    wNonMatchDefault.setEnabled(!keep);
+    wlNonMatchDefault.setEnabled(!keep);
+  }
 }
diff --git 
a/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMeta.java
 
b/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMeta.java
index 9294d03699..2ac0420377 100644
--- 
a/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMeta.java
+++ 
b/plugins/transforms/valuemapper/src/main/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMeta.java
@@ -19,9 +19,11 @@ package org.apache.hop.pipeline.transforms.valuemapper;
 
 import java.util.ArrayList;
 import java.util.List;
+import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.core.CheckResult;
+import org.apache.hop.core.Const;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.annotations.Transform;
 import org.apache.hop.core.exception.HopPluginException;
@@ -38,7 +40,7 @@ import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.BaseTransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 
-/** Maps String values of a certain field to new values */
+/** Maps String values of a certain field to new values. */
 @Transform(
     id = "ValueMapper",
     image = "valuemapper.svg",
@@ -70,6 +72,42 @@ public class ValueMapperMeta extends 
BaseTransformMeta<ValueMapper, ValueMapperD
       injectionKeyDescription = "ValueMapper.Injection.NON_MATCH_DEFAULT")
   private String nonMatchDefault;
 
+  /** Stored as Y/N when set; {@code null} when absent from serialized 
metadata. */
+  @HopMetadataProperty(
+      key = "keep_original_value_on_non_match",
+      injectionKey = "KEEP_ORIGINAL_ON_NON_MATCH",
+      injectionKeyDescription = 
"ValueMapper.Injection.KEEP_ORIGINAL_ON_NON_MATCH")
+  @Getter(AccessLevel.NONE)
+  @Setter(AccessLevel.NONE)
+  private String keepOriginalValueOnNonMatch;
+
+  /**
+   * When true, non-matching values keep the source field value (in-place or 
copied into the new
+   * target field). The non-match default is ignored. When false, a non-match 
uses the default if
+   * set, otherwise null (same for overwrite and new-field modes).
+   */
+  public boolean isKeepOriginalValueOnNonMatch() {
+    if (!Utils.isEmpty(keepOriginalValueOnNonMatch)) {
+      return Const.toBoolean(keepOriginalValueOnNonMatch);
+    }
+    return Utils.isEmpty(getTargetField()) && 
Utils.isEmpty(getNonMatchDefault());
+  }
+
+  /** Persists an explicit Y/N; used by the dialog and metadata injection. */
+  public void setKeepOriginalValueOnNonMatch(boolean value) {
+    this.keepOriginalValueOnNonMatch = value ? "Y" : "N";
+  }
+
+  public String getKeepOriginalValueOnNonMatch() {
+    return keepOriginalValueOnNonMatch;
+  }
+
+  /** Raw Y/N from metadata; empty clears to unset. */
+  public void setKeepOriginalValueOnNonMatch(String 
keepOriginalValueOnNonMatch) {
+    this.keepOriginalValueOnNonMatch =
+        Utils.isEmpty(keepOriginalValueOnNonMatch) ? null : 
keepOriginalValueOnNonMatch;
+  }
+
   @HopMetadataProperty(
       key = "target_type",
       injectionKey = "TARGET_TYPE",
@@ -97,6 +135,7 @@ public class ValueMapperMeta extends 
BaseTransformMeta<ValueMapper, ValueMapperD
     this.fieldToUse = meta.fieldToUse;
     this.targetField = meta.targetField;
     this.nonMatchDefault = meta.nonMatchDefault;
+    this.keepOriginalValueOnNonMatch = meta.getKeepOriginalValueOnNonMatch();
     if (meta.targetType != null && meta.targetType.isEmpty()) {
       this.targetType = meta.targetType;
     } else {
diff --git 
a/plugins/transforms/valuemapper/src/main/resources/org/apache/hop/pipeline/transforms/valuemapper/messages/messages_en_US.properties
 
b/plugins/transforms/valuemapper/src/main/resources/org/apache/hop/pipeline/transforms/valuemapper/messages/messages_en_US.properties
index ff486b5882..abd98d1054 100644
--- 
a/plugins/transforms/valuemapper/src/main/resources/org/apache/hop/pipeline/transforms/valuemapper/messages/messages_en_US.properties
+++ 
b/plugins/transforms/valuemapper/src/main/resources/org/apache/hop/pipeline/transforms/valuemapper/messages/messages_en_US.properties
@@ -18,6 +18,7 @@
 ValueMapper.Description=Value mapper
 ValueMapper.Injection.FIELDNAME=The source field to use for mapping
 ValueMapper.Injection.NON_MATCH_DEFAULT=The default value if there is not a 
match during lookup
+ValueMapper.Injection.KEEP_ORIGINAL_ON_NON_MATCH=When Y, keep the source field 
value on non-match (default-on-non-match is ignored)
 ValueMapper.Injection.SOURCE=The source values for mapping
 ValueMapper.Injection.TARGET=The target values for mapping
 ValueMapper.Injection.EMPTY_STRING_EQUALS_NULL=When Y, an empty target value 
is emitted as null
@@ -35,6 +36,8 @@ ValueMapperDialog.Fields.Column.TargetValue=Target value
 ValueMapperDialog.Fields.Column.EmptyStringEqualsNull=Empty string equals null
 ValueMapperDialog.Fields.Label=Field values\:
 ValueMapperDialog.NonMatchDefault.Label=Default upon non-matching
+ValueMapperDialog.KeepOriginalOnNonMatch.Label=Keep original value on non-match
+ValueMapperDialog.KeepOriginalOnNonMatch.Tooltip=When enabled, non-matching 
values keep the source value and the default-on-non-match value is not used.
 ValueMapperDialog.TargetFieldname.Label=Target field name (empty\=overwrite)
 ValueMapperDialog.TransformName.Label=Transform name
 ValueMapperMeta.CheckResult.NotReceivingFieldsFromPreviousTransforms=Not 
receiving any fields from previous transforms\!
diff --git 
a/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMetaInjectionTest.java
 
b/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMetaInjectionTest.java
index 429aef1dc6..b11311dbb9 100644
--- 
a/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMetaInjectionTest.java
+++ 
b/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMetaInjectionTest.java
@@ -39,6 +39,7 @@ class ValueMapperMetaInjectionTest extends 
BaseMetadataInjectionTestJunit5<Value
     check("FIELDNAME", () -> meta.getFieldToUse());
     check("TARGET_FIELDNAME", () -> meta.getTargetField());
     check("NON_MATCH_DEFAULT", () -> meta.getNonMatchDefault());
+    check("KEEP_ORIGINAL_ON_NON_MATCH", () -> 
meta.isKeepOriginalValueOnNonMatch());
     check("SOURCE", () -> meta.getValues().get(0).getSource());
     check("TARGET", () -> meta.getValues().get(0).getTarget());
     check("EMPTY_STRING_EQUALS_NULL", () -> 
meta.getValues().get(0).isEmptyStringEqualsNull());
diff --git 
a/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMetaTest.java
 
b/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMetaTest.java
index f91e1d6f73..40562ee6cc 100644
--- 
a/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMetaTest.java
+++ 
b/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperMetaTest.java
@@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 
 import java.util.ArrayList;
@@ -89,7 +90,7 @@ class ValueMapperMetaTest {
       assertEquals(
           true,
           v.isEmptyStringEqualsNull(),
-          "Older pipelines without empty_string_equals_null must default to Y 
(true)");
+          "Missing empty_string_equals_null defaults empty string equals null 
to true");
     }
   }
 
@@ -111,6 +112,10 @@ class ValueMapperMetaTest {
     assertEquals("Country_Name", meta.getTargetField());
     assertEquals("[${NOT_FOUND}]", meta.getNonMatchDefault());
 
+    assertNull(meta.getKeepOriginalValueOnNonMatch());
+    assertFalse(
+        meta.isKeepOriginalValueOnNonMatch(),
+        "Unset keep-original resolves to N when a target field or non-match 
default exists");
     assertNull(meta.getTargetType(), "Expected null target type right after 
deserialization");
     // Before getFields: input has only the source field
     IRowMeta input = new RowMeta();
@@ -129,6 +134,32 @@ class ValueMapperMetaTest {
         "New target field should default to String type when no explicit 
target type is set");
   }
 
+  @Test
+  void implicitKeepOriginalTrueWhenUnsetInPlaceAndNoDefault() {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setTargetField("");
+    meta.setNonMatchDefault(null);
+    assertNull(meta.getKeepOriginalValueOnNonMatch());
+    assertTrue(meta.isKeepOriginalValueOnNonMatch());
+  }
+
+  @Test
+  void implicitKeepOriginalFalseWhenUnsetWithTargetField() {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setTargetField("out");
+    assertNull(meta.getKeepOriginalValueOnNonMatch());
+    assertFalse(meta.isKeepOriginalValueOnNonMatch());
+  }
+
+  @Test
+  void implicitKeepOriginalFalseWhenUnsetWithNonMatchDefaultOnly() {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setTargetField("");
+    meta.setNonMatchDefault("fallback");
+    assertNull(meta.getKeepOriginalValueOnNonMatch());
+    assertFalse(meta.isKeepOriginalValueOnNonMatch());
+  }
+
   @Test
   void cloneCopiesValuesAndEmptyStringEqualsNull() {
     ValueMapperMeta meta = new ValueMapperMeta();
@@ -139,8 +170,11 @@ class ValueMapperMetaTest {
     v.setEmptyStringEqualsNull(false);
     meta.getValues().add(v);
 
+    meta.setKeepOriginalValueOnNonMatch(true);
     ValueMapperMeta clone = (ValueMapperMeta) meta.clone();
     assertEquals(1, clone.getValues().size());
+    assertTrue(clone.isKeepOriginalValueOnNonMatch());
+    assertEquals("Y", clone.getKeepOriginalValueOnNonMatch());
     assertFalse(clone.getValues().get(0).isEmptyStringEqualsNull());
     assertEquals("x", clone.getValues().get(0).getSource());
     assertEquals("y", clone.getValues().get(0).getTarget());
diff --git 
a/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperTest.java
 
b/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperTest.java
index 44e6cc6592..3ad23f0fc5 100644
--- 
a/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperTest.java
+++ 
b/plugins/transforms/valuemapper/src/test/java/org/apache/hop/pipeline/transforms/valuemapper/ValueMapperTest.java
@@ -220,6 +220,181 @@ class ValueMapperTest {
     assertNull(outputs.get(0)[0]);
   }
 
+  @Test
+  void inPlaceNoMatchUnsetCompatKeepsSourceWhenNoDefault() throws Exception {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setFieldToUse("code");
+    meta.setTargetField("");
+    meta.setTargetType(null);
+    meta.getValues().add(mapping("A", "B"));
+
+    ValueMapperData data = new ValueMapperData();
+    ValueMapper vm = newSpyTransform(meta, data);
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("code"));
+    doReturn(new Object[] {"Z"}).doReturn(null).when(vm).getRow();
+    doReturn(inputMeta).when(vm).getInputRowMeta();
+
+    List<Object[]> outputs = new ArrayList<>();
+    doAnswer(
+            inv -> {
+              outputs.add(((Object[]) inv.getArgument(1)).clone());
+              return null;
+            })
+        .when(vm)
+        .putRow(any(IRowMeta.class), any(Object[].class));
+
+    assertTrue(vm.processRow());
+    assertEquals("Z", outputs.get(0)[0]);
+  }
+
+  @Test
+  void inPlaceNoMatchExplicitNoEmitsNullWhenNoDefault() throws Exception {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setFieldToUse("code");
+    meta.setTargetField("");
+    meta.setTargetType(null);
+    meta.setKeepOriginalValueOnNonMatch(false);
+    meta.getValues().add(mapping("A", "B"));
+
+    ValueMapperData data = new ValueMapperData();
+    ValueMapper vm = newSpyTransform(meta, data);
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("code"));
+    doReturn(new Object[] {"Z"}).doReturn(null).when(vm).getRow();
+    doReturn(inputMeta).when(vm).getInputRowMeta();
+
+    List<Object[]> outputs = new ArrayList<>();
+    doAnswer(
+            inv -> {
+              outputs.add(((Object[]) inv.getArgument(1)).clone());
+              return null;
+            })
+        .when(vm)
+        .putRow(any(IRowMeta.class), any(Object[].class));
+
+    assertTrue(vm.processRow());
+    assertNull(outputs.get(0)[0]);
+  }
+
+  @Test
+  void newFieldNoMatchWithoutDefaultEmitsNull() throws Exception {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setFieldToUse("code");
+    meta.setTargetField("mapped");
+    meta.setTargetType(null);
+    meta.getValues().add(mapping("A", "B"));
+
+    ValueMapperData data = new ValueMapperData();
+    ValueMapper vm = newSpyTransform(meta, data);
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("code"));
+    doReturn(new Object[] {"Z"}).doReturn(null).when(vm).getRow();
+    doReturn(inputMeta).when(vm).getInputRowMeta();
+
+    List<Object[]> outputs = new ArrayList<>();
+    doAnswer(
+            inv -> {
+              outputs.add(((Object[]) inv.getArgument(1)).clone());
+              return null;
+            })
+        .when(vm)
+        .putRow(any(IRowMeta.class), any(Object[].class));
+
+    assertTrue(vm.processRow());
+    assertEquals("Z", outputs.get(0)[0]);
+    assertNull(outputs.get(0)[1]);
+  }
+
+  @Test
+  void keepOriginalOnNonMatchInPlacePreservesSource() throws Exception {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setFieldToUse("code");
+    meta.setTargetField("");
+    meta.setTargetType(null);
+    meta.setKeepOriginalValueOnNonMatch(true);
+    meta.getValues().add(mapping("A", "B"));
+
+    ValueMapperData data = new ValueMapperData();
+    ValueMapper vm = newSpyTransform(meta, data);
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("code"));
+    doReturn(new Object[] {"Z"}).doReturn(null).when(vm).getRow();
+    doReturn(inputMeta).when(vm).getInputRowMeta();
+
+    List<Object[]> outputs = new ArrayList<>();
+    doAnswer(
+            inv -> {
+              outputs.add(((Object[]) inv.getArgument(1)).clone());
+              return null;
+            })
+        .when(vm)
+        .putRow(any(IRowMeta.class), any(Object[].class));
+
+    assertTrue(vm.processRow());
+    assertEquals("Z", outputs.get(0)[0]);
+  }
+
+  @Test
+  void keepOriginalOnNonMatchNewFieldCopiesSource() throws Exception {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setFieldToUse("code");
+    meta.setTargetField("mapped");
+    meta.setTargetType(null);
+    meta.setKeepOriginalValueOnNonMatch(true);
+    meta.getValues().add(mapping("A", "B"));
+
+    ValueMapperData data = new ValueMapperData();
+    ValueMapper vm = newSpyTransform(meta, data);
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("code"));
+    doReturn(new Object[] {"Z"}).doReturn(null).when(vm).getRow();
+    doReturn(inputMeta).when(vm).getInputRowMeta();
+
+    List<Object[]> outputs = new ArrayList<>();
+    doAnswer(
+            inv -> {
+              outputs.add(((Object[]) inv.getArgument(1)).clone());
+              return null;
+            })
+        .when(vm)
+        .putRow(any(IRowMeta.class), any(Object[].class));
+
+    assertTrue(vm.processRow());
+    assertEquals("Z", outputs.get(0)[0]);
+    assertEquals("Z", outputs.get(0)[1]);
+  }
+
+  @Test
+  void keepOriginalIgnoresNonMatchDefaultAtRuntime() throws Exception {
+    ValueMapperMeta meta = new ValueMapperMeta();
+    meta.setFieldToUse("code");
+    meta.setTargetField("");
+    meta.setTargetType(null);
+    meta.setKeepOriginalValueOnNonMatch(true);
+    meta.setNonMatchDefault("SHOULD_NOT_USE");
+    meta.getValues().add(mapping("A", "B"));
+
+    ValueMapperData data = new ValueMapperData();
+    ValueMapper vm = newSpyTransform(meta, data);
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("code"));
+    doReturn(new Object[] {"Z"}).doReturn(null).when(vm).getRow();
+    doReturn(inputMeta).when(vm).getInputRowMeta();
+
+    List<Object[]> outputs = new ArrayList<>();
+    doAnswer(
+            inv -> {
+              outputs.add(((Object[]) inv.getArgument(1)).clone());
+              return null;
+            })
+        .when(vm)
+        .putRow(any(IRowMeta.class), any(Object[].class));
+
+    assertTrue(vm.processRow());
+    assertEquals("Z", outputs.get(0)[0]);
+  }
+
   @Test
   void nonMatchDefaultUsedWhenNoMapping() throws Exception {
     ValueMapperMeta meta = new ValueMapperMeta();

Reply via email to