Skip to content

fix(postgres): parse ON CONFLICT after a FROM-less INSERT ... SELECT#2672

Open
skyf0l wants to merge 1 commit into
quarylabs:mainfrom
skyf0l:fix/postgres-insert-select-on-conflict
Open

fix(postgres): parse ON CONFLICT after a FROM-less INSERT ... SELECT#2672
skyf0l wants to merge 1 commit into
quarylabs:mainfrom
skyf0l:fix/postgres-insert-select-on-conflict

Conversation

@skyf0l
Copy link
Copy Markdown
Contributor

@skyf0l skyf0l commented Jun 6, 2026

Summary

In the Postgres dialect, INSERT … SELECT … ON CONFLICT … is reported as an Unparsable section whenever the SELECT source has no FROM clause. The same statement parses fine with a VALUES source, or with a SELECT that does have a FROM clause — so the upsert grammar is correct; the bare select target list simply doesn't stop at ON CONFLICT and greedily consumes the ON token.

This is valid PostgreSQL: an INSERT source may be a query (a SELECT), and ON CONFLICT applies to all three source forms (DEFAULT VALUES, VALUES, query). See the INSERT synopsis.

Reproduction

-- dialect: postgres

-- ❌ Unparsable section (at `ON`)
INSERT INTO t (a, b) SELECT 1, 2 ON CONFLICT (a, b) DO NOTHING;
INSERT INTO t (a, b) SELECT 1, 2 ON CONFLICT (a) DO UPDATE SET b = 1;

-- ✅ already parsed fine
INSERT INTO t (a, b) VALUES (1, 2) ON CONFLICT (a, b) DO NOTHING;
INSERT INTO t (a, b) SELECT 1, c.id FROM unnest('{}'::uuid[]) c(id) ON CONFLICT (a, b) DO NOTHING;

A common real-world case is a writable CTE inserting unnested rows:

WITH ins AS (
    INSERT INTO link (parent_unid, child_unid)
    SELECT $1, unnest($2::uuid[])
    ON CONFLICT (parent_unid, child_unid) DO NOTHING
)
SELECT 1;

Root cause

The Postgres SelectClauseSegment parses its target list in ParseMode::GreedyOnceStarted with an inline terminators list (FROM, WHERE, LIMIT, ORDER BY, …) that does not include ON CONFLICT. With no FROM clause to terminate at, the greedy target list swallows the trailing ON and the statement becomes unparsable. When a FROM clause is present the select clause terminates there, which is why the bug only affects FROM-less selects.

Fix

Add ON CONFLICT as a terminator of the Postgres select target list. The two-keyword sequence is intentional so it does not affect SELECT DISTINCT ON (…) or JOIN … ON ….

                  Ref::keyword("LIMIT").to_matchable(),
                  Ref::keyword("OVERLAPS").to_matchable(),
                  Ref::new("SetOperatorSegment").to_matchable(),
                  Sequence::new(vec![
                      Ref::keyword("WITH").to_matchable(),
                      Ref::keyword("NO").optional().to_matchable(),
                      Ref::keyword("DATA").to_matchable(),
                  ])
                  .to_matchable(),
                  Ref::new("WithCheckOptionSegment").to_matchable(),
+                Sequence::new(vec![
+                    Ref::keyword("ON").to_matchable(),
+                    Ref::keyword("CONFLICT").to_matchable(),
+                ])
+                .to_matchable(),
              ];
              this.parse_mode(ParseMode::GreedyOnceStarted);

(crates/lib-dialects/src/postgres.rs, in the SelectClauseSegment replace_grammar terminators.)

Tests

Added a sqruff-native fixture covering DO NOTHING, DO UPDATE, and the writable-CTE form:

  • crates/lib-dialects/test/fixtures/dialects/postgres/sqruff/insert_select_on_conflict.sql
  • crates/lib-dialects/test/fixtures/dialects/postgres/sqruff/insert_select_on_conflict.yml (generated via env UPDATE_EXPECT=1 cargo test)

The generated parse tree contains real insert_statement / conflict_target / conflict_action / with_compound_statement nodes and no unparsable segments.

cargo test -p sqruff-lib-dialects passes (all dialect fixtures, 0 failed), and no existing fixtures changed — the grammar tweak doesn't perturb any other parse tree.

Manually re-verified after the fix:

  • ✅ now parse: INSERT … SELECT … ON CONFLICT DO NOTHING, … DO UPDATE, and the writable-CTE INSERT … SELECT unnest(…) … ON CONFLICT form
  • ✅ no regression: SELECT DISTINCT ON (a) a, b FROM t, SELECT a.x FROM a JOIN b ON a.id = b.id, INSERT … VALUES … ON CONFLICT, INSERT … SELECT … FROM … ON CONFLICT, plain
    SELECT 1, 2, SELECT a b FROM t

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.

1 participant