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

Issue:
Parser can't automatically detect timezone info in timestamp literal
## Relevant Issue/Bug
-  The parser needed to automatically detect timezone information in timestamp literals (like TIMESTAMP '2018-01-01 00:00:00.000+00:00') and map them to the appropriate timezone-aware data types (TIMESTAMP_WITH_TIME_ZONE or TIME_WITH_TIME_ZONE) instead of the basic TIMESTAMP or TIME types.

## Describe Alternatives
- none

## Additional Context
Currently functions like `FnOverlaps` contain `TODO` comments blocking timezone support due to this missing conversion. The parser can handle timezone syntax but the types don't map to SPI layer for evaluation.

## DoD (Definition of Done)
 - Update regex patterns to detect timezone suffixes (+HH:MM, -HH:MM, Z) in timestamp and time literals
 - Update logic to automatically map timezone-aware literals to correct data types
 - Add tests verifying timezone types parse and evaluate correctly

Pull request:
## Relevant Issues
- Closes #1795 

## Description
- Add support for parsing `TIMESTAMP` and `TIME` with timezone
- Fix `TIMESTAMP` print out format in conformance tests

### Current Behavior
```
partiql ▶ TIME '12:00:00';

=== RESULT ===
TIME '12:00'

OK!

partiql ▶ TIME '12:00:00+05:00';

=== RESULT ===
TIME '12:00+05:00'

OK!

partiql ▶ TIME WITH TIME ZONE '12:00:00';
e: [syntax] Unexpected failure encountered. Caused by: org.partiql.parser.PartiQLLexerException: TIME WITH TIME ZONE specified but no time offset in literal string.
org.partiql.parser.PartiQLLexerException: TIME WITH TIME ZONE specified but no time offset in literal string  

partiql ▶ TIMESTAMP '2018-01-01 00:00:00.000';

=== RESULT ===
TIMESTAMP '2018-01-01T00:00'

OK!

partiql ▶ TIMESTAMP '2018-01-01 00:00:00.000-05:00';

=== RESULT ===
TIMESTAMP '2018-01-01T00:00-05:00'

OK!

partiql ▶ TIMESTAMP WITH TIME ZONE '2018-01-01 00:00:00.000+05:00';

=== RESULT ===
TIMESTAMP '2018-01-01T00:00+05:00'

OK!

partiql ▶ TIMESTAMP WITH TIME ZONE '2018-01-01 00:00:00.000';
e: [syntax] Unexpected failure encountered. Caused by: org.partiql.parser.PartiQLLexerException: TIMESTAMP WITH TIME ZONE specified but no time offset in literal string.
org.partiql.parser.PartiQLLexerException: TIMESTAMP WITH TIME ZONE specified but no time offset in literal string
```

## 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 a2b9f099d..77e1afeb0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,6 +59,7 @@ rewritten to their query representation
 - Prioritize `missing` propagation when both `null` and `missing` exist in function args
 - Return `missing` instead of `null` for `NULLIF(MISSING, MISSING)`
 - CLI printing of mixed sign literals
+- Parsing of `TIMESTAMP` and `TIME` literals with timezone offsets
 
 ### Removed
 
diff --git a/partiql-parser/src/main/kotlin/org/partiql/parser/internal/PartiQLParserDefault.kt b/partiql-parser/src/main/kotlin/org/partiql/parser/internal/PartiQLParserDefault.kt
index 6491bc05c..3186fb0d5 100644
--- a/partiql-parser/src/main/kotlin/org/partiql/parser/internal/PartiQLParserDefault.kt
+++ b/partiql-parser/src/main/kotlin/org/partiql/parser/internal/PartiQLParserDefault.kt
@@ -2073,26 +2073,31 @@ internal class PartiQLParserDefault : PartiQLParser {
             val pattern = ctx.LITERAL_STRING().symbol
             val timeString = ctx.LITERAL_STRING().getStringValue()
             if (GENERIC_TIME_REGEX.matches(timeString).not()) {
-                throw error(pattern, "Expected TIME string to be of the format HH:mm:ss[.SSS]")
+                throw error(pattern, "Expected TIME string to be of the format HH:mm:ss[.SSS][+HH:MM]")
             }
             val precision = ctx.LITERAL_INTEGER()?.let {
                 val p = it.text.toBigInteger().toInt()
                 if (p < 0 || 9 < p) throw error(it.symbol, "Precision out of bounds [0,9]")
                 p
             }
-            val type = when (ctx.ZONE()) {
-                null -> {
+            val hasTimezoneLiteral = timeString.contains(Regex("[+|-]\\d\\d:\\d\\d$"))
+            if (ctx.ZONE() != null && !hasTimezoneLiteral) {
+                throw error(pattern, "TIME WITH TIME ZONE specified without a timezone offset in the literal string")
+            }
+            val hasTimezone = hasTimezoneLiteral || ctx.ZONE() != null
+            val type = when (hasTimezone) {
+                true -> {
                     if (precision == null) {
-                        DataType.TIME()
+                        DataType.TIME_WITH_TIME_ZONE()
                     } else {
-                        DataType.TIME(precision)
+                        DataType.TIME_WITH_TIME_ZONE(precision)
                     }
                 }
-                else -> {
+                false -> {
                     if (precision == null) {
-                        DataType.TIME_WITH_TIME_ZONE()
+                        DataType.TIME()
                     } else {
-                        DataType.TIME_WITH_TIME_ZONE(precision)
+                        DataType.TIME(precision)
                     }
                 }
             }
@@ -2103,26 +2108,31 @@ internal class PartiQLParserDefault : PartiQLParser {
             val pattern = ctx.LITERAL_STRING().symbol
             val timestampString = ctx.LITERAL_STRING().getStringValue()
             if (GENERIC_TIMESTAMP_REGEX.matches(timestampString).not()) {
-                throw error(pattern, "Expected TIMESTAMP string to be of the format yyyy-MM-dd HH:mm:ss[.SSS]")
+                throw error(pattern, "Expected TIMESTAMP string to be of the format yyyy-MM-dd HH:mm:ss[.SSS][+HH:MM]")
             }
             val precision = ctx.LITERAL_INTEGER()?.let {
                 val p = it.text.toBigInteger().toInt()
                 if (p < 0 || 9 < p) throw error(it.symbol, "Precision out of bounds")
                 p
             }
-            val type = when (ctx.ZONE()) {
-                null -> {
+            val hasTimezoneLiteral = timestampString.contains(Regex("[+|-]\\d\\d:\\d\\d$"))
+            if (ctx.ZONE() != null && !hasTimezoneLiteral) {
+                throw error(pattern, "TIMESTAMP WITH TIME ZONE specified without a timezone offset in the literal string")
+            }
+            val hasTimezone = hasTimezoneLiteral || ctx.ZONE() != null
+            val type = when (hasTimezone) {
+                true -> {
                     if (precision == null) {
-                        DataType.TIMESTAMP()
+                        DataType.TIMESTAMP_WITH_TIME_ZONE()
                     } else {
-                        DataType.TIMESTAMP(precision)
+                        DataType.TIMESTAMP_WITH_TIME_ZONE(precision)
                     }
                 }
-                else -> {
+                false -> {
                     if (precision == null) {
-                        DataType.TIMESTAMP_WITH_TIME_ZONE()
+                        DataType.TIMESTAMP()
                     } else {
-                        DataType.TIMESTAMP_WITH_TIME_ZONE(precision)
+                        DataType.TIMESTAMP(precision)
                     }
                 }
             }


Test patch:
diff --git a/test/partiql-tests-runner/src/test/kotlin/org/partiql/runner/util/DatumUtil.kt b/test/partiql-tests-runner/src/test/kotlin/org/partiql/runner/util/DatumUtil.kt
index b3d339b75..b67b2342f 100644
--- a/test/partiql-tests-runner/src/test/kotlin/org/partiql/runner/util/DatumUtil.kt
+++ b/test/partiql-tests-runner/src/test/kotlin/org/partiql/runner/util/DatumUtil.kt
@@ -31,7 +31,8 @@ internal fun Datum.toIonElement(): IonElement {
                 PType.DECIMAL -> ionDecimal(Decimal.valueOf(this.bigDecimal))
                 PType.DOUBLE -> ionFloat(this.double)
                 PType.REAL -> ionFloat(this.float.toDouble())
-                PType.TIMESTAMP, PType.TIMESTAMPZ -> ionTimestamp(this.localDateTime.toString())
+                PType.TIMESTAMP -> ionTimestamp(this.localDateTime.toString() + "Z")
+                PType.TIMESTAMPZ -> ionTimestamp(this.localDateTime.toString())
                 PType.DATE -> ionString(this.localDate.toString()).withAnnotations("\$date")
                 PType.TIME -> {
                     val time = this.localTime