From 04f0eb8041b5138980ece1d70967c11703586ebe Mon Sep 17 00:00:00 2001 From: Rex Johnston Date: Mon, 13 Jul 2026 15:32:58 +1200 Subject: [PATCH] MDEV-36610 Subquery wrongly eliminated by table elimination When equality propagation (build_equal_items()) merges an equality that contains a subquery, such as "t1.a = (SELECT ...)", with an outer join's ON equality, it can inject a reference to that subquery into the ON expression. If the join columns have compatible types the subquery ends up as the constant of a multiple equality (which Item::walk() skips); if they differ (e.g. BIGINT vs INT) the field cannot be merged and the subquery is substituted in as a plain "tbl.col = (SELECT ...)" argument. In the latter case, if that outer join is removed by table elimination, mark_as_eliminated() walks the ON expression and flags the shared Item_subselect as eliminated. The subquery, however, still lives in another part of the query and has to be executed, tripping DBUG_ASSERT(!eliminated) in Item_subselect::exec() (and, in release builds, disabling the subquery cache and hiding it from EXPLAIN). The surviving reference can be: - a WHERE/HAVING/select-list/ORDER/GROUP expression (subquery written there and pushed down into the eliminated ON), or - the ON expression of an outer join that was not eliminated (subquery written in a surviving outer ON and pushed down into an eliminated inner one). Fix: after table elimination, walk the expressions that survive into execution (WHERE, HAVING, select list, ORDER/GROUP BY and the ON expressions of outer joins that were not eliminated) and clear the "eliminated" flag on any subquery still reachable from them. Because a subquery can also be the constant of a multiple equality, and Item::walk() does not visit an Item_equal's constant, Item_equal gets an unmark_as_eliminated_processor() override that descends into its constant explicitly. Assisted by Claude Opus 4.8 --- mysql-test/main/table_elim.result | 48 +++++++++++++++++++++++ mysql-test/main/table_elim.test | 43 +++++++++++++++++++++ sql/item.h | 1 + sql/item_cmpfunc.cc | 18 +++++++++ sql/item_cmpfunc.h | 1 + sql/item_subselect.cc | 20 ++++++++++ sql/item_subselect.h | 1 + sql/opt_table_elimination.cc | 63 +++++++++++++++++++++++++++++++ 8 files changed, 195 insertions(+) diff --git a/mysql-test/main/table_elim.result b/mysql-test/main/table_elim.result index 4aac1c4fa3292..3926dc2f2b7fc 100644 --- a/mysql-test/main/table_elim.result +++ b/mysql-test/main/table_elim.result @@ -1086,5 +1086,53 @@ id select_type table type possible_keys key key_len ref rows Extra 1 SIMPLE t1 ALL NULL NULL NULL NULL 2 DROP TABLE t1, t2; # +# MDEV-36610: Subquery wrongly marked as eliminated by table elimination +# when equality propagation shares a WHERE-clause subquery +# into an eliminated outer join's ON expression +# +CREATE TABLE t0 (k INT); +INSERT INTO t0 VALUES (20),(9); +CREATE TABLE t1 (a BIGINT); +INSERT INTO t1 VALUES (1),(2); +CREATE TABLE t2 (b INT PRIMARY KEY); +INSERT INTO t2 VALUES (3),(4); +INSERT INTO t2 VALUES (5),(6); +CREATE TABLE t3 (c INT); +INSERT INTO t3 VALUES (2); +CREATE TABLE t4 (a BIGINT, x INT); +INSERT INTO t4 VALUES (2,20),(3,30); +# t2 is eliminated, but the subquery must still be shown and executed: +explain SELECT t1.* FROM t1 LEFT JOIN t2 ON (t2.b = t1.a) +WHERE t1.a = (SELECT c FROM t3); +id select_type table type possible_keys key key_len ref rows Extra +1 PRIMARY t1 ALL NULL NULL NULL NULL 2 Using where +2 SUBQUERY t3 system NULL NULL NULL NULL 1 +SELECT t1.* FROM t1 LEFT JOIN t2 ON (t2.b = t1.a) +WHERE t1.a = (SELECT c FROM t3); +a +2 +# +# Same problem, but the subquery is written in the ON expression of a +# surviving outer join and leaks into the ON of an eliminated inner one. +# Here only the surviving-ON sweep can clear the "eliminated" flag. +# The BIGINT vs INT mismatch keeps the subquery out of the (walk-skipped) +# multiple-equality constant, so it is reached as a plain equality arg. +# +# t2 is eliminated; the subquery must still be shown and executed: +explain SELECT t0.k, t4.x FROM t0 +LEFT JOIN (t4 LEFT JOIN t2 ON t2.b = t4.a) +ON (t0.k = t4.x AND t4.a = (SELECT c FROM t3)); +id select_type table type possible_keys key key_len ref rows Extra +1 PRIMARY t0 ALL NULL NULL NULL NULL 2 +1 PRIMARY t4 ALL NULL NULL NULL NULL 2 Using where; Using join buffer (flat, BNL join) +2 SUBQUERY t3 system NULL NULL NULL NULL 1 +SELECT t0.k, t4.x FROM t0 +LEFT JOIN (t4 LEFT JOIN t2 ON t2.b = t4.a) +ON (t0.k = t4.x AND t4.a = (SELECT c FROM t3)); +k x +20 20 +9 NULL +DROP TABLE t0, t1, t2, t3, t4; +# # End of 10.11 tests # diff --git a/mysql-test/main/table_elim.test b/mysql-test/main/table_elim.test index 376a738934811..9719c58436534 100644 --- a/mysql-test/main/table_elim.test +++ b/mysql-test/main/table_elim.test @@ -837,6 +837,49 @@ select t1.null_col from t1 left join t2 on (t2.unique_col<=>t1.notnull_col); DROP TABLE t1, t2; +--echo # +--echo # MDEV-36610: Subquery wrongly marked as eliminated by table elimination +--echo # when equality propagation shares a WHERE-clause subquery +--echo # into an eliminated outer join's ON expression +--echo # +CREATE TABLE t0 (k INT); +INSERT INTO t0 VALUES (20),(9); +CREATE TABLE t1 (a BIGINT); +INSERT INTO t1 VALUES (1),(2); +CREATE TABLE t2 (b INT PRIMARY KEY); +INSERT INTO t2 VALUES (3),(4); +INSERT INTO t2 VALUES (5),(6); +CREATE TABLE t3 (c INT); +INSERT INTO t3 VALUES (2); +CREATE TABLE t4 (a BIGINT, x INT); +INSERT INTO t4 VALUES (2,20),(3,30); + +--echo # t2 is eliminated, but the subquery must still be shown and executed: + +let $q= +SELECT t1.* FROM t1 LEFT JOIN t2 ON (t2.b = t1.a) + WHERE t1.a = (SELECT c FROM t3); +eval explain $q; +eval $q; + +--echo # +--echo # Same problem, but the subquery is written in the ON expression of a +--echo # surviving outer join and leaks into the ON of an eliminated inner one. +--echo # Here only the surviving-ON sweep can clear the "eliminated" flag. +--echo # The BIGINT vs INT mismatch keeps the subquery out of the (walk-skipped) +--echo # multiple-equality constant, so it is reached as a plain equality arg. +--echo # + +--echo # t2 is eliminated; the subquery must still be shown and executed: +let $q= +SELECT t0.k, t4.x FROM t0 +LEFT JOIN (t4 LEFT JOIN t2 ON t2.b = t4.a) +ON (t0.k = t4.x AND t4.a = (SELECT c FROM t3)); +eval explain $q; +eval $q; + +DROP TABLE t0, t1, t2, t3, t4; + --echo # --echo # End of 10.11 tests --echo # diff --git a/sql/item.h b/sql/item.h index 55ff46c7af80a..01e568c302b57 100644 --- a/sql/item.h +++ b/sql/item.h @@ -2299,6 +2299,7 @@ class Item :public Value_source, virtual bool enumerate_field_refs_processor(void *arg) { return 0; } virtual bool mark_as_eliminated_processor(void *arg) { return 0; } + virtual bool unmark_as_eliminated_processor(void *arg) { return 0; } virtual bool eliminate_subselect_processor(void *arg) { return 0; } virtual bool view_used_tables_processor(void *arg) { return 0; } virtual bool eval_not_null_tables(void *arg) { return 0; } diff --git a/sql/item_cmpfunc.cc b/sql/item_cmpfunc.cc index 5f2c471de7fba..b167c5fbc5032 100644 --- a/sql/item_cmpfunc.cc +++ b/sql/item_cmpfunc.cc @@ -7457,6 +7457,24 @@ bool Item_equal::walk(Item_processor processor, bool walk_subquery, void *arg) } +/* + A multiple equality keeps its optional constant as the head of equal_items, + and the generic walk() (via Item_equal_fields_iterator) does not visit it. + So reach it explicitly here: otherwise a subquery that equality propagation + turned into the constant of a surviving multiple equality would stay wrongly + flagged as eliminated by table elimination. See + Item_subselect::unmark_as_eliminated_processor(). +*/ + +bool Item_equal::unmark_as_eliminated_processor(void *arg) +{ + Item *c= get_const(); + if (c) + c->walk(&Item::unmark_as_eliminated_processor, FALSE, arg); + return FALSE; +} + + Item *Item_equal::transform(THD *thd, Item_transformer transformer, uchar *arg) { DBUG_ASSERT(!thd->stmt_arena->is_stmt_prepare()); diff --git a/sql/item_cmpfunc.h b/sql/item_cmpfunc.h index 66db4361d7253..a75b1eef510f9 100644 --- a/sql/item_cmpfunc.h +++ b/sql/item_cmpfunc.h @@ -3519,6 +3519,7 @@ class Item_equal: public Item_bool_func SARGABLE_PARAM **sargables) override; SEL_TREE *get_mm_tree(RANGE_OPT_PARAM *param, Item **cond_ptr) override; bool walk(Item_processor processor, bool walk_subquery, void *arg) override; + bool unmark_as_eliminated_processor(void *arg) override; Item *transform(THD *thd, Item_transformer transformer, uchar *arg) override; void print(String *str, enum_query_type query_type) override; const Type_handler *compare_type_handler() const { return m_compare_handler; } diff --git a/sql/item_subselect.cc b/sql/item_subselect.cc index 213a3d22fa742..ad31e7f6ebb9a 100644 --- a/sql/item_subselect.cc +++ b/sql/item_subselect.cc @@ -374,6 +374,19 @@ bool Item_subselect::enumerate_field_refs_processor(void *arg) return FALSE; } + +/* + Set/Clear the "eliminated" flag set by mark_as_eliminated_processor(). + + Table elimination marks every subquery reachable from an eliminated outer + join's ON expression as eliminated. However, equality propagation + (build_equal_items()) can inject a reference to a subquery that lives in + another part of the query (e.g. the WHERE clause) into that ON expression. + Such a subquery still has to be executed, so after elimination we walk the + surviving expressions and clear the flag on any subquery still referenced + from them. +*/ + bool Item_subselect::mark_as_eliminated_processor(void *arg) { eliminated= TRUE; @@ -381,6 +394,13 @@ bool Item_subselect::mark_as_eliminated_processor(void *arg) } +bool Item_subselect::unmark_as_eliminated_processor(void *arg) +{ + eliminated= FALSE; + return FALSE; +} + + /** Remove a subselect item from its unit so that the unit no longer represents a subquery. diff --git a/sql/item_subselect.h b/sql/item_subselect.h index b3b5e0ff3cc60..ef2fa8b1fe168 100644 --- a/sql/item_subselect.h +++ b/sql/item_subselect.h @@ -242,6 +242,7 @@ class Item_subselect :public Item_result_field, bool walk(Item_processor processor, bool walk_subquery, void *arg) override; bool unknown_splocal_processor(void *arg) override; bool mark_as_eliminated_processor(void *arg) override; + bool unmark_as_eliminated_processor(void *arg) override; bool eliminate_subselect_processor(void *arg) override; bool enumerate_field_refs_processor(void *arg) override; bool check_vcol_func_processor(void *arg) override diff --git a/sql/opt_table_elimination.cc b/sql/opt_table_elimination.cc index c93e5421c6f00..bf712794d2706 100644 --- a/sql/opt_table_elimination.cc +++ b/sql/opt_table_elimination.cc @@ -629,6 +629,44 @@ void add_module_expr(Dep_analysis_context *dac, Dep_module_expr **eq_mod, /*****************************************************************************/ +/* + Clear the "eliminated" flag on every subquery reachable from the given + expression (see Item_subselect::unmark_as_eliminated_processor). +*/ + +static inline void unmark_eliminated_subqueries(Item *expr) +{ + if (expr) + expr->walk(&Item::unmark_as_eliminated_processor, FALSE, NULL); +} + + +/* + Clear the "eliminated" flag on subqueries reachable from ON expressions of + outer joins that were NOT eliminated. Such an ON expression is still + evaluated at runtime. +*/ + +static void unmark_live_on_expr_subqueries(JOIN *join, + List *join_list) +{ + TABLE_LIST *tbl; + List_iterator it(*join_list); + while ((tbl= it++)) + { + if (tbl->nested_join) + { + unmark_live_on_expr_subqueries(join, &tbl->nested_join->join_list); + /* Only sweep this nest's ON expr if some of its tables survived. */ + if (tbl->nested_join->used_tables & ~join->eliminated_tables) + unmark_eliminated_subqueries(tbl->on_expr); + } + else if (tbl->table && !(tbl->table->map & join->eliminated_tables)) + unmark_eliminated_subqueries(tbl->on_expr); + } +} + + /* Perform table elimination @@ -769,6 +807,31 @@ void eliminate_tables(JOIN *join) eliminate_tables_for_list(join, join->join_list, all_tables, NULL, used_tables, &trace_eliminated_tables); } + + /* + Equality propagation (build_equal_items()) can inject into the ON + expression of an eliminated outer join, a reference to a subquery that + actually lives in another (surviving) part of the query. + mark_as_eliminated() then flags that subquery as eliminated even though + it still has to be executed. + Clear the flag on every subquery that is still reachable. + */ + if (join->eliminated_tables) + { + unmark_eliminated_subqueries(join->conds); + unmark_eliminated_subqueries(join->having); + + List_iterator live_it(join->fields_list); + while ((item= live_it++)) + unmark_eliminated_subqueries(item); + + for (ORDER *ord= join->order; ord; ord= ord->next) + unmark_eliminated_subqueries(*(ord->item)); + for (ORDER *ord= join->group_list; ord; ord= ord->next) + unmark_eliminated_subqueries(*(ord->item)); + + unmark_live_on_expr_subqueries(join, join->join_list); + } DBUG_VOID_RETURN; }