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 732c5954a6 Formula transform hardening, fixes #3572 (#7916)
732c5954a6 is described below
commit 732c5954a6365cfd125df85007afeff2d0e1aaef
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Wed Aug 12 16:37:52 2026 +0200
Formula transform hardening, fixes #3572 (#7916)
---
.../ROOT/pages/pipeline/transforms/formula.adoc | 25 +-
.../transforms/0042-formula-error-values.hpl | 597 +++++++++++++++++++++
.../transforms/0042-formula-na-and-iferror.hpl | 267 +++++++++
.../0042-formula-pre1900-error-handling.hpl | 560 +++++++++++++++++++
.../transforms/0042-formula-pre1900-fails.hpl | 136 +++++
.../main-0042-formula-error-handling.hwf | 234 ++++++++
.../hop/pipeline/transforms/formula/Formula.java | 67 ++-
.../pipeline/transforms/formula/FormulaMeta.java | 7 +
.../transforms/formula/util/FormulaParser.java | 31 +-
.../transforms/formula/function/functions.xml | 4 +-
.../formula/messages/messages_en_US.properties | 3 +
.../formula/FormulaErrorHandlingTest.java | 176 ++++++
.../formula/util/FormulaParserEvaluationTest.java | 65 +++
tools/install-wave1-plugins.sh | 2 +-
14 files changed, 2166 insertions(+), 8 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/formula.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/formula.adoc
index 119e167d3b..650dd970ff 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/formula.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/formula.adoc
@@ -80,6 +80,27 @@ TIP: The formula will not evaluate a field unless the field
pre-exists. Either s
* Set a Null Timestamp with formula: DATEVALUE("0")
+== Dates before 1900
+
+WARNING: The Formula transform can not calculate with dates before
*1899-12-31*. Formulas are evaluated the way Excel does, as date serial numbers
counting the days since that date, so there simply is no representation for
anything older.
+
+This has two consequences to be aware of.
+
+*Date and Timestamp fields older than 1899-12-31 are rejected.* Because such a
date can not be converted to a serial number, the transform reports an error
for the row instead of calculating with a wrong value. Applications that use a
very old date as a placeholder for "no value" (1750-01-01 and similar) will run
into this. Filter or convert those values before the Formula transform, for
example by mapping them to `null` in a Calculator, Switch/Case or Filter Rows
transform, or divert th [...]
+
+*The DATE() function adds 1900 to years below 1900.* This is Excel behavior,
not a Hop limitation: a year between 0 and 1899 is interpreted as an offset
from 1900. `DATE(1800,1,1)` therefore returns 3700-01-01 and `DATE(50,1,1)`
returns 1950-01-01. Hop can not detect this, since the result is a perfectly
valid date, so double check any formula that builds a date from a literal or
calculated year.
+
+== Error handling
+
+A formula that can not be calculated for a row stops the pipeline with an
error message naming the field and the formula. This covers unsupported dates
as described above, as well as the error values Excel returns for a failed
calculation: `#DIV/0!`, `#VALUE!`, `#NUM!`, `#REF!`, `#NAME?` and `#NULL!`.
+
+Two options are available to handle these rows instead:
+
+* Handle the error inside the formula with `IFERROR()`, for example
`IFERROR([amount]/[count], 0)`.
+* Enable error handling on the transform to divert the offending rows to
another transform, leaving the rest of the stream untouched.
+
+NOTE: `#N/A` is the exception: it means "no value available" and is written to
the output field as a `null`. This is what the *Set Null to #N/A* option relies
on, so formulas using `NA()` keep working as before.
+
== Options
[options="header"]
@@ -290,8 +311,8 @@ The kettle import process is able to migrate the existing
Formula steps containe
|*description*|Construct date from year, month, and day of month.
|*syntax*|DATE( Integer Year , Integer Month , Integer Day )
|*returns*|Date
-|*semantics*|This computes the date's serial number given Year, Month, and
Day. Fractional values are truncated. The value of the serial number depends on
the current epoch. Note that some applications may not handle correctly dates
before 1904; in particular, many spreadsheets incorrectly claim that 1900 is a
leap year (it was not; there was no 1900-02-29).
-|*constraints*|1 <= Month <= 12; 1 <= Day <= 31
+|*semantics*|This computes the date's serial number given Year, Month, and
Day. Fractional values are truncated. The value of the serial number depends on
the current epoch. Note that some applications may not handle correctly dates
before 1904; in particular, many spreadsheets incorrectly claim that 1900 is a
leap year (it was not; there was no 1900-02-29). A Year between 0 and 1899 is
interpreted as an offset from 1900, so DATE(1800,1,1) returns 3700-01-01 and
DATE(50,1,1) returns 1950 [...]
+|*constraints*|1 <= Month <= 12; 1 <= Day <= 31; Year >= 1900
|===
*Examples*
diff --git a/integration-tests/transforms/0042-formula-error-values.hpl
b/integration-tests/transforms/0042-formula-error-values.hpl
new file mode 100644
index 0000000000..5e93546ea5
--- /dev/null
+++ b/integration-tests/transforms/0042-formula-error-values.hpl
@@ -0,0 +1,597 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0042-formula-error-values</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Excel reports a failed calculation with an error value
instead of throwing, which
+used to leave the output field silently blank. A #DIV/0! now fails the row, so
with error handling
+configured it is diverted to the error stream while the rows that do calculate
pass through.</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/08/12 09:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/08/12 09:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>input numbers</from>
+ <to>divide</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>divide</from>
+ <to>verify calculated rows</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>divide</from>
+ <to>verify error description</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify calculated rows</from>
+ <to>count calculated rows</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify calculated rows</from>
+ <to>Abort on wrong value</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>count calculated rows</from>
+ <to>verify calculated count</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify calculated count</from>
+ <to>Calculated rows OK</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify calculated count</from>
+ <to>Abort on calculated count</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify error description</from>
+ <to>count error rows</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify error description</from>
+ <to>Abort on wrong error</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>count error rows</from>
+ <to>verify error count</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify error count</from>
+ <to>Errors OK</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify error count</from>
+ <to>Abort on error count</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>input numbers</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>div by zero</item>
+ <item>10</item>
+ <item>0</item>
+ </line>
+ <line>
+ <item>valid</item>
+ <item>10</item>
+ <item>2</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <name>label</name>
+ <type>String</type>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <name>numerator</name>
+ <type>Integer</type>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <name>denominator</name>
+ <type>Integer</type>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>112</xloc>
+ <yloc>144</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>divide</name>
+ <type>Formula</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <formulas>
+ <formula>
+ <field_name>ratio</field_name>
+ <formula>[numerator]/[denominator]</formula>
+ <replace_field/>
+ <value_length>-1</value_length>
+ <value_precision>-1</value_precision>
+ <value_type>5</value_type>
+ </formula>
+ </formulas>
+ <attributes/>
+ <GUI>
+ <xloc>288</xloc>
+ <yloc>144</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>verify calculated rows</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>count calculated rows</send_true_to>
+ <send_false_to>Abort on wrong value</send_false_to>
+ <compare>
+ <condition>
+ <conditions>
+ <condition>
+ <conditions>
+</conditions>
+ <function>=</function>
+ <leftvalue>label</leftvalue>
+ <negated>N</negated>
+ <operator>-</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>valid</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ </value>
+ </condition>
+ <condition>
+ <conditions>
+</conditions>
+ <function>=</function>
+ <leftvalue>ratio</leftvalue>
+ <negated>N</negated>
+ <operator>AND</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>5</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </conditions>
+ <negated>N</negated>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>480</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>count calculated rows</name>
+ <type>GroupBy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <all_rows>N</all_rows>
+ <ignore_aggregate>N</ignore_aggregate>
+ <field_ignore/>
+ <directory>${java.io.tmpdir}</directory>
+ <prefix>grp</prefix>
+ <add_linenr>N</add_linenr>
+ <linenr_fieldname/>
+ <give_back_row>N</give_back_row>
+ <group>
+ </group>
+ <fields>
+ <field>
+ <aggregate>calculated_rows</aggregate>
+ <subject>label</subject>
+ <type>COUNT_ALL</type>
+ <valuefield/>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>672</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>verify calculated count</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>Calculated rows OK</send_true_to>
+ <send_false_to>Abort on calculated count</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>calculated_rows</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>1</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>864</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Calculated rows OK</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort on wrong value</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>672</xloc>
+ <yloc>16</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort on calculated count</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>16</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>verify error description</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>count error rows</send_true_to>
+ <send_false_to>Abort on wrong error</send_false_to>
+ <compare>
+ <condition>
+ <conditions>
+ <condition>
+ <conditions>
+</conditions>
+ <function>CONTAINS</function>
+ <leftvalue>err_desc</leftvalue>
+ <negated>N</negated>
+ <operator>-</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>#DIV/0!</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ </value>
+ </condition>
+ <condition>
+ <conditions>
+</conditions>
+ <function>=</function>
+ <leftvalue>err_field</leftvalue>
+ <negated>N</negated>
+ <operator>AND</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>ratio</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ </value>
+ </condition>
+ <condition>
+ <conditions>
+</conditions>
+ <function>=</function>
+ <leftvalue>label</leftvalue>
+ <negated>N</negated>
+ <operator>AND</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>div by zero</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ </value>
+ </condition>
+ </conditions>
+ <negated>N</negated>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>480</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>count error rows</name>
+ <type>GroupBy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <all_rows>N</all_rows>
+ <ignore_aggregate>N</ignore_aggregate>
+ <field_ignore/>
+ <directory>${java.io.tmpdir}</directory>
+ <prefix>grp</prefix>
+ <add_linenr>N</add_linenr>
+ <linenr_fieldname/>
+ <give_back_row>N</give_back_row>
+ <group>
+ </group>
+ <fields>
+ <field>
+ <aggregate>error_rows</aggregate>
+ <subject>label</subject>
+ <type>COUNT_ALL</type>
+ <valuefield/>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>672</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>verify error count</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>Errors OK</send_true_to>
+ <send_false_to>Abort on error count</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>error_rows</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>1</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>864</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Errors OK</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort on wrong error</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>672</xloc>
+ <yloc>320</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort on error count</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>320</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ <error>
+ <source_transform>divide</source_transform>
+ <target_transform>verify error description</target_transform>
+ <is_enabled>Y</is_enabled>
+ <nr_valuename>err_nr</nr_valuename>
+ <descriptions_valuename>err_desc</descriptions_valuename>
+ <fields_valuename>err_field</fields_valuename>
+ <codes_valuename>err_code</codes_valuename>
+ <max_errors/>
+ <max_pct_errors/>
+ <min_pct_rows/>
+ </error>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/transforms/0042-formula-na-and-iferror.hpl
b/integration-tests/transforms/0042-formula-na-and-iferror.hpl
new file mode 100644
index 0000000000..816e0f2129
--- /dev/null
+++ b/integration-tests/transforms/0042-formula-na-and-iferror.hpl
@@ -0,0 +1,267 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0042-formula-na-and-iferror</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Not every Excel error value is a failure. #N/A means "no
value available" and is
+produced on purpose by the "Set Null to #N/A" option, so it keeps mapping to a
null instead of
+failing the row. IFERROR() stays the in-formula way to handle the error values
that do fail.</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/08/12 09:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/08/12 09:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>generate 1 row</from>
+ <to>na and iferror formulas</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>na and iferror formulas</from>
+ <to>Verify</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Verify</from>
+ <to>Valid</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Verify</from>
+ <to>Invalid</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>generate 1 row</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <length>-1</length>
+ <name>one</name>
+ <nullif>1</nullif>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <type>Integer</type>
+ </field>
+ </fields>
+ <interval_in_ms>5000</interval_in_ms>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <limit>1</limit>
+ <never_ending>N</never_ending>
+ <row_time_field>now</row_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>144</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>na and iferror formulas</name>
+ <type>Formula</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <formulas>
+ <formula>
+ <field_name>na_result</field_name>
+ <formula>NA()</formula>
+ <replace_field/>
+ <value_length>-1</value_length>
+ <value_precision>-1</value_precision>
+ <value_type>2</value_type>
+ </formula>
+ <formula>
+ <field_name>na_propagated</field_name>
+ <formula>SUM(NA(),1)</formula>
+ <replace_field/>
+ <value_length>-1</value_length>
+ <value_precision>-1</value_precision>
+ <value_type>5</value_type>
+ </formula>
+ <formula>
+ <field_name>isna_flag</field_name>
+ <formula>IF(ISNA(NA()), 1, 0)</formula>
+ <replace_field/>
+ <value_length>-1</value_length>
+ <value_precision>-1</value_precision>
+ <value_type>5</value_type>
+ </formula>
+ <formula>
+ <field_name>iferror_result</field_name>
+ <formula>IFERROR(1/0, -1)</formula>
+ <replace_field/>
+ <value_length>-1</value_length>
+ <value_precision>-1</value_precision>
+ <value_type>5</value_type>
+ </formula>
+ </formulas>
+ <attributes/>
+ <GUI>
+ <xloc>352</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Verify</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>Valid</send_true_to>
+ <send_false_to>Invalid</send_false_to>
+ <compare>
+ <condition>
+ <conditions>
+ <condition>
+ <conditions>
+</conditions>
+ <function>IS NULL</function>
+ <leftvalue>na_result</leftvalue>
+ <negated>N</negated>
+ <operator>-</operator>
+ <rightvalue/>
+ </condition>
+ <condition>
+ <conditions>
+</conditions>
+ <function>IS NULL</function>
+ <leftvalue>na_propagated</leftvalue>
+ <negated>N</negated>
+ <operator>AND</operator>
+ <rightvalue/>
+ </condition>
+ <condition>
+ <conditions>
+</conditions>
+ <function>=</function>
+ <leftvalue>isna_flag</leftvalue>
+ <negated>N</negated>
+ <operator>AND</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>1</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ <condition>
+ <conditions>
+</conditions>
+ <function>=</function>
+ <leftvalue>iferror_result</leftvalue>
+ <negated>N</negated>
+ <operator>AND</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>-1</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </conditions>
+ <negated>N</negated>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>560</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Valid</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>768</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Invalid</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>768</xloc>
+ <yloc>208</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/integration-tests/transforms/0042-formula-pre1900-error-handling.hpl
b/integration-tests/transforms/0042-formula-pre1900-error-handling.hpl
new file mode 100644
index 0000000000..fbd3acfb0e
--- /dev/null
+++ b/integration-tests/transforms/0042-formula-pre1900-error-handling.hpl
@@ -0,0 +1,560 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0042-formula-pre1900-error-handling</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>With error handling configured, a date the Formula transform
can not represent is
+diverted instead of stopping the pipeline. 1899-12-31 is the oldest supported
date (Excel date
+serial number 0), so only the 1800-01-01 row ends up on the error stream while
the two supported
+dates pass through unchanged. See
https://github.com/apache/hop/issues/3572</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/08/12 09:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/08/12 09:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>input dates</from>
+ <to>date formula</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>date formula</from>
+ <to>verify supported dates</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>date formula</from>
+ <to>verify error description</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify supported dates</from>
+ <to>count supported rows</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify supported dates</from>
+ <to>Abort on wrong date</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>count supported rows</from>
+ <to>verify supported count</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify supported count</from>
+ <to>Supported dates OK</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify supported count</from>
+ <to>Abort on supported count</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify error description</from>
+ <to>count error rows</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify error description</from>
+ <to>Abort on wrong error</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>count error rows</from>
+ <to>verify error count</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify error count</from>
+ <to>Errors OK</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>verify error count</from>
+ <to>Abort on error count</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>input dates</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>epoch</item>
+ <item>1899-12-31</item>
+ </line>
+ <line>
+ <item>supported</item>
+ <item>1900-01-01</item>
+ </line>
+ <line>
+ <item>too old</item>
+ <item>1800-01-01</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <name>label</name>
+ <type>String</type>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ <field>
+ <name>input_date</name>
+ <type>Date</type>
+ <format>yyyy-MM-dd</format>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>112</xloc>
+ <yloc>144</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>date formula</name>
+ <type>Formula</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <formulas>
+ <formula>
+ <field_name>passthrough_date</field_name>
+ <formula>IF(1=2, DATE(2000,1,1), [input_date])</formula>
+ <replace_field/>
+ <value_length>-1</value_length>
+ <value_precision>-1</value_precision>
+ <value_type>3</value_type>
+ </formula>
+ </formulas>
+ <attributes/>
+ <GUI>
+ <xloc>288</xloc>
+ <yloc>144</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>verify supported dates</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>count supported rows</send_true_to>
+ <send_false_to>Abort on wrong date</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>passthrough_date</leftvalue>
+ <function>=</function>
+ <rightvalue>input_date</rightvalue>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>480</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>count supported rows</name>
+ <type>GroupBy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <all_rows>N</all_rows>
+ <ignore_aggregate>N</ignore_aggregate>
+ <field_ignore/>
+ <directory>${java.io.tmpdir}</directory>
+ <prefix>grp</prefix>
+ <add_linenr>N</add_linenr>
+ <linenr_fieldname/>
+ <give_back_row>N</give_back_row>
+ <group>
+ </group>
+ <fields>
+ <field>
+ <aggregate>supported_rows</aggregate>
+ <subject>label</subject>
+ <type>COUNT_ALL</type>
+ <valuefield/>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>672</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>verify supported count</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>Supported dates OK</send_true_to>
+ <send_false_to>Abort on supported count</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>supported_rows</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>2</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>864</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Supported dates OK</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort on wrong date</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>672</xloc>
+ <yloc>16</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort on supported count</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>16</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>verify error description</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>count error rows</send_true_to>
+ <send_false_to>Abort on wrong error</send_false_to>
+ <compare>
+ <condition>
+ <conditions>
+ <condition>
+ <conditions>
+</conditions>
+ <function>CONTAINS</function>
+ <leftvalue>err_desc</leftvalue>
+ <negated>N</negated>
+ <operator>-</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>1899-12-31</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ </value>
+ </condition>
+ <condition>
+ <conditions>
+</conditions>
+ <function>=</function>
+ <leftvalue>err_field</leftvalue>
+ <negated>N</negated>
+ <operator>AND</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>passthrough_date</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ </value>
+ </condition>
+ <condition>
+ <conditions>
+</conditions>
+ <function>=</function>
+ <leftvalue>label</leftvalue>
+ <negated>N</negated>
+ <operator>AND</operator>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>too old</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ </value>
+ </condition>
+ </conditions>
+ <negated>N</negated>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>480</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>count error rows</name>
+ <type>GroupBy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <all_rows>N</all_rows>
+ <ignore_aggregate>N</ignore_aggregate>
+ <field_ignore/>
+ <directory>${java.io.tmpdir}</directory>
+ <prefix>grp</prefix>
+ <add_linenr>N</add_linenr>
+ <linenr_fieldname/>
+ <give_back_row>N</give_back_row>
+ <group>
+ </group>
+ <fields>
+ <field>
+ <aggregate>error_rows</aggregate>
+ <subject>label</subject>
+ <type>COUNT_ALL</type>
+ <valuefield/>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>672</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>verify error count</name>
+ <type>FilterRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <send_true_to>Errors OK</send_true_to>
+ <send_false_to>Abort on error count</send_false_to>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <leftvalue>error_rows</leftvalue>
+ <function>=</function>
+ <rightvalue/>
+ <value>
+ <name>constant</name>
+ <type>Integer</type>
+ <text>1</text>
+ <length>-1</length>
+ <precision>0</precision>
+ <isnull>N</isnull>
+ <mask>####0;-####0</mask>
+ </value>
+ </condition>
+ </compare>
+ <attributes/>
+ <GUI>
+ <xloc>864</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Errors OK</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort on wrong error</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>672</xloc>
+ <yloc>320</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Abort on error count</name>
+ <type>Abort</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <always_log_rows>Y</always_log_rows>
+ <row_threshold>0</row_threshold>
+ <attributes/>
+ <GUI>
+ <xloc>1056</xloc>
+ <yloc>320</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ <error>
+ <source_transform>date formula</source_transform>
+ <target_transform>verify error description</target_transform>
+ <is_enabled>Y</is_enabled>
+ <nr_valuename>err_nr</nr_valuename>
+ <descriptions_valuename>err_desc</descriptions_valuename>
+ <fields_valuename>err_field</fields_valuename>
+ <codes_valuename>err_code</codes_valuename>
+ <max_errors/>
+ <max_pct_errors/>
+ <min_pct_rows/>
+ </error>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/transforms/0042-formula-pre1900-fails.hpl
b/integration-tests/transforms/0042-formula-pre1900-fails.hpl
new file mode 100644
index 0000000000..181c1ec03f
--- /dev/null
+++ b/integration-tests/transforms/0042-formula-pre1900-fails.hpl
@@ -0,0 +1,136 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0042-formula-pre1900-fails</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Formulas are evaluated with Excel date serial numbers, which
start at 1899-12-31.
+A date field older than that can not be represented, so the transform has to
report an error
+instead of silently turning the value into a blank or a wrong date. Without
error handling
+configured that error stops the pipeline, which is what this test expects: the
calling workflow
+treats a successful run as a failure. See
https://github.com/apache/hop/issues/3572</description>
+ <extended_description/>
+ <pipeline_version/>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <created_user>-</created_user>
+ <created_date>2026/08/12 09:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/08/12 09:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>generate old date</from>
+ <to>formula on pre 1900 date</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>formula on pre 1900 date</from>
+ <to>Output</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>generate old date</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <format>yyyy-MM-dd</format>
+ <length>-1</length>
+ <name>old_date</name>
+ <nullif>1800-01-01</nullif>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <type>Date</type>
+ </field>
+ </fields>
+ <interval_in_ms>5000</interval_in_ms>
+ <last_time_field>FiveSecondsAgo</last_time_field>
+ <limit>1</limit>
+ <never_ending>N</never_ending>
+ <row_time_field>now</row_time_field>
+ <attributes/>
+ <GUI>
+ <xloc>144</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>formula on pre 1900 date</name>
+ <type>Formula</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <formulas>
+ <formula>
+ <field_name>passthrough_date</field_name>
+ <formula>IF(1=2, DATE(2000,1,1), [old_date])</formula>
+ <replace_field/>
+ <value_length>-1</value_length>
+ <value_precision>-1</value_precision>
+ <value_type>3</value_type>
+ </formula>
+ </formulas>
+ <attributes/>
+ <GUI>
+ <xloc>352</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Output</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>560</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/transforms/main-0042-formula-error-handling.hwf
b/integration-tests/transforms/main-0042-formula-error-handling.hwf
new file mode 100644
index 0000000000..c7df329d4a
--- /dev/null
+++ b/integration-tests/transforms/main-0042-formula-error-handling.hwf
@@ -0,0 +1,234 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>main-0042-formula-error-handling</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Formula transform error reporting: dates the Excel date serial
numbers can not
+represent, Excel error values, and the #N/A value that has to keep mapping to
a null.
+See https://github.com/apache/hop/issues/3572</description>
+ <extended_description/>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/08/12 09:00:00.000</created_date>
+ <modified_date>2026/08/12 09:00:00.000</modified_date>
+ <workflow_version/>
+ <parameters/>
+ <actions>
+ <action>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <DayOfMonth>1</DayOfMonth>
+ <weekDay>1</weekDay>
+ <minutes>0</minutes>
+ <hour>12</hour>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <xloc>48</xloc>
+ <yloc>48</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename>${PROJECT_HOME}/0042-formula-na-and-iferror.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <clear_rows>N</clear_rows>
+ <clear_files>N</clear_files>
+ <create_parent_folder>N</create_parent_folder>
+ <set_logfile>N</set_logfile>
+ <set_append_logfile>N</set_append_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local</run_configuration>
+ <name>0042-formula-na-and-iferror.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <xloc>208</xloc>
+ <yloc>48</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename>${PROJECT_HOME}/0042-formula-error-values.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <clear_rows>N</clear_rows>
+ <clear_files>N</clear_files>
+ <create_parent_folder>N</create_parent_folder>
+ <set_logfile>N</set_logfile>
+ <set_append_logfile>N</set_append_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local</run_configuration>
+ <name>0042-formula-error-values.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <xloc>480</xloc>
+ <yloc>48</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+
<filename>${PROJECT_HOME}/0042-formula-pre1900-error-handling.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <clear_rows>N</clear_rows>
+ <clear_files>N</clear_files>
+ <create_parent_folder>N</create_parent_folder>
+ <set_logfile>N</set_logfile>
+ <set_append_logfile>N</set_append_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local</run_configuration>
+ <name>0042-formula-pre1900-error-handling.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <xloc>784</xloc>
+ <yloc>48</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename>${PROJECT_HOME}/0042-formula-pre1900-fails.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <clear_rows>N</clear_rows>
+ <clear_files>N</clear_files>
+ <create_parent_folder>N</create_parent_folder>
+ <set_logfile>N</set_logfile>
+ <set_append_logfile>N</set_append_logfile>
+ <logfile/>
+ <logext/>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local</run_configuration>
+ <name>0042-formula-pre1900-fails.hpl</name>
+ <description>This pipeline is expected to FAIL: without error handling a
date the transform
+can not represent stops the pipeline. The hops are inverted
accordingly.</description>
+ <type>PIPELINE</type>
+ <attributes/>
+ <xloc>1072</xloc>
+ <yloc>48</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Success</name>
+ <description/>
+ <type>SUCCESS</type>
+ <attributes/>
+ <xloc>1280</xloc>
+ <yloc>144</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <loglevel>Error</loglevel>
+ <name>Abort workflow</name>
+ <description/>
+ <type>ABORT</type>
+ <attributes/>
+ <xloc>1280</xloc>
+ <yloc>48</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0042-formula-na-and-iferror.hpl</to>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>0042-formula-na-and-iferror.hpl</from>
+ <to>0042-formula-error-values.hpl</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>0042-formula-error-values.hpl</from>
+ <to>0042-formula-pre1900-error-handling.hpl</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>0042-formula-pre1900-error-handling.hpl</from>
+ <to>0042-formula-pre1900-fails.hpl</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>0042-formula-pre1900-fails.hpl</from>
+ <to>Abort workflow</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>0042-formula-pre1900-fails.hpl</from>
+ <to>Success</to>
+ <evaluation>N</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ </hops>
+ <notepads/>
+ <attributes/>
+</workflow>
diff --git
a/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/Formula.java
b/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/Formula.java
index 938861aa25..60a0e90bbd 100644
---
a/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/Formula.java
+++
b/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/Formula.java
@@ -27,10 +27,12 @@ import java.util.stream.IntStream;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.exception.HopRuntimeException;
import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.exception.HopValueException;
import org.apache.hop.core.row.IValueMeta;
import org.apache.hop.core.row.RowDataUtil;
import org.apache.hop.core.row.value.ValueMetaFactory;
import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.pipeline.Pipeline;
import org.apache.hop.pipeline.PipelineMeta;
import org.apache.hop.pipeline.transform.BaseTransform;
@@ -39,8 +41,10 @@ import
org.apache.hop.pipeline.transforms.formula.util.FormulaParser;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.CellValue;
import org.apache.poi.ss.usermodel.DateUtil;
+import org.apache.poi.ss.usermodel.FormulaError;
public class Formula extends BaseTransform<FormulaMeta, FormulaData> {
+ private static final Class<?> PKG = Formula.class; // for i18n purposes
private FormulaPoi[] poi;
private List<String>[] formulaFieldLists;
@@ -201,6 +205,9 @@ public class Formula extends BaseTransform<FormulaMeta,
FormulaData> {
data.returnType[i] = FormulaData.RETURN_TYPE_STRING;
formula.setNeedDataConversion(outputValueType !=
IValueMeta.TYPE_STRING);
break;
+ case ERROR:
+ outputValue = getErrorValue(cellValue, formula);
+ break;
default:
break;
}
@@ -210,8 +217,9 @@ public class Formula extends BaseTransform<FormulaMeta,
FormulaData> {
outputRowData[realIndex] =
getReturnValue(outputValue, data.returnType[i], realIndex,
formula);
} catch (Exception e) {
- throw new HopException(
- "Formula '" + formula.getFormula() + "' could not not be parsed ",
e);
+ // The row can not be calculated: divert it to the error stream if the
user asked for it,
+ // stop the pipeline otherwise.
+ return handleFormulaError(r, formula, e);
}
}
@@ -250,6 +258,61 @@ public class Formula extends BaseTransform<FormulaMeta,
FormulaData> {
super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
}
+ /**
+ * Excel reports a failed calculation as an error value rather than by
throwing. Only {@code #N/A}
+ * means "no value available" - the transform produces it on purpose through
the "Set Null to
+ * #N/A" option - so it maps back to null. Every other error code ({@code
#DIV/0!}, {@code
+ * #VALUE!}, {@code #NUM!}, ...) is a genuine calculation failure and has to
be reported instead
+ * of silently turning the field blank.
+ *
+ * @param cellValue the evaluated cell holding the error
+ * @param formula the formula that produced it
+ * @return null for {@code #N/A}
+ * @throws HopValueException for any other error value
+ */
+ private Object getErrorValue(CellValue cellValue, FormulaMetaFunction
formula)
+ throws HopValueException {
+ byte errorCode = cellValue.getErrorValue();
+ if (FormulaError.isValidCode(errorCode) && FormulaError.forInt(errorCode)
== FormulaError.NA) {
+ return null;
+ }
+ String errorText =
+ FormulaError.isValidCode(errorCode)
+ ? FormulaError.forInt(errorCode).getString()
+ : Byte.toString(errorCode);
+ throw new HopValueException(
+ BaseMessages.getString(
+ PKG, "Formula.Exception.FormulaError", formula.getFieldName(),
errorText));
+ }
+
+ /**
+ * A formula could not be calculated for the current row. Send the row to
the error stream when
+ * error handling is configured, stop the pipeline otherwise.
+ *
+ * @param row the input row that could not be calculated
+ * @param formula the formula that failed
+ * @param e the cause of the failure
+ * @return true when the row was diverted and processing can continue, false
to end this transform
+ * @throws HopTransformException when the row could not be written to the
error stream
+ */
+ private boolean handleFormulaError(Object[] row, FormulaMetaFunction
formula, Exception e)
+ throws HopTransformException {
+ String message =
+ BaseMessages.getString(
+ PKG, "Formula.Exception.CouldNotBeEvaluated",
formula.getFormula(), e.getMessage());
+
+ if (getTransformMeta().isDoingErrorHandling()) {
+ putError(getInputRowMeta(), row, 1, message, formula.getFieldName(),
"Formula001");
+ return true;
+ }
+
+ logError(message, e);
+ setErrors(1);
+ stopAll();
+ setOutputDone();
+ return false;
+ }
+
protected Object getReturnValue(
Object formulaResult, int returnType, int realIndex, FormulaMetaFunction
fn)
throws HopException {
diff --git
a/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/FormulaMeta.java
b/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/FormulaMeta.java
index a901f297cd..5129c6ac1d 100644
---
a/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/FormulaMeta.java
+++
b/plugins/transforms/formula/src/main/java/org/apache/hop/pipeline/transforms/formula/FormulaMeta.java
@@ -70,6 +70,13 @@ public class FormulaMeta extends BaseTransformMeta<Formula,
FormulaData> {
return new FormulaMeta(this);
}
+ @Override
+ public boolean supportsErrorHandling() {
+ // Rows for which a formula can not be calculated can be diverted instead
of stopping the
+ // pipeline.
+ return true;
+ }
+
@Override
public void getFields(
IRowMeta row,
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 a111f3bf49..a704f47aef 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
@@ -19,6 +19,7 @@ package org.apache.hop.pipeline.transforms.formula.util;
import static
org.apache.hop.pipeline.transforms.formula.util.FormulaFieldsExtractor.getFormulaFieldList;
+import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
@@ -26,14 +27,18 @@ import org.apache.hop.core.exception.HopValueException;
import org.apache.hop.core.row.IRowMeta;
import org.apache.hop.core.row.IValueMeta;
import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.transforms.formula.Formula;
import org.apache.hop.pipeline.transforms.formula.FormulaMetaFunction;
import org.apache.hop.pipeline.transforms.formula.FormulaPoi;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellValue;
+import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.util.CellReference;
public class FormulaParser {
+ private static final Class<?> PKG = Formula.class; // for i18n purposes
private FormulaMetaFunction formulaMetaFunction;
private IRowMeta rowMeta;
@@ -116,7 +121,9 @@ public class FormulaParser {
} else if (fieldMeta.isBigNumber()) {
cell.setCellValue(rowMeta.getNumber(dataRow, fieldPosition));
} else if (fieldMeta.isDate()) {
- cell.setCellValue(rowMeta.getDate(dataRow, fieldPosition));
+ Date date = rowMeta.getDate(dataRow, fieldPosition);
+ checkSupportedDate(fieldMeta, date);
+ cell.setCellValue(date);
} else if (fieldMeta.isInteger()) {
cell.setCellValue(rowMeta.getInteger(dataRow, fieldPosition));
} else if (fieldMeta.isNumber()) {
@@ -145,4 +152,26 @@ public class FormulaParser {
return evaluator.evaluator().evaluate(formulaCell);
}
+
+ /**
+ * Formulas are evaluated as Excel date serial numbers, which start at
1899-12-31 (serial 0). POI
+ * silently maps anything older to the same BAD_DATE sentinel (-1), so every
earlier date would
+ * collapse to one value and come out of the transform blank or, after date
arithmetic, plain
+ * wrong. Report it instead of losing the value without a trace.
+ *
+ * @param fieldMeta the metadata of the date field being written to a cell
+ * @param date the value to write, may be null
+ * @throws HopValueException when the date can not be represented as an
Excel date serial number
+ */
+ private void checkSupportedDate(IValueMeta fieldMeta, Date date) throws
HopValueException {
+ if (date == null || DateUtil.getExcelDate(date) >= 0) {
+ return;
+ }
+ throw new HopValueException(
+ BaseMessages.getString(
+ PKG,
+ "Formula.Exception.DateBeforeExcelEpoch",
+ fieldMeta.getName(),
+ fieldMeta.getString(date)));
+ }
}
diff --git
a/plugins/transforms/formula/src/main/resources/org/apache/hop/pipeline/transforms/formula/function/functions.xml
b/plugins/transforms/formula/src/main/resources/org/apache/hop/pipeline/transforms/formula/function/functions.xml
index 39a4f8e2da..b06c970868 100644
---
a/plugins/transforms/formula/src/main/resources/org/apache/hop/pipeline/transforms/formula/function/functions.xml
+++
b/plugins/transforms/formula/src/main/resources/org/apache/hop/pipeline/transforms/formula/function/functions.xml
@@ -658,8 +658,8 @@
<description>Construct date from year, month, and day of
month.</description>
<syntax>DATE( Integer Year , Integer Month , Integer Day )</syntax>
<returns>Date</returns>
- <constraints>1 <= Month <= 12, 1 <= Day <= 31</constraints>
- <semantics>This computes the date's serial number given Year, Month, and
Day. Fractional values are truncated. The value of the serial number depends on
the current epoch. Note that some applications may not handle correctly dates
before 1904; in particular, many spreadsheets incorrectly claim that 1900 is a
leap year (it was not; there was no 1900-02-29).</semantics>
+ <constraints>1 <= Month <= 12, 1 <= Day <= 31, Year >=
1900</constraints>
+ <semantics>This computes the date's serial number given Year, Month, and
Day. Fractional values are truncated. The value of the serial number depends on
the current epoch. Note that some applications may not handle correctly dates
before 1904; in particular, many spreadsheets incorrectly claim that 1900 is a
leap year (it was not; there was no 1900-02-29). A Year between 0 and 1899 is
interpreted as an offset from 1900, so DATE(1800,1,1) returns 3700-01-01 and
DATE(50,1,1) returns 19 [...]
<examples>
<example><expression>DATE(2005,1,31)=[.C7]</expression>
<result>True</result> <level>1</level> <comment>Simple date
value.</comment></example>
<example><expression>DATE(2005,12,31)-DATE(1904,1,1)</expression>
<result>37255</result> <level>1</level> <comment>Date differences are computed
correctly.</comment></example>
diff --git
a/plugins/transforms/formula/src/main/resources/org/apache/hop/pipeline/transforms/formula/messages/messages_en_US.properties
b/plugins/transforms/formula/src/main/resources/org/apache/hop/pipeline/transforms/formula/messages/messages_en_US.properties
index 8ae1d5e1d5..85d47375b9 100644
---
a/plugins/transforms/formula/src/main/resources/org/apache/hop/pipeline/transforms/formula/messages/messages_en_US.properties
+++
b/plugins/transforms/formula/src/main/resources/org/apache/hop/pipeline/transforms/formula/messages/messages_en_US.properties
@@ -26,6 +26,9 @@ Category.Logical = Logical
Category.Mathematical = Mathematical
Category.Text = Text
Formula.description=Formula
+Formula.Exception.CouldNotBeEvaluated=Formula ''{0}'' could not be evaluated:
{1}
+Formula.Exception.DateBeforeExcelEpoch=Field [{0}] contains date {1}, which is
before 1899-12-31. Formulas are calculated with Excel date serial numbers,
which can not represent earlier dates.
+Formula.Exception.FormulaError=The formula for field [{0}] evaluated to {1}
Formula.keywords=formula,calculate,expression,math,libreoffice
Formula.name=Formula
FormulaDialog.DialogTitle = Formula
diff --git
a/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/FormulaErrorHandlingTest.java
b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/FormulaErrorHandlingTest.java
new file mode 100644
index 0000000000..6c63342a7d
--- /dev/null
+++
b/plugins/transforms/formula/src/test/java/org/apache/hop/pipeline/transforms/formula/FormulaErrorHandlingTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Date;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaDate;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+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.mockito.ArgumentCaptor;
+
+/**
+ * Unit tests for the way {@link Formula} reports formulas that can not be
calculated. Excel returns
+ * an error value instead of throwing, which used to leave the output field
silently blank.
+ */
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class FormulaErrorHandlingTest {
+ private TransformMockHelper<FormulaMeta, FormulaData> transformMockHelper;
+
+ @BeforeEach
+ void setUp() {
+ transformMockHelper =
+ new TransformMockHelper<>("Formula", FormulaMeta.class,
FormulaData.class);
+ when(transformMockHelper.logChannelFactory.create(any(),
any(ILoggingObject.class)))
+ .thenReturn(transformMockHelper.iLogChannel);
+ when(transformMockHelper.pipeline.isRunning()).thenReturn(true);
+ when(transformMockHelper.transformMeta.getName()).thenReturn("Formula");
+ }
+
+ @AfterEach
+ void tearDown() {
+ transformMockHelper.cleanUp();
+ }
+
+ @Test
+ void naIsReturnedAsNull() throws Exception {
+ // #N/A means "no value available" and is produced on purpose by the "Set
Null to #N/A"
+ // option, so it has to stay a null rather than fail the row.
+ Formula formula = createTransform("NA()", IValueMeta.TYPE_NUMBER);
+ ArgumentCaptor<Object[]> rows = ArgumentCaptor.forClass(Object[].class);
+ doNothing().when(formula).putRow(any(IRowMeta.class), rows.capture());
+
+ assertTrue(formula.processRow());
+
+ assertNull(rows.getValue()[1]);
+ verify(formula, never()).putError(any(), any(), anyLong(), anyString(),
any(), anyString());
+ formula.dispose();
+ }
+
+ @Test
+ void divisionByZeroStopsThePipelineWithoutErrorHandling() throws Exception {
+ Formula formula = createTransform("1/0", IValueMeta.TYPE_NUMBER);
+
when(transformMockHelper.transformMeta.isDoingErrorHandling()).thenReturn(false);
+
+ assertFalse(formula.processRow());
+
+ assertEquals(1, formula.getErrors());
+ verify(formula, never()).putRow(any(), any());
+ verify(formula, never()).putError(any(), any(), anyLong(), anyString(),
any(), anyString());
+ formula.dispose();
+ }
+
+ @Test
+ void divisionByZeroGoesToTheErrorStreamWithErrorHandling() throws Exception {
+ Formula formula = createTransform("1/0", IValueMeta.TYPE_NUMBER);
+
when(transformMockHelper.transformMeta.isDoingErrorHandling()).thenReturn(true);
+ doNothing().when(formula).putError(any(), any(), anyLong(), anyString(),
any(), anyString());
+
+ // the row is diverted, the transform keeps going
+ assertTrue(formula.processRow());
+
+ ArgumentCaptor<String> description = ArgumentCaptor.forClass(String.class);
+ verify(formula, times(1))
+ .putError(any(), any(), anyLong(), description.capture(), any(),
anyString());
+ assertTrue(description.getValue().contains("#DIV/0!"),
description.getValue());
+ verify(formula, never()).putRow(any(), any());
+ formula.dispose();
+ }
+
+ @Test
+ void dateBeforeTheExcelEpochGoesToTheErrorStream() throws Exception {
+ // Before this was reported, the pre-1900 date silently became POI's
BAD_DATE and the field
+ // came out blank. See https://github.com/apache/hop/issues/3572
+ Date tooOld =
+ Date.from(LocalDate.of(1800, 1,
1).atStartOfDay(ZoneId.systemDefault()).toInstant());
+ Formula formula =
+ createTransform(
+ "IF(1=2, DATE(2000,1,1), [start])",
+ IValueMeta.TYPE_DATE,
+ new ValueMetaDate("start"),
+ tooOld);
+
when(transformMockHelper.transformMeta.isDoingErrorHandling()).thenReturn(true);
+ doNothing().when(formula).putError(any(), any(), anyLong(), anyString(),
any(), anyString());
+
+ assertTrue(formula.processRow());
+
+ ArgumentCaptor<String> description = ArgumentCaptor.forClass(String.class);
+ verify(formula, times(1))
+ .putError(any(), any(), anyLong(), description.capture(), any(),
anyString());
+ assertTrue(description.getValue().contains("1899-12-31"),
description.getValue());
+ verify(formula, never()).putRow(any(), any());
+ formula.dispose();
+ }
+
+ private Formula createTransform(String expression, int valueType) throws
HopException {
+ return createTransform(expression, valueType, new
ValueMetaInteger("amount"), 10L);
+ }
+
+ /** Builds a Formula transform with a single formula, fed a single row
holding one field. */
+ private Formula createTransform(
+ String expression, int valueType, IValueMeta inputMeta, Object
inputValue)
+ throws HopException {
+ FormulaMeta meta = new FormulaMeta();
+ meta.getFormulas()
+ .add(new FormulaMetaFunction("result", expression, valueType, -1, -1,
"", false));
+
+ Formula formula =
+ spy(
+ new Formula(
+ transformMockHelper.transformMeta,
+ meta,
+ new FormulaData(),
+ 0,
+ transformMockHelper.pipelineMeta,
+ transformMockHelper.pipeline));
+ assertTrue(formula.init());
+
+ RowMeta inputRowMeta = new RowMeta();
+ inputRowMeta.addValueMeta(inputMeta);
+ formula.setInputRowMeta(inputRowMeta);
+
+ doReturn(new Object[] {inputValue}).when(formula).getRow();
+ return formula;
+ }
+}
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
index aa85a431d1..7b0c0817ef 100644
---
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
@@ -19,18 +19,26 @@ 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Date;
import java.util.HashMap;
import java.util.List;
+import org.apache.hop.core.exception.HopValueException;
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.ValueMetaDate;
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.row.value.ValueMetaTimestamp;
import org.apache.hop.core.variables.Variables;
import org.apache.hop.pipeline.transforms.formula.FormulaMetaFunction;
import org.apache.hop.pipeline.transforms.formula.FormulaPoi;
@@ -116,6 +124,57 @@ class FormulaParserEvaluationTest {
assertTrue(result.getBooleanValue());
}
+ @Test
+ void dateFieldOnTheExcelEpochIsEvaluated() throws Exception {
+ // 1899-12-31 is the oldest representable date: it is Excel date serial
number 0.
+ CellValue result =
+ evaluate(List.of(field(new ValueMetaDate("start"), date(1899, 12,
31))), "[start]", false);
+
+ assertEquals(CellType.NUMERIC, result.getCellType());
+ assertEquals(0.0, result.getNumberValue());
+ }
+
+ @Test
+ void dateFieldBeforeTheExcelEpochIsRejected() {
+ HopValueException e =
+ assertThrows(
+ HopValueException.class,
+ () ->
+ evaluate(
+ List.of(field(new ValueMetaDate("start"), date(1800, 1,
1))),
+ "IF(1=2, DATE(2000,1,1), [start])",
+ false));
+
+ assertTrue(e.getMessage().contains("start"), e.getMessage());
+ assertTrue(e.getMessage().contains("1899-12-31"), e.getMessage());
+ }
+
+ @Test
+ void timestampFieldBeforeTheExcelEpochIsRejected() {
+ Timestamp timestamp = new Timestamp(date(1750, 6, 15).getTime());
+
+ HopValueException e =
+ assertThrows(
+ HopValueException.class,
+ () ->
+ evaluate(
+ List.of(field(new ValueMetaTimestamp("start"), timestamp)),
+ "[start] + 1",
+ false));
+
+ assertTrue(e.getMessage().contains("start"), e.getMessage());
+ }
+
+ @Test
+ void nullDateFieldIsNotRejected() throws Exception {
+ CellValue result =
+ evaluate(
+ List.of(field(new ValueMetaDate("start"), null)),
"IF(ISBLANK([start]), 1, 0)", false);
+
+ assertEquals(CellType.NUMERIC, result.getCellType());
+ assertEquals(1.0, result.getNumberValue());
+ }
+
@Test
void twoIntegerFieldsAreSummed() throws Exception {
CellValue result =
@@ -204,5 +263,11 @@ class FormulaParserEvaluationTest {
return new FieldBinding(meta, value);
}
+ /** POI converts dates in the default time zone, so build them in the
default zone as well. */
+ private static Date date(int year, int month, int day) {
+ return Date.from(
+ LocalDate.of(year, month,
day).atStartOfDay(ZoneId.systemDefault()).toInstant());
+ }
+
private record FieldBinding(IValueMeta meta, Object value) {}
}
diff --git a/tools/install-wave1-plugins.sh b/tools/install-wave1-plugins.sh
index 2e258232ec..80ba2e6e8e 100755
--- a/tools/install-wave1-plugins.sh
+++ b/tools/install-wave1-plugins.sh
@@ -25,7 +25,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL_DIR="${1:-${ROOT}/assemblies/client/target/hop}"
-VERSION="${HOP_VERSION:-2.19.0-SNAPSHOT}"
+VERSION="${HOP_VERSION:-2.20.0-SNAPSHOT}"
LIST_SCRIPT="${ROOT}/tools/list-marketplace-plugins.sh"
ZIP_DIR="${HOP_PLUGIN_ZIP_DIR:-}"