Skip to content

Commit

Permalink
Implement java.time.* encoders and decoders (except `java.time.Offs…
Browse files Browse the repository at this point in the history
…etTime`) (#171)

* Implement `java.time.*` encoders and decoders

* lint

* Init tests

* Drop support of `java.time.OffsetTime`

* Clean

* Clean

* Clean

* Clean

* lint

* Fix some tests

* Fix tests and fix `instantSetter` implementation

* scalafmt

* clean
  • Loading branch information
guizmaii authored Oct 26, 2023
1 parent 2e747c0 commit 6205129
Show file tree
Hide file tree
Showing 4 changed files with 360 additions and 33 deletions.
28 changes: 25 additions & 3 deletions core/src/main/scala/zio/jdbc/JdbcDecoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,35 @@ object JdbcDecoder extends JdbcDecoderLowPriorityImplicits {
implicit val byteDecoder: JdbcDecoder[Byte] = JdbcDecoder(_.getByte)
implicit val byteArrayDecoder: JdbcDecoder[Array[Byte]] = JdbcDecoder(_.getBytes)
implicit val blobDecoder: JdbcDecoder[Blob] = JdbcDecoder(_.getBlob)
implicit val dateDecoder: JdbcDecoder[java.sql.Date] = JdbcDecoder(_.getDate)
implicit val timeDecoder: JdbcDecoder[java.sql.Time] = JdbcDecoder(_.getTime)
implicit val timestampDecoder: JdbcDecoder[java.sql.Timestamp] = JdbcDecoder(_.getTimestamp)
implicit val uuidDecoder: JdbcDecoder[java.util.UUID] =
// See: https://stackoverflow.com/a/56267754/2431728
JdbcDecoder(rs => i => rs.getObject(i, classOf[java.util.UUID]), "UUID")

implicit val dateDecoder: JdbcDecoder[java.sql.Date] = JdbcDecoder(_.getDate)
implicit val timeDecoder: JdbcDecoder[java.sql.Time] = JdbcDecoder(_.getTime)
implicit val timestampDecoder: JdbcDecoder[java.sql.Timestamp] = JdbcDecoder(_.getTimestamp)

// These `java.time.*` decoders are copied from Quill's 'ObjectGenericTimeDecoders' trait.
// Note:
// 1. These decoders probably don't work for SQLite. Quill as a separate trait, named `BasicTimeDecoders` which seems dedicated to SQLite.
// 2. We deliberately decided not to support `java.time.OffsetTime`.
// The reasons for this choice are detailed next to the `java.time.*` Setters implementation
implicit val localDateDecoder: JdbcDecoder[java.time.LocalDate] =
JdbcDecoder(rs => i => rs.getObject(i, classOf[java.time.LocalDate]), "java.time.LocalDate")
implicit val localTimeDecoder: JdbcDecoder[java.time.LocalTime] =
JdbcDecoder(rs => i => rs.getObject(i, classOf[java.time.LocalTime]), "java.time.LocalTime")
implicit val localDateTimeDecoder: JdbcDecoder[java.time.LocalDateTime] =
JdbcDecoder(rs => i => rs.getObject(i, classOf[java.time.LocalDateTime]), "java.time.LocalDateTime")
implicit val zonedDateTimeDecoder: JdbcDecoder[java.time.ZonedDateTime] =
JdbcDecoder(
rs => i => rs.getObject(i, classOf[java.time.OffsetDateTime]).toZonedDateTime,
"java.time.ZonedDateTime"
)
implicit val instantDecoder: JdbcDecoder[java.time.Instant] =
JdbcDecoder(rs => i => rs.getObject(i, classOf[java.time.OffsetDateTime]).toInstant, "java.time.Instant")
implicit val offsetDateTimeDecoder: JdbcDecoder[java.time.OffsetDateTime] =
JdbcDecoder(rs => i => rs.getObject(i, classOf[java.time.OffsetDateTime]), "java.time.OffsetDateTime")

implicit def optionDecoder[A](implicit decoder: JdbcDecoder[A]): JdbcDecoder[Option[A]] =
JdbcDecoder(rs =>
int =>
Expand Down
40 changes: 35 additions & 5 deletions core/src/main/scala/zio/jdbc/SqlFragment.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import zio._
import zio.jdbc.SqlFragment.Segment

import java.sql.{ PreparedStatement, Types }
import java.time.{ OffsetDateTime, ZoneOffset }
import scala.language.implicitConversions

/**
Expand Down Expand Up @@ -345,8 +346,6 @@ object SqlFragment {
implicit val byteSetter: Setter[Byte] = forSqlType((ps, i, value) => ps.setByte(i, value), Types.TINYINT)
implicit val byteArraySetter: Setter[Array[Byte]] = forSqlType((ps, i, value) => ps.setBytes(i, value), Types.ARRAY)
implicit val blobSetter: Setter[java.sql.Blob] = forSqlType((ps, i, value) => ps.setBlob(i, value), Types.BLOB)
implicit val sqlDateSetter: Setter[java.sql.Date] = forSqlType((ps, i, value) => ps.setDate(i, value), Types.DATE)
implicit val sqlTimeSetter: Setter[java.sql.Time] = forSqlType((ps, i, value) => ps.setTime(i, value), Types.TIME)

implicit def chunkSetter[A](implicit setter: Setter[A]): Setter[Chunk[A]] = iterableSetter[A, Chunk[A]]
implicit def listSetter[A](implicit setter: Setter[A]): Setter[List[A]] = iterableSetter[A, List[A]]
Expand All @@ -373,16 +372,47 @@ object SqlFragment {

implicit val bigDecimalSetter: Setter[java.math.BigDecimal] =
forSqlType((ps, i, value) => ps.setBigDecimal(i, value), Types.NUMERIC)
implicit val sqlTimestampSetter: Setter[java.sql.Timestamp] =
forSqlType((ps, i, value) => ps.setTimestamp(i, value), Types.TIMESTAMP)

implicit val uuidParamSetter: Setter[java.util.UUID] = other((ps, i, value) => ps.setObject(i, value), "uuid")

implicit val charSetter: Setter[Char] = stringSetter.contramap(_.toString)
implicit val bigIntSetter: Setter[java.math.BigInteger] = bigDecimalSetter.contramap(new java.math.BigDecimal(_))
implicit val bigDecimalScalaSetter: Setter[scala.math.BigDecimal] = bigDecimalSetter.contramap(_.bigDecimal)
implicit val byteChunkSetter: Setter[Chunk[Byte]] = byteArraySetter.contramap(_.toArray)
implicit val instantSetter: Setter[java.time.Instant] = sqlTimestampSetter.contramap(java.sql.Timestamp.from)

// These `java.time.*` are inspired from Quill encoders. See `ObjectGenericTimeEncoders` in Quill.
// Notes:
// 1. These setters probably don't work for SQLite. Quill as a separate trait, named `BasicTimeDecoders` which seems dedicated to SQLite.
// 2. We deliberately decided not to support `java.time.OffsetTime`.
// Because:
// - See https://github.com/h2database/h2database/issues/521#issuecomment-333517705
// - It's supposed to be mapped to `java.sql.Types.TIME_WITH_TIMEZONE` but this type isn't supported by the PG JDBC driver.
// See: https://github.com/pgjdbc/pgjdbc/blob/9cf9f36a1d3a1edd9286721f9c0b9cfa9e8422e3/pgjdbc/src/main/java/org/postgresql/jdbc/PgPreparedStatement.java#L557-L741
// Note that Quill made a different choice. For PG, it uses `java.sql.Types.TIME` but as we don't support yet differences between DBs and `OffsetTime` is almost never used
// it's simpler, for now, to not support it and to document this choice.
// If you need it, please open an issue or a PR explaining your use case.
implicit val sqlDateSetter: Setter[java.sql.Date] = forSqlType((ps, i, value) => ps.setDate(i, value), Types.DATE)
implicit val sqlTimeSetter: Setter[java.sql.Time] = forSqlType((ps, i, value) => ps.setTime(i, value), Types.TIME)
implicit val sqlTimestampSetter: Setter[java.sql.Timestamp] =
forSqlType((ps, i, value) => ps.setTimestamp(i, value), Types.TIMESTAMP)
implicit val localDateSetter: Setter[java.time.LocalDate] =
sqlDateSetter.contramap(java.sql.Date.valueOf)
implicit val localTimeSetter: Setter[java.time.LocalTime] =
sqlTimeSetter.contramap(java.sql.Time.valueOf)
implicit val localDateTimeSetter: Setter[java.time.LocalDateTime] =
sqlTimestampSetter.contramap(java.sql.Timestamp.valueOf)
implicit val zonedDateTimeSetter: Setter[java.time.ZonedDateTime] =
forSqlType(
(ps, i, value) => ps.setObject(i, value.toOffsetDateTime, Types.TIMESTAMP_WITH_TIMEZONE),
Types.TIMESTAMP_WITH_TIMEZONE
)
implicit val instantSetter: Setter[java.time.Instant] =
forSqlType(
(ps, i, value) => ps.setObject(i, OffsetDateTime.ofInstant(value, ZoneOffset.UTC)),
Types.TIMESTAMP_WITH_TIMEZONE
)
implicit val offsetDateTimeSetter: Setter[java.time.OffsetDateTime] =
forSqlType((ps, i, value) => ps.setObject(i, value, Types.TIMESTAMP_WITH_TIMEZONE), Types.TIMESTAMP_WITH_TIMEZONE)
}

def apply(sql: String): SqlFragment = sql
Expand Down
265 changes: 265 additions & 0 deletions integration/src/test/scala/zio/jdbc/JavaTimeSupportSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
package zio.jdbc

import zio.test.TestAspect._
import zio.test._
import zio.{ Scope, ZIO }

import java.time._
import java.time.chrono.IsoEra
import java.time.temporal.{ ChronoField, ChronoUnit }

object JavaTimeSupportSpec extends PgSpec {

/**
* Constants copied from https://github.com/pgjdbc/pgjdbc/blob/REL42.6.0/pgjdbc/src/main/java/org/postgresql/jdbc/TimestampUtils.java#L59-L67
*
* See also https://www.postgresql.org/docs/current/datatype-datetime.html
*/
object PgConstants {
// LocalTime.MAX is 23:59:59.999_999_999, and it wraps to 24:00:00 when nanos exceed 999_999_499
// since PostgreSQL has microsecond resolution only
val MAX_TIME: LocalTime = LocalTime.MAX.minus(Duration.ofNanos(500))
// low value for dates is 4713 BC
val MIN_LOCAL_DATE: LocalDate = LocalDate.of(4713, 1, 1).`with`(ChronoField.ERA, IsoEra.BCE.getValue)
val MIN_LOCAL_DATETIME: LocalDateTime = MIN_LOCAL_DATE.atStartOfDay
val MIN_OFFSET_DATETIME: OffsetDateTime = MIN_LOCAL_DATETIME.atOffset(ZoneOffset.UTC)
}
import PgConstants._

/**
* We didn't find these constants in the pgjdbc code or because the one we found are not the ones indicated in the doc.
* See https://www.postgresql.org/docs/current/datatype-datetime.html
*/
object ManuallyWrittenPgConstants {
val MAX_LOCAL_DATETIME: LocalDateTime =
LocalDate
.of(294276, 1, 1)
.`with`(ChronoField.ERA, IsoEra.CE.getValue)
.atStartOfDay
.minus(500, ChronoUnit.NANOS)

val MAX_OFFSET_DATETIME: OffsetDateTime =
LocalDate
.of(294276, 1, 1)
.`with`(ChronoField.ERA, IsoEra.CE.getValue)
.atTime(OffsetTime.MAX)
.minus(500, ChronoUnit.NANOS)

val MAX_LOCAL_DATE: LocalDate =
LocalDate
.of(5874897, 1, 1)
.`with`(ChronoField.ERA, IsoEra.CE.getValue)
.minus(1L, ChronoUnit.DAYS)

val MIN_TIMESTAMP: Instant = MIN_LOCAL_DATETIME.toInstant(ZoneOffset.UTC)
val MAX_TIMESTAMP: Instant = MAX_LOCAL_DATETIME.toInstant(ZoneOffset.UTC)
}
import ManuallyWrittenPgConstants._

val genPGLocalDate: Gen[Any, LocalDate] = Gen.localDate(MIN_LOCAL_DATE, MAX_LOCAL_DATE)
val genPGLocalTime: Gen[Any, LocalTime] = Gen.localTime(LocalTime.MIN, MAX_TIME)
val genPGLocalDateTime: Gen[Any, LocalDateTime] = Gen.localDateTime(MIN_LOCAL_DATETIME, MAX_LOCAL_DATETIME)
// We need to set `UTC` as PG will move the date to UTC and so can generate a date that is not in the range of `MIN_TIMESTAMP` and `MAX_TIMESTAMP`
val genPGOffsetDateTime: Gen[Any, OffsetDateTime] =
Gen.offsetDateTime(MIN_OFFSET_DATETIME, MAX_OFFSET_DATETIME).map(_.withOffsetSameInstant(ZoneOffset.UTC))
val genPGInstant: Gen[Any, Instant] = Gen.instant(MIN_TIMESTAMP, MAX_TIMESTAMP)

/**
* Adapted from [[Gen.zonedDateTime]]
*/
val genPGZonedDateTime: Gen[Any, ZonedDateTime] =
for {
offsetDateTime <- genPGOffsetDateTime
zoneId <- Gen.zoneId
} yield offsetDateTime.atZoneSameInstant(zoneId)

val genSqlDate: Gen[Any, java.sql.Date] = genPGLocalDate.map(java.sql.Date.valueOf)
val genSqlTime: Gen[Any, java.sql.Time] = genPGLocalTime.map(java.sql.Time.valueOf)
val genSqlTimestamp: Gen[Any, java.sql.Timestamp] = genPGInstant.map(java.sql.Timestamp.from)

/**
* PG or the PG driver has only a MICRO precision and it rounds up the nanos to the nearest micro
* For example, 145513948 will be rounded to 145514
* So I do the same thing here.
* The math formula comes from ChatGPT
*/
def nanosRoundedUpToMicros(nanos: Int): Long = (math.round(nanos.toDouble / 1000) * 1000) / 1000

override def spec: Spec[ZConnectionPool with TestEnvironment with Scope, Any] =
suite("java.time.* types support")(
test("java.sql.Date") {
check(genSqlDate) { sqlDate =>
for {
_ <- transaction(sql"""CREATE TABLE sql_date (value DATE)""".execute)
i <- transaction(sql"""INSERT INTO sql_date VALUES ($sqlDate)""".insert)
d <- transaction(sql"""SELECT * FROM sql_date""".query[java.sql.Date].selectOne)
_ <- transaction(sql"DROP TABLE sql_date".execute)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == sqlDate
)
}
},
test("java.sql.Time") {
check(genSqlTime) { sqlTime =>
for {
_ <- transaction(sql"""CREATE TABLE sql_time (value TIME)""".execute)
i <- transaction(sql"""INSERT INTO sql_time VALUES ($sqlTime)""".insert)
d <- transaction(sql"""SELECT * FROM sql_time""".query[java.sql.Time].selectOne)
_ <- transaction(sql"DROP TABLE sql_time".execute)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == sqlTime
)
}
},
test("java.sql.Timestamp - now") {
for {
now <- ZIO.clockWith(_.instant).map(java.sql.Timestamp.from)
_ <- transaction(sql"""CREATE TABLE sql_timestamp (value TIMESTAMP)""".execute)
i <- transaction(sql"""INSERT INTO sql_timestamp VALUES ($now)""".insert)
d <- transaction(sql"""SELECT * FROM sql_timestamp""".query[java.sql.Timestamp].selectOne)
_ <- transaction(sql"DROP TABLE sql_timestamp".execute)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == now
)
},
test("java.sql.Timestamp - Gen") {
check(genSqlTimestamp) { sqlTimestamp =>
for {
_ <- transaction(sql"""CREATE TABLE sql_timestamp (value TIMESTAMP)""".execute)
i <- transaction(sql"""INSERT INTO sql_timestamp VALUES ($sqlTimestamp)""".insert)
d <- transaction(sql"""SELECT * FROM sql_timestamp""".query[java.sql.Timestamp].selectOne)
_ <- transaction(sql"DROP TABLE sql_timestamp".execute)
rounded = nanosRoundedUpToMicros(sqlTimestamp.getNanos)
expected = sqlTimestamp.toInstant
.`with`(ChronoField.MICRO_OF_SECOND, rounded) // Replaces the micros with the rounded value
.truncatedTo(ChronoUnit.MICROS)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == java.sql.Timestamp.from(expected)
)
}
},
test("java.time.LocalDate") {
check(genPGLocalDate) { localDate =>
for {
_ <- transaction(sql"""CREATE TABLE local_date (value DATE)""".execute)
i <- transaction(sql"""INSERT INTO local_date VALUES ($localDate)""".insert)
d <- transaction(sql"""SELECT * FROM local_date""".query[java.time.LocalDate].selectOne)
_ <- transaction(sql"DROP TABLE local_date".execute)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == localDate
)
}
},
test("java.time.LocalTime") {
check(genPGLocalTime) { localTime =>
for {
_ <- transaction(sql"""CREATE TABLE local_time (value TIME)""".execute)
i <- transaction(sql"""INSERT INTO local_time VALUES ($localTime)""".insert)
d <- transaction(sql"""SELECT * FROM local_time""".query[java.time.LocalTime].selectOne)
_ <- transaction(sql"DROP TABLE local_time".execute)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == localTime.truncatedTo(ChronoUnit.SECONDS)
)
}
},
test("java.time.LocalDateTime") {
check(genPGLocalDateTime) { localDateTime =>
for {
_ <- transaction(sql"""CREATE TABLE local_datetime (value TIMESTAMP)""".execute)
i <- transaction(sql"""INSERT INTO local_datetime VALUES ($localDateTime)""".insert)
d <- transaction(sql"""SELECT * FROM local_datetime""".query[java.time.LocalDateTime].selectOne)
_ <- transaction(sql"DROP TABLE local_datetime".execute)
rounded = nanosRoundedUpToMicros(localDateTime.getNano)
expected = localDateTime
.`with`(ChronoField.MICRO_OF_SECOND, rounded) // Replaces the micros with the rounded value
.truncatedTo(ChronoUnit.MICROS)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == expected
)
}
},
test("java.time.ZonedDateTime") {
check(genPGZonedDateTime) { zonedDateTime =>
for {
_ <- transaction(sql"""CREATE TABLE zoned_datetime (value TIMESTAMP WITH TIME ZONE)""".execute)
i <- transaction(sql"""INSERT INTO zoned_datetime VALUES ($zonedDateTime)""".insert)
d <- transaction(sql"""SELECT * FROM zoned_datetime""".query[java.time.ZonedDateTime].selectOne)
_ <- transaction(sql"DROP TABLE zoned_datetime".execute)
rounded = nanosRoundedUpToMicros(zonedDateTime.getNano)
expected = zonedDateTime
.`with`(ChronoField.MICRO_OF_SECOND, rounded) // Replaces the micros with the rounded value
.truncatedTo(ChronoUnit.MICROS)
.withZoneSameInstant(ZoneOffset.UTC)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == expected
)
}
},
test("java.time.Instant - now") {
for {
now <- ZIO.clockWith(_.instant)
_ <- transaction(sql"""CREATE TABLE instant (value TIMESTAMP)""".execute)
i <- transaction(sql"""INSERT INTO instant VALUES ($now)""".insert)
d <- transaction(sql"""SELECT * FROM instant""".query[java.time.Instant].selectOne)
_ <- transaction(sql"DROP TABLE instant".execute)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == now
)
},
test("java.time.Instant - Gen") {
check(genPGInstant) { instant =>
for {
_ <- transaction(sql"""CREATE TABLE instant (value TIMESTAMP)""".execute)
i <- transaction(sql"""INSERT INTO instant VALUES ($instant)""".insert)
d <- transaction(sql"""SELECT * FROM instant""".query[java.time.Instant].selectOne)
_ <- transaction(sql"DROP TABLE instant".execute)
rounded = nanosRoundedUpToMicros(instant.getNano)
expected = instant
.`with`(ChronoField.MICRO_OF_SECOND, rounded) // Replaces the micros with the rounded value
.truncatedTo(ChronoUnit.MICROS)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == expected
)
}
},
test("java.time.OffsetDateTime") {
check(genPGOffsetDateTime) { offsetDateTime =>
for {
_ <- transaction(sql"""CREATE TABLE offset_datetime (value TIMESTAMP WITH TIME ZONE)""".execute)
i <- transaction(sql"""INSERT INTO offset_datetime VALUES ($offsetDateTime)""".insert)
d <- transaction(sql"""SELECT * FROM offset_datetime""".query[java.time.OffsetDateTime].selectOne)
_ <- transaction(sql"DROP TABLE offset_datetime".execute)
rounded = nanosRoundedUpToMicros(offsetDateTime.getNano)
expected = offsetDateTime
.`with`(ChronoField.MICRO_OF_SECOND, rounded) // Replaces the micros with the rounded value
.truncatedTo(ChronoUnit.MICROS)
.withOffsetSameInstant(ZoneOffset.UTC)
} yield assertTrue(
i == 1L,
d.isDefined,
d.get == expected
)
}
}
) @@ sequential @@ shrinks(0) @@ repeats(100) @@ withLiveClock
}
Loading

0 comments on commit 6205129

Please sign in to comment.