Skip to content

Commit f6327de

Browse files
authored
Merge pull request #2195 from typelevel/Read_Write_from_list_of_instances
Allow constructing Read/Write from a list of underlying instances
2 parents 83cd81e + 4ce9b95 commit f6327de

File tree

4 files changed

+101
-10
lines changed

4 files changed

+101
-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: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import doobie.free.resultset as IFRS
1212

1313
import java.sql.ResultSet
1414
import scala.annotation.implicitNotFound
15+
import scala.reflect.ClassTag
1516

1617
@implicitNotFound("""
1718
Cannot find or construct a Read instance for type:
@@ -119,7 +120,7 @@ object Read extends LowerPriority1Read {
119120
override lazy val length: Int = underlyingRead.length
120121
}
121122

122-
/** A Read instance consists of multiple underlying Read instances */
123+
/** A Read instance consists of two underlying Read instances */
123124
class Composite[A, S0, S1](read0: Read[S0], read1: Read[S1], f: (S0, S1) => A) extends Read[A] {
124125
override def unsafeGet(rs: ResultSet, startIdx: Int): A = {
125126
val r0 = read0.unsafeGet(rs, startIdx)
@@ -145,6 +146,30 @@ object Read extends LowerPriority1Read {
145146
override lazy val length: Int = read0.length + read1.length
146147
}
147148

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

150175
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)