Skip to content

Fold schema-qualified table names to bare identifiers in alias position#871

Open
phdoerfler wants to merge 6 commits into
typelevel:mainfrom
phdoerfler:fix/issue-342
Open

Fold schema-qualified table names to bare identifiers in alias position#871
phdoerfler wants to merge 6 commits into
typelevel:mainfrom
phdoerfler:fix/issue-342

Conversation

@phdoerfler

@phdoerfler phdoerfler commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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:

syntax error at or near "."

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.:

INNER JOIN public.country AS public.country_alias_1

which Postgres rejects. Four sites in SqlMapping.scala seed 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's encapsulateUnionBranch sibling.

Fix

A shared TableName.asIdentifier helper 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 to qualified_country) now collide with an unrelated real table actually named qualified_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 both qualified.country (recursively aliased) and a real qualified_country table 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 both doobie-pg and skunk:

  • The reported reproducer (recursive Country → City → Country), watched fail with the exact PSQLException before the fix.
  • A top-level limit with a child join, and a nested limit — both exercise syntheticName, which the first fix alone didn't cover.
  • An associative-field query, exercising the _assoc derived-table alias.
  • The collision-safety coexistence pin described above.

MSSQL's encapsulateUnionBranch sibling 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: doobiepg 249/249, skunkJVM 250/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.

@phdoerfler
phdoerfler marked this pull request as ready for review July 14, 2026 11:23

@milessabin milessabin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
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.

@phdoerfler phdoerfler closed this Jul 17, 2026
@phdoerfler phdoerfler reopened this Jul 18, 2026
@phdoerfler

Copy link
Copy Markdown
Contributor Author

nvm my last comment. I confused something. Yes, I also think an own TableName type would be great and I think it should be a reasonably sized change.

@milessabin milessabin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside from a compilation warning ... LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQL mapping does not work for recursive queries to DB tables with qualified names

2 participants