Repository: partiql/partiql-lang-kotlin (kotlin)

Issue:
Support `ABS` on `INTERVAL` fields
`ABS` is in the SQL spec and is currently defined on numeric exprs. It's not yet implemented on INTERVAL. We should add support for `ABS` on interval values -- https://ronsavage.github.io/SQL/sql-99.bnf.html#interval%20absolute%20value%20function.

Pull request:
## Relevant Issues
- [Closes/Related To] Issue #1781 

## Description
- Add `ABS` function support on `INTERVAL` values
- Add conformance tests to verify 

## Other Information
- Updated Unreleased Section in CHANGELOG: **[YES]**
- Any backward-incompatible changes? **[NO]**
- Any new external dependencies? **[NO]**
- Do your changes comply with the [contributing][cg] and [code style][csg] guidelines? **[YES]**

## License Information

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

<!-- DO NOT DELETE BELOW -->

[cg]: https://github.com/partiql/partiql-lang-kotlin/blob/main/CONTRIBUTING.md
[csg]: https://github.com/partiql/partiql-lang-kotlin/blob/main/CODE_STYLE.md

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09dd8583c..56cc6e541 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,7 @@ rewritten to their query representation
 - Handle the formatting when the nanoseconds of `INTERVAL` datum underflow
 - CLI printing of negative interval literals
 - Fix alias collision problem when multiple table references need auto-generated aliases
+- Add `ABS` function support on `INTERVAL` field
 
 ### Removed
 
diff --git a/partiql-planner/src/main/kotlin/org/partiql/planner/internal/FnComparator.kt b/partiql-planner/src/main/kotlin/org/partiql/planner/internal/FnComparator.kt
index 7a3dd20f2..7c6509bc4 100644
--- a/partiql-planner/src/main/kotlin/org/partiql/planner/internal/FnComparator.kt
+++ b/partiql-planner/src/main/kotlin/org/partiql/planner/internal/FnComparator.kt
@@ -62,6 +62,8 @@ internal object FnComparator : Comparator<FnOverload> {
         PType.TIMEZ,
         PType.TIMESTAMP,
         PType.TIMESTAMPZ,
+        PType.INTERVAL_YM,
+        PType.INTERVAL_DT,
         PType.ARRAY,
         PType.BAG,
         PType.ROW,
diff --git a/partiql-spi/src/main/kotlin/org/partiql/spi/function/Builtins.kt b/partiql-spi/src/main/kotlin/org/partiql/spi/function/Builtins.kt
index e8b6d53d8..7dd40c78d 100644
--- a/partiql-spi/src/main/kotlin/org/partiql/spi/function/Builtins.kt
+++ b/partiql-spi/src/main/kotlin/org/partiql/spi/function/Builtins.kt
@@ -27,6 +27,8 @@ internal object Builtins {
         Fn_ABS__FLOAT32__FLOAT32,
         Fn_ABS__FLOAT64__FLOAT64,
         Fn_ABS__DECIMAL_ARBITRARY__DECIMAL_ARBITRARY,
+        Fn_ABS__INTERVAL_YM__INTERVAL_YM,
+        Fn_ABS__INTERVAL_DT__INTERVAL_DT,
         Fn_AND__BOOL_BOOL__BOOL,
         FnBetween,
         Fn_BIT_LENGTH__STRING__INT32,
diff --git a/partiql-spi/src/main/kotlin/org/partiql/spi/function/builtins/FnAbs.kt b/partiql-spi/src/main/kotlin/org/partiql/spi/function/builtins/FnAbs.kt
index 537dbfd18..95a41e202 100644
--- a/partiql-spi/src/main/kotlin/org/partiql/spi/function/builtins/FnAbs.kt
+++ b/partiql-spi/src/main/kotlin/org/partiql/spi/function/builtins/FnAbs.kt
@@ -6,6 +6,7 @@ package org.partiql.spi.function.builtins
 import org.partiql.spi.function.Function
 import org.partiql.spi.function.Parameter
 import org.partiql.spi.function.builtins.internal.PErrors
+import org.partiql.spi.types.IntervalCode
 import org.partiql.spi.types.PType
 import org.partiql.spi.value.Datum
 import kotlin.math.absoluteValue
@@ -110,3 +111,97 @@ internal val Fn_ABS__FLOAT64__FLOAT64 = Function.overload(
     val value = args[0].double
     Datum.doublePrecision(value.absoluteValue)
 }
+
+internal val Fn_ABS__INTERVAL_YM__INTERVAL_YM = Function.overload(
+    name = "abs",
+    returns = PType.intervalYearMonth(2), // Default precision 2
+    parameters = arrayOf(Parameter("value", PType.intervalYearMonth(2))),
+) { args ->
+    val interval = args[0]
+    when (interval.type.getIntervalCode()) {
+        IntervalCode.YEAR -> Datum.intervalYear(
+            interval.years.absoluteValue,
+            interval.type.precision,
+        )
+        IntervalCode.MONTH -> Datum.intervalMonth(
+            interval.months.absoluteValue,
+            interval.type.precision,
+        )
+        IntervalCode.YEAR_MONTH -> Datum.intervalYearMonth(
+            interval.years.absoluteValue,
+            interval.months.absoluteValue,
+            interval.type.precision,
+        )
+        else -> throw IllegalArgumentException("Unexpected interval code for year-month interval: ${interval.type.getIntervalCode()}")
+    }
+}
+
+internal val Fn_ABS__INTERVAL_DT__INTERVAL_DT = Function.overload(
+    name = "abs",
+    returns = PType.intervalDaySecond(2, 6), // Default precision 2, fractional precision 6
+    parameters = arrayOf(Parameter("value", PType.intervalDaySecond(2, 6))),
+) { args ->
+    val interval = args[0]
+    val intervalCode = interval.type.getIntervalCode()
+    when (intervalCode) {
+        IntervalCode.DAY -> Datum.intervalDay(
+            interval.days.absoluteValue,
+            interval.type.precision,
+        )
+        IntervalCode.HOUR -> Datum.intervalHour(
+            interval.hours.absoluteValue,
+            interval.type.precision,
+        )
+        IntervalCode.MINUTE -> Datum.intervalMinute(
+            interval.minutes.absoluteValue,
+            interval.type.precision,
+        )
+        IntervalCode.SECOND -> Datum.intervalSecond(
+            interval.seconds.absoluteValue,
+            interval.nanos.absoluteValue,
+            interval.type.precision,
+            interval.type.fractionalPrecision,
+        )
+        IntervalCode.DAY_HOUR -> Datum.intervalDayHour(
+            interval.days.absoluteValue,
+            interval.hours.absoluteValue,
+            interval.type.precision,
+        )
+        IntervalCode.DAY_MINUTE -> Datum.intervalDayMinute(
+            interval.days.absoluteValue,
+            interval.hours.absoluteValue,
+            interval.minutes.absoluteValue,
+            interval.type.precision,
+        )
+        IntervalCode.DAY_SECOND -> Datum.intervalDaySecond(
+            interval.days.absoluteValue,
+            interval.hours.absoluteValue,
+            interval.minutes.absoluteValue,
+            interval.seconds.absoluteValue,
+            interval.nanos.absoluteValue,
+            interval.type.precision,
+            interval.type.fractionalPrecision,
+        )
+        IntervalCode.HOUR_MINUTE -> Datum.intervalHourMinute(
+            interval.hours.absoluteValue,
+            interval.minutes.absoluteValue,
+            interval.type.precision,
+        )
+        IntervalCode.HOUR_SECOND -> Datum.intervalHourSecond(
+            interval.hours.absoluteValue,
+            interval.minutes.absoluteValue,
+            interval.seconds.absoluteValue,
+            interval.nanos.absoluteValue,
+            interval.type.precision,
+            interval.type.fractionalPrecision,
+        )
+        IntervalCode.MINUTE_SECOND -> Datum.intervalMinuteSecond(
+            interval.minutes.absoluteValue,
+            interval.seconds.absoluteValue,
+            interval.nanos.absoluteValue,
+            interval.type.precision,
+            interval.type.fractionalPrecision,
+        )
+        else -> throw IllegalArgumentException("Unexpected interval code for day-time interval: $intervalCode")
+    }
+}


Test patch:
diff --git a/partiql-eval/src/test/kotlin/org/partiql/eval/internal/PartiQLEvaluatorTest.kt b/partiql-eval/src/test/kotlin/org/partiql/eval/internal/PartiQLEvaluatorTest.kt
index ccdb6bbe5..f42ba1eee 100644
--- a/partiql-eval/src/test/kotlin/org/partiql/eval/internal/PartiQLEvaluatorTest.kt
+++ b/partiql-eval/src/test/kotlin/org/partiql/eval/internal/PartiQLEvaluatorTest.kt
@@ -65,6 +65,11 @@ class PartiQLEvaluatorTest {
     @Execution(ExecutionMode.CONCURRENT)
     fun castTests(tc: SuccessTestCase) = tc.run()
 
+    @ParameterizedTest
+    @MethodSource("intervalAbsTestCases")
+    @Execution(ExecutionMode.CONCURRENT)
+    fun intervalAbsTests(tc: SuccessTestCase) = tc.run()
+
     companion object {
 
         @JvmStatic
@@ -1408,6 +1413,168 @@ class PartiQLEvaluatorTest {
                 )
             )
         )
+
+        @JvmStatic
+        fun intervalAbsTestCases() = listOf(
+            // Year-Month interval tests
+            SuccessTestCase(
+                input = "ABS(INTERVAL '1-6' YEAR TO MONTH)",
+                expected = Datum.intervalYearMonth(1, 6, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '+1-6' YEAR TO MONTH)",
+                expected = Datum.intervalYearMonth(1, 6, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-1-6' YEAR TO MONTH)",
+                expected = Datum.intervalYearMonth(1, 6, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '100-6' YEAR(3) TO MONTH)",
+                expected = Datum.intervalYearMonth(100, 6, 3)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-100-6' YEAR(3) TO MONTH)",
+                expected = Datum.intervalYearMonth(100, 6, 3)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '5' YEAR)",
+                expected = Datum.intervalYear(5, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-5' YEAR)",
+                expected = Datum.intervalYear(5, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '100' MONTH(3))",
+                expected = Datum.intervalMonth(100, 3)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-100' MONTH(3))",
+                expected = Datum.intervalMonth(100, 3)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '0-0' YEAR TO MONTH)",
+                expected = Datum.intervalYearMonth(0, 0, 2)
+            ),
+            // Day-Time interval tests  
+            SuccessTestCase(
+                input = "ABS(INTERVAL '5' DAY)",
+                expected = Datum.intervalDay(5, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-5' DAY)",
+                expected = Datum.intervalDay(5, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '100' DAY(3))",
+                expected = Datum.intervalDay(100, 3)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-100' DAY(3))",
+                expected = Datum.intervalDay(100, 3)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '3' HOUR)",
+                expected = Datum.intervalHour(3, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-3' HOUR)",
+                expected = Datum.intervalHour(3, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '30' MINUTE)",
+                expected = Datum.intervalMinute(30, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-30' MINUTE)",
+                expected = Datum.intervalMinute(30, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '45.123' SECOND)",
+                expected = Datum.intervalSecond(45, 123000000, 2, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-45.123' SECOND)",
+                expected = Datum.intervalSecond(45, 123000000, 2, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '123.456789' SECOND(3,6))",
+                expected = Datum.intervalSecond(123, 456789000, 3, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-123.456789' SECOND(3,6))",
+                expected = Datum.intervalSecond(123, 456789000, 3, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '1 2' DAY TO HOUR)",
+                expected = Datum.intervalDayHour(1, 2, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-1 2' DAY TO HOUR)",
+                expected = Datum.intervalDayHour(1, 2, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '1 2:30' DAY TO MINUTE)",
+                expected = Datum.intervalDayMinute(1, 2, 30, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-1 2:30' DAY TO MINUTE)",
+                expected = Datum.intervalDayMinute(1, 2, 30, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '100 10:30' DAY(3) TO MINUTE)",
+                expected = Datum.intervalDayMinute(100, 10, 30, 3)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-100 10:30' DAY(3) TO MINUTE)",
+                expected = Datum.intervalDayMinute(100, 10, 30, 3)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '1 2:30:45.567' DAY TO SECOND)",
+                expected = Datum.intervalDaySecond(1, 2, 30, 45, 567000000, 2, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-1 2:30:45.567' DAY TO SECOND)",
+                expected = Datum.intervalDaySecond(1, 2, 30, 45, 567000000, 2, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '100 10:30:45.123456' DAY(3) TO SECOND(6))",
+                expected = Datum.intervalDaySecond(100, 10, 30, 45, 123456000, 3, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-100 10:30:45.123456' DAY(3) TO SECOND(6))",
+                expected = Datum.intervalDaySecond(100, 10, 30, 45, 123456000, 3, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '2:30' HOUR TO MINUTE)",
+                expected = Datum.intervalHourMinute(2, 30, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-2:30' HOUR TO MINUTE)",
+                expected = Datum.intervalHourMinute(2, 30, 2)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '2:30:45.789' HOUR TO SECOND)",
+                expected = Datum.intervalHourSecond(2, 30, 45, 789000000, 2, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-2:30:45.789' HOUR TO SECOND)",
+                expected = Datum.intervalHourSecond(2, 30, 45, 789000000, 2, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '30:45.123' MINUTE TO SECOND)",
+                expected = Datum.intervalMinuteSecond(30, 45, 123000000, 2, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '-30:45.123' MINUTE TO SECOND)",
+                expected = Datum.intervalMinuteSecond(30, 45, 123000000, 2, 6)
+            ),
+            SuccessTestCase(
+                input = "ABS(INTERVAL '0 0:0:0' DAY TO SECOND)",
+                expected = Datum.intervalDaySecond(0, 0, 0, 0, 0, 2, 6)
+            )
+        )
     }
 
     @Test
diff --git a/partiql-spi/src/testFixtures/java/org/partiql/spi/value/ValueUtils.java b/partiql-spi/src/testFixtures/java/org/partiql/spi/value/ValueUtils.java
index a7fcd1d99..221206fe2 100644
--- a/partiql-spi/src/testFixtures/java/org/partiql/spi/value/ValueUtils.java
+++ b/partiql-spi/src/testFixtures/java/org/partiql/spi/value/ValueUtils.java
@@ -24,6 +24,8 @@
 import static org.partiql.spi.types.PType.DOUBLE;
 import static org.partiql.spi.types.PType.DYNAMIC;
 import static org.partiql.spi.types.PType.INTEGER;
+import static org.partiql.spi.types.PType.INTERVAL_DT;
+import static org.partiql.spi.types.PType.INTERVAL_YM;
 import static org.partiql.spi.types.PType.NUMERIC;
 import static org.partiql.spi.types.PType.REAL;
 import static org.partiql.spi.types.PType.ROW;
@@ -90,6 +92,23 @@ public static PartiQLValue newPartiQLValue(@NotNull Datum datum) {
                 return datum.isNull() ? PartiQL.timestampValue(null) : PartiQL.timestampValue(DateTimeUtil.toTimestamp(datum.getLocalDateTime()));
             case TIMESTAMPZ:
                 return datum.isNull() ? PartiQL.timestampValue(null) : PartiQL.timestampValue(DateTimeUtil.toTimestamp(datum.getOffsetDateTime()));
+            // TODO: Temporary placeholder for INTERVAL NULL handling.
+            // We're not adding full INTERVAL support to this function due to PartiQLValue removal.
+            // Related issues:
+            // https://github.com/partiql/partiql-lang-kotlin/issues/1782
+            // https://github.com/partiql/partiql-lang-kotlin/issues/1783
+            case INTERVAL_YM:
+                if (datum.isNull()) {
+                    return PartiQL.nullValue();
+                } else {
+                    throw new UnsupportedOperationException("Unsupported datum type: " + type);
+                }
+            case INTERVAL_DT:
+                if (datum.isNull()) {
+                    return PartiQL.nullValue();
+                } else {
+                    throw new UnsupportedOperationException("Unsupported datum type: " + type);
+                }
             case BAG:
                 return datum.isNull() ? PartiQL.bagValue((Iterable<? extends PartiQLValue>) null) : PartiQL.bagValue(new PQLToPartiQLIterable(datum));
             case ARRAY: