From 04072ead8317e10bc90d469145040d9e4c9a16a8 Mon Sep 17 00:00:00 2001 From: Jan Nidzwetzki Date: Fri, 28 Aug 2026 15:03:59 +0200 Subject: [PATCH v8 3/4] Enable partitionwise join for outer joins on RelabelType-wrapped keys An outer join qual forms no equivalence class, so have_partkey_equi_join() can only prove the keys equal by matching the clause with match_expr_to_partition_keys(). That function strips RelabelType decorations from the clause operand but not from the partition key expressions, which are themselves wrapped in a RelabelType when the key involves a binary-coercible cast, so the match fails. Fix by stripping both sides. Collation is not lost, as the caller compares the clause's inputcollid against the partition collation. Discussion: https://postgr.es/m/DEYVEFEFMSDC.23KLLYQ3F81R9@gmail.com --- src/backend/optimizer/util/relnode.c | 27 ++++-- src/test/regress/expected/partition_join.out | 88 ++++++++++++++++++++ src/test/regress/sql/partition_join.sql | 43 ++++++++++ 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c index ee69f81945f..1d45a013acf 100644 --- a/src/backend/optimizer/util/relnode.c +++ b/src/backend/optimizer/util/relnode.c @@ -2480,6 +2480,18 @@ have_partkey_equi_join(PlannerInfo *root, RelOptInfo *joinrel, return false; } +/* + * strip_relabel_decorations + * Remove any RelabelType decorations from "expr". + */ +static Expr * +strip_relabel_decorations(Expr *expr) +{ + while (expr && IsA(expr, RelabelType)) + expr = ((RelabelType *) expr)->arg; + return expr; +} + /* * match_expr_to_partition_keys * @@ -2501,9 +2513,14 @@ match_expr_to_partition_keys(Expr *expr, RelOptInfo *rel, bool strict_op) Assert(rel->partexprs); Assert(rel->nullable_partexprs); - /* Remove any relabel decorations. */ - while (IsA(expr, RelabelType)) - expr = (Expr *) (castNode(RelabelType, expr))->arg; + /* + * Remove any relabel decorations, from the clause expression here and + * from each partition key expression below. A key involving a + * binary-compatible cast is itself stored wrapped in a RelabelType. The + * collation is not lost, as the caller compares the clause's inputcollid + * against the partition collation. + */ + expr = strip_relabel_decorations(expr); for (cnt = 0; cnt < rel->part_scheme->partnatts; cnt++) { @@ -2512,7 +2529,7 @@ match_expr_to_partition_keys(Expr *expr, RelOptInfo *rel, bool strict_op) /* We can always match to the non-nullable partition keys. */ foreach(lc, rel->partexprs[cnt]) { - if (equal(lfirst(lc), expr)) + if (equal(strip_relabel_decorations(lfirst(lc)), expr)) return cnt; } @@ -2528,7 +2545,7 @@ match_expr_to_partition_keys(Expr *expr, RelOptInfo *rel, bool strict_op) */ foreach(lc, rel->nullable_partexprs[cnt]) { - if (equal(lfirst(lc), expr)) + if (equal(strip_relabel_decorations(lfirst(lc)), expr)) return cnt; } } diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out index 8a122a6cc24..dd25536f85f 100644 --- a/src/test/regress/expected/partition_join.out +++ b/src/test/regress/expected/partition_join.out @@ -1806,6 +1806,94 @@ SELECT count(*) FROM pht3 t1 JOIN pht4 t2 ON t1.d = t2.d WHERE t1.c = '0002' AND 40 (1 row) +RESET enable_hashjoin; +RESET enable_mergejoin; +-- outer join on a partition key that is an expression wrapped in a RelabelType +CREATE TABLE pht5 (a int, c varchar(40)) PARTITION BY HASH ((c::text)); +CREATE TABLE pht5_p1 PARTITION OF pht5 FOR VALUES WITH (MODULUS 2, REMAINDER 0); +CREATE TABLE pht5_p2 PARTITION OF pht5 FOR VALUES WITH (MODULUS 2, REMAINDER 1); +INSERT INTO pht5 SELECT i, to_char(i/50, 'FM0000') FROM generate_series(0, 599, 2) i; +-- a key with no match in pht6, so that the join produces a null-extended row +INSERT INTO pht5 VALUES (600, '9999'); +ANALYZE pht5; +CREATE TABLE pht6 (a int, c varchar(40)) PARTITION BY HASH ((c::text)); +CREATE TABLE pht6_p1 PARTITION OF pht6 FOR VALUES WITH (MODULUS 2, REMAINDER 0); +CREATE TABLE pht6_p2 PARTITION OF pht6 FOR VALUES WITH (MODULUS 2, REMAINDER 1); +INSERT INTO pht6 SELECT i, to_char(i/50, 'FM0000') FROM generate_series(0, 599, 3) i; +ANALYZE pht6; +CREATE TABLE pht7 (a int, c varchar(40)) PARTITION BY HASH ((c::text)); +CREATE TABLE pht7_p1 PARTITION OF pht7 FOR VALUES WITH (MODULUS 2, REMAINDER 0); +CREATE TABLE pht7_p2 PARTITION OF pht7 FOR VALUES WITH (MODULUS 2, REMAINDER 1); +INSERT INTO pht7 SELECT i, to_char(i/50, 'FM0000') FROM generate_series(0, 599, 5) i; +ANALYZE pht7; +-- avoid hash and merge joins, whose costs here are close enough to the +-- partitionwise nested loop's to make the test output unstable +SET enable_hashjoin = off; +SET enable_mergejoin = off; +SET max_parallel_workers_per_gather = 0; +-- the second count() shows that a null-extended row is produced +EXPLAIN (COSTS OFF) +SELECT count(*), count(t2.a) FROM pht5 t1 LEFT JOIN pht6 t2 ON t1.c::text = t2.c::text; + QUERY PLAN +-------------------------------------------------------------- + Aggregate + -> Append + -> Nested Loop Left Join + Join Filter: ((t1_1.c)::text = (t2_1.c)::text) + -> Seq Scan on pht5_p1 t1_1 + -> Materialize + -> Seq Scan on pht6_p1 t2_1 + -> Nested Loop Left Join + Join Filter: ((t1_2.c)::text = (t2_2.c)::text) + -> Seq Scan on pht5_p2 t1_2 + -> Materialize + -> Seq Scan on pht6_p2 t2_2 +(12 rows) + +SELECT count(*), count(t2.a) FROM pht5 t1 LEFT JOIN pht6 t2 ON t1.c::text = t2.c::text; + count | count +-------+------- + 5001 | 5000 +(1 row) + +-- Here the upper join clause references t2.c, which is a nullable partition +-- key expression of the (pht5, pht6) join relation, so matching it requires +-- ignoring relabeling on nullable_partexprs as well. +EXPLAIN (COSTS OFF) +SELECT count(*) FROM (pht5 t1 LEFT JOIN pht6 t2 ON t1.c::text = t2.c::text) + LEFT JOIN pht7 t3 ON t2.c::text = t3.c::text; + QUERY PLAN +-------------------------------------------------------------------------- + Aggregate + -> Append + -> Nested Loop Left Join + Join Filter: ((t1_1.c)::text = (t2_1.c)::text) + -> Seq Scan on pht5_p1 t1_1 + -> Materialize + -> Nested Loop Left Join + Join Filter: ((t2_1.c)::text = (t3_1.c)::text) + -> Seq Scan on pht6_p1 t2_1 + -> Materialize + -> Seq Scan on pht7_p1 t3_1 + -> Nested Loop Left Join + Join Filter: ((t1_2.c)::text = (t2_2.c)::text) + -> Seq Scan on pht5_p2 t1_2 + -> Materialize + -> Nested Loop Left Join + Join Filter: ((t2_2.c)::text = (t3_2.c)::text) + -> Seq Scan on pht6_p2 t2_2 + -> Materialize + -> Seq Scan on pht7_p2 t3_2 +(20 rows) + +SELECT count(*) FROM (pht5 t1 LEFT JOIN pht6 t2 ON t1.c::text = t2.c::text) + LEFT JOIN pht7 t3 ON t2.c::text = t3.c::text; + count +------- + 50001 +(1 row) + +RESET max_parallel_workers_per_gather; RESET enable_hashjoin; RESET enable_mergejoin; -- test default partition behavior for range diff --git a/src/test/regress/sql/partition_join.sql b/src/test/regress/sql/partition_join.sql index 2a423f14190..34e7e1fdaca 100644 --- a/src/test/regress/sql/partition_join.sql +++ b/src/test/regress/sql/partition_join.sql @@ -387,6 +387,49 @@ SELECT count(*) FROM pht3 t1 JOIN pht4 t2 ON t1.d = t2.d WHERE t1.c = '0002' AND RESET enable_hashjoin; RESET enable_mergejoin; +-- outer join on a partition key that is an expression wrapped in a RelabelType +CREATE TABLE pht5 (a int, c varchar(40)) PARTITION BY HASH ((c::text)); +CREATE TABLE pht5_p1 PARTITION OF pht5 FOR VALUES WITH (MODULUS 2, REMAINDER 0); +CREATE TABLE pht5_p2 PARTITION OF pht5 FOR VALUES WITH (MODULUS 2, REMAINDER 1); +INSERT INTO pht5 SELECT i, to_char(i/50, 'FM0000') FROM generate_series(0, 599, 2) i; +-- a key with no match in pht6, so that the join produces a null-extended row +INSERT INTO pht5 VALUES (600, '9999'); +ANALYZE pht5; + +CREATE TABLE pht6 (a int, c varchar(40)) PARTITION BY HASH ((c::text)); +CREATE TABLE pht6_p1 PARTITION OF pht6 FOR VALUES WITH (MODULUS 2, REMAINDER 0); +CREATE TABLE pht6_p2 PARTITION OF pht6 FOR VALUES WITH (MODULUS 2, REMAINDER 1); +INSERT INTO pht6 SELECT i, to_char(i/50, 'FM0000') FROM generate_series(0, 599, 3) i; +ANALYZE pht6; + +CREATE TABLE pht7 (a int, c varchar(40)) PARTITION BY HASH ((c::text)); +CREATE TABLE pht7_p1 PARTITION OF pht7 FOR VALUES WITH (MODULUS 2, REMAINDER 0); +CREATE TABLE pht7_p2 PARTITION OF pht7 FOR VALUES WITH (MODULUS 2, REMAINDER 1); +INSERT INTO pht7 SELECT i, to_char(i/50, 'FM0000') FROM generate_series(0, 599, 5) i; +ANALYZE pht7; + +-- avoid hash and merge joins, whose costs here are close enough to the +-- partitionwise nested loop's to make the test output unstable +SET enable_hashjoin = off; +SET enable_mergejoin = off; +SET max_parallel_workers_per_gather = 0; +-- the second count() shows that a null-extended row is produced +EXPLAIN (COSTS OFF) +SELECT count(*), count(t2.a) FROM pht5 t1 LEFT JOIN pht6 t2 ON t1.c::text = t2.c::text; +SELECT count(*), count(t2.a) FROM pht5 t1 LEFT JOIN pht6 t2 ON t1.c::text = t2.c::text; + +-- Here the upper join clause references t2.c, which is a nullable partition +-- key expression of the (pht5, pht6) join relation, so matching it requires +-- ignoring relabeling on nullable_partexprs as well. +EXPLAIN (COSTS OFF) +SELECT count(*) FROM (pht5 t1 LEFT JOIN pht6 t2 ON t1.c::text = t2.c::text) + LEFT JOIN pht7 t3 ON t2.c::text = t3.c::text; +SELECT count(*) FROM (pht5 t1 LEFT JOIN pht6 t2 ON t1.c::text = t2.c::text) + LEFT JOIN pht7 t3 ON t2.c::text = t3.c::text; +RESET max_parallel_workers_per_gather; +RESET enable_hashjoin; +RESET enable_mergejoin; + -- test default partition behavior for range ALTER TABLE prt1 DETACH PARTITION prt1_p3; ALTER TABLE prt1 ATTACH PARTITION prt1_p3 DEFAULT; -- 2.47.3