Skip to content

Commit dc8505c

Browse files
authored
perf(macros): reduce redundant implicit searches in ctx.run() pipeline (#720)
Previously, ProtoQuill's `ctx.run()` macro expansion performed redundant `Expr.summon` (implicit search) calls for GenericEncoder/GenericDecoder resolution. For a 10-field case class, the pipeline issued ~30 implicit searches; for 20 fields, ~60. Each search is expensive in Scala 3's macro system, making this the primary compilation bottleneck (issue #619 documents a single repository file taking 2m49s to compile). This commit takes a two-pronged approach: optimizing the existing macro pipeline, and introducing a new NamedTuple-based query API that sidesteps the most expensive macro work entirely. ## Macro pipeline optimizations Six coordinated changes reduce redundant implicit searches in the existing `query[T]` / `ctx.run()` path: 1. **isKnownLeafType fast-path** (TypeExtensions.scala): Adds a cheap O(1) `TypeRepr.=:=` check for 10 primitive types (String, Int, Long, Short, Byte, Float, Double, Boolean, BigDecimal, Array[Byte]) that are guaranteed to have encoders/decoders in every context. `java.util.Date` is intentionally excluded because it has no encoder in the Cassandra context. This bypasses expensive implicit search entirely for these common types. 2. **GenericDecoder fast-path** (GenericDecoder.scala): Adds new first cases in the `flatten` and `values` pattern matches that use `isKnownLeafType` to skip the `Expr.summon` guard. Known scalar fields no longer trigger implicit search just to determine leaf-vs-branch status. 3. **Decoder expression cache** (GenericDecoder.scala): Introduces a `mutable.Map[String, Option[Expr[_]]]` cache threaded through the entire decode pipeline (flatten, values, decode, decodeOptional, Summon.decoder, Summon.nullChecker). The cache is created once at the `GenericDecoder.summon` entry point. For 20 fields with 5 unique types, this reduces implicit searches from ~60 to ~7 (~88% reduction). 4. **ElaborateStructure leaf cache** (ElaborateStructure.scala): Adds a `mutable.Map[String, Boolean]` cache to `base()`, threaded through `flatten()` and `collectFields()`, with isKnownLeafType as an initial fast-path. Prevents repeated encoder/decoder summoning when determining leaf-vs-branch status for the same type appearing in multiple fields. 5. **QuatMaking cache fix** (QuatMaking.scala): The existing caches (`encodeableCache` and `quatCache`) were defined but completely bypassed— `lookupIsEncodeable` and `lookupCache` called `computeEncodeable()` / `computeQuat()` directly instead of using `getOrElseUpdate`. This commit fixes both to actually use their caches. Also adds the isKnownLeafType fast-path to `existsEncoderFor` to avoid 2 Expr.summon calls per known primitive type. The cache comment now accurately describes the object-level `getOrElseUpdate` mechanism (previously the comment incorrectly referenced non-existent instance-level methods). 6. **Pre-materialized Option decoders** (Decoders.scala, Encoders.scala in jdbc, cassandra, mirror, doobie contexts): Adds explicit `implicit val` definitions for `Option[T]` encoders/decoders of common types. Previously, `Option[String]` etc. required the compiler to derive them through `optionDecoder[String]` via implicit search chains. Pre-materializing them short-circuits this search. ## NamedTuple-based record API (io.getquill.record) As proposed in #643, this introduces a `Selectable`-based `Record[C, W[_]]` that uses `NamedTuple.Map[NamedTuple.From[C], W]` for type-level schema derivation instead of macro AST inspection. The higher-kinded `W[_]` parameter wraps each field type so the compiler sees the correct runtime representation — for query building, `W = Col` where `type Col[A] = FieldExpr`, so `Record[Person, Col]` has `Fields = (name: FieldExpr, age: FieldExpr)`, matching what `selectDynamic` actually returns at runtime: - `Record[C, W[_]]` extends `Selectable` with `type Fields = NamedTuple.Map[NamedTuple.From[C], W]`, so field access (e.g. `record.name`) is resolved entirely through the type system via `selectDynamic` — no macro reflection needed, and no ClassCastException from mismatched compile-time vs runtime types. - `TypedEntityQuery[T]` wraps the query and produces the same AST nodes (`Entity`, `Filter`, `Map`, `SortBy`, etc.) as the macro-based `query[T]`. Note: `map` currently returns `TypedEntityQuery[T]` (same entity type) rather than supporting type-changing projections — this is a known limitation documented in the code, with the full fix planned as part of a `Queryable[Q, R]` typeclass rearchitecture. - `SchemaDeriving` extracts field names and types at the type level using `NamedTuple.Names` and `NamedTuple.From` instead of recursive macro `'[field *: fields]` pattern matching. - `RecordCodec` uses `summonInline` to materialize all encoders/decoders in a single traversal, replacing the per-field `Expr.summon` calls. - `TypedQuerySpec` covers the API with 8 AST-level unit tests: entity creation, filter/map/sortBy/take/drop AST construction, toQuoted bridging, Record field access, and chained operation composition. The new API is available as `typedQuery[T]` and bridges seamlessly into the existing `ctx.run()` infrastructure via an implicit conversion to `Quoted[EntityQuery[T]]`. The existing `query[T]` API is completely unchanged. ## Other changes - **Scala 3.8.1 upgrade** with sbt 1.12.4, bringing compiler improvements including given-loop prevention, parallelized JVM backend, pipelined builds, and type-size normalization - **JVM tuning** in CI: 8GB heap with ParallelGC (was 6GB with G1GC) - **Java target fix**: Changed `-release` and `-target` from 21 to 17 to match the JDK version used in CI (temurin 17) - **Parser fix** for Scala 3.8 augmentString desugaring of toInt/toLong - **Doobie upgrade**: RC5 → RC12, resolving incompatibility with newer doobie releases - **HikariCP 6.3.3**: Aligns Scala 3 version with the Scala 2.13 artifact, resolving the version mismatch between the two - **Other dependency updates**: ZIO 2.1.24, pprint 0.9.6, logback 1.5.32, scalatest 3.2.19, scala-logging 3.9.6, and other test dependencies - **@implicitNotFound annotations** on mappedEncoder/mappedDecoder for clearer compile-time error messages when MappedEncoding instances are missing - **-Vprofile diagnostic flag** gated behind `-Dprofile=true` for before/after measurement All 265 tests in quill-sql (including new TypedQuerySpec) and 668 tests in quill-sql-tests pass. No public API changes to query[T], ctx.run(), or encoder/decoder interfaces. Inspired-by: #643 Resolves: #619 Resolves: #650 Resolves: #621 Refs: #195 Authored-By: Colin K. Williams / li-nk.social <colin@li-nk.org>
1 parent 4d953d7 commit dc8505c

23 files changed

Lines changed: 761 additions & 132 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ env:
1212
# See:
1313
# - https://stackoverflow.com/a/73708006
1414
# - https://stackoverflow.com/questions/73465937/apache-spark-3-3-0-breaks-on-java-17-with-cannot-access-class-sun-nio-ch-direct
15-
JAVA_OPTS: -Xms6G -Xmx6G -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+TieredCompilation -Dcommunity=false -Dquill.macro.log=false --add-exports java.base/sun.nio.ch=ALL-UNNAMED
16-
JDK_JAVA_OPTIONS: -Xms6G -Xmx6G -XX:+UseG1GC -XX:ReservedCodeCacheSize=256m -XX:+TieredCompilation -Dcommunity=false -Dquill.macro.log=false --add-exports java.base/sun.nio.ch=ALL-UNNAMED
15+
JAVA_OPTS: -Xms8G -Xmx8G -XX:+UseParallelGC -XX:ReservedCodeCacheSize=256m -XX:+TieredCompilation -Dcommunity=false -Dquill.macro.log=false --add-exports java.base/sun.nio.ch=ALL-UNNAMED
16+
JDK_JAVA_OPTIONS: -Xms8G -Xmx8G -XX:+UseParallelGC -XX:ReservedCodeCacheSize=256m -XX:+TieredCompilation -Dcommunity=false -Dquill.macro.log=false --add-exports java.base/sun.nio.ch=ALL-UNNAMED
1717

1818
jobs:
1919
build:
@@ -22,7 +22,7 @@ jobs:
2222
strategy:
2323
fail-fast: false
2424
matrix:
25-
scala: [ 3.3.6 ]
25+
scala: [ 3.8.1 ]
2626
module: [ sqltest, db, bigdata ]
2727

2828
steps:

build.sbt

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ val isCommunityRemoteBuild =
2727
sys.props.getOrElse("communityRemote", "false").toBoolean
2828

2929
lazy val scalatestVersion =
30-
if (isCommunityRemoteBuild) "3.2.7" else "3.2.18"
30+
if (isCommunityRemoteBuild) "3.2.7" else "3.2.19"
3131

3232
lazy val baseModules = Seq[sbt.ClasspathDep[sbt.ProjectReference]](
3333
`quill-sql`
@@ -86,7 +86,7 @@ val filteredModules = {
8686
}
8787

8888
val zioQuillVersion = "4.8.5"
89-
val zioVersion = "2.1.20"
89+
val zioVersion = "2.1.24"
9090

9191
lazy val `quill` =
9292
(project in file("."))
@@ -116,8 +116,8 @@ lazy val `quill-sql` =
116116
// Needs to be in-sync with both quill-engine and scalafmt-core or ClassNotFound
117117
// errors will happen. Even if the pprint classes are actually there
118118
"io.suzaku" %% "boopickle" % "1.5.0",
119-
"com.lihaoyi" %% "pprint" % "0.9.3",
120-
"ch.qos.logback" % "logback-classic" % "1.5.18" % Test,
119+
"com.lihaoyi" %% "pprint" % "0.9.6",
120+
"ch.qos.logback" % "logback-classic" % "1.5.32" % Test,
121121
"io.getquill" %% "quill-engine" % zioQuillVersion,
122122
"dev.zio" %% "zio" % zioVersion,
123123
("io.getquill" %% "quill-util" % zioQuillVersion)
@@ -127,11 +127,12 @@ lazy val `quill-sql` =
127127
else
128128
Seq.empty
129129
}: _*),
130-
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.5",
130+
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.6",
131131
"org.scalatest" %% "scalatest" % scalatestVersion % Test,
132132
"org.scalatest" %% "scalatest-mustmatchers" % scalatestVersion % Test,
133133
"com.vladsch.flexmark" % "flexmark-all" % "0.64.8" % Test
134-
)
134+
),
135+
packageDoc / publishArtifact := false,
135136
)
136137

137138
// Moving heavy tests to separate module so it can be compiled in parallel with others
@@ -153,14 +154,15 @@ lazy val `quill-jdbc` =
153154
.dependsOn(`quill-sql` % "compile->compile;test->test")
154155

155156
ThisBuild / libraryDependencySchemes += "org.typelevel" %% "cats-effect" % "always"
157+
ThisBuild / libraryDependencySchemes += "dev.zio" %% "zio-json" % "always"
156158
lazy val `quill-doobie` =
157159
(project in file("quill-doobie"))
158160
.settings(commonSettings: _*)
159161
.settings(jdbcTestingSettings: _*)
160162
.settings(
161163
libraryDependencies ++= Seq(
162-
"org.tpolecat" %% "doobie-core" % "1.0.0-RC5",
163-
"org.tpolecat" %% "doobie-postgres" % "1.0.0-RC5" % Test
164+
"org.tpolecat" %% "doobie-core" % "1.0.0-RC12",
165+
"org.tpolecat" %% "doobie-postgres" % "1.0.0-RC12" % Test
164166
)
165167
)
166168
.dependsOn(`quill-jdbc` % "compile->compile;test->test")
@@ -171,14 +173,14 @@ lazy val `quill-caliban` =
171173
.settings(
172174
Test / fork := true,
173175
libraryDependencies ++= Seq(
174-
"com.github.ghostdogpr" %% "caliban-quick" % "2.11.1",
176+
"com.github.ghostdogpr" %% "caliban-quick" % "2.11.2",
175177
// Adding this to main dependencies would force users to use logback-classic for SLF4j unless the specifically remove it
176178
// seems to be safer to just exclude & add a commented about need for a SLF4j implementation in Docs.
177-
"ch.qos.logback" % "logback-classic" % "1.5.18" % Test,
179+
"ch.qos.logback" % "logback-classic" % "1.5.32" % Test,
178180
// Don't want to make this dependant on zio-test for the testing code so importing this here separately
179181
"org.scalatest" %% "scalatest" % scalatestVersion % Test,
180182
"org.scalatest" %% "scalatest-mustmatchers" % scalatestVersion % Test,
181-
"org.postgresql" % "postgresql" % "42.7.7" % Test,
183+
"org.postgresql" % "postgresql" % "42.7.10" % Test,
182184
)
183185
)
184186
.dependsOn(`quill-jdbc-zio` % "compile->compile")
@@ -202,8 +204,8 @@ lazy val `quill-jdbc-zio` =
202204
.settings(
203205
libraryDependencies ++= Seq(
204206
// Needed for PGObject in JsonExtensions but not necessary if user is not using postgres
205-
"org.postgresql" % "postgresql" % "42.7.7" % "provided",
206-
"dev.zio" %% "zio-json" % "0.7.44"
207+
"org.postgresql" % "postgresql" % "42.7.10" % "provided",
208+
"dev.zio" %% "zio-json" % "0.8.0"
207209
),
208210
Test / runMain / fork := true,
209211
Test / fork := true,
@@ -264,13 +266,13 @@ lazy val commonSettings =
264266
lazy val jdbcTestingLibraries = Seq(
265267
// JDBC Libraries for testing of quill-jdbc___ contexts
266268
libraryDependencies ++= Seq(
267-
"com.zaxxer" % "HikariCP" % "6.3.2" exclude("org.slf4j", "*"),
269+
"com.zaxxer" % "HikariCP" % "6.3.3" exclude("org.slf4j", "*"),
268270
// In 8.0.22 error happens: Conversion from java.time.OffsetDateTime to TIMESTAMP is not supported
269-
"com.mysql" % "mysql-connector-j" % "9.4.0" % Test,
270-
"com.h2database" % "h2" % "2.3.232" % Test,
271+
"com.mysql" % "mysql-connector-j" % "9.6.0" % Test,
272+
"com.h2database" % "h2" % "2.4.240" % Test,
271273
// In 42.2.18 error happens: PSQLException: conversion to class java.time.OffsetTime from timetz not supported
272-
"org.postgresql" % "postgresql" % "42.7.7" % Test,
273-
"org.xerial" % "sqlite-jdbc" % "3.50.3.0" % Test,
274+
"org.postgresql" % "postgresql" % "42.7.10" % Test,
275+
"org.xerial" % "sqlite-jdbc" % "3.51.2.0" % Test,
274276
// In 7.1.1-jre8-preview error happens: The conversion to class java.time.OffsetDateTime is unsupported.
275277
"com.microsoft.sqlserver" % "mssql-jdbc" % "7.4.1.jre11" % Test,
276278
"com.oracle.ojdbc" % "ojdbc8" % "19.3.0.0" % Test,
@@ -290,7 +292,7 @@ lazy val basicSettings = Seq(
290292
excludeDependencies ++= Seq(
291293
ExclusionRule("org.scala-lang.modules", "scala-collection-compat_2.13")
292294
),
293-
scalaVersion := "3.3.6",
295+
scalaVersion := "3.8.1",
294296
// The -e option is the 'error' report of ScalaTest. We want it to only make a log
295297
// of the failed tests once all tests are done, the regular -o log shows everything else.
296298
// Test / testOptions ++= Seq(
@@ -299,13 +301,16 @@ lazy val basicSettings = Seq(
299301
// //Tests.Argument(TestFrameworks.ScalaTest, "-u", "junits")
300302
// //Tests.Argument(TestFrameworks.ScalaTest, "-h", "testresults")
301303
// ),
304+
usePipelining := true,
302305
scalacOptions ++= Seq(
303306
"-language:implicitConversions", "-explain",
304307
// See https://docs.scala-lang.org/scala3/guides/migration/tooling-syntax-rewriting.html
305308
"-no-indent",
306-
"-release:11",
309+
"-release:17",
310+
"-source:3.3", // Suppress `implicit` keyword deprecation warnings for gradual migration
307311
),
308-
javacOptions := Seq("-source", "11", "-target", "11"),
312+
javacOptions := Seq("-source", "17", "-target", "17"),
313+
scalacOptions ++= (if (sys.props.getOrElse("profile", "false").toBoolean) Seq("-Vprofile") else Seq.empty),
309314
)
310315

311316
// force redraft

project/build.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
sbt.version=1.11.4
1+
sbt.version=1.12.4

quill-cassandra/src/main/scala/io/getquill/context/cassandra/encoding/Decoders.scala

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,20 @@ trait Decoders extends CassandraRowContext with EncodingDsl with CollectionDecod
6161
implicit val timestampDecoder: Decoder[Instant] = decoder(_.getInstant)
6262
implicit val cassandraLocalTimeDecoder: Decoder[LocalTime] = decoder(_.getLocalTime)
6363
implicit val cassandraLocalDateDecoder: Decoder[LocalDate] = decoder(_.getLocalDate)
64+
65+
// Pre-materialized Option decoders to short-circuit implicit search
66+
implicit val optionStringDecoder: Decoder[Option[String]] = optionDecoder[String]
67+
implicit val optionBigDecimalDecoder: Decoder[Option[BigDecimal]] = optionDecoder[BigDecimal]
68+
implicit val optionBooleanDecoder: Decoder[Option[Boolean]] = optionDecoder[Boolean]
69+
implicit val optionByteDecoder: Decoder[Option[Byte]] = optionDecoder[Byte]
70+
implicit val optionShortDecoder: Decoder[Option[Short]] = optionDecoder[Short]
71+
implicit val optionIntDecoder: Decoder[Option[Int]] = optionDecoder[Int]
72+
implicit val optionLongDecoder: Decoder[Option[Long]] = optionDecoder[Long]
73+
implicit val optionFloatDecoder: Decoder[Option[Float]] = optionDecoder[Float]
74+
implicit val optionDoubleDecoder: Decoder[Option[Double]] = optionDecoder[Double]
75+
implicit val optionByteArrayDecoder: Decoder[Option[Array[Byte]]] = optionDecoder[Array[Byte]]
76+
implicit val optionUuidDecoder: Decoder[Option[UUID]] = optionDecoder[UUID]
77+
implicit val optionTimestampDecoder: Decoder[Option[Instant]] = optionDecoder[Instant]
78+
implicit val optionLocalTimeDecoder: Decoder[Option[LocalTime]] = optionDecoder[LocalTime]
79+
implicit val optionLocalDateDecoder: Decoder[Option[LocalDate]] = optionDecoder[LocalDate]
6480
}

quill-cassandra/src/main/scala/io/getquill/context/cassandra/encoding/Encoders.scala

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,20 @@ with UdtEncoding {
7979
implicit val timestampEncoder: Encoder[Instant] = encoder(_.setInstant)
8080
implicit val cassandraLocalTimeEncoder: Encoder[LocalTime] = encoder(_.setLocalTime)
8181
implicit val cassandraLocalDateEncoder: Encoder[LocalDate] = encoder(_.setLocalDate)
82+
83+
// Pre-materialized Option encoders to short-circuit implicit search
84+
implicit val optionStringEncoder: Encoder[Option[String]] = optionEncoder[String]
85+
implicit val optionBigDecimalEncoder: Encoder[Option[BigDecimal]] = optionEncoder[BigDecimal]
86+
implicit val optionBooleanEncoder: Encoder[Option[Boolean]] = optionEncoder[Boolean]
87+
implicit val optionByteEncoder: Encoder[Option[Byte]] = optionEncoder[Byte]
88+
implicit val optionShortEncoder: Encoder[Option[Short]] = optionEncoder[Short]
89+
implicit val optionIntEncoder: Encoder[Option[Int]] = optionEncoder[Int]
90+
implicit val optionLongEncoder: Encoder[Option[Long]] = optionEncoder[Long]
91+
implicit val optionFloatEncoder: Encoder[Option[Float]] = optionEncoder[Float]
92+
implicit val optionDoubleEncoder: Encoder[Option[Double]] = optionEncoder[Double]
93+
implicit val optionByteArrayEncoder: Encoder[Option[Array[Byte]]] = optionEncoder[Array[Byte]]
94+
implicit val optionUuidEncoder: Encoder[Option[UUID]] = optionEncoder[UUID]
95+
implicit val optionTimestampEncoder: Encoder[Option[Instant]] = optionEncoder[Instant]
96+
implicit val optionLocalTimeEncoder: Encoder[Option[LocalTime]] = optionEncoder[LocalTime]
97+
implicit val optionLocalDateEncoder: Encoder[Option[LocalDate]] = optionEncoder[LocalDate]
8298
}

quill-doobie/src/main/scala/io/getquill/doobie/DoobieContextBase.scala

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ trait DoobieContextBase[+Dialect <: SqlIdiom, +Naming <: NamingStrategy]
9191
): ConnectionIO[List[A]] =
9292
HC.prepareStatement(sql) {
9393
useConnection { implicit connection =>
94+
implicit val read: Read[A] = extractorToRead(extractor)
9495
prepareAndLog(sql, prepare) *>
9596
HPS.executeQuery {
96-
HRS.list(extractor)
97+
HRS.list[A]
9798
}
9899
}
99100
}
@@ -108,9 +109,10 @@ trait DoobieContextBase[+Dialect <: SqlIdiom, +Naming <: NamingStrategy]
108109
): ConnectionIO[A] =
109110
HC.prepareStatement(sql) {
110111
useConnection { implicit connection =>
112+
implicit val read: Read[A] = extractorToRead(extractor)
111113
prepareAndLog(sql, prepare) *>
112114
HPS.executeQuery {
113-
HRS.getUnique(extractor)
115+
HRS.getUnique[A]
114116
}
115117
}
116118
}
@@ -126,12 +128,14 @@ trait DoobieContextBase[+Dialect <: SqlIdiom, +Naming <: NamingStrategy]
126128
): Stream[ConnectionIO, A] =
127129
for {
128130
connection <- Stream.eval(FC.raw(identity))
129-
result <-
130-
HC.stream(
131+
result <- {
132+
implicit val read: Read[A] = extractorToRead(extractor)(connection)
133+
HC.stream[A](
131134
sql,
132135
prepareAndLog(sql, prepare)(connection),
133136
fetchSize.getOrElse(DefaultChunkSize),
134-
)(extractorToRead(extractor)(connection))
137+
)
138+
}
135139
} yield result
136140

137141
override def executeAction(
@@ -175,9 +179,10 @@ trait DoobieContextBase[+Dialect <: SqlIdiom, +Naming <: NamingStrategy]
175179
): ConnectionIO[List[A]] =
176180
prepareConnections[List[A]](returningBehavior)(sql) {
177181
useConnection { implicit connection =>
182+
implicit val read: Read[A] = extractorToRead(extractor)
178183
prepareAndLog(sql, prepare) *>
179184
FPS.executeUpdate *>
180-
HPS.getGeneratedKeys[List[A]](HRS.list(extractor))
185+
HPS.getGeneratedKeys[List[A]](HRS.list[A])
181186
}
182187
}
183188

@@ -219,11 +224,12 @@ trait DoobieContextBase[+Dialect <: SqlIdiom, +Naming <: NamingStrategy]
219224
prepareConnections(returningBehavior)(sql) {
220225

221226
useConnection { implicit connection =>
227+
implicit val read: Read[A] = extractorToRead(extractor)
222228
for {
223229
_ <- FPS.delay(log.underlying.debug("Batch: {}", sql))
224230
_ <- preps.traverse(prepareBatchAndLog(sql, _) *> FPS.addBatch)
225231
_ <- HPS.executeBatch
226-
r <- HPS.getGeneratedKeys(HRS.list(extractor))
232+
r <- HPS.getGeneratedKeys(HRS.list[A])
227233
} yield r
228234
}
229235
}
@@ -234,7 +240,12 @@ trait DoobieContextBase[+Dialect <: SqlIdiom, +Naming <: NamingStrategy]
234240
ex: Extractor[A]
235241
)(
236242
implicit connection: Connection
237-
): Read[A] = new Read[A](Nil, (rs, _) => ex(rs, connection))
243+
): Read[A] = new Read[A] {
244+
override def unsafeGet(rs: java.sql.ResultSet, startIdx: Int): A = ex(rs, connection)
245+
override def gets = Nil
246+
override def toOpt: Read[Option[A]] = this.map(a => Option(a))
247+
override def length: Int = 0
248+
}
238249

239250
// Nothing to do here.
240251
override def close(): Unit = ()

quill-jdbc/src/main/scala/io/getquill/context/jdbc/Decoders.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ trait Decoders {
7474
implicit val dateDecoder: Decoder[util.Date] =
7575
decoder((index, row, session) =>
7676
new util.Date(row.getTimestamp(index, Calendar.getInstance(dateTimeZone)).getTime))
77+
78+
// Pre-materialized Option decoders to short-circuit implicit search
79+
implicit val optionStringDecoder: Decoder[Option[String]] = optionDecoder[String]
80+
implicit val optionBigDecimalDecoder: Decoder[Option[BigDecimal]] = optionDecoder[BigDecimal]
81+
implicit val optionByteDecoder: Decoder[Option[Byte]] = optionDecoder[Byte]
82+
implicit val optionShortDecoder: Decoder[Option[Short]] = optionDecoder[Short]
83+
implicit val optionIntDecoder: Decoder[Option[Int]] = optionDecoder[Int]
84+
implicit val optionLongDecoder: Decoder[Option[Long]] = optionDecoder[Long]
85+
implicit val optionFloatDecoder: Decoder[Option[Float]] = optionDecoder[Float]
86+
implicit val optionDoubleDecoder: Decoder[Option[Double]] = optionDecoder[Double]
87+
implicit val optionByteArrayDecoder: Decoder[Option[Array[Byte]]] = optionDecoder[Array[Byte]]
88+
implicit val optionDateDecoder: Decoder[Option[util.Date]] = optionDecoder[util.Date]
89+
implicit val optionSqlDateDecoder: Decoder[Option[java.sql.Date]] = optionDecoder[java.sql.Date]
90+
implicit val optionSqlTimeDecoder: Decoder[Option[java.sql.Time]] = optionDecoder[java.sql.Time]
91+
implicit val optionSqlTimestampDecoder: Decoder[Option[java.sql.Timestamp]] = optionDecoder[java.sql.Timestamp]
7792
}
7893

7994
trait BasicTimeDecoders extends Decoders {

quill-jdbc/src/main/scala/io/getquill/context/jdbc/Encoders.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ trait Encoders extends EncodingDsl {
8282
implicit val dateEncoder: Encoder[util.Date] =
8383
encoder(Types.TIMESTAMP, (index, value, row) =>
8484
row.setTimestamp(index, new sql.Timestamp(value.getTime), Calendar.getInstance(dateTimeZone)))
85+
86+
// Pre-materialized Option encoders to short-circuit implicit search
87+
implicit val optionStringEncoder: Encoder[Option[String]] = optionEncoder[String]
88+
implicit val optionBigDecimalEncoder: Encoder[Option[BigDecimal]] = optionEncoder[BigDecimal]
89+
implicit val optionByteEncoder: Encoder[Option[Byte]] = optionEncoder[Byte]
90+
implicit val optionShortEncoder: Encoder[Option[Short]] = optionEncoder[Short]
91+
implicit val optionIntEncoder: Encoder[Option[Int]] = optionEncoder[Int]
92+
implicit val optionLongEncoder: Encoder[Option[Long]] = optionEncoder[Long]
93+
implicit val optionFloatEncoder: Encoder[Option[Float]] = optionEncoder[Float]
94+
implicit val optionDoubleEncoder: Encoder[Option[Double]] = optionEncoder[Double]
95+
implicit val optionByteArrayEncoder: Encoder[Option[Array[Byte]]] = optionEncoder[Array[Byte]]
96+
implicit val optionDateEncoder: Encoder[Option[util.Date]] = optionEncoder[util.Date]
97+
implicit val optionSqlDateEncoder: Encoder[Option[java.sql.Date]] = optionEncoder[java.sql.Date]
98+
implicit val optionSqlTimeEncoder: Encoder[Option[java.sql.Time]] = optionEncoder[java.sql.Time]
99+
implicit val optionSqlTimestampEncoder: Encoder[Option[java.sql.Timestamp]] = optionEncoder[java.sql.Timestamp]
85100
}
86101

87102
trait BasicTimeEncoders extends Encoders {

quill-sql/src/main/scala/io/getquill/Dsl.scala

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ extension (str: String) {
4545
inline def query[T]: EntityQuery[T] = ${ QueryMacro[T] }
4646
inline def select[T]: Query[T] = ${ QueryMacro[T] }
4747

48+
// NamedTuple-based query API (Scala 3.7+) — uses Selectable + NamedTuple.From[T]
49+
// for type-level schema derivation instead of macro AST inspection.
50+
// Produces identical SQL to query[T] but with faster compilation.
51+
inline def typedQuery[T]: io.getquill.record.TypedEntityQuery[T] =
52+
${ io.getquill.record.TypedQueryMacro[T] }
53+
54+
// Implicit conversion: TypedEntityQuery[T] -> Quoted[EntityQuery[T]] so it can be
55+
// passed directly to ctx.run() without explicit .toQuoted call
56+
implicit inline def typedQueryToQuoted[T](inline teq: io.getquill.record.TypedEntityQuery[T]): Quoted[EntityQuery[T]] =
57+
teq.toQuoted
58+
4859
def max[A](a: A): A = NonQuotedException()
4960
def min[A](a: A): A = NonQuotedException()
5061
def count[A](a: A): A = NonQuotedException()

quill-sql/src/main/scala/io/getquill/context/mirror/MirrorDecoders.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,19 @@ trait MirrorDecoders extends EncodingDsl {
5555
implicit val dateDecoder: Decoder[Date] = decoder[Date]
5656
implicit val localDateDecoder: Decoder[LocalDate] = decoder[LocalDate]
5757
implicit val uuidDecoder: Decoder[UUID] = decoder[UUID]
58+
59+
// Pre-materialized Option decoders to short-circuit implicit search
60+
implicit val optionStringDecoder: Decoder[Option[String]] = optionDecoder[String]
61+
implicit val optionBigDecimalDecoder: Decoder[Option[BigDecimal]] = optionDecoder[BigDecimal]
62+
implicit val optionBooleanDecoder: Decoder[Option[Boolean]] = optionDecoder[Boolean]
63+
implicit val optionByteDecoder: Decoder[Option[Byte]] = optionDecoder[Byte]
64+
implicit val optionShortDecoder: Decoder[Option[Short]] = optionDecoder[Short]
65+
implicit val optionIntDecoder: Decoder[Option[Int]] = optionDecoder[Int]
66+
implicit val optionLongDecoder: Decoder[Option[Long]] = optionDecoder[Long]
67+
implicit val optionFloatDecoder: Decoder[Option[Float]] = optionDecoder[Float]
68+
implicit val optionDoubleDecoder: Decoder[Option[Double]] = optionDecoder[Double]
69+
implicit val optionByteArrayDecoder: Decoder[Option[Array[Byte]]] = optionDecoder[Array[Byte]]
70+
implicit val optionDateDecoder: Decoder[Option[Date]] = optionDecoder[Date]
71+
implicit val optionLocalDateDecoder: Decoder[Option[LocalDate]] = optionDecoder[LocalDate]
72+
implicit val optionUuidDecoder: Decoder[Option[UUID]] = optionDecoder[UUID]
5873
}

0 commit comments

Comments
 (0)