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

Issue:
`INTERVAL` does not allow for sign (+/-) before the literal
## Description
- `INTERVAL` literals do not allow for a sign before the interval value
- Parsing is fine but bug is in the AST -> Plan conversion

## To Reproduce
```
partiql ▶ INTERVAL '-10' YEAR (2);
e: [semantic] Unexpected failure encountered. Caused by: java.lang.IllegalStateException: Invalid interval string: '-10'.
java.lang.IllegalStateException: Invalid interval string: '-10'
        at org.partiql.planner.internal.util.IntervalUtils.parseIntervalSingle(IntervalUtils.kt:48)
        at org.partiql.planner.internal.util.IntervalUtils.parseInterval$partiql_planner(IntervalUtils.kt:21)
```

## Expected Behavior
Expect to be able to parse negative interval literal values/.

## Additional Context
- Java version: 17
- PartiQL version: 1.2.3-SNAPSHOT

Pull request:
## Relevant Issues
-  Issue #1770 

## Description
- Refactor the parsing logic to support signed `INTERVAL` literals (with optional leading '+'/'-')
- Add test cases to verify it works

## Other Information
- Updated Unreleased Section in CHANGELOG: **[YES]**: <Explain if NO>
- Any backward-incompatible changes? **[NO]**: <Explain if YES>
- Any new external dependencies? **[NO]**: <Explain if YES>
- 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 f16969f74..d8969ae3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ Thank you to all who have contributed!
 rewritten to their query representation
   - **NOTE** evaluating plans without the inlined `With` rewrite will not work as expected. Users trying to evaluate
   `With` should use the default planner.
+- Signed `INTERVAL` literals support 
 
 ### Changed
 
diff --git a/partiql-planner/src/main/kotlin/org/partiql/planner/internal/util/IntervalUtils.kt b/partiql-planner/src/main/kotlin/org/partiql/planner/internal/util/IntervalUtils.kt
index 8592cd675..e71168d1f 100644
--- a/partiql-planner/src/main/kotlin/org/partiql/planner/internal/util/IntervalUtils.kt
+++ b/partiql-planner/src/main/kotlin/org/partiql/planner/internal/util/IntervalUtils.kt
@@ -31,24 +31,25 @@ internal object IntervalUtils {
      * This represents an interval string unquoted used for parsing single datetime fields. "is2" is used for the optional
      * fractional part of seconds. We check this.
      */
-    private val INTERVAL_SINGLE = Pattern.compile("(?<is1>\\d+)(\\.(?<is2>\\d+))?")
+    private val INTERVAL_SINGLE = Pattern.compile("(?<sign>[+-])?(?<is1>\\d+)(\\.(?<is2>\\d+))?")
 
     // Below are the regular expressions used for interval ranges (multiple datetime fields).
-    private val INTERVAL_YEAR_MONTH = Pattern.compile("(?<y>\\d+)-(?<m>\\d+)")
-    private val INTERVAL_DAY_HOUR = Pattern.compile("(?<d>\\d+)\\s+(?<h>\\d+)")
-    private val INTERVAL_DAY_MINUTE = Pattern.compile("(?<d>\\d+)\\s+(?<h>\\d+):(?<m>\\d+)")
-    private val INTERVAL_DAY_SECOND = Pattern.compile("(?<d>\\d+)\\s+(?<h>\\d+):(?<m>\\d+):(?<s1>\\d+)(\\.(?<s2>\\d+))?")
-    private val INTERVAL_HOUR_MINUTE = Pattern.compile("(?<h>\\d+):(?<m>\\d+)")
-    private val INTERVAL_HOUR_SECOND = Pattern.compile("(?<h>\\d+):(?<m>\\d+):(?<s1>\\d+)(\\.(?<s2>\\d+))?")
-    private val INTERVAL_MINUTE_SECOND = Pattern.compile("(?<m>\\d+):(?<s1>\\d+)(\\.(?<s2>\\d+))?")
+    private val INTERVAL_YEAR_MONTH = Pattern.compile("(?<sign>[+-])?(?<y>\\d+)-(?<m>\\d+)")
+    private val INTERVAL_DAY_HOUR = Pattern.compile("(?<sign>[+-])?(?<d>\\d+)\\s+(?<h>\\d+)")
+    private val INTERVAL_DAY_MINUTE = Pattern.compile("(?<sign>[+-])?(?<d>\\d+)\\s+(?<h>\\d+):(?<mi>\\d+)")
+    private val INTERVAL_DAY_SECOND = Pattern.compile("(?<sign>[+-])?(?<d>\\d+)\\s+(?<h>\\d+):(?<mi>\\d+):(?<s1>\\d+)(\\.(?<s2>\\d+))?")
+    private val INTERVAL_HOUR_MINUTE = Pattern.compile("(?<sign>[+-])?(?<h>\\d+):(?<mi>\\d+)")
+    private val INTERVAL_HOUR_SECOND = Pattern.compile("(?<sign>[+-])?(?<h>\\d+):(?<mi>\\d+):(?<s1>\\d+)(\\.(?<s2>\\d+))?")
+    private val INTERVAL_MINUTE_SECOND = Pattern.compile("(?<sign>[+-])?(?<mi>\\d+):(?<s1>\\d+)(\\.(?<s2>\\d+))?")
 
     private fun parseIntervalSingle(input: String, qualifier: IntervalQualifier.Single): Datum {
         val matcher: Matcher = INTERVAL_SINGLE.matcher(input)
         if (!matcher.matches()) {
             error("Invalid interval string: '$input'")
         }
-        val integral: Int = matcher.group("is1")!!.toInt()
-        val fractional: Int? = matcher.group("is2")?.getNanosFromFractionalSeconds()
+        val sign: Int = if (matcher.group("sign") == "-") -1 else 1
+        val integral: Int = matcher.group("is1")!!.toInt().times(sign)
+        val fractional: Int? = matcher.group("is2")?.getNanosFromFractionalSeconds()?.times(sign)
         val precision = qualifier.precision ?: DEFAULT_INTERVAL_PRECISION
         return when (qualifier.field.code()) {
             DatetimeField.YEAR -> {
@@ -82,6 +83,22 @@ internal object IntervalUtils {
         }
     }
 
+    private class IntervalFieldExtractor(
+        private val matcher: Matcher
+    ) {
+        private val sign: Int = if (matcher.group("sign") == "-") -1 else 1
+
+        val year: Int by lazy { matcher.group("y")!!.toInt().times(sign) }
+        val month: Int by lazy { matcher.group("m")!!.toInt().times(sign) }
+        val day: Int by lazy { matcher.group("d")!!.toInt().times(sign) }
+        val hour: Int by lazy { matcher.group("h")!!.toInt().times(sign) }
+        val minute: Int by lazy { matcher.group("mi")!!.toInt().times(sign) }
+        val second: Int by lazy { matcher.group("s1")!!.toInt().times(sign) }
+        val nanos: Int by lazy {
+            matcher.group("s2")?.getNanosFromFractionalSeconds()?.times(sign) ?: 0
+        }
+    }
+
     private fun parseIntervalRange(input: String, qualifier: IntervalQualifier.Range): Datum {
         val start = qualifier.startField.code()
         val end = qualifier.endField.code()
@@ -91,80 +108,66 @@ internal object IntervalUtils {
                 if (!matcher.matches()) {
                     error("Invalid interval string: '$input'")
                 }
-                val year: Int = matcher.group("y")!!.toInt()
-                val month: Int = matcher.group("m")!!.toInt()
+                val extractor = IntervalFieldExtractor(matcher)
                 val precision = qualifier.startFieldPrecision ?: DEFAULT_INTERVAL_PRECISION
-                return Datum.intervalYearMonth(year, month, precision)
+                return Datum.intervalYearMonth(extractor.year, extractor.month, precision)
             }
             DatetimeField.DAY to DatetimeField.HOUR -> {
                 val matcher: Matcher = INTERVAL_DAY_HOUR.matcher(input)
                 if (!matcher.matches()) {
                     error("Invalid interval string: '$input'")
                 }
-                val day: Int = matcher.group("d")!!.toInt()
-                val hour: Int = matcher.group("h")!!.toInt()
+                val extractor = IntervalFieldExtractor(matcher)
                 val precision = qualifier.startFieldPrecision ?: DEFAULT_INTERVAL_PRECISION
-                return Datum.intervalDayHour(day, hour, precision)
+                return Datum.intervalDayHour(extractor.day, extractor.hour, precision)
             }
             DatetimeField.DAY to DatetimeField.MINUTE -> {
                 val matcher: Matcher = INTERVAL_DAY_MINUTE.matcher(input)
                 if (!matcher.matches()) {
                     error("Invalid interval string: '$input'")
                 }
-                val day: Int = matcher.group("d")!!.toInt()
-                val hour: Int = matcher.group("h")!!.toInt()
-                val minute: Int = matcher.group("m")!!.toInt()
+                val extractor = IntervalFieldExtractor(matcher)
                 val precision = qualifier.startFieldPrecision ?: DEFAULT_INTERVAL_PRECISION
-                return Datum.intervalDayMinute(day, hour, minute, precision)
+                return Datum.intervalDayMinute(extractor.day, extractor.hour, extractor.minute, precision)
             }
             DatetimeField.DAY to DatetimeField.SECOND -> {
                 val matcher: Matcher = INTERVAL_DAY_SECOND.matcher(input)
                 if (!matcher.matches()) {
                     error("Invalid interval string: '$input'")
                 }
-                val day: Int = matcher.group("d")!!.toInt()
-                val hour: Int = matcher.group("h")!!.toInt()
-                val minute: Int = matcher.group("m")!!.toInt()
-                val second: Int = matcher.group("s1")!!.toInt()
-                val nanos: Int = matcher.group("s2")?.getNanosFromFractionalSeconds() ?: 0
+                val extractor = IntervalFieldExtractor(matcher)
                 val precision = qualifier.startFieldPrecision ?: DEFAULT_INTERVAL_PRECISION
                 val scale = qualifier.endFieldFractionalPrecision ?: DEFAULT_INTERVAL_FRACTIONAL_PRECISION
-                return Datum.intervalDaySecond(day, hour, minute, second, nanos, precision, scale)
+                return Datum.intervalDaySecond(extractor.day, extractor.hour, extractor.minute, extractor.second, extractor.nanos, precision, scale)
             }
             DatetimeField.HOUR to DatetimeField.MINUTE -> {
                 val matcher: Matcher = INTERVAL_HOUR_MINUTE.matcher(input)
                 if (!matcher.matches()) {
                     error("Invalid interval string: '$input'")
                 }
-                val hour: Int = matcher.group("h")!!.toInt()
-                val minute: Int = matcher.group("m")!!.toInt()
+                val extractor = IntervalFieldExtractor(matcher)
                 val precision = qualifier.startFieldPrecision ?: DEFAULT_INTERVAL_PRECISION
-                return Datum.intervalHourMinute(hour, minute, precision)
+                return Datum.intervalHourMinute(extractor.hour, extractor.minute, precision)
             }
             DatetimeField.HOUR to DatetimeField.SECOND -> {
                 val matcher: Matcher = INTERVAL_HOUR_SECOND.matcher(input)
                 if (!matcher.matches()) {
                     error("Invalid interval string: '$input'")
                 }
-                val hour: Int = matcher.group("h")!!.toInt()
-                val minute: Int = matcher.group("m")!!.toInt()
-                val second: Int = matcher.group("s1")!!.toInt()
-                val nanos: Int = matcher.group("s2")?.getNanosFromFractionalSeconds() ?: 0
+                val extractor = IntervalFieldExtractor(matcher)
                 val precision = qualifier.startFieldPrecision ?: DEFAULT_INTERVAL_PRECISION
                 val scale = qualifier.endFieldFractionalPrecision ?: DEFAULT_INTERVAL_FRACTIONAL_PRECISION
-                return Datum.intervalHourSecond(hour, minute, second, nanos, precision, scale)
+                return Datum.intervalHourSecond(extractor.hour, extractor.minute, extractor.second, extractor.nanos, precision, scale)
             }
             DatetimeField.MINUTE to DatetimeField.SECOND -> {
                 val matcher: Matcher = INTERVAL_MINUTE_SECOND.matcher(input)
                 if (!matcher.matches()) {
                     error("Invalid interval string: '$input'")
                 }
-                val minute: Int = matcher.group("m")!!.toInt()
-                val second: Int = matcher.group("s1")!!.toInt()
-                val nanos: Int = matcher.group("s2")?.getNanosFromFractionalSeconds() ?: 0
+                val extractor = IntervalFieldExtractor(matcher)
                 val precision = qualifier.startFieldPrecision ?: DEFAULT_INTERVAL_PRECISION
                 val scale = qualifier.endFieldFractionalPrecision ?: DEFAULT_INTERVAL_FRACTIONAL_PRECISION
-                return Datum.intervalMinuteSecond(minute, second, nanos, precision, scale)
+                return Datum.intervalMinuteSecond(extractor.minute, extractor.second, extractor.nanos, precision, scale)
             }
             else -> error("Not a valid interval range: ${qualifier.startField} TO ${qualifier.endField}.")
         }


Test patch:
diff --git a/partiql-eval/src/test/kotlin/org/partiql/eval/internal/IntervalParseSignedTests.kt b/partiql-eval/src/test/kotlin/org/partiql/eval/internal/IntervalParseSignedTests.kt
new file mode 100644
index 000000000..65363fa5a
--- /dev/null
+++ b/partiql-eval/src/test/kotlin/org/partiql/eval/internal/IntervalParseSignedTests.kt
@@ -0,0 +1,75 @@
+package org.partiql.eval.internal
+
+import org.junit.jupiter.api.parallel.Execution
+import org.junit.jupiter.api.parallel.ExecutionMode
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
+import org.partiql.spi.value.Datum
+
+class IntervalParseSignedTests {
+
+    @ParameterizedTest
+    @MethodSource("signedIntervalCases")
+    @Execution(ExecutionMode.CONCURRENT)
+    fun signedInterval(tc: SuccessTestCase) = tc.run()
+
+    companion object {
+        private const val INTERVAL_POSITIVE_M = "INTERVAL '+1' MONTH"
+        private const val INTERVAL_NEGATIVE_M = "INTERVAL '-1' MONTH"
+        private const val INTERVAL_POSITIVE_D = "INTERVAL '+1' DAY"
+        private const val INTERVAL_NEGATIVE_D = "INTERVAL '-1' DAY"
+        private const val INTERVAL_POSITIVE_H = "INTERVAL '+1' HOUR"
+        private const val INTERVAL_NEGATIVE_H = "INTERVAL '-1' HOUR"
+        private const val INTERVAL_POSITIVE_MIN = "INTERVAL '+1' MINUTE"
+        private const val INTERVAL_NEGATIVE_MIN = "INTERVAL '-1' MINUTE"
+        private const val INTERVAL_POSITIVE_S = "INTERVAL '+1.1' SECOND"
+        private const val INTERVAL_NEGATIVE_S = "INTERVAL '-1.1' SECOND"
+        private const val INTERVAL_POSITIVE_YM = "INTERVAL '+1-1' YEAR TO MONTH"
+        private const val INTERVAL_NEGATIVE_YM = "INTERVAL '-1-1' YEAR TO MONTH"
+        private const val INTERVAL_POSITIVE_DTH = "INTERVAL '+1 1' DAY TO HOUR"
+        private const val INTERVAL_NEGATIVE_DTH = "INTERVAL '-1 1' DAY TO HOUR"
+        private const val INTERVAL_POSITIVE_DTM = "INTERVAL '+1 1:1' DAY TO MINUTE"
+        private const val INTERVAL_NEGATIVE_DTM = "INTERVAL '-1 1:1' DAY TO MINUTE"
+        private const val INTERVAL_POSITIVE_DTS = "INTERVAL '+1 1:1:1.1' DAY TO SECOND"
+        private const val INTERVAL_NEGATIVE_DTS = "INTERVAL '-1 1:1:1.1' DAY TO SECOND"
+        private const val INTERVAL_POSITIVE_HM = "INTERVAL '+1:1' HOUR TO MINUTE"
+        private const val INTERVAL_NEGATIVE_HM = "INTERVAL '-1:1' HOUR TO MINUTE"
+        private const val INTERVAL_POSITIVE_HTS = "INTERVAL '+1:1:1.1' HOUR TO SECOND"
+        private const val INTERVAL_NEGATIVE_HTS = "INTERVAL '-1:1:1.1' HOUR TO SECOND"
+        private const val INTERVAL_POSITIVE_MTS = "INTERVAL '+1:1.1' MINUTE TO SECOND"
+        private const val INTERVAL_NEGATIVE_MTS = "INTERVAL '-1:1.1' MINUTE TO SECOND"
+
+        private const val NANO = 100_000_000 // 0.1
+
+        @JvmStatic
+        fun signedIntervalCases() = listOf(
+            // parse single
+            SuccessTestCase(INTERVAL_POSITIVE_M, Datum.intervalMonth(1, 2)),
+            SuccessTestCase(INTERVAL_NEGATIVE_M, Datum.intervalMonth(-1, 2)),
+            SuccessTestCase(INTERVAL_POSITIVE_D, Datum.intervalDay(1, 2)),
+            SuccessTestCase(INTERVAL_NEGATIVE_D, Datum.intervalDay(-1, 2)),
+            SuccessTestCase(INTERVAL_POSITIVE_H, Datum.intervalHour(1, 2)),
+            SuccessTestCase(INTERVAL_NEGATIVE_H, Datum.intervalHour(-1, 2)),
+            SuccessTestCase(INTERVAL_POSITIVE_MIN, Datum.intervalMinute(1, 2)),
+            SuccessTestCase(INTERVAL_NEGATIVE_MIN, Datum.intervalMinute(-1, 2)),
+            SuccessTestCase(INTERVAL_POSITIVE_S, Datum.intervalSecond(1, NANO, 2, 6)),
+            SuccessTestCase(INTERVAL_NEGATIVE_S, Datum.intervalSecond(-1, -NANO, 2, 6)),
+
+            // parse range
+            SuccessTestCase(INTERVAL_POSITIVE_YM, Datum.intervalYearMonth(1, 1, 2)),
+            SuccessTestCase(INTERVAL_NEGATIVE_YM, Datum.intervalYearMonth(-1, -1, 2)),
+            SuccessTestCase(INTERVAL_POSITIVE_DTH, Datum.intervalDayHour(1, 1, 2)),
+            SuccessTestCase(INTERVAL_NEGATIVE_DTH, Datum.intervalDayHour(-1, -1, 2)),
+            SuccessTestCase(INTERVAL_POSITIVE_DTM, Datum.intervalDayMinute(1, 1, 1, 2)),
+            SuccessTestCase(INTERVAL_NEGATIVE_DTM, Datum.intervalDayMinute(-1, -1, -1, 2)),
+            SuccessTestCase(INTERVAL_POSITIVE_DTS, Datum.intervalDaySecond(1, 1, 1, 1, NANO, 2, 6)),
+            SuccessTestCase(INTERVAL_NEGATIVE_DTS, Datum.intervalDaySecond(-1, -1, -1, -1, -NANO, 2, 6)),
+            SuccessTestCase(INTERVAL_POSITIVE_HM, Datum.intervalHourMinute(1, 1, 2)),
+            SuccessTestCase(INTERVAL_NEGATIVE_HM, Datum.intervalHourMinute(-1, -1, 2)),
+            SuccessTestCase(INTERVAL_POSITIVE_HTS, Datum.intervalHourSecond(1, 1, 1, NANO, 2, 6)),
+            SuccessTestCase(INTERVAL_NEGATIVE_HTS, Datum.intervalHourSecond(-1, -1, -1, -NANO, 2, 6)),
+            SuccessTestCase(INTERVAL_POSITIVE_MTS, Datum.intervalMinuteSecond(1, 1, NANO, 2, 6)),
+            SuccessTestCase(INTERVAL_NEGATIVE_MTS, Datum.intervalMinuteSecond(-1, -1, -NANO, 2, 6))
+        )
+    }
+}