diff --git a/mysql-test/r/foreign_key_multi_table_dml.result b/mysql-test/r/foreign_key_multi_table_dml.result new file mode 100644 index 000000000000..d62bf0cd1897 --- /dev/null +++ b/mysql-test/r/foreign_key_multi_table_dml.result @@ -0,0 +1,264 @@ +# +# Multi-table DELETE with ON DELETE CASCADE +# +CREATE TABLE t1(id INT PRIMARY KEY, i INT); +INSERT INTO t1 VALUES (1, 1), (2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (7, 1); +CREATE TABLE t2( +id INT PRIMARY KEY, +t1_id INT, +FOREIGN KEY (t1_id) REFERENCES t1(id) ON DELETE CASCADE +); +INSERT INTO t2 VALUES +(1, 1), (2, 1), (3, 1), (4, 1), (5, NULL), (6, 6), (7, 7), (8, 1), (9, 2), +(10, 3), (11, 4), (12, 5), (13, 6), (14, 7), (15, 1), (16, 2), (17, 3), +(18, 4), (19, 5), (20, 6), (21, 7), (22, NULL), (23, 1), (24, 2), (25, 3); +ANALYZE TABLE t1, t2; +Table Op Msg_type Msg_text +test.t1 analyze status OK +test.t2 analyze status OK +DELETE t1 FROM t1, t2 WHERE t1.i = t2.id; +SELECT * FROM t1 ORDER BY id; +id i +SELECT * FROM t2 ORDER BY id; +id t1_id +5 NULL +22 NULL +DROP TABLE t2, t1; +# +# Multi-table DELETE with ON DELETE SET NULL +# +CREATE TABLE t1(id INT PRIMARY KEY, i INT); +INSERT INTO t1 VALUES (1, 1), (2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (7, 1); +CREATE TABLE t2( +id INT PRIMARY KEY, +t1_id INT, +FOREIGN KEY (t1_id) REFERENCES t1(id) ON DELETE SET NULL +); +INSERT INTO t2 VALUES +(1, 1), (2, 1), (3, 1), (4, 1), (5, NULL), (6, 6), (7, 7), (8, 1), (9, 2), +(10, 3), (11, 4), (12, 5), (13, 6), (14, 7), (15, 1), (16, 2), (17, 3), +(18, 4), (19, 5), (20, 6), (21, 7), (22, NULL), (23, 1), (24, 2), (25, 3); +ANALYZE TABLE t1, t2; +Table Op Msg_type Msg_text +test.t1 analyze status OK +test.t2 analyze status OK +DELETE t1 FROM t1, t2 WHERE t1.i = t2.id AND t1_id IS NOT NULL; +SELECT * FROM t1 ORDER BY id; +id i +SELECT * FROM t2 ORDER BY id; +id t1_id +1 NULL +2 NULL +3 NULL +4 NULL +5 NULL +6 NULL +7 NULL +8 NULL +9 NULL +10 NULL +11 NULL +12 NULL +13 NULL +14 NULL +15 NULL +16 NULL +17 NULL +18 NULL +19 NULL +20 NULL +21 NULL +22 NULL +23 NULL +24 NULL +25 NULL +DROP TABLE t2, t1; +# +# Multi-table DELETE cascading through a table that is not in the query +# +CREATE TABLE t1(id INT PRIMARY KEY, i INT); +INSERT INTO t1 VALUES (1, 1), (2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (7, 1); +CREATE TABLE t_mid( +id INT PRIMARY KEY, +t1_id INT, +FOREIGN KEY (t1_id) REFERENCES t1(id) ON DELETE CASCADE +); +INSERT INTO t_mid VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), +(7, 7); +CREATE TABLE t3( +id INT PRIMARY KEY, +mid_id INT, +FOREIGN KEY (mid_id) REFERENCES t_mid(id) ON DELETE CASCADE +); +INSERT INTO t3 VALUES +(1, 1), (2, 1), (3, 1), (4, 1), (5, NULL), (6, 6), (7, 7), (8, 1), (9, 2), +(10, 3), (11, 4), (12, 5), (13, 6), (14, 7), (15, 1), (16, 2), (17, 3), +(18, 4), (19, 5), (20, 6), (21, 7), (22, NULL), (23, 1), (24, 2), (25, 3); +ANALYZE TABLE t1, t_mid, t3; +Table Op Msg_type Msg_text +test.t1 analyze status OK +test.t_mid analyze status OK +test.t3 analyze status OK +DELETE t1 FROM t1, t3 WHERE t1.i = t3.id; +SELECT * FROM t1 ORDER BY id; +id i +SELECT * FROM t_mid ORDER BY id; +id t1_id +SELECT * FROM t3 ORDER BY id; +id mid_id +5 NULL +22 NULL +DROP TABLE t3, t_mid, t1; +# +# Multi-table DELETE reaching a table through both a delete path and +# an update path in a diamond-shaped foreign key graph +# +CREATE TABLE root(id INT PRIMARY KEY, i INT); +INSERT INTO root VALUES (1, 2), (2, 1); +CREATE TABLE a_update( +id INT PRIMARY KEY, +root_id INT UNIQUE, +FOREIGN KEY (root_id) REFERENCES root(id) ON DELETE SET NULL +); +INSERT INTO a_update VALUES (1, 1), (2, 2); +CREATE TABLE z_delete( +id INT PRIMARY KEY, +root_id INT, +FOREIGN KEY (root_id) REFERENCES root(id) ON DELETE CASCADE +); +INSERT INTO z_delete VALUES (1, 1); +CREATE TABLE common_child( +id INT PRIMARY KEY, +a_ref INT UNIQUE, +z_ref INT, +FOREIGN KEY (a_ref) REFERENCES a_update(root_id) ON UPDATE CASCADE, +FOREIGN KEY (z_ref) REFERENCES z_delete(id) ON DELETE CASCADE +); +INSERT INTO common_child VALUES (1, 1, NULL), (2, NULL, 1), (3, 2, NULL); +CREATE TABLE query_child( +id INT PRIMARY KEY, +common_ref INT, +FOREIGN KEY (common_ref) REFERENCES common_child(a_ref) ON UPDATE CASCADE +); +INSERT INTO query_child VALUES +(1, 1), (2, 2), (3, NULL), (4, NULL), (5, NULL), (6, NULL), (7, NULL), +(8, NULL), (9, NULL), (10, NULL), (11, NULL), (12, NULL), (13, NULL), +(14, NULL), (15, NULL), (16, NULL), (17, NULL), (18, NULL), (19, NULL), +(20, NULL), (21, NULL), (22, NULL), (23, NULL), (24, NULL), (25, NULL); +ANALYZE TABLE root, a_update, z_delete, common_child, query_child; +Table Op Msg_type Msg_text +test.root analyze status OK +test.a_update analyze status OK +test.z_delete analyze status OK +test.common_child analyze status OK +test.query_child analyze status OK +DELETE root FROM root JOIN query_child ON root.i = query_child.common_ref; +SELECT * FROM root ORDER BY id; +id i +DROP TABLE query_child, common_child, z_delete, a_update, root; +# +# Multi-table UPDATE with ON UPDATE SET NULL +# +CREATE TABLE t1(id INT PRIMARY KEY, u INT UNIQUE, i INT); +INSERT INTO t1 VALUES (1, 1, 2), (2, 2, 1); +CREATE TABLE t2( +id INT PRIMARY KEY, +t1_u INT, +FOREIGN KEY (t1_u) REFERENCES t1(u) ON UPDATE SET NULL +); +INSERT INTO t2 VALUES +(1, 1), (2, 2), (3, NULL), (4, NULL), (5, NULL), (6, NULL), (7, NULL), +(8, NULL), (9, NULL), (10, NULL), (11, NULL), (12, NULL), (13, NULL), +(14, NULL), (15, NULL), (16, NULL), (17, NULL), (18, NULL), (19, NULL), +(20, NULL), (21, NULL), (22, NULL), (23, NULL), (24, NULL), (25, NULL); +ANALYZE TABLE t1, t2; +Table Op Msg_type Msg_text +test.t1 analyze status OK +test.t2 analyze status OK +UPDATE t1 JOIN t2 ON t1.i = t2.t1_u +SET t1.u = t1.u + 10; +SELECT * FROM t1 ORDER BY id; +id u i +1 11 2 +2 12 1 +SELECT * FROM t2 ORDER BY id; +id t1_u +1 NULL +2 NULL +3 NULL +4 NULL +5 NULL +6 NULL +7 NULL +8 NULL +9 NULL +10 NULL +11 NULL +12 NULL +13 NULL +14 NULL +15 NULL +16 NULL +17 NULL +18 NULL +19 NULL +20 NULL +21 NULL +22 NULL +23 NULL +24 NULL +25 NULL +DROP TABLE t2, t1; +# +# Multi-table UPDATE with ON UPDATE CASCADE +# +CREATE TABLE t1(id INT PRIMARY KEY, u INT UNIQUE, i INT); +INSERT INTO t1 VALUES (1, 1, 2), (2, 2, 1); +CREATE TABLE t2( +id INT PRIMARY KEY, +t1_u INT, +FOREIGN KEY (t1_u) REFERENCES t1(u) ON UPDATE CASCADE +); +INSERT INTO t2 VALUES +(1, 1), (2, 2), (3, NULL), (4, NULL), (5, NULL), (6, NULL), (7, NULL), +(8, NULL), (9, NULL), (10, NULL), (11, NULL), (12, NULL), (13, NULL), +(14, NULL), (15, NULL), (16, NULL), (17, NULL), (18, NULL), (19, NULL), +(20, NULL), (21, NULL), (22, NULL), (23, NULL), (24, NULL), (25, NULL); +ANALYZE TABLE t1, t2; +Table Op Msg_type Msg_text +test.t1 analyze status OK +test.t2 analyze status OK +UPDATE t1 JOIN t2 ON t1.i = t2.t1_u +SET t1.u = t1.u + 10; +SELECT * FROM t1 ORDER BY id; +id u i +1 11 2 +2 12 1 +SELECT * FROM t2 ORDER BY id; +id t1_u +1 11 +2 12 +3 NULL +4 NULL +5 NULL +6 NULL +7 NULL +8 NULL +9 NULL +10 NULL +11 NULL +12 NULL +13 NULL +14 NULL +15 NULL +16 NULL +17 NULL +18 NULL +19 NULL +20 NULL +21 NULL +22 NULL +23 NULL +24 NULL +25 NULL +DROP TABLE t2, t1; diff --git a/mysql-test/t/foreign_key_multi_table_dml.test b/mysql-test/t/foreign_key_multi_table_dml.test new file mode 100644 index 000000000000..d82ac2cc4074 --- /dev/null +++ b/mysql-test/t/foreign_key_multi_table_dml.test @@ -0,0 +1,253 @@ +# ==== Purpose ==== +# +# Check that a multi-table DELETE or UPDATE on a table whose referential +# actions modify another table used by the same statement produces correct +# results. +# +# Before this fix, the first table in the join order could be modified while +# the join was still scanning. The referential action then deleted or updated +# rows of the other tables before the join had read them, so the join saw a +# mix of old and new rows. This gave wrong results on a single server and +# also broke row-based replication, since the row events logged for the +# statement no longer matched what the referential action had already done on +# the replica. +# +# ==== References ==== +# +# Bug#80821: Replication breaks if multi-table DELETE is used in conjunction +# with Foreign Key +# Bug#102586: Foreign Key ON DELETE CASCADE breaks with RBR and multiple-table +# DELETE +# +############################################################################### + +--echo # +--echo # Multi-table DELETE with ON DELETE CASCADE +--echo # + +CREATE TABLE t1(id INT PRIMARY KEY, i INT); + +INSERT INTO t1 VALUES (1, 1), (2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (7, 1); + +CREATE TABLE t2( + id INT PRIMARY KEY, + t1_id INT, + FOREIGN KEY (t1_id) REFERENCES t1(id) ON DELETE CASCADE +); + +INSERT INTO t2 VALUES + (1, 1), (2, 1), (3, 1), (4, 1), (5, NULL), (6, 6), (7, 7), (8, 1), (9, 2), + (10, 3), (11, 4), (12, 5), (13, 6), (14, 7), (15, 1), (16, 2), (17, 3), + (18, 4), (19, 5), (20, 6), (21, 7), (22, NULL), (23, 1), (24, 2), (25, 3); + +ANALYZE TABLE t1, t2; + +# Every row in t1 matches a row in t2, so all rows in t1 should be deleted, +# and the cascade should keep only the t2 rows that reference no t1 row. +DELETE t1 FROM t1, t2 WHERE t1.i = t2.id; + +SELECT * FROM t1 ORDER BY id; +SELECT * FROM t2 ORDER BY id; + +DROP TABLE t2, t1; + +--echo # +--echo # Multi-table DELETE with ON DELETE SET NULL +--echo # + +CREATE TABLE t1(id INT PRIMARY KEY, i INT); + +INSERT INTO t1 VALUES (1, 1), (2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (7, 1); + +CREATE TABLE t2( + id INT PRIMARY KEY, + t1_id INT, + FOREIGN KEY (t1_id) REFERENCES t1(id) ON DELETE SET NULL +); + +INSERT INTO t2 VALUES + (1, 1), (2, 1), (3, 1), (4, 1), (5, NULL), (6, 6), (7, 7), (8, 1), (9, 2), + (10, 3), (11, 4), (12, 5), (13, 6), (14, 7), (15, 1), (16, 2), (17, 3), + (18, 4), (19, 5), (20, 6), (21, 7), (22, NULL), (23, 1), (24, 2), (25, 3); + +ANALYZE TABLE t1, t2; + +# Every row in t1 matches a row in t2 that satisfies the predicate at the +# start of the statement, so all rows in t1 should be deleted, and the SET +# NULL action should clear t1_id in every t2 row. +DELETE t1 FROM t1, t2 WHERE t1.i = t2.id AND t1_id IS NOT NULL; + +SELECT * FROM t1 ORDER BY id; +SELECT * FROM t2 ORDER BY id; + +DROP TABLE t2, t1; + +--echo # +--echo # Multi-table DELETE cascading through a table that is not in the query +--echo # + +CREATE TABLE t1(id INT PRIMARY KEY, i INT); + +INSERT INTO t1 VALUES (1, 1), (2, 2), (3, 1), (4, 2), (5, 1), (6, 2), (7, 1); + +CREATE TABLE t_mid( + id INT PRIMARY KEY, + t1_id INT, + FOREIGN KEY (t1_id) REFERENCES t1(id) ON DELETE CASCADE +); + +INSERT INTO t_mid VALUES (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), + (7, 7); + +CREATE TABLE t3( + id INT PRIMARY KEY, + mid_id INT, + FOREIGN KEY (mid_id) REFERENCES t_mid(id) ON DELETE CASCADE +); + +INSERT INTO t3 VALUES + (1, 1), (2, 1), (3, 1), (4, 1), (5, NULL), (6, 6), (7, 7), (8, 1), (9, 2), + (10, 3), (11, 4), (12, 5), (13, 6), (14, 7), (15, 1), (16, 2), (17, 3), + (18, 4), (19, 5), (20, 6), (21, 7), (22, NULL), (23, 1), (24, 2), (25, 3); + +ANALYZE TABLE t1, t_mid, t3; + +# Deleting from t1 cascades into t_mid and from there into t3, which the +# query reads, even though t_mid itself is not in the query. Every row in t1 +# matches a row in t3, so all rows in t1 and t_mid should be deleted, and +# only the t3 rows that reference no t_mid row should remain. +DELETE t1 FROM t1, t3 WHERE t1.i = t3.id; + +SELECT * FROM t1 ORDER BY id; +SELECT * FROM t_mid ORDER BY id; +SELECT * FROM t3 ORDER BY id; + +DROP TABLE t3, t_mid, t1; + +--echo # +--echo # Multi-table DELETE reaching a table through both a delete path and +--echo # an update path in a diamond-shaped foreign key graph +--echo # + +CREATE TABLE root(id INT PRIMARY KEY, i INT); + +INSERT INTO root VALUES (1, 2), (2, 1); + +CREATE TABLE a_update( + id INT PRIMARY KEY, + root_id INT UNIQUE, + FOREIGN KEY (root_id) REFERENCES root(id) ON DELETE SET NULL +); + +INSERT INTO a_update VALUES (1, 1), (2, 2); + +CREATE TABLE z_delete( + id INT PRIMARY KEY, + root_id INT, + FOREIGN KEY (root_id) REFERENCES root(id) ON DELETE CASCADE +); + +INSERT INTO z_delete VALUES (1, 1); + +CREATE TABLE common_child( + id INT PRIMARY KEY, + a_ref INT UNIQUE, + z_ref INT, + FOREIGN KEY (a_ref) REFERENCES a_update(root_id) ON UPDATE CASCADE, + FOREIGN KEY (z_ref) REFERENCES z_delete(id) ON DELETE CASCADE +); + +INSERT INTO common_child VALUES (1, 1, NULL), (2, NULL, 1), (3, 2, NULL); + +CREATE TABLE query_child( + id INT PRIMARY KEY, + common_ref INT, + FOREIGN KEY (common_ref) REFERENCES common_child(a_ref) ON UPDATE CASCADE +); + +INSERT INTO query_child VALUES + (1, 1), (2, 2), (3, NULL), (4, NULL), (5, NULL), (6, NULL), (7, NULL), + (8, NULL), (9, NULL), (10, NULL), (11, NULL), (12, NULL), (13, NULL), + (14, NULL), (15, NULL), (16, NULL), (17, NULL), (18, NULL), (19, NULL), + (20, NULL), (21, NULL), (22, NULL), (23, NULL), (24, NULL), (25, NULL); + +ANALYZE TABLE root, a_update, z_delete, common_child, query_child; + +# common_child is reachable from root both through a pure delete path +# (root -> z_delete -> common_child) and through a path that updates its +# rows (root SET NULLs a_update.root_id, which cascades into +# common_child.a_ref). Only the update path reaches query_child, which the +# query reads, so both root rows should be deleted. +DELETE root FROM root JOIN query_child ON root.i = query_child.common_ref; + +SELECT * FROM root ORDER BY id; + +DROP TABLE query_child, common_child, z_delete, a_update, root; + +--echo # +--echo # Multi-table UPDATE with ON UPDATE SET NULL +--echo # + +CREATE TABLE t1(id INT PRIMARY KEY, u INT UNIQUE, i INT); + +INSERT INTO t1 VALUES (1, 1, 2), (2, 2, 1); + +CREATE TABLE t2( + id INT PRIMARY KEY, + t1_u INT, + FOREIGN KEY (t1_u) REFERENCES t1(u) ON UPDATE SET NULL +); + +INSERT INTO t2 VALUES + (1, 1), (2, 2), (3, NULL), (4, NULL), (5, NULL), (6, NULL), (7, NULL), + (8, NULL), (9, NULL), (10, NULL), (11, NULL), (12, NULL), (13, NULL), + (14, NULL), (15, NULL), (16, NULL), (17, NULL), (18, NULL), (19, NULL), + (20, NULL), (21, NULL), (22, NULL), (23, NULL), (24, NULL), (25, NULL); + +ANALYZE TABLE t1, t2; + +# The updated column is a referenced secondary unique key, so nothing else +# stops an immediate update. Each row in t1 matches a row in t2 at the start +# of the statement, so both rows in t1 should be updated, and the SET NULL +# action should clear t1_u in both referencing t2 rows. +UPDATE t1 JOIN t2 ON t1.i = t2.t1_u + SET t1.u = t1.u + 10; + +SELECT * FROM t1 ORDER BY id; +SELECT * FROM t2 ORDER BY id; + +DROP TABLE t2, t1; + +--echo # +--echo # Multi-table UPDATE with ON UPDATE CASCADE +--echo # + +CREATE TABLE t1(id INT PRIMARY KEY, u INT UNIQUE, i INT); + +INSERT INTO t1 VALUES (1, 1, 2), (2, 2, 1); + +CREATE TABLE t2( + id INT PRIMARY KEY, + t1_u INT, + FOREIGN KEY (t1_u) REFERENCES t1(u) ON UPDATE CASCADE +); + +INSERT INTO t2 VALUES + (1, 1), (2, 2), (3, NULL), (4, NULL), (5, NULL), (6, NULL), (7, NULL), + (8, NULL), (9, NULL), (10, NULL), (11, NULL), (12, NULL), (13, NULL), + (14, NULL), (15, NULL), (16, NULL), (17, NULL), (18, NULL), (19, NULL), + (20, NULL), (21, NULL), (22, NULL), (23, NULL), (24, NULL), (25, NULL); + +ANALYZE TABLE t1, t2; + +# The updated column is a referenced secondary unique key, so nothing else +# stops an immediate update. Each row in t1 matches a row in t2 at the start +# of the statement, so both rows in t1 should be updated, and the cascade +# should add 10 to t1_u in both referencing t2 rows. +UPDATE t1 JOIN t2 ON t1.i = t2.t1_u + SET t1.u = t1.u + 10; + +SELECT * FROM t1 ORDER BY id; +SELECT * FROM t2 ORDER BY id; + +DROP TABLE t2, t1; diff --git a/sql/join_optimizer/join_optimizer.cc b/sql/join_optimizer/join_optimizer.cc index 90fba5d03d50..ee54d1cdb977 100644 --- a/sql/join_optimizer/join_optimizer.cc +++ b/sql/join_optimizer/join_optimizer.cc @@ -4841,8 +4841,7 @@ bool CostingReceiver::evaluate_secondary_engine_optimizer_state_request() { m_subgraph_pair_limit = restart_parameters.subgraph_pair_limit; DBUG_EXECUTE_IF("verify_hyp_opt_sg_pair_requested", { if (TraceStarted(m_thd) && m_subgraph_pair_limit > 0) { - Trace(m_thd) << "Hypergraph non zero SG pairs requested" - << "\n"; + Trace(m_thd) << "Hypergraph non zero SG pairs requested" << "\n"; } }); return true; @@ -7406,6 +7405,14 @@ bool IsImmediateDeleteCandidate(const Table_ref *table_ref, return false; } + // Cannot delete from the table immediately if the delete cascades to another + // table in the query, as the cascade would remove rows that the query still + // reads and deletes itself. See Bug#80821 and Bug#102586. + if (fk_actions_affect_queried_table(table_ref, query_block, + /*is_delete=*/true)) { + return false; + } + return true; } @@ -7435,6 +7442,14 @@ bool IsImmediateUpdateCandidate(const Table_ref *table_ref, int node_idx, return false; } + // Cannot update the table immediately if its referential actions can + // modify rows of another table in the query. See Bug#80821 and + // Bug#102586. + if (fk_actions_affect_queried_table(table_ref, graph.query_block(), + /*is_delete=*/false)) { + return false; + } + TABLE *const table = table_ref->table; // Cannot update the table immediately if it modifies a partitioning column, @@ -10115,8 +10130,7 @@ static AccessPath *FindBestQueryPlanInner(THD *thd, Query_block *query_block, DBUG_EXECUTE_IF("verify_hyp_opt_sg_pair_requested", { if (TraceStarted(thd) && root_path_quality_status.subgraph_pair_limit > 0) { - Trace(thd) << "Hypergraph non zero SG pairs reset requested" - << "\n"; + Trace(thd) << "Hypergraph non zero SG pairs reset requested" << "\n"; } }); return nullptr; diff --git a/sql/sql_base.cc b/sql/sql_base.cc index 2c28c089dd59..57d9f608e92a 100644 --- a/sql/sql_base.cc +++ b/sql/sql_base.cc @@ -36,6 +36,7 @@ #include #include #include +#include #include "ft_global.h" #include "m_string.h" @@ -2256,6 +2257,124 @@ Table_ref *unique_table(const Table_ref *table, Table_ref *table_list, return dup; } +/// True if this referential action modifies rows in the referencing table. +static bool fk_rule_modifies_child(dd::Foreign_key::enum_rule rule) { + return rule != dd::Foreign_key::RULE_RESTRICT && + rule != dd::Foreign_key::RULE_NO_ACTION; +} + +/// Find the share of an open table matching the given name, or nullptr. +/// The list is walked through next_global, so prelocked tables are seen too. +static const TABLE_SHARE *find_open_table_share(const Table_ref *tables, + const char *db, + const char *table_name) { + for (const Table_ref *tl = tables; tl != nullptr; tl = tl->next_global) { + if (tl->table == nullptr) continue; + const TABLE_SHARE *share = tl->table->s; + if (my_strcasecmp(table_alias_charset, share->db.str, db) == 0 && + my_strcasecmp(table_alias_charset, share->table_name.str, table_name) == + 0) + return share; + } + return nullptr; +} + +/** + Test whether modifying rows of the subject table of a multi-table DELETE + or UPDATE can, through referential actions, modify rows of another table + which the same statement reads. + + Modifying such a table while the join is still scanning gives wrong + results: the referential action deletes or updates rows of the other + table that the join has not read yet, so the join sees a mix of old and + new rows. It also breaks row-based replication, since the row events + logged for the statement no longer match what the referential action + already did on the replica. Deferring the modification until the join has + finished avoids both. + + Every referential action except RESTRICT and NO ACTION modifies rows in + the referencing table. The check follows the actions transitively: a + delete cascading from t1 into t2 can trigger t2's own referential actions + into t3, so t3 being part of the query makes immediate deletes from t1 + unsafe even when t2 is not in the query. Whether a table's children are + affected through their delete rule or their update rule depends on + whether the action deletes or updates that table's rows. + + @param table table to be checked (must be updatable base table) + @param query_block query block of the DELETE or UPDATE statement + @param is_delete true for DELETE, false for UPDATE + + @retval true A referential action triggered by modifying @p table can + modify rows of a table read by the query. + @retval false No such dependency within the query. +*/ + +bool fk_actions_affect_queried_table(const Table_ref *table, + const Query_block *query_block, + bool is_delete) { + assert(table->table != nullptr); + + const Table_ref *all_tables = query_block->parent_lex->query_tables; + + // Depth-first walk over the tables whose rows the statement's referential + // actions may modify. The bool tracks whether rows of that table get + // deleted (true) or updated (false), which decides whether its children + // are affected through their delete rule or their update rule. Since the + // two rules can lead to different descendants, a table reached both ways + // must be walked once per state, so visited entries are (table, state) + // pairs rather than tables. + std::vector> pending; + std::vector> visited; + pending.emplace_back(table->table->s, is_delete); + visited.emplace_back(table->table->s, is_delete); + + while (!pending.empty()) { + const auto [share, rows_deleted] = pending.back(); + pending.pop_back(); + + for (const TABLE_SHARE_FOREIGN_KEY_PARENT_INFO *fk_p = + share->foreign_key_parent; + fk_p < share->foreign_key_parent + share->foreign_key_parents; + ++fk_p) { + const dd::Foreign_key::enum_rule rule = + rows_deleted ? fk_p->delete_rule : fk_p->update_rule; + if (!fk_rule_modifies_child(rule)) continue; + + // A modified child that the query reads makes immediate modification + // of the subject table unsafe. + for (const Table_ref *tl = query_block->leaf_tables; tl != nullptr; + tl = tl->next_leaf) { + if (tl->table == nullptr) continue; // View or derived table. + const TABLE_SHARE *leaf_share = tl->table->s; + if (my_strcasecmp(table_alias_charset, leaf_share->db.str, + fk_p->referencing_table_db.str) == 0 && + my_strcasecmp(table_alias_charset, leaf_share->table_name.str, + fk_p->referencing_table_name.str) == 0) + return true; + } + + // Follow the chain: the child's own referential actions may modify + // further tables. The child is not among the open tables when the + // storage engine handles referential actions internally, so that + // prelocking did not add it; assume the worst in that case, since the + // engine-internal action poses the same hazard. + const TABLE_SHARE *child_share = + find_open_table_share(all_tables, fk_p->referencing_table_db.str, + fk_p->referencing_table_name.str); + if (child_share == nullptr) return true; + const std::pair child_state( + child_share, rows_deleted && rule == dd::Foreign_key::RULE_CASCADE); + if (std::find(visited.begin(), visited.end(), child_state) == + visited.end()) { + visited.push_back(child_state); + pending.push_back(child_state); + } + } + } + + return false; +} + /** Issue correct error message in case we found 2 duplicate tables which prevent some update operation @@ -3182,7 +3301,7 @@ bool open_table(THD *thd, Table_ref *table_list, Open_table_context *ot_ctx) { } else if (table_list->open_strategy == Table_ref::OPEN_STUB) return false; -retry_share : { +retry_share: { Table_cache *tc = table_cache_manager.get_cache(thd); tc->lock(); diff --git a/sql/sql_base.h b/sql/sql_base.h index 820dbe88a5d8..dd1f67ba043f 100644 --- a/sql/sql_base.h +++ b/sql/sql_base.h @@ -302,6 +302,9 @@ void close_thread_table(THD *thd, TABLE **table_ptr); bool close_temporary_tables(THD *thd); Table_ref *unique_table(const Table_ref *table, Table_ref *table_list, bool check_alias); +bool fk_actions_affect_queried_table(const Table_ref *table, + const Query_block *query_block, + bool is_delete); void drop_temporary_table(THD *thd, Table_ref *table_list); void close_temporary_table(THD *thd, TABLE *table, bool free_share, bool delete_table); diff --git a/sql/sql_delete.cc b/sql/sql_delete.cc index 05002ceedf21..18ecd2a3de08 100644 --- a/sql/sql_delete.cc +++ b/sql/sql_delete.cc @@ -631,8 +631,8 @@ bool Sql_cmd_delete::delete_from_single_table(THD *thd) { break; } - DBUG_LOG("jdv_dml", "DML-DELETE: " - << " table_list->field_translation->name: " + DBUG_LOG("jdv_dml", + "DML-DELETE: " << " table_list->field_translation->name: " << table_list->field_translation->name << " ->type():" << table_list->field_translation->item->type()); @@ -1328,11 +1328,15 @@ table_map GetImmediateDeleteTables(const JOIN *join, table_map delete_tables) { tr = tr->next_leaf) { if (!tr->is_deleted()) continue; - if (unique_table(tr, join->tables_list, false) != nullptr) { + if (unique_table(tr, join->tables_list, false) != nullptr || + fk_actions_affect_queried_table(tr, join->query_block, + /*is_delete=*/true)) { /* If the table being deleted from is also referenced in the query, defer delete so that the delete doesn't interfere with reading of this - table. + table. The same applies if deleting from the table cascades to another + table in the query, since the cascade would remove rows that the query + still reads and deletes itself. See Bug#80821 and Bug#102586. */ return 0; } diff --git a/sql/sql_update.cc b/sql/sql_update.cc index 790e7d26ac36..d9607dc44389 100644 --- a/sql/sql_update.cc +++ b/sql/sql_update.cc @@ -188,8 +188,8 @@ bool Sql_cmd_update::precheck(THD *thd) { if (chk(SELECT_ACL)) return true; } } // else - } // for - } // else + } // for + } // else return false; } @@ -2130,6 +2130,12 @@ static bool safe_update_on_fly(const QEP_TAB *join_tab, // Check that the table is not joined to itself: if (unique_table(table_ref, all_tables, false)) return false; + // Check that updating the table cannot, through referential actions, + // modify rows of another table in the query. See Bug#80821 and + // Bug#102586. + if (fk_actions_affect_queried_table(table_ref, join_tab->join()->query_block, + /*is_delete=*/false)) + return false; if (table->part_info && // if there is risk for a row to move in a next partition, in which case // it may be read twice: