diff --git a/modules/bench/src/main/scala/doobie/bench/insert.scala b/modules/bench/src/main/scala/doobie/bench/insert.scala index fb8e64462..b60783123 100644 --- a/modules/bench/src/main/scala/doobie/bench/insert.scala +++ b/modules/bench/src/main/scala/doobie/bench/insert.scala @@ -9,6 +9,7 @@ import doobie.util.Write import doobie.util.update.Update import org.openjdk.jmh.annotations.* +import java.time.Instant import java.util.concurrent.TimeUnit @BenchmarkMode(Array(Mode.SingleShotTime)) @@ -70,19 +71,21 @@ class insert { object insert { import doobie.postgres.instances.array.* - final case class Widget(name: String, extensions: Int) + final case class Widget(name: String, extensions: Int, produced: Instant) object Widget { implicit val write: Write[Widget] = Write.derived - def generate(n: Int) = List.fill(n)(Widget("widget", n)) + val now = Instant.now() + + def generate(n: Int) = List.fill(n)(Widget("widget", n, now.plusMillis(n.toLong))) } private val insertBatch = { val sql = """ INSERT INTO bench_widget - (name, extensions) + (name, extensions, produced) VALUES - (?, ?) + (?, ?, ?) """ Update[Widget](sql) } @@ -90,9 +93,9 @@ object insert { private def insertValues(widgets: List[Widget]) = { val sql = fr""" INSERT INTO bench_widget - (name, extensions) + (name, extensions, produced) VALUES - ${widgets.map(w => fr"(${w.name}, ${w.extensions})").intercalate(fr",")} + ${widgets.map(w => fr"(${w.name}, ${w.extensions}, ${w.produced})").intercalate(fr",")} """ sql.update } @@ -101,19 +104,22 @@ object insert { val n = widgets.length val names = Array.ofDim[String](n) val extensions = Array.ofDim[Int](n) + val produced = Array.ofDim[Instant](n) var i = 0 widgets.foreach { w => names(i) = w.name extensions(i) = w.extensions + produced(i) = w.produced i = i + 1 } val sql = fr""" INSERT INTO bench_widget - (name, extensions) + (name, extensions, produced) SELECT * FROM unnest( $names::text[], - $extensions::int4[] + $extensions::int4[], + $produced::timestamptz[] ) """ sql.update @@ -123,7 +129,8 @@ object insert { sql"drop table if exists bench_widget".update.run *> sql"""create table bench_widget ( name text not null, - extensions integer not null + extensions integer not null, + produced timestamptz not null )""".update.run.void @State(Scope.Thread) diff --git a/modules/postgres/src/main/scala/doobie/postgres/instances/array.scala b/modules/postgres/src/main/scala/doobie/postgres/instances/array.scala index 89e198201..d1ef192df 100644 --- a/modules/postgres/src/main/scala/doobie/postgres/instances/array.scala +++ b/modules/postgres/src/main/scala/doobie/postgres/instances/array.scala @@ -4,11 +4,20 @@ package doobie.postgres.instances +import cats.data.NonEmptyList +import doobie.enumerated.JdbcType +import doobie.util.Get +import doobie.util.Put import doobie.util.invariant.* import doobie.util.meta.Meta +import org.postgresql.jdbc.PgArray +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset import scala.reflect.ClassTag +@SuppressWarnings(Array("org.wartremover.warts.AutoUnboxing")) object array { // java.sql.Array::getArray returns an Object that may be of primitive type or of boxed type, @@ -27,32 +36,40 @@ object array { // automatic lifting to Meta will give us lifted and unlifted arrays, for a total of four variants // of each 1-d array type. In the non-nullable case we simply check for nulls and perform a cast; // in the nullable case we must copy the array in both directions to lift/unlift Option. - @SuppressWarnings(Array("org.wartremover.warts.Null", "org.wartremover.warts.Throw")) private def boxedPair[A >: Null <: AnyRef: ClassTag]( elemType: String, arrayType: String, arrayTypeT: String*, ): (Meta[Array[A]], Meta[Array[Option[A]]]) = { val raw = Meta.Advanced.array[A](elemType, arrayType, arrayTypeT*) - // Ensure `a`, which may be null, which is ok, contains no null elements. - def checkNull[B >: Null](a: Array[B], e: => Exception): Array[B] = - if (a == null) null else if (a.exists(_ == null)) throw e else a - ( - raw.timap(checkNull(_, NullableCellRead()))(checkNull(_, NullableCellUpdate())), - raw.timap[Array[Option[A]]](_.map(Option(_)))(_.map(_.orNull).toArray), - ) + boxedPairMeta(raw) } - // Arrays of lifted (nullable) and unlifted (non-nullable) Java wrapped primitives. PostgreSQL - // does not seem to support tinyint[](use a bytea instead) and smallint[] always arrives as Int[] - // so you can xmap if you need Short[]. The type names provided here are what is reported by JDBC - // when metadata is requested; there are numerous aliases but these are the ones we need. Nothing - // about this is portable, sorry. (╯°□°)╯︵ ┻━┻ + @SuppressWarnings(Array("org.wartremover.warts.Null")) + private def boxedPairMeta[A >: Null <: AnyRef: ClassTag]( + raw: Meta[Array[A]], + ): (Meta[Array[A]], Meta[Array[Option[A]]]) = ( + raw.timap(checkNull(_, NullableCellRead()))(checkNull(_, NullableCellUpdate())), + raw.timap[Array[Option[A]]](_.map(Option(_)))(_.map(_.orNull).toArray), + ) + + // Ensure `a`, which may be null, which is ok, contains no null elements. + @SuppressWarnings(Array("org.wartremover.warts.Null", "org.wartremover.warts.Throw")) + private def checkNull[B >: Null](a: Array[B], e: => Exception): Array[B] = + if (a == null) null else if (a.exists(_ == null)) throw e else a + + // Arrays of lifted (nullable) and unlifted (non-nullable) Java wrapped primitives. + // The type names provided here are what is reported by JDBC when metadata is requested; there + // are numerous aliases but these are the ones we need. private val boxedPairBoolean = boxedPair[java.lang.Boolean]("bit", "_bit") implicit val unliftedBooleanArrayType: Meta[Array[java.lang.Boolean]] = boxedPairBoolean._1 implicit val liftedBooleanArrayType: Meta[Array[Option[java.lang.Boolean]]] = boxedPairBoolean._2 + private val boxedPairShort = boxedPair[java.lang.Short]("int2", "_int2") + implicit val unliftedShortArrayType: Meta[Array[java.lang.Short]] = boxedPairShort._1 + implicit val liftedShortArrayType: Meta[Array[Option[java.lang.Short]]] = boxedPairShort._2 + private val boxedPairInteger = boxedPair[java.lang.Integer]("int4", "_int4") implicit val unliftedIntegerArrayType: Meta[Array[java.lang.Integer]] = boxedPairInteger._1 implicit val liftedIntegerArrayType: Meta[Array[Option[java.lang.Integer]]] = boxedPairInteger._2 @@ -81,6 +98,62 @@ object array { implicit val unliftedBigDecimalArrayType: Meta[Array[java.math.BigDecimal]] = boxedPairBigDecimal._1 implicit val iftedBigDecimalArrayType: Meta[Array[Option[java.math.BigDecimal]]] = boxedPairBigDecimal._2 + private val boxedPairDate = boxedPair[java.sql.Date]("date", "_date") + implicit val unliftedDateArrayType: Meta[Array[java.sql.Date]] = boxedPairDate._1 + implicit val liftedDateArrayType: Meta[Array[Option[java.sql.Date]]] = boxedPairDate._2 + + implicit val unliftedLocalDateArrayType: Meta[Array[LocalDate]] = + unliftedDateArrayType.timap(_.map(_.toLocalDate))(_.map(java.sql.Date.valueOf)) + implicit val liftedLocalDateArrayType: Meta[Array[Option[LocalDate]]] = + liftedDateArrayType.timap(_.map(_.map(_.toLocalDate)))(_.map(_.map(java.sql.Date.valueOf))) + + private val boxedPairTimestamp = boxedPair[java.sql.Timestamp]("timestamp", "_timestamp") + implicit val unliftedTimestampArrayType: Meta[Array[java.sql.Timestamp]] = boxedPairTimestamp._1 + implicit val liftedTimestampArrayType: Meta[Array[Option[java.sql.Timestamp]]] = boxedPairTimestamp._2 + + private class InstantValidToString(val i: Instant) { + override def toString = i.toString.replace('T', ' ').stripSuffix("Z") + } + + @SuppressWarnings(Array("org.wartremover.warts.AsInstanceOf", "org.wartremover.warts.Null")) + private val boxedPairInstant = { + val schema = NonEmptyList.of("_timestamptz") + + val fieldStringField = classOf[PgArray].getDeclaredField("fieldString") + fieldStringField.setAccessible(true) + + val get = Get.Advanced.one( + JdbcType.Array, + schema, + (r, n) => { + val a = r.getArray(n) + if (a == null) { + null + } else { + a match { + case a: PgArray => + // the array string looks like "0001-01-01 20:03:28.658293+10:12:08" for some reason... + val fieldString = fieldStringField.get(a).asInstanceOf[String] + val newFieldString = fieldString.replaceAll("\\+[0-9]{2}(:[0-9]{2})?(:[0-9]{2})?", "") + fieldStringField.set(a, newFieldString) + case _ => () + } + // cant override, a timestamptz array always has java.sql.Timestamp elements + a.getArray.asInstanceOf[Array[java.sql.Timestamp]] + .map(_.toLocalDateTime.toInstant(ZoneOffset.UTC)) + } + }, + ) + + // postgres driver calls .toString() ... + val put = Put.Advanced.array[InstantValidToString](schema, "timestamptz") + .contramap[Array[Instant]](_.map(new InstantValidToString(_))) + + boxedPairMeta(new Meta(get, put)) + } + implicit val unliftedInstantArrayType: Meta[Array[Instant]] = boxedPairInstant._1 + implicit val liftedInstantArrayType: Meta[Array[Option[Instant]]] = boxedPairInstant._2 + // Unboxed equivalents (actually identical in the lifted case). We require that B is the unboxed // equivalent of A, otherwise this will fail in spectacular fashion, and we're using a cast in the // lifted case because the representation is identical, assuming no nulls. In the long run this @@ -91,31 +164,33 @@ object array { boxed: Meta[Array[A]], boxedLifted: Meta[Array[Option[A]]], ): (Meta[Array[B]], Meta[Array[Option[B]]]) = - // TODO: assert, somehow, that A is the boxed version of B so we catch errors on instance - // construction, which is somewhat better than at [logical] execution time. ( boxed.timap(a => if (a == null) null else a.map(f))(a => if (a == null) null else a.map(g)), boxedLifted.timap(_.asInstanceOf[Array[Option[B]]])(_.asInstanceOf[Array[Option[A]]]), ) // Arrays of lifted (nullable) and unlifted (non-nullable) AnyVals - private val unboxedPairBoolean = unboxedPair[java.lang.Boolean, Boolean](_.booleanValue, java.lang.Boolean.valueOf) + private val unboxedPairBoolean = unboxedPair[java.lang.Boolean, Boolean](Boolean2boolean, boolean2Boolean) implicit val unliftedUnboxedBooleanArrayType: Meta[Array[Boolean]] = unboxedPairBoolean._1 implicit val liftedUnboxedBooleanArrayType: Meta[Array[Option[Boolean]]] = unboxedPairBoolean._2 - private val unboxedPairInteger = unboxedPair[java.lang.Integer, Int](_.intValue, java.lang.Integer.valueOf) + private val unboxedPairShort = unboxedPair[java.lang.Short, Short](Short2short, short2Short) + implicit val unliftedUnboxedShortArrayType: Meta[Array[Short]] = unboxedPairShort._1 + implicit val liftedUnboxedShortArrayType: Meta[Array[Option[Short]]] = unboxedPairShort._2 + + private val unboxedPairInteger = unboxedPair[java.lang.Integer, Int](Integer2int, int2Integer) implicit val unliftedUnboxedIntegerArrayType: Meta[Array[Int]] = unboxedPairInteger._1 implicit val liftedUnboxedIntegerArrayType: Meta[Array[Option[Int]]] = unboxedPairInteger._2 - private val unboxedPairLong = unboxedPair[java.lang.Long, Long](_.longValue, java.lang.Long.valueOf) + private val unboxedPairLong = unboxedPair[java.lang.Long, Long](Long2long, long2Long) implicit val unliftedUnboxedLongArrayType: Meta[Array[Long]] = unboxedPairLong._1 implicit val liftedUnboxedLongArrayType: Meta[Array[Option[Long]]] = unboxedPairLong._2 - private val unboxedPairFloat = unboxedPair[java.lang.Float, Float](_.floatValue, java.lang.Float.valueOf) + private val unboxedPairFloat = unboxedPair[java.lang.Float, Float](Float2float, float2Float) implicit val unliftedUnboxedFloatArrayType: Meta[Array[Float]] = unboxedPairFloat._1 implicit val liftedUnboxedFloatArrayType: Meta[Array[Option[Float]]] = unboxedPairFloat._2 - private val unboxedPairDouble = unboxedPair[java.lang.Double, Double](_.doubleValue, java.lang.Double.valueOf) + private val unboxedPairDouble = unboxedPair[java.lang.Double, Double](Double2double, double2Double) implicit val unliftedUnboxedDoubleArrayType: Meta[Array[Double]] = unboxedPairDouble._1 implicit val liftedUnboxedDoubleArrayType: Meta[Array[Option[Double]]] = unboxedPairDouble._2 @@ -135,17 +210,4 @@ object array { )( _.map(_.map(a => if (a == null) null else a.bigDecimal)), ) - - // So, it turns out that arrays of structs don't work because something is missing from the - // implementation. So this means we will only be able to support primitive types for arrays. - // - // java.sql.SQLFeatureNotSupportedException: Method org.postgresql.jdbc4.Jdbc4Array.getArrayImpl(long,int,Map) is not yet implemented. - // at org.postgresql.Driver.notImplemented(Driver.java:729) - // at org.postgresql.jdbc2.AbstractJdbc2Array.buildArray(AbstractJdbc2Array.java:771) - // at org.postgresql.jdbc2.AbstractJdbc2Array.getArrayImpl(AbstractJdbc2Array.java:171) - // at org.postgresql.jdbc2.AbstractJdbc2Array.getArray(AbstractJdbc2Array.java:128) - - // TODO: multidimensional arrays; in the worst case it's just copy/paste of everything above but - // we can certainly do better than that. - } diff --git a/modules/postgres/src/test/scala/doobie/postgres/PostgresCheckSuite.scala b/modules/postgres/src/test/scala/doobie/postgres/PostgresCheckSuite.scala index 6cabd6458..41391b90b 100644 --- a/modules/postgres/src/test/scala/doobie/postgres/PostgresCheckSuite.scala +++ b/modules/postgres/src/test/scala/doobie/postgres/PostgresCheckSuite.scala @@ -165,8 +165,7 @@ object PostgresCheckSuite extends PostgresDatabaseSpec { } private def errorWrite[A: Put](value: A, dbType: String) = for { - analysisResult <- - fr"SELECT $value::${Fragment.const(dbType)}".update.analysis.transact + analysisResult <- fr"SELECT $value::${Fragment.const(dbType)}".update.analysis.transact } yield { val errorClasses = analysisResult.parameterAlignmentErrors.map(_.getClass) assertTrue(errorClasses == List(classOf[ParameterTypeError])) diff --git a/modules/postgres/src/test/scala/doobie/postgres/PostgresTypesSuite.scala b/modules/postgres/src/test/scala/doobie/postgres/PostgresTypesSuite.scala index 780fdd2b1..21768bb5f 100644 --- a/modules/postgres/src/test/scala/doobie/postgres/PostgresTypesSuite.scala +++ b/modules/postgres/src/test/scala/doobie/postgres/PostgresTypesSuite.scala @@ -134,14 +134,18 @@ object PostgresTypesSuite extends PostgresDatabaseSpec { skip("json"), // 8.15 Arrays - skip("bit[]", "Requires a cast"), - skip("smallint[]", "always comes back as Array[Int]"), + suiteGetPut[List[Boolean]]("bit[]", Gen.listOfBounded(0, 10)(Gen.boolean)), + suiteGetPut[List[Short]]("smallint[]", Gen.listOfBounded(0, 10)(Gen.short)), suiteGetPut[List[Int]]("integer[]", Gen.listOfBounded(0, 10)(Gen.int)), suiteGetPut[List[Long]]("bigint[]", Gen.listOfBounded(0, 10)(Gen.long)), suiteGetPut[List[Float]]("real[]", Gen.listOfBounded(0, 10)(Gen.float)), suiteGetPut[List[Double]]("double precision[]", Gen.listOfBounded(0, 10)(Gen.double)), suiteGetPut[List[String]]("varchar[]", Gen.listOfBounded(0, 10)(genString)), suiteGetPut[List[UUID]]("uuid[]", Gen.listOfBounded(0, 10)(Gen.uuid)), + suiteGetPut[List[java.sql.Date]]("date[]", Gen.listOfBounded(0, 10)(genSQLDateArray)), + suiteGetPut[List[java.time.LocalDate]]("date[]", Gen.listOfBounded(0, 10)(genLocalDateArray)), + suiteGetPut[List[java.sql.Timestamp]]("timestamp[]", Gen.listOfBounded(0, 10)(genSQLTimestampArray)), + suiteGetPut[List[java.time.Instant]]("timestamptz[]", Gen.listOfBounded(0, 10)(genInstantArray)), suiteGetPut("numeric[]", Gen.const(List[JBigDecimal](BigDecimal("3.14").bigDecimal, BigDecimal("42.0").bigDecimal))), suiteGetPut("numeric[]", Gen.const(List[BigDecimal](BigDecimal("3.14"), BigDecimal("42.0")))), diff --git a/modules/postgres/src/test/scala/doobie/postgres/util/generators/SQLGenerators.scala b/modules/postgres/src/test/scala/doobie/postgres/util/generators/SQLGenerators.scala index e0efe9130..4b9de67c7 100644 --- a/modules/postgres/src/test/scala/doobie/postgres/util/generators/SQLGenerators.scala +++ b/modules/postgres/src/test/scala/doobie/postgres/util/generators/SQLGenerators.scala @@ -14,6 +14,8 @@ object SQLGenerators { val genSQLTime: Gen[Any, Time] = TimeGenerators.genLocalTime.map(Time.valueOf(_)) val genSQLDate: Gen[Any, Date] = TimeGenerators.genLocalDate.map(Date.valueOf(_)) + val genSQLDateArray: Gen[Any, Date] = TimeGenerators.genLocalDateArray.map(Date.valueOf(_)) val genSQLTimestamp: Gen[Any, Timestamp] = TimeGenerators.genLocalDateTime.map(Timestamp.valueOf(_)) + val genSQLTimestampArray: Gen[Any, Timestamp] = TimeGenerators.genLocalDateTimeArray.map(Timestamp.valueOf(_)) } diff --git a/modules/postgres/src/test/scala/doobie/postgres/util/generators/TimeGenerators.scala b/modules/postgres/src/test/scala/doobie/postgres/util/generators/TimeGenerators.scala index c4039c8e4..5fd2e0025 100644 --- a/modules/postgres/src/test/scala/doobie/postgres/util/generators/TimeGenerators.scala +++ b/modules/postgres/src/test/scala/doobie/postgres/util/generators/TimeGenerators.scala @@ -28,6 +28,8 @@ object TimeGenerators { // 4713 BC to 5874897 AD val genLocalDate: Gen[Any, LocalDate] = Gen.localDate(MinDate, MaxDate) + val genLocalDateArray: Gen[Any, LocalDate] = Gen.localDate(LocalDate.of(1, 1, 1), LocalDate.of(9999, 12, 31)) + // 00:00:00.000000 to 23:59:59.999999 val genLocalTime: Gen[Any, LocalTime] = { val min = micros(LocalTime.MIN.toNanoOfDay) @@ -40,10 +42,19 @@ object TimeGenerators { time <- genLocalTime } yield LocalDateTime.of(date, time) + val genLocalDateTimeArray: Gen[Any, LocalDateTime] = for { + date <- genLocalDateArray + time <- genLocalTime + } yield LocalDateTime.of(date, time) + val genInstant: Gen[Any, Instant] = { genLocalDateTime.map(_.toInstant(ZoneOffset.UTC)) } + val genInstantArray: Gen[Any, Instant] = { + genLocalDateTimeArray.map(_.toInstant(ZoneOffset.UTC)) + } + val genZoneOffset: Gen[Any, ZoneOffset] = { Gen.int(MinOffset.getTotalSeconds, MaxOffset.getTotalSeconds).map(ZoneOffset.ofTotalSeconds(_)) } diff --git a/project/build.properties b/project/build.properties index 489e0a72d..5e6884d37 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.11.4 +sbt.version=1.11.6