Skip to content

fix(catalog): name columns of scalar subqueries and field selections like Postgres - #891

Merged
psteinroe merged 3 commits into
mainfrom
fix/figure-colname-sublinks
Oct 26, 2026
Merged

psteinroe merged 3 commits into
mainfrom
fix/figure-colname-sublinks

Conversation

@psteinroe

Copy link
Copy Markdown
Collaborator

The column names we give unnamed select list entries (our port of Postgres' FigureColname) were wrong in three places, and both banDuplicateColumnAssignments (new in #885, recommended) and unknownColumn trust them:

  • An unaliased scalar subquery was named ?column?. Postgres transforms the subquery first and takes the name of its column, so (select max(a) from t) is max.
  • (t.c).arr[1] was named after c, because only the last indirection item was looked at. Postgres takes the last field name anywhere in the list: arr.
  • Every CASE was named case. Postgres uses the name of the ELSE result if that is a good name, so case when ... then 1 else a end is a.

So valid migrations failed the linter:

create view max_and_min as select (select max(a) from t), (select min(a) from t);
-- before: × Column ?column? is specified more than once.
-- after:  nothing (Postgres names the columns max and min)

create view v as select (t.c).arr[1], c from t;
-- before: × Column c is specified more than once.

create view va as select (select max(a) from t);
select max from va;
-- before: × Column max does not exist.

The casts of subqueries follow from this: (select max(a) from t)::text keeps max, and (select 1)::text stays ?column? (the subquery's name is a strong name, so the cast doesn't replace it with text).

What changed

  • EXPR_SUBLINK takes the name of the first column of the subquery: the alias of the first entry of its left-most select, otherwise the name figured for its value, column1 for VALUES, and unknown for a * (it expands to a column of the relation). The name is strong, as in Postgres.
  • ColumnRef and A_Indirection take the last field name in the list, ignoring * and subscripts, and an indirection without one falls back to its argument.
  • CASE ports the ELSE handling.
  • MULTIEXPR_SUBLINK has no name, as in Postgres (it only appears in UPDATE ... SET (a, b) = (select ...)).
  • figure_column_name returns None for a top-level *, instead of ?column? for */t.*. The resolver already expanded those itself; output_name (ORDER BY / GROUP BY references) no longer gets a bogus ?column? for them.

Every name was checked against Postgres 15 (create view + pg_attribute), and the three duplicates in the new spec are rejected by Postgres with 42701.

banDuplicateColumnAssignments now also runs in postgres_regress.rs, so any finding on a statement Postgres accepted fails CI on every version. The corpus is clean; its three findings are on rejected statements (update.sql 42601, create_table.sql and typed_table.sql 42701) and show up in the per-version snapshots.

The rule docs said insert into t (a, a) fails with 42601. Postgres raises 42701 duplicate_column (column "a" specified more than once); only duplicate SET assignments are 42601.

Tests

Spec files banDuplicateColumnAssignments/unnamed_columns.sql (accepted views, CREATE TABLE AS, SELECT INTO with subqueries, set operations, VALUES, casts, field subscripts, CASE), unnamed_duplicates.sql (the cases Postgres does reject), and unknownColumn/unnamed_view_columns.sql. Regress snapshots for 15–18 gain the three true positives.

Not covered

The subquery's first column is unknown if it is a * (we'd need the relation's first column), so that case reports nothing.

…like Postgres

FigureColname takes the name of a scalar subquery's column, the last field
name of an indirection, and the name of a CASE's ELSE result if it is a good
name. The port named the first two `?column?` or after the indirection's
argument, and every CASE `case`, so banDuplicateColumnAssignments reported
valid views as duplicates and unknownColumn rejected existing view columns.

Also run banDuplicateColumnAssignments over the Postgres regression suite and
fix the SQLSTATE of duplicate column lists in its docs.
# Conflicts:
#	crates/pgls_analyser/tests/snapshots/postgres_regress__typecheck_15.snap
#	crates/pgls_analyser/tests/snapshots/postgres_regress__typecheck_16.snap
#	crates/pgls_analyser/tests/snapshots/postgres_regress__typecheck_17.snap
#	crates/pgls_analyser/tests/snapshots/postgres_regress__typecheck_18.snap
@psteinroe
psteinroe merged commit b881d5d into main Oct 26, 2026
11 checks passed
@psteinroe
psteinroe deleted the fix/figure-colname-sublinks branch October 26, 2026 08:46
psteinroe added a commit that referenced this pull request Oct 26, 2026
…quawk (#905)

Stacked on #891.

Follow-ups to the rules ported from Squawk in #885: where the diagnostics point, and what they cover. Every new error case was checked against Postgres 15 in a rolled-back transaction.

**banDuplicateColumnAssignments**

- Diagnostics pointed at the whole statement. They now point at the second occurrence: the column name in SET, INSERT and column definitions, and the whole select list entry (e.g. `coalesce(a, b) as x`) in views, `CREATE TABLE AS` and `SELECT INTO`. View column lists and COPY column lists have no locations and still point at the statement.
- A `*` anywhere in a view's select list turned the check off. It now only hides the names it may expand to:

  ```sql
  create view v as select t.*, 1 as x, 2 as x from t;
  -- before: nothing
  -- after:  × Column x is specified more than once.   (Postgres: 42701)
  ```

  If the view's explicit column names reach into the `*`, we still can't tell what the later entries are called, so `create view v (a, c, d) as select t.*, 2 as x, 3 as x` stays silent (Postgres accepts it when `t` has two columns).
- New statements, all rejected by Postgres with 42701: `alter table users add column email text, add column email text` (also after `ADD COLUMN IF NOT EXISTS email`; a later `IF NOT EXISTS` is skipped by Postgres and not reported), `create type pair as (a int, a int)`, `copy users (id, id)`, and `create foreign table ... (id int, id int)`. ADD COLUMN and COPY have their own detail text.

**requireTableSchema** (opt-in)

- Points at the table name, and the note uses it (`e.g. public.posts` instead of `public.my_table`). `DROP TABLE a, b` points at each name.
- Temporary tables created earlier in the file (`create temp table`, `create table pg_temp.x`, `select ... into temp x`) are no longer reported on later `ALTER`/`DROP`/`CREATE INDEX`, until they are dropped. An unqualified name refers to them since `pg_temp` comes first in the search path. Squawk reports these too.
- Scope: statements that create, change or drop a table, and the tables they reference by name. Newly covered: `CREATE FOREIGN TABLE`, `ALTER/DROP FOREIGN TABLE`, `ALTER TABLE ... RENAME` (table, column, constraint), `SET SCHEMA`, `SELECT INTO`, `CREATE INDEX ON`, and inside those statements `REFERENCES`, `PARTITION OF`, `INHERITS`, `LIKE`, `ATTACH PARTITION`. Queries (the body of a view, `CREATE TABLE AS ... SELECT ... FROM x`, DML) stay out of scope, otherwise the rule would fire on every query. This is wider than Squawk, which only checks CREATE/ALTER/DROP TABLE; the rule is opt-in, and a user who enables it wants every table name in DDL qualified.

**banDropConstraint, banDropDefault** (opt-in)

One diagnostic per statement without a name became one per command, with the name, pointing at its `DROP CONSTRAINT` / `DROP DEFAULT`:

```sql
alter table users drop constraint users_email_key, drop constraint if exists users_name_check;
-- before: ! Dropping a constraint may break existing clients.
-- after:  ! Dropping constraint users_email_key may break existing clients.
--         ! Dropping constraint users_name_check may break existing clients.
```

**renamingObject** (opt-in)

Most of its findings on the regression corpus are `ALTER INDEX ... RENAME` and `RENAME CONSTRAINT`, which Squawk's `renaming-object` reports as well, so they stay. We also report collations, text search configurations, FDWs and other objects Squawk doesn't. I kept those: clients do reference collations and text search configurations by name, and the rule is opt-in. The one change is `ALTER TYPE ... RENAME ATTRIBUTE`, which moves to `renamingColumn` like in Squawk (an attribute is a column of a composite type).

**requireEnumValueOrdering** now has `applies_to: Migration`, like the other DDL rules from #885.

**Tests**

New spec files for the new duplicate cases (and the accepted ones next to them), the new requireTableSchema positions and temporary tables, multiple commands for banDropConstraint/banDropDefault, and RENAME ATTRIBUTE. The spec snapshots don't show spans, so there are unit tests per rule asserting the text each diagnostic points at, using a small test helper `spanned_texts` in `lib.rs`. `StatementTokens` gets a `keyword_pairs` helper for the `DROP CONSTRAINT` / `DROP DEFAULT` spans.

**Not covered**

Duplicates that need the catalog (`ADD COLUMN` of an existing column, `LIKE t` plus a column of `t`) and view column lists / COPY column lists still point at the statement.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant