This is an automated email from the ASF dual-hosted git repository.

pvillard pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new d9f9147306 NIFI-15474 Support timestamp truncation in RecordPath DSL
d9f9147306 is described below

commit d9f9147306f5b6d999fb6cabbfb9b62a2ef722ae
Author: Robert Jensen <[email protected]>
AuthorDate: Fri Jan 16 17:19:53 2026 -0500

    NIFI-15474 Support timestamp truncation in RecordPath DSL
    
    This closes #10796.
    
    Signed-off-by: Pierre Villard <[email protected]>
---
 .../apache/nifi/record/path/functions/Divide.java  |  48 ++++
 .../nifi/record/path/functions/Multiply.java       |  48 ++++
 .../nifi/record/path/functions/ToNumber.java       |  45 ++++
 .../nifi/record/path/math/MathBinaryEvaluator.java |  55 ++++
 .../nifi/record/path/math/MathBinaryOperator.java  |  22 ++
 .../nifi/record/path/math/MathDivideOperator.java  |  40 +++
 .../nifi/record/path/math/MathEvaluator.java       |  25 ++
 .../record/path/math/MathMultiplyOperator.java     |  34 +++
 .../apache/nifi/record/path/math/MathOperator.java |  21 ++
 .../nifi/record/path/math/MathTypeUtils.java       |  56 ++++
 .../nifi/record/path/paths/RecordPathCompiler.java |  15 ++
 .../apache/nifi/record/path/TestRecordPath.java    | 282 ++++++++++++++++++++-
 nifi-docs/src/main/asciidoc/record-path-guide.adoc |  99 ++++++++
 13 files changed, 788 insertions(+), 2 deletions(-)

diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/Divide.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/Divide.java
new file mode 100644
index 0000000000..310fa5bdbb
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/Divide.java
@@ -0,0 +1,48 @@
+/*
+ * 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.nifi.record.path.functions;
+
+import org.apache.nifi.record.path.FieldValue;
+import org.apache.nifi.record.path.RecordPathEvaluationContext;
+import org.apache.nifi.record.path.math.MathBinaryEvaluator;
+import org.apache.nifi.record.path.paths.RecordPathSegment;
+
+import java.util.stream.Stream;
+
+public class Divide extends RecordPathSegment {
+    private final RecordPathSegment lhsPath;
+    private final RecordPathSegment rhsPath;
+    private final MathBinaryEvaluator divide = MathBinaryEvaluator.divide();
+
+    public Divide(final RecordPathSegment lhsPath, final RecordPathSegment 
rhsPath, final boolean absolute) {
+        super("divide", null, absolute);
+        this.lhsPath = lhsPath;
+        this.rhsPath = rhsPath;
+    }
+
+    @Override
+    public Stream<FieldValue> evaluate(RecordPathEvaluationContext context) {
+        final FieldValue lhs = 
lhsPath.evaluate(context).findFirst().orElseThrow(() -> new 
IllegalArgumentException("divide function requires a left-hand operand"));
+        final FieldValue rhs = 
rhsPath.evaluate(context).findFirst().orElseThrow(() -> new 
IllegalArgumentException("divide function requires a right-hand operand"));
+
+        if (lhs.getValue() == null || rhs.getValue() == null) {
+            return Stream.of();
+        }
+
+        return Stream.of(divide.evaluate(lhs, rhs));
+    }
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/Multiply.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/Multiply.java
new file mode 100644
index 0000000000..cab1c427a1
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/Multiply.java
@@ -0,0 +1,48 @@
+/*
+ * 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.nifi.record.path.functions;
+
+import org.apache.nifi.record.path.FieldValue;
+import org.apache.nifi.record.path.RecordPathEvaluationContext;
+import org.apache.nifi.record.path.math.MathBinaryEvaluator;
+import org.apache.nifi.record.path.paths.RecordPathSegment;
+
+import java.util.stream.Stream;
+
+public class Multiply extends RecordPathSegment {
+    private final RecordPathSegment lhsPath;
+    private final RecordPathSegment rhsPath;
+    private final MathBinaryEvaluator multiply = 
MathBinaryEvaluator.multiply();
+
+    public Multiply(final RecordPathSegment lhsPath, final RecordPathSegment 
rhsPath, final boolean absolute) {
+        super("multiply", null, absolute);
+        this.lhsPath = lhsPath;
+        this.rhsPath = rhsPath;
+    }
+
+    @Override
+    public Stream<FieldValue> evaluate(RecordPathEvaluationContext context) {
+        final FieldValue lhs = 
lhsPath.evaluate(context).findFirst().orElseThrow(() -> new 
IllegalArgumentException("multiply function requires a left-hand operand"));
+        final FieldValue rhs = 
rhsPath.evaluate(context).findFirst().orElseThrow(() -> new 
IllegalArgumentException("multiply function requires a right-hand operand"));
+
+        if (lhs.getValue() == null || rhs.getValue() == null) {
+            return Stream.of();
+        }
+
+        return Stream.of(multiply.evaluate(lhs, rhs));
+    }
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/ToNumber.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/ToNumber.java
new file mode 100644
index 0000000000..f945cc2758
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/ToNumber.java
@@ -0,0 +1,45 @@
+/*
+ * 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.nifi.record.path.functions;
+
+import org.apache.nifi.record.path.FieldValue;
+import org.apache.nifi.record.path.RecordPathEvaluationContext;
+import org.apache.nifi.record.path.math.MathTypeUtils;
+import org.apache.nifi.record.path.paths.RecordPathSegment;
+
+import java.util.stream.Stream;
+
+public class ToNumber extends RecordPathSegment {
+    private final RecordPathSegment valuePath;
+
+    public ToNumber(final RecordPathSegment valuePath, final boolean absolute) 
{
+        super("toNumber", null, absolute);
+        this.valuePath = valuePath;
+    }
+
+    @Override
+    public Stream<FieldValue> evaluate(RecordPathEvaluationContext context) {
+        final FieldValue fieldValue = 
valuePath.evaluate(context).findFirst().orElseThrow(() -> new 
IllegalArgumentException("toNumber function requires an operand"));
+        final Object value = fieldValue.getValue();
+
+        if (value == null || value instanceof Number) {
+            return Stream.of(fieldValue);
+        } else {
+            return Stream.of(MathTypeUtils.toNumber(fieldValue));
+        }
+    }
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathBinaryEvaluator.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathBinaryEvaluator.java
new file mode 100644
index 0000000000..140c8c3010
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathBinaryEvaluator.java
@@ -0,0 +1,55 @@
+/*
+ * 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.nifi.record.path.math;
+
+import org.apache.nifi.record.path.FieldValue;
+import org.apache.nifi.record.path.StandardFieldValue;
+import org.apache.nifi.serialization.record.DataType;
+import org.apache.nifi.serialization.record.RecordField;
+import org.apache.nifi.serialization.record.RecordFieldType;
+
+public class MathBinaryEvaluator extends MathEvaluator<MathBinaryOperator> {
+    public MathBinaryEvaluator(MathBinaryOperator op) {
+        super(op);
+    }
+
+    public static MathBinaryEvaluator divide() {
+        return new MathBinaryEvaluator(new MathDivideOperator());
+    }
+
+    public static MathBinaryEvaluator multiply() {
+        return new MathBinaryEvaluator(new MathMultiplyOperator());
+    }
+
+    public FieldValue evaluate(FieldValue lhs, FieldValue rhs) {
+        final Number lhsValue = MathTypeUtils.coerceNumber(lhs);
+        final Number rhsValue = MathTypeUtils.coerceNumber(rhs);
+
+        Number result;
+        DataType resultType;
+
+        if (MathTypeUtils.isLongCompatible(lhsValue) && 
MathTypeUtils.isLongCompatible(rhsValue)) {
+            result = op.operate(lhsValue.longValue(), rhsValue.longValue());
+            resultType = RecordFieldType.LONG.getDataType();
+        } else {
+            result = op.operate(lhsValue.doubleValue(), 
rhsValue.doubleValue());
+            resultType = RecordFieldType.DOUBLE.getDataType();
+        }
+
+        return new StandardFieldValue(result, new 
RecordField(op.getFieldName(), resultType), null);
+    }
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathBinaryOperator.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathBinaryOperator.java
new file mode 100644
index 0000000000..4d017361a4
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathBinaryOperator.java
@@ -0,0 +1,22 @@
+/*
+ * 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.nifi.record.path.math;
+
+public interface MathBinaryOperator extends MathOperator {
+    Long operate(Long n, Long m);
+    Double operate(Double n, Double m);
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathDivideOperator.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathDivideOperator.java
new file mode 100644
index 0000000000..db0de980b8
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathDivideOperator.java
@@ -0,0 +1,40 @@
+/*
+ * 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.nifi.record.path.math;
+
+public class MathDivideOperator implements MathBinaryOperator {
+    @Override
+    public Long operate(Long n, Long m) {
+        if (m == 0L) {
+            throw new ArithmeticException("Division by zero in RecordPath 
divide function");
+        }
+        return n / m;
+    }
+
+    @Override
+    public Double operate(Double n, Double m) {
+        if (m == 0.0) {
+            throw new ArithmeticException("Division by zero in RecordPath 
divide function");
+        }
+        return n / m;
+    }
+
+    @Override
+    public String getFieldName() {
+        return "divide";
+    }
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathEvaluator.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathEvaluator.java
new file mode 100644
index 0000000000..38e9c6800c
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathEvaluator.java
@@ -0,0 +1,25 @@
+/*
+ * 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.nifi.record.path.math;
+
+public class MathEvaluator<T extends MathOperator> {
+    protected final T op;
+
+    public MathEvaluator(T op) {
+        this.op = op;
+    }
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathMultiplyOperator.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathMultiplyOperator.java
new file mode 100644
index 0000000000..7c6b99c853
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathMultiplyOperator.java
@@ -0,0 +1,34 @@
+/*
+ * 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.nifi.record.path.math;
+
+public class MathMultiplyOperator implements MathBinaryOperator {
+    @Override
+    public Long operate(Long n, Long m) {
+        return n * m;
+    }
+
+    @Override
+    public Double operate(Double n, Double m) {
+        return n * m;
+    }
+
+    @Override
+    public String getFieldName() {
+        return "multiply";
+    }
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathOperator.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathOperator.java
new file mode 100644
index 0000000000..6bcfd2332d
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathOperator.java
@@ -0,0 +1,21 @@
+/*
+ * 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.nifi.record.path.math;
+
+public interface MathOperator {
+    String getFieldName();
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathTypeUtils.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathTypeUtils.java
new file mode 100644
index 0000000000..73ffc69b88
--- /dev/null
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/math/MathTypeUtils.java
@@ -0,0 +1,56 @@
+/*
+ * 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.nifi.record.path.math;
+
+import org.apache.nifi.record.path.FieldValue;
+import org.apache.nifi.record.path.StandardFieldValue;
+import org.apache.nifi.serialization.record.DataType;
+import org.apache.nifi.serialization.record.RecordField;
+import org.apache.nifi.serialization.record.RecordFieldType;
+import org.apache.nifi.serialization.record.util.DataTypeUtils;
+
+public class MathTypeUtils {
+    public static FieldValue toNumber(FieldValue fieldValue) {
+        Number result = coerceNumber(fieldValue);
+        DataType resultType = isLongCompatible(result) ? 
RecordFieldType.LONG.getDataType() : RecordFieldType.DOUBLE.getDataType();
+
+        return new StandardFieldValue(result, new RecordField("toNumber", 
resultType), null);
+    }
+
+    public static Number coerceNumber(FieldValue fieldValue) {
+        final Object value = fieldValue.getValue();
+
+        if (value instanceof Number) {
+            return (Number) value;
+        }
+
+        final RecordField field = fieldValue.getField();
+        final String fieldName = field == null ? "<Anonymous Inner Field>" : 
field.getFieldName();
+
+        if (DataTypeUtils.isLongTypeCompatible(value)) {
+            return DataTypeUtils.toLong(value, fieldName);
+        }
+        if (DataTypeUtils.isDoubleTypeCompatible(value)) {
+            return DataTypeUtils.toDouble(value, fieldName);
+        }
+        throw new IllegalArgumentException("Cannot coerce field '" + fieldName 
+ "' to number");
+    }
+
+    public static boolean isLongCompatible(Number value) {
+        return (value instanceof Long || value instanceof Integer || value 
instanceof Short || value instanceof Byte);
+    }
+}
diff --git 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/paths/RecordPathCompiler.java
 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/paths/RecordPathCompiler.java
index 73a2ca7fc6..ca3691b539 100644
--- 
a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/paths/RecordPathCompiler.java
+++ 
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/paths/RecordPathCompiler.java
@@ -42,6 +42,7 @@ import org.apache.nifi.record.path.functions.Base64Encode;
 import org.apache.nifi.record.path.functions.Coalesce;
 import org.apache.nifi.record.path.functions.Concat;
 import org.apache.nifi.record.path.functions.Count;
+import org.apache.nifi.record.path.functions.Divide;
 import org.apache.nifi.record.path.functions.EscapeJson;
 import org.apache.nifi.record.path.functions.FieldName;
 import org.apache.nifi.record.path.functions.FilterFunction;
@@ -49,6 +50,7 @@ import org.apache.nifi.record.path.functions.Format;
 import org.apache.nifi.record.path.functions.Hash;
 import org.apache.nifi.record.path.functions.Join;
 import org.apache.nifi.record.path.functions.MapOf;
+import org.apache.nifi.record.path.functions.Multiply;
 import org.apache.nifi.record.path.functions.PadLeft;
 import org.apache.nifi.record.path.functions.PadRight;
 import org.apache.nifi.record.path.functions.RecordOf;
@@ -63,6 +65,7 @@ import 
org.apache.nifi.record.path.functions.SubstringBeforeLast;
 import org.apache.nifi.record.path.functions.ToBytes;
 import org.apache.nifi.record.path.functions.ToDate;
 import org.apache.nifi.record.path.functions.ToLowerCase;
+import org.apache.nifi.record.path.functions.ToNumber;
 import org.apache.nifi.record.path.functions.ToString;
 import org.apache.nifi.record.path.functions.ToUpperCase;
 import org.apache.nifi.record.path.functions.TrimString;
@@ -458,6 +461,18 @@ public class RecordPathCompiler {
                         final RecordPathSegment[] args = 
getArgPaths(argumentListTree, 2, functionName, absolute);
                         return new Anchored(args[0], args[1], absolute);
                     }
+                    case "multiply": {
+                        final RecordPathSegment[] args = 
getArgPaths(argumentListTree, 2, functionName, absolute);
+                        return new Multiply(args[0], args[1], absolute);
+                    }
+                    case "divide": {
+                        final RecordPathSegment[] args = 
getArgPaths(argumentListTree, 2, functionName, absolute);
+                        return new Divide(args[0], args[1], absolute);
+                    }
+                    case "toNumber": {
+                        final RecordPathSegment[] args = 
getArgPaths(argumentListTree, 1, functionName, absolute);
+                        return new ToNumber(args[0], absolute);
+                    }
                     case "not":
                     case "contains":
                     case "containsRegex":
diff --git 
a/nifi-commons/nifi-record-path/src/test/java/org/apache/nifi/record/path/TestRecordPath.java
 
b/nifi-commons/nifi-record-path/src/test/java/org/apache/nifi/record/path/TestRecordPath.java
index 605632a6cf..9d64882c23 100644
--- 
a/nifi-commons/nifi-record-path/src/test/java/org/apache/nifi/record/path/TestRecordPath.java
+++ 
b/nifi-commons/nifi-record-path/src/test/java/org/apache/nifi/record/path/TestRecordPath.java
@@ -69,6 +69,7 @@ import static 
org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 @SuppressWarnings({"SameParameterValue"})
 public class TestRecordPath {
@@ -2350,6 +2351,279 @@ public class TestRecordPath {
         }
     }
 
+    @Nested
+    class MathFunctions {
+        @Test
+        public void supportsPromoteByteToLong() {
+            final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(2, /bytes[0])", record);
+
+            assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+            assertEquals("144", fieldValue.getValue().toString());
+        }
+        @Test
+        public void supportsPromoteByteToDouble() {
+            final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply('2.0', /bytes[0])", record);
+
+            assertEquals(RecordFieldType.DOUBLE, 
fieldValue.getField().getDataType().getFieldType());
+            assertEquals("144.0", fieldValue.getValue().toString());
+        }
+        @Test
+        public void supportsPromoteShortToLong() {
+            final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(2, /shortNumber)", record);
+
+            assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+            assertEquals("246", fieldValue.getValue().toString());
+        }
+        @Test
+        public void supportsPromoteShortToDouble() {
+            final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply('2.0', /shortNumber)", record);
+
+            assertEquals(RecordFieldType.DOUBLE, 
fieldValue.getField().getDataType().getFieldType());
+            assertEquals("246.0", fieldValue.getValue().toString());
+        }
+        @Test
+        public void supportsPromoteIntToLong() {
+            final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(2, /longNumber)", record);
+
+            assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+            assertEquals("2469135780246913578", 
fieldValue.getValue().toString());
+        }
+        @Test
+        public void supportsPromoteIntToDouble() {
+            final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(2, /mainAccount/balance)", record);
+
+            assertEquals(RecordFieldType.DOUBLE, 
fieldValue.getField().getDataType().getFieldType());
+            assertEquals("246.9", fieldValue.getValue().toString());
+        }
+        @Test
+        public void supportsPromoteLongToDouble() {
+            final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply('2.0', /longNumber)", record);
+
+            assertEquals(RecordFieldType.DOUBLE, 
fieldValue.getField().getDataType().getFieldType());
+            assertEquals("2.4691357802469135E18", 
fieldValue.getValue().toString());
+        }
+        @Test
+        public void supportsPromoteFloatToDouble() {
+            final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply('2.0', /floatNumber)", record);
+
+            assertEquals(RecordFieldType.DOUBLE, 
fieldValue.getField().getDataType().getFieldType());
+            assertEquals("246.89999389648438", 
fieldValue.getValue().toString());
+        }
+        @Test
+        public void throwsExceptionOnUnsupportedLhsType() {
+            Exception exception =
+                    assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("multiply(/numbers, 2)", record));
+            assertEquals("Cannot coerce field 'numbers' to number", 
exception.getMessage());
+        }
+        @Test
+        public void throwsExceptionOnUnsupportedRhsType() {
+            Exception exception =
+                    assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("multiply(2, /firstName)", record));
+            assertEquals("Cannot coerce field 'firstName' to number", 
exception.getMessage());
+        }
+        @Test
+        public void throwsExceptionOnUnsupportedTypeWithAnonymousField() {
+            Exception exception =
+                    assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("multiply(2, 'hello')", record));
+            assertEquals("Cannot coerce field '<Anonymous Inner Field>' to 
number", exception.getMessage());
+        }
+
+        @Nested
+        class Multiply {
+            @Test
+            public void supportsLhsLiteralRhsLiteral() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(2, 2)", record);
+
+                assertEquals("multiply", fieldValue.getField().getFieldName());
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("4", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLhsPathRhsLiteral() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(/id, 2)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("96", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLhsLiteralRhsPath() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(2, /id)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("96", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLhsPathRhsPath() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(/id, /id)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("2304", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLongOverflow() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply(/longNumber, /longNumber)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("-8736265215553819719", 
fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsDoubleOverflow() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("multiply('-1.0e308', /mainAccount/balance)", record);
+
+                assertEquals(RecordFieldType.DOUBLE, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("-Infinity", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLhsNull() {
+                final List<FieldValue> fieldValues = 
evaluateMultiFieldValue("multiply(/notAField, 0)", record);
+                assertTrue(fieldValues.isEmpty());
+            }
+            @Test
+            public void supportsRhsNull() {
+                final List<FieldValue> fieldValues = 
evaluateMultiFieldValue("multiply(0, /notAField)", record);
+                assertTrue(fieldValues.isEmpty());
+            }
+            @Test
+            public void throwsExceptionOnInvalidArityMissingLhs() {
+                Exception exception =
+                        assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("multiply(multiply(/notAField, 0), 
multiply(/notAField, 0))", record));
+                assertEquals("multiply function requires a left-hand operand", 
exception.getMessage());
+            }
+            @Test
+            public void throwsExceptionOnInvalidArityMissingRhs() {
+                Exception exception =
+                        assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("multiply(0, multiply(/notAField, 0))", record));
+                assertEquals("multiply function requires a right-hand 
operand", exception.getMessage());
+            }
+        }
+
+        @Nested
+        class Divide {
+            @Test
+            public void supportsLhsLiteralRhsLiteral() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("divide(3, 2)", record);
+
+                assertEquals("divide", fieldValue.getField().getFieldName());
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("1", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLhsPathRhsLiteral() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("divide(/id, 2)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("24", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLhsLiteralRhsPath() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("divide(2, /id)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("0", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLhsPathRhsPath() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("divide(/id, /id)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("1", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLongOverflow() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("divide(-9223372036854775808, -1)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("-9223372036854775808", 
fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsDoubleOverflow() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("divide('1.0e300', '1.0e-300')", record);
+
+                assertEquals(RecordFieldType.DOUBLE, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("Infinity", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsLhsNull() {
+                final List<FieldValue> fieldValues = 
evaluateMultiFieldValue("divide(/notAField, 0)", record);
+                assertTrue(fieldValues.isEmpty());
+            }
+            @Test
+            public void supportsRhsNull() {
+                final List<FieldValue> fieldValues = 
evaluateMultiFieldValue("divide(0, /notAField)", record);
+                assertTrue(fieldValues.isEmpty());
+            }
+            @Test
+            public void throwsExceptionOnDivideByZeroLong() {
+                Exception exception =
+                        assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("divide(2, 0)", record));
+                assertEquals("Division by zero in RecordPath divide function", 
exception.getMessage());
+            }
+            @Test
+            public void throwsExceptionOnDivideByZeroDouble() {
+                Exception exception =
+                        assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("divide('2.0', 0)", record));
+                assertEquals("Division by zero in RecordPath divide function", 
exception.getMessage());
+            }
+            @Test
+            public void throwsExceptionOnInvalidArityMissingLhs() {
+                Exception exception =
+                        assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("divide(divide(/notAField, 0), divide(/notAField, 
0))", record));
+                assertEquals("divide function requires a left-hand operand", 
exception.getMessage());
+            }
+            @Test
+            public void throwsExceptionOnInvalidArityMissingRhs() {
+                Exception exception =
+                        assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("divide(0, divide(/notAField, 0))", record));
+                assertEquals("divide function requires a right-hand operand", 
exception.getMessage());
+            }
+        }
+
+        @Nested
+        class ToNumber {
+            @Test
+            public void supportsLiteral() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("toNumber('1e1')", record);
+
+                assertEquals("toNumber", fieldValue.getField().getFieldName());
+                assertEquals(RecordFieldType.DOUBLE, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("10.0", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsPath() {
+                record.setValue("id", new 
Date(Instant.parse("1970-01-01T00:00:00Z").toEpochMilli()));
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("toNumber(/id)", record);
+
+                assertEquals(RecordFieldType.LONG, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("0", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsNumber() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("toNumber(/id)", record);
+
+                // preserves type
+                assertEquals(RecordFieldType.INT, 
fieldValue.getField().getDataType().getFieldType());
+                assertEquals("48", fieldValue.getValue().toString());
+            }
+            @Test
+            public void supportsNull() {
+                final FieldValue fieldValue = 
evaluateSingleFieldValue("toNumber(/notAField)", record);
+                assertEquals(null, fieldValue.getValue());
+            }
+            @Test
+            public void throwsExceptionOnUnsupportedType() {
+                Exception exception =
+                        assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("toNumber(/firstName)", record));
+                assertEquals("Cannot coerce field 'firstName' to number", 
exception.getMessage());
+            }
+            @Test
+            public void throwsExceptionOnInvalidArityMissingOperand() {
+                Exception exception =
+                        assertThrows(Exception.class, () -> 
evaluateSingleFieldValue("toNumber(multiply(/notAField, 0))", record));
+                assertEquals("toNumber function requires an operand", 
exception.getMessage());
+            }
+        }
+    }
+
     @Nested
     class FilterFunctions {
         @Test
@@ -3148,7 +3422,9 @@ public class TestRecordPath {
                 recordFieldOf("numbers", arrayTypeOf(RecordFieldType.INT)),
                 recordFieldOf("friends", arrayTypeOf(RecordFieldType.STRING)),
                 recordFieldOf("bytes", arrayTypeOf(RecordFieldType.BYTE)),
-                recordFieldOf("longNumber", RecordFieldType.LONG)
+                recordFieldOf("shortNumber", RecordFieldType.SHORT),
+                recordFieldOf("longNumber", RecordFieldType.LONG),
+                recordFieldOf("floatNumber", RecordFieldType.FLOAT)
         );
     }
 
@@ -3187,7 +3463,9 @@ public class TestRecordPath {
                 entry("numbers", new Integer[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}),
                 entry("friends", new String[]{"John", "Jane", "Jacob", 
"Judy"}),
                 entry("bytes", boxBytes("Hello 
World!".getBytes(StandardCharsets.UTF_8))),
-                entry("longNumber", 1234567890123456789L)
+                entry("shortNumber", (short) 123),
+                entry("longNumber", 1234567890123456789L),
+                entry("floatNumber", 123.45f)
         );
 
         return new MapRecord(getExampleSchema(), new HashMap<>(values));
diff --git a/nifi-docs/src/main/asciidoc/record-path-guide.adoc 
b/nifi-docs/src/main/asciidoc/record-path-guide.adoc
index 89af5e561b..97ea7ab268 100644
--- a/nifi-docs/src/main/asciidoc/record-path-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/record-path-guide.adoc
@@ -1409,3 +1409,102 @@ only of white space (spaces, tabs, carriage returns, 
and new-line characters).
 | `/name[isBlank(/details/phase)]` | John Doe
 | `/name[isBlank(.)]` | <returns no results>
 |==============================================================================
+
+[[math_functions]]
+== Math Functions
+
+Math functions accept the following number types:
+
+- Byte
+- Short
+- Integer
+- Long
+- Float
+- Double
+
+The following non-number types are also accepted if they can be coerced to a 
number:
+
+- String
+- Date
+
+Unless otherwise specified, the calculated result is either a Long or Double:
+
+- If any argument is a Float or Double, the result is a Double
+- Otherwise, the result is a Long
+
+Math functions do not handle overflow.
+
+Examples below use the schema:
+
+```
+{
+    "type": "record",
+    "name": "logs",
+    "fields": [
+        { "name": "flags", "type": "byte" },
+        { "name": "seq", "type" : "short"},
+        { "name": "txns", "type" : "int"},
+        { "name": "timestamp", "type" : "long"},
+        { "name": "temp", "type" : "float"},
+        { "name": "load", "type" : "double"},
+        { "name": "timestampMillis", "type" : "string"},
+        { "name": "date", "type" : "date"},
+    ]
+}
+```
+
+and the record:
+
+```
+{
+    "flags" : 7,
+    "seq": 1024,
+    "txns": 125000,
+    "timestamp": 1768967368,
+    "temp": 55.0,
+    "load": 5.67,
+    "timestampMillis": "1768967368000",
+    "date": "2026-01-21",
+}
+```
+
+
+=== toNumber
+
+Coerces a String or Date value to a number. If the argument is already a 
number it is returned unmodified, preserving the original type.
+
+|==============================================================================
+| RecordPath | Return value
+| `toNumber(456)` | 456
+| `toNumber(/flags)` | 7
+| `toNumber(/load)` | 5.67
+| `toNumber('7.89')` | 7.89
+| `toNumber(/timestampMillis)` | 1768967368000
+| `toNumber(/date)` | 1768953600000
+|==============================================================================
+
+=== multiply
+
+Calculates the product of two numbers.
+
+|==============================================================================
+| RecordPath | Return value
+| `multiply(2, 2)` | 4
+| `multiply('2.0', 2)` | 4.0
+| `multiply(/flags, 2)` | 14
+| `multiply(2, /temp)` | 110.0
+| `multiply(/load, /temp)` | 311.85
+|==============================================================================
+
+=== divide
+
+Calculates the quotient of two numbers.
+
+|==============================================================================
+| RecordPath | Return value
+| `divide(2, 2)` | 1
+| `divide(3, 2)` | 1
+| `divide(3, '2.0')` | 1.5
+| `divide(/txns, /temp)` | 2272.7272727272725
+| `divide(/timestampMillis, 300000)` | 5896557
+|==============================================================================


Reply via email to