From 7dcbda90fa749e03785cafa164e15c7f8a8a4638 Mon Sep 17 00:00:00 2001 From: phdoerfler Date: Mon, 13 Jul 2026 16:20:56 +0200 Subject: [PATCH 1/7] Derive table aliases from the unqualified table name Fixes #342. When a mapping uses schema-qualified table names (e.g. public.country) and a query revisits a table - as any recursive relationship does - the alias machinery minted the alias by appending _alias_N to the full table name, producing SQL like INNER JOIN public.country AS public.country_alias_1 which is invalid: an alias must be a bare identifier. Postgres rejects it with a cryptic 'syntax error at or near "."', surfacing to users as an unexplained 500. Mint the alias from the unqualified part of the name instead, giving INNER JOIN public.country AS country_alias_1 with all column references going through the bare alias. For unqualified names the derivation is the identity, so existing behaviour is unchanged, and alias uniqueness is preserved by the counter regardless of name collisions between schemas. The new SqlQualifiedNamesSuite exercises a recursive query over a schema-qualified pair of tables (Country -> City -> Country) and is wired up for both doobie-pg and skunk, seeded by the new testdata/pg/qualified-names.sql fixture. --- .../src/test/scala/DoobiePgSuites.scala | 4 + .../js-jvm/src/test/scala/SkunkSuites.scala | 4 + .../sql-core/src/main/scala/SqlMapping.scala | 7 +- .../test/scala/SqlQualifiedNamesMapping.scala | 93 +++++++++++++++++++ .../test/scala/SqlQualifiedNamesSuite.scala | 73 +++++++++++++++ testdata/pg/qualified-names.sql | 21 +++++ 6 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala create mode 100644 modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala create mode 100644 testdata/pg/qualified-names.sql diff --git a/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala b/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala index c59868eb..c38e0141 100644 --- a/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala +++ b/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala @@ -209,6 +209,10 @@ final class ProjectionSuite extends DoobiePgDatabaseSuite with SqlProjectionSuit lazy val mapping = new DoobiePgTestMapping(transactor) with SqlProjectionMapping[IO] } +final class QualifiedNamesSuite extends DoobiePgDatabaseSuite with SqlQualifiedNamesSuite { + lazy val mapping = new DoobiePgTestMapping(transactor) with SqlQualifiedNamesMapping[IO] +} + final class RecursiveInterfacesSuite extends DoobiePgDatabaseSuite with SqlRecursiveInterfacesSuite { diff --git a/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala b/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala index 1152f8a6..5a17e9b0 100644 --- a/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala +++ b/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala @@ -214,6 +214,10 @@ final class ProjectionSuite extends SkunkDatabaseSuite with SqlProjectionSuite { lazy val mapping = new SkunkTestMapping(pool) with SqlProjectionMapping[IO] } +final class QualifiedNamesSuite extends SkunkDatabaseSuite with SqlQualifiedNamesSuite { + lazy val mapping = new SkunkTestMapping(pool) with SqlQualifiedNamesMapping[IO] +} + final class RecursiveInterfacesSuite extends SkunkDatabaseSuite with SqlRecursiveInterfacesSuite { diff --git a/modules/sql-core/src/main/scala/SqlMapping.scala b/modules/sql-core/src/main/scala/SqlMapping.scala index 272866fc..08ac2030 100644 --- a/modules/sql-core/src/main/scala/SqlMapping.scala +++ b/modules/sql-core/src/main/scala/SqlMapping.scala @@ -144,7 +144,12 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self case Some(alias) => (this, alias) case None => if (seenTables(table.name)) { - val alias = s"${table.name}_alias_$next" + // Derive the alias from the unqualified table name: an alias must be a bare + // identifier, so a qualified name like "public.country" cannot be used verbatim + // (issue #342). Uniqueness is preserved by the counter, which is shared across + // table and column aliases, so same-named tables in different schemas cannot + // collide. For unqualified names the derivation is the identity. + val alias = s"${table.name.substring(table.name.lastIndexOf('.') + 1)}_alias_$next" val newState = copy( next = next + 1, diff --git a/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala b/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala new file mode 100644 index 00000000..52cc771f --- /dev/null +++ b/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala @@ -0,0 +1,93 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle.sql.test + +import grackle._ +import grackle.Predicate.{Const, Eql} +import grackle.Query.{Binding, Filter, Unique} +import grackle.QueryCompiler.{Elab, SelectElaborator} +import grackle.Value.StringValue +import grackle.syntax._ + +// Mapping over tables with schema-qualified names (issue #342). The City -> Country +// relationship closes a cycle, so a query traversing Country -> City -> Country revisits +// the country table and forces the alias machinery to mint an alias for a qualified name. +trait SqlQualifiedNamesMapping[F[_]] extends SqlTestMapping[F] { + + object country extends TableDef("qualified.country") { + val code = col("code", bpchar(3)) + val name = col("name", text) + } + + object city extends TableDef("qualified.city") { + val id = col("id", int4) + val countrycode = col("countrycode", bpchar(3)) + val name = col("name", text) + } + + val schema = + schema""" + type Query { + country(code: String!): Country + } + type Country { + code: String! + name: String! + cities: [City!]! + } + type City { + name: String! + country: Country! + } + """ + + val QueryType = schema.ref("Query") + val CountryType = schema.ref("Country") + val CityType = schema.ref("City") + + val typeMappings = + List( + ObjectMapping( + tpe = QueryType, + fieldMappings = List( + SqlObject("country") + ) + ), + ObjectMapping( + tpe = CountryType, + fieldMappings = List( + SqlField("code", country.code, key = true), + SqlField("name", country.name), + SqlObject("cities", Join(country.code, city.countrycode)) + ) + ), + ObjectMapping( + tpe = CityType, + fieldMappings = List( + SqlField("id", city.id, key = true, hidden = true), + SqlField("countrycode", city.countrycode, hidden = true), + SqlField("name", city.name), + SqlObject("country", Join(city.countrycode, country.code)) + ) + ) + ) + + override val selectElaborator = SelectElaborator { + case (QueryType, "country", List(Binding("code", StringValue(code)))) => + Elab.transformChild(child => + Unique(Filter(Eql(CountryType / "code", Const(code)), child))) + } +} diff --git a/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala b/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala new file mode 100644 index 00000000..6724b13e --- /dev/null +++ b/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala @@ -0,0 +1,73 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle.sql.test + +import cats.effect.IO +import io.circe.literal._ +import munit.CatsEffectSuite + +import grackle._ +import grackle.test.GraphQLResponseTests.assertWeaklyEqualIO + +// Wired up for doobie-pg and skunk, which share the testdata/pg fixtures; the fix under test +// lives in sql-core so those two backends suffice to pin it. Oracle is omitted because its +// schemas are users (a qualified-name fixture needs dedicated user setup), and MSSQL because +// its fixture init would need equivalent schema plumbing - both can adopt this suite later. +trait SqlQualifiedNamesSuite extends CatsEffectSuite { + def mapping: Mapping[IO] + + test("recursive query against schema-qualified table names (#342)") { + val query = """ + query { + country(code: "CAN") { + name + cities { + name + country { + name + } + } + } + } + """ + + val expected = json""" + { + "data" : { + "country" : { + "name" : "Canada", + "cities" : [ + { + "name" : "Toronto", + "country" : { + "name" : "Canada" + } + }, + { + "name" : "Ottawa", + "country" : { + "name" : "Canada" + } + } + ] + } + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } +} diff --git a/testdata/pg/qualified-names.sql b/testdata/pg/qualified-names.sql new file mode 100644 index 00000000..c64c51d7 --- /dev/null +++ b/testdata/pg/qualified-names.sql @@ -0,0 +1,21 @@ +CREATE SCHEMA qualified; + +CREATE TABLE qualified.country ( + code character(3) NOT NULL PRIMARY KEY, + name text NOT NULL +); + +CREATE TABLE qualified.city ( + id integer NOT NULL PRIMARY KEY, + countrycode character(3) NOT NULL, + name text NOT NULL +); + +INSERT INTO qualified.country (code, name) VALUES +('CAN', 'Canada'), +('DEU', 'Germany'); + +INSERT INTO qualified.city (id, countrycode, name) VALUES +(1, 'CAN', 'Toronto'), +(2, 'CAN', 'Ottawa'), +(3, 'DEU', 'Berlin'); From 56d4ee0b7bee128fd76b9457e90d0685cf5e23db Mon Sep 17 00:00:00 2001 From: phdoerfler Date: Mon, 13 Jul 2026 17:43:16 +0200 Subject: [PATCH 2/7] Sanitize synthesized subquery names for qualified table names The alias-mint fix covered the reported reproducer, but schema-qualified names reach alias position through two further paths: syntheticName concatenates table and join child names verbatim into subquery names (exercised by any query shape that cannot merge its subqueries, e.g. top-level or nested limits), and addFilterOrderByOffsetLimit passes the parent table name directly as a subquery name on the union path. Both rendered e.g. '( SELECT ... ) AS qualified.country_qualified.city_pred' - invalid for the same reason as before. Fold qualifiers with underscores via a shared TableName.asIdentifier helper, now used at all three sites; the derivation remains the identity for unqualified names, so existing mappings are unaffected. Derived names (the '_assoc' and '_base' variants) inherit sanitized inputs. Two new tests pin the previously-failing shapes: a top-level limit with a child join, and a nested limit, both over the schema-qualified fixture. --- .../sql-core/src/main/scala/SqlMapping.scala | 28 +++++--- .../test/scala/SqlQualifiedNamesMapping.scala | 26 +++++-- .../test/scala/SqlQualifiedNamesSuite.scala | 70 +++++++++++++++++++ 3 files changed, 112 insertions(+), 12 deletions(-) diff --git a/modules/sql-core/src/main/scala/SqlMapping.scala b/modules/sql-core/src/main/scala/SqlMapping.scala index 08ac2030..2a8dad22 100644 --- a/modules/sql-core/src/main/scala/SqlMapping.scala +++ b/modules/sql-core/src/main/scala/SqlMapping.scala @@ -69,6 +69,14 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self val rootName = "" val rootTableName = TableName(rootName) def isRoot(table: String): Boolean = table == rootName + + /** + * Yields a name usable as a bare SQL identifier, for aliases and synthesized table names + * derived from `name`. A schema-qualified name like "public.country" is not a legal alias, + * so qualifiers are folded in with underscores (issue #342); unqualified names are + * unchanged. + */ + def asIdentifier(name: String): String = name.replace('.', '_') } class TableDef(name: String) { implicit val tableName: TableName = TableName(name) @@ -144,12 +152,10 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self case Some(alias) => (this, alias) case None => if (seenTables(table.name)) { - // Derive the alias from the unqualified table name: an alias must be a bare - // identifier, so a qualified name like "public.country" cannot be used verbatim - // (issue #342). Uniqueness is preserved by the counter, which is shared across - // table and column aliases, so same-named tables in different schemas cannot - // collide. For unqualified names the derivation is the identity. - val alias = s"${table.name.substring(table.name.lastIndexOf('.') + 1)}_alias_$next" + // An alias must be a bare identifier, so a qualified name like "public.country" + // cannot seed it verbatim (issue #342); uniqueness is preserved by the counter, + // which is shared across table and column aliases. + val alias = s"${TableName.asIdentifier(table.name)}_alias_$next" val newState = copy( next = next + 1, @@ -2351,8 +2357,12 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self * joins */ def syntheticName(suffix: String): String = { + // Synthesized names are used as subquery aliases, so they must be bare identifiers + // even when built from schema-qualified table names (issue #342). val joinNames = joins.map(_.child.name) - (table.name :: joinNames).mkString("_").take(50 - suffix.length) + suffix + TableName + .asIdentifier((table.name :: joinNames).mkString("_")) + .take(50 - suffix.length) + suffix } /** @@ -3373,7 +3383,9 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self for { withFilter0 <- withFilter table <- parentTableForType(context) - sel <- withFilter0.toSubquery(table.name) + // The subquery name lands in alias position, so it must be a bare + // identifier even for a schema-qualified table (issue #342). + sel <- withFilter0.toSubquery(TableName.asIdentifier(table.name)) res <- sel.addFilterOrderByOffsetLimit( None, orderBy, diff --git a/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala b/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala index 52cc771f..120b01b9 100644 --- a/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala +++ b/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala @@ -17,9 +17,9 @@ package grackle.sql.test import grackle._ import grackle.Predicate.{Const, Eql} -import grackle.Query.{Binding, Filter, Unique} +import grackle.Query.{Binding, Filter, Limit, OrderBy, OrderSelection, OrderSelections, Unique} import grackle.QueryCompiler.{Elab, SelectElaborator} -import grackle.Value.StringValue +import grackle.Value.{IntValue, StringValue} import grackle.syntax._ // Mapping over tables with schema-qualified names (issue #342). The City -> Country @@ -42,11 +42,12 @@ trait SqlQualifiedNamesMapping[F[_]] extends SqlTestMapping[F] { schema""" type Query { country(code: String!): Country + countries(limit: Int!): [Country!]! } type Country { code: String! name: String! - cities: [City!]! + cities(limit: Int): [City!]! } type City { name: String! @@ -63,7 +64,8 @@ trait SqlQualifiedNamesMapping[F[_]] extends SqlTestMapping[F] { ObjectMapping( tpe = QueryType, fieldMappings = List( - SqlObject("country") + SqlObject("country"), + SqlObject("countries") ) ), ObjectMapping( @@ -89,5 +91,21 @@ trait SqlQualifiedNamesMapping[F[_]] extends SqlTestMapping[F] { case (QueryType, "country", List(Binding("code", StringValue(code)))) => Elab.transformChild(child => Unique(Filter(Eql(CountryType / "code", Const(code)), child))) + + case (QueryType, "countries", List(Binding("limit", IntValue(limit)))) => + Elab.transformChild(child => + Limit( + limit, + OrderBy(OrderSelections(List(OrderSelection[String](CountryType / "code"))), child))) + + case (CountryType, "cities", List(Binding("limit", limit))) => + Elab.transformChild(child => + limit match { + case IntValue(lim) => + Limit( + lim, + OrderBy(OrderSelections(List(OrderSelection[String](CityType / "name"))), child)) + case _ => child + }) } } diff --git a/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala b/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala index 6724b13e..f4d082b9 100644 --- a/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala +++ b/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala @@ -70,4 +70,74 @@ trait SqlQualifiedNamesSuite extends CatsEffectSuite { assertWeaklyEqualIO(mapping.compileAndRun(query), expected) } + + // Top-level limit over a list with a child join forces the compiler to synthesize named + // subqueries (via syntheticName), a second path on which a schema-qualified table name + // must not leak into alias position. + test("top-level limit over schema-qualified tables (#342)") { + val query = """ + query { + countries(limit: 2) { + name + cities { + name + } + } + } + """ + + val expected = json""" + { + "data" : { + "countries" : [ + { + "name" : "Canada", + "cities" : [ + { "name" : "Toronto" }, + { "name" : "Ottawa" } + ] + }, + { + "name" : "Germany", + "cities" : [ + { "name" : "Berlin" } + ] + } + ] + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } + + // A limit nested below the root exercises the window-function machinery and its + // synthesized subquery names. + test("nested limit over schema-qualified tables (#342)") { + val query = """ + query { + country(code: "CAN") { + name + cities(limit: 1) { + name + } + } + } + """ + + val expected = json""" + { + "data" : { + "country" : { + "name" : "Canada", + "cities" : [ + { "name" : "Ottawa" } + ] + } + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } } From 7c78758b3ec9fa2522d96ad3e5c2f49c1ff1e419 Mon Sep 17 00:00:00 2001 From: phdoerfler Date: Mon, 13 Jul 2026 18:12:35 +0200 Subject: [PATCH 3/7] Fold qualified names in the associative derived table alias Review follow-up completing the alias-position inventory: on the mergeable branch of mkSubquery, base.table is the raw TableRef, so the '_assoc' DerivedTableRef alias was seeded with the schema-qualified name verbatim - reachable through any associative field one plain join away from a qualified table, failing with the same syntax error as the original report. Fold it with TableName.asIdentifier like the other sites. Two new tests: an associative field over the qualified fixture (fails before this change), and a coexistence pin - qualified_country is a real table whose name equals qualified.country with its qualifier folded, joined into the same statement that recursively aliases qualified.country, demonstrating that folded synthesized identifiers cannot collide with identically named real tables (the render-time alias state uniquifies any second occurrence of a seen name). --- .../sql-core/src/main/scala/SqlMapping.scala | 4 +- .../test/scala/SqlQualifiedNamesMapping.scala | 40 +++++++- .../test/scala/SqlQualifiedNamesSuite.scala | 94 +++++++++++++++++++ testdata/pg/qualified-names.sql | 22 +++++ 4 files changed, 158 insertions(+), 2 deletions(-) diff --git a/modules/sql-core/src/main/scala/SqlMapping.scala b/modules/sql-core/src/main/scala/SqlMapping.scala index 2a8dad22..0ddef53b 100644 --- a/modules/sql-core/src/main/scala/SqlMapping.scala +++ b/modules/sql-core/src/main/scala/SqlMapping.scala @@ -2466,9 +2466,11 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self val finalJoin = lastJoin.toSqlJoin(lastJoinParentTable, base.table, inner) finalJoin :: Nil } else { + // On the mergeable branch base.table is the raw TableRef, so its name may be + // schema-qualified and must be folded before use in alias position (#342). val assocTable = TableExpr.DerivedTableRef( context, - Some(base.table.name + "_assoc"), + Some(TableName.asIdentifier(base.table.name) + "_assoc"), base.table, true) val assocJoin = lastJoin.toSqlJoin(lastJoinParentTable, assocTable, inner) diff --git a/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala b/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala index 120b01b9..9ded80fe 100644 --- a/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala +++ b/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala @@ -38,6 +38,18 @@ trait SqlQualifiedNamesMapping[F[_]] extends SqlTestMapping[F] { val name = col("name", text) } + object speaks extends TableDef("qualified.speaks") { + val countrycode = col("countrycode", bpchar(3)) + val lang = col("lang", text) + } + + // Named so that folding qualified.country's qualifier with an underscore yields exactly + // this table's name, pinning that synthesized identifiers and real tables coexist. + object twin extends TableDef("qualified_country") { + val code = col("code", bpchar(3)) + val motto = col("motto", text) + } + val schema = schema""" type Query { @@ -48,16 +60,26 @@ trait SqlQualifiedNamesMapping[F[_]] extends SqlTestMapping[F] { code: String! name: String! cities(limit: Int): [City!]! + languages: [Language!]! + twin: Twin } type City { name: String! country: Country! } + type Language { + language: String! + } + type Twin { + motto: String! + } """ val QueryType = schema.ref("Query") val CountryType = schema.ref("Country") val CityType = schema.ref("City") + val LanguageType = schema.ref("Language") + val TwinType = schema.ref("Twin") val typeMappings = List( @@ -73,7 +95,9 @@ trait SqlQualifiedNamesMapping[F[_]] extends SqlTestMapping[F] { fieldMappings = List( SqlField("code", country.code, key = true), SqlField("name", country.name), - SqlObject("cities", Join(country.code, city.countrycode)) + SqlObject("cities", Join(country.code, city.countrycode)), + SqlObject("languages", Join(country.code, speaks.countrycode)), + SqlObject("twin", Join(country.code, twin.code)) ) ), ObjectMapping( @@ -84,6 +108,20 @@ trait SqlQualifiedNamesMapping[F[_]] extends SqlTestMapping[F] { SqlField("name", city.name), SqlObject("country", Join(city.countrycode, country.code)) ) + ), + ObjectMapping( + tpe = LanguageType, + fieldMappings = List( + SqlField("language", speaks.lang, key = true, associative = true), + SqlField("countrycode", speaks.countrycode, hidden = true) + ) + ), + ObjectMapping( + tpe = TwinType, + fieldMappings = List( + SqlField("code", twin.code, key = true, hidden = true), + SqlField("motto", twin.motto) + ) ) ) diff --git a/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala b/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala index f4d082b9..e2584c6b 100644 --- a/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala +++ b/modules/sql-core/src/test/scala/SqlQualifiedNamesSuite.scala @@ -140,4 +140,98 @@ trait SqlQualifiedNamesSuite extends CatsEffectSuite { assertWeaklyEqualIO(mapping.compileAndRun(query), expected) } + + // An associative child reached through a single mergeable join takes the DerivedTableRef + // "_assoc" path, whose alias is derived from the raw table name - a further place a + // schema-qualified name must not leak into alias position. + test("associative field over schema-qualified tables (#342)") { + val query = """ + query { + country(code: "CAN") { + name + languages { + language + } + } + } + """ + + val expected = json""" + { + "data" : { + "country" : { + "name" : "Canada", + "languages" : [ + { "language" : "English" }, + { "language" : "French" } + ] + } + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } + + // qualified_country is a real table whose name equals qualified.country with its qualifier + // folded by an underscore. Recursing through qualified.country while joining + // qualified_country in the same statement pins that folded synthesized identifiers and + // identically-named real tables coexist (the render-time alias state uniquifies any + // second occurrence of an already-seen name). + test("folded qualified name coexists with an identically named table (#342)") { + val query = """ + query { + country(code: "CAN") { + name + twin { + motto + } + cities { + name + country { + name + twin { + motto + } + } + } + } + } + """ + + val expected = json""" + { + "data" : { + "country" : { + "name" : "Canada", + "twin" : { + "motto" : "A mari usque ad mare" + }, + "cities" : [ + { + "name" : "Toronto", + "country" : { + "name" : "Canada", + "twin" : { + "motto" : "A mari usque ad mare" + } + } + }, + { + "name" : "Ottawa", + "country" : { + "name" : "Canada", + "twin" : { + "motto" : "A mari usque ad mare" + } + } + } + ] + } + } + } + """ + + assertWeaklyEqualIO(mapping.compileAndRun(query), expected) + } } diff --git a/testdata/pg/qualified-names.sql b/testdata/pg/qualified-names.sql index c64c51d7..e5dfb965 100644 --- a/testdata/pg/qualified-names.sql +++ b/testdata/pg/qualified-names.sql @@ -11,6 +11,19 @@ CREATE TABLE qualified.city ( name text NOT NULL ); +CREATE TABLE qualified.speaks ( + countrycode character(3) NOT NULL, + lang text NOT NULL, + PRIMARY KEY (countrycode, lang) +); + +-- Deliberately named so that folding the qualifier of qualified.country with an underscore +-- yields this table's name: pins that synthesized aliases and real tables can coexist. +CREATE TABLE qualified_country ( + code character(3) NOT NULL PRIMARY KEY, + motto text NOT NULL +); + INSERT INTO qualified.country (code, name) VALUES ('CAN', 'Canada'), ('DEU', 'Germany'); @@ -19,3 +32,12 @@ INSERT INTO qualified.city (id, countrycode, name) VALUES (1, 'CAN', 'Toronto'), (2, 'CAN', 'Ottawa'), (3, 'DEU', 'Berlin'); + +INSERT INTO qualified.speaks (countrycode, lang) VALUES +('CAN', 'English'), +('CAN', 'French'), +('DEU', 'German'); + +INSERT INTO qualified_country (code, motto) VALUES +('CAN', 'A mari usque ad mare'), +('DEU', 'Einigkeit und Recht und Freiheit'); From 73fb2bef2aeedf8f944c5a62a582a07b75e8c185 Mon Sep 17 00:00:00 2001 From: phdoerfler Date: Mon, 13 Jul 2026 18:12:37 +0200 Subject: [PATCH 4/7] Fold qualified names in MSSQL union branch encapsulation encapsulateUnionBranch seeded its subquery name with the table name verbatim, the MSSQL sibling of the alias-position leaks fixed in sql-core (issue #342); fold it with TableName.asIdentifier. --- modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala b/modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala index 0756d76b..752ca99e 100644 --- a/modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala +++ b/modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala @@ -88,7 +88,10 @@ trait DoobieMSSqlMappingLike[F[_]] extends DoobieMappingLike[F] with SqlMappingL def encapsulateUnionBranch(s: SqlSelect): SqlSelect = if (s.orders.isEmpty) s - else s.toSubquery(s.table.name + "_encaps", Laterality.NotLateral) + else + // The subquery name lands in alias position, so a schema-qualified table name must be + // folded to a bare identifier first (issue #342). + s.toSubquery(TableName.asIdentifier(s.table.name) + "_encaps", Laterality.NotLateral) def mkLateral(inner: Boolean): Laterality = Laterality.Apply(inner) From 79be4f55d3fb6ed0518f3a8f963e8ae215d3f1d6 Mon Sep 17 00:00:00 2001 From: phdoerfler Date: Sun, 19 Jul 2026 05:46:56 +0200 Subject: [PATCH 5/7] Replace TableName.asIdentifier string-fold with a structured TableName --- .../src/main/scala/DoobieMapping.scala | 2 +- .../src/test/scala/DoobiePgSuites.scala | 4 + .../js-jvm/src/test/scala/SkunkSuites.scala | 4 + .../shared/src/main/scala/SkunkMapping.scala | 2 +- .../sql-core/src/main/scala/SqlMapping.scala | 96 +++++++++++++------ .../SqlMappingValidatorInvalidSuite.scala | 6 +- .../src/test/scala/SqlTestMapping.scala | 2 +- .../src/test/scala/TableNameSuite.scala | 57 +++++++++++ 8 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 modules/sql-core/src/test/scala/TableNameSuite.scala diff --git a/modules/doobie-core/src/main/scala/DoobieMapping.scala b/modules/doobie-core/src/main/scala/DoobieMapping.scala index 597d0e38..704ba092 100644 --- a/modules/doobie-core/src/main/scala/DoobieMapping.scala +++ b/modules/doobie-core/src/main/scala/DoobieMapping.scala @@ -58,7 +58,7 @@ trait DoobieMappingLike[F[_]] extends Mapping[F] with SqlMappingLike[F] { implicit tableName: TableName, typeName: TypeName[T], pos: SourcePos): ColumnRef = - ColumnRef(tableName.name, colName, (codec, nullable), typeName.value, pos) + ColumnRef(tableName, colName, (codec, nullable), typeName.value, pos) implicit def Fragments: SqlFragment[Fragment] = new SqlFragment[Fragment] { diff --git a/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala b/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala index c38e0141..35e6c254 100644 --- a/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala +++ b/modules/doobie-pg/src/test/scala/DoobiePgSuites.scala @@ -213,6 +213,10 @@ final class QualifiedNamesSuite extends DoobiePgDatabaseSuite with SqlQualifiedN lazy val mapping = new DoobiePgTestMapping(transactor) with SqlQualifiedNamesMapping[IO] } +final class TableNameSuite extends DoobiePgDatabaseSuite with SqlTableNameSuite { + lazy val mapping = new DoobiePgTestMapping(transactor) with SqlQualifiedNamesMapping[IO] +} + final class RecursiveInterfacesSuite extends DoobiePgDatabaseSuite with SqlRecursiveInterfacesSuite { diff --git a/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala b/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala index 5a17e9b0..ebfaa2c2 100644 --- a/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala +++ b/modules/skunk/js-jvm/src/test/scala/SkunkSuites.scala @@ -218,6 +218,10 @@ final class QualifiedNamesSuite extends SkunkDatabaseSuite with SqlQualifiedName lazy val mapping = new SkunkTestMapping(pool) with SqlQualifiedNamesMapping[IO] } +final class TableNameSuite extends SkunkDatabaseSuite with SqlTableNameSuite { + lazy val mapping = new SkunkTestMapping(pool) with SqlQualifiedNamesMapping[IO] +} + final class RecursiveInterfacesSuite extends SkunkDatabaseSuite with SqlRecursiveInterfacesSuite { diff --git a/modules/skunk/shared/src/main/scala/SkunkMapping.scala b/modules/skunk/shared/src/main/scala/SkunkMapping.scala index 7463f8fc..c37db74b 100644 --- a/modules/skunk/shared/src/main/scala/SkunkMapping.scala +++ b/modules/skunk/shared/src/main/scala/SkunkMapping.scala @@ -90,7 +90,7 @@ trait SkunkMappingLike[F[_]] extends Mapping[F] with SqlPgMappingLike[F] { outer typeName: NullableTypeName[T], isNullable: IsNullable[T], pos: SourcePos): ColumnRef = - ColumnRef(tableName.name, colName, (codec, isNullable.isNullable), typeName.value, pos) + ColumnRef(tableName, colName, (codec, isNullable.isNullable), typeName.value, pos) // We need to demonstrate that our `Fragment` type has certain compositional properties. implicit def Fragments: SqlFragment[AppliedFragment] = diff --git a/modules/sql-core/src/main/scala/SqlMapping.scala b/modules/sql-core/src/main/scala/SqlMapping.scala index 0ddef53b..ececbc06 100644 --- a/modules/sql-core/src/main/scala/SqlMapping.scala +++ b/modules/sql-core/src/main/scala/SqlMapping.scala @@ -64,19 +64,33 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self def orderToFragment(col: Fragment, ascending: Boolean, nullsLast: Boolean): Fragment def nullsHigh: Boolean - case class TableName(name: String) + /** + * The name of a SQL table, split into an optional schema qualifier and a local name. + * + * A table's raw SQL name plays two distinct roles depending on where it's used: a reference + * (`sqlRef`, schema-qualified, e.g. "public.country" — legal in a FROM/JOIN clause) and a + * bare identifier (`identifier`, e.g. "public_country" — required wherever an alias or + * synthesized name is minted, since a dot is not a legal identifier character). Keeping both + * derived from one structured value means a call site that needs the identifier form reaches + * for `.identifier` and can't accidentally reach for the raw, possibly-dotted `.sqlRef` + * instead (issue #342). + */ + case class TableName(schema: Option[String], name: String) { + def sqlRef: String = schema.fold(name)(s => s"$s.$name") + def identifier: String = schema.fold(name)(s => s"${s.replace('.', '_')}_$name") + override def toString: String = sqlRef + } object TableName { + def apply(raw: String): TableName = + raw.lastIndexOf('.') match { + case -1 => TableName(None, raw) + case i => + val (schema, dotName) = raw.splitAt(i) + TableName(Some(schema), dotName.tail) + } val rootName = "" - val rootTableName = TableName(rootName) - def isRoot(table: String): Boolean = table == rootName - - /** - * Yields a name usable as a bare SQL identifier, for aliases and synthesized table names - * derived from `name`. A schema-qualified name like "public.country" is not a legal alias, - * so qualifiers are folded in with underscores (issue #342); unqualified names are - * unchanged. - */ - def asIdentifier(name: String): String = name.replace('.', '_') + val rootTableName = TableName(None, rootName) + def isRoot(table: TableName): Boolean = table == rootTableName } class TableDef(name: String) { implicit val tableName: TableName = TableName(name) @@ -95,7 +109,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self * used to construct `SqlColumns`. */ case class ColumnRef( - table: String, + table: TableName, column: String, codec: Codec, scalaTypeName: String, @@ -155,7 +169,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self // An alias must be a bare identifier, so a qualified name like "public.country" // cannot seed it verbatim (issue #342); uniqueness is preserved by the counter, // which is shared across table and column aliases. - val alias = s"${TableName.asIdentifier(table.name)}_alias_$next" + val alias = s"${table.identifier}_alias_$next" val newState = copy( next = next + 1, @@ -1319,6 +1333,12 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self */ def name: String + /** + * A bare-identifier-safe form of this `TableExpr`'s name, for use in alias and synthesized + * subquery name positions where a dot is not legal (issue #342). + */ + def identifier: String + /** * Is the supplied column an immediate component of this `TableExpr`? */ @@ -1365,14 +1385,17 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self /** * Table expression corresponding to a possibly aliased table */ - case class TableRef(context: Context, name: String) extends TableExpr { + case class TableRef(context: Context, tableName: TableName) extends TableExpr { + def name: String = tableName.sqlRef + def identifier: String = tableName.identifier + def owns(col: SqlColumn): Boolean = isSameOwner(col.owner) def contains(other: ColumnOwner): Boolean = isSameOwner(other) def findNamedOwner(col: SqlColumn): Option[TableExpr] = if (this == col.owner) Some(this) else None - def isRoot: Boolean = TableName.isRoot(name) + def isRoot: Boolean = TableName.isRoot(tableName) def isUnion: Boolean = false @@ -1440,6 +1463,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self if (this == col.owner) Some(this) else subquery.findNamedOwner(col) def isRoot: Boolean = false + def identifier: String = name def isUnion: Boolean = subquery.isUnion @@ -1474,6 +1498,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self if (this == col.owner) Some(this) else withQuery.findNamedOwner(col) def isRoot: Boolean = false + def identifier: String = name def isUnion: Boolean = withQuery.isUnion @@ -1504,6 +1529,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self assert(!underlying.isInstanceOf[WithRef] || noalias) def name = alias.getOrElse(underlying.name) + def identifier: String = alias.getOrElse(underlying.identifier) def owns(col: SqlColumn): Boolean = col.owner.isSameOwner(this) || underlying.owns(col) def contains(other: ColumnOwner): Boolean = @@ -2308,7 +2334,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self def isDistinct: Boolean = distinct.nonEmpty override def isSameOwner(other: ColumnOwner): Boolean = - other.isSameOwner(TableRef(context, table.name)) + other.isSameOwner(TableRef(context, TableName(table.name))) def owns(col: SqlColumn): Boolean = cols.contains(col) || owns0(col) def contains(other: ColumnOwner): Boolean = @@ -2359,10 +2385,8 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self def syntheticName(suffix: String): String = { // Synthesized names are used as subquery aliases, so they must be bare identifiers // even when built from schema-qualified table names (issue #342). - val joinNames = joins.map(_.child.name) - TableName - .asIdentifier((table.name :: joinNames).mkString("_")) - .take(50 - suffix.length) + suffix + val joinNames = joins.map(_.child.identifier) + (table.identifier :: joinNames).mkString("_").take(50 - suffix.length) + suffix } /** @@ -2470,7 +2494,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self // schema-qualified and must be folded before use in alias position (#342). val assocTable = TableExpr.DerivedTableRef( context, - Some(TableName.asIdentifier(base.table.name) + "_assoc"), + Some(base.table.identifier + "_assoc"), base.table, true) val assocJoin = lastJoin.toSqlJoin(lastJoinParentTable, assocTable, inner) @@ -3387,7 +3411,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self table <- parentTableForType(context) // The subquery name lands in alias position, so it must be a bare // identifier even for a schema-qualified table (issue #342). - sel <- withFilter0.toSubquery(TableName.asIdentifier(table.name)) + sel <- withFilter0.toSubquery(table.identifier) res <- sel.addFilterOrderByOffsetLimit( None, orderBy, @@ -4646,7 +4670,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self val tables = allTables(List(om)) val split = tables.sizeCompare(1) > 0 if (!split) Nil - else List(SplitObjectTypeMapping(om, tables)) + else List(SplitObjectTypeMapping(om, tables.map(_.sqlRef))) } def checkSuperInterfaces(om: ObjectMapping): List[ValidationFailure] = { @@ -4658,7 +4682,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self val tables = allTables(allMappings) val split = tables.sizeCompare(1) > 0 if (!split) Nil - else List(SplitInterfaceTypeMapping(om, allMappings, tables)) + else List(SplitInterfaceTypeMapping(om, allMappings, tables.map(_.sqlRef))) } def checkUnionMembers(om: ObjectMapping): List[ValidationFailure] = { @@ -4668,7 +4692,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self val tables = allTables(allMappings) val split = tables.sizeCompare(1) > 0 if (!split) Nil - else List(SplitUnionTypeMapping(om, allMappings, tables)) + else List(SplitUnionTypeMapping(om, allMappings, tables.map(_.sqlRef))) case _ => Nil } @@ -4793,7 +4817,14 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self } yield { val childTables = allTables(List(com)) if (parentTables.sameElements(childTables)) Nil - else List(SplitEmbeddedObjectTypeMapping(om, fm, com, parentTables, childTables)) + else + List( + SplitEmbeddedObjectTypeMapping( + om, + fm, + com, + parentTables.map(_.sqlRef), + childTables.map(_.sqlRef))) }).getOrElse(Nil) } @@ -4829,8 +4860,8 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self InconsistentJoinConditions( om, fm, - j.conditions.map(_._1.table).distinct, - j.conditions.map(_._2.table).distinct) + j.conditions.map(_._1.table.sqlRef).distinct, + j.conditions.map(_._2.table.sqlRef).distinct) } val serConsistent = { @@ -4850,9 +4881,12 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self if (headIsParent && lastIsChild && consistentChain) Nil else { val path = nonEmptyJoins.map(j => - (j.conditions.head._1.table, j.conditions.last._2.table)) + ( + j.conditions.head._1.table.sqlRef, + j.conditions.last._2.table.sqlRef)) - List(MisalignedJoins(om, fm, parentTable, childTable, path)) + List( + MisalignedJoins(om, fm, parentTable.sqlRef, childTable.sqlRef, path)) } } } @@ -4868,7 +4902,7 @@ trait SqlMappingLike[F[_]] extends CirceMappingLike[F] with SqlModule[F] { self } } - private def allTables(oms: List[ObjectMapping]): List[String] = + private def allTables(oms: List[ObjectMapping]): List[TableName] = oms .flatMap(_.fieldMappings.flatMap { case SqlField(_, columnRef, _, _, _, _) => List(columnRef.table) diff --git a/modules/sql-core/src/test/scala/SqlMappingValidatorInvalidSuite.scala b/modules/sql-core/src/test/scala/SqlMappingValidatorInvalidSuite.scala index ce1c539d..4fec8bc9 100644 --- a/modules/sql-core/src/test/scala/SqlMappingValidatorInvalidSuite.scala +++ b/modules/sql-core/src/test/scala/SqlMappingValidatorInvalidSuite.scala @@ -46,7 +46,7 @@ trait SqlMappingValidatorInvalidSuite extends CatsEffectSuite { fm.fieldName, SchemaRenderer.renderType(field.tpe), field.tpe.isNullable, - columnRef.table, + columnRef.table.sqlRef, columnRef.column, colIsNullable)) case _ => None @@ -63,7 +63,7 @@ trait SqlMappingValidatorInvalidSuite extends CatsEffectSuite { om.tpe.name, fm.fieldName, SchemaRenderer.renderType(field.tpe), - columnRef.table, + columnRef.table.sqlRef, columnRef.column, columnRef.scalaTypeName)) case _ => None @@ -80,7 +80,7 @@ trait SqlMappingValidatorInvalidSuite extends CatsEffectSuite { om.tpe.name, fm.fieldName, SchemaRenderer.renderType(field.tpe), - columnRef.table, + columnRef.table.sqlRef, columnRef.column, columnRef.scalaTypeName)) case _ => None diff --git a/modules/sql-core/src/test/scala/SqlTestMapping.scala b/modules/sql-core/src/test/scala/SqlTestMapping.scala index 15e47246..b0cc3f59 100644 --- a/modules/sql-core/src/test/scala/SqlTestMapping.scala +++ b/modules/sql-core/src/test/scala/SqlTestMapping.scala @@ -54,5 +54,5 @@ trait SqlTestMapping[F[_]] extends SqlMappingLike[F] { outer => implicit tableName: TableName, typeName: TypeName[T], pos: SourcePos): ColumnRef = - ColumnRef(tableName.name, colName, codec, typeName.value, pos) + ColumnRef(tableName, colName, codec, typeName.value, pos) } diff --git a/modules/sql-core/src/test/scala/TableNameSuite.scala b/modules/sql-core/src/test/scala/TableNameSuite.scala new file mode 100644 index 00000000..5b42a750 --- /dev/null +++ b/modules/sql-core/src/test/scala/TableNameSuite.scala @@ -0,0 +1,57 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// Copyright (c) 2016-2025 Grackle Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grackle.sql.test + +import cats.effect.IO +import munit.CatsEffectSuite + +import grackle.sql._ + +// TableName is a path-dependent member of SqlMappingLike[F[_]], not a free-standing top-level +// type, so it needs a concrete mapping instance to reach - any SqlMappingLike[IO] will do, since +// these tests never touch the mapping's own fields or run a query. +trait SqlTableNameSuite extends CatsEffectSuite { + def mapping: SqlMappingLike[IO] + + lazy val M = mapping + + test("unqualified name has no schema") { + val tn = M.TableName("country") + assertEquals(tn.schema, None) + assertEquals(tn.name, "country") + } + + test("schema-qualified name splits on the last dot") { + val tn = M.TableName("public.country") + assertEquals(tn.schema, Some("public")) + assertEquals(tn.name, "country") + } + + test("sqlRef renders the qualified reference, dots intact") { + assertEquals(M.TableName("public.country").sqlRef, "public.country") + assertEquals(M.TableName("country").sqlRef, "country") + } + + test("identifier folds the qualifier to a bare-identifier-safe form") { + assertEquals(M.TableName("public.country").identifier, "public_country") + assertEquals(M.TableName("country").identifier, "country") + } + + test("toString matches sqlRef") { + val tn = M.TableName("public.country") + assertEquals(tn.toString, tn.sqlRef) + } +} From ee52680ed3e5f24edab9d8960ceaf22fd7d97da0 Mon Sep 17 00:00:00 2001 From: phdoerfler Date: Sun, 19 Jul 2026 06:01:12 +0200 Subject: [PATCH 6/7] Fold qualified table names via TableExpr.identifier in MSSQL union encapsulation Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01X36kAoXf4877eSJgWBVen1 --- modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala b/modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala index 752ca99e..abed6a6e 100644 --- a/modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala +++ b/modules/doobie-mssql/src/main/scala/DoobieMSSqlMapping.scala @@ -91,7 +91,7 @@ trait DoobieMSSqlMappingLike[F[_]] extends DoobieMappingLike[F] with SqlMappingL else // The subquery name lands in alias position, so a schema-qualified table name must be // folded to a bare identifier first (issue #342). - s.toSubquery(TableName.asIdentifier(s.table.name) + "_encaps", Laterality.NotLateral) + s.toSubquery(s.table.identifier + "_encaps", Laterality.NotLateral) def mkLateral(inner: Boolean): Laterality = Laterality.Apply(inner) From 80fe03e497e779fd2253664866196217aa0fd1c0 Mon Sep 17 00:00:00 2001 From: phdoerfler Date: Wed, 29 Jul 2026 03:08:11 +0200 Subject: [PATCH 7/7] Drop unused wildcard import in SqlQualifiedNamesMapping Leftover from the TableName refactor; -Xfatal-warnings turns the unused-import warning into a CI compile failure. --- modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala b/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala index 9ded80fe..78a5d7b5 100644 --- a/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala +++ b/modules/sql-core/src/test/scala/SqlQualifiedNamesMapping.scala @@ -15,7 +15,6 @@ package grackle.sql.test -import grackle._ import grackle.Predicate.{Const, Eql} import grackle.Query.{Binding, Filter, Limit, OrderBy, OrderSelection, OrderSelections, Unique} import grackle.QueryCompiler.{Elab, SelectElaborator}