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 47054d4272 Add option to omit failed values from Data Validator log 
messages (#7570)
47054d4272 is described below

commit 47054d4272a1965bef6dd6cd81274c1540c87ed7
Author: Lance <[email protected]>
AuthorDate: Mon Jul 20 18:53:10 2026 +0800

    Add option to omit failed values from Data Validator log messages (#7570)
    
    Signed-off-by: lance <[email protected]>
---
 .../hop/pipeline/transform/BaseTransform.java      |  16 +-
 .../pipeline/transforms/validator/Validator.java   |  53 +-
 .../transforms/validator/ValidatorDialog.java      |  65 +-
 .../transforms/validator/ValidatorMeta.java        | 127 +--
 .../validator/messages/messages_en_US.properties   |   3 +
 .../validator/messages/messages_zh_CN.properties   |   3 +
 .../validator/HopValidatorExceptionTest.java       | 135 ++++
 .../transforms/validator/ValidationTest.java       | 157 ++++
 .../transforms/validator/ValidatorDataTest.java    |  42 +
 .../validator/ValidatorDialogSelectionTest.java    |  93 +++
 .../transforms/validator/ValidatorMetaTest.java    | 282 +++++++
 .../transforms/validator/ValidatorTest.java        | 865 +++++++++++++++++++++
 .../src/test/resources/validator-transform.xml     |  60 ++
 13 files changed, 1770 insertions(+), 131 deletions(-)

diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/transform/BaseTransform.java 
b/engine/src/main/java/org/apache/hop/pipeline/transform/BaseTransform.java
index 23d19ebc0a..5600a65210 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/transform/BaseTransform.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/transform/BaseTransform.java
@@ -1417,6 +1417,7 @@ public class BaseTransform<Meta extends ITransformMeta, 
Data extends ITransformD
       try {
         Thread.sleep(1);
       } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
         throw new HopTransformException(e);
       }
     }
@@ -1523,7 +1524,7 @@ public class BaseTransform<Meta extends ITransformMeta, 
Data extends ITransformD
         }
       }
       incrementLinesRejected();
-      if (!Utils.isEmpty(errorDescriptions)) {
+      if (isLoggingErrorDescriptions() && !Utils.isEmpty(errorDescriptions)) {
         logError(errorDescriptions);
       }
     } else if (transformErrorMeta.isEnabled()) {
@@ -2723,6 +2724,19 @@ public class BaseTransform<Meta extends ITransformMeta, 
Data extends ITransformD
     log.logRowlevel(message, arguments);
   }
 
+  /**
+   * Whether rejected-row error descriptions should be written to the log.
+   *
+   * <p>Transforms that can produce a very large number of 
validation/rejection errors may override
+   * this to reduce log volume. Error rows are still sent to the error 
handling hop when configured.
+   *
+   * @return true when error descriptions should be logged (default)
+   * @since 2.19.0
+   */
+  protected boolean isLoggingErrorDescriptions() {
+    return true;
+  }
+
   /**
    * Log error.
    *
diff --git 
a/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/Validator.java
 
b/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/Validator.java
index f4257dde01..191fdcc4c8 100644
--- 
a/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/Validator.java
+++ 
b/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/Validator.java
@@ -47,6 +47,9 @@ import org.apache.hop.pipeline.transform.stream.IStream;
 public class Validator extends BaseTransform<ValidatorMeta, ValidatorData> 
implements ITransform {
   private static final Class<?> PKG = ValidatorMeta.class;
 
+  /** Placeholder used in log/error messages when failed data must not be 
exposed. */
+  static final String OMITTED_VALUE = "?";
+
   public Validator(
       TransformMeta transformMeta,
       ValidatorMeta meta,
@@ -279,7 +282,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                     PKG,
                     "Validator.Exception.NullNotAllowed",
                     field.getFieldName(),
-                    inputRowMeta.getString(r)),
+                    getRowForMessage(inputRowMeta, r)),
                 field.getFieldName());
         exceptions.add(exception);
         if (!meta.isValidatingAll()) {
@@ -297,7 +300,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                     PKG,
                     "Validator.Exception.OnlyNullAllowed",
                     field.getFieldName(),
-                    inputRowMeta.getString(r)),
+                    getRowForMessage(inputRowMeta, r)),
                 field.getFieldName());
         exceptions.add(exception);
         if (!meta.isValidatingAll()) {
@@ -367,7 +370,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                       PKG,
                       "Validator.Exception.ShorterThanMininumLength",
                       field.getFieldName(),
-                      valueMeta.getString(valueData),
+                      getValueForMessage(valueMeta, valueData),
                       Integer.toString(stringValue.length()),
                       field.getMinimumLength()),
                   field.getFieldName());
@@ -390,7 +393,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                       PKG,
                       "Validator.Exception.LongerThanMaximumLength",
                       field.getFieldName(),
-                      valueMeta.getString(valueData),
+                      getValueForMessage(valueMeta, valueData),
                       Integer.toString(stringValue.length()),
                       field.getMaximumLength()),
                   field.getFieldName());
@@ -413,7 +416,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                       PKG,
                       "Validator.Exception.LowerThanMinimumValue",
                       field.getFieldName(),
-                      valueMeta.getString(valueData),
+                      getValueForMessage(valueMeta, valueData),
                       data.constantsMeta[i].getString(data.minimumValue[i])),
                   field.getFieldName());
           exceptions.add(exception);
@@ -435,7 +438,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                       PKG,
                       "Validator.Exception.HigherThanMaximumValue",
                       field.getFieldName(),
-                      valueMeta.getString(valueData),
+                      getValueForMessage(valueMeta, valueData),
                       data.constantsMeta[i].getString(data.maximumValue[i])),
                   field.getFieldName());
           exceptions.add(exception);
@@ -465,7 +468,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                         PKG,
                         "Validator.Exception.NotInList",
                         field.getFieldName(),
-                        valueMeta.getString(valueData)),
+                        getValueForMessage(valueMeta, valueData)),
                     field.getFieldName());
             exceptions.add(exception);
             if (!meta.isValidatingAll()) {
@@ -498,7 +501,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                       PKG,
                       "Validator.Exception.DoesNotStartWithString",
                       field.getFieldName(),
-                      valueMeta.getString(valueData),
+                      getValueForMessage(valueMeta, valueData),
                       field.getStartString()),
                   field.getFieldName());
           exceptions.add(exception);
@@ -519,7 +522,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                       PKG,
                       "Validator.Exception.DoesNotEndWithString",
                       field.getFieldName(),
-                      valueMeta.getString(valueData),
+                      getValueForMessage(valueMeta, valueData),
                       field.getEndString()),
                   field.getFieldName());
           exceptions.add(exception);
@@ -541,7 +544,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                       PKG,
                       "Validator.Exception.StartsWithString",
                       field.getFieldName(),
-                      valueMeta.getString(valueData),
+                      getValueForMessage(valueMeta, valueData),
                       field.getStartStringNotAllowed()),
                   field.getFieldName());
           exceptions.add(exception);
@@ -563,7 +566,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                       PKG,
                       "Validator.Exception.EndsWithString",
                       field.getFieldName(),
-                      valueMeta.getString(valueData),
+                      getValueForMessage(valueMeta, valueData),
                       field.getEndStringNotAllowed()),
                   field.getFieldName());
           exceptions.add(exception);
@@ -586,7 +589,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                         PKG,
                         "Validator.Exception.MatchingRegExpExpected",
                         field.getFieldName(),
-                        valueMeta.getString(valueData),
+                        getValueForMessage(valueMeta, valueData),
                         data.regularExpression[i]),
                     field.getFieldName());
             exceptions.add(exception);
@@ -610,7 +613,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
                         PKG,
                         "Validator.Exception.MatchingRegExpNotAllowed",
                         field.getFieldName(),
-                        valueMeta.getString(valueData),
+                        getValueForMessage(valueMeta, valueData),
                         data.regularExpressionNotAllowed[i]),
                     field.getFieldName());
             exceptions.add(exception);
@@ -625,6 +628,28 @@ public class Validator extends 
BaseTransform<ValidatorMeta, ValidatorData> imple
     return exceptions;
   }
 
+  /**
+   * Returns the field value for inclusion in validation messages, or a 
placeholder when logging of
+   * failed data is suppressed.
+   */
+  String getValueForMessage(IValueMeta valueMeta, Object valueData) throws 
HopValueException {
+    if (meta.isSuppressingLogFailedData()) {
+      return OMITTED_VALUE;
+    }
+    return valueMeta.getString(valueData);
+  }
+
+  /**
+   * Returns the row for inclusion in validation messages, or a placeholder 
when logging of failed
+   * data is suppressed.
+   */
+  String getRowForMessage(IRowMeta inputRowMeta, Object[] r) throws 
HopValueException {
+    if (meta.isSuppressingLogFailedData()) {
+      return OMITTED_VALUE;
+    }
+    return inputRowMeta.getString(r);
+  }
+
   // package-local visibility for testing purposes
   HopValidatorException assertNumeric(IValueMeta valueMeta, Object valueData, 
Validation field)
       throws HopValueException {
@@ -640,7 +665,7 @@ public class Validator extends BaseTransform<ValidatorMeta, 
ValidatorData> imple
             "Validator.Exception.NonNumericDataNotAllowed",
             field.getFieldName(),
             valueMeta.toStringMeta(),
-            valueMeta.getString(valueData)),
+            getValueForMessage(valueMeta, valueData)),
         field.getFieldName());
   }
 
diff --git 
a/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/ValidatorDialog.java
 
b/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/ValidatorDialog.java
index c6cbf24034..861318cb15 100644
--- 
a/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/ValidatorDialog.java
+++ 
b/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/ValidatorDialog.java
@@ -113,6 +113,7 @@ public class ValidatorDialog extends BaseTransformDialog {
   private TextVar wErrorDescription;
   private Button wConcatErrors;
   private TextVar wConcatSeparator;
+  private Button wSuppressLogFailedData;
 
   public ValidatorDialog(
       Shell parent, IVariables variables, ValidatorMeta transformMeta, 
PipelineMeta pipelineMeta) {
@@ -207,6 +208,20 @@ public class ValidatorDialog extends BaseTransformDialog {
     fdConcatSeparator.top = new FormAttachment(wValidateAll, margin);
     wConcatSeparator.setLayoutData(fdConcatSeparator);
 
+    // Optionally suppress logging of failed validation data (reduces log 
volume)
+    //
+    wSuppressLogFailedData = new Button(shell, SWT.CHECK);
+    wSuppressLogFailedData.setText(
+        BaseMessages.getString(PKG, 
"ValidatorDialog.SuppressLogFailedData.Label"));
+    wSuppressLogFailedData.setToolTipText(
+        BaseMessages.getString(PKG, 
"ValidatorDialog.SuppressLogFailedData.Tooltip"));
+    PropsUi.setLook(wSuppressLogFailedData);
+    FormData fdSuppressLogFailedData = new FormData();
+    fdSuppressLogFailedData.left = new FormAttachment(middle, 0);
+    fdSuppressLogFailedData.right = new FormAttachment(100, 0);
+    fdSuppressLogFailedData.top = new FormAttachment(wConcatErrors, margin);
+    wSuppressLogFailedData.setLayoutData(fdSuppressLogFailedData);
+
     // Create a scrolled composite on the right side...
     //
     ScrolledComposite wSComp = new ScrolledComposite(shell, SWT.H_SCROLL | 
SWT.V_SCROLL);
@@ -214,7 +229,7 @@ public class ValidatorDialog extends BaseTransformDialog {
     wSComp.setLayout(new FillLayout());
     FormData fdComp = new FormData();
     fdComp.left = new FormAttachment(middle / 2, margin);
-    fdComp.top = new FormAttachment(wConcatSeparator, margin);
+    fdComp.top = new FormAttachment(wSuppressLogFailedData, margin);
     fdComp.right = new FormAttachment(100, -margin);
     fdComp.bottom = new FormAttachment(wOk, -margin);
     // Limit viewport size so the dialog opens at a reasonable size; content 
scrolls inside.
@@ -344,8 +359,8 @@ public class ValidatorDialog extends BaseTransformDialog {
     PropsUi.setLook(wgType);
     wgType.setText(BaseMessages.getString(PKG, 
"ValidatorDialog.TypeGroup.Label"));
     FormLayout typeGroupLayout = new FormLayout();
-    typeGroupLayout.marginHeight = Const.FORM_MARGIN;
-    typeGroupLayout.marginWidth = Const.FORM_MARGIN;
+    typeGroupLayout.marginHeight = PropsUi.getFormMargin();
+    typeGroupLayout.marginWidth = PropsUi.getFormMargin();
     wgType.setLayout(typeGroupLayout);
     FormData fdType = new FormData();
     fdType.left = new FormAttachment(0, 0);
@@ -893,10 +908,6 @@ public class ValidatorDialog extends BaseTransformDialog {
       selectedField.setSourcingField(wSourceField.getText());
       selectedField.setSourcingTransformName(wSourceTransform.getText());
       
selectedField.setSourcingTransform(pipelineMeta.findTransform(wSourceTransform.getText()));
-
-      // Save the old info in the map
-      //
-      // selectionList.add(selectedField);
     }
   }
 
@@ -1044,11 +1055,12 @@ public class ValidatorDialog extends 
BaseTransformDialog {
     wValidateAll.setSelection(input.isValidatingAll());
     wConcatErrors.setSelection(input.isConcatenatingErrors());
     wConcatSeparator.setText(Const.NVL(input.getConcatenationSeparator(), ""));
+    wSuppressLogFailedData.setSelection(input.isSuppressingLogFailedData());
 
     // Select the first available field...
     //
     if (!input.getValidations().isEmpty()) {
-      Validation validatorField = input.getValidations().get(0);
+      Validation validatorField = input.getValidations().getFirst();
       String description = validatorField.getName();
       int index = wValidationsList.indexOf(description);
       if (index >= 0) {
@@ -1085,6 +1097,7 @@ public class ValidatorDialog extends BaseTransformDialog {
     input.setValidatingAll(wValidateAll.getSelection());
     input.setConcatenatingErrors(wConcatErrors.getSelection());
     input.setConcatenationSeparator(wConcatSeparator.getText());
+    input.setSuppressingLogFailedData(wSuppressLogFailedData.getSelection());
 
     input.setValidations(selectionList);
 
@@ -1169,17 +1182,37 @@ public class ValidatorDialog extends 
BaseTransformDialog {
 
   /** Clear the validation rules for a certain field. */
   private void clearValidation() {
-
     int index = wValidationsList.getSelectionIndex();
-    if (index >= 0) {
-      selectionList.remove(index);
-      selectedField = null;
-      wValidationsList.remove(index);
+    if (index < 0) {
+      return;
+    }
+
+    selectionList.remove(index);
+    selectedField = null;
+    wValidationsList.remove(index);
+
+    if (selectionList.isEmpty()) {
       enableFields();
+      return;
+    }
 
-      if (!selectionList.isEmpty()) {
-        wValidationsList.select(selectionList.size() - 1);
-      }
+    // Keep showing the nearest remaining rule (same index, or previous when 
deleting the last)
+    int newIndex = indexAfterRemoval(index, selectionList.size());
+    wValidationsList.select(newIndex);
+    showSelectedValidatorField(wValidationsList.getItem(newIndex));
+  }
+
+  /**
+   * Calculate which list index to select after removing an item.
+   *
+   * @param removedIndex the index that was removed
+   * @param remainingCount the number of items left after removal
+   * @return the index to select, or -1 when nothing remains
+   */
+  static int indexAfterRemoval(int removedIndex, int remainingCount) {
+    if (remainingCount <= 0) {
+      return -1;
     }
+    return Math.min(removedIndex, remainingCount - 1);
   }
 }
diff --git 
a/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/ValidatorMeta.java
 
b/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/ValidatorMeta.java
index c6cf5813ac..c8ccb6407a 100644
--- 
a/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/ValidatorMeta.java
+++ 
b/plugins/transforms/validator/src/main/java/org/apache/hop/pipeline/transforms/validator/ValidatorMeta.java
@@ -20,6 +20,8 @@ package org.apache.hop.pipeline.transforms.validator;
 
 import java.util.ArrayList;
 import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.hop.core.CheckResult;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.ICheckResult;
@@ -38,6 +40,8 @@ import org.apache.hop.pipeline.transform.stream.IStream;
 import org.apache.hop.pipeline.transform.stream.Stream;
 import org.apache.hop.pipeline.transform.stream.StreamIcon;
 
+@Getter
+@Setter
 @Transform(
     id = "Validator",
     name = "i18n::ValidatorDialog.Transform.Name",
@@ -76,6 +80,27 @@ public class ValidatorMeta extends 
BaseTransformMeta<Validator, ValidatorData> {
       injectionKeyDescription = "Validator.Injection.CONCATENATION_SEPARATOR")
   private String concatenationSeparator;
 
+  /**
+   * When true, failed field/row values are omitted from validation error 
messages. Errors are still
+   * logged and error rows are still produced for error handling. Defaults to 
false so existing
+   * pipelines keep including failed values in messages.
+   */
+  @HopMetadataProperty(
+      key = "suppress_log_failed_data",
+      injectionKey = "SUPPRESS_LOG_FAILED_DATA",
+      injectionKeyDescription = "Validator.Injection.SUPPRESS_LOG_FAILED_DATA")
+  private boolean suppressingLogFailedData;
+
+  /** The standard new validation stream */
+  @Getter @Setter
+  private static IStream newValidation =
+      new Stream(
+          IStream.StreamType.INFO,
+          null,
+          BaseMessages.getString(PKG, 
"ValidatorMeta.NewValidation.Description"),
+          StreamIcon.INFO,
+          null);
+
   public ValidatorMeta() {
     this.validations = new ArrayList<>();
   }
@@ -86,6 +111,7 @@ public class ValidatorMeta extends 
BaseTransformMeta<Validator, ValidatorData> {
     this.validatingAll = m.validatingAll;
     this.concatenatingErrors = m.concatenatingErrors;
     this.concatenationSeparator = m.concatenationSeparator;
+    this.suppressingLogFailedData = m.suppressingLogFailedData;
   }
 
   @Override
@@ -170,7 +196,7 @@ public class ValidatorMeta extends 
BaseTransformMeta<Validator, ValidatorData> {
   }
 
   @Override
-  public void searchInfoAndTargetTransforms(List transforms) {
+  public void searchInfoAndTargetTransforms(List<TransformMeta> transforms) {
     for (Validation validation : validations) {
       TransformMeta transformMeta =
           TransformMeta.findTransform(transforms, 
validation.getSourcingTransformName());
@@ -179,15 +205,6 @@ public class ValidatorMeta extends 
BaseTransformMeta<Validator, ValidatorData> {
     resetTransformIoMeta();
   }
 
-  /** The standard new validation stream */
-  private static IStream newValidation =
-      new Stream(
-          IStream.StreamType.INFO,
-          null,
-          BaseMessages.getString(PKG, 
"ValidatorMeta.NewValidation.Description"),
-          StreamIcon.INFO,
-          null);
-
   @Override
   public void handleStreamSelection(IStream stream) {
     // A hack to prevent us from losing information in the UI because
@@ -212,94 +229,4 @@ public class ValidatorMeta extends 
BaseTransformMeta<Validator, ValidatorData> {
     // Force the IO to be recreated when it is next needed.
     resetTransformIoMeta();
   }
-
-  /**
-   * Gets validations
-   *
-   * @return value of validations
-   */
-  public List<Validation> getValidations() {
-    return validations;
-  }
-
-  /**
-   * Sets validations
-   *
-   * @param validations value of validations
-   */
-  public void setValidations(List<Validation> validations) {
-    this.validations = validations;
-  }
-
-  /**
-   * Gets validatingAll
-   *
-   * @return value of validatingAll
-   */
-  public boolean isValidatingAll() {
-    return validatingAll;
-  }
-
-  /**
-   * Sets validatingAll
-   *
-   * @param validatingAll value of validatingAll
-   */
-  public void setValidatingAll(boolean validatingAll) {
-    this.validatingAll = validatingAll;
-  }
-
-  /**
-   * Gets concatenatingErrors
-   *
-   * @return value of concatenatingErrors
-   */
-  public boolean isConcatenatingErrors() {
-    return concatenatingErrors;
-  }
-
-  /**
-   * Sets concatenatingErrors
-   *
-   * @param concatenatingErrors value of concatenatingErrors
-   */
-  public void setConcatenatingErrors(boolean concatenatingErrors) {
-    this.concatenatingErrors = concatenatingErrors;
-  }
-
-  /**
-   * Gets concatenationSeparator
-   *
-   * @return value of concatenationSeparator
-   */
-  public String getConcatenationSeparator() {
-    return concatenationSeparator;
-  }
-
-  /**
-   * Sets concatenationSeparator
-   *
-   * @param concatenationSeparator value of concatenationSeparator
-   */
-  public void setConcatenationSeparator(String concatenationSeparator) {
-    this.concatenationSeparator = concatenationSeparator;
-  }
-
-  /**
-   * Gets newValidation
-   *
-   * @return value of newValidation
-   */
-  public static IStream getNewValidation() {
-    return newValidation;
-  }
-
-  /**
-   * Sets newValidation
-   *
-   * @param newValidation value of newValidation
-   */
-  public static void setNewValidation(IStream newValidation) {
-    ValidatorMeta.newValidation = newValidation;
-  }
 }
diff --git 
a/plugins/transforms/validator/src/main/resources/org/apache/hop/pipeline/transforms/validator/messages/messages_en_US.properties
 
b/plugins/transforms/validator/src/main/resources/org/apache/hop/pipeline/transforms/validator/messages/messages_en_US.properties
index b79d97aa41..f81ecbec9a 100644
--- 
a/plugins/transforms/validator/src/main/resources/org/apache/hop/pipeline/transforms/validator/messages/messages_en_US.properties
+++ 
b/plugins/transforms/validator/src/main/resources/org/apache/hop/pipeline/transforms/validator/messages/messages_en_US.properties
@@ -38,6 +38,7 @@ Validator.Exception.StartsWithString=During validation of 
field ''{0}'' we found
 Validator.Exception.UnexpectedDataType=During validation of field ''{0}'' we 
found that its data type [{1}] is different from the expected [{2}].
 Validator.Injection.CONCATENATE_ERRORS=This option causes all errors to be 
combined into a single output row.
 Validator.Injection.CONCATENATION_SEPARATOR=Specify the error separator when 
the errors are in one output row.
+Validator.Injection.SUPPRESS_LOG_FAILED_DATA=When enabled, failed field/row 
values are omitted from validation error messages written to the log. Error 
rows are still sent to the error handling hop.
 Validator.Injection.CONVERSION_MASK=Specify the mask to use to convert the 
data specified in this validation rule.
 Validator.Injection.DATA_TYPE=Specify the data type to verify.
 Validator.Injection.DATA_TYPE_VERIFIED=This option causes the specified data 
type to be verified.
@@ -70,6 +71,8 @@ ValidatorDialog.ButtonAddAllowed.Label=\    Add
 ValidatorDialog.ButtonRemoveAllowed.Label=\ Remove 
 ValidatorDialog.ClearButton.Label=Remove validation
 ValidatorDialog.ConcatErrors.Label=Output one row, concatenate errors with 
separator
+ValidatorDialog.SuppressLogFailedData.Label=Do not log failed validation data
+ValidatorDialog.SuppressLogFailedData.Tooltip=When enabled, failed field/row 
values are omitted from validation error messages (shown as "?"). Error 
messages are still logged. Error rows are still sent to the error handling hop 
when configured.
 ValidatorDialog.ConversionMask.Label=Conversion mask
 ValidatorDialog.DataGroup.Label=Data
 ValidatorDialog.DataType.Label=Data type
diff --git 
a/plugins/transforms/validator/src/main/resources/org/apache/hop/pipeline/transforms/validator/messages/messages_zh_CN.properties
 
b/plugins/transforms/validator/src/main/resources/org/apache/hop/pipeline/transforms/validator/messages/messages_zh_CN.properties
index 9f7a130079..f590eb1006 100644
--- 
a/plugins/transforms/validator/src/main/resources/org/apache/hop/pipeline/transforms/validator/messages/messages_zh_CN.properties
+++ 
b/plugins/transforms/validator/src/main/resources/org/apache/hop/pipeline/transforms/validator/messages/messages_zh_CN.properties
@@ -23,3 +23,6 @@ 
Validator.Exception.DataConversionErrorEncountered=\u5728\u8FDB\u884C\u9A8C\u8BC
 Validator.Exception.DoesNotEndWithString=\u5728\u9A8C\u8BC1\u5B57\u6BB5 
''{0}'' \u65F6\uFF0C\u6211\u4EEC\u53D1\u73B0\u5B57\u7B26\u4E32\u503C [{1}] 
\u5E76\u672A\u4EE5\u5B57\u7B26\u4E32 [{2}] \u7ED3\u5C3E
 Validator.Exception.DoesNotStartWithString=\u5728\u9A8C\u8BC1\u5B57\u6BB5 
''{0}'' \u65F6\uFF0C\u5B57\u7B26\u4E32\u503C [{1}] 
\u5E76\u672A\u4EE5\u5B57\u7B26\u4E32 [{2}] \u5F00\u5934
 
Validator.Exception.EndsWithString=\u5728\u9A8C\u8BC1\u5B57\u6BB5\u201C{0}\u201D\u65F6\uFF0C\u6211\u4EEC\u53D1\u73B0\u5B57\u7B26\u4E32\u503C
 [{1}] \u4EE5\u5B57\u7B26\u4E32 [{2}] \u7ED3\u5C3E
+Validator.Injection.SUPPRESS_LOG_FAILED_DATA=\u542F\u7528\u540E\uFF0C\u5199\u5165\u65E5\u5FD7\u7684\u6821\u9A8C\u9519\u8BEF\u6D88\u606F\u4E2D\u4E0D\u5305\u542B\u5931\u8D25\u5B57\u6BB5/\u884C\u6570\u636E\u3002\u9519\u8BEF\u884C\u4ECD\u4F1A\u53D1\u9001\u5230\u9519\u8BEF\u5904\u7406\u8DEF\u5F84\u3002
+ValidatorDialog.SuppressLogFailedData.Label=\u4E0D\u8BB0\u5F55\u6821\u9A8C\u5931\u8D25\u7684\u6570\u636E
+ValidatorDialog.SuppressLogFailedData.Tooltip=\u542F\u7528\u540E\uFF0C\u6821\u9A8C\u9519\u8BEF\u6D88\u606F\u4E2D\u7684\u5931\u8D25\u5B57\u6BB5/\u884C\u6570\u636E\u4F1A\u88AB\u7701\u7565\uFF08\u663E\u793A\u4E3A\u201C?\u201D\uFF09\uFF0C\u4F46\u4ECD\u4F1A\u6253\u5370\u9519\u8BEF\u65E5\u5FD7\u3002\u82E5\u914D\u7F6E\u4E86\u9519\u8BEF\u5904\u7406\uFF0C\u9519\u8BEF\u884C\u4ECD\u4F1A\u53D1\u9001\u5230\u9519\u8BEF\u8DEF\u5F84\u3002
diff --git 
a/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/HopValidatorExceptionTest.java
 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/HopValidatorExceptionTest.java
new file mode 100644
index 0000000000..fa8cd4d77a
--- /dev/null
+++ 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/HopValidatorExceptionTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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.validator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link HopValidatorException}. */
+class HopValidatorExceptionTest {
+
+  private Validator validator;
+
+  @BeforeEach
+  void setUp() {
+    validator = mock(Validator.class);
+    when(validator.resolve(anyString())).thenAnswer(invocation -> 
invocation.getArgument(0));
+  }
+
+  @Test
+  void exposesCodeFieldNameAndValidation() {
+    Validation field = new Validation();
+    field.setName("rule");
+    HopValidatorException exception =
+        new HopValidatorException(
+            validator,
+            field,
+            HopValidatorException.ERROR_NULL_VALUE_NOT_ALLOWED,
+            "null not allowed",
+            "value");
+
+    assertEquals(HopValidatorException.ERROR_NULL_VALUE_NOT_ALLOWED, 
exception.getCode());
+    assertEquals("value", exception.getFieldName());
+    assertSame(field, exception.getValidatorField());
+    assertTrue(exception.getMessage().contains("null not allowed"));
+  }
+
+  @Test
+  void getCodeDescUsesBuiltInCodeWhenNoCustomErrorCode() {
+    Validation field = new Validation();
+    HopValidatorException exception =
+        new HopValidatorException(
+            validator,
+            field,
+            HopValidatorException.ERROR_LONGER_THAN_MAXIMUM_LENGTH,
+            "too long",
+            "value");
+
+    assertEquals("KVD002", exception.getCodeDesc());
+  }
+
+  @Test
+  void getCodeDescUsesResolvedCustomErrorCode() {
+    Validation field = new Validation();
+    field.setErrorCode("${ERR_CODE}");
+    when(validator.resolve("${ERR_CODE}")).thenReturn("CUSTOM_CODE");
+
+    HopValidatorException exception =
+        new HopValidatorException(
+            validator,
+            field,
+            HopValidatorException.ERROR_VALUE_NOT_IN_LIST,
+            "not in list",
+            "value");
+
+    assertEquals("CUSTOM_CODE", exception.getCodeDesc());
+  }
+
+  @Test
+  void getMessageUsesResolvedCustomErrorDescription() {
+    Validation field = new Validation();
+    field.setErrorDescription("${ERR_DESC}");
+    when(validator.resolve("${ERR_DESC}")).thenReturn("custom description");
+
+    HopValidatorException exception =
+        new HopValidatorException(
+            validator,
+            field,
+            HopValidatorException.ERROR_SHORTER_THAN_MINIMUM_LENGTH,
+            "built-in message",
+            "value");
+
+    assertTrue(exception.getMessage().contains("custom description"));
+  }
+
+  @Test
+  void getMessageFallsBackToBuiltInMessage() {
+    Validation field = new Validation();
+    HopValidatorException exception =
+        new HopValidatorException(
+            validator,
+            field,
+            HopValidatorException.ERROR_ONLY_NULL_VALUE_ALLOWED,
+            "built-in message",
+            "value");
+
+    assertTrue(exception.getMessage().contains("built-in message"));
+  }
+
+  @Test
+  void allBuiltInErrorCodesAreMapped() {
+    Validation field = new Validation();
+    String[] expected = {
+      "KVD000", "KVD001", "KVD002", "KVD003", "KVD004", "KVD005", "KVD006", 
"KVD007", "KVD008",
+      "KVD009", "KVD010", "KVD011", "KVD012", "KVD013", "KVD014", "KVD015",
+    };
+
+    for (int code = 0; code < expected.length; code++) {
+      HopValidatorException exception =
+          new HopValidatorException(validator, field, code, "msg", "field");
+      assertEquals(expected[code], exception.getCodeDesc(), "code " + code);
+    }
+  }
+}
diff --git 
a/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidationTest.java
 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidationTest.java
new file mode 100644
index 0000000000..02e2a782da
--- /dev/null
+++ 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidationTest.java
@@ -0,0 +1,157 @@
+/*
+ * 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.validator;
+
+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.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link Validation}. */
+class ValidationTest {
+
+  @Test
+  void defaultConstructorSetsSafeDefaults() {
+    Validation validation = new Validation();
+
+    assertTrue(validation.isNullAllowed());
+    assertFalse(validation.isOnlyNullAllowed());
+    assertFalse(validation.isOnlyNumericAllowed());
+    assertEquals("", validation.getMaximumLength());
+    assertEquals("", validation.getMinimumLength());
+    assertNotNull(validation.getAllowedValues());
+    assertTrue(validation.getAllowedValues().isEmpty());
+  }
+
+  @Test
+  void nameConstructorSetsFieldName() {
+    Validation validation = new Validation("email");
+    assertEquals("email", validation.getFieldName());
+    assertTrue(validation.isNullAllowed());
+  }
+
+  @Test
+  void copyConstructorAndCloneAreDeepForAllowedValues() {
+    Validation original = new Validation();
+    original.setName("rule");
+    original.setFieldName("field");
+    original.setMaximumLength("10");
+    original.setMinimumLength("1");
+    original.setNullAllowed(false);
+    original.setOnlyNullAllowed(true);
+    original.setOnlyNumericAllowed(true);
+    original.setDataType("String");
+    original.setDataTypeVerified(true);
+    original.setConversionMask("#");
+    original.setDecimalSymbol(".");
+    original.setGroupingSymbol(",");
+    original.setSourcingValues(true);
+    original.setSourcingTransformName("source");
+    original.setSourcingField("allowed");
+    original.setMinimumValue("1");
+    original.setMaximumValue("9");
+    original.setStartString("A");
+    original.setEndString("Z");
+    original.setStartStringNotAllowed("X");
+    original.setEndStringNotAllowed("Y");
+    original.setRegularExpression(".*");
+    original.setRegularExpressionNotAllowed("bad");
+    original.setErrorCode("E1");
+    original.setErrorDescription("desc");
+    original.setAllowedValues(new ArrayList<>(List.of("A", "B")));
+    TransformMeta sourcing = new TransformMeta();
+    sourcing.setName("source");
+    original.setSourcingTransform(sourcing);
+
+    Validation copy = new Validation(original);
+    Validation clone = original.clone();
+
+    assertNotSame(original, copy);
+    assertNotSame(original.getAllowedValues(), copy.getAllowedValues());
+    assertEquals(original.getName(), copy.getName());
+    assertEquals(original.getFieldName(), copy.getFieldName());
+    assertEquals(original.getMaximumLength(), copy.getMaximumLength());
+    assertEquals(original.getMinimumLength(), copy.getMinimumLength());
+    assertEquals(original.isNullAllowed(), copy.isNullAllowed());
+    assertEquals(original.isOnlyNullAllowed(), copy.isOnlyNullAllowed());
+    assertEquals(original.isOnlyNumericAllowed(), copy.isOnlyNumericAllowed());
+    assertEquals(original.getDataType(), copy.getDataType());
+    assertEquals(original.isDataTypeVerified(), copy.isDataTypeVerified());
+    assertEquals(original.getConversionMask(), copy.getConversionMask());
+    assertEquals(original.getDecimalSymbol(), copy.getDecimalSymbol());
+    assertEquals(original.getGroupingSymbol(), copy.getGroupingSymbol());
+    assertEquals(original.isSourcingValues(), copy.isSourcingValues());
+    assertEquals(original.getSourcingTransformName(), 
copy.getSourcingTransformName());
+    assertEquals(original.getSourcingField(), copy.getSourcingField());
+    assertEquals(original.getMinimumValue(), copy.getMinimumValue());
+    assertEquals(original.getMaximumValue(), copy.getMaximumValue());
+    assertEquals(original.getStartString(), copy.getStartString());
+    assertEquals(original.getEndString(), copy.getEndString());
+    assertEquals(original.getStartStringNotAllowed(), 
copy.getStartStringNotAllowed());
+    assertEquals(original.getEndStringNotAllowed(), 
copy.getEndStringNotAllowed());
+    assertEquals(original.getRegularExpression(), copy.getRegularExpression());
+    assertEquals(original.getRegularExpressionNotAllowed(), 
copy.getRegularExpressionNotAllowed());
+    assertEquals(original.getErrorCode(), copy.getErrorCode());
+    assertEquals(original.getErrorDescription(), copy.getErrorDescription());
+    assertEquals(original.getAllowedValues(), copy.getAllowedValues());
+    assertSameSourcingTransform(original, copy);
+
+    assertEquals(original.getName(), clone.getName());
+    assertNotSame(original.getAllowedValues(), clone.getAllowedValues());
+
+    copy.getAllowedValues().add("C");
+    assertEquals(2, original.getAllowedValues().size());
+    assertEquals(3, copy.getAllowedValues().size());
+  }
+
+  @Test
+  void equalsIgnoresCaseOnName() {
+    Validation left = new Validation();
+    left.setName("RuleOne");
+    Validation right = new Validation();
+    right.setName("ruleone");
+
+    assertTrue(left.equals(right));
+    assertTrue(right.equals(left));
+  }
+
+  @Test
+  void findValidationMatchesCaseInsensitively() {
+    Validation first = new Validation();
+    first.setName("Alpha");
+    Validation second = new Validation();
+    second.setName("Beta");
+    List<Validation> list = List.of(first, second);
+
+    assertEquals(first, Validation.findValidation(list, "alpha"));
+    assertEquals(second, Validation.findValidation(list, "BETA"));
+    assertNull(Validation.findValidation(list, "gamma"));
+  }
+
+  private static void assertSameSourcingTransform(Validation original, 
Validation copy) {
+    assertSame(original.getSourcingTransform(), copy.getSourcingTransform());
+  }
+}
diff --git 
a/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorDataTest.java
 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorDataTest.java
new file mode 100644
index 0000000000..479853ccb8
--- /dev/null
+++ 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorDataTest.java
@@ -0,0 +1,42 @@
+/*
+ * 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.validator;
+
+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 org.apache.hop.pipeline.transform.ITransformData;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link ValidatorData}. */
+class ValidatorDataTest {
+
+  @Test
+  void constructorCreatesUsableTransformData() {
+    ValidatorData data = new ValidatorData();
+
+    assertNotNull(data);
+    assertInstanceOf(ITransformData.class, data);
+    assertNull(data.fieldIndexes);
+    assertNull(data.constantsMeta);
+    assertNull(data.inputRowMeta);
+    assertNull(data.patternExpected);
+    assertNull(data.patternDisallowed);
+  }
+}
diff --git 
a/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorDialogSelectionTest.java
 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorDialogSelectionTest.java
new file mode 100644
index 0000000000..00fcd704f8
--- /dev/null
+++ 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorDialogSelectionTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.validator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for non-UI logic in {@link ValidatorDialog}. Full SWT dialog 
coverage is out of scope
+ * for headless unit tests.
+ */
+class ValidatorDialogSelectionTest {
+
+  private ValidatorDialog dialog;
+
+  @BeforeEach
+  void setUp() {
+    dialog = mock(ValidatorDialog.class);
+    when(dialog.spacesValidation(anyString())).thenCallRealMethod();
+    when(dialog.spacesValidation(null)).thenCallRealMethod();
+    when(dialog.trailingSpacesValidation(anyString())).thenCallRealMethod();
+    when(dialog.trailingSpacesValidation(null)).thenCallRealMethod();
+  }
+
+  @Test
+  void indexAfterRemovalSelectsSameIndexWhenItemsRemainAfter() {
+    // Remove index 0 from [A,B,C] -> remaining [B,C], select B (index 0)
+    assertEquals(0, ValidatorDialog.indexAfterRemoval(0, 2));
+  }
+
+  @Test
+  void indexAfterRemovalSelectsPreviousWhenLastItemRemoved() {
+    // Remove index 2 from [A,B,C] -> remaining [A,B], select B (index 1)
+    assertEquals(1, ValidatorDialog.indexAfterRemoval(2, 2));
+  }
+
+  @Test
+  void indexAfterRemovalSelectsMiddleNeighbor() {
+    // Remove index 1 from [A,B,C] -> remaining [A,C], select C (index 1)
+    assertEquals(1, ValidatorDialog.indexAfterRemoval(1, 2));
+  }
+
+  @Test
+  void indexAfterRemovalReturnsMinusOneWhenEmpty() {
+    assertEquals(-1, ValidatorDialog.indexAfterRemoval(0, 0));
+  }
+
+  @Test
+  void indexAfterRemovalKeepsSingleRemainingItem() {
+    assertEquals(0, ValidatorDialog.indexAfterRemoval(0, 1));
+    assertEquals(0, ValidatorDialog.indexAfterRemoval(5, 1));
+  }
+
+  @Test
+  void spacesValidationDetectsOnlySpaces() {
+    assertTrue(dialog.spacesValidation("   "));
+    assertFalse(dialog.spacesValidation("a"));
+    assertFalse(dialog.spacesValidation(" a "));
+    assertFalse(dialog.spacesValidation(""));
+    assertFalse(dialog.spacesValidation(null));
+  }
+
+  @Test
+  void trailingSpacesValidationDetectsTrailingSpace() {
+    assertTrue(dialog.trailingSpacesValidation("value "));
+    assertTrue(dialog.trailingSpacesValidation(" "));
+    assertFalse(dialog.trailingSpacesValidation("value"));
+    assertFalse(dialog.trailingSpacesValidation(""));
+    assertFalse(dialog.trailingSpacesValidation(null));
+  }
+}
diff --git 
a/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorMetaTest.java
 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorMetaTest.java
new file mode 100644
index 0000000000..c65f32fbca
--- /dev/null
+++ 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorMetaTest.java
@@ -0,0 +1,282 @@
+/*
+ * 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.validator;
+
+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.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.TransformSerializationTestUtil;
+import org.apache.hop.pipeline.transform.stream.IStream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/** Unit test for {@link ValidatorMeta} */
+class ValidatorMetaTest {
+  @RegisterExtension
+  static RestoreHopEngineEnvironmentExtension env = new 
RestoreHopEngineEnvironmentExtension();
+
+  @BeforeEach
+  void setUp() throws Exception {
+    HopEnvironment.init();
+    PluginRegistry.init();
+  }
+
+  @Test
+  void testSerialization() throws Exception {
+    ValidatorMeta meta =
+        TransformSerializationTestUtil.testSerialization(
+            "/validator-transform.xml", ValidatorMeta.class);
+
+    assertTrue(meta.isValidatingAll());
+    assertTrue(meta.isConcatenatingErrors());
+    assertEquals(";", meta.getConcatenationSeparator());
+    assertTrue(meta.isSuppressingLogFailedData());
+    assertEquals(1, meta.getValidations().size());
+
+    Validation validation = meta.getValidations().getFirst();
+    assertEquals("value rules", validation.getName());
+    assertEquals("value", validation.getFieldName());
+    assertFalse(validation.isNullAllowed());
+    assertEquals("10", validation.getMaximumLength());
+    assertEquals("1", validation.getMinimumLength());
+    assertEquals("CODE_NULL", validation.getErrorCode());
+    assertEquals(2, validation.getAllowedValues().size());
+    assertEquals("A", validation.getAllowedValues().get(0));
+    assertEquals("B", validation.getAllowedValues().get(1));
+  }
+
+  @Test
+  void testCloneCopiesOptionsAndValidations() {
+    ValidatorMeta meta = new ValidatorMeta();
+    meta.setValidatingAll(true);
+    meta.setConcatenatingErrors(true);
+    meta.setConcatenationSeparator("|");
+    meta.setSuppressingLogFailedData(true);
+
+    Validation validation = new Validation();
+    validation.setName("rule1");
+    validation.setFieldName("field1");
+    validation.setNullAllowed(false);
+    meta.getValidations().add(validation);
+
+    ValidatorMeta clone = meta.clone();
+    assertNotSame(meta, clone);
+    assertNotSame(meta.getValidations(), clone.getValidations());
+    assertNotSame(meta.getValidations().getFirst(), 
clone.getValidations().getFirst());
+    assertTrue(clone.isValidatingAll());
+    assertTrue(clone.isConcatenatingErrors());
+    assertEquals("|", clone.getConcatenationSeparator());
+    assertTrue(clone.isSuppressingLogFailedData());
+    assertEquals(1, clone.getValidations().size());
+    assertEquals("rule1", clone.getValidations().getFirst().getName());
+    assertEquals("field1", clone.getValidations().getFirst().getFieldName());
+  }
+
+  @Test
+  void testSupportsErrorHandling() {
+    assertTrue(new ValidatorMeta().supportsErrorHandling());
+  }
+
+  @Test
+  void testSuppressLogFailedDataDefaultsToFalse() {
+    assertFalse(new ValidatorMeta().isSuppressingLogFailedData());
+  }
+
+  @Test
+  void checkNoPrevFieldsAddsWarning() {
+    ValidatorMeta meta = new ValidatorMeta();
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        null,
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_WARNING));
+  }
+
+  @Test
+  void checkEmptyPrevFieldsAddsWarning() {
+    ValidatorMeta meta = new ValidatorMeta();
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_WARNING));
+  }
+
+  @Test
+  void checkWithPrevAndInputAddsOkRemarks() {
+    ValidatorMeta meta = new ValidatorMeta();
+    RowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("field1"));
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        prev,
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertEquals(2, remarks.size());
+    assertEquals(ICheckResult.TYPE_RESULT_OK, remarks.get(0).getType());
+    assertEquals(ICheckResult.TYPE_RESULT_OK, remarks.get(1).getType());
+  }
+
+  @Test
+  void checkNoInputAddsError() {
+    ValidatorMeta meta = new ValidatorMeta();
+    RowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("field1"));
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        prev,
+        new String[0],
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void getTransformIOMetaCreatesInfoStreamPerValidation() {
+    ValidatorMeta meta = new ValidatorMeta();
+    Validation validation = new Validation();
+    validation.setName("rule1");
+    validation.setSourcingTransformName("source");
+    meta.getValidations().add(validation);
+
+    ITransformIOMeta ioMeta = meta.getTransformIOMeta();
+    assertNotNull(ioMeta);
+    assertEquals(1, ioMeta.getInfoStreams().size());
+    assertEquals(1, meta.getTransformIOMeta().getInfoStreams().size());
+  }
+
+  @Test
+  void searchInfoAndTargetTransformsResolvesSourcingTransform() {
+    ValidatorMeta meta = new ValidatorMeta();
+    Validation validation = new Validation();
+    validation.setName("rule1");
+    validation.setSourcingTransformName("source");
+    meta.getValidations().add(validation);
+
+    TransformMeta source = new TransformMeta();
+    source.setName("source");
+    meta.searchInfoAndTargetTransforms(List.of(source));
+
+    assertSame(source, validation.getSourcingTransform());
+  }
+
+  @Test
+  void searchInfoAndTargetTransformsClearsMissingSource() {
+    ValidatorMeta meta = new ValidatorMeta();
+    Validation validation = new Validation();
+    validation.setName("rule1");
+    validation.setSourcingTransformName("missing");
+    meta.getValidations().add(validation);
+
+    meta.searchInfoAndTargetTransforms(List.of());
+    assertNull(validation.getSourcingTransform());
+  }
+
+  @Test
+  void handleStreamSelectionAddsValidationForNewValidationStream() {
+    ValidatorMeta meta = new ValidatorMeta();
+    TransformMeta source = new TransformMeta();
+    source.setName("allowed_values");
+
+    IStream newValidation = ValidatorMeta.getNewValidation();
+    newValidation.setTransformMeta(source);
+
+    meta.handleStreamSelection(newValidation);
+
+    assertEquals(1, meta.getValidations().size());
+    Validation created = meta.getValidations().getFirst();
+    assertEquals("allowed_values", created.getName());
+    assertTrue(created.isSourcingValues());
+    assertSame(source, created.getSourcingTransform());
+  }
+
+  @Test
+  void handleStreamSelectionIgnoresUnrelatedStream() {
+    ValidatorMeta meta = new ValidatorMeta();
+    IStream other = mock(IStream.class);
+
+    meta.handleStreamSelection(other);
+    assertTrue(meta.getValidations().isEmpty());
+  }
+
+  @Test
+  void newValidationStreamAccessorRoundTrip() {
+    IStream original = ValidatorMeta.getNewValidation();
+    assertNotNull(original);
+
+    IStream replacement = mock(IStream.class);
+    try {
+      ValidatorMeta.setNewValidation(replacement);
+      assertSame(replacement, ValidatorMeta.getNewValidation());
+    } finally {
+      ValidatorMeta.setNewValidation(original);
+    }
+    assertSame(original, ValidatorMeta.getNewValidation());
+  }
+}
diff --git 
a/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorTest.java
 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorTest.java
new file mode 100644
index 0000000000..8d7b8b203c
--- /dev/null
+++ 
b/plugins/transforms/validator/src/test/java/org/apache/hop/pipeline/transforms/validator/ValidatorTest.java
@@ -0,0 +1,865 @@
+/*
+ * 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.validator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/** Unit test for {@link Validator} */
+class ValidatorTest {
+
+  @RegisterExtension
+  static RestoreHopEngineEnvironmentExtension env = new 
RestoreHopEngineEnvironmentExtension();
+
+  private TransformMockHelper<ValidatorMeta, ValidatorData> helper;
+
+  @BeforeAll
+  static void setUpBeforeClass() throws HopException {
+    HopEnvironment.init();
+  }
+
+  @BeforeEach
+  void setUp() throws Exception {
+    helper = new TransformMockHelper<>("Validator", ValidatorMeta.class, 
ValidatorData.class);
+    when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(helper.iLogChannel);
+    when(helper.pipeline.isRunning()).thenReturn(true);
+    when(helper.transformMeta.getName()).thenReturn("Validator");
+  }
+
+  @AfterEach
+  void tearDown() {
+    helper.cleanUp();
+  }
+
+  @Test
+  void testValidRowIsPassedThrough() throws Exception {
+    ValidatorMeta meta = createMeta(nullNotAllowed());
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"ok"}).doReturn(null).when(validator).getRow();
+    doNothing().when(validator).putRow(any(IRowMeta.class), 
any(Object[].class));
+
+    assertTrue(validator.processRow());
+    verify(validator).putRow(any(IRowMeta.class), eq(new Object[] {"ok"}));
+    verify(validator, never())
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            anyLong(),
+            anyString(),
+            anyString(),
+            anyString());
+    assertFalse(validator.processRow());
+  }
+
+  @Test
+  void testNullNotAllowedPutsError() throws Exception {
+    ValidatorMeta meta = createMeta(nullNotAllowed());
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {null}).doReturn(null).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD001"));
+  }
+
+  @Test
+  void testNullNotAllowedWithoutErrorHandlingThrows() throws Exception {
+    ValidatorMeta meta = createMeta(nullNotAllowed());
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, false);
+
+    doReturn(new Object[] {null}).when(validator).getRow();
+
+    HopException exception = assertThrows(HopException.class, 
validator::processRow);
+    assertTrue(exception.getMessage().contains("null"));
+  }
+
+  @Test
+  void testMaximumLengthViolation() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("max length");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setMaximumLength("3");
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"toolong"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            contains("toolong"),
+            eq("value"),
+            eq("KVD002"));
+  }
+
+  @Test
+  void testMinimumLengthViolation() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("min length");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setMinimumLength("5");
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"abc"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            contains("abc"),
+            eq("value"),
+            eq("KVD003"));
+  }
+
+  @Test
+  void testValueNotInAllowedList() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("list");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setDataType("String");
+    rule.setAllowedValues(List.of("A", "B"));
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"C"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            contains("C"),
+            eq("value"),
+            eq("KVD007"));
+  }
+
+  @Test
+  void testOnlyNumericAllowed() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("numeric");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setOnlyNumericAllowed(true);
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"12a"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD008"));
+  }
+
+  @Test
+  void testRegularExpressionExpected() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("regex");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setRegularExpression("^[0-9]+$");
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"abc"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD013"));
+  }
+
+  @Test
+  void testMinimumValueViolation() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("min value");
+    rule.setFieldName("amount");
+    rule.setNullAllowed(true);
+    rule.setDataType("Integer");
+    rule.setMinimumValue("10");
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaInteger("amount"));
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {5L}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("amount"),
+            eq("KVD005"));
+  }
+
+  @Test
+  void testValidateAllConcatenatesErrorsIntoOneRow() throws Exception {
+    Validation lengthRule = new Validation();
+    lengthRule.setName("length");
+    lengthRule.setFieldName("value");
+    lengthRule.setNullAllowed(true);
+    lengthRule.setMaximumLength("1");
+
+    Validation onlyNull = new Validation();
+    onlyNull.setName("only null");
+    onlyNull.setFieldName("value");
+    onlyNull.setNullAllowed(true);
+    onlyNull.setOnlyNullAllowed(true);
+
+    ValidatorMeta meta = createMeta(lengthRule, onlyNull);
+    meta.setValidatingAll(true);
+    meta.setConcatenatingErrors(true);
+    meta.setConcatenationSeparator(";");
+
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"ab"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(2L),
+            contains(";"),
+            contains(";"),
+            contains(";"));
+  }
+
+  @Test
+  void testCustomErrorCodeAndDescription() throws Exception {
+    Validation rule = nullNotAllowed();
+    rule.setErrorCode("MY_CODE");
+    rule.setErrorDescription("custom description");
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {null}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            eq("custom description"),
+            eq("value"),
+            eq("MY_CODE"));
+  }
+
+  @Test
+  void testSuppressLogFailedDataOmitsValuesFromErrorMessages() throws 
Exception {
+    Validation rule = new Validation();
+    rule.setName("max length");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setMaximumLength("3");
+
+    ValidatorMeta meta = createMeta(rule);
+    meta.setSuppressingLogFailedData(true);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"toolong"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            contains(Validator.OMITTED_VALUE),
+            eq("value"),
+            eq("KVD002"));
+    verify(validator, never())
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            anyLong(),
+            contains("toolong"),
+            anyString(),
+            anyString());
+  }
+
+  @Test
+  void testSuppressLogFailedDataStillLogsCustomErrorDescription() throws 
Exception {
+    Validation rule = nullNotAllowed();
+    rule.setErrorCode("name_valid_code");
+    rule.setErrorDescription("name_valid_desc");
+
+    ValidatorMeta meta = createMeta(rule);
+    meta.setSuppressingLogFailedData(true);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {null}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            eq("name_valid_desc"),
+            eq("value"),
+            eq("name_valid_code"));
+  }
+
+  @Test
+  void testValueAndRowForMessageRespectSuppressFlag() throws Exception {
+    ValidatorMeta meta = new ValidatorMeta();
+    ValidatorData data = new ValidatorData();
+    Validator validator =
+        new Validator(helper.transformMeta, meta, data, 0, 
helper.pipelineMeta, helper.pipeline);
+
+    ValueMetaString valueMeta = new ValueMetaString("value");
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(valueMeta);
+    Object[] row = new Object[] {"secret"};
+
+    meta.setSuppressingLogFailedData(false);
+    assertEquals("secret", validator.getValueForMessage(valueMeta, "secret"));
+    assertEquals(rowMeta.getString(row), validator.getRowForMessage(rowMeta, 
row));
+
+    meta.setSuppressingLogFailedData(true);
+    assertEquals(Validator.OMITTED_VALUE, 
validator.getValueForMessage(valueMeta, "secret"));
+    assertEquals(Validator.OMITTED_VALUE, validator.getRowForMessage(rowMeta, 
row));
+  }
+
+  @Test
+  void testAssertNumericAllowsDigitsAndNumericTypes() throws Exception {
+    ValidatorMeta meta = new ValidatorMeta();
+    ValidatorData data = new ValidatorData();
+    Validator validator =
+        new Validator(helper.transformMeta, meta, data, 0, 
helper.pipelineMeta, helper.pipeline);
+
+    Validation field = new Validation();
+    field.setFieldName("value");
+    ValueMetaString stringMeta = new ValueMetaString("value");
+    ValueMetaInteger integerMeta = new ValueMetaInteger("value");
+
+    assertNull(validator.assertNumeric(stringMeta, "12345", field));
+    assertNull(validator.assertNumeric(integerMeta, 42L, field));
+    HopValidatorException exception = validator.assertNumeric(stringMeta, 
"12a", field);
+    assertEquals(HopValidatorException.ERROR_NON_NUMERIC_DATA, 
exception.getCode());
+  }
+
+  @Test
+  void testStartAndEndStringRules() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("prefix");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setStartString("AB");
+    rule.setEndString("Z");
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = stringRowMeta();
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {"ABX"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD010"));
+  }
+
+  @Test
+  void testDoesNotStartWithExpectedString() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("start");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setStartString("AB");
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"XX"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD009"));
+  }
+
+  @Test
+  void testMaximumValueViolation() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("max value");
+    rule.setFieldName("amount");
+    rule.setNullAllowed(true);
+    rule.setDataType("Integer");
+    rule.setMaximumValue("10");
+
+    ValidatorMeta meta = createMeta(rule);
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaInteger("amount"));
+    Validator validator = createInitializedValidator(meta, rowMeta, true);
+
+    doReturn(new Object[] {11L}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("amount"),
+            eq("KVD006"));
+  }
+
+  @Test
+  void testOnlyNullAllowedViolation() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("only null");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setOnlyNullAllowed(true);
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"x"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD015"));
+  }
+
+  @Test
+  void testUnexpectedDataTypeViolation() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("type");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setDataType("Integer");
+    rule.setDataTypeVerified(true);
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"abc"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD004"));
+  }
+
+  @Test
+  void testAllowedValueInListPasses() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("list");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setDataType("String");
+    rule.setAllowedValues(List.of("A", "B"));
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"A"}).doReturn(null).when(validator).getRow();
+    doNothing().when(validator).putRow(any(IRowMeta.class), 
any(Object[].class));
+
+    assertTrue(validator.processRow());
+    verify(validator).putRow(any(IRowMeta.class), eq(new Object[] {"A"}));
+    verify(validator, never())
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            anyLong(),
+            anyString(),
+            anyString(),
+            anyString());
+  }
+
+  @Test
+  void testDisallowedRegularExpression() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("bad regex");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setRegularExpressionNotAllowed("^[0-9]+$");
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"123"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD014"));
+  }
+
+  @Test
+  void testStartsWithNotAllowedString() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("bad start");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setStartStringNotAllowed("XX");
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"XXY"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD011"));
+  }
+
+  @Test
+  void testEndsWithNotAllowedString() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("bad end");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setEndStringNotAllowed("YY");
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"aYY"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            eq("KVD012"));
+  }
+
+  @Test
+  void testValidateAllWithoutConcatenateEmitsMultipleErrors() throws Exception 
{
+    Validation lengthRule = new Validation();
+    lengthRule.setName("length");
+    lengthRule.setFieldName("value");
+    lengthRule.setNullAllowed(true);
+    lengthRule.setMaximumLength("1");
+
+    Validation onlyNull = new Validation();
+    onlyNull.setName("only null");
+    onlyNull.setFieldName("value");
+    onlyNull.setNullAllowed(true);
+    onlyNull.setOnlyNullAllowed(true);
+
+    ValidatorMeta meta = createMeta(lengthRule, onlyNull);
+    meta.setValidatingAll(true);
+    meta.setConcatenatingErrors(false);
+
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+    doReturn(new Object[] {"ab"}).when(validator).getRow();
+    stubPutError(validator);
+
+    assertTrue(validator.processRow());
+    verify(validator, times(2))
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            eq(1L),
+            anyString(),
+            eq("value"),
+            anyString());
+  }
+
+  @Test
+  void testNullInputEndsProcessing() throws Exception {
+    ValidatorMeta meta = createMeta(nullNotAllowed());
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(null).when(validator).getRow();
+
+    assertFalse(validator.processRow());
+  }
+
+  @Test
+  void testMissingFieldNameFailsInitCalculation() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("missing field");
+    rule.setFieldName("does_not_exist");
+    rule.setNullAllowed(true);
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"x"}).when(validator).getRow();
+
+    HopException exception = assertThrows(HopException.class, 
validator::processRow);
+    assertTrue(exception.getMessage().contains("does_not_exist"));
+  }
+
+  @Test
+  void testEmptyFieldNameFailsOnProcess() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("empty field name");
+    rule.setFieldName("");
+    rule.setNullAllowed(true);
+
+    ValidatorMeta meta = createMeta(rule);
+    when(helper.transformMeta.isDoingErrorHandling()).thenReturn(true);
+    when(helper.pipelineMeta.getTransformFields(any(IVariables.class), 
anyString()))
+        .thenReturn(stringRowMeta());
+
+    ValidatorData data = new ValidatorData();
+    Validator validator =
+        spy(
+            new Validator(
+                helper.transformMeta, meta, data, 0, helper.pipelineMeta, 
helper.pipeline));
+    validator.setInputRowMeta(stringRowMeta());
+    assertTrue(validator.init());
+
+    doReturn(new Object[] {"x"}).when(validator).getRow();
+
+    HopException exception = assertThrows(HopException.class, 
validator::processRow);
+    assertTrue(exception.getMessage().toLowerCase().contains("no name"));
+  }
+
+  @Test
+  void testInitFailsForInvalidMinimumLength() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("bad length");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setMinimumLength("abc");
+
+    ValidatorMeta meta = createMeta(rule);
+    when(helper.pipelineMeta.getTransformFields(any(IVariables.class), 
anyString()))
+        .thenReturn(stringRowMeta());
+
+    Validator validator =
+        new Validator(
+            helper.transformMeta,
+            meta,
+            new ValidatorData(),
+            0,
+            helper.pipelineMeta,
+            helper.pipeline);
+
+    assertFalse(validator.init());
+  }
+
+  @Test
+  void testValidStartAndEndStringPasses() throws Exception {
+    Validation rule = new Validation();
+    rule.setName("prefix-suffix");
+    rule.setFieldName("value");
+    rule.setNullAllowed(true);
+    rule.setStartString("AB");
+    rule.setEndString("Z");
+
+    ValidatorMeta meta = createMeta(rule);
+    Validator validator = createInitializedValidator(meta, stringRowMeta(), 
true);
+
+    doReturn(new Object[] {"ABZ"}).doReturn(null).when(validator).getRow();
+    doNothing().when(validator).putRow(any(IRowMeta.class), 
any(Object[].class));
+
+    assertTrue(validator.processRow());
+    verify(validator).putRow(any(IRowMeta.class), eq(new Object[] {"ABZ"}));
+  }
+
+  private Validator createInitializedValidator(
+      ValidatorMeta meta, RowMeta rowMeta, boolean errorHandling) throws 
Exception {
+    
when(helper.transformMeta.isDoingErrorHandling()).thenReturn(errorHandling);
+    when(helper.pipelineMeta.getTransformFields(any(IVariables.class), 
anyString()))
+        .thenReturn(rowMeta);
+
+    ValidatorData data = new ValidatorData();
+    Validator validator =
+        spy(
+            new Validator(
+                helper.transformMeta, meta, data, 0, helper.pipelineMeta, 
helper.pipeline));
+    validator.setInputRowMeta(rowMeta);
+
+    assertTrue(validator.init());
+    return validator;
+  }
+
+  private static void stubPutError(Validator validator) throws HopException {
+    doNothing()
+        .when(validator)
+        .putError(
+            any(IRowMeta.class),
+            any(Object[].class),
+            anyLong(),
+            anyString(),
+            nullable(String.class),
+            anyString());
+  }
+
+  private static ValidatorMeta createMeta(Validation... validations) {
+    ValidatorMeta meta = new ValidatorMeta();
+    meta.setValidations(new ArrayList<>(List.of(validations)));
+    return meta;
+  }
+
+  private static Validation nullNotAllowed() {
+    Validation rule = new Validation();
+    rule.setName("value" + " null check");
+    rule.setFieldName("value");
+    rule.setNullAllowed(false);
+    return rule;
+  }
+
+  private static RowMeta stringRowMeta() {
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("value"));
+    return rowMeta;
+  }
+}
diff --git 
a/plugins/transforms/validator/src/test/resources/validator-transform.xml 
b/plugins/transforms/validator/src/test/resources/validator-transform.xml
new file mode 100644
index 0000000000..770f34184f
--- /dev/null
+++ b/plugins/transforms/validator/src/test/resources/validator-transform.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  ~
+  -->
+<transform>
+  <name>Data validator</name>
+  <type>Validator</type>
+  <description/>
+  <distribute>Y</distribute>
+  <custom_distribution/>
+  <copies>1</copies>
+  <partitioning>
+    <method>none</method>
+    <schema_name/>
+  </partitioning>
+  <concat_errors>Y</concat_errors>
+  <concat_separator>;</concat_separator>
+  <validate_all>Y</validate_all>
+  <suppress_log_failed_data>Y</suppress_log_failed_data>
+  <validator_field>
+    <allowed_value>
+      <value>A</value>
+      <value>B</value>
+    </allowed_value>
+    <conversion_mask/>
+    <data_type>String</data_type>
+    <data_type_verified>N</data_type_verified>
+    <decimal_symbol/>
+    <error_code>CODE_NULL</error_code>
+    <error_description>Field value cannot be null</error_description>
+    <grouping_symbol/>
+    <is_sourcing_values>N</is_sourcing_values>
+    <max_length>10</max_length>
+    <min_length>1</min_length>
+    <name>value</name>
+    <null_allowed>N</null_allowed>
+    <only_null_allowed>N</only_null_allowed>
+    <only_numeric_allowed>N</only_numeric_allowed>
+    <validation_name>value rules</validation_name>
+  </validator_field>
+  <attributes/>
+  <GUI>
+    <xloc>100</xloc>
+    <yloc>100</yloc>
+  </GUI>
+</transform>


Reply via email to