diff --git a/src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp b/src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp index 3abfd5fed5a..2e12a5e7aa8 100644 --- a/src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp +++ b/src/backend/gpopt/translate/CTranslatorDXLToPlStmt.cpp @@ -550,34 +550,6 @@ CTranslatorDXLToPlStmt::TranslateDXLOperatorToPlan( return plan; } -//--------------------------------------------------------------------------- -// @function: -// CTranslatorDXLToPlStmt::SetParamIds -// -// @doc: -// Set the bitmapset with the param_ids defined in the plan -// -//--------------------------------------------------------------------------- -void -CTranslatorDXLToPlStmt::SetParamIds(Plan *plan) -{ - List *params_node_list = gpdb::ExtractNodesPlan( - plan, T_Param, true /* descend_into_subqueries */); - - ListCell *lc = nullptr; - - Bitmapset *bitmapset = nullptr; - - ForEach(lc, params_node_list) - { - Param *param = (Param *) lfirst(lc); - bitmapset = gpdb::BmsAddMember(bitmapset, param->paramid); - } - - plan->extParam = bitmapset; - plan->allParam = bitmapset; -} - List * CTranslatorDXLToPlStmt::TranslatePartOids(IMdIdArray *parts, INT lockmode) { @@ -730,7 +702,6 @@ CTranslatorDXLToPlStmt::TranslateDXLTblScan( // translate operator costs TranslatePlanCosts(tbl_scan_dxlnode, plan); - SetParamIds(plan); return plan_return; } @@ -835,7 +806,6 @@ CTranslatorDXLToPlStmt::TranslateDXLParallelTblScan( plan->plan_rows = ceil(plan->plan_rows / parallel_workers); } - SetParamIds(plan); return plan_return; } @@ -1027,7 +997,6 @@ CTranslatorDXLToPlStmt::TranslateDXLIndexScan( * As of 8.4, the indexstrategy and indexsubtype fields are no longer * available or needed in IndexScan. Ignore them. */ - SetParamIds(plan); return (Plan *) index_scan; } @@ -1173,7 +1142,6 @@ CTranslatorDXLToPlStmt::TranslateDXLIndexOnlyScan( } index_scan->indexqual = index_cond; - SetParamIds(plan); return (Plan *) index_scan; } @@ -1513,7 +1481,6 @@ CTranslatorDXLToPlStmt::TranslateDXLLimit( limit->limitOffset = limit_offset; } - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -1751,7 +1718,6 @@ CTranslatorDXLToPlStmt::TranslateDXLHashJoin( plan->lefttree = left_plan; plan->righttree = right_plan; - SetParamIds(plan); // cleanup translation_context_arr_with_siblings->Release(); @@ -1844,7 +1810,6 @@ CTranslatorDXLToPlStmt::TranslateDXLTvf( } func_scan->functions = ListMake1(rtfunc); - SetParamIds(plan); return (Plan *) func_scan; } @@ -2218,7 +2183,6 @@ CTranslatorDXLToPlStmt::TranslateDXLNLJoin( } plan->lefttree = left_plan; plan->righttree = right_plan; - SetParamIds(plan); // cleanup translation_context_arr_with_siblings->Release(); @@ -2331,7 +2295,6 @@ CTranslatorDXLToPlStmt::TranslateDXLMergeJoin( plan->lefttree = left_plan; plan->righttree = right_plan; - SetParamIds(plan); merge_join->mergeFamilies = (Oid *) gpdb::GPDBAlloc(sizeof(Oid) * num_join_conds); @@ -2438,7 +2401,6 @@ CTranslatorDXLToPlStmt::TranslateDXLHash( plan->qual = NIL; hash->rescannable = false; - SetParamIds(plan); return (Plan *) hash; } @@ -2734,7 +2696,6 @@ CTranslatorDXLToPlStmt::TranslateDXLMotion( plan->plan_rows = ceil(plan->plan_rows / sendslice->parallel_workers); } - SetParamIds(plan); return (Plan *) motion; } @@ -2867,7 +2828,6 @@ CTranslatorDXLToPlStmt::TranslateDXLRedistributeMotionToResultHashFilters( plan->lefttree = child_plan; - SetParamIds(plan); Plan *child_result = (Plan *) result; @@ -2920,7 +2880,6 @@ CTranslatorDXLToPlStmt::TranslateDXLRedistributeMotionToResultHashFilters( plan->qual = NIL; plan->lefttree = child_result; - SetParamIds(plan); return (Plan *) result; } @@ -3195,7 +3154,6 @@ CTranslatorDXLToPlStmt::TranslateDXLAgg( m_dxl_to_plstmt_context->ResetAggInfosAndTransInfos(); - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -3525,7 +3483,6 @@ CTranslatorDXLToPlStmt::TranslateDXLWindowAgg( } } - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -3729,7 +3686,6 @@ CTranslatorDXLToPlStmt::TranslateDXLWindowHashAgg( } } - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -3803,7 +3759,6 @@ CTranslatorDXLToPlStmt::TranslateDXLSort( TranslateSortCols(sort_col_list_dxl, &child_context, sort->sortColIdx, sort->sortOperators, sort->collations, sort->nullsFirst); - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -3924,7 +3879,6 @@ CTranslatorDXLToPlStmt::TranslateDXLProjectSet(const CDXLNode *result_dxlnode) // translate operator costs TranslatePlanCosts(result_dxlnode, plan); - SetParamIds(plan); return (Plan *) project_set; } @@ -4272,7 +4226,6 @@ CTranslatorDXLToPlStmt::TranslateDXLResult( plan->qual = quals_list; result->resconstantqual = (Node *) one_time_quals_list; - SetParamIds(plan); // Creating project set nodes plan tree Plan *project_set_parent_plan = CreateProjectSetNodeTree( @@ -4393,7 +4346,6 @@ CTranslatorDXLToPlStmt::TranslateDXLPartSelector( partition_selector->part_prune_info = MakeNode(PartitionPruneInfo); partition_selector->part_prune_info->prune_infos = prune_infos; - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -4566,7 +4518,6 @@ CTranslatorDXLToPlStmt::TranslateDXLAppend( nullptr, // translate context for the base table child_contexts, output_context); - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -4597,8 +4548,6 @@ CTranslatorDXLToPlStmt::TranslateDXLMaterialize( CDXLPhysicalMaterialize::Cast(materialize_dxlnode->GetOperator()); materialize->cdb_strict = materialize_dxlop->IsEager(); - // ensure that executor actually materializes results - materialize->cdb_shield_child_from_rescans = true; // translate operator costs TranslatePlanCosts(materialize_dxlnode, plan); @@ -4628,7 +4577,19 @@ CTranslatorDXLToPlStmt::TranslateDXLMaterialize( plan->lefttree = child_plan; - SetParamIds(plan); + // Shield the child from rescans only if there is a Motion somewhere in + // the subtree: Motions cannot be rescanned, so the Material must then + // keep its tuplestore across rescans and squelching must not propagate + // below it. For a Motion-free subtree rely on normal executor + // semantics instead; in particular, if the subtree refers to exec + // params of an enclosing SubPlan, the materialized result must be + // discarded and rebuilt whenever those params change (chgParam), and + // shielding it would add no benefit while making that dependency + // fragile. + List *child_motions = + gpdb::ExtractNodesPlan(child_plan, T_Motion, + true /* descendIntoSubqueries */); + materialize->cdb_shield_child_from_rescans = (NIL != child_motions); // cleanup child_contexts->Release(); @@ -4688,7 +4649,6 @@ CTranslatorDXLToPlStmt::TranslateDXLCTEProducerToSharedScan( plan->lefttree = child_plan; plan->qual = NIL; - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -4781,7 +4741,6 @@ CTranslatorDXLToPlStmt::TranslateDXLCTEConsumerToSharedScan( plan->qual = nullptr; - SetParamIds(plan); // DON'T REMOVE, if current consumer need projection, then we can direct add it. // we still keep the path of projection in consumer @@ -4847,7 +4806,6 @@ CTranslatorDXLToPlStmt::TranslateDXLSequence( nullptr, // base table translation context child_contexts, output_context); - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -4941,7 +4899,6 @@ CTranslatorDXLToPlStmt::TranslateDXLDynTblScan( security_query_quals = gpdb::ListConcat(security_query_quals, query_quals); plan->qual = security_query_quals; - SetParamIds(plan); return plan; } @@ -5027,7 +4984,6 @@ CTranslatorDXLToPlStmt::TranslateDXLDynIdxOnlyScan( dyn_idx_only_scan->indexscan.indexqual = index_cond; - SetParamIds(plan); return (Plan *) dyn_idx_only_scan; } @@ -5108,7 +5064,6 @@ CTranslatorDXLToPlStmt::TranslateDXLDynIdxScan( dyn_idx_only_scan->indexscan.indexqual = index_cond; dyn_idx_only_scan->indexscan.indexqualorig = index_orig_cond; - SetParamIds(plan); return (Plan *) dyn_idx_only_scan; } @@ -5279,7 +5234,6 @@ CTranslatorDXLToPlStmt::TranslateDXLDynForeignScan( // translate operator costs TranslatePlanCosts(dyn_foreign_scan_dxlnode, plan); - SetParamIds(plan); return plan; } @@ -5441,7 +5395,6 @@ CTranslatorDXLToPlStmt::TranslateDXLDml( result_plan->lefttree = child_plan; result_plan->targetlist = dml_target_list; - SetParamIds(result_plan); if (m_cmd_type == CMD_UPDATE && isSplit) { @@ -5454,7 +5407,6 @@ CTranslatorDXLToPlStmt::TranslateDXLDml( final_result->resconstantqual = (Node *) gpdb::LAppend(NIL, gpdb::MakeBoolConst(true /*value*/, false /*isnull*/)); - SetParamIds(final_result_plan); result = final_result; result_plan = final_result_plan; @@ -5484,7 +5436,6 @@ CTranslatorDXLToPlStmt::TranslateDXLDml( plan->targetlist = NIL; plan->plan_node_id = m_dxl_to_plstmt_context->GetNextPlanId(); - SetParamIds(plan); if (m_is_tgt_tbl_distributed) { @@ -5731,7 +5682,6 @@ CTranslatorDXLToPlStmt::TranslateDXLSplit( plan->lefttree = child_plan; plan->plan_node_id = m_dxl_to_plstmt_context->GetNextPlanId(); - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -5812,7 +5762,6 @@ CTranslatorDXLToPlStmt::TranslateDXLAssert( GPOS_ASSERT(gpdb::ListLength(plan->qual) == gpdb::ListLength(assert_node->errmessage)); - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -6851,7 +6800,6 @@ CTranslatorDXLToPlStmt::TranslateDXLCtas( child_contexts, output_context); SetVarTypMod(phy_ctas_dxlop, target_list); - SetParamIds(plan); // cleanup child_contexts->Release(); @@ -6875,7 +6823,6 @@ CTranslatorDXLToPlStmt::TranslateDXLCtas( result_plan->lefttree = plan; result_plan->targetlist = target_list; - SetParamIds(result_plan); plan = (Plan *) result; @@ -7109,7 +7056,6 @@ CTranslatorDXLToPlStmt::TranslateDXLBitmapTblScan( bitmap_tbl_scan->scan.plan.lefttree = TranslateDXLBitmapAccessPath( bitmap_access_path_dxlnode, output_context, md_rel, table_descr, &base_table_context, ctxt_translation_prev_siblings, bitmap_tbl_scan); - SetParamIds(plan); if (is_dynamic) { @@ -7279,7 +7225,6 @@ CTranslatorDXLToPlStmt::TranslateDXLBitmapIndexProbe( * As of 8.4, the indexstrategy and indexsubtype fields are no longer * available or needed in IndexScan. Ignore them. */ - SetParamIds(plan); return plan; } diff --git a/src/backend/optimizer/plan/orca.c b/src/backend/optimizer/plan/orca.c index cc5a70639cf..9a6d3e1f219 100644 --- a/src/backend/optimizer/plan/orca.c +++ b/src/backend/optimizer/plan/orca.c @@ -45,6 +45,8 @@ #include "utils/syscache.h" #include "catalog/pg_proc.h" #include "catalog/pg_namespace.h" +#include "cdb/cdbllize.h" +#include "optimizer/walkers.h" /* GPORCA entry point */ extern PlannedStmt * GPOPTOptimizedPlan(Query *parse, bool *had_unexpected_failure, OptimizerOptions *opts); @@ -53,6 +55,7 @@ static Plan *remove_redundant_results(PlannerInfo *root, Plan *plan); static Node *remove_redundant_results_mutator(Node *node, void *); static bool can_replace_tlist(Plan *plan); static Node *push_down_expr_mutator(Node *node, List *child_tlist); +static bool set_plan_param_bitmaps_walker(Node *node, void *context); /* * Logging of optimization outcome @@ -352,6 +355,28 @@ optimize_query(Query *parse, int cursorOptions, ParamListInfo boundParams, Optim result->planTree = remove_redundant_results(root, result->planTree); + /* + * Compute the extParam/allParam bitmapsets of every Plan node, for the + * final shape of the plan. + * + * Rescan correctness depends on these: the executor propagates chgParam + * to a child only if the child's allParam contains the changed param + * (see UpdateChangedParamSet()), and e.g. a Material node relies on that + * to discard its tuplestore when a param of an enclosing SubPlan + * changes; a stale bitmap silently yields wrong results. Plans from the + * Postgres planner get these bits from SS_finalize_plan(); for ORCA + * plans we compute them here in one authoritative pass, instead of + * relying on every translator function to fill them in piecemeal. + */ + { + plan_tree_base_prefix ctx; + + ctx.node = (Node *) root; + foreach(lp, glob->subplans) + set_plan_param_bitmaps_walker((Node *) lfirst(lp), &ctx); + set_plan_param_bitmaps_walker((Node *) result->planTree, &ctx); + } + /* * To save on memory, and on the network bandwidth when the plan is * dispatched to QEs, strip all subquery RTEs of the original Query @@ -412,6 +437,52 @@ optimize_query(Query *parse, int cursorOptions, ParamListInfo boundParams, Optim return result; } +/* + * Set the extParam/allParam bitmapsets of every Plan node to the set of + * PARAM_EXEC params referenced in the node's subtree. + * + * SubPlan bodies are not entered here (extract_nodes_plan() collects the + * params of a SubPlan's testexpr and args, but does not descend into the + * body); the caller walks each entry of glob->subplans separately instead, + * so every plan node in the tree gets its bitmaps set exactly once. + */ +static bool +set_plan_param_bitmaps_walker(Node *node, void *context) +{ + if (node == NULL) + return false; + + if (is_plan_node(node)) + { + Plan *plan = (Plan *) node; + List *params; + Bitmapset *bms = NULL; + ListCell *lc; + + params = extract_nodes_plan(plan, T_Param, + true /* descendIntoSubqueries */); + foreach(lc, params) + { + Param *param = (Param *) lfirst(lc); + + /* + * extParam/allParam must contain only PARAM_EXEC paramids (see + * plannodes.h); PARAM_EXTERN params live in a separate numbering + * space and never change during execution. + */ + if (param->paramkind == PARAM_EXEC) + bms = bms_add_member(bms, param->paramid); + } + list_free(params); + + plan->extParam = bms; + plan->allParam = bms; + } + + return plan_tree_walker(node, set_plan_param_bitmaps_walker, context, + false /* recurse_into_subplans */); +} + /* * ORCA tends to generate gratuitous Result nodes for various reasons. We * try to clean it up here, as much as we can, by eliminating the Results diff --git a/src/include/gpopt/translate/CTranslatorDXLToPlStmt.h b/src/include/gpopt/translate/CTranslatorDXLToPlStmt.h index 3cd5fc9d638..f1f9df67076 100644 --- a/src/include/gpopt/translate/CTranslatorDXLToPlStmt.h +++ b/src/include/gpopt/translate/CTranslatorDXLToPlStmt.h @@ -190,9 +190,6 @@ class CTranslatorDXLToPlStmt static JoinType GetGPDBJoinTypeFromDXLJoinType(EdxlJoinType join_type); private: - // Set the bitmapset of a plan to the list of param_ids defined by the plan - static void SetParamIds(Plan *); - static List *TranslatePartOids(IMdIdArray *parts, INT lockmode); static List *TranslateJoinPruneParamids( diff --git a/src/test/regress/expected/orca_material_rescan.out b/src/test/regress/expected/orca_material_rescan.out new file mode 100644 index 00000000000..96e1ac3f5a3 --- /dev/null +++ b/src/test/regress/expected/orca_material_rescan.out @@ -0,0 +1,101 @@ +-- +-- Test rescan of Materialize in ORCA-translated correlated SubPlans. +-- +-- A Materialize sitting above a subtree that consumes a param of an +-- enclosing SubPlan (here: a ProjectSet computing UNNEST over an outer +-- column) must discard its tuplestore and rebuild whenever the param +-- changes. The executor propagates chgParam to a child only if the +-- child's allParam bitmapset contains the changed param, so this hangs +-- on the ORCA translator producing correct param bitmaps for every +-- node, and on the Materialize not shielding a Motion-free child from +-- rescans. When either is broken, the Materialize replays the first +-- outer row's result for every subsequent row. GPDB 7.5.x shipped +-- exactly this class of wrong-results regression. +-- +-- The tables deliberately have no stats: with default cardinalities +-- ORCA puts the ProjectSet on the nestloop inner side underneath a +-- Materialize, which is the vulnerable plan shape. Keep them +-- unanalyzed. +-- +CREATE SCHEMA orca_material_rescan; +SET search_path TO orca_material_rescan; +CREATE TABLE ae_event ( + seq_nvl numeric(10) NOT NULL, + ae_rule_id_ctn varchar(200) NULL, + ae_occr_dtm timestamp NULL +) USING ao_row DISTRIBUTED RANDOMLY; +CREATE TABLE ae_rule ( + ae_rule_id varchar(50) NOT NULL, + ae_rule_nm varchar(200) NULL +) USING ao_row DISTRIBUTED RANDOMLY; +INSERT INTO ae_rule VALUES + ('R_01', 'Overheating'), + ('R_02', NULL), + ('R_03', 'Calibration Error'), + ('R_04', 'Sensor Offline'); +-- Edge cases: rule id lists with a duplicate, an id with no match in +-- ae_rule, and empty lists. Every row must get its own aggregate, not +-- a replay of the first row's. +INSERT INTO ae_event VALUES + (1, 'R_01,R_02', '2026-06-10 08:59:49'), + (2, 'R_03', '2026-06-10 08:59:50'), + (3, '', '2026-06-10 08:59:51'), + (4, 'R_04,R_99', '2026-06-10 08:59:52'), + (5, 'R_01,R_01', '2026-06-10 08:59:53'), + (6, '', '2026-06-10 08:59:54'); +SELECT $query$ +SELECT seq_nvl, ae_rule_id_ctn + , ( SELECT string_agg(rul_nm, ',' ORDER BY ae_rule_id) + FROM ( SELECT coalesce(ae_rule_nm, ae_rule_id) AS rul_nm, ae_rule_id + FROM ae_rule t1 + JOIN ( SELECT unnest(string_to_array(sh.ae_rule_id_ctn, ',')) ) t2(column2) + ON t1.ae_rule_id = t2.column2 ) t + ) AS rule_ctrl +FROM ae_event sh +WHERE sh.ae_occr_dtm >= '2026-06-10 06:00:00' + AND sh.ae_occr_dtm < '2026-06-11 06:00:00' +ORDER BY seq_nvl +$query$ AS qry \gset +SET optimizer TO on; +-- The shape under test: the SubPlan's nestloop inner side must be a +-- Materialize over a Motion-free ProjectSet consuming the outer param. +EXPLAIN (COSTS OFF) +:qry ; + QUERY PLAN +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + Sort + Sort Key: sh.seq_nvl + -> Result + -> Gather Motion 3:1 (slice1; segments: 3) + -> Seq Scan on ae_event sh + Filter: ((ae_occr_dtm >= 'Wed Jun 10 06:00:00 2026'::timestamp without time zone) AND (ae_occr_dtm < 'Thu Jun 11 06:00:00 2026'::timestamp without time zone)) + SubPlan 1 + -> Aggregate + -> Nested Loop + Join Filter: ((t1.ae_rule_id)::text = (unnest(string_to_array((sh.ae_rule_id_ctn)::text, ','::text)))) + -> Materialize + -> Gather Motion 3:1 (slice2; segments: 3) + -> Seq Scan on ae_rule t1 + -> Materialize + -> ProjectSet + -> Result + Optimizer: GPORCA +(17 rows) + +:qry ; + seq_nvl | ae_rule_id_ctn | rule_ctrl +---------+----------------+------------------------- + 1 | R_01,R_02 | Overheating,R_02 + 2 | R_03 | Calibration Error + 3 | | + 4 | R_04,R_99 | Sensor Offline + 5 | R_01,R_01 | Overheating,Overheating + 6 | | +(6 rows) + +RESET optimizer; +RESET search_path; +DROP SCHEMA orca_material_rescan CASCADE; +NOTICE: drop cascades to 2 other objects +DETAIL: drop cascades to table orca_material_rescan.ae_event +drop cascades to table orca_material_rescan.ae_rule diff --git a/src/test/regress/greenplum_schedule b/src/test/regress/greenplum_schedule index 84e8766844b..0cda6a6f5f7 100755 --- a/src/test/regress/greenplum_schedule +++ b/src/test/regress/greenplum_schedule @@ -64,7 +64,7 @@ test: leastsquares opr_sanity_gp decode_expr bitmapscan bitmapscan_ao case_gp li # below test(s) inject faults so each of them need to be in a separate group test: gpcopy -test: orca_static_pruning orca_groupingsets_fallbacks +test: orca_static_pruning orca_groupingsets_fallbacks orca_material_rescan test: filter gpctas gpdist gpdist_opclasses gpdist_legacy_opclasses matrix sublink table_functions olap_setup complex opclass_ddl information_schema guc_env_var gp_explain distributed_transactions explain_format olap_plans gp_copy_dtx # below test(s) inject faults so each of them need to be in a separate group test: guc_gp diff --git a/src/test/regress/sql/orca_material_rescan.sql b/src/test/regress/sql/orca_material_rescan.sql new file mode 100644 index 00000000000..bd11d5a3016 --- /dev/null +++ b/src/test/regress/sql/orca_material_rescan.sql @@ -0,0 +1,76 @@ +-- +-- Test rescan of Materialize in ORCA-translated correlated SubPlans. +-- +-- A Materialize sitting above a subtree that consumes a param of an +-- enclosing SubPlan (here: a ProjectSet computing UNNEST over an outer +-- column) must discard its tuplestore and rebuild whenever the param +-- changes. The executor propagates chgParam to a child only if the +-- child's allParam bitmapset contains the changed param, so this hangs +-- on the ORCA translator producing correct param bitmaps for every +-- node, and on the Materialize not shielding a Motion-free child from +-- rescans. When either is broken, the Materialize replays the first +-- outer row's result for every subsequent row. GPDB 7.5.x shipped +-- exactly this class of wrong-results regression. +-- +-- The tables deliberately have no stats: with default cardinalities +-- ORCA puts the ProjectSet on the nestloop inner side underneath a +-- Materialize, which is the vulnerable plan shape. Keep them +-- unanalyzed. +-- +CREATE SCHEMA orca_material_rescan; +SET search_path TO orca_material_rescan; + +CREATE TABLE ae_event ( + seq_nvl numeric(10) NOT NULL, + ae_rule_id_ctn varchar(200) NULL, + ae_occr_dtm timestamp NULL +) USING ao_row DISTRIBUTED RANDOMLY; + +CREATE TABLE ae_rule ( + ae_rule_id varchar(50) NOT NULL, + ae_rule_nm varchar(200) NULL +) USING ao_row DISTRIBUTED RANDOMLY; + +INSERT INTO ae_rule VALUES + ('R_01', 'Overheating'), + ('R_02', NULL), + ('R_03', 'Calibration Error'), + ('R_04', 'Sensor Offline'); + +-- Edge cases: rule id lists with a duplicate, an id with no match in +-- ae_rule, and empty lists. Every row must get its own aggregate, not +-- a replay of the first row's. +INSERT INTO ae_event VALUES + (1, 'R_01,R_02', '2026-06-10 08:59:49'), + (2, 'R_03', '2026-06-10 08:59:50'), + (3, '', '2026-06-10 08:59:51'), + (4, 'R_04,R_99', '2026-06-10 08:59:52'), + (5, 'R_01,R_01', '2026-06-10 08:59:53'), + (6, '', '2026-06-10 08:59:54'); + +SELECT $query$ +SELECT seq_nvl, ae_rule_id_ctn + , ( SELECT string_agg(rul_nm, ',' ORDER BY ae_rule_id) + FROM ( SELECT coalesce(ae_rule_nm, ae_rule_id) AS rul_nm, ae_rule_id + FROM ae_rule t1 + JOIN ( SELECT unnest(string_to_array(sh.ae_rule_id_ctn, ',')) ) t2(column2) + ON t1.ae_rule_id = t2.column2 ) t + ) AS rule_ctrl +FROM ae_event sh +WHERE sh.ae_occr_dtm >= '2026-06-10 06:00:00' + AND sh.ae_occr_dtm < '2026-06-11 06:00:00' +ORDER BY seq_nvl +$query$ AS qry \gset + +SET optimizer TO on; + +-- The shape under test: the SubPlan's nestloop inner side must be a +-- Materialize over a Motion-free ProjectSet consuming the outer param. +EXPLAIN (COSTS OFF) +:qry ; + +:qry ; + +RESET optimizer; +RESET search_path; +DROP SCHEMA orca_material_rescan CASCADE;