From 82d31246317389001cd959ab5f09c3a6ff5ad449 Mon Sep 17 00:00:00 2001 From: lance Date: Tue, 26 May 2026 22:50:43 +0800 Subject: [PATCH] Pass BigNumber fields as numeric values in Formula transform Signed-off-by: lance --- .../editor/util/CompletionProposal.java | 35 --- .../formula/util/FormulaParser.java | 6 +- .../function/FunctionDescriptionTest.java | 177 +++++++++++++++ .../formula/function/FunctionExampleTest.java | 79 +++++++ .../formula/function/FunctionLibTest.java | 53 ++++- .../util/FormulaFieldsExtractorTest.java | 33 ++- .../util/FormulaParserEvaluationTest.java | 208 ++++++++++++++++++ .../formula/util/FormulaParserTest.java | 48 ++++ .../util/StringToTypeConverterTest.java | 61 +++++ 9 files changed, 657 insertions(+), 43 deletions(-) delete mode 100644 plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/editor/util/CompletionProposal.java create mode 100644 plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionDescriptionTest.java create mode 100644 plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionExampleTest.java create mode 100644 plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParserEvaluationTest.java create mode 100644 plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/StringToTypeConverterTest.java diff --git a/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/editor/util/CompletionProposal.java b/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/editor/util/CompletionProposal.java deleted file mode 100644 index 27c3e49b74e..00000000000 --- a/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/editor/util/CompletionProposal.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.formula.editor.util; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class CompletionProposal { - private String menuText; - private String completionString; - int offset; - - public CompletionProposal(String menuText, String completionString, int offset) { - this.menuText = menuText; - this.completionString = completionString; - this.offset = offset; - } -} diff --git a/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParser.java b/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParser.java index 9dccc8de3fb..ea2b950f4ed 100644 --- a/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParser.java +++ b/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParser.java @@ -28,7 +28,6 @@ import org.apache.hop.core.variables.IVariables; import org.apache.hop.pipeline.transforms.formula.FormulaMetaFunction; import org.apache.hop.pipeline.transforms.formula.FormulaPoi; -import org.apache.poi.hssf.usermodel.HSSFRichTextString; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellValue; import org.apache.poi.ss.usermodel.Row; @@ -108,12 +107,13 @@ public CellValue getFormulaValue() throws HopValueException { IValueMeta fieldMeta = rowMeta.getValueMeta(fieldPosition); if (dataRow[fieldPosition] != null) { - if (fieldMeta.isString()) { // most common first to avoid a lot of "if" for nothing + // most common first to avoid a lot of "if" for nothing + if (fieldMeta.isString()) { cell.setCellValue(rowMeta.getString(dataRow, fieldPosition)); } else if (fieldMeta.isBoolean()) { cell.setCellValue(rowMeta.getBoolean(dataRow, fieldPosition)); } else if (fieldMeta.isBigNumber()) { - cell.setCellValue(new HSSFRichTextString(rowMeta.getString(dataRow, fieldPosition))); + cell.setCellValue(rowMeta.getNumber(dataRow, fieldPosition)); } else if (fieldMeta.isDate()) { cell.setCellValue(rowMeta.getDate(dataRow, fieldPosition)); } else if (fieldMeta.isInteger()) { diff --git a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionDescriptionTest.java b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionDescriptionTest.java new file mode 100644 index 00000000000..ec8088ba16b --- /dev/null +++ b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionDescriptionTest.java @@ -0,0 +1,177 @@ +/* + * 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.formula.function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.List; +import org.apache.hop.core.exception.HopXmlException; +import org.apache.hop.core.xml.XmlHandler; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Node; + +/** Unit test for {@link FunctionDescription} */ +class FunctionDescriptionTest { + + private static final String FUNCTION_XML = + """ + + ABS + %Category.Mathematical + Returns the absolute value of a number. + ABS( NUMBER N ) + Number + + If N < 0, returns -N. + + + ABS(2) + 2 + 1 + Positive values return unchanged. + + + + """; + + @Test + void constructorSetsAllFields() { + List examples = List.of(new FunctionExample("ABS(-2)", "2", "1", "Negation")); + + FunctionDescription description = + new FunctionDescription( + "Math", + "ABS", + "Absolute value", + "ABS(N)", + "Number", + "N must be numeric", + "Returns |N|", + examples); + + assertEquals("Math", description.getCategory()); + assertEquals("ABS", description.getName()); + assertEquals("Absolute value", description.getDescription()); + assertEquals("ABS(N)", description.getSyntax()); + assertEquals("Number", description.getReturns()); + assertEquals("N must be numeric", description.getConstraints()); + assertEquals("Returns |N|", description.getSemantics()); + assertEquals(1, description.getFunctionExamples().size()); + assertEquals("ABS(-2)", description.getFunctionExamples().getFirst().getExpression()); + } + + @Test + void parsesFromXmlNode() throws HopXmlException { + Node node = XmlHandler.loadXmlString(FUNCTION_XML, FunctionDescription.XML_TAG); + FunctionDescription description = new FunctionDescription(node); + + assertEquals("ABS", description.getName()); + assertEquals("%Category.Mathematical", description.getCategory()); + assertEquals("Returns the absolute value of a number.", description.getDescription()); + assertEquals("ABS( NUMBER N )", description.getSyntax()); + assertEquals("Number", description.getReturns()); + assertEquals("If N < 0, returns -N.", description.getSemantics()); + assertEquals(1, description.getFunctionExamples().size()); + assertEquals("ABS(2)", description.getFunctionExamples().getFirst().getExpression()); + } + + @Test + void parsesFromXmlNodeWithoutExamples() throws HopXmlException { + String xml = + """ + + NA + %Category.Information + Not available. + + """; + FunctionDescription description = + new FunctionDescription(XmlHandler.loadXmlString(xml, FunctionDescription.XML_TAG)); + + assertEquals("NA", description.getName()); + assertNotNull(description.getFunctionExamples()); + assertTrue(description.getFunctionExamples().isEmpty()); + } + + @Test + void getHtmlReportIncludesAllSections() { + FunctionDescription description = + new FunctionDescription( + "Math", + "ABS", + "Absolute value", + "ABS(N)", + "Number", + "None", + "Standard abs", + List.of(new FunctionExample("ABS(-1)", "1", "1", "Sample"))); + + String html = description.getHtmlReport(); + + assertTrue(html.contains("

ABS

")); + assertTrue(html.contains("Description:")); + assertTrue(html.contains("Absolute value")); + assertTrue(html.contains("Syntax:")); + assertTrue(html.contains("ABS(N)")); + assertTrue(html.contains("Returns:")); + assertTrue(html.contains("Constraints:")); + assertTrue(html.contains("Semantics:")); + assertTrue(html.contains("Examples:")); + assertTrue(html.contains("ABS(-1)")); + assertTrue(html.contains("Sample")); + } + + @Test + void getHtmlReportOmitsEmptyOptionalSections() { + FunctionDescription description = + new FunctionDescription("Math", "MIN", "Minimum", "", "", "", "", Collections.emptyList()); + + String html = description.getHtmlReport(); + + assertTrue(html.contains("

MIN

")); + assertTrue(html.contains("Minimum")); + assertFalse(html.contains("Syntax:")); + assertFalse(html.contains("Returns:")); + assertFalse(html.contains("Constraints:")); + assertFalse(html.contains("Semantics:")); + assertFalse(html.contains("Examples:")); + } + + @Test + void getHtmlReportExampleWithoutComment() { + FunctionDescription description = + new FunctionDescription( + "Math", + "SUM", + "Sum", + "SUM(a,b)", + "Number", + "", + "", + List.of(new FunctionExample("SUM(1,2)", "3", "1", ""))); + + String html = description.getHtmlReport(); + + assertTrue(html.contains("SUM(1,2)")); + assertTrue(html.contains("3")); + } +} diff --git a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionExampleTest.java b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionExampleTest.java new file mode 100644 index 00000000000..483b09e2f19 --- /dev/null +++ b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionExampleTest.java @@ -0,0 +1,79 @@ +/* + * 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.formula.function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.apache.hop.core.exception.HopXmlException; +import org.apache.hop.core.xml.XmlHandler; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Node; + +/** Unit test for {@link FunctionExample} */ +class FunctionExampleTest { + + private static final String EXAMPLE_XML = + """ + + ABS(2) + 2 + 1 + Positive values return unchanged. + + """; + + @Test + void constructorSetsAllFields() { + FunctionExample example = + new FunctionExample("ABS(2)", "2", "1", "Positive values return unchanged."); + + assertEquals("ABS(2)", example.getExpression()); + assertEquals("2", example.getResult()); + assertEquals("1", example.getLevel()); + assertEquals("Positive values return unchanged.", example.getComment()); + } + + @Test + void parsesFromXmlNode() throws HopXmlException { + Node node = XmlHandler.loadXmlString(EXAMPLE_XML, FunctionExample.XML_TAG); + FunctionExample example = new FunctionExample(node); + + assertEquals("ABS(2)", example.getExpression()); + assertEquals("2", example.getResult()); + assertEquals("1", example.getLevel()); + assertEquals("Positive values return unchanged.", example.getComment()); + } + + @Test + void parsesFromXmlNodeWithMissingOptionalTags() throws HopXmlException { + String xml = + """ + + 1+1 + 2 + + """; + FunctionExample example = new FunctionExample(XmlHandler.loadXmlString(xml, "example")); + + assertEquals("1+1", example.getExpression()); + assertEquals("2", example.getResult()); + assertNull(example.getLevel()); + assertNull(example.getComment()); + } +} diff --git a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionLibTest.java b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionLibTest.java index ad3e659cdf2..9646aa9d87e 100644 --- a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionLibTest.java +++ b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/function/FunctionLibTest.java @@ -24,11 +24,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Arrays; import java.util.List; import org.apache.hop.core.exception.HopXmlException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +/** Unit test for {@link FunctionLib} */ class FunctionLibTest { private FunctionLib functionLib; @@ -137,7 +139,7 @@ void testInvalidXmlFile() { void testFunctionDescriptionProperties() { List functions = functionLib.getFunctions(); if (!functions.isEmpty()) { - FunctionDescription firstFunction = functions.get(0); + FunctionDescription firstFunction = functions.getFirst(); assertNotNull(firstFunction.getName()); assertNotNull(firstFunction.getCategory()); // Description can be null/empty for some functions @@ -153,4 +155,53 @@ void testSetFunctions() { functionLib.setFunctions(originalFunctions); assertEquals(originalSize, functionLib.getFunctions().size()); } + + @Test + void getFunctionDescriptionIsCaseInsensitive() { + FunctionDescription upper = functionLib.getFunctionDescription("ABS"); + FunctionDescription lower = functionLib.getFunctionDescription("abs"); + FunctionDescription mixed = functionLib.getFunctionDescription("AbS"); + + assertNotNull(upper); + assertNotNull(lower); + assertNotNull(mixed); + assertEquals(upper.getName(), lower.getName()); + assertEquals(upper.getName(), mixed.getName()); + } + + @Test + void getFunctionsForACategoryIsCaseInsensitive() { + FunctionDescription abs = functionLib.getFunctionDescription("ABS"); + assertNotNull(abs); + + String[] functions = functionLib.getFunctionsForACategory(abs.getCategory().toLowerCase()); + assertTrue(Arrays.asList(functions).contains("ABS")); + + for (int i = 1; i < functions.length; i++) { + assertTrue(functions[i - 1].compareTo(functions[i]) <= 0); + } + } + + @Test + void getFunctionNamesCountMatchesLibrarySize() { + assertEquals(functionLib.getFunctions().size(), functionLib.getFunctionNames().length); + } + + @Test + void getFunctionCategoriesAreUnique() { + String[] categories = functionLib.getFunctionCategories(); + long distinct = Arrays.stream(categories).distinct().count(); + assertEquals(categories.length, distinct); + } + + @Test + void absFunctionLoadedFromXmlWithExamples() { + FunctionDescription abs = functionLib.getFunctionDescription("ABS"); + assertNotNull(abs); + assertEquals("%Category.Mathematical", abs.getCategory()); + assertFalse(abs.getDescription().isEmpty()); + assertEquals(3, abs.getFunctionExamples().size()); + assertNotNull(abs.getHtmlReport()); + assertTrue(abs.getHtmlReport().contains("ABS(2)")); + } } diff --git a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaFieldsExtractorTest.java b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaFieldsExtractorTest.java index 96d839a3ac9..9aaa40ab39c 100644 --- a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaFieldsExtractorTest.java +++ b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaFieldsExtractorTest.java @@ -24,17 +24,19 @@ import java.util.List; import org.junit.jupiter.api.Test; +/** Unit test for {@link FormulaFieldsExtractor} */ class FormulaFieldsExtractorTest { @Test void testSimpleFieldExtraction() { - String formula = "[field1] + [field2]"; + String formula = "[field1] + [field2] + [field3]"; List fields = FormulaFieldsExtractor.getFormulaFieldList(formula); assertNotNull(fields); - assertEquals(2, fields.size()); + assertEquals(3, fields.size()); assertTrue(fields.contains("field1")); assertTrue(fields.contains("field2")); + assertTrue(fields.contains("field3")); } @Test @@ -44,7 +46,7 @@ void testSingleFieldExtraction() { assertNotNull(fields); assertEquals(1, fields.size()); - assertEquals("name", fields.get(0)); + assertEquals("name", fields.getFirst()); } @Test @@ -106,7 +108,7 @@ void testUnmatchedBrackets() { assertNotNull(fields); assertEquals(1, fields.size()); - assertEquals("incomplete + [complete", fields.get(0)); + assertEquals("incomplete + [complete", fields.getFirst()); } @Test @@ -142,4 +144,27 @@ void testSpecialCharactersInFieldNames() { assertTrue(fields.contains("field_with_underscores")); assertTrue(fields.contains("field.with.dots")); } + + @Test + void fieldOrderIsPreserved() { + List fields = + FormulaFieldsExtractor.getFormulaFieldList("[first] + [second] * [third]"); + + assertEquals(List.of("first", "second", "third"), fields); + } + + @Test + void onlyOpenBracketWithoutClosingBracket() { + List fields = FormulaFieldsExtractor.getFormulaFieldList("[onlyOpen"); + + assertEquals(0, fields.size()); + } + + @Test + void trailingOpenBracketWithoutClose() { + List fields = FormulaFieldsExtractor.getFormulaFieldList("[complete] + [incomplete"); + + assertEquals(1, fields.size()); + assertEquals("complete", fields.getFirst()); + } } diff --git a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParserEvaluationTest.java b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParserEvaluationTest.java new file mode 100644 index 00000000000..aa85a431d11 --- /dev/null +++ b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParserEvaluationTest.java @@ -0,0 +1,208 @@ +/* + * 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.formula.util; + +import static org.apache.hop.pipeline.transforms.formula.util.FormulaFieldsExtractor.getFormulaFieldList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import org.apache.hop.core.row.IValueMeta; +import org.apache.hop.core.row.RowMeta; +import org.apache.hop.core.row.value.ValueMetaBigNumber; +import org.apache.hop.core.row.value.ValueMetaBoolean; +import org.apache.hop.core.row.value.ValueMetaInteger; +import org.apache.hop.core.row.value.ValueMetaNumber; +import org.apache.hop.core.row.value.ValueMetaString; +import org.apache.hop.core.variables.Variables; +import org.apache.hop.pipeline.transforms.formula.FormulaMetaFunction; +import org.apache.hop.pipeline.transforms.formula.FormulaPoi; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.CellValue; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link FormulaParser} formula evaluation and field binding. */ +class FormulaParserEvaluationTest { + + private FormulaPoi poi; + + @AfterEach + void tearDown() throws Exception { + if (poi != null) { + poi.destroy(); + poi = null; + } + } + + @Test + void numberFieldGreaterThanLiteral() throws Exception { + CellValue result = + evaluate(List.of(field(new ValueMetaNumber("amount"), 150.0)), "[amount] > 100", false); + + assertEquals(CellType.BOOLEAN, result.getCellType()); + assertTrue(result.getBooleanValue()); + } + + @Test + void stringFieldEqualsLiteral() throws Exception { + CellValue result = + evaluate( + List.of(field(new ValueMetaString("status"), "active")), + "[status] = \"active\"", + false); + + assertEquals(CellType.BOOLEAN, result.getCellType()); + assertTrue(result.getBooleanValue()); + } + + @Test + void booleanFieldIsTrue() throws Exception { + CellValue result = + evaluate( + List.of(field(new ValueMetaBoolean("flag"), Boolean.TRUE)), "[flag] = TRUE", false); + + assertEquals(CellType.BOOLEAN, result.getCellType()); + assertTrue(result.getBooleanValue()); + } + + @Test + void bigNumberFieldGreaterThanLiteral() throws Exception { + CellValue result = + evaluate( + List.of(field(new ValueMetaBigNumber("amount"), new BigDecimal("200.5"))), + "[amount] > 100", + false); + + assertEquals(CellType.BOOLEAN, result.getCellType()); + assertTrue(result.getBooleanValue()); + } + + @Test + void nullFieldIsBlankWhenSetNaIsFalse() throws Exception { + CellValue result = + evaluate( + List.of(field(new ValueMetaInteger("amount"), null)), + "IF(ISBLANK([amount]), 1, 0)", + false); + + assertEquals(CellType.NUMERIC, result.getCellType()); + assertEquals(1.0, result.getNumberValue()); + } + + @Test + void nullFieldIsNaWhenSetNaIsTrue() throws Exception { + CellValue result = + evaluate(List.of(field(new ValueMetaInteger("amount"), null)), "ISNA([amount])", true); + + assertEquals(CellType.BOOLEAN, result.getCellType()); + assertTrue(result.getBooleanValue()); + } + + @Test + void twoIntegerFieldsAreSummed() throws Exception { + CellValue result = + evaluate( + List.of(field(new ValueMetaInteger("a"), 10L), field(new ValueMetaInteger("b"), 20L)), + "[a] + [b]", + false); + + assertEquals(CellType.NUMERIC, result.getCellType()); + assertEquals(30.0, result.getNumberValue()); + } + + @Test + void replaceMapRedirectsFormulaFieldToRealColumn() throws Exception { + RowMeta rowMeta = new RowMeta(); + rowMeta.addValueMeta(new ValueMetaInteger("realAmount")); + Object[] row = new Object[] {42L}; + + HashMap replaceMap = new HashMap<>(); + replaceMap.put("aliasAmount", "realAmount"); + + String formula = "[aliasAmount] * 2"; + FormulaMetaFunction fn = + new FormulaMetaFunction("result", formula, IValueMeta.TYPE_INTEGER, -1, -1, "", false); + + poi = new FormulaPoi(msg -> {}); + FormulaParser parser = + new FormulaParser( + fn, rowMeta, row, poi, new Variables(), replaceMap, getFormulaFieldList(formula)); + + CellValue result = parser.getFormulaValue(); + assertEquals(CellType.NUMERIC, result.getCellType()); + assertEquals(84.0, result.getNumberValue()); + } + + @Test + void variablesAreResolvedBeforeEvaluation() throws Exception { + Variables variables = new Variables(); + variables.setVariable("THRESHOLD", "50"); + + CellValue result = + evaluate( + variables, + List.of(field(new ValueMetaInteger("amount"), 60L)), + "[amount] > ${THRESHOLD}", + false); + + assertEquals(CellType.BOOLEAN, result.getCellType()); + assertTrue(result.getBooleanValue()); + } + + private CellValue evaluate(List bindings, String formula, boolean setNa) + throws Exception { + return evaluate(new Variables(), bindings, formula, setNa); + } + + private CellValue evaluate( + Variables variables, List bindings, String formula, boolean setNa) + throws Exception { + RowMeta rowMeta = new RowMeta(); + Object[] row = new Object[bindings.size()]; + for (int i = 0; i < bindings.size(); i++) { + FieldBinding binding = bindings.get(i); + rowMeta.addValueMeta(binding.meta); + row[i] = binding.value; + } + + FormulaMetaFunction fn = + new FormulaMetaFunction("result", formula, IValueMeta.TYPE_STRING, -1, -1, "", setNa); + + poi = new FormulaPoi(msg -> {}); + FormulaParser parser = + new FormulaParser( + fn, + rowMeta, + row, + poi, + variables, + new HashMap<>(), + getFormulaFieldList(variables.resolve(formula))); + + return parser.getFormulaValue(); + } + + private static FieldBinding field(IValueMeta meta, Object value) { + return new FieldBinding(meta, value); + } + + private record FieldBinding(IValueMeta meta, Object value) {} +} diff --git a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParserTest.java b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParserTest.java index 29e7942b0f0..97272580f05 100644 --- a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParserTest.java +++ b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/FormulaParserTest.java @@ -18,9 +18,11 @@ package org.apache.hop.pipeline.transforms.formula.util; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import org.apache.hop.core.BlockingRowSet; @@ -39,8 +41,10 @@ import org.apache.hop.core.row.IValueMeta; import org.apache.hop.core.row.RowMeta; import org.apache.hop.core.row.value.ValueMetaBigNumber; +import org.apache.hop.core.row.value.ValueMetaInteger; import org.apache.hop.core.row.value.ValueMetaPluginType; import org.apache.hop.core.row.value.ValueMetaString; +import org.apache.hop.core.variables.Variables; import org.apache.hop.pipeline.Pipeline; import org.apache.hop.pipeline.PipelineMeta; import org.apache.hop.pipeline.config.PipelineRunConfiguration; @@ -52,8 +56,12 @@ import org.apache.hop.pipeline.transforms.formula.FormulaData; import org.apache.hop.pipeline.transforms.formula.FormulaMeta; import org.apache.hop.pipeline.transforms.formula.FormulaMetaFunction; +import org.apache.hop.pipeline.transforms.formula.FormulaPoi; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.CellValue; import org.junit.jupiter.api.Test; +/** Unit test for {@link FormulaParser} */ class FormulaParserTest { static { HopLogStore.setLogChannelFactory(new ConsoleLogChannelFactory()); @@ -163,6 +171,46 @@ public void rowWrittenEvent(final IRowMeta rowMeta, final Object[] row) { assertEquals(maxSize, counter.get()); } + /** + * Regression for #7006: BigNumber fields + * must be passed to POI as numeric values so comparisons with numeric literals work. + */ + @Test + void bigNumberComparesEqualToNumericLiteral() throws Exception { + assertTrue( + evaluateEqualsZero(new ValueMetaBigNumber("amount"), new Object[] {BigDecimal.ZERO}), + "BigNumber 0 should equal numeric literal 0 in a Formula expression"); + } + + @Test + void integerComparesEqualToNumericLiteral() throws Exception { + assertTrue( + evaluateEqualsZero(new ValueMetaInteger("amount"), new Object[] {0L}), + "Integer 0 should equal numeric literal 0 in a Formula expression"); + } + + private static boolean evaluateEqualsZero(IValueMeta valueMeta, Object[] row) throws Exception { + RowMeta rowMeta = new RowMeta(); + rowMeta.addValueMeta(valueMeta); + + FormulaMetaFunction fn = + new FormulaMetaFunction( + "result", "[amount] = 0", IValueMeta.TYPE_BOOLEAN, -1, -1, "", false); + + FormulaPoi poi = new FormulaPoi(msg -> {}); + try { + FormulaParser parser = + new FormulaParser( + fn, rowMeta, row, poi, new Variables(), new HashMap<>(), List.of("amount")); + + CellValue cellValue = parser.getFormulaValue(); + assertEquals(CellType.BOOLEAN, cellValue.getCellType()); + return cellValue.getBooleanValue(); + } finally { + poi.destroy(); + } + } + private static class ConsoleLogChannelFactory implements ILogChannelFactory { private final ILogChannel simpleLog = new LogChannel("test") { diff --git a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/StringToTypeConverterTest.java b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/StringToTypeConverterTest.java new file mode 100644 index 00000000000..7863a26f553 --- /dev/null +++ b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/util/StringToTypeConverterTest.java @@ -0,0 +1,61 @@ +/* + * 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.formula.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.apache.hop.core.row.IValueMeta; +import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** Unit test for {@link StringToTypeConverter} */ +@ExtendWith(RestoreHopEngineEnvironmentExtension.class) +class StringToTypeConverterTest { + + private StringToTypeConverter converter; + + @BeforeEach + void setUp() { + converter = new StringToTypeConverter(); + } + + @Test + void string2intPrimitiveMapsKnownTypes() throws Exception { + assertEquals(IValueMeta.TYPE_STRING, converter.string2intPrimitive("String")); + assertEquals(IValueMeta.TYPE_NUMBER, converter.string2intPrimitive("Number")); + assertEquals(IValueMeta.TYPE_INTEGER, converter.string2intPrimitive("Integer")); + assertEquals(IValueMeta.TYPE_BIGNUMBER, converter.string2intPrimitive("BigNumber")); + assertEquals(IValueMeta.TYPE_BOOLEAN, converter.string2intPrimitive("Boolean")); + assertEquals(IValueMeta.TYPE_DATE, converter.string2intPrimitive("Date")); + } + + @Test + void string2intPrimitiveIsCaseInsensitive() throws Exception { + assertEquals(IValueMeta.TYPE_STRING, converter.string2intPrimitive("string")); + assertEquals(IValueMeta.TYPE_NUMBER, converter.string2intPrimitive("NUMBER")); + assertEquals(IValueMeta.TYPE_INTEGER, converter.string2intPrimitive("InTeGeR")); + } + + @Test + void string2intPrimitiveReturnsNoneForUnknownType() throws Exception { + assertEquals(IValueMeta.TYPE_NONE, converter.string2intPrimitive("NotARealValueMetaType")); + assertEquals(IValueMeta.TYPE_NONE, converter.string2intPrimitive(null)); + } +}