Skip to content

Summary: Fix a relation going missing when a CTE shares its name - #367

Merged
seanlinsley merged 1 commit into
pganalyze:18-latestfrom
aurelienbottazini:fix-summary-cte-with-same-name
Sep 23, 2026
Merged

seanlinsley merged 1 commit into
pganalyze:18-latestfrom
aurelienbottazini:fix-summary-cte-with-same-name

Conversation

@aurelienbottazini

@aurelienbottazini aurelienbottazini commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Related PR on Ruby gem: pganalyze/pg_query#355

pg_query_summary() drops a relation when a CTE shares its name:

"WITH t AS (SELECT * FROM s.t) SELECT 1"   /* tables: []   expected: s.t */
"WITH t AS (SELECT * FROM t) SELECT 1"     /* tables: []   expected: t   */

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. Three separate cases fall out of that:

  1. 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.
  2. Only relname was compared, so a schema qualified reference was skipped too
    (the first example above).
  3. A CTE cannot be the target of INSERT/UPDATE/DELETE/MERGE or DDL, so
    those 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 are
    known by the time a nested WITH clause is reached.
  • It records the range vars inside each CTE's own definition that carry the CTE's
    own name (cte_self_reference_walker()), and handle_range_var() skips only
    those. The scan is skipped forWITH RECURSIVE (which does make a CTE visible to itself) and when the name is
    already a visible CTE from an enclosing scope, e.g. the inner a in
    WITH a AS (...) SELECT * FROM (WITH a AS (SELECT * FROM a) ...) s.
  • is_cte_reference() now gathers the whole decision: CONTEXT_SELECT only,
    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->tables and summary->cte_names is unchanged, so no existing
expectations move.

Tests

13 cases in test/summary_tests.c (test/summary_tests_list.c regenerated with
scripts/update_summary_tests_list.rb)

Against unpatched src/, 9 of them fail (20 assertions). With the fix,
make test is green

Performance

Measured through the Ruby binding, with these files dropped into pg_query's
ext/pg_query/ and PgQuery.summary called 20k times per query:

query before after
SELECT * FROM companies WHERE id = 1 1.91us 1.89us
WITH x AS (SELECT * FROM companies) SELECT * FROM x 2.08us 2.14us
WITH companies AS (SELECT * FROM companies) SELECT * FROM companies 1.90us 2.07us
3-CTE query with joins 7.23us 7.78us
SELECT * FROM t0 + 100 joins 36.0us 37.1us

Statements without a WITH clause are unaffected; statements with one pay a
single extra pass over the CTE definitions.

Notes / open questions

No CHANGELOG.md entry here, matching recent fix PRs on this branch. Glad to
add one wherever you'd like it.

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
aurelienbottazini marked this pull request as ready for review September 17, 2026 09:57
@aurelienbottazini
aurelienbottazini marked this pull request as draft September 17, 2026 09:57
@aurelienbottazini
aurelienbottazini marked this pull request as ready for review September 17, 2026 09:58
@lfittl

lfittl commented Sep 17, 2026

Copy link
Copy Markdown
Member

Thanks! I've asked @duckinator to take a look at this.

@duckinator duckinator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
seanlinsley merged commit 47075dd into pganalyze:18-latest Sep 23, 2026
15 checks passed
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.

4 participants