diff --git a/modules/bench/src/main/scala/doobie/bench/README.md b/modules/bench/src/main/scala/doobie/bench/README.md index 564f0981d..0cde272ab 100644 --- a/modules/bench/src/main/scala/doobie/bench/README.md +++ b/modules/bench/src/main/scala/doobie/bench/README.md @@ -25,13 +25,13 @@ d.b.t.copy_stream avgt 2 382.481 ns/op # JMH version: 1.37 # VM version: JDK 21.0.8, OpenJDK 64-Bit Server VM Benchmark Mode Cnt Score Error Units -d.b.i.array_512 ss 1500 2108.146 ± 29.363 ns/op -d.b.i.array_1024 ss 1500 1282.125 ± 17.193 ns/op -d.b.i.array_2048 ss 1500 901.204 ± 27.426 ns/op -d.b.i.batch_512 ss 1500 3610.360 ± 49.533 ns/op -d.b.i.batch_1024 ss 1500 2824.224 ± 158.971 ns/op -d.b.i.batch_2048 ss 1500 2413.790 ± 15.661 ns/op -d.b.i.values_512 ss 1500 2661.017 ± 70.472 ns/op -d.b.i.values_1024 ss 1500 1930.406 ± 25.466 ns/op -d.b.i.values_2048 ss 1500 1759.005 ± 13.514 ns/op +d.b.i.array_512 ss 1500 2637.671 ± 74.508 ns/op +d.b.i.array_1024 ss 1500 1536.033 ± 17.970 ns/op +d.b.i.array_2048 ss 1500 1115.185 ± 20.325 ns/op +d.b.i.batch_512 ss 1500 3894.445 ± 42.689 ns/op +d.b.i.batch_1024 ss 1500 3040.211 ± 21.192 ns/op +d.b.i.batch_2048 ss 1500 2648.115 ± 19.796 ns/op +d.b.i.values_512 ss 1500 3057.419 ± 34.115 ns/op +d.b.i.values_1024 ss 1500 2360.481 ± 41.843 ns/op +d.b.i.values_2048 ss 1500 2343.947 ± 116.611 ns/op ``` diff --git a/modules/bench/src/main/scala/doobie/bench/insert.scala b/modules/bench/src/main/scala/doobie/bench/insert.scala index fb8e64462..414845b37 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.LocalDate import java.util.concurrent.TimeUnit @BenchmarkMode(Array(Mode.SingleShotTime)) @@ -70,50 +71,56 @@ 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: LocalDate) object Widget { implicit val write: Write[Widget] = Write.derived - def generate(n: Int) = List.fill(n)(Widget("widget", n)) + private val now = LocalDate.now() + + def generate(n: Int) = Vector.fill(n)(Widget("widget", n, now.plusDays(n.toLong))) } private val insertBatch = { val sql = """ INSERT INTO bench_widget - (name, extensions) + (name, extensions, produced) VALUES - (?, ?) + (?, ?, ?) """ Update[Widget](sql) } - private def insertValues(widgets: List[Widget]) = { + private def insertValues(widgets: Vector[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 } - private def insertArrayValues(widgets: List[Widget]) = { + private def insertArrayValues(widgets: Vector[Widget]) = { val n = widgets.length val names = Array.ofDim[String](n) val extensions = Array.ofDim[Int](n) + val produced = Array.ofDim[LocalDate](n) var i = 0 - widgets.foreach { w => + while (i < n) { + val w = widgets(i) 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::date[] ) """ sql.update @@ -123,7 +130,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 date 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..23a920d97 100644 --- a/modules/postgres/src/main/scala/doobie/postgres/instances/array.scala +++ b/modules/postgres/src/main/scala/doobie/postgres/instances/array.scala @@ -7,8 +7,10 @@ package doobie.postgres.instances import doobie.util.invariant.* import doobie.util.meta.Meta +import java.time.LocalDate 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 +29,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 +91,19 @@ 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 + // 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 +114,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 +160,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..384589557 100644 --- a/modules/postgres/src/test/scala/doobie/postgres/PostgresTypesSuite.scala +++ b/modules/postgres/src/test/scala/doobie/postgres/PostgresTypesSuite.scala @@ -134,14 +134,17 @@ 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("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..7355d5980 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,6 +42,11 @@ 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)) } diff --git a/project/build.properties b/project/build.properties index 489e0a72d..01a16ed14 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.11.4 +sbt.version=1.11.7