Skip to content

Commit d8eb009

Browse files
eugkhpclaude
andcommitted
Fix #3403: wrap top-level infix queries decoding into case classes
#3147 made top-level infix queries pass through unwrapped (TopInfixQuery). For case-class results this changes the decode contract: extractors decode positionally in field-declaration order, while the raw SQL (e.g. SELECT *) returns the table's physical column order — decoders silently read neighboring columns when the orders differ. Keep the raw passthrough for single values and tuples (column order is explicit in the same expression) and restore the wrapping query for case-class results, whose explicit projection pins the column order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5a5b8ae commit d8eb009

2 files changed

Lines changed: 39 additions & 3 deletions

File tree

quill-engine/src/main/scala/io/getquill/sql/SqlQuery.scala

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,11 @@ final case class UnaryOperationSqlQuery(
6868
}
6969

7070
// A top-level infix query. This needs special rendering because we don't
71-
// want to tokenize column names, e.g. sql"select foo, bar from baz".as[Person]
71+
// want to tokenize column names, e.g. sql"select foo, bar from baz".as[Query[(String, Int)]]
7272
// should become just "select foo, bar from baz", not the fully expanded form
73-
// "select p.name, p.age from (select foo, bar from baz) as Person p"
73+
// "select x._1, x._2 from (select foo, bar from baz) as x".
74+
// Only used for values and tuples; case-class results keep the expanded form
75+
// to pin column order for positional decoding (see SqlQueryApply below, #3403).
7476
final case class TopInfixQuery(ast: Infix) extends SqlQuery {
7577
override def quat: Quat = ast.quat
7678
}
@@ -119,6 +121,22 @@ class SqlQueryApply(traceConfig: TraceConfig, allowTopLevelInfix: Boolean = true
119121

120122
def apply(query: Ast) = build(query, true)
121123

124+
// Raw passthrough is only safe when positional decoding can't mismatch the raw
125+
// SQL's column order: single values (one column) and tuples (order is explicit
126+
// in the same expression). Case classes decode in field-declaration order, so e.g.
127+
// sql"SELECT t.* FROM TestEntity t".as[Query[TestEntity]] would decode whatever
128+
// order the table's DDL has; they keep the wrapping query whose projection
129+
// pins the column order. See #3403.
130+
private def infixSafeForRawPassthrough(infix: Infix): Boolean =
131+
infix.quat match {
132+
case p: Quat.Product => isTupleQuat(p)
133+
case _ => true
134+
}
135+
136+
private def isTupleQuat(p: Quat.Product): Boolean =
137+
p.name.matches("Tuple[0-9]+") &&
138+
p.fields.keysIterator.zipWithIndex.forall { case (f, i) => f == s"_${i + 1}" }
139+
122140
private def build(query: Ast, isTopLevel: Boolean = false): SqlQuery =
123141
query match {
124142
case Union(a, b) =>
@@ -155,7 +173,7 @@ class SqlQueryApply(traceConfig: TraceConfig, allowTopLevelInfix: Boolean = true
155173
flatten(infix, "x")
156174
}
157175
case infix: Infix =>
158-
if (allowTopLevelInfix && isTopLevel)
176+
if (allowTopLevelInfix && isTopLevel && infixSafeForRawPassthrough(infix))
159177
TopInfixQuery(infix)
160178
else
161179
trace"Construct SqlQuery from: Infix" andReturn {

quill-sql-test/src/test/scala/io/getquill/context/sql/SqlQuerySpec.scala

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,24 @@ class SqlQuerySpec extends Spec {
378378
testContext.run(q).string mustEqual
379379
"""SELECT x.* FROM (SELECT foo, bar FROM baz) AS x"""
380380
}
381+
// Case classes decode positionally in field-declaration order, so raw
382+
// passthrough would mis-decode whenever the raw SQL's column order differs
383+
// (e.g. SELECT * returning table-DDL order). They keep the wrapping query
384+
// whose projection pins the column order. See #3403.
385+
"using a case class - wrapped to pin column order" in {
386+
val q = quote {
387+
sql"""SELECT * FROM TestEntity""".as[Query[TestEntity]]
388+
}
389+
testContext.run(q).string mustEqual
390+
"""SELECT x.s, x.i, x.l, x.o, x.b FROM (SELECT * FROM TestEntity) AS x"""
391+
}
392+
"using a case class - pure is also wrapped" in {
393+
val q = quote {
394+
sql"""SELECT * FROM TestEntity""".pure.as[Query[TestEntity]]
395+
}
396+
testContext.run(q).string mustEqual
397+
"""SELECT x.s, x.i, x.l, x.o, x.b FROM (SELECT * FROM TestEntity) AS x"""
398+
}
381399
}
382400

383401
"nested infix query" - {

0 commit comments

Comments
 (0)