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