From 9a6738f3948a75c212104973ab1a164224b51219 Mon Sep 17 00:00:00 2001
From: Ewan Young <kdbase.hack@gmail.com>
Date: Fri, 7 Aug 2026 01:45:24 +0800
Subject: [PATCH v1 1/2] Don't prune the default partition for bounds unbounded
 in a trailing key

get_matching_range_bounds() may drop the extreme bound offset from its
result when the corresponding key space is covered by no partition, on
the grounds that no value can exist beyond a bound that is unbounded.
The test for that looked at the last key of the lookup value rather than
the first key of the bound, but only a bound that is unbounded in its
first key has no key space beyond it.  With multi-column range keys,
TO (19, MAXVALUE) merely means "unbounded within a = 19", and everything
above that bound is real key space owned by the default partition, if one
exists.  FROM (13, MINVALUE) is the mirror image of this.

Dropping the offset loses the information that the default partition has
to be scanned: since 489247b0e615 get_matching_partitions() derives that
from a returned offset whose partindices[] entry is -1, and the
scan_default flag is not set on this path either, so both representations
of "scan the default partition" are lost at once.  Queries then silently
return too few rows, and UPDATE and DELETE silently skip rows:

    CREATE TABLE t (a int, b int) PARTITION BY RANGE (a, b);
    CREATE TABLE t1 PARTITION OF t FOR VALUES FROM (13, 0) TO (19, MAXVALUE);
    CREATE TABLE td PARTITION OF t DEFAULT;
    INSERT INTO t VALUES (32, 5);
    SELECT count(*) FROM t WHERE a = 32;              -- 1
    SELECT count(*) FROM t WHERE a = 32 AND b >= -7;  -- 0, should be 1

Fix by testing the first key of the bound instead.  Bounds that really are
unbounded still allow the default partition to be pruned, so this does not
give up any pruning that was valid before, and no existing regression test
output changes.

This is the same class of mistake as 709dfd27f14, which removed the
equivalent adjustment from the nvalues == 0 path of this function.
---
 src/backend/partitioning/partprune.c          |  34 +++---
 src/test/regress/expected/partition_prune.out | 107 ++++++++++++++++++
 src/test/regress/sql/partition_prune.sql      |  47 ++++++++
 3 files changed, 172 insertions(+), 16 deletions(-)

diff --git a/src/backend/partitioning/partprune.c b/src/backend/partitioning/partprune.c
index afe57ac297d..ab48ea7ff40 100644
--- a/src/backend/partitioning/partprune.c
+++ b/src/backend/partitioning/partprune.c
@@ -3331,16 +3331,20 @@ get_matching_range_bounds(PartitionPruneContext *context,
 
 	/*
 	 * If the smallest partition to return has MINVALUE (negative infinity) as
-	 * its lower bound, increment it to point to the next finite bound
-	 * (supposedly its upper bound), so that we don't inadvertently end up
-	 * scanning the default partition.
+	 * its lower bound, there is no key space below it at all, so increment
+	 * minoff to point to the next finite bound (supposedly its upper bound),
+	 * so that we don't inadvertently end up scanning the default partition.
+	 *
+	 * Only a bound that is unbounded in its *first* key has nothing below it.
+	 * When just a trailing key is MINVALUE, as in FROM (13, MINVALUE), the key
+	 * space below the bound is real and is owned by the default partition, if
+	 * one exists, so the offset must be retained: get_matching_partitions()
+	 * turns an offset whose partindices[] entry is -1 into "also scan the
+	 * default partition".
 	 */
 	if (minoff < boundinfo->ndatums && partindices[minoff] < 0)
 	{
-		int			lastkey = nvalues - 1;
-
-		if (boundinfo->kind[minoff][lastkey] ==
-			PARTITION_RANGE_DATUM_MINVALUE)
+		if (boundinfo->kind[minoff][0] == PARTITION_RANGE_DATUM_MINVALUE)
 		{
 			minoff++;
 			Assert(boundinfo->indexes[minoff] >= 0);
@@ -3348,18 +3352,16 @@ get_matching_range_bounds(PartitionPruneContext *context,
 	}
 
 	/*
-	 * If the previous greatest partition has MAXVALUE (positive infinity) as
-	 * its upper bound (something only possible to do with multi-column range
-	 * partitioning), we scan switch to it as the greatest partition to
-	 * return.  Again, so that we don't inadvertently end up scanning the
-	 * default partition.
+	 * Likewise, if the previous greatest partition has MAXVALUE (positive
+	 * infinity) as its upper bound (something only possible to do with
+	 * multi-column range partitioning), we switch to it as the greatest
+	 * partition to return.  Again, this is only correct when the first key of
+	 * the bound is unbounded; TO (19, MAXVALUE) merely means "unbounded within
+	 * a = 19", and everything above it belongs to the default partition.
 	 */
 	if (maxoff >= 1 && partindices[maxoff] < 0)
 	{
-		int			lastkey = nvalues - 1;
-
-		if (boundinfo->kind[maxoff - 1][lastkey] ==
-			PARTITION_RANGE_DATUM_MAXVALUE)
+		if (boundinfo->kind[maxoff - 1][0] == PARTITION_RANGE_DATUM_MAXVALUE)
 		{
 			maxoff--;
 			Assert(boundinfo->indexes[maxoff] >= 0);
diff --git a/src/test/regress/expected/partition_prune.out b/src/test/regress/expected/partition_prune.out
index 0d21a2d027c..cf6ed4efff5 100644
--- a/src/test/regress/expected/partition_prune.out
+++ b/src/test/regress/expected/partition_prune.out
@@ -1099,6 +1099,113 @@ explain (costs off) select * from mc2p where b is null;
    Filter: (b IS NULL)
 (2 rows)
 
+-- Ensure the default partition is not pruned when a bound is unbounded only
+-- in a trailing key
+create table mc2pdmax (a int, b int) partition by range (a, b);
+create table mc2pdmax_default partition of mc2pdmax default;
+create table mc2pdmax1 partition of mc2pdmax for values from (13, 0) to (19, maxvalue);
+insert into mc2pdmax values (32, 5);
+explain (costs off) select * from mc2pdmax where a = 32 and b >= -7;
+                  QUERY PLAN                   
+-----------------------------------------------
+ Seq Scan on mc2pdmax_default mc2pdmax
+   Filter: ((b >= '-7'::integer) AND (a = 32))
+(2 rows)
+
+select * from mc2pdmax where a = 32 and b >= -7;
+ a  | b 
+----+---
+ 32 | 5
+(1 row)
+
+explain (costs off) select * from mc2pdmax where a >= 25 and b >= 0;
+              QUERY PLAN               
+---------------------------------------
+ Seq Scan on mc2pdmax_default mc2pdmax
+   Filter: ((a >= 25) AND (b >= 0))
+(2 rows)
+
+select * from mc2pdmax where a >= 25 and b >= 0;
+ a  | b 
+----+---
+ 32 | 5
+(1 row)
+
+-- The same lookup value reaches the key space above the bound here but not in
+-- the a = 19 case below, so the default partition must be scanned for the
+-- first query only.
+insert into mc2pdmax values (19, 5);
+select * from mc2pdmax where a >= 19 and b >= 5;
+ a  | b 
+----+---
+ 19 | 5
+ 32 | 5
+(2 rows)
+
+select * from mc2pdmax where a = 19 and b >= 5;
+ a  | b 
+----+---
+ 19 | 5
+(1 row)
+
+drop table mc2pdmax;
+create table mc2pdmin (a int, b int) partition by range (a, b);
+create table mc2pdmin_default partition of mc2pdmin default;
+create table mc2pdmin1 partition of mc2pdmin for values from (13, minvalue) to (19, 5);
+insert into mc2pdmin values (5, 5);
+explain (costs off) select * from mc2pdmin where a = 5 and b <= 7;
+              QUERY PLAN               
+---------------------------------------
+ Seq Scan on mc2pdmin_default mc2pdmin
+   Filter: ((b <= 7) AND (a = 5))
+(2 rows)
+
+select * from mc2pdmin where a = 5 and b <= 7;
+ a | b 
+---+---
+ 5 | 5
+(1 row)
+
+drop table mc2pdmin;
+-- Ensure the default partition is still pruned when the first key is unbounded
+create table mc2pinf (a int, b int) partition by range (a, b);
+create table mc2pinf_default partition of mc2pinf default;
+create table mc2pinf1 partition of mc2pinf for values from (0, 0) to (maxvalue, maxvalue);
+create table mc2pinf2 partition of mc2pinf for values from (minvalue, minvalue) to (0, 0);
+explain (costs off) select * from mc2pinf where a = 32 and b >= -7;
+                  QUERY PLAN                   
+-----------------------------------------------
+ Seq Scan on mc2pinf1 mc2pinf
+   Filter: ((b >= '-7'::integer) AND (a = 32))
+(2 rows)
+
+explain (costs off) select * from mc2pinf where a = -32 and b <= 7;
+                  QUERY PLAN                   
+-----------------------------------------------
+ Seq Scan on mc2pinf2 mc2pinf
+   Filter: ((b <= 7) AND (a = '-32'::integer))
+(2 rows)
+
+drop table mc2pinf;
+-- Ensure the uncovered key space matches nothing when there is no default
+create table mc2pnodef (a int, b int) partition by range (a, b);
+create table mc2pnodef1 partition of mc2pnodef for values from (13, 0) to (19, maxvalue);
+explain (costs off) select * from mc2pnodef where a = 32 and b >= -7;
+          QUERY PLAN           
+-------------------------------
+ Result
+   Replaces: Scan on mc2pnodef
+   One-Time Filter: false
+(3 rows)
+
+explain (costs off) select * from mc2pnodef where a = 15 and b >= 0;
+            QUERY PLAN             
+-----------------------------------
+ Seq Scan on mc2pnodef1 mc2pnodef
+   Filter: ((b >= 0) AND (a = 15))
+(2 rows)
+
+drop table mc2pnodef;
 -- boolean partitioning
 create table boolpart (a bool) partition by list (a);
 create table boolpart_default partition of boolpart default;
diff --git a/src/test/regress/sql/partition_prune.sql b/src/test/regress/sql/partition_prune.sql
index dac673ef80a..5f3d4e104af 100644
--- a/src/test/regress/sql/partition_prune.sql
+++ b/src/test/regress/sql/partition_prune.sql
@@ -192,6 +192,53 @@ explain (costs off) select * from mc2p where a is null and b = 1;
 explain (costs off) select * from mc2p where a is null;
 explain (costs off) select * from mc2p where b is null;
 
+-- Ensure the default partition is not pruned when a bound is unbounded only
+-- in a trailing key
+create table mc2pdmax (a int, b int) partition by range (a, b);
+create table mc2pdmax_default partition of mc2pdmax default;
+create table mc2pdmax1 partition of mc2pdmax for values from (13, 0) to (19, maxvalue);
+insert into mc2pdmax values (32, 5);
+
+explain (costs off) select * from mc2pdmax where a = 32 and b >= -7;
+select * from mc2pdmax where a = 32 and b >= -7;
+explain (costs off) select * from mc2pdmax where a >= 25 and b >= 0;
+select * from mc2pdmax where a >= 25 and b >= 0;
+
+-- The same lookup value reaches the key space above the bound here but not in
+-- the a = 19 case below, so the default partition must be scanned for the
+-- first query only.
+insert into mc2pdmax values (19, 5);
+select * from mc2pdmax where a >= 19 and b >= 5;
+select * from mc2pdmax where a = 19 and b >= 5;
+drop table mc2pdmax;
+
+create table mc2pdmin (a int, b int) partition by range (a, b);
+create table mc2pdmin_default partition of mc2pdmin default;
+create table mc2pdmin1 partition of mc2pdmin for values from (13, minvalue) to (19, 5);
+insert into mc2pdmin values (5, 5);
+
+explain (costs off) select * from mc2pdmin where a = 5 and b <= 7;
+select * from mc2pdmin where a = 5 and b <= 7;
+drop table mc2pdmin;
+
+-- Ensure the default partition is still pruned when the first key is unbounded
+create table mc2pinf (a int, b int) partition by range (a, b);
+create table mc2pinf_default partition of mc2pinf default;
+create table mc2pinf1 partition of mc2pinf for values from (0, 0) to (maxvalue, maxvalue);
+create table mc2pinf2 partition of mc2pinf for values from (minvalue, minvalue) to (0, 0);
+
+explain (costs off) select * from mc2pinf where a = 32 and b >= -7;
+explain (costs off) select * from mc2pinf where a = -32 and b <= 7;
+drop table mc2pinf;
+
+-- Ensure the uncovered key space matches nothing when there is no default
+create table mc2pnodef (a int, b int) partition by range (a, b);
+create table mc2pnodef1 partition of mc2pnodef for values from (13, 0) to (19, maxvalue);
+
+explain (costs off) select * from mc2pnodef where a = 32 and b >= -7;
+explain (costs off) select * from mc2pnodef where a = 15 and b >= 0;
+drop table mc2pnodef;
+
 -- boolean partitioning
 create table boolpart (a bool) partition by list (a);
 create table boolpart_default partition of boolpart default;
-- 
2.47.3

