Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions modules/bench/src/main/scala/doobie/bench/insert.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -70,29 +71,31 @@ 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)
}

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
}
Expand All @@ -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
Expand All @@ -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)
Expand Down
128 changes: 95 additions & 33 deletions modules/postgres/src/main/scala/doobie/postgres/instances/array.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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.

}
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")))),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(_))

}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(_))
}
Expand Down
2 changes: 1 addition & 1 deletion project/build.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
sbt.version=1.11.4
sbt.version=1.11.6