diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c index 7bbddb8bee4..ca653439b01 100644 --- a/src/backend/optimizer/path/costsize.c +++ b/src/backend/optimizer/path/costsize.c @@ -162,6 +162,7 @@ bool enable_partitionwise_join = false; bool enable_partitionwise_aggregate = false; bool enable_parallel_append = true; bool enable_parallel_hash = true; +int or_expansion_limit = 8; bool enable_partition_pruning = true; bool enable_presorted_aggregate = true; bool enable_async_append = true; diff --git a/src/backend/optimizer/plan/Makefile b/src/backend/optimizer/plan/Makefile index 80ef162e484..3abff3baf65 100644 --- a/src/backend/optimizer/plan/Makefile +++ b/src/backend/optimizer/plan/Makefile @@ -18,6 +18,7 @@ OBJS = \ initsplan.o \ planagg.o \ planmain.o \ + planorexpand.o \ planner.o \ setrefs.o \ subselect.o diff --git a/src/backend/optimizer/plan/meson.build b/src/backend/optimizer/plan/meson.build index c565b2adbcc..7e900b3107b 100644 --- a/src/backend/optimizer/plan/meson.build +++ b/src/backend/optimizer/plan/meson.build @@ -6,6 +6,7 @@ backend_sources += files( 'initsplan.c', 'planagg.c', 'planmain.c', + 'planorexpand.c', 'planner.c', 'setrefs.c', 'subselect.c', diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index edaa42d713a..f0f0e526fe0 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -1850,6 +1850,7 @@ grouping_planner(PlannerInfo *root, double tuple_fraction, List *activeWindows = NIL; grouping_sets_data *gset_data = NULL; standard_qp_extra qp_extra; + List *or_cands; /* A recursive query should always have setOperations */ Assert(!root->hasRecursion); @@ -1925,6 +1926,11 @@ grouping_planner(PlannerInfo *root, double tuple_fraction, if (parse->hasAggs) preprocess_minmax_aggregates(root); + /* + * Consider expanding eligible OR clauses to UNION ALL (Append) paths. + */ + or_cands = plan_or_expansion_arms(root); + /* * Figure out whether there's a hard limit on the number of rows that * query_planner's result subplan needs to return. Even if we know a @@ -1961,6 +1967,13 @@ grouping_planner(PlannerInfo *root, double tuple_fraction, */ current_rel = query_planner(root, standard_qp_callback, &qp_extra); + /* + * If we generated any OR-expansion candidates, add the resulting + * Append path(s) to the topmost scan/join relation. + */ + if (or_cands != NIL) + add_or_expansion_paths(root, current_rel, or_cands); + /* * Convert the query's result tlist into PathTarget format. * diff --git a/src/backend/optimizer/plan/planorexpand.c b/src/backend/optimizer/plan/planorexpand.c new file mode 100644 index 00000000000..6266c244f56 --- /dev/null +++ b/src/backend/optimizer/plan/planorexpand.c @@ -0,0 +1,531 @@ +/*------------------------------------------------------------------------- + * + * planorexpand.c + * Consider whether OR clauses in join queries can be expanded to + * UNION ALL (Append) paths. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/optimizer/plan/planorexpand.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "catalog/pg_class.h" +#include "catalog/pg_type.h" +#include "nodes/makefuncs.h" +#include "nodes/nodeFuncs.h" +#include "optimizer/clauses.h" +#include "optimizer/cost.h" +#include "optimizer/optimizer.h" +#include "optimizer/pathnode.h" +#include "optimizer/planmain.h" +#include "optimizer/prep.h" +#include "optimizer/tlist.h" + + +static bool is_query_safe_for_or_expansion(PlannerInfo *root); +static void or_expansion_qp_callback(PlannerInfo *root, void *extra); +static List *create_or_expansion_subpaths(PlannerInfo *root, BoolExpr *orclause, + int qual_idx); +static bool has_outer_joins(Node *jtnode); +static bool match_target_expr(Node *want, Node *have); +static Path *or_expansion_align_path(PlannerInfo *root, RelOptInfo *joinrel, Path *arm); + +/* + * is_query_safe_for_or_expansion + * Verify query-level safety gates for OR expansion. + */ +static bool +is_query_safe_for_or_expansion(PlannerInfo *root) +{ + Query *parse = root->parse; + ListCell *lc; + + /* + * Only top-level queries may be expanded. Expanding within subqueries + * leads to extreme planning overhead with nested subqueries. + */ + if (root->parent_root != NULL) + return false; + + /* Volatile functions in jointree (WHERE or FROM) are unsafe to duplicate */ + if (contain_volatile_functions((Node *) parse->jointree)) + return false; + + /* + * Reject if the jointree contains any SubPlan or SubLink (in WHERE or + * outer join ON conditions). At this point, SS_process_sublinks() has + * already converted sublinks into SubPlan nodes; contain_subplans() + * catches SubPlans, InitPlans, and any remaining SubLinks. + */ + if (contain_subplans((Node *) parse->jointree)) + return false; + + /* Disallow queries with lateral references */ + if (root->hasLateralRTEs) + return false; + + /* + * Check RTEs. OR-expansion is strictly designed for multi-table joins + * across base relations. Disallow non-base relations (derived subqueries, + * functions, values lists) and any relation subject to inheritance or + * partitioning. Those subsystems dynamically create subroots or modify + * the range table during planning, leaving side-channel state that cannot + * be preserved across independent arm planning. + */ + foreach(lc, parse->rtable) + { + RangeTblEntry *rte = (RangeTblEntry *) lfirst(lc); + + if (rte->rtekind == RTE_JOIN) + { + if (rte->jointype == JOIN_FULL) + return false; + continue; + } + + /* Only plain base relations are permitted */ + if (rte->rtekind != RTE_RELATION) + return false; + + /* Disallow partitioned tables and traditional table inheritance */ + if (rte->inh || rte->relkind == RELKIND_PARTITIONED_TABLE) + return false; + + /* Disallow tablesample (arms could sample different rows) */ + if (rte->tablesample != NULL) + return false; + + /* Security barrier quals must not contain volatile functions */ + if (contain_volatile_functions((Node *) rte->securityQuals)) + return false; + } + + return true; +} + +static bool +has_outer_joins(Node *jtnode) +{ + if (jtnode == NULL) + return false; + if (IsA(jtnode, FromExpr)) + { + FromExpr *f = (FromExpr *) jtnode; + ListCell *lc; + + foreach(lc, f->fromlist) + { + if (has_outer_joins(lfirst(lc))) + return true; + } + return false; + } + if (IsA(jtnode, JoinExpr)) + { + JoinExpr *j = (JoinExpr *) jtnode; + + if (IS_OUTER_JOIN(j->jointype)) + return true; + return has_outer_joins(j->larg) || has_outer_joins(j->rarg); + } + + return false; +} + +/* + * or_expansion_qp_callback + * Query planner callback for subroot arm planning. + */ +static void +or_expansion_qp_callback(PlannerInfo *root, void *extra) +{ + /* + * Ordering is destroyed through UNION ALL / Append, so clear pathkeys + * that might otherwise restrict lower paths. + */ + root->group_pathkeys = NIL; + root->window_pathkeys = NIL; + root->distinct_pathkeys = NIL; + root->sort_pathkeys = NIL; + root->query_pathkeys = NIL; +} + +/* + * create_or_expansion_subpaths + * Create best paths for each arm of the candidate OR clause. + */ +static List * +create_or_expansion_subpaths(PlannerInfo *root, BoolExpr *orclause, int qual_idx) +{ + List *subpaths = NIL; + int num_arms = list_length(orclause->args); + int k; + + for (k = 0; k < num_arms; k++) + { + PlannerInfo *subroot; + Query *parse; + List *arm_quals = NIL; + ListCell *lc; + int q_idx; + Node *arm_k; + int j; + RelOptInfo *final_rel; + Path *subpath; + + /* Clone PlannerInfo state modeled on planagg.c */ + subroot = (PlannerInfo *) palloc(sizeof(PlannerInfo)); + memcpy(subroot, root, sizeof(PlannerInfo)); + subroot->parse = parse = (Query *) copyObject(root->parse); + + /* + * subroot will set up its own rel/rte arrays inside query_planner. + */ + subroot->simple_rel_array = NULL; + subroot->simple_rte_array = NULL; + subroot->simple_rel_array_size = 0; + + subroot->processed_tlist = (List *) copyObject(root->processed_tlist); + subroot->append_rel_list = (List *) copyObject(root->append_rel_list); + subroot->tuple_fraction = 1.0; + subroot->limit_tuples = -1.0; + + /* + * give subroot its own JoinDomain and reset planner working lists so + * query_planner(subroot) does not corrupt root's state. + */ + subroot->join_domains = list_make1(makeNode(JoinDomain)); + subroot->plan_params = NIL; + subroot->outer_params = NULL; + subroot->rowMarks = NIL; + subroot->init_plans = NIL; + subroot->agginfos = NIL; + subroot->aggtransinfos = NIL; + subroot->join_info_list = NIL; + subroot->eq_classes = NIL; + subroot->placeholder_list = NIL; + subroot->placeholder_array = NULL; + subroot->placeholder_array_size = 0; + subroot->left_join_clauses = NIL; + subroot->right_join_clauses = NIL; + subroot->full_join_clauses = NIL; + subroot->fkey_list = NIL; + subroot->initial_rels = NIL; + subroot->last_rinfo_serial = 0; + + /* + * Build arm quals: R AND C_k AND (C_0 IS NOT TRUE) AND ... AND + * (C_{k-1} IS NOT TRUE) + */ + q_idx = 0; + foreach(lc, (List *) root->parse->jointree->quals) + { + if (q_idx != qual_idx) + arm_quals = lappend(arm_quals, copyObject(lfirst(lc))); + q_idx++; + } + + /* Add arm k's disjunct, flattened if it is an AND expression */ + arm_k = (Node *) copyObject(list_nth(orclause->args, k)); + arm_quals = list_concat(arm_quals, make_ands_implicit((Expr *) arm_k)); + + /* Add null-safe exclusion conditions for all previous arms */ + for (j = 0; j < k; j++) + { + Node *prev_arm = (Node *) copyObject(list_nth(orclause->args, j)); + BooleanTest *btest; + + btest = makeNode(BooleanTest); + btest->arg = (Expr *) prev_arm; + btest->booltesttype = IS_NOT_TRUE; + btest->location = -1; + + arm_quals = lappend(arm_quals, btest); + } + + parse->jointree->quals = (Node *) arm_quals; + + /* Re-run reduce_outer_joins if rtable contains outer joins */ + if (has_outer_joins((Node *) parse->jointree)) + reduce_outer_joins(subroot); + + /* Plan this arm */ + final_rel = query_planner(subroot, or_expansion_qp_callback, NULL); + + if (!final_rel || !final_rel->cheapest_total_path || + final_rel->cheapest_total_path->param_info != NULL) + return NIL; + + subpath = final_rel->cheapest_total_path; + subpaths = lappend(subpaths, subpath); + } + + return subpaths; +} + +/* + * plan_or_expansion_arms + * Check eligibility and generate subpaths for OR clauses in top-level WHERE. + * Called by grouping_planner() before query_planner(). + */ +List * +plan_or_expansion_arms(PlannerInfo *root) +{ + List *results = NIL; + Query *parse = root->parse; + bool checked_query = false; + ListCell *lc; + int qual_idx; + + /* Cheap gates first */ + if (or_expansion_limit < 2) + return NIL; + + if (parse->commandType != CMD_SELECT || + parse->rowMarks != NIL || + parse->setOperations != NULL || + parse->cteList != NIL || + root->hasRecursion) + return NIL; + + if (list_length(parse->rtable) < 2) + return NIL; + + if (parse->jointree == NULL || parse->jointree->quals == NULL) + return NIL; + + /* Scan top-level WHERE quals for eligible OR clauses */ + qual_idx = 0; + foreach(lc, (List *) parse->jointree->quals) + { + Node *qual = (Node *) lfirst(lc); + + if (is_orclause(qual)) + { + BoolExpr *orclause = (BoolExpr *) qual; + int num_arms = list_length(orclause->args); + Relids all_arm_varnos = NULL; + ListCell *lc_arm; + + if (num_arms < 2 || num_arms > or_expansion_limit) + { + qual_idx++; + continue; + } + + /* Structural case gate: check referenced relations */ + foreach(lc_arm, orclause->args) + { + Node *arm = (Node *) lfirst(lc_arm); + Relids arm_varnos = pull_varnos(root, arm); + + all_arm_varnos = bms_join(all_arm_varnos, arm_varnos); + } + + /* + * Require the OR clause to reference at least 2 distinct + * relations. + */ + if (bms_num_members(all_arm_varnos) < 2) + { + bms_free(all_arm_varnos); + qual_idx++; + continue; + } + bms_free(all_arm_varnos); + + /* Query-level safety check */ + if (!checked_query) + { + if (!is_query_safe_for_or_expansion(root)) + return NIL; + checked_query = true; + } + + /* Transform OR clause and generate subpaths for each arm */ + { + List *subpaths = create_or_expansion_subpaths(root, orclause, qual_idx); + + if (subpaths != NIL) + results = lappend(results, subpaths); + } + } + qual_idx++; + } + + return results; +} + +/* + * match_target_expr + * Check if two targetlist expressions match, ignoring varnullingrels + * differences that may arise from outer join reduction in subroot. + */ +static bool +match_target_expr(Node *want, Node *have) +{ + if (want == have) + return true; + if (want == NULL || have == NULL) + return false; + if (nodeTag(want) != nodeTag(have)) + return false; + + if (IsA(want, Var)) + { + Var *v1 = (Var *) want; + Var *v2 = (Var *) have; + + return (v1->varno == v2->varno && + v1->varattno == v2->varattno && + v1->varlevelsup == v2->varlevelsup); + } + + if (IsA(want, PlaceHolderVar)) + { + PlaceHolderVar *p1 = (PlaceHolderVar *) want; + PlaceHolderVar *p2 = (PlaceHolderVar *) have; + + return (p1->phid == p2->phid && + p1->phlevelsup == p2->phlevelsup); + } + + return equal(want, have); +} + +/* + * or_expansion_align_path + * Ensure the arm path emits columns in the exact order needed by joinrel. + * If column order differs, wrap with a projection using the arm's own + * expressions. + */ +static Path * +or_expansion_align_path(PlannerInfo *root, RelOptInfo *joinrel, Path *arm) +{ + List *want = joinrel->reltarget->exprs; + List *have = arm->pathtarget->exprs; + List *new_exprs = NIL; + bool identical = true; + ListCell *lc_want; + ListCell *lc_have; + PathTarget *newtarget; + + if (list_length(want) != list_length(have)) + identical = false; + + lc_have = list_head(have); + foreach(lc_want, want) + { + Node *want_node = (Node *) lfirst(lc_want); + Node *matched = NULL; + ListCell *lc; + + if (identical && lc_have != NULL) + { + if (match_target_expr(want_node, (Node *) lfirst(lc_have))) + { + matched = (Node *) lfirst(lc_have); + lc_have = lnext(have, lc_have); + } + else + identical = false; + } + + if (matched == NULL) + { + foreach(lc, have) + { + Node *have_node = (Node *) lfirst(lc); + + if (match_target_expr(want_node, have_node)) + { + matched = have_node; + break; + } + } + } + + if (matched == NULL) + return NULL; + + new_exprs = lappend(new_exprs, matched); + } + + if (identical) + return arm; + + newtarget = create_empty_pathtarget(); + newtarget->exprs = new_exprs; + set_pathtarget_cost_width(root, newtarget); + + /* Apply projection in-place if supported, or wrap with a ProjectionPath */ + return apply_projection_to_path(root, joinrel, arm, newtarget); +} + +/* + * add_or_expansion_paths + * Combine arm subpaths into an AppendPath and add to the top join rel. + * Called by grouping_planner() right after query_planner(). + */ +void +add_or_expansion_paths(PlannerInfo *root, RelOptInfo *joinrel, List *or_cands) +{ + ListCell *lc; + bool added_any = false; + + if (joinrel == NULL || joinrel->reltarget == NULL) + return; + + foreach(lc, or_cands) + { + List *subpaths = (List *) lfirst(lc); + List *aligned_subpaths = NIL; + ListCell *lc_subpath; + bool valid = true; + AppendPathInput ainput; + AppendPath *appendpath; + + foreach(lc_subpath, subpaths) + { + Path *subpath = (Path *) lfirst(lc_subpath); + Path *aligned; + + aligned = or_expansion_align_path(root, joinrel, subpath); + if (aligned == NULL) + { + valid = false; + break; + } + aligned_subpaths = lappend(aligned_subpaths, aligned); + } + + if (!valid) + continue; + + ainput.subpaths = aligned_subpaths; + ainput.partial_subpaths = NIL; + ainput.child_append_relid_sets = NIL; + + /* Build Append path combining arm subpaths */ + appendpath = create_append_path(root, + joinrel, + ainput, + NIL, + NULL, + 0, + false, + -1); + + add_path(joinrel, (Path *) appendpath); + added_any = true; + } + + /* We need to refigure which is the cheapest path for the joinrel */ + if (added_any) + set_cheapest(joinrel); +} diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index 380679a76c0..8f6d63d0726 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -2319,6 +2319,16 @@ ifdef => 'DEBUG_BOUNDED_SORT', }, +{ name => 'or_expansion_limit', type => 'int', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER', + short_desc => 'Sets the maximum number of OR arms considered for OR-expansion.', + long_desc => 'The planner will consider expanding OR clauses if they have no more than this many arms. 0 disables OR-expansion.', + flags => 'GUC_EXPLAIN', + variable => 'or_expansion_limit', + boot_val => '8', + min => '0', + max => 'INT_MAX', +}, + { name => 'output_plugin_libraries', type => 'string', context => 'PGC_SUSET', group => 'REPLICATION_SENDING', short_desc => 'Lists libraries that may be named as logical decoding output plugins.', long_desc => 'Users with REPLICATION privileges may only use plugins in this list when creating logical replication slots.', diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index 01f98de8a9f..b11ea2a847e 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -495,6 +495,7 @@ #jit = off # allow JIT compilation #join_collapse_limit = 8 # 1 disables collapsing of explicit # JOIN clauses +#or_expansion_limit = 8 # 0 disables OR-expansion to UNION ALL #plan_cache_mode = auto # auto, force_generic_plan or # force_custom_plan #recursive_worktable_factor = 10.0 # range 0.001-1000000 diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h index 14255c900fc..f5e22c39c6c 100644 --- a/src/include/optimizer/cost.h +++ b/src/include/optimizer/cost.h @@ -68,6 +68,7 @@ extern PGDLLIMPORT bool enable_partitionwise_join; extern PGDLLIMPORT bool enable_partitionwise_aggregate; extern PGDLLIMPORT bool enable_parallel_append; extern PGDLLIMPORT bool enable_parallel_hash; +extern PGDLLIMPORT int or_expansion_limit; extern PGDLLIMPORT bool enable_partition_pruning; extern PGDLLIMPORT bool enable_presorted_aggregate; extern PGDLLIMPORT bool enable_async_append; diff --git a/src/include/optimizer/planmain.h b/src/include/optimizer/planmain.h index c23ab568b98..e7c780d774c 100644 --- a/src/include/optimizer/planmain.h +++ b/src/include/optimizer/planmain.h @@ -36,6 +36,13 @@ extern RelOptInfo *query_planner(PlannerInfo *root, */ extern void preprocess_minmax_aggregates(PlannerInfo *root); +/* + * prototypes for plan/planorexpand.c + */ +extern List *plan_or_expansion_arms(PlannerInfo *root); +extern void add_or_expansion_paths(PlannerInfo *root, RelOptInfo *joinrel, + List *or_cands); + /* * prototypes for plan/createplan.c */ diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out index 4ee94d00ade..b8a23193969 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -12102,14 +12102,26 @@ WHERE ss.unique1 = ss.phv AND t1.unique1 < 100; EXPLAIN (COSTS OFF) SELECT COUNT(*) FROM tenk1 t1, tenk1 t2 WHERE t2.thousand = t1.tenthous OR t2.thousand = t1.unique1 OR t2.thousand = t1.unique2; - QUERY PLAN ------------------------------------------------------------------------------------------ + QUERY PLAN +--------------------------------------------------------------------------------------------------------------------- Aggregate - -> Nested Loop - -> Seq Scan on tenk1 t1 - -> Index Only Scan using tenk1_thous_tenthous on tenk1 t2 - Index Cond: (thousand = ANY (ARRAY[t1.tenthous, t1.unique1, t1.unique2])) -(5 rows) + -> Append + -> Hash Join + Hash Cond: (t2.thousand = t1.tenthous) + -> Index Only Scan using tenk1_thous_tenthous on tenk1 t2 + -> Hash + -> Index Only Scan using tenk1_thous_tenthous on tenk1 t1 + -> Merge Join + Merge Cond: (t1.unique1 = t2.thousand) + Join Filter: ((t2.thousand = t1.tenthous) IS NOT TRUE) + -> Index Scan using tenk1_unique1 on tenk1 t1 + -> Index Only Scan using tenk1_thous_tenthous on tenk1 t2 + -> Merge Join + Merge Cond: (t1.unique2 = t2.thousand) + Join Filter: (((t2.thousand = t1.tenthous) IS NOT TRUE) AND ((t2.thousand = t1.unique1) IS NOT TRUE)) + -> Index Scan using tenk1_unique2 on tenk1 t1 + -> Index Only Scan using tenk1_thous_tenthous on tenk1 t2 +(17 rows) SELECT COUNT(*) FROM tenk1 t1, tenk1 t2 WHERE t2.thousand = t1.tenthous OR t2.thousand = t1.unique1 OR t2.thousand = t1.unique2; diff --git a/src/test/regress/expected/or_expansion.out b/src/test/regress/expected/or_expansion.out new file mode 100644 index 00000000000..aae75315f27 --- /dev/null +++ b/src/test/regress/expected/or_expansion.out @@ -0,0 +1,249 @@ +-- +-- Tests for OR-expansion to UNION ALL (Append) +-- +CREATE TABLE orexp_a (id int, val int); +CREATE TABLE orexp_b (x int, y int, val int); +INSERT INTO orexp_a VALUES + (1, 10), + (2, 20), + (3, 30), + (4, NULL), + (5, 50); +INSERT INTO orexp_b VALUES + (1, 99, 100), + (99, 2, 200), + (3, 3, 300), + (4, 99, 400), + (99, 99, 500), + (NULL, NULL, 600); +-- Verify GUC exists and can be toggled +SHOW or_expansion_limit; + or_expansion_limit +-------------------- + 8 +(1 row) + +-- Case C3: Join condition OR (disjunction of join keys) +-- With default or_expansion_limit = 8, planner should expand to Append of joins +EXPLAIN (COSTS OFF) +SELECT a.id, b.x, b.y FROM orexp_a a, orexp_b b +WHERE a.id = b.x OR a.id = b.y +ORDER BY a.id, b.x, b.y; + QUERY PLAN +------------------------------------------------------- + Sort + Sort Key: a.id, b.x, b.y + -> Append + -> Merge Join + Merge Cond: (b.x = a.id) + -> Sort + Sort Key: b.x + -> Seq Scan on orexp_b b + -> Sort + Sort Key: a.id + -> Seq Scan on orexp_a a + -> Merge Join + Merge Cond: (b.y = a.id) + Join Filter: ((a.id = b.x) IS NOT TRUE) + -> Sort + Sort Key: b.y + -> Seq Scan on orexp_b b + -> Sort + Sort Key: a.id + -> Seq Scan on orexp_a a +(20 rows) + +SELECT a.id, b.x, b.y FROM orexp_a a, orexp_b b +WHERE a.id = b.x OR a.id = b.y +ORDER BY a.id, b.x, b.y; + id | x | y +----+----+---- + 1 | 1 | 99 + 2 | 99 | 2 + 3 | 3 | 3 + 4 | 4 | 99 +(4 rows) + +-- Result must match or_expansion_limit = 0 (disabled) +SET or_expansion_limit = 0; +EXPLAIN (COSTS OFF) +SELECT a.id, b.x, b.y FROM orexp_a a, orexp_b b +WHERE a.id = b.x OR a.id = b.y +ORDER BY a.id, b.x, b.y; + QUERY PLAN +----------------------------------------------------- + Sort + Sort Key: a.id, b.x, b.y + -> Nested Loop + Join Filter: ((a.id = b.x) OR (a.id = b.y)) + -> Seq Scan on orexp_a a + -> Materialize + -> Seq Scan on orexp_b b +(7 rows) + +SELECT a.id, b.x, b.y FROM orexp_a a, orexp_b b +WHERE a.id = b.x OR a.id = b.y +ORDER BY a.id, b.x, b.y; + id | x | y +----+----+---- + 1 | 1 | 99 + 2 | 99 | 2 + 3 | 3 | 3 + 4 | 4 | 99 +(4 rows) + +-- Case C4: Cross-table filter with separate join key +-- Tests null-safety, 3-valued logic, and duplicate exclusion +SET or_expansion_limit = 8; +EXPLAIN (COSTS OFF) +SELECT a.id, a.val, b.val FROM orexp_a a JOIN orexp_b b ON a.id = b.x +WHERE a.val = 10 OR b.val = 300 +ORDER BY a.id, a.val, b.val; + QUERY PLAN +------------------------------------------------------ + Sort + Sort Key: a.id, a.val, b.val + -> Append + -> Hash Join + Hash Cond: (b.x = a.id) + -> Seq Scan on orexp_b b + -> Hash + -> Seq Scan on orexp_a a + Filter: (val = 10) + -> Hash Join + Hash Cond: (a.id = b.x) + -> Seq Scan on orexp_a a + Filter: ((val = 10) IS NOT TRUE) + -> Hash + -> Seq Scan on orexp_b b + Filter: (val = 300) +(16 rows) + +SELECT a.id, a.val, b.val FROM orexp_a a JOIN orexp_b b ON a.id = b.x +WHERE a.val = 10 OR b.val = 300 +ORDER BY a.id, a.val, b.val; + id | val | val +----+-----+----- + 1 | 10 | 100 + 3 | 30 | 300 +(2 rows) + +SET or_expansion_limit = 0; +SELECT a.id, a.val, b.val FROM orexp_a a JOIN orexp_b b ON a.id = b.x +WHERE a.val = 10 OR b.val = 300 +ORDER BY a.id, a.val, b.val; + id | val | val +----+-----+----- + 1 | 10 | 100 + 3 | 30 | 300 +(2 rows) + +-- Case C5: Non-flattened subquery over ordinary table with cross-table OR +-- Verifies expression_tree_walker is not called on RangeTblEntry nodes +CREATE TABLE orexp_c (id int, tier text); +CREATE TABLE orexp_d (cid int, val int); +INSERT INTO orexp_c VALUES (1, 'vip'), (2, 'std'); +INSERT INTO orexp_d VALUES (1, 10), (1, 20), (2, 5); +SET or_expansion_limit = 8; +EXPLAIN (COSTS OFF) +SELECT c.id, s.n FROM orexp_c c + JOIN (SELECT cid, count(*) n FROM orexp_d GROUP BY cid) s ON s.cid = c.id +WHERE c.tier = 'vip' OR s.n > 20 +ORDER BY c.id; + QUERY PLAN +------------------------------------------------------------- + Sort + Sort Key: c.id + -> Hash Join + Hash Cond: (c.id = s.cid) + Join Filter: ((c.tier = 'vip'::text) OR (s.n > 20)) + -> Seq Scan on orexp_c c + -> Hash + -> Subquery Scan on s + -> HashAggregate + Group Key: orexp_d.cid + -> Seq Scan on orexp_d +(11 rows) + +SELECT c.id, s.n FROM orexp_c c + JOIN (SELECT cid, count(*) n FROM orexp_d GROUP BY cid) s ON s.cid = c.id +WHERE c.tier = 'vip' OR s.n > 20 +ORDER BY c.id; + id | n +----+--- + 1 | 2 +(1 row) + +SET or_expansion_limit = 0; +SELECT c.id, s.n FROM orexp_c c + JOIN (SELECT cid, count(*) n FROM orexp_d GROUP BY cid) s ON s.cid = c.id +WHERE c.tier = 'vip' OR s.n > 20 +ORDER BY c.id; + id | n +----+--- + 1 | 2 +(1 row) + +DROP TABLE orexp_c; +DROP TABLE orexp_d; +-- Case C6: Subquery over partitioned table (gate must reject OR expansion) +CREATE TABLE orexp_part (id int, d int) PARTITION BY RANGE (d); +CREATE TABLE orexp_part1 PARTITION OF orexp_part FOR VALUES FROM (0) TO (10); +CREATE TABLE orexp_part2 PARTITION OF orexp_part FOR VALUES FROM (10) TO (20); +INSERT INTO orexp_part VALUES (1, 5), (2, 15); +SET or_expansion_limit = 8; +EXPLAIN (COSTS OFF) +SELECT a.id, s.d FROM orexp_a a, + (SELECT id, d FROM orexp_part GROUP BY id, d) s +WHERE a.id = s.id OR s.d = 15 +ORDER BY a.id, s.d; + QUERY PLAN +-------------------------------------------------------------------------- + Sort + Sort Key: a.id, s.d + -> Nested Loop + Join Filter: ((a.id = s.id) OR (s.d = 15)) + -> Seq Scan on orexp_a a + -> Materialize + -> Subquery Scan on s + -> HashAggregate + Group Key: orexp_part.id, orexp_part.d + -> Append + -> Seq Scan on orexp_part1 orexp_part_1 + -> Seq Scan on orexp_part2 orexp_part_2 +(12 rows) + +SELECT a.id, s.d FROM orexp_a a, + (SELECT id, d FROM orexp_part GROUP BY id, d) s +WHERE a.id = s.id OR s.d = 15 +ORDER BY a.id, s.d; + id | d +----+---- + 1 | 5 + 1 | 15 + 2 | 15 + 3 | 15 + 4 | 15 + 5 | 15 +(6 rows) + +SET or_expansion_limit = 0; +SELECT a.id, s.d FROM orexp_a a, + (SELECT id, d FROM orexp_part GROUP BY id, d) s +WHERE a.id = s.id OR s.d = 15 +ORDER BY a.id, s.d; + id | d +----+---- + 1 | 5 + 1 | 15 + 2 | 15 + 3 | 15 + 4 | 15 + 5 | 15 +(6 rows) + +DROP TABLE orexp_part; +-- Clean up +RESET or_expansion_limit; +DROP TABLE orexp_a; +DROP TABLE orexp_b; diff --git a/src/test/regress/sql/or_expansion.sql b/src/test/regress/sql/or_expansion.sql new file mode 100644 index 00000000000..a3a035d6f05 --- /dev/null +++ b/src/test/regress/sql/or_expansion.sql @@ -0,0 +1,122 @@ +-- +-- Tests for OR-expansion to UNION ALL (Append) +-- + +CREATE TABLE orexp_a (id int, val int); +CREATE TABLE orexp_b (x int, y int, val int); + +INSERT INTO orexp_a VALUES + (1, 10), + (2, 20), + (3, 30), + (4, NULL), + (5, 50); + +INSERT INTO orexp_b VALUES + (1, 99, 100), + (99, 2, 200), + (3, 3, 300), + (4, 99, 400), + (99, 99, 500), + (NULL, NULL, 600); + +-- Verify GUC exists and can be toggled +SHOW or_expansion_limit; + +-- Case C3: Join condition OR (disjunction of join keys) +-- With default or_expansion_limit = 8, planner should expand to Append of joins +EXPLAIN (COSTS OFF) +SELECT a.id, b.x, b.y FROM orexp_a a, orexp_b b +WHERE a.id = b.x OR a.id = b.y +ORDER BY a.id, b.x, b.y; + +SELECT a.id, b.x, b.y FROM orexp_a a, orexp_b b +WHERE a.id = b.x OR a.id = b.y +ORDER BY a.id, b.x, b.y; + +-- Result must match or_expansion_limit = 0 (disabled) +SET or_expansion_limit = 0; +EXPLAIN (COSTS OFF) +SELECT a.id, b.x, b.y FROM orexp_a a, orexp_b b +WHERE a.id = b.x OR a.id = b.y +ORDER BY a.id, b.x, b.y; + +SELECT a.id, b.x, b.y FROM orexp_a a, orexp_b b +WHERE a.id = b.x OR a.id = b.y +ORDER BY a.id, b.x, b.y; + +-- Case C4: Cross-table filter with separate join key +-- Tests null-safety, 3-valued logic, and duplicate exclusion +SET or_expansion_limit = 8; +EXPLAIN (COSTS OFF) +SELECT a.id, a.val, b.val FROM orexp_a a JOIN orexp_b b ON a.id = b.x +WHERE a.val = 10 OR b.val = 300 +ORDER BY a.id, a.val, b.val; + +SELECT a.id, a.val, b.val FROM orexp_a a JOIN orexp_b b ON a.id = b.x +WHERE a.val = 10 OR b.val = 300 +ORDER BY a.id, a.val, b.val; + +SET or_expansion_limit = 0; +SELECT a.id, a.val, b.val FROM orexp_a a JOIN orexp_b b ON a.id = b.x +WHERE a.val = 10 OR b.val = 300 +ORDER BY a.id, a.val, b.val; + +-- Case C5: Non-flattened subquery over ordinary table with cross-table OR +-- Verifies expression_tree_walker is not called on RangeTblEntry nodes +CREATE TABLE orexp_c (id int, tier text); +CREATE TABLE orexp_d (cid int, val int); +INSERT INTO orexp_c VALUES (1, 'vip'), (2, 'std'); +INSERT INTO orexp_d VALUES (1, 10), (1, 20), (2, 5); + +SET or_expansion_limit = 8; +EXPLAIN (COSTS OFF) +SELECT c.id, s.n FROM orexp_c c + JOIN (SELECT cid, count(*) n FROM orexp_d GROUP BY cid) s ON s.cid = c.id +WHERE c.tier = 'vip' OR s.n > 20 +ORDER BY c.id; + +SELECT c.id, s.n FROM orexp_c c + JOIN (SELECT cid, count(*) n FROM orexp_d GROUP BY cid) s ON s.cid = c.id +WHERE c.tier = 'vip' OR s.n > 20 +ORDER BY c.id; + +SET or_expansion_limit = 0; +SELECT c.id, s.n FROM orexp_c c + JOIN (SELECT cid, count(*) n FROM orexp_d GROUP BY cid) s ON s.cid = c.id +WHERE c.tier = 'vip' OR s.n > 20 +ORDER BY c.id; + +DROP TABLE orexp_c; +DROP TABLE orexp_d; + +-- Case C6: Subquery over partitioned table (gate must reject OR expansion) +CREATE TABLE orexp_part (id int, d int) PARTITION BY RANGE (d); +CREATE TABLE orexp_part1 PARTITION OF orexp_part FOR VALUES FROM (0) TO (10); +CREATE TABLE orexp_part2 PARTITION OF orexp_part FOR VALUES FROM (10) TO (20); +INSERT INTO orexp_part VALUES (1, 5), (2, 15); + +SET or_expansion_limit = 8; +EXPLAIN (COSTS OFF) +SELECT a.id, s.d FROM orexp_a a, + (SELECT id, d FROM orexp_part GROUP BY id, d) s +WHERE a.id = s.id OR s.d = 15 +ORDER BY a.id, s.d; + +SELECT a.id, s.d FROM orexp_a a, + (SELECT id, d FROM orexp_part GROUP BY id, d) s +WHERE a.id = s.id OR s.d = 15 +ORDER BY a.id, s.d; + +SET or_expansion_limit = 0; +SELECT a.id, s.d FROM orexp_a a, + (SELECT id, d FROM orexp_part GROUP BY id, d) s +WHERE a.id = s.id OR s.d = 15 +ORDER BY a.id, s.d; + +DROP TABLE orexp_part; + +-- Clean up +RESET or_expansion_limit; +DROP TABLE orexp_a; +DROP TABLE orexp_b;