Fold schema-qualified table names to bare identifiers in alias position#871
Open
phdoerfler wants to merge 6 commits into
Open
Fold schema-qualified table names to bare identifiers in alias position#871phdoerfler wants to merge 6 commits into
phdoerfler wants to merge 6 commits into
Conversation
phdoerfler
marked this pull request as ready for review
July 14, 2026 11:23
milessabin
requested changes
Jul 17, 2026
milessabin
left a comment
Member
There was a problem hiding this comment.
I think the "deeper fix" described in the design note would be the right way to go.
Fixes typelevel#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.
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.
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).
encapsulateUnionBranch seeded its subquery name with the table name verbatim, the MSSQL sibling of the alias-position leaks fixed in sql-core (issue typelevel#342); fold it with TableName.asIdentifier.
phdoerfler
force-pushed
the
fix/issue-342
branch
from
July 17, 2026 13:23
f69689b to
73fb2be
Compare
Contributor
Author
|
Fair enough. I'll just close this PR then. I figured it might be worth having this superficial fix in the meantime so the user does not get an unexpected 500 but I do also think a more thorough fix is the way to go ultimately. |
Contributor
Author
|
nvm my last comment. I confused something. Yes, I also think an own |
phdoerfler
force-pushed
the
fix/issue-342
branch
from
July 19, 2026 11:45
b017913 to
6319d29
Compare
…capsulation Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X36kAoXf4877eSJgWBVen1
phdoerfler
force-pushed
the
fix/issue-342
branch
from
July 19, 2026 12:39
6319d29 to
ee52680
Compare
milessabin
requested changes
Jul 20, 2026
milessabin
left a comment
Member
There was a problem hiding this comment.
Aside from a compilation warning ... LGTM!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note: The current check failure is due to the usual
Your tlBaseVersion 0.28 is behind the latest tag 0.29.0.As with #867, I put Claude to this one. Same caveat applies — I've reviewed what I could but I'm not deep enough into the aliasing internals to independently vouch for correctness. That said, I have prodded and poked the AI whenever I saw something potentially suspicious such as the string manipulation code until I was satisfied. Ultimately, the fix is small, targeted, and every claim below is backed by a reproducible test. Here's the PR text Claude proposed:
Fixes #342.
Symptom
A mapping over a schema-qualified table name (e.g.
public.country) that participates in a recursive relationship (Country → City → Country) fails with a cryptic Postgres error:Root cause
Grackle treats a table's SQL name as a single opaque string used in three different roles: the SQL reference (
public.country, correctly dotted), the alias it's given when a query revisits the table, and a component of synthesized subquery names. Alias and synthesized-name positions both require a bare identifier — a dot is illegal there. When a table name is schema-qualified, both positions render the qualifier verbatim, e.g.:which Postgres rejects. Four sites in
SqlMapping.scalaseed one of these positions with the raw qualified name: the alias mint,syntheticName(subquery names for shapes that can't merge, e.g. limits), the union path's subquery naming, the associative (_assoc) derived-table alias, and MSSQL'sencapsulateUnionBranchsibling.Fix
A shared
TableName.asIdentifierhelper folds a qualifier's dots to underscores, used at all five sites. For unqualified names the fold is the identity, so existing mappings are unaffected. Derived names (_assoc,_base,_encaps) inherit sanitized inputs since they're built from already-folded names.Collision safety
Folding qualifiers raises an obvious question: can
qualified.country(folded toqualified_country) now collide with an unrelated real table actually namedqualified_country? No —AliasState's alias mint deduplicates against every name it has already seen at render time, regardless of whether that name came from a real table or a folded synthesized one. A dedicated test joins bothqualified.country(recursively aliased) and a realqualified_countrytable into the same statement and asserts it renders correctly.One residual, pre-existing edge case: a table name containing a literal dot inside a quoted identifier (not a schema separator) was already broken before this change and remains out of scope.
Tests
Five tests over a new
SqlQualifiedNamesMapping/SqlQualifiedNamesSuite(testdata/pg/qualified-names.sql), wired into bothdoobie-pgandskunk:PSQLExceptionbefore the fix.syntheticName, which the first fix alone didn't cover._assocderived-table alias.MSSQL's
encapsulateUnionBranchsibling was fixed by inspection (same pattern, no MSSQL-specific test infrastructure in this suite) — flagging for review attention since it's the one change not directly test-covered. Oracle and MSSQL were deliberately not wired into the shared suite itself; their schema models differ enough from Postgres's that the fixture wouldn't transfer directly (explained in a suite comment).All green:
doobiepg249/249,skunkJVM250/250, on both Scala 2.13 and 3.3.7.Design note
The deeper fix would be a structured
TableName(schema, name)instead of one opaque string doing three jobs — that would make "which role needs the bare identifier" a type-level question instead of a per-call-site one. I judged that out of scope for a bug report this size; flagging it as a possible follow-up if maintainers agree the pattern is worth generalizing.