Skip to content

Commit 59b75a8

Browse files
committed
Allow constructing Read from a list of underlying Read instances
1 parent 3729572 commit 59b75a8

File tree

4 files changed

+102
-10
lines changed

4 files changed

+102
-10
lines changed

build.sbt

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ lazy val log4catsVersion = "2.7.0"
1515
lazy val postGisVersion = "2024.1.0"
1616
lazy val postgresVersion = "42.7.5"
1717
lazy val refinedVersion = "0.11.3"
18+
lazy val scalaCollectionCompatVersion = "2.13.0"
1819
lazy val scalaCheckVersion = "1.15.4"
1920
lazy val scalatestVersion = "3.2.18"
2021
lazy val munitVersion = "1.1.0"
@@ -249,12 +250,17 @@ lazy val core = project
249250
name := "doobie-core",
250251
description := "Pure functional JDBC layer for Scala.",
251252
libraryDependencies ++= Seq(
252-
"com.chuusai" %% "shapeless" % shapelessVersion
253-
).filterNot(_ => tlIsScala3.value) ++ Seq(
254253
"org.tpolecat" %% "typename" % "1.1.0",
255254
"com.h2database" % "h2" % h2Version % "test",
256255
"org.postgresql" % "postgresql" % postgresVersion % "test"
257256
),
257+
libraryDependencies ++= (if (tlIsScala3.value)
258+
Seq.empty
259+
else
260+
Seq("com.chuusai" %% "shapeless" % shapelessVersion)),
261+
libraryDependencies ++= (if (scalaVersion.value == scala212Version)
262+
Seq("org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatVersion)
263+
else Seq.empty),
258264
Compile / unmanagedSourceDirectories += {
259265
val sourceDir = (Compile / sourceDirectory).value
260266
CrossVersion.partialVersion(scalaVersion.value) match {
@@ -334,7 +340,7 @@ lazy val postgres = project
334340
"co.fs2" %% "fs2-io" % fs2Version,
335341
"org.postgresql" % "postgresql" % postgresVersion,
336342
postgisDep % "provided",
337-
"org.scala-lang.modules" %% "scala-collection-compat" % "2.13.0" % Test
343+
"org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatVersion % Test
338344
),
339345
freeGen2Dir := (Compile / scalaSource).value / "doobie" / "postgres" / "free",
340346
freeGen2Package := "doobie.postgres.free",
@@ -496,11 +502,6 @@ lazy val bench = project
496502
.enablePlugins(NoPublishPlugin)
497503
.enablePlugins(AutomateHeaderPlugin)
498504
.enablePlugins(JmhPlugin)
499-
.settings(
500-
libraryDependencies ++= (if (scalaVersion.value == scala212Version)
501-
Seq("org.scala-lang.modules" %% "scala-collection-compat" % "2.13.0")
502-
else Seq.empty)
503-
)
504505
.dependsOn(core, postgres, hikari)
505506
.settings(doobieSettings)
506507

modules/core/src/main/scala/doobie/util/package.scala

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,28 @@
44

55
package doobie
66

7+
import scala.reflect.ClassTag
8+
79
/** Collection of modules for typeclasses and other helpful bits. */
810
package object util {
9-
1011
val unlabeled: String = "unlabeled"
1112

1213
private[util] def void(a: Any*): Unit = {
1314
val _ = a
1415
}
1516

17+
private[doobie] def arraySequence[A: ClassTag](arr: Array[Option[A]]): Option[Array[A]] = {
18+
val result = new Array[A](arr.length)
19+
var i = 0
20+
while (i < arr.length) {
21+
arr(i) match {
22+
case None => return None
23+
case Some(value) =>
24+
result(i) = value
25+
i += 1
26+
}
27+
}
28+
Some(result)
29+
}
30+
1631
}

modules/core/src/main/scala/doobie/util/read.scala

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package doobie.util
66

7+
import cats.syntax.all.*
78
import cats.Applicative
89
import doobie.ResultSetIO
910
import doobie.enumerated.Nullability
@@ -12,6 +13,7 @@ import doobie.free.resultset as IFRS
1213

1314
import java.sql.ResultSet
1415
import scala.annotation.implicitNotFound
16+
import scala.reflect.ClassTag
1517

1618
@implicitNotFound("""
1719
Cannot find or construct a Read instance for type:
@@ -119,7 +121,7 @@ object Read extends LowerPriority1Read {
119121
override lazy val length: Int = underlyingRead.length
120122
}
121123

122-
/** A Read instance consists of multiple underlying Read instances */
124+
/** A Read instance consists of two underlying Read instances */
123125
class Composite[A, S0, S1](read0: Read[S0], read1: Read[S1], f: (S0, S1) => A) extends Read[A] {
124126
override def unsafeGet(rs: ResultSet, startIdx: Int): A = {
125127
val r0 = read0.unsafeGet(rs, startIdx)
@@ -145,6 +147,30 @@ object Read extends LowerPriority1Read {
145147
override lazy val length: Int = read0.length + read1.length
146148
}
147149

150+
/** A Composite made up of a list of underlying Read instances. This class but is intended to provide a simpler
151+
* interface for other libraries that uses Doobie. This isn't used by Doobie itself for its derived instances.
152+
*
153+
* For large number of columns, this class may be more performant than chain of Read.Composite.
154+
*/
155+
class CompositeOfInstances[A: ClassTag](readInstances: Array[Read[A]]) extends Read[Array[A]] {
156+
override def unsafeGet(rs: ResultSet, startIdx: Int): Array[A] = {
157+
var columnIdx = startIdx
158+
readInstances.map { r =>
159+
val res = r.unsafeGet(rs, columnIdx)
160+
columnIdx += r.length // This Read instance "consumed" x number of columns
161+
res
162+
}
163+
}
164+
165+
override def gets: List[(Get[?], NullabilityKnown)] = readInstances.flatMap(_.gets).toList
166+
167+
override def toOpt: Read[Option[Array[A]]] = {
168+
new CompositeOfInstances(readInstances.map(_.toOpt)).map(arraySequence)
169+
}
170+
171+
override def length: Int = readInstances.map(_.length).sum
172+
}
173+
148174
}
149175

150176
trait LowerPriority1Read extends LowerPriority2Read {

modules/core/src/test/scala/doobie/util/ReadSuite.scala

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,56 @@ class ReadSuite extends munit.CatsEffectSuite with ReadSuitePlatform {
201201
q2.transact(xa).assertEquals(List(Some((1, 2, 3, 4))))
202202
}
203203

204+
test("Read.CompositeOfInstances reads columns correctly") {
205+
import cats.syntax.functor.*
206+
val rscc: Read[SimpleCaseClass] = Read.derived[SimpleCaseClass]
207+
208+
implicit val read: Read[ComplexCaseClass] =
209+
new Read.CompositeOfInstances(Array(
210+
rscc.widen[Any],
211+
rscc.toOpt.widen[Any],
212+
Read[Option[Int]].widen[Any],
213+
Read[String].widen[Any]))
214+
.map { arr =>
215+
ComplexCaseClass(
216+
arr(0).asInstanceOf[SimpleCaseClass],
217+
arr(1).asInstanceOf[Option[SimpleCaseClass]],
218+
arr(2).asInstanceOf[Option[Int]],
219+
arr(3).asInstanceOf[String])
220+
}
221+
222+
for {
223+
_ <- IO(assertEquals(read.length, 8))
224+
_ <-
225+
sql"SELECT 1, '2', '3', 4, '5', '6', 7, '8'"
226+
.query[ComplexCaseClass].unique.transact(xa)
227+
.assertEquals(
228+
ComplexCaseClass(
229+
SimpleCaseClass(Some(1), "2", Some("3")),
230+
Some(SimpleCaseClass(Some(4), "5", Some("6"))),
231+
Some(7),
232+
"8"
233+
)
234+
)
235+
_ <-
236+
// The 's' field in Option[SimpleCaseClass] is NULL, so whole case class value is None
237+
sql"SELECT NULL, '2', '3', 4, NULL, '6', 7, '8'"
238+
.query[ComplexCaseClass].unique.transact(xa)
239+
.assertEquals(
240+
ComplexCaseClass(
241+
SimpleCaseClass(None, "2", Some("3")),
242+
None,
243+
Some(7),
244+
"8"
245+
)
246+
)
247+
_ <-
248+
sql"SELECT 1, NULL, '3', 4, '5', '6', 7, '8'"
249+
.query[Option[ComplexCaseClass]].unique.transact(xa)
250+
.assertEquals(None)
251+
} yield ()
252+
}
253+
204254
test("Read should select correct columns when combined with `ap`") {
205255
import cats.syntax.all.*
206256
import doobie.implicits.*

0 commit comments

Comments
 (0)