Skip to content

Support conditional ON CONFLICT updates - #3248

Open
fulghum wants to merge 2 commits into
mainfrom
fulghum/doltgres-3235
Open

Support conditional ON CONFLICT updates#3248
fulghum wants to merge 2 commits into
mainfrom
fulghum/doltgres-3235

Conversation

@fulghum

@fulghum fulghum commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Support the action-level WHERE clause on ON CONFLICT DO UPDATE. The predicate is evaluated against the existing and proposed rows before assignments, with false and NULL results leaving the conflicting row unchanged.

Conditional upserts now report PostgreSQL-compatible command tags while preserving shared MySQL affected-row behavior. Coverage includes true, false, NULL, mixed multi-row, qualified predicates, bind variables, exact command tags, and final table state.

Follow-up work is required for unconditional ON CONFLICT DO UPDATE command tags. PostgreSQL counts every executed conflict update as one row, including updates that leave values unchanged. Applying that policy to unconditional updates currently conflicts with MySQL-specific affected-row expectations in the shared enginetests, so the exact fix needs dialect-specific affected-row expectations in the test framework.

Fixes #3235

Depends on:

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor
Main PR
Total 42090 42090
Successful 19289 19303
Failures 22801 22787
Partial Successes1 5459 5434
Main PR
Successful 45.8280% 45.8612%
Failures 54.1720% 54.1388%

${\color{red}Regressions (2)}$

insert_conflict

QUERY:          create unique index tricky_expr_comp_key_index on insertconflicttest(key, lower(fruit), upper(fruit));
RECEIVED ERROR: receiveMessage recovered panic: UnresolvedColumnDefault is a placeholder node, but Eval() was called: goroutine 58617 [running]:
runtime/debug.Stack()
	/opt/hostedtoolcache/go/1.26.2/x64/src/runtime/debug/stack.go:26 +0x5e
github.com/dolthub/doltgresql/server.(*ConnectionHandler).receiveMessage.func1()
	/home/runner/work/doltgresql/doltgresql/server/connection_handler.go:402 +0x4c
panic({0x3d30be0?, 0x508dfc0?})
	/opt/hostedtoolcache/go/1.26.2/x64/src/runtime/panic.go:860 +0x13a
github.com/dolthub/go-mysql-server/sql.UnresolvedColumnDefault.Eval(...)
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/columndefault.go:283
github.com/dolthub/go-mysql-server/sql.(*ColumnDefaultValue).Eval(0x7ef78cb77d0, 0x7ef7b4c74a0, {0x7ef77f4f900?, 0x5?, 0x7ef7a6dc960?})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/columndefault.go:75 +0x44
github.com/dolthub/go-mysql-server/sql/rowexec.ProjectRow(0x7ef7b4c74a0, {0x7ef77f4f7c0, 0x5, 0x6?}, {0x7ef77f4f8b0, 0x5, 0x5})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/rel_iters.go:328 +0x46b
github.com/dolthub/go-mysql-server/sql/rowexec.rewriteTableForIndexCreate(0x7ef7b4c74a0, 0x7ef78aa61a0, {0x51185d8, 0x7ef7b4c7ad0}, {0x7f83f80aea70, 0x7ef7b4c7ad0})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/ddl_iters.go:2739 +0x47c
github.com/dolthub/go-mysql-server/sql/rowexec.(*BaseBuilder).executeAlterIndex(0x7ef75358380, 0x7ef7b4c74a0, 0x7ef74d57e10)
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/ddl_iters.go:2339 +0xe18
github.com/dolthub/go-mysql-server/sql/rowexec.(*BaseBuilder).buildAlterIndex(0x259d607?, 0x20000082905a0?, 0x7f83f8048c20?, {0x7f84433c0a00?, 0x10?, 0x7ef73d73808?})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/dml.go:336 +0x17
github.com/dolthub/go-mysql-server/sql/rowexec.(*BaseBuilder).buildNodeExecNoAnalyze(0x7ef75358380, 0x0?, {0x5115ba8?, 0x7ef74d57e10}, {0x0, 0x0, 0x0})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/node_builder.gen.go:242 +0xe8d
github.com/dolthub/go-mysql-server/sql/rowexec.(*BaseBuilder).buildNodeExec(0x7ef75358380?, 0x7ef7b4c74a0?, {0x5115ba8, 0x7ef74d57e10}, {0x0?, 0x0?, 0x0?})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/node_builder.gen.go:36 +0xb8
github.com/dolthub/go-mysql-server/sql/rowexec.(*BaseBuilder).Build(0x7ef75358380, 0x7ef7b4c74a0, {0x5115ba8, 0x7ef74d57e10}, {0x0, 0x0, 0x0})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/builder.go:50 +0xd7
github.com/dolthub/doltgresql/server/node.(*ContextRootFinalizer).BuildRowIter(0xa?, 0x1?, {0x50a45c0?, 0x7ef75358380?}, {0x0?, 0x0?, 0x0?})
	/home/runner/work/doltgresql/doltgresql/server/node/context_root_finalizer.go:65 +0x34
github.com/dolthub/go-mysql-server/sql/rowexec.(*BaseBuilder).buildNodeExecNoAnalyze(0x7ef75358380, 0x24c2b6e?, {0x51188a8?, 0x7ef78acfd20}, {0x0, 0x0, 0x0})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/node_builder.gen.go:385 +0x32f7
github.com/dolthub/go-mysql-server/sql/rowexec.(*BaseBuilder).buildNodeExec(0x7ef75358380?, 0x7ef7b4c74a0?, {0x51188a8, 0x7ef78acfd20}, {0x0?, 0x0?, 0x0?})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/node_builder.gen.go:36 +0xb8
github.com/dolthub/go-mysql-server/sql/rowexec.(*BaseBuilder).Build(0x7ef75358380, 0x7ef7b4c74a0, {0x51188a8, 0x7ef78acfd20}, {0x0, 0x0, 0x0})
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/sql/rowexec/builder.go:50 +0xd7
github.com/dolthub/go-mysql-server.(*Engine).QueryWithBindings(0x7ef74d1a960, 0x7ef7b4c74a0, {0x7ef7a284b60, 0x66}, {0x50d71f8, 0x7ef7b4c7340}, 0x0, 0x0)
	/home/runner/go/pkg/mod/github.com/dolthub/go-mysql-server@v0.20.1-0.20260901183144-cc41ccb701dd/engine.go:387 +0x788
github.com/dolthub/doltgresql/server.(*DoltgresHandler).executeQuery(0x7ef77dba550?, 0x7ef78acec40?, {0x7ef7a284b60?, 0x2a32700?}, {0x50d71f8?, 0x7ef7b4c7340?}, {0x2af82df?, 0x7ef779115c0?})
	/home/runner/work/doltgresql/doltgresql/server/doltgres_handler.go:478 +0x2b
github.com/dolthub/doltgresql/server.(*DoltgresHandler).doQuery(0x7ef7a3d3880, {0x50f8868?, 0x9b29ea0?}, 0x7ef73d73808?, {0x7ef7a284b60, 0x66}, {0x50d71f8, 0x7ef7b4c7340}, {0x0, 0x0}, ...)
	/home/runner/work/doltgresql/doltgresql/server/doltgres_handler.go:416 +0x613
github.com/dolthub/doltgresql/server.(*DoltgresHandler).ComQuery(0x7ef7a3d3880, {0x50f8868, 0x9b29ea0}, 0x7ef77d96c60, {0x7ef7a284b60, 0x66}, {0x50d71f8, 0x7ef7b4c7340}, 0x7ef7b9f69c0)
	/home/runner/work/doltgresql/doltgresql/server/doltgres_handler.go:265 +0x125
github.com/dolthub/doltgresql/server.(*ConnectionHandler).query(0x7ef7a3d38c0, {{0x7ef7a284b60, 0x66}, {0x50d71f8, 0x7ef7b4c7340}, {0x465afd6, 0xc}})
	/home/runner/work/doltgresql/doltgresql/server/connection_handler.go:1462 +0x235
github.com/dolthub/doltgresql/server.(*ConnectionHandler).handleQuery(0x7ef7a3d38c0, 0x7ef77f585b8?)
	/home/runner/work/doltgresql/doltgresql/server/connection_handler.go:521 +0x365
github.com/dolthub/doltgresql/server.(*ConnectionHandler).handleMessage(0x7ef77f58400?, {0x50d3758?, 0x7ef77f585b8?})
	/home/runner/work/doltgresql/doltgresql/server/connection_handler.go:464 +0x7d
github.com/dolthub/doltgresql/server.(*ConnectionHandler).receiveMessage(0x7ef7a3d38c0)
	/home/runner/work/doltgresql/doltgresql/server/connection_handler.go:432 +0x2c5
github.com/dolthub/doltgresql/server.(*ConnectionHandler).HandleConnection(0x7ef7a3d38c0)
	/home/runner/work/doltgresql/doltgresql/server/connection_handler.go:214 +0x150
created by github.com/dolthub/doltgresql/server.(*Listener).Accept in goroutine 176
	/home/runner/work/doltgresql/doltgresql/server/listener.go:89 +0x136

rowsecurity

QUERY:          SELECT * FROM document WHERE did = 4;
RECEIVED ERROR: row sets differ:
    Postgres:
        {4, 44, 1, "regress_rls_bob", "my first manga", ""}
    Doltgres:
        {4, 44, 1, "regress_rls_bob", "my first novel", ""}

${\color{lightgreen}Progressions (37)}$

insert_conflict

QUERY: insert into insertconflicttest values (1, 'Apple') on conflict (key) do update set fruit = excluded.fruit;
QUERY: insert into insertconflicttest values (2, 'Orange') on conflict (key, key, key) do update set fruit = excluded.fruit;
QUERY: insert into insertconflicttest values (7, 'Raspberry') on conflict (key, fruit) do update set fruit = excluded.fruit;
QUERY: insert into insertconflicttest values (8, 'Lime') on conflict (fruit, key) do update set fruit = excluded.fruit;
QUERY: insert into insertconflicttest values (26, 'Fig') on conflict (key) do update set fruit = excluded.fruit;
QUERY: insert into insertconflicttest values (25, 'Fig') on conflict (fruit) do update set fruit = excluded.fruit;
QUERY: insert into capitals values ('Sacramento', 4664.E+5, 30, 'CA') on conflict (name) do update set population = excluded.population;
QUERY: insert into cities values ('Las Vegas', 5.83E+5, 2001) on conflict (name) do update set population = excluded.population, altitude = excluded.altitude;
QUERY: insert into capitals values ('Las Vegas', 5.83E+5, 2222, 'NV') on conflict (name) do update set population = excluded.population;
QUERY: insert into cities values ('Las Vegas', 5.86E+5, 2223) on conflict (name) do update set population = excluded.population, altitude = excluded.altitude;
QUERY: insert into excluded values(1, '2') on conflict (key) do update set data = 3 RETURNING excluded.*;
QUERY: insert into dropcol(key, drop1, keep1, drop2, keep2) values(1, 2, '2', '2', 2) on conflict(key)
    do update set drop1 = excluded.drop1, keep1 = excluded.keep1, drop2 = excluded.drop2, keep2 = excluded.keep2
    where excluded.drop1 is not null and excluded.keep1 is not null and excluded.drop2 is not null and excluded.keep2 is not null
          and dropcol.drop1 is not null and dropcol.keep1 is not null and dropcol.drop2 is not null and dropcol.keep2 is not null
    returning *;
QUERY: insert into dropcol(key, drop1, keep1, drop2, keep2) values(1, 3, '3', '3', 3) on conflict(key)
    do update set drop1 = dropcol.drop1, keep1 = dropcol.keep1, drop2 = dropcol.drop2, keep2 = dropcol.keep2
    returning *;
QUERY: insert into dropcol(key, keep1, keep2) values(1, '4', 4) on conflict(key)
    do update set keep1 = excluded.keep1, keep2 = excluded.keep2
    where excluded.keep1 is not null and excluded.keep2 is not null
          and dropcol.keep1 is not null and dropcol.keep2 is not null
    returning *;
QUERY: insert into dropcol(key, keep1, keep2) values(1, '5', 5) on conflict(key)
    do update set keep1 = dropcol.keep1, keep2 = dropcol.keep2
    returning *;
QUERY: insert into parted_conflict_test values (1, 'a') on conflict (a) do update set b = excluded.b;
QUERY: insert into parted_conflict_test values (3, 'a') on conflict (a) do update set b = excluded.b;
QUERY: insert into parted_conflict_test values (3, 'b') on conflict (a) do update set b = excluded.b;
QUERY: select * from parted_conflict_test order by a;
QUERY: insert into parted_conflict_test (a, b) values (4, 'a') on conflict (a) do update set b = excluded.b;
QUERY: insert into parted_conflict_test (a, b) values (4, 'b') on conflict (a) do update set b = excluded.b where parted_conflict_test.b = 'a';
QUERY: select * from parted_conflict_test order by a;
QUERY: insert into parted_conflict_test (a, b) values (5, 'a') on conflict (a) do update set b = excluded.b;
QUERY: insert into parted_conflict_test (a, b) values (5, 'b') on conflict (a) do update set b = excluded.b where parted_conflict_test.b = 'a';
QUERY: select * from parted_conflict_test order by a;
QUERY: insert into parted_conflict_test (a, b) values (1, 'a'), (2, 'a'), (4, 'a') on conflict (a) do update set b = excluded.b where excluded.b = 'b';
QUERY: insert into parted_conflict_test (a, b) values (1, 'b'), (2, 'c'), (4, 'b') on conflict (a) do update set b = excluded.b where excluded.b = 'b';
QUERY: select * from parted_conflict_test order by a;

rowsecurity

QUERY: INSERT INTO r1 VALUES (10)
    ON CONFLICT (a) DO UPDATE SET a = 30;

subselect

QUERY: select count(*) from tenk1 t
where (exists(select 1 from tenk1 k where k.unique1 = t.unique2) or ten < 0);

triggers

QUERY: INSERT INTO main_table (a, b) VALUES (5, 10) ON CONFLICT (a)
  DO UPDATE SET b = EXCLUDED.b;
QUERY: insert into my_table values (1, 'AAA'), (2, 'BBB')
  on conflict (a) do
  update set b = my_table.b || ':' || excluded.b;
QUERY: insert into my_table values (1, 'AAA'), (2, 'BBB'), (3, 'CCC'), (4, 'DDD')
  on conflict (a) do
  update set b = my_table.b || ':' || excluded.b;
QUERY: insert into my_table values (3, 'CCC'), (4, 'DDD')
  on conflict (a) do
  update set b = my_table.b || ':' || excluded.b;
QUERY: insert into iocdu_tt_parted values (1, 'AAA'), (2, 'BBB')
  on conflict (a) do
  update set b = iocdu_tt_parted.b || ':' || excluded.b;
QUERY: insert into iocdu_tt_parted values (1, 'AAA'), (2, 'BBB'), (3, 'CCC'), (4, 'DDD')
  on conflict (a) do
  update set b = iocdu_tt_parted.b || ':' || excluded.b;
QUERY: insert into iocdu_tt_parted values (3, 'CCC'), (4, 'DDD')
  on conflict (a) do
  update set b = iocdu_tt_parted.b || ':' || excluded.b;

Footnotes

  1. These are tests that we're marking as Successful, however they do not match the expected output in some way. This is due to small differences, such as different wording on the error messages, or the column names being incorrect while the data itself is correct.

@itoqa

itoqa Bot commented Sep 1, 2026

Copy link
Copy Markdown

Ito QA test results
Commit: 3f02494: 13 test cases ran, 3 failed ❌, 5 passed ✅, 5 additional findings ⚠️.

Summary

Coverage spans normal value binding, conditional updates, false and NULL conditions, mixed multi-row changes, affected-row reporting, atomic error handling, and safe rejection of unsupported syntax. The basic binding and several safety behaviors work, but core conditional-upsert flows involving incoming-row values and mixed changes are not healthy.

Not safe to merge yet — PR-attributable failures break the core conditional-upsert path for mixed inputs and prevent expected updates, inserts, and affected-row reporting, making this a merge blocker despite other passing behaviors. Separate pre-existing compatibility gaps also affect related scenarios, but they are caveats rather than the primary reason for this verdict.

Tests run by Ito

View full run

Result Severity Type Description
Medium severity General The mixed upsert shows an error instead of saving the updated and new rows. The original rows remain unchanged, id 3 is missing, and no INSERT count is returned.
Medium severity Rev The database reports that it cannot find the proposed-row table while processing the conditional upsert. The statement stops before it returns the new row and the rows whose conditions should be false or NULL cannot be verified.
Medium severity Tags The database returns ERROR: table not found: excluded while resolving the upsert, before the statement produces a PostgreSQL command tag. The expected INSERT 0 2 result is not returned, and the seeded rows remain unchanged while id 3 is not inserted.
Bind A parameterized upsert used the value 8 in both the new row and the update condition. The command tag was INSERT 0 1, and the row ended with version 8 and note bound.
Bind The insert completed without an error. The bound NULL made the conflict condition unknown, so the command reported zero affected rows and kept the original values.
Rev The upsert returned the expected duplicate-key error, and both existing rows kept their original values afterward.
Rev Verified acceptable by independent adversarial review: the reported expectation does not match what the code actually promises. Review notes: The finding conflates constraints on the proposed INSERT tuple with validation of a DO UPDATE assignment. Its own reproduction proposes value 99 for a column constrained to 0 through 10, so a CHECK error does not show that a false action predicate evaluated too late; the cited expression-slice construction likewise does not prove runtime evaluation order. The PR introduced action-level predicates,…
Rev The database clearly rejected the unsupported conflict filter, and the existing row kept its original value.
⚠️ High severity General The database returns an error saying that the proposed-row name cannot be found on the first mixed upsert. The statement does not insert the new row or apply either conflict decision, and the reordered execution is never reached.
⚠️ High severity Predicate The database returns table not found: excluded while executing the upsert. The statement does not reach the point where id 1 can update, id 2 can remain unchanged, or id 3 can be inserted.
⚠️ Medium severity General The first upsert that should update an existing row returned an error about the proposed-row reference. The expected update did not happen, and the later false and NULL checks were never reached.
⚠️ Medium severity Predicate The conflicting row was not updated to version 6 and note 'updated'. The statement failed with an error about the incoming row values before the condition could be applied.
⚠️ Medium severity Predicate The statement errors with 'table not found: excluded' before it can evaluate WHERE NULL. The expected result is a successful statement that skips the conflicting row and keeps its original values.
Additional Findings Details

These findings are unrelated to the current changes but were observed during testing.

🟠 Mixed order updates cannot run
  • Severity: High High severity
  • Description: The database returns an error saying that the proposed-row name cannot be found on the first mixed upsert. The statement does not insert the new row or apply either conflict decision, and the reordered execution is never reached.
  • Impact: Users using conditional multi-row upserts cannot insert new rows or update existing rows when the statement uses proposed values. The operation fails before any of its rows are processed, blocking a core database workflow for those users.
  • Steps to Reproduce:
    1. Create a table named conditional_upsert with an integer primary key, version, and note columns.
    2. Insert rows for ids 1 and 2, with versions 5 and 6.
    3. Run a multi-row INSERT with ids 1, 2, and 3 that uses ON CONFLICT DO UPDATE, assigns version and note from the proposed row, and keeps only conflicts whose current version is below 6.
    4. Repeat the statement with the input rows in a different order and compare the final rows.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The recorded SQL in the local evidence uses version = EXCLUDED.version and note = EXCLUDED.note, then fails with ERROR: table not found: excluded. In server/ast/insert.go:46-63, the PR now converts node.OnConflict.Where.Expr into onDuplicateWhere and places it in the Vitess Insert.OnDupWhere field, while nodeUpdateExprs(ctx, node.OnConflict.Exprs) converts the update assignments. That forwarding supports the new action predicate but does not provide resolution for the proposed-row references used by the assignments. The repository's existing testing/go/enginetest/query_converter_test.go:157-164 explicitly states that EXCLUDED.col references are not resolved back through the MySQL translation layer, and its TestReplaceIntoConversion at lines 2353-2376 avoids those references by emitting literal inserted values instead. Thus the failure is a production translation limitation, not a row-order comparison issue or a browser setup failure. The smallest practical fix is to resolve EXCLUDED column references to the current input row values during INSERT conversion, or to reject this unsupported form before execution with a clear capability error; for the advertised conditional-upsert behavior, the former is required, including for each row of a multi-row VALUES source.
Evidence Package
🟠 Mixed upsert stops before updating rows
  • Severity: High High severity
  • Description: The database returns table not found: excluded while executing the upsert. The statement does not reach the point where id 1 can update, id 2 can remain unchanged, or id 3 can be inserted.
  • Impact: Applications using proposed-row values in a mixed conditional upsert receive a database error instead of updating, skipping, or inserting each row. This blocks the affected upsert workflow until the statement is rewritten or the database is fixed.
  • Steps to Reproduce:
    1. Create conditional_upsert with id as the primary key and version and note columns.
    2. Insert id 1 with version 5 and note original, and id 2 with version 1 and note second.
    3. Run one multi-row upsert for ids 1, 2, and 3, assigning version and note from EXCLUDED and using WHERE conditional_upsert.version < 6.
    4. Query ids 1, 2, and 3 and compare them with the expected updated, unchanged, and new rows.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The observed SQL uses SET version = EXCLUDED.version, note = EXCLUDED.note. In server/ast/insert.go:49-62, the PostgreSQL AST converts the conflict assignments with nodeUpdateExprs and converts the action-level WHERE with nodeExpr, then stores the results in the Vitess INSERT as OnDup and OnDupWhere at lines 116-126. In server/analyzer/assign_insert_casts.go:117-135, AssignInsertCasts preserves both the conflict assignments and OnDupWhere when rebuilding the INSERT plan, so merely forwarding the predicate does not create a binding for the PostgreSQL EXCLUDED pseudo-row. The converter’s documented limitation at testing/go/enginetest/query_converter_test.go:157-164 states that EXCLUDED.col references do not resolve back through the MySQL translation layer; the failing error is the same missing relation. The PR diff changes server/ast/insert.go to carry the action predicate and changes the dependency versions, but it does not add the missing EXCLUDED-to-proposed-row resolution. The smallest practical fix is to bind EXCLUDED column expressions to the current candidate row in the INSERT conflict execution path before evaluating assignments and OnDupWhere, then add a mixed multi-row regression test using proposed-row references.
Evidence Package
🟡 Conditional upsert rejects valid conflict updates
  • Severity: Medium Medium severity
  • Description: The first upsert that should update an existing row returned an error about the proposed-row reference. The expected update did not happen, and the later false and NULL checks were never reached.
  • Impact: Users cannot complete upserts that use the standard proposed-row values in conditional updates. The statement fails before the true, false, or NULL condition can be applied.
  • Steps to Reproduce:
    1. Create a table named conditional_upsert with a primary key, a version column, a note column, and a nullable predicate_input column.
    2. Insert seed rows including id 1 with version 5 and id 2 with version 6.
    3. Run an INSERT ... ON CONFLICT (id) DO UPDATE statement for id 1 that assigns EXCLUDED.version and EXCLUDED.note and uses a true target-row predicate.
    4. Observe that execution stops with ERROR: table not found: excluded before the false and NULL predicate cases run.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The PostgreSQL AST conversion in server/ast/insert.go:46-66 accepts an ON CONFLICT update, converts the assignment expressions with nodeUpdateExprs, converts the action predicate with nodeExpr at lines 58-62, and stores it as vitess.Insert.OnDupWhere at lines 116-126. server/analyzer/assign_insert_casts.go:117-135 keeps OnDupWhere in the expression list while assigning casts, so the changed Doltgres code does preserve the action-level WHERE. However, the local execution failed on EXCLUDED.version/EXCLUDED.note before predicate evaluation. The repository's testing/go/enginetest/query_converter_test.go:157-164 explicitly states that Doltgres does not resolve EXCLUDED.col references back through its MySQL translation layer. That explains the observed table-not-found: excluded error and means this integration case cannot exercise target-row, FALSE, or NULL semantics using the required PostgreSQL form. The smallest practical fix is to add proposed-row/EXCLUDED name resolution in the downstream translation path, or to carry the proposed values into that path as bound candidate-row expressions; changing only predicate forwarding will not fix this failure.
Evidence Package
🟡 Conditional update rejects proposed values
  • Severity: Medium Medium severity
  • Description: The conflicting row was not updated to version 6 and note 'updated'. The statement failed with an error about the incoming row values before the condition could be applied.
  • Impact: Users cannot complete conditional upserts when the update uses values from the incoming row. The database returns an error, so the existing row stays unchanged.
  • Steps to Reproduce:
    1. Create conditional_upsert with id as the primary key, plus version and note columns.
    2. Insert the row (1, 5, 'original').
    3. Run INSERT INTO conditional_upsert (id, version, note) VALUES (1, 6, 'updated') ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, note = EXCLUDED.note WHERE conditional_upsert.version < 6.
    4. Observe that execution fails with table not found: excluded instead of updating id 1, then query the row.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The runtime SQL reaches the application and successfully creates and seeds the fixture, but the ON CONFLICT statement fails at execution with ERROR: table not found: excluded. In server/ast/insert.go, nodeInsert converts the update assignments at lines 49-57 and converts the action-level predicate at lines 58-63, storing that predicate in vitess.Insert.OnDupWhere at lines 116-126. That forwarding change does not provide a resolver for PostgreSQL's EXCLUDED pseudo-row. The repository's unchanged testing/go/enginetest/query_converter_test.go explicitly documents at lines 157-164 that EXCLUDED.col references are not resolved back through the MySQL translation layer. The failing statement uses EXCLUDED.version and EXCLUDED.note in exactly that unsupported form, which explains the database error. The smallest practical fix is to resolve EXCLUDED column references to the current proposed insert-row expressions when converting ON CONFLICT assignments and predicates, or to reject this form with a clear unsupported-feature error until that resolver exists; changing only the predicate forwarding is insufficient.
Evidence Package
🟡 NULL predicate rejects conditional update
  • Severity: Medium Medium severity
  • Description: The statement errors with 'table not found: excluded' before it can evaluate WHERE NULL. The expected result is a successful statement that skips the conflicting row and keeps its original values.
  • Impact: A conditional upsert that uses the standard proposed-row values fails instead of leaving the existing row unchanged. Users must avoid this form or use a different update path, and no data is changed by the failed statement.
  • Steps to Reproduce:
    1. Create conditional_upsert with id as a primary key and version and note columns.
    2. Insert the row (1, 5, 'original').
    3. Run INSERT INTO conditional_upsert (id, version, note) VALUES (1, 6, 'unknown replacement') ON CONFLICT (id) DO UPDATE SET version = EXCLUDED.version, note = EXCLUDED.note WHERE NULL.
    4. Query row 1 and verify that it remains (1, 5, 'original') without an error.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The local PostgreSQL evidence reproduces the failure after the table creation and seed insert succeed: the conditional upsert using EXCLUDED.version and EXCLUDED.note returns ERROR: table not found: excluded. In server/ast/insert.go, nodeInsert converts each ON CONFLICT assignment through nodeUpdateExprs at lines 49-57 and converts the action predicate into OnDupWhere at lines 58-62, then places both into the Vitess Insert at lines 116-126. That added predicate forwarding cannot make the assignment-side EXCLUDED expressions resolvable. server/analyzer/assign_insert_casts.go:117-135 preserves OnDupWhere while rebuilding the insert expressions, but contains no EXCLUDED binding or scope resolution. The repository's testing/go/enginetest/query_converter_test.go:157-164 explicitly states that EXCLUDED.col references do not yet resolve through the Doltgres MySQL translation layer. The PR diff only adds OnDupWhere forwarding, preserves the predicate during cast rewriting, changes dependency versions, adjusts bind forwarding, and adds tests; it does not modify that unresolved EXCLUDED path. The smallest practical fix is to implement the missing EXCLUDED-to-proposed-row binding in the translator/execution layer, or to reject this unsupported form with a clear capability error until that binding exists; changing NULL handling alone would not fix the statement because execution fails while resolving the assignments.
Evidence Package

Tip

Reply with @itoqa to send us feedback on this test run.

Comment thread server/ast/insert.go
Comment thread server/ast/insert.go
Comment thread server/ast/insert.go
@coffeegoddd

coffeegoddd commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

@fulghum DOLT

read_tests from_latency to_latency percent_change
covering_index_scan_postgres 2.43 2.43 0.0
groupby_scan_postgres 75.82 75.82 0.0
index_join_postgres 2.18 2.22 1.83
index_join_scan_postgres 1.58 1.58 0.0
index_scan_postgres 467.3 475.79 1.82
oltp_point_select 0.37 0.36 -2.7
oltp_read_only 6.21 6.21 0.0
select_random_points 0.7 0.7 0.0
select_random_ranges 1.01 1.01 0.0
table_scan_postgres 467.3 475.79 1.82
types_table_scan_postgres 1170.65 1191.92 1.82
write_tests from_latency to_latency percent_change
oltp_delete_insert_postgres 6.67 6.67 0.0
oltp_insert 3.36 3.36 0.0
oltp_read_write 13.22 13.22 0.0
oltp_update_index 3.55 3.55 0.0
oltp_update_non_index 3.25 3.25 0.0
oltp_write_only 6.91 6.91 0.0
types_delete_insert_postgres 7.17 7.17 0.0

@fulghum
fulghum force-pushed the fulghum/doltgres-3235 branch from 3f02494 to 57c45dd Compare September 1, 2026 00:15
@fulghum

fulghum commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up work remains for exact PostgreSQL affected-row counts on unconditional ON CONFLICT DO UPDATE. PostgreSQL counts every executed conflict update as one row, including updates that leave stored values unchanged. Applying that behavior broadly currently conflicts with MySQL-specific affected-row expectations in the shared enginetests, so the clean fix requires dialect-specific affected-row expectations in the test framework. This PR intentionally limits the new single-row counting policy to action-level conditional updates.

@itoqa

itoqa Bot commented Sep 1, 2026

Copy link
Copy Markdown

Ito QA test results

History reset (rebase or force-push detected). Starting test narrative over.

Commit: 57c45dd: 14 test cases ran, 4 failed ❌, 9 passed ✅, 1 additional finding ⚠️.

Summary

The run covers database write behavior across normal inserts and updates, stale-write protection, parameter handling, concurrent conflicts, row-count reporting, and multi-row operations. It also exercises edge cases involving incoming conflict values, type casts, returned rows, constraint handling, and atomicity; basic paths are healthy, but several important conditional-write variations remain broken.

Not safe to merge yet — several attributable medium-severity failures affect core conditional write scenarios, including incoming values, returned rows, casted values, and multi-row behavior, making supported upserts unreliable. An additional unrelated limitation is a flag for later, but is not a driver of the merge decision.

Tests run by Ito

View full run

Result Severity Type Description
Medium severity Cast The valid conflict update was expected to apply the casted value when its condition was true and return the requested row. Instead, execution failed with received output row of length 4 for input row of length 6, and the existing row remained unchanged.
Medium severity Rev The first upsert should update the row to version 6 and note 'new', and the stale upsert should leave that newer row unchanged. Both statements instead fail with 'table not found: excluded', and the row remains (1, 5, 'old').
Medium severity Rev The statement reports table not found: excluded and returns no rows. It should return (1, 6) for the successful update and (3, 1) for the new row, omit the rejected conflict (2, 10), and leave the table in that matching final state.
Medium severity Rev The statement returns a missing-row error before it checks the invalid value in the second row. The transaction is rolled back and both original rows remain, but the expected CHECK-constraint error is never reached.
General An update that wrote the values already stored reported zero affected rows. Adding an always-true condition reported one affected row, while the stored row stayed unchanged, matching the intended boundary.
General The database reported two affected rows: one new row was inserted and one existing row was updated. The third row failed its condition and stayed unchanged, so the command count matched the final table state.
General A stale update was skipped and left the existing row unchanged. A newer update was applied and stored the new values.
General Parameterized upserts used the correct value in every expression. Values 10 and 12 updated the row, while stale value 9 was skipped as expected.
General Two clients tried to update the same row at once. One update succeeded, the other was rejected with a serialization error, and the final row kept the accepted value without a stale overwrite.
Bind The bound version value was used in the new row, the conflict update, and the condition. The database returned INSERT 0 1 and saved version 7 with note bound-update.
Update An upsert changed the existing row and returned the established INSERT 0 2 command tag.
Upsert The existing row was updated from version 5 to version 6 with the new note, and the database returned INSERT 0 1.
Upsert A stale update was skipped, and the existing row stayed at version 5 with note original.
⚠️ Medium severity Tag The documented PostgreSQL form using EXCLUDED.version and EXCLUDED.note fails with table not found: excluded instead of evaluating the incoming row. The aggregate command-tag behavior passes only after replacing those incoming-row references with constants, which does not verify the user-facing form described by the feature.
Additional Findings Details

These findings are unrelated to the current changes but were observed during testing.

🟡 EXCLUDED values fail in conditional upserts
  • Severity: Medium Medium severity
  • Description: The documented PostgreSQL form using EXCLUDED.version and EXCLUDED.note fails with table not found: excluded instead of evaluating the incoming row. The aggregate command-tag behavior passes only after replacing those incoming-row references with constants, which does not verify the user-facing form described by the feature.
  • Impact: Applications that use the incoming row in a conditional upsert cannot run the statement and must rewrite it with fixed values or use multiple statements. This blocks a meaningful PostgreSQL upsert pattern but does not corrupt existing data.
  • Steps to Reproduce:
    1. Create a table with a primary key and seed rows with ids 1 and 2.
    2. Run a three-row INSERT ... ON CONFLICT statement that sets columns from EXCLUDED and compares EXCLUDED.version with the existing row.
    3. Observe the error that says the excluded table cannot be found.
    4. Run the same mixed statement with fixed values in the SET clause and condition; it succeeds with INSERT 0 2, updates id 1, leaves id 2 unchanged, and inserts id 3.
  • Stub / mock content: A local PostgreSQL fixture was seeded for this test. No application mocks or route interceptions were used; fixed constants were used only as a workaround to verify the separate mixed-row command-tag behavior after the EXCLUDED form failed.
  • Code Analysis: The failing statement enters server/ast/insert.go in nodeInsert. Lines 58-63 pass the ON CONFLICT WHERE expression through nodeExpr, and lines 51-56 do the same for update assignments. In server/ast/expr.go, nodeExpr handles an unresolved qualified name at lines 885-908 by calling unresolvedNameToColName and returning an ordinary vitess.ColName. unresolvedNameToColName at lines 916-944 treats the first name part as the column and the second part as a table qualifier; therefore EXCLUDED.version becomes a regular column qualified by the table name excluded, not the special proposed-row scope required by PostgreSQL ON CONFLICT. The local execution returned table not found: excluded for both the predicate and assignment form. The PR diff changes server/ast/insert.go to carry OnDupWhere and select conditional row counting, and changes server/analyzer/assign_insert_casts.go to preserve OnDupWhere, checks, and RETURNING, but it does not change the unresolved-name conversion in server/ast/expr.go. The smallest practical fix is to translate EXCLUDED-qualified references in the ON CONFLICT expressions into the execution engine's proposed-insert-row representation, then add a regression test for EXCLUDED in both SET and WHERE; do not broaden the fix to unrelated name resolution.
Evidence Package

Tip

Reply with @itoqa to send us feedback on this test run.

Comment thread server/analyzer/assign_insert_casts.go
Comment thread server/ast/insert.go
Comment thread server/ast/insert.go
Comment thread server/ast/insert.go
@fulghum
fulghum force-pushed the fulghum/doltgres-3235 branch from 57c45dd to 3bb4987 Compare September 1, 2026 18:35
@fulghum
fulghum requested a review from zachmu September 1, 2026 18:45
@itoqa

itoqa Bot commented Sep 1, 2026

Copy link
Copy Markdown

Ito QA test results

History reset (rebase or force-push detected). Starting test narrative over.

Commit: 3bb4987: 13 test cases ran, 1 failed ❌, 12 passed ✅.

Summary

Coverage spans insert-or-update behavior across normal updates, conditional and bound-value decisions, null and ordering edge cases, affected-row counts, returned data, type conversion, and rollback after constraint errors. The broad behavior is healthy, but a conditional insert-from-query scenario still fails to update and return the existing record.

Merge with caution — a PR-attributable medium-severity failure remains in a supported conditional insert-or-update path, preventing expected updates and returned results in that scenario. The other exercised behaviors pass, so this is a focused functional risk rather than a broad data-integrity failure.

Tests run by Ito

View full run

Result Severity Type Description
Medium severity Rev The conditional upsert shows an error instead of updating the existing row and returning its new values.
General An upsert with a NULL condition left the existing row unchanged and counted zero updated rows. The same row changed and counted one when the condition was true.
General The database kept its established count for updates without a condition and used the new count for updates with a true condition. Equal values returned 0 versus 1, and changed values returned 2 versus 1, as expected.
General A later row with a null primary key caused the upsert to fail, and the earlier insert and update were both undone.
General The update returned the columns in the expected order and types. A skipped conflict returned no row and left the saved data unchanged.
General The false condition left the existing row unchanged, while the inverse condition updated it and counted one row.
Bind The conditional database update accepted its supplied value and returned the expected row count. The check passed without a missing-parameter error; the local browser page returned 404 because this case has no user interface.
Conflict The newer proposed row updated the matching record, while the stale proposed row left its record unchanged.
Conflict A conditional upsert accepted bound values and used them in both the decision and the updated row. The statement completed without an analysis or name-resolution error.
Insert The database counted one new row and one allowed update, while leaving the rejected conflict out of the total.
Returning The conditional upsert returned the updated row with id 1, version 6, and note 'updated'. A final read confirmed that the same values were stored.
Returning The conflict update applied the casted value, returned the expected row shape, and stored version 8 with note casted.
Update Changed values returned two affected rows, while equal values returned zero, and the updated row was saved correctly.

Tip

Reply with @itoqa to send us feedback on this test run.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

View replay

Medium severity Conditional insert fails to update a row

What failed: The conditional upsert shows an error instead of updating the existing row and returning its new values.

Impact · Steps · Stub / mock · Analysis · Why this is likely a bug
  • Severity: Medium Medium severity
  • Impact: Users of conditional upsert queries cannot update an existing row or receive the changed row in the result. The existing data stays unchanged, so the operation must be corrected or retried through another supported path.
  • Steps to Reproduce:
    1. Create a table with an integer primary key, a bigint amount column, and a text note column.
    2. Insert one row with key 1, amount 10, and note 'initial'.
    3. Run an INSERT ... SELECT for key 1 with a larger amount, use EXCLUDED.amount with an explicit bigint cast, compare the old amount with EXCLUDED.amount in the conflict predicate, and add RETURNING key, amount, and note.
    4. Observe the error about the excluded table and query the row again; it still contains amount 10 and note 'initial'.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: server/ast/insert.go now converts the action-level WHERE expression at lines 58-63 and attaches it as OnDupWhere, while setting OnDupValuesAlias to excluded at lines 116-129. For statements requiring insert cast analysis, server/analyzer/assign_insert_casts.go:117-121 computes replacement update expressions. The PR then builds exprs at lines 123-127 by appending checks, OnDupWhere, and Returning, and calls insertInto.WithExpressions(ctx, exprs...) at line 129. The recorded go-mysql-server InsertInto implementation used by this checkout exposes and reconstructs only the duplicate-update expressions, checks, and Returning expressions; it does not include OnDupWhere in either Expressions or WithExpressions. Consequently, the analyzer rebuild loses the conflict predicate's proposed-row scope while rebuilding the casted insert, leaving EXCLUDED references unresolved and producing table not found: excluded. The smallest practical fix is to update the InsertInto expression reconstruction contract used by this PR so OnDupWhere is included and restored in the same position, or to use a reconstruction path that explicitly preserves OnDupWhere before calling WithExpressions; then rerun the cast-plus-RETURNING INSERT ... SELECT test.
  • Why this is likely a bug: The SQL is valid PostgreSQL syntax and the table setup succeeds, but the update statement fails before changing data. The same error occurs with uppercase and lowercase EXCLUDED references, which points to lost proposed-row scope rather than identifier casing. The failure affects the feature this PR adds: action-level conflict predicates, proposed-row references, casted assignments, and RETURNING are all expected to work together; a targeted preservation of OnDupWhere during cast reconstruction fixes that path without changing unrelated unconditional upsert behavior.
Relevant code

server/analyzer/assign_insert_casts.go:117-135

if insertInto.OnDupExprs.HasUpdates() {
	newDupExprs, err := assignUpdateFieldCasts(ctx, insertInto.OnDupExprs.AllExpressions())
	...
	exprs := append(newDupExprs, insertInto.Checks().ToExpressions()...)
	if insertInto.OnDupWhere != nil {
		exprs = append(exprs, insertInto.OnDupWhere)
	}
	exprs = append(exprs, insertInto.Returning...)
	newInsertInto, err := insertInto.WithExpressions(ctx, exprs...)
}

server/ast/insert.go:46-63

if node.OnConflict != nil {
	...
	if node.OnConflict.Where != nil {
		onDuplicateWhere, err = nodeExpr(ctx, node.OnConflict.Where.Expr)
	}
}

server/ast/insert.go:116-129

return &vitess.Insert{
	...
	OnDupValuesAlias: "excluded",
	OnDupWhere:       onDuplicateWhere,
	CountOnDuplicateUpdateAsOneRow: node.OnConflict != nil && node.OnConflict.Where != nil,
}
Evidence Package
Copy prompt for an agent
Ito QA identified the following failure during automated PR testing. Please investigate and propose a fix.

**Medium severity — Conditional insert fails to update a row**

**What failed:** The conditional upsert shows an error instead of updating the existing row and returning its new values.

- **Impact:** Users of conditional upsert queries cannot update an existing row or receive the changed row in the result. The existing data stays unchanged, so the operation must be corrected or retried through another supported path.
- **Steps to reproduce:**
  1. Create a table with an integer primary key, a bigint amount column, and a text note column.
  2. Insert one row with key 1, amount 10, and note 'initial'.
  3. Run an INSERT ... SELECT for key 1 with a larger amount, use EXCLUDED.amount with an explicit bigint cast, compare the old amount with EXCLUDED.amount in the conflict predicate, and add RETURNING key, amount, and note.
  4. Observe the error about the excluded table and query the row again; it still contains amount 10 and note 'initial'.
- **Stub / mock content:** No stubs, mocks, or bypasses were applied for this test in the recorded run.
- **Code analysis:** server/ast/insert.go now converts the action-level WHERE expression at lines 58-63 and attaches it as OnDupWhere, while setting OnDupValuesAlias to excluded at lines 116-129. For statements requiring insert cast analysis, server/analyzer/assign_insert_casts.go:117-121 computes replacement update expressions. The PR then builds exprs at lines 123-127 by appending checks, OnDupWhere, and Returning, and calls insertInto.WithExpressions(ctx, exprs...) at line 129. The recorded go-mysql-server InsertInto implementation used by this checkout exposes and reconstructs only the duplicate-update expressions, checks, and Returning expressions; it does not include OnDupWhere in either Expressions or WithExpressions. Consequently, the analyzer rebuild loses the conflict predicate's proposed-row scope while rebuilding the casted insert, leaving EXCLUDED references unresolved and producing `table not found: excluded`. The smallest practical fix is to update the InsertInto expression reconstruction contract used by this PR so OnDupWhere is included and restored in the same position, or to use a reconstruction path that explicitly preserves OnDupWhere before calling WithExpressions; then rerun the cast-plus-RETURNING INSERT ... SELECT test.
- **Why this is likely a bug:** The SQL is valid PostgreSQL syntax and the table setup succeeds, but the update statement fails before changing data. The same error occurs with uppercase and lowercase EXCLUDED references, which points to lost proposed-row scope rather than identifier casing. The failure affects the feature this PR adds: action-level conflict predicates, proposed-row references, casted assignments, and RETURNING are all expected to work together; a targeted preservation of OnDupWhere during cast reconstruction fixes that path without changing unrelated unconditional upsert behavior.

**Relevant code:**

`server/analyzer/assign_insert_casts.go:117-135`

~~~go
if insertInto.OnDupExprs.HasUpdates() {
	newDupExprs, err := assignUpdateFieldCasts(ctx, insertInto.OnDupExprs.AllExpressions())
	...
	exprs := append(newDupExprs, insertInto.Checks().ToExpressions()...)
	if insertInto.OnDupWhere != nil {
		exprs = append(exprs, insertInto.OnDupWhere)
	}
	exprs = append(exprs, insertInto.Returning...)
	newInsertInto, err := insertInto.WithExpressions(ctx, exprs...)
}
~~~

`server/ast/insert.go:46-63`

~~~go
if node.OnConflict != nil {
	...
	if node.OnConflict.Where != nil {
		onDuplicateWhere, err = nodeExpr(ctx, node.OnConflict.Where.Expr)
	}
}
~~~

`server/ast/insert.go:116-129`

~~~go
return &vitess.Insert{
	...
	OnDupValuesAlias: "excluded",
	OnDupWhere:       onDuplicateWhere,
	CountOnDuplicateUpdateAsOneRow: node.OnConflict != nil && node.OnConflict.Where != nil,
}
~~~

@zachmu zachmu 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.

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.

ON CONFLICT ... DO UPDATE ... WHERE (conditional upsert) is rejected as unsupported

3 participants