Summary: Fix a relation going missing when a CTE shares its name - #367
Merged
seanlinsley merged 1 commit intoSep 23, 2026
Merged
seanlinsley merged 1 commit into
seanlinsley merged 1 commit into
Conversation
WalkState.cte_names is a single flat set for the whole statement, and handle_range_var treated every range var whose relname matched one of those names as a CTE reference. That is too eager in three cases: - A non-recursive CTE is not visible inside its own definition, so in "WITH users AS (SELECT * FROM users) SELECT * FROM users" the inner reference is the users table, and only the outer one is the CTE. Both were skipped, so the relation was lost entirely. - Only relname was compared, so a schema qualified reference was skipped as well: "WITH t AS (SELECT * FROM s.t) SELECT 1" reported no tables. - A CTE cannot be the target of INSERT/UPDATE/DELETE/MERGE or DDL, so those always name a real relation, even when a CTE shares the name. Here we resolve CTE references with the scope they appear in. The WITH clause of a statement is now handled before descending into it, which also makes the CTE names of enclosing scopes known by the time a nested WITH clause is reached, and the range vars inside a CTE's own definition that carry the CTE's own name are recorded so that only those are skipped. Recording is by RangeVar pointer identity, so synthesized range vars sharing location -1 cannot collide. WITH RECURSIVE self-references, and references to a CTE that is already visible from an enclosing scope, keep resolving to the CTE. In passing, is_cte_reference now gathers that decision in one place. The order of summary->tables and summary->cte_names is unchanged. The same bug exists in the Ruby implementation of #tables: pganalyze/pg_query#355
aurelienbottazini
marked this pull request as ready for review
September 17, 2026 09:57
aurelienbottazini
marked this pull request as draft
September 17, 2026 09:57
aurelienbottazini
marked this pull request as ready for review
September 17, 2026 09:58
Member
|
Thanks! I've asked @duckinator to take a look at this. |
duckinator
approved these changes
Sep 22, 2026
duckinator
left a comment
Contributor
There was a problem hiding this comment.
This looks good to me. I'm not surprised there were a few bugs lurking in the CTE handling; it had a lot of edge cases.
seanlinsley
approved these changes
Sep 23, 2026
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.
Related PR on Ruby gem: pganalyze/pg_query#355
pg_query_summary()drops a relation when a CTE shares its name:WalkState.cte_namesis a single flat set for the whole statement, andhandle_range_var()treated every range var whoserelnamematched one of thosenames as a CTE reference. Three separate cases fall out of that:
WITH users AS (SELECT * FROM users) SELECT * FROM usersthe inner referenceis the
userstable and only the outer one is the CTE. Both were skipped.relnamewas compared, so a schema qualified reference was skipped too(the first example above).
INSERT/UPDATE/DELETE/MERGEor DDL, sothose always name a real relation.
WITH users AS (...) UPDATE users SET ...reported no target table.
This is easy to hit from ORMs that name a CTE after the model's own table, and it
matters for anything using the table list for query attribution, read/write
routing or cache invalidation, because the relation goes missing silently.
Fix
handle_with_clause()runs before descending into the statement, so the CTE names of enclosing scopes areknown by the time a nested
WITHclause is reached.own name (
cte_self_reference_walker()), andhandle_range_var()skips onlythose. The scan is skipped for
WITH RECURSIVE(which does make a CTE visible to itself) and when the name isalready a visible CTE from an enclosing scope, e.g. the inner
ainWITH a AS (...) SELECT * FROM (WITH a AS (SELECT * FROM a) ...) s.is_cte_reference()now gathers the whole decision:CONTEXT_SELECTonly,unqualified only, name in
cte_names, and not a recorded self-reference.Range vars are still resolved after the tree walk, and the order of
summary->tablesandsummary->cte_namesis unchanged, so no existingexpectations move.
Tests
13 cases in
test/summary_tests.c(test/summary_tests_list.cregenerated withscripts/update_summary_tests_list.rb)Against unpatched
src/, 9 of them fail (20 assertions). With the fix,make testis greenPerformance
Measured through the Ruby binding, with these files dropped into pg_query's
ext/pg_query/andPgQuery.summarycalled 20k times per query:SELECT * FROM companies WHERE id = 1WITH x AS (SELECT * FROM companies) SELECT * FROM xWITH companies AS (SELECT * FROM companies) SELECT * FROM companiesSELECT * FROM t0+ 100 joinsStatements without a
WITHclause are unaffected; statements with one pay asingle extra pass over the CTE definitions.
Notes / open questions
No
CHANGELOG.mdentry here, matching recent fix PRs on this branch. Glad toadd one wherever you'd like it.