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 8c972c594f Fix String Operations Transform Adds Unexpected Empty Row 
(#7331)
8c972c594f is described below

commit 8c972c594f336bbdb9fcb474652d3e2a3d9d6a51
Author: Lance <[email protected]>
AuthorDate: Thu Jun 25 15:19:19 2026 +0800

    Fix String Operations Transform Adds Unexpected Empty Row (#7331)
    
    Signed-off-by: lance <[email protected]>
---
 .../stringoperations/StringOperationsDialog.java   |   3 +-
 .../stringoperations/StringOperationsDataTest.java |  36 ++
 .../StringOperationsMetaCoverageTest.java          | 403 +++++++++++++++++
 .../stringoperations/StringOperationsTest.java     | 489 +++++++++++++++++++++
 4 files changed, 930 insertions(+), 1 deletion(-)

diff --git 
a/plugins/transforms/stringoperations/src/main/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsDialog.java
 
b/plugins/transforms/stringoperations/src/main/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsDialog.java
index 346348be9d..3d602234c4 100644
--- 
a/plugins/transforms/stringoperations/src/main/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsDialog.java
+++ 
b/plugins/transforms/stringoperations/src/main/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsDialog.java
@@ -95,7 +95,7 @@ public class StringOperationsDialog extends 
BaseTransformDialog {
             shell,
             SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI | SWT.V_SCROLL | 
SWT.H_SCROLL,
             ciKey,
-            1,
+            input.getOperations().size(),
             lsMod,
             props);
 
@@ -250,6 +250,7 @@ public class StringOperationsDialog extends 
BaseTransformDialog {
       item.setText(11, operation.getRemoveSpecialChars().getDescription());
     }
 
+    wFields.removeEmptyRows();
     wFields.setRowNums();
     wFields.optWidth(true);
   }
diff --git 
a/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsDataTest.java
 
b/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsDataTest.java
new file mode 100644
index 0000000000..4580864c50
--- /dev/null
+++ 
b/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsDataTest.java
@@ -0,0 +1,36 @@
+/*
+ * 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.stringoperations;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link StringOperationsData}. */
+class StringOperationsDataTest {
+
+  @Test
+  void defaultConstructorInitializesBaseTransformData() {
+    StringOperationsData data = new StringOperationsData();
+
+    assertNotNull(data);
+    assertNull(data.inStreamNrs);
+    assertNull(data.outputRowMeta);
+  }
+}
diff --git 
a/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsMetaCoverageTest.java
 
b/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsMetaCoverageTest.java
new file mode 100644
index 0000000000..a17b30adcd
--- /dev/null
+++ 
b/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsMetaCoverageTest.java
@@ -0,0 +1,403 @@
+/*
+ * 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.stringoperations;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+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.ICheckResult;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/** Additional unit tests for {@link StringOperationsMeta}. */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class StringOperationsMetaCoverageTest {
+
+  @Test
+  void cloneCreatesDeepCopyOfOperations() {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+    op.setFieldInStream("in");
+    op.setFieldOutStream("out");
+    op.setTrimType(StringOperationsMeta.TrimType.BOTH);
+    op.setLowerUpper(StringOperationsMeta.LowerUpper.UPPER);
+    meta.getOperations().add(op);
+
+    StringOperationsMeta clone = (StringOperationsMeta) meta.clone();
+
+    assertNotSame(meta, clone);
+    assertNotSame(meta.getOperations(), clone.getOperations());
+    assertEquals(1, clone.getOperations().size());
+    assertEquals("in", clone.getOperations().getFirst().getFieldInStream());
+    assertEquals("out", clone.getOperations().getFirst().getFieldOutStream());
+    assertEquals(
+        StringOperationsMeta.TrimType.BOTH, 
clone.getOperations().getFirst().getTrimType());
+    assertEquals(
+        StringOperationsMeta.LowerUpper.UPPER, 
clone.getOperations().getFirst().getLowerUpper());
+  }
+
+  @Test
+  void stringOperationCopyConstructorCopiesAllFields() {
+    StringOperationsMeta.StringOperation source = new 
StringOperationsMeta.StringOperation();
+    source.setFieldInStream("in");
+    source.setFieldOutStream("out");
+    source.setTrimType(StringOperationsMeta.TrimType.LEFT);
+    source.setLowerUpper(StringOperationsMeta.LowerUpper.LOWER);
+    source.setInitCap(StringOperationsMeta.InitCap.YES);
+    source.setMaskXml(StringOperationsMeta.MaskXml.ESCAPE_HTML);
+    source.setDigits(StringOperationsMeta.Digits.DIGITS_ONLY);
+    source.setRemoveSpecialChars(StringOperationsMeta.RemoveSpecialChars.TAB);
+    source.setPaddingType(StringOperationsMeta.Padding.RIGHT);
+    source.setPadChar("*");
+    source.setPadLen("10");
+
+    StringOperationsMeta.StringOperation copy = new 
StringOperationsMeta.StringOperation(source);
+
+    assertEquals(source.getFieldInStream(), copy.getFieldInStream());
+    assertEquals(source.getFieldOutStream(), copy.getFieldOutStream());
+    assertEquals(source.getTrimType(), copy.getTrimType());
+    assertEquals(source.getLowerUpper(), copy.getLowerUpper());
+    assertEquals(source.getInitCap(), copy.getInitCap());
+    assertEquals(source.getMaskXml(), copy.getMaskXml());
+    assertEquals(source.getDigits(), copy.getDigits());
+    assertEquals(source.getRemoveSpecialChars(), copy.getRemoveSpecialChars());
+    assertEquals(source.getPaddingType(), copy.getPaddingType());
+    assertEquals(source.getPadChar(), copy.getPadChar());
+    assertEquals(source.getPadLen(), copy.getPadLen());
+  }
+
+  @Test
+  void stringOperationDefaultsAreInitialized() {
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+
+    assertEquals(StringOperationsMeta.TrimType.NONE, op.getTrimType());
+    assertEquals(StringOperationsMeta.LowerUpper.NONE, op.getLowerUpper());
+    assertEquals(StringOperationsMeta.InitCap.NO, op.getInitCap());
+    assertEquals(StringOperationsMeta.MaskXml.NONE, op.getMaskXml());
+    assertEquals(StringOperationsMeta.Padding.NONE, op.getPaddingType());
+    assertEquals(StringOperationsMeta.RemoveSpecialChars.NONE, 
op.getRemoveSpecialChars());
+    assertEquals(StringOperationsMeta.Digits.NONE, op.getDigits());
+  }
+
+  @Test
+  void supportsErrorHandling() {
+    assertTrue(new StringOperationsMeta().supportsErrorHandling());
+  }
+
+  @Test
+  void getFieldsAddsNewOutputField() throws HopTransformException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+    op.setFieldInStream("in_field");
+    op.setFieldOutStream("out_field");
+    meta.getOperations().add(op);
+
+    IRowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("in_field"));
+
+    meta.getFields(rowMeta, "StringOperations", null, new TransformMeta(), new 
Variables(), null);
+
+    assertNotNull(rowMeta.searchValueMeta("out_field"));
+    assertEquals(IValueMeta.TYPE_STRING, 
rowMeta.searchValueMeta("out_field").getType());
+  }
+
+  @Test
+  void getFieldsUpdatesLengthWhenPaddingExceedsExistingLength() throws 
HopTransformException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+    op.setFieldInStream("in_field");
+    op.setPaddingType(StringOperationsMeta.Padding.LEFT);
+    op.setPadLen("50");
+    meta.getOperations().add(op);
+
+    ValueMetaString valueMeta = new ValueMetaString("in_field");
+    valueMeta.setLength(10, -1);
+    IRowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(valueMeta);
+
+    meta.getFields(rowMeta, "StringOperations", null, new TransformMeta(), new 
Variables(), null);
+
+    assertEquals(50, rowMeta.searchValueMeta("in_field").getLength());
+    assertEquals(
+        IValueMeta.STORAGE_TYPE_NORMAL, 
rowMeta.searchValueMeta("in_field").getStorageType());
+  }
+
+  @Test
+  void getFieldsSkipsMissingInputFieldWhenUpdatingInPlace() throws 
HopTransformException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+    op.setFieldInStream("missing");
+    meta.getOperations().add(op);
+
+    IRowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("other"));
+
+    meta.getFields(rowMeta, "StringOperations", null, new TransformMeta(), new 
Variables(), null);
+
+    assertEquals(1, rowMeta.size());
+  }
+
+  @Test
+  void checkReportsErrorWhenNoInputReceived() {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = mock(TransformMeta.class);
+
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        transformMeta,
+        null,
+        new String[] {"in"},
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertEquals(1, remarks.size());
+    assertEquals(ICheckResult.TYPE_RESULT_ERROR, remarks.getFirst().getType());
+  }
+
+  @Test
+  void checkReportsErrorForMissingInputFieldName() {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+    op.setFieldInStream("");
+    meta.getOperations().add(op);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = mock(TransformMeta.class);
+    IRowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("in_field"));
+
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        transformMeta,
+        prev,
+        new String[] {"in"},
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                remark ->
+                    remark.getType() == ICheckResult.TYPE_RESULT_ERROR
+                        && remark.getText().contains("1")));
+  }
+
+  @Test
+  void checkReportsErrorForDuplicateInputFields() {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op1 = new 
StringOperationsMeta.StringOperation();
+    op1.setFieldInStream("dup");
+    StringOperationsMeta.StringOperation op2 = new 
StringOperationsMeta.StringOperation();
+    op2.setFieldInStream("dup");
+    meta.getOperations().add(op1);
+    meta.getOperations().add(op2);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = mock(TransformMeta.class);
+    IRowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("dup"));
+
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        transformMeta,
+        prev,
+        new String[] {"in"},
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                remark ->
+                    remark.getType() == ICheckResult.TYPE_RESULT_ERROR
+                        && remark.getText().contains("dup")));
+  }
+
+  @Test
+  void checkReportsErrorForNonStringField() {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+    op.setFieldInStream("number");
+    meta.getOperations().add(op);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = mock(TransformMeta.class);
+    IRowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaInteger("number"));
+
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        transformMeta,
+        prev,
+        new String[] {"in"},
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                remark ->
+                    remark.getType() == ICheckResult.TYPE_RESULT_ERROR
+                        && remark.getText().contains("number")));
+  }
+
+  @Test
+  void checkReportsOkWhenAllFieldsAreStrings() {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+    op.setFieldInStream("text");
+    meta.getOperations().add(op);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = mock(TransformMeta.class);
+    IRowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("text"));
+
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        transformMeta,
+        prev,
+        new String[] {"in"},
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                remark ->
+                    remark.getType() == ICheckResult.TYPE_RESULT_OK
+                        && remark
+                            .getText()
+                            .contains(
+                                BaseMessages.getString(
+                                    StringOperationsMeta.class,
+                                    
"StringOperationsMeta.CheckResult.AllOperationsOnStringFields"))));
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.TrimType.class)
+  void trimTypeLookupDescription(StringOperationsMeta.TrimType trimType) {
+    assertEquals(
+        trimType, 
StringOperationsMeta.TrimType.lookupDescription(trimType.getDescription()));
+    assertTrue(StringOperationsMeta.TrimType.getDescriptions().length > 0);
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.LowerUpper.class)
+  void lowerUpperLookupDescription(StringOperationsMeta.LowerUpper lowerUpper) 
{
+    assertEquals(
+        lowerUpper, 
StringOperationsMeta.LowerUpper.lookupDescription(lowerUpper.getDescription()));
+    assertTrue(StringOperationsMeta.LowerUpper.getDescriptions().length > 0);
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.InitCap.class)
+  void initCapLookupDescription(StringOperationsMeta.InitCap initCap) {
+    assertEquals(initCap, 
StringOperationsMeta.InitCap.lookupDescription(initCap.getDescription()));
+    assertTrue(StringOperationsMeta.InitCap.getDescriptions().length > 0);
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.Digits.class)
+  void digitsLookupDescription(StringOperationsMeta.Digits digits) {
+    assertEquals(digits, 
StringOperationsMeta.Digits.lookupDescription(digits.getDescription()));
+    assertTrue(StringOperationsMeta.Digits.getDescriptions().length > 0);
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.MaskXml.class)
+  void maskXmlLookupDescription(StringOperationsMeta.MaskXml maskXml) {
+    assertEquals(maskXml, 
StringOperationsMeta.MaskXml.lookupDescription(maskXml.getDescription()));
+    assertTrue(StringOperationsMeta.MaskXml.getDescriptions().length > 0);
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.RemoveSpecialChars.class)
+  void removeSpecialCharsLookupDescription(
+      StringOperationsMeta.RemoveSpecialChars removeSpecialChars) {
+    assertEquals(
+        removeSpecialChars,
+        StringOperationsMeta.RemoveSpecialChars.lookupDescription(
+            removeSpecialChars.getDescription()));
+    
assertTrue(StringOperationsMeta.RemoveSpecialChars.getDescriptions().length > 
0);
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.Padding.class)
+  void paddingLookupDescription(StringOperationsMeta.Padding padding) {
+    assertEquals(padding, 
StringOperationsMeta.Padding.lookupDescription(padding.getDescription()));
+    assertTrue(StringOperationsMeta.Padding.getDescriptions().length > 0);
+  }
+
+  @Test
+  void enumLookupDescriptionReturnsDefaultForUnknownValue() {
+    assertEquals(
+        StringOperationsMeta.TrimType.NONE,
+        StringOperationsMeta.TrimType.lookupDescription("unknown"));
+    assertEquals(
+        StringOperationsMeta.LowerUpper.NONE,
+        StringOperationsMeta.LowerUpper.lookupDescription("unknown"));
+    assertEquals(
+        StringOperationsMeta.InitCap.NO, 
StringOperationsMeta.InitCap.lookupDescription("unknown"));
+    assertEquals(
+        StringOperationsMeta.Digits.NONE, 
StringOperationsMeta.Digits.lookupDescription("unknown"));
+    assertEquals(
+        StringOperationsMeta.MaskXml.NONE,
+        StringOperationsMeta.MaskXml.lookupDescription("unknown"));
+    assertEquals(
+        StringOperationsMeta.RemoveSpecialChars.NONE,
+        StringOperationsMeta.RemoveSpecialChars.lookupDescription("unknown"));
+    assertEquals(
+        StringOperationsMeta.Padding.NONE,
+        StringOperationsMeta.Padding.lookupDescription("unknown"));
+  }
+}
diff --git 
a/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsTest.java
 
b/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsTest.java
new file mode 100644
index 0000000000..c875802d33
--- /dev/null
+++ 
b/plugins/transforms/stringoperations/src/test/java/org/apache/hop/pipeline/transforms/stringoperations/StringOperationsTest.java
@@ -0,0 +1,489 @@
+/*
+ * 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.stringoperations;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.QueueRowSet;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.ValueDataUtil;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/** Unit tests for {@link StringOperations}. */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class StringOperationsTest {
+  private TransformMockHelper<StringOperationsMeta, StringOperationsData> 
mockHelper;
+
+  @BeforeEach
+  void setUp() {
+    mockHelper =
+        new TransformMockHelper<>(
+            "STRING_OPERATIONS", StringOperationsMeta.class, 
StringOperationsData.class);
+    when(mockHelper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(mockHelper.iLogChannel);
+    when(mockHelper.pipeline.isRunning()).thenReturn(true);
+  }
+
+  @AfterEach
+  void tearDown() {
+    mockHelper.cleanUp();
+  }
+
+  private static StringOperationsMeta.StringOperation operation(
+      String inField, String outField, StringOperationsMeta.TrimType trimType) 
{
+    StringOperationsMeta.StringOperation op = new 
StringOperationsMeta.StringOperation();
+    op.setFieldInStream(inField);
+    op.setFieldOutStream(outField);
+    op.setTrimType(trimType);
+    return op;
+  }
+
+  private static StringOperationsMeta.StringOperation 
operationWithDefaults(String inField) {
+    return operation(inField, "", StringOperationsMeta.TrimType.NONE);
+  }
+
+  private Object[] processRows(StringOperationsMeta meta, RowMeta 
inputRowMeta, Object[] inputRow)
+      throws HopException {
+    StringOperationsData data = new StringOperationsData();
+    StringOperations transform =
+        new StringOperations(
+            mockHelper.transformMeta, meta, data, 0, mockHelper.pipelineMeta, 
mockHelper.pipeline);
+    transform.setInputRowMeta(inputRowMeta);
+    transform.addRowSetToInputRowSets(mockHelper.getMockInputRowSet(inputRow));
+    QueueRowSet outputRowSet = new QueueRowSet();
+    transform.addRowSetToOutputRowSets(outputRowSet);
+
+    boolean hasMoreRows;
+    do {
+      hasMoreRows = transform.processRow();
+    } while (hasMoreRows);
+
+    return outputRowSet.getRow();
+  }
+
+  @Test
+  void processRowReturnsFalseWhenNoInput() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsData data = new StringOperationsData();
+    StringOperations transform =
+        new StringOperations(
+            mockHelper.transformMeta, meta, data, 0, mockHelper.pipelineMeta, 
mockHelper.pipeline);
+    transform.addRowSetToInputRowSets(mockHelper.getMockInputRowSet());
+
+    assertFalse(transform.processRow());
+  }
+
+  @Test
+  void trimsBothSidesInPlace() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    meta.getOperations().add(operation("text", "", 
StringOperationsMeta.TrimType.BOTH));
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("text"));
+
+    Object[] output = processRows(meta, inputRowMeta, new Object[] {"  hello  
"});
+
+    assertEquals("hello", output[0]);
+  }
+
+  @Test
+  void convertsToUpperCaseInNewField() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = operationWithDefaults("text");
+    op.setLowerUpper(StringOperationsMeta.LowerUpper.UPPER);
+    op.setFieldOutStream("upper_text");
+    meta.getOperations().add(op);
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("text"));
+
+    Object[] output = processRows(meta, inputRowMeta, new Object[] {"hello"});
+
+    assertEquals("hello", output[0]);
+    assertEquals("HELLO", output[1]);
+  }
+
+  @Test
+  void leftPadsToConfiguredLengthInNewField() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = operationWithDefaults("text");
+    op.setPaddingType(StringOperationsMeta.Padding.LEFT);
+    op.setPadChar("#");
+    op.setPadLen("8");
+    op.setFieldOutStream("padded");
+    meta.getOperations().add(op);
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("text"));
+
+    Object[] output = processRows(meta, inputRowMeta, new Object[] {"abc"});
+
+    assertEquals("abc", output[0]);
+    assertEquals("#####abc", output[1]);
+  }
+
+  @Test
+  void appliesInitCapInPlace() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = operationWithDefaults("text");
+    op.setInitCap(StringOperationsMeta.InitCap.YES);
+    meta.getOperations().add(op);
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("text"));
+
+    Object[] output = processRows(meta, inputRowMeta, new Object[] {"hello 
world"});
+
+    assertEquals(ValueDataUtil.initCap("hello world"), output[0]);
+  }
+
+  @Test
+  void escapesXmlInNewField() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = operationWithDefaults("text");
+    op.setMaskXml(StringOperationsMeta.MaskXml.ESCAPE_XML);
+    op.setFieldOutStream("escaped");
+    meta.getOperations().add(op);
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("text"));
+
+    Object[] output = processRows(meta, inputRowMeta, new Object[] {"a<b"});
+
+    assertEquals("a<b", output[0]);
+    assertEquals(Const.escapeXml("a<b"), output[1]);
+  }
+
+  @Test
+  void keepsDigitsOnlyInNewField() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = operationWithDefaults("text");
+    op.setDigits(StringOperationsMeta.Digits.DIGITS_ONLY);
+    op.setFieldOutStream("digits");
+    meta.getOperations().add(op);
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("text"));
+
+    Object[] output = processRows(meta, inputRowMeta, new Object[] {"a1b2c3"});
+
+    assertEquals("123", output[1]);
+  }
+
+  @Test
+  void removesCarriageReturnInPlace() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsMeta.StringOperation op = operationWithDefaults("text");
+    op.setRemoveSpecialChars(StringOperationsMeta.RemoveSpecialChars.CR);
+    meta.getOperations().add(op);
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("text"));
+
+    Object[] output = processRows(meta, inputRowMeta, new Object[] {"a\rb"});
+
+    assertEquals("ab", output[0]);
+  }
+
+  @Test
+  void appliesMultipleOperationsToDifferentFields() throws HopException {
+    StringOperationsMeta meta = new StringOperationsMeta();
+
+    StringOperationsMeta.StringOperation trimOp =
+        operation("trimmed", "out_trim", StringOperationsMeta.TrimType.BOTH);
+    meta.getOperations().add(trimOp);
+
+    StringOperationsMeta.StringOperation upperOp = 
operationWithDefaults("upper_src");
+    upperOp.setLowerUpper(StringOperationsMeta.LowerUpper.UPPER);
+    upperOp.setFieldOutStream("out_upper");
+    meta.getOperations().add(upperOp);
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("trimmed"));
+    inputRowMeta.addValueMeta(new ValueMetaString("upper_src"));
+
+    Object[] output = processRows(meta, inputRowMeta, new Object[] {"  hello  
", "world"});
+
+    assertEquals("  hello  ", output[0]);
+    assertEquals("world", output[1]);
+    assertEquals("hello", output[2]);
+    assertEquals("WORLD", output[3]);
+  }
+
+  @Test
+  void throwsWhenConfiguredFieldIsMissing() {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    meta.getOperations().add(operation("missing", "", 
StringOperationsMeta.TrimType.BOTH));
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaString("text"));
+
+    assertThrows(
+        HopTransformException.class, () -> processRows(meta, inputRowMeta, new 
Object[] {"value"}));
+  }
+
+  @Test
+  void throwsWhenConfiguredFieldIsNotString() {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    meta.getOperations().add(operation("number", "", 
StringOperationsMeta.TrimType.BOTH));
+
+    RowMeta inputRowMeta = new RowMeta();
+    inputRowMeta.addValueMeta(new ValueMetaInteger("number"));
+
+    assertThrows(
+        HopTransformException.class, () -> processRows(meta, inputRowMeta, new 
Object[] {1L}));
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.TrimType.class)
+  void processStringTrimHandlesAllTrimTypes(StringOperationsMeta.TrimType 
trimType)
+      throws Exception {
+    String input = "  value  ";
+    String expected =
+        switch (trimType) {
+          case RIGHT -> Const.rtrim(input);
+          case LEFT -> Const.ltrim(input);
+          case BOTH -> Const.trim(input);
+          case NONE -> input;
+        };
+    assertEquals(expected, invokeStaticTrim(trimType, input));
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.LowerUpper.class)
+  void processStringLowerUpperHandlesAllCases(StringOperationsMeta.LowerUpper 
lowerUpper)
+      throws Exception {
+    String input = "AbC";
+    String expected =
+        switch (lowerUpper) {
+          case LOWER -> "abc";
+          case UPPER -> "ABC";
+          case NONE -> input;
+        };
+    assertEquals(expected, invokeStaticLowerUpper(lowerUpper, input));
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.Padding.class)
+  void processStringPaddingHandlesAllCases(StringOperationsMeta.Padding 
padding) throws Exception {
+    String input = "abc";
+    String expected =
+        switch (padding) {
+          case LEFT -> Const.lpad(input, "*", 5);
+          case RIGHT -> Const.rpad(input, "*", 5);
+          case NONE -> input;
+        };
+    assertEquals(expected, invokeStaticPadding(padding, input));
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.InitCap.class)
+  void processStringInitCapHandlesAllCases(StringOperationsMeta.InitCap 
initCap) throws Exception {
+    String input = "hello world";
+    String expected =
+        initCap == StringOperationsMeta.InitCap.YES ? 
ValueDataUtil.initCap(input) : input;
+    assertEquals(expected, invokeStaticInitCap(initCap, input));
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.MaskXml.class)
+  void processStringMaskXmlHandlesAllCases(StringOperationsMeta.MaskXml 
maskXml) throws Exception {
+    String input = "a<b&\"c";
+    String expected =
+        switch (maskXml) {
+          case NONE -> input;
+          case ESCAPE_XML -> Const.escapeXml(input);
+          case CDATA -> Const.protectXmlCdata(input);
+          case UNESCAPE_XML -> Const.unEscapeXml(input);
+          case ESCAPE_HTML -> Const.escapeHtml(input);
+          case UNESCAPE_HTML -> Const.unEscapeHtml(input);
+          case ESCAPE_SQL -> Const.escapeSql(input);
+        };
+    assertEquals(expected, invokeStaticMaskXml(maskXml, input));
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.Digits.class)
+  void processStringDigitsHandlesAllCases(StringOperationsMeta.Digits digits) 
throws Exception {
+    String input = "a1b2";
+    String expected =
+        switch (digits) {
+          case NONE -> input;
+          case DIGITS_ONLY -> Const.getDigitsOnly(input);
+          case DIGITS_REMOVE -> Const.removeDigits(input);
+        };
+    assertEquals(expected, invokeStaticDigits(digits, input));
+  }
+
+  @ParameterizedTest
+  @EnumSource(StringOperationsMeta.RemoveSpecialChars.class)
+  void processStringRemoveSpecialCharactersHandlesAllCases(
+      StringOperationsMeta.RemoveSpecialChars removeSpecialChars) throws 
Exception {
+    String input = "a \r\n\tb";
+    String expected =
+        switch (removeSpecialChars) {
+          case NONE -> input;
+          case CR -> Const.removeCR(input);
+          case LF -> Const.removeLF(input);
+          case CRLF -> Const.removeCRLF(input);
+          case TAB -> Const.removeTAB(input);
+          case SPACE -> input.replace(" ", "");
+        };
+    assertEquals(expected, 
invokeStaticRemoveSpecialCharacters(removeSpecialChars, input));
+  }
+
+  @Test
+  void processStringReturnsNullForNullInput() throws Exception {
+    StringOperationsMeta.StringOperation op = operationWithDefaults("text");
+    assertNull(invokeProcessString(null, op));
+  }
+
+  @Test
+  void processStringAppliesOperationsInConfiguredOrder() throws Exception {
+    StringOperationsMeta.StringOperation op = operationWithDefaults("text");
+    op.setTrimType(StringOperationsMeta.TrimType.BOTH);
+    op.setLowerUpper(StringOperationsMeta.LowerUpper.UPPER);
+    op.setPaddingType(StringOperationsMeta.Padding.RIGHT);
+    op.setPadChar(" ");
+    op.setPadLen("10");
+    op.setInitCap(StringOperationsMeta.InitCap.YES);
+    op.setMaskXml(StringOperationsMeta.MaskXml.NONE);
+    op.setDigits(StringOperationsMeta.Digits.DIGITS_REMOVE);
+    op.setRemoveSpecialChars(StringOperationsMeta.RemoveSpecialChars.SPACE);
+
+    String input = "  ab12  ";
+    String expected =
+        Const.removeDigits(
+            ValueDataUtil.initCap(Const.rpad(Const.trim(input).toUpperCase(), 
" ", 10)));
+
+    assertNotNull(expected);
+    assertEquals(expected.trim(), invokeProcessString(input, op));
+  }
+
+  private String invokeProcessString(String input, 
StringOperationsMeta.StringOperation operation)
+      throws Exception {
+    StringOperationsMeta meta = new StringOperationsMeta();
+    StringOperationsData data = new StringOperationsData();
+    StringOperations transform =
+        new StringOperations(
+            mockHelper.transformMeta, meta, data, 0, mockHelper.pipelineMeta, 
mockHelper.pipeline);
+    Method method =
+        StringOperations.class.getDeclaredMethod(
+            "processString", String.class, 
StringOperationsMeta.StringOperation.class);
+    method.setAccessible(true);
+    try {
+      return (String) method.invoke(transform, input, operation);
+    } catch (InvocationTargetException e) {
+      throw new Exception(e.getCause());
+    }
+  }
+
+  private static String invokeStaticTrim(StringOperationsMeta.TrimType 
trimType, String input)
+      throws Exception {
+    return invokeStatic("processStringTrim", trimType, input);
+  }
+
+  private static String invokeStaticLowerUpper(
+      StringOperationsMeta.LowerUpper lowerUpper, String input) throws 
Exception {
+    return invokeStatic("processStringLowerUpper", lowerUpper, input);
+  }
+
+  private static String invokeStaticPadding(StringOperationsMeta.Padding 
padding, String input)
+      throws Exception {
+    Method method =
+        StringOperations.class.getDeclaredMethod(
+            "processStringPadding",
+            StringOperationsMeta.Padding.class,
+            String.class,
+            int.class,
+            String.class);
+    method.setAccessible(true);
+    try {
+      return (String) method.invoke(null, padding, "*", 5, input);
+    } catch (InvocationTargetException e) {
+      throw new Exception(e.getCause());
+    }
+  }
+
+  private static String invokeStaticInitCap(StringOperationsMeta.InitCap 
initCap, String input)
+      throws Exception {
+    return invokeStatic("processStringInitCap", initCap, input);
+  }
+
+  private static String invokeStaticMaskXml(StringOperationsMeta.MaskXml 
maskXml, String input)
+      throws Exception {
+    return invokeStatic("processStringMaskXml", maskXml, input);
+  }
+
+  private static String invokeStaticDigits(StringOperationsMeta.Digits digits, 
String input)
+      throws Exception {
+    return invokeStatic("processStringDigits", digits, input);
+  }
+
+  private static String invokeStaticRemoveSpecialCharacters(
+      StringOperationsMeta.RemoveSpecialChars removeSpecialChars, String 
input) throws Exception {
+    return invokeStatic("processStringRemoveSpecialCharacters", 
removeSpecialChars, input);
+  }
+
+  private static String invokeStatic(String methodName, Object enumValue, 
String input)
+      throws Exception {
+    Method method =
+        StringOperations.class.getDeclaredMethod(methodName, 
enumValue.getClass(), String.class);
+    method.setAccessible(true);
+    try {
+      return (String) method.invoke(null, enumValue, input);
+    } catch (InvocationTargetException e) {
+      throw new Exception(e.getCause());
+    }
+  }
+
+  @Test
+  void nullAndEmptyInputsArePassedThroughByStaticHelpers() throws Exception {
+    assertNull(invokeStaticTrim(StringOperationsMeta.TrimType.BOTH, null));
+    assertEquals("", invokeStaticTrim(StringOperationsMeta.TrimType.BOTH, ""));
+    assertArrayEquals(
+        new Object[] {null, "", "   "},
+        new Object[] {
+          invokeStaticLowerUpper(StringOperationsMeta.LowerUpper.LOWER, null),
+          invokeStaticLowerUpper(StringOperationsMeta.LowerUpper.LOWER, ""),
+          invokeStaticLowerUpper(StringOperationsMeta.LowerUpper.LOWER, "   ")
+        });
+  }
+}


Reply via email to