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

Issue:
`PartiQLValueTextWriter` currently does not implement printing datetime values
## Description
[`PartiQLValueTextWriter`](https://github.com/partiql/partiql-lang-kotlin/blob/68e4849b152debc3bf5964c9dccd0082d80ed26e/partiql-types/src/main/kotlin/org/partiql/value/io/PartiQLValueTextWriter.kt#L50) does not print datetime values. Currently it does not implement the writer for datetime values, so attempting to print out a datetime value will result in an `IllegalArgumentException`.

This is also pointed out in the tests:

https://github.com/partiql/partiql-lang-kotlin/blob/68e4849b152debc3bf5964c9dccd0082d80ed26e/partiql-types/src/test/kotlin/org/partiql/value/io/PartiQLValueTextWriterTest.kt#L39-L40.

## To Reproduce
Steps to reproduce the behavior:
1. Call `PartiQLValueTextWriter`'s `append`, which calls the `ToString` visitor with a datetime `PartiQLValue`
2. Get an `IllegalArgumentException` -- `Caused by: java.lang.IllegalArgumentException: Cannot write value of type DATE`

## Expected Behavior
- Expect `PartiQLValueTextWriter` to print out datetime values

## Additional Context
- Java version: 11
- PartiQL version: tested w/ 0.14.6-SNAPSHOT and 0.14.5

Pull request:
## Relevant Issues
- Implements #1491

## Description
- Adds an implementation of datetime values for the `PartiQLValueTextWriter`
- With this change, `SqlDialect` will also be able to print datetime literals.

## 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 Guidelines](https://github.com/partiql/partiql-lang-kotlin/blob/main/CONTRIBUTING.md)
  and [Code Style Guidelines](https://github.com/partiql/partiql-lang-kotlin/blob/main/CODE_STYLE.md)? **[YES]**

## License Information

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1dbdafbbde..f644b23357 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ Thank you to all who have contributed!
 ## [Unreleased]
 
 ### Added
+- Adds `PartiQLValueTextWriter` implementation of date, time, and timestamp values
 
 ### Changed
 - **Behavioral change**: The planner now does NOT support the NullType and MissingType variants of StaticType. The logic
diff --git a/partiql-types/src/main/kotlin/org/partiql/value/io/PartiQLValueTextWriter.kt b/partiql-types/src/main/kotlin/org/partiql/value/io/PartiQLValueTextWriter.kt
index e0d1ca63bb..9ae8e49d07 100644
--- a/partiql-types/src/main/kotlin/org/partiql/value/io/PartiQLValueTextWriter.kt
+++ b/partiql-types/src/main/kotlin/org/partiql/value/io/PartiQLValueTextWriter.kt
@@ -18,6 +18,7 @@ import org.partiql.value.BagValue
 import org.partiql.value.BoolValue
 import org.partiql.value.CharValue
 import org.partiql.value.CollectionValue
+import org.partiql.value.DateValue
 import org.partiql.value.DecimalValue
 import org.partiql.value.Float32Value
 import org.partiql.value.Float64Value
@@ -35,9 +36,17 @@ import org.partiql.value.SexpValue
 import org.partiql.value.StringValue
 import org.partiql.value.StructValue
 import org.partiql.value.SymbolValue
+import org.partiql.value.TimeValue
+import org.partiql.value.TimestampValue
+import org.partiql.value.datetime.Date
+import org.partiql.value.datetime.Time
+import org.partiql.value.datetime.TimeZone
+import org.partiql.value.datetime.Timestamp
 import org.partiql.value.util.PartiQLValueBaseVisitor
 import java.io.OutputStream
 import java.io.PrintStream
+import java.math.BigDecimal
+import kotlin.math.abs
 
 /**
  * [PartiQLValueWriter] which outputs PartiQL text.
@@ -192,6 +201,74 @@ public class PartiQLValueTextWriter(
             }
         }
 
+        override fun visitDate(v: DateValue, format: Format?) = v.toString(format) {
+            when (val value = v.value) {
+                null -> "null" // null.date
+                else -> sqlString(value)
+            }
+        }
+
+        private fun padZeros(v: Int, totalDigits: Int): String = String.format("%0${totalDigits}d", v)
+
+        private fun sqlString(d: Date): String {
+            val yyyy = padZeros(d.year, 4)
+            val mm = padZeros(d.month, 2)
+            val dd = padZeros(d.day, 2)
+            return "DATE '$yyyy-$mm-$dd'"
+        }
+
+        override fun visitTime(v: TimeValue, format: Format?) = v.toString(format) {
+            when (val value = v.value) {
+                null -> "null" // null.time
+                else -> sqlString(value)
+            }
+        }
+
+        private fun sqlString(tz: TimeZone?): String {
+            return when (tz) {
+                null -> ""
+                is TimeZone.UnknownTimeZone -> "-00:00"
+                is TimeZone.UtcOffset -> {
+                    val sign = if (tz.totalOffsetMinutes < 0) {
+                        "-"
+                    } else {
+                        "+"
+                    }
+                    val hh = padZeros(abs(tz.tzHour), 2)
+                    val mm = padZeros(abs(tz.tzMinute), 2)
+                    "$sign$hh:$mm"
+                }
+            }
+        }
+
+        private fun sqlString(t: Time): String {
+            val hh = padZeros(t.hour, 2)
+            val mm = padZeros(t.minute, 2)
+            val ss = padZeros(t.decimalSecond.toInt(), 2)
+            val frac = t.decimalSecond.remainder(BigDecimal.ONE).toString().substring(1) // drop leading 0
+            val timeZone = sqlString(t.timeZone)
+            return "TIME '$hh:$mm:$ss$frac$timeZone'"
+        }
+
+        override fun visitTimestamp(v: TimestampValue, format: Format?) = v.toString(format) {
+            when (val value = v.value) {
+                null -> "null" // null.timestamp
+                else -> sqlString(value)
+            }
+        }
+
+        private fun sqlString(t: Timestamp): String {
+            val yyyy = padZeros(t.year, 4)
+            val mon = padZeros(t.month, 2)
+            val dd = padZeros(t.day, 2)
+            val hh = padZeros(t.hour, 2)
+            val min = padZeros(t.minute, 2)
+            val ss = padZeros(t.decimalSecond.toInt(), 2)
+            val frac = t.decimalSecond.remainder(BigDecimal.ONE).toString().substring(1) // drop leading 0
+            val timeZone = sqlString(t.timeZone)
+            return "TIMESTAMP '$yyyy-$mon-$dd $hh:$min:$ss$frac$timeZone'"
+        }
+
         override fun visitBag(v: BagValue<*>, format: Format?) = collection(v, format, "<<" to ">>")
 
         override fun visitList(v: ListValue<*>, format: Format?) = collection(v, format, "[" to "]")


Test patch:
diff --git a/partiql-ast/src/test/kotlin/org/partiql/ast/sql/SqlDialectTest.kt b/partiql-ast/src/test/kotlin/org/partiql/ast/sql/SqlDialectTest.kt
index 039fbd4112..6276adb43d 100644
--- a/partiql-ast/src/test/kotlin/org/partiql/ast/sql/SqlDialectTest.kt
+++ b/partiql-ast/src/test/kotlin/org/partiql/ast/sql/SqlDialectTest.kt
@@ -27,6 +27,9 @@ import org.partiql.ast.builder.ast
 import org.partiql.ast.exprLit
 import org.partiql.value.PartiQLValueExperimental
 import org.partiql.value.boolValue
+import org.partiql.value.dateValue
+import org.partiql.value.datetime.DateTimeValue
+import org.partiql.value.datetime.TimeZone
 import org.partiql.value.decimalValue
 import org.partiql.value.float32Value
 import org.partiql.value.float64Value
@@ -39,6 +42,8 @@ import org.partiql.value.missingValue
 import org.partiql.value.nullValue
 import org.partiql.value.stringValue
 import org.partiql.value.symbolValue
+import org.partiql.value.timeValue
+import org.partiql.value.timestampValue
 import java.math.BigDecimal
 import java.math.BigInteger
 import kotlin.test.assertFails
@@ -406,6 +411,16 @@ class SqlDialectTest {
             expect("""hello""") {
                 exprLit(symbolValue("hello"))
             },
+            expect("DATE '0001-02-03'") {
+                exprLit(dateValue(DateTimeValue.date(1, 2, 3)))
+            },
+            expect("TIME '01:02:03.456-00:30'") {
+                exprLit(timeValue(DateTimeValue.time(1, 2, BigDecimal.valueOf(3.456), TimeZone.UtcOffset.of(-30))))
+            },
+            expect("TIMESTAMP '0001-02-03 04:05:06.78-00:30'") {
+                exprLit(timestampValue(DateTimeValue.timestamp(1, 2, 3, 4, 5, BigDecimal.valueOf(6.78), TimeZone.UtcOffset.of(-30))))
+            },
+
             // expect("""{{ '''Hello'''    '''World''' }}""") {
             //     exprLit(clobValue("HelloWorld".toByteArray()))
             // },
diff --git a/partiql-types/src/test/kotlin/org/partiql/value/io/PartiQLValueTextWriterTest.kt b/partiql-types/src/test/kotlin/org/partiql/value/io/PartiQLValueTextWriterTest.kt
index 309d832a64..1b5d40495b 100644
--- a/partiql-types/src/test/kotlin/org/partiql/value/io/PartiQLValueTextWriterTest.kt
+++ b/partiql-types/src/test/kotlin/org/partiql/value/io/PartiQLValueTextWriterTest.kt
@@ -13,6 +13,9 @@ import org.partiql.value.PartiQLValueExperimental
 import org.partiql.value.bagValue
 import org.partiql.value.boolValue
 import org.partiql.value.charValue
+import org.partiql.value.dateValue
+import org.partiql.value.datetime.DateTimeValue
+import org.partiql.value.datetime.TimeZone
 import org.partiql.value.decimalValue
 import org.partiql.value.float32Value
 import org.partiql.value.float64Value
@@ -28,6 +31,8 @@ import org.partiql.value.sexpValue
 import org.partiql.value.stringValue
 import org.partiql.value.structValue
 import org.partiql.value.symbolValue
+import org.partiql.value.timeValue
+import org.partiql.value.timestampValue
 import java.io.ByteArrayOutputStream
 import java.io.PrintStream
 import java.math.BigDecimal
@@ -37,7 +42,6 @@ import java.math.BigInteger
  * Basic text writing test.
  *
  * TODOs
- *  - Dates and times
  *  - String/Symbol escapes
  */
 class PartiQLValueTextWriterTest {
@@ -173,13 +177,62 @@ class PartiQLValueTextWriterTest {
                 value = symbolValue("f.x"),
                 expected = "f.x",
             ),
+            case(
+                value = dateValue(DateTimeValue.date(1, 2, 3)),
+                expected = "DATE '0001-02-03'",
+            ),
+            case(
+                value = dateValue(DateTimeValue.date(2020, 2, 3)),
+                expected = "DATE '2020-02-03'",
+            ),
+            case(
+                value = timeValue(DateTimeValue.time(1, 2, 3)),
+                expected = "TIME '01:02:03'",
+            ),
+            case(
+                value = timeValue(DateTimeValue.time(1, 2, BigDecimal.valueOf(3.456))),
+                expected = "TIME '01:02:03.456'",
+            ),
+            case(
+                value = timeValue(DateTimeValue.time(1, 2, BigDecimal.valueOf(3.456), TimeZone.UnknownTimeZone)),
+                expected = "TIME '01:02:03.456-00:00'",
+            ),
+            case(
+                value = timeValue(DateTimeValue.time(1, 2, BigDecimal.valueOf(3.456), TimeZone.UtcOffset.of(0))),
+                expected = "TIME '01:02:03.456+00:00'",
+            ),
+            case(
+                value = timeValue(DateTimeValue.time(1, 2, BigDecimal.valueOf(3.456), TimeZone.UtcOffset.of(+30))),
+                expected = "TIME '01:02:03.456+00:30'",
+            ),
+            case(
+                value = timeValue(DateTimeValue.time(1, 2, BigDecimal.valueOf(3.456), TimeZone.UtcOffset.of(-30))),
+                expected = "TIME '01:02:03.456-00:30'",
+            ),
+            case(
+                value = timestampValue(DateTimeValue.timestamp(1, 2, 3, 4, 5, BigDecimal.valueOf(6.78))),
+                expected = "TIMESTAMP '0001-02-03 04:05:06.78'",
+            ),
+            case(
+                value = timestampValue(DateTimeValue.timestamp(1, 2, 3, 4, 5, BigDecimal.valueOf(6.78), TimeZone.UnknownTimeZone)),
+                expected = "TIMESTAMP '0001-02-03 04:05:06.78-00:00'",
+            ),
+            case(
+                value = timestampValue(DateTimeValue.timestamp(1, 2, 3, 4, 5, BigDecimal.valueOf(6.78), TimeZone.UtcOffset.of(0))),
+                expected = "TIMESTAMP '0001-02-03 04:05:06.78+00:00'",
+            ),
+            case(
+                value = timestampValue(DateTimeValue.timestamp(1, 2, 3, 4, 5, BigDecimal.valueOf(6.78), TimeZone.UtcOffset.of(+30))),
+                expected = "TIMESTAMP '0001-02-03 04:05:06.78+00:30'",
+            ),
+            case(
+                value = timestampValue(DateTimeValue.timestamp(1, 2, 3, 4, 5, BigDecimal.valueOf(6.78), TimeZone.UtcOffset.of(-30))),
+                expected = "TIMESTAMP '0001-02-03 04:05:06.78-00:30'",
+            ),
             // TODO CLOB
             // TODO BINARY
             // TODO BYTE
             // TODO BLOB
-            // TODO DATE
-            // TODO TIME
-            // TODO TIMESTAMP
             // TODO INTERVAL
         )