fix(replace): apply parent-side foreign key actions during REPLACE#25089
fix(replace): apply parent-side foreign key actions during REPLACE#25089ck89119 wants to merge 10 commits into
Conversation
REPLACE replaces a conflicting row as delete-then-insert, so MySQL applies
the referencing child tables' ON DELETE action against the removed parent
row before the new row is inserted. The operator-based REPLACE path did not
do this, so replacing a referenced parent row succeeded and left child rows
untouched.
Generate parent-side FK background SQLs for non-self-referencing child
tables (RefChildTbls) of the replaced parent, gated by foreign_key_checks
and run before the main REPLACE in the same transaction:
- RESTRICT / NO_ACTION -> a count(*)=0 pre-check that fails with
ErrFKRowIsReferenced (MySQL 1451) when a child still references a
replaced parent value (prefix REPLACE_PARENT_CHK:).
- CASCADE -> delete from child where fk in (pk values).
- SET NULL -> update child set fk = null where fk in (pk values)
(both prefixed REPLACE_PARENT_ACTION:).
Limitations mirror the existing self-referencing pre-check path: only
literal VALUES, single-column FKs that reference the parent's single-column
PRIMARY KEY are handled; other shapes are skipped.
Adds plan unit tests (mock RefChildTbls fixtures) and BVT coverage for
RESTRICT, CASCADE and SET NULL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
- Do not skip the whole REPLACE when a multi-row VALUES list mixes literal and non-literal PKs; generate the parent-side action for the literal rows so CASCADE/SET NULL no longer leave orphan child rows for them. - Gate the self-referencing FK SQL generation under foreign_key_checks too, so all REPLACE FK checks/actions are consistently disabled when the session variable is off. - Strengthen tests: assert SQL content for the explicit-columns case, add a reverse assertion for SET NULL, and cover NO ACTION, multi-row, and mixed literal/non-literal rows. Adds a multi-row CASCADE BVT case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
# Conflicts: # test/distributed/cases/dml/replace/replace.result # test/distributed/cases/dml/replace/replace.test
XuPeng-SH
left a comment
There was a problem hiding this comment.
This still needs changes.
The new parent-side FK handling is still not semantics-complete for a common valid REPLACE shape: a referenced parent table that also has a secondary UNIQUE key.
In genParentSideReplaceFKSqls (pkg/sql/plan/build_util.go), the generator bails out as soon as parent.Indexes contains any unique index, and it also requires the incoming VALUES list to carry the parent PK literal. That means it only knows how to drive child RESTRICT/CASCADE/SET NULL from the incoming parent PK values. But REPLACE deletes rows selected by any conflicting unique key, not just by the incoming PK.
A concrete case is already in the new BVT coverage:
create table fk_review_p(id int primary key, u int unique, v int);
create table fk_review_c(id int primary key, pid int,
foreign key(pid) references fk_review_p(id) on delete cascade);
insert into fk_review_p values (1, 10, 100);
insert into fk_review_c values (1, 1);
replace into fk_review_p values (2, 10, 200);
MySQL semantics here are: delete old parent row id=1 because of the UNIQUE(u) conflict, apply the ON DELETE CASCADE to child rows that reference id=1, then insert the new parent row id=2. This patch instead returns 'not supported: REPLACE on a referenced table with secondary unique keys'.
The same root cause also rejects the common auto-increment case where the REPLACE column list omits the parent PK, because pkPos cannot be found even though the conflicting old parent row is still well-defined by another unique key.
Suggestion: derive the actual set of old parent PKs that REPLACE will delete from the full conflict set (PK and secondary unique conflicts), then drive parent-side RESTRICT/CASCADE/SET NULL from that old-PK set. As long as the implementation only works from the incoming literal PK values, it will keep rejecting valid MySQL REPLACE statements on referenced parents.
| } | ||
| parts := make([]string, len(idx.Parts)) | ||
| for i, part := range idx.Parts { | ||
| parts[i] = catalog.ResolveAlias(part) |
There was a problem hiding this comment.
[P1/blocker] Preserve unique-prefix semantics when deriving the old parent rows that REPLACE will delete. This reduces every unique index to raw column names and drops idx.IndexAlgoParams, including prefix lengths. For UNIQUE KEY u(body(4)), an existing abcdxxxx and incoming abcdyyyy conflict in the actual unique index, but the generated predicate below compares parent.body = 'abcdyyyy', so it does not find the old row. The main REPLACE can then delete that referenced parent while RESTRICT incorrectly permits it or CASCADE/SET NULL skips its child action. Prefix indexes are supported and already covered by prefix_index_dml.sql and the ODKU planner path. Parse catalog.IndexPrefixLengthsFromParamsWithError(idx.IndexAlgoParams) and generate each conflict predicate from the same indexed prefix expression, then add a referenced-parent BVT regression.
aunjgr
left a comment
There was a problem hiding this comment.
The current head addresses the earlier secondary-unique, auto-increment, composite-FK, and non-PK-reference gaps, but it is still not merge-ready because conflict discovery does not preserve unique prefix-index semantics. The inline example shows how REPLACE can delete an old referenced parent while the generated exact-column predicate misses it, causing RESTRICT/CASCADE/SET NULL to be skipped. Please build the old-row predicate from the same prefix key semantics used by the unique index and add a BVT regression. Prepared parameters and REPLACE SELECT remain explicit limitations; the PR description should also be updated because it still says composite/non-PK references are skipped even though this head supports them.
What type of PR is this?
Which issue(s) this PR fixes:
issue #24951
What this PR does / why we need it:
Subtask 3.2 of #24918.
REPLACEreplaces a conflicting row as delete-then-insert, so MySQL applies each referencing child table'sON DELETEaction against the removed parent row before the new row is inserted. The operator-basedREPLACEpath did not do this: replacing a referenced parent row succeeded and left child rows untouched.This generates parent-side FK background SQLs for the non-self-referencing child tables (
RefChildTbls) of the replaced parent. They are gated byforeign_key_checksand run before the mainREPLACEin the same transaction, so they roll back together if theREPLACElater fails:select count(*) = 0 ...pre-check that fails withErrFKRowIsReferenced(MySQL 1451) when a child still references a replaced parent value (prefixREPLACE_PARENT_CHK:).delete from child where fk in (pk values).update child set fk = null where fk in (pk values)(both prefixedREPLACE_PARENT_ACTION:).Limitations mirror the existing self-referencing pre-check path: only literal
VALUESand single-column FKs that reference the parent's single-columnPRIMARY KEYare handled; other shapes (prepared params, expressions, composite/non-PK references,REPLACE ... SELECT) are skipped.Behavior (matches MySQL)
Tests
RefChildTblsfixtures (RESTRICT / CASCADE / SET NULL / explicit columns / non-literal skip); coverage of the new generator is 77.3%.test/distributed/cases/dml/replace/replace.testfor all three actions.PR review point - Highlight it if your changes are based on aspect below: