OK, I've had a considerable refactor which hopefully addresses all your points.

The function now proceeds as follows;

1) Preparatory/rudimentary checks outside of main loop (OR-based SAOP
clause, array dimensionality, element count etc.)
2) Pre-filter bitmap indices for partials or planner implied
3) For each element IN() now look for best choice index via
compare_path_costs (not just first as before)
3a) Check clause fits general index structure (affecting all elements)
3b) Check index matches predicate value/element
4) Build bitmap OR path for this candidate
5) Tests now moved to existing bitmapopts sql/out

I've extended the existing test too to include the multiple-choice
path for the planner as you suggested, which this patch should now
handle (before it was greedy).

It's rebased on top of the current master
(aecc558666ad62fbecb08ff7af1394656811a581) to remain
relevant/up-to-date.

I've not yet tested the performance of it (preferring
correctness/acceptance first!) in the face of many indexes and 100
elements in the array but before I do so, is there a best place for
this kind of test in the source tree? Somewhere beneath src/test
again? I looked but couldn't see an obvious place (bar the regression
tests).

Cheers,

Jim


Jim

On Mon, 12 Jan 2026 at 00:54, David Rowley <[email protected]> wrote:
>
> On Sun, 11 Jan 2026 at 06:03, Jim Vanns <[email protected]> wrote:
> > Before I continue with the other suggestions of consolidating the test
> > and benchmarking, I've made the code change you suggested and used a
> > bitmap for recording positions in the list of candidate indexes. Can
> > you check and make sure I'm on the right track?
>
> Just a quick look;
>
> 1. There doesn't seem to be any consideration that there may be many
> partial indexes which are suitable for the SAOP element:
>
> drop table if exists t;
> create table t (a int);
> insert into t select x/1000 from generate_series(1,1000000)x;
> create index t_eq_1 on t (a) where a = 1;
> create index t_eq_2 on t (a) where a = 2;
> create index t_eq_3 on t (a) where a = 3;
>
> create index t_le_2 on t (a) where a <= 2;
>
> explain select * from t where a in(1,2,3); -- Uses t_le_2 twice rather
> than the other two more suitable indexes.
>
> drop index t_le_2;
> explain select * from t where a in(1,2,3); -- What I'd expect the
> above query to produce.
>
> See: compare_path_costs_fuzzily()
>
> 2. Is there any point in trying the index again when this condition is
> true: if (!clauseset.nonempty). Since you'll be looking for the same
> column for the next element, shouldn't you do bms_del_member() on that
> index? Then put an "if (bms_is_empty(suitable_indexes)) break;" before
> the while loop so that you don't needlessly process the entire SAOP
> array when you run out of suitable indexes.
>
> 3. Styistically, instead of using int index_pos, you can use
> foreach_current_index(idx_lc).
>
> 4. I think the following code puts too much faith into there only
> being 1 path produced. From a quick skim of the current code in
> build_index_paths(), because you're requesting ST_BITMAPSCAN, we don't
> seem to ever produce more than 1 path, but if that changed, then your
> code would make the list contain too many paths.
>
> per_saop_paths = list_concat(per_saop_paths, indexpaths);
>
> 5. Minor detail, but there's a bit of inconsistency in how you're
> checking for empty Lists. The preferred way is: list != NIL.
>
> 6. Are you sure you want to use predOK == true indexes? Do you have a
> case where this new code can produce a better plan than if the predOK
> index was used directly by the existing Path generation code? If so,
> please provide examples.
>
> David
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index 67d9dc35f44..8681afe4e78 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -34,12 +34,23 @@
 #include "optimizer/restrictinfo.h"
 #include "utils/lsyscache.h"
 #include "utils/selfuncs.h"
+#include "utils/array.h"
 
 
 /* XXX see PartCollMatchesExprColl */
 #define IndexCollMatchesExprColl(idxcollation, exprcollation) \
 	((idxcollation) == InvalidOid || (idxcollation) == (exprcollation))
 
+/*
+ * Proof attempts involving large arrays in ScalarArrayOpExpr nodes are
+ * likely to require O(N^2) time, and more often than not fail anyway.
+ * So we set an arbitrary limit on the number of array elements that
+ * we will allow to be treated as an AND or OR clause.
+ * XXX is it worth exposing this as a GUC knob?
+ * This block was copied verbatim from ../utils/predtest.c
+ */
+#define MAX_SAOP_ARRAY_SIZE		100
+
 /* Whether we are looking for plain indexscan, bitmap scan, or either */
 typedef enum
 {
@@ -111,6 +122,8 @@ static List *build_paths_for_OR(PlannerInfo *root, RelOptInfo *rel,
 								List *clauses, List *other_clauses);
 static List *generate_bitmap_or_paths(PlannerInfo *root, RelOptInfo *rel,
 									  List *clauses, List *other_clauses);
+static List *generate_bitmap_saop_paths(PlannerInfo *root, RelOptInfo *rel,
+										List *clauses, List *indexes);
 static Path *choose_bitmap_and(PlannerInfo *root, RelOptInfo *rel,
 							   List *paths);
 static int	path_usage_comparator(const void *a, const void *b);
@@ -323,6 +336,15 @@ create_index_paths(PlannerInfo *root, RelOptInfo *rel)
 										  rel->baserestrictinfo, NIL);
 	bitindexpaths = list_concat(bitindexpaths, indexpaths);
 
+	/*
+	 * Now generate BitmapOrPaths for any suitable SAOP-clauses present in the
+	 * restriction list.  Add these to bitindexpaths.
+	 */
+	indexpaths = generate_bitmap_saop_paths(root, rel,
+											rel->baserestrictinfo,
+											rel->indexlist);
+	bitindexpaths = list_concat(bitindexpaths, indexpaths);
+
 	/*
 	 * Likewise, generate BitmapOrPaths for any suitable OR-clauses present in
 	 * the joinclause list.  Add these to bitjoinpaths.
@@ -1770,6 +1792,299 @@ generate_bitmap_or_paths(PlannerInfo *root, RelOptInfo *rel,
 }
 
 
+/*
+ * generate_bitmap_saop_paths
+ *
+ * Attempt to transform simple ScalarArrayOpExpr (IN/ANY) clauses into
+ * a BitmapOr of per-element bitmap index scans.
+ *
+ * For a clause of the form:
+ *
+ *      var IN (c1, c2, ..., cn) or
+ *      var = ANY(c1, c2, ..., cn)
+ *
+ * where the right-hand side is a non-null constant array, we decompose
+ * the SAOP into n equality clauses:
+ *
+ *      var = c1
+ *      var = c2
+ *      ...
+ *
+ * For each element, we search for bitmap-capable indexes (including partial
+ * indexes - the original motivation for this patch) that can support the
+ * derived equality clause. Note we support this for only up to a maximum
+ * number of elements which must not exceed MAX_SAOP_ARRAY_SIZE.
+ *
+ * All viable index paths for each element are costed, and the cheapest
+ * path is selected.  If and only if every element of the IN list can
+ * be satisfied by some index, the resulting per-element bitmap scans
+ * are combined into a BitmapOrPath and returned.
+ *
+ * If any element cannot be matched to a usable index, the transformation is
+ * abandoned and the SAOP is left to the normal planner machinery.
+ *
+ * Structural index mismatches are pruned during processing to avoid repeated
+ * checks across elements, but value-specific predicate failures do not
+ * eliminate an index globally.
+ */
+static List *
+generate_bitmap_saop_paths(PlannerInfo *root, RelOptInfo *rel,
+                           List *clauses, List *indexes)
+{
+    ListCell   *lc;
+    List       *result = NIL;
+
+    foreach(lc, clauses)
+    {
+        RestrictInfo       *rinfo = lfirst_node(RestrictInfo, lc);
+        ScalarArrayOpExpr  *saop;
+        Node               *leftop;
+        Node               *rightop;
+        const Const        *arrayconst;
+        ArrayType          *arrayval;
+        Datum              *elem_values = NULL;
+        bool               *elem_nulls = NULL;
+        Bitmapset          *suitable_indexes = NULL;
+        List               *per_saop_paths = NIL;
+        int                 nelems;
+        Oid                 elem_type;
+        int16               elem_typlen;
+        bool                elem_typbyval;
+        char                elem_typalign;
+
+        if (!IsA(rinfo->clause, ScalarArrayOpExpr))
+            continue;
+
+        saop = (ScalarArrayOpExpr *) rinfo->clause;
+
+        /* Only handle IN (ANY), not ALL */
+        if (!saop->useOr)
+            continue;
+
+        leftop = (Node *) linitial(saop->args);
+        rightop = (Node *) lsecond(saop->args);
+
+        if (!rightop || !IsA(rightop, Const) ||
+            ((Const *) rightop)->constisnull)
+            continue;
+
+        arrayconst = (const Const *) rightop;
+        arrayval = DatumGetArrayTypeP(arrayconst->constvalue);
+
+        if (ARR_NDIM(arrayval) != 1)
+            continue;
+
+        nelems = ArrayGetNItems(1, ARR_DIMS(arrayval));
+
+        if (nelems < 1 || nelems > MAX_SAOP_ARRAY_SIZE)
+            continue;
+
+        /*
+         * Pre-filter indexes - structurally only. Note we must check for
+         * indpred (the predicate expression of a partial index) or where
+         * the planner has already proven that the query's WHERE clause
+         * *implies* the index predicate.
+         */
+        {
+            int index_pos = 0;
+            ListCell *idx_lc;
+
+            foreach(idx_lc, indexes)
+            {
+                IndexOptInfo *index = lfirst(idx_lc);
+
+                if (index->amhasgetbitmap &&
+                    (index->indpred != NIL || index->predOK))
+                {
+                    suitable_indexes =
+                        bms_add_member(suitable_indexes, index_pos);
+                }
+                index_pos++;
+            }
+        }
+
+        if (bms_is_empty(suitable_indexes))
+            continue;
+
+        elem_type = ARR_ELEMTYPE(arrayval);
+        get_typlenbyvalalign(elem_type,
+                            &elem_typlen,
+                            &elem_typbyval,
+                            &elem_typalign);
+
+        deconstruct_array(arrayval,
+                          elem_type,
+                          elem_typlen,
+                          elem_typbyval,
+                          elem_typalign,
+                          &elem_values,
+                          &elem_nulls,
+                          &nelems);
+
+        /*
+         * For each IN element, build ALL possible index paths,
+         * not just the first one (avoid greedy choice).
+         */
+        for (int i = 0; i < nelems; i++)
+        {
+            Expr       *elem_const;
+            OpExpr     *opclause;
+            RestrictInfo *new_rinfo;
+            List       *paths_for_elem = NIL;
+            Bitmapset  *to_remove = NULL;
+            int         index_pos = -1;
+
+            if (elem_nulls[i])
+            {
+                per_saop_paths = NIL;
+                break;
+            }
+
+            elem_const = (Expr *) makeConst(elem_type,
+                                            -1,
+                                            arrayconst->constcollid,
+                                            elem_typlen,
+                                            elem_values[i],
+                                            false,
+                                            elem_typbyval);
+
+            opclause = (OpExpr *) make_opclause(saop->opno,
+                                     BOOLOID,
+                                     false,
+                                     (Expr *) copyObject(leftop),
+                                     elem_const,
+                                     saop->inputcollid,
+                                     saop->inputcollid);
+
+            new_rinfo = make_simple_restrictinfo(root,
+                                                 (Expr *) opclause);
+
+            while ((index_pos =
+                        bms_next_member(suitable_indexes,
+                                        index_pos)) >= 0)
+            {
+                IndexOptInfo *index =
+                    list_nth(indexes, index_pos);
+                IndexClauseSet clauseset;
+                List *indexpaths;
+
+                /*
+                 * Element-specific predicate check.
+                 * Do NOT prune index here.
+                 */
+                if (!predicate_implied_by(index->indpred,
+                                          list_make1(new_rinfo),
+                                          false))
+                    continue;
+
+                MemSet(&clauseset, 0, sizeof(clauseset));
+
+                match_clause_to_index(root,
+                                      new_rinfo,
+                                      index,
+                                      &clauseset);
+
+                /*
+                 * Structural mismatch → prune permanently.
+                 */
+                if (!clauseset.nonempty)
+                {
+                    to_remove =
+                        bms_add_member(to_remove,
+                                       index_pos);
+                    continue;
+                }
+
+                indexpaths =
+                    build_index_paths(root,
+                                      rel,
+                                      index,
+                                      &clauseset,
+                                      true,
+                                      ST_BITMAPSCAN,
+                                      NULL);
+
+                if (indexpaths != NIL)
+                    paths_for_elem =
+                        list_concat(paths_for_elem,
+                                    indexpaths);
+            }
+
+            /*
+             * Apply structural pruning after iteration.
+             */
+            if (to_remove != NULL)
+            {
+                suitable_indexes =
+                    bms_del_members(suitable_indexes,
+                                    to_remove);
+                bms_free(to_remove);
+
+                if (bms_is_empty(suitable_indexes))
+                {
+                    per_saop_paths = NIL;
+                    break;
+                }
+            }
+
+            /*
+             * If no index could satisfy this element,
+             * abort entire SAOP transformation.
+             */
+            if (paths_for_elem == NIL)
+            {
+                per_saop_paths = NIL;
+                break;
+            }
+
+            /*
+             * Choose cheapest path for this element.
+             * Avoid path explosion and respect planner costing.
+             */
+            {
+                Path *cheapest = (Path *) linitial(paths_for_elem);
+                ListCell *plc;
+
+                foreach(plc, paths_for_elem)
+                {
+                    Path *p = lfirst(plc);
+
+                    if (compare_path_costs(p, cheapest, TOTAL_COST) < 0)
+                        cheapest = p;
+                }
+
+                per_saop_paths = lappend(per_saop_paths, cheapest);
+            }
+        }
+
+        bms_free(suitable_indexes);
+
+        if (per_saop_paths != NIL)
+        {
+            Path *bitmapqual;
+
+            if (list_length(per_saop_paths) > 1)
+                bitmapqual =
+                    (Path *) create_bitmap_or_path(root,
+                                                   rel,
+                                                   per_saop_paths);
+            else
+                bitmapqual =
+                    (Path *) linitial(per_saop_paths);
+
+            result = lappend(result, bitmapqual);
+        }
+
+        if (elem_values)
+            pfree(elem_values);
+        if (elem_nulls)
+            pfree(elem_nulls);
+    }
+
+    return result;
+}
+
+
 /*
  * choose_bitmap_and
  *		Given a nonempty list of bitmap paths, AND them into one path.
diff --git a/src/test/regress/expected/bitmapops.out b/src/test/regress/expected/bitmapops.out
index 64068e0469c..2d632edfab1 100644
--- a/src/test/regress/expected/bitmapops.out
+++ b/src/test/regress/expected/bitmapops.out
@@ -46,3 +46,118 @@ SELECT count(*) FROM bmscantest WHERE a = 1 OR b = 1;
 
 -- clean up
 DROP TABLE bmscantest;
+-- Test ScalarArrayOpExpr (SAOP) for partial indexes
+-- Generate enough data that we can test the use of an index
+CREATE TABLE ipairs(
+   key INT,
+   value INT
+)
+WITH (autovacuum_enabled = false);
+-- Install the partial index on these values
+CREATE INDEX ipairs_key_idx1 ON ipairs(key) WHERE key = 507;
+CREATE INDEX ipairs_key_idx2 ON ipairs(key) WHERE key = 508;
+CREATE INDEX ipairs_key_idx3 ON ipairs(key) WHERE key = 509;
+CREATE INDEX ipairs_key_idx4 ON ipairs(key) WHERE key <= 508; -- Give planner a choice
+-- Dummy data to surround ours
+INSERT INTO ipairs(key, value)
+SELECT n, -n
+FROM generate_series(1, 1000) AS tmp(n);
+-- Update statistics
+VACUUM (ANALYZE) ipairs;
+-- Our specific test data to force the partial index
+EXPLAIN (ANALYZE, COSTS OFF, BUFFERS OFF, SUMMARY OFF, TIMING OFF)
+UPDATE ipairs
+SET value = 0
+WHERE key IN (507,508,509);
+                                    QUERY PLAN                                     
+-----------------------------------------------------------------------------------
+ Update on ipairs (actual rows=0.00 loops=1)
+   ->  Bitmap Heap Scan on ipairs (actual rows=3.00 loops=1)
+         Recheck Cond: ((key = 507) OR (key = 508) OR (key = 509))
+         Heap Blocks: exact=1
+         ->  BitmapOr (actual rows=0.00 loops=1)
+               ->  Bitmap Index Scan on ipairs_key_idx1 (actual rows=1.00 loops=1)
+                     Index Cond: (key = 507)
+                     Index Searches: 1
+               ->  Bitmap Index Scan on ipairs_key_idx2 (actual rows=1.00 loops=1)
+                     Index Cond: (key = 508)
+                     Index Searches: 1
+               ->  Bitmap Index Scan on ipairs_key_idx3 (actual rows=1.00 loops=1)
+                     Index Cond: (key = 509)
+                     Index Searches: 1
+(14 rows)
+
+-- We want ensure that an index use is always attempted - just for the test
+SET enable_seqscan=false;
+-- Variant 1 (works without the SAOP optimiser patch)
+EXPLAIN (COSTS OFF)
+SELECT value = 0
+FROM ipairs
+WHERE
+   key = 507 OR
+   key = 508 OR
+   key = 509;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Bitmap Heap Scan on ipairs
+   Recheck Cond: ((key = 507) OR (key = 508) OR (key = 509))
+   ->  BitmapOr
+         ->  Bitmap Index Scan on ipairs_key_idx1
+               Index Cond: (key = 507)
+         ->  Bitmap Index Scan on ipairs_key_idx2
+               Index Cond: (key = 508)
+         ->  Bitmap Index Scan on ipairs_key_idx3
+               Index Cond: (key = 509)
+(9 rows)
+
+-- Variant 2 (works only with the SAOP optimiser patch)
+EXPLAIN (COSTS OFF)
+SELECT value = 0
+FROM ipairs
+WHERE key IN (507, 508, 509);
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Bitmap Heap Scan on ipairs
+   Recheck Cond: ((key = 507) OR (key = 508) OR (key = 509))
+   ->  BitmapOr
+         ->  Bitmap Index Scan on ipairs_key_idx1
+               Index Cond: (key = 507)
+         ->  Bitmap Index Scan on ipairs_key_idx2
+               Index Cond: (key = 508)
+         ->  Bitmap Index Scan on ipairs_key_idx3
+               Index Cond: (key = 509)
+(9 rows)
+
+-- Variant 3 (as above)
+EXPLAIN (COSTS OFF)
+SELECT value = 0
+FROM ipairs
+WHERE key = ANY('{507,508,509}'::INT[]);
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Bitmap Heap Scan on ipairs
+   Recheck Cond: ((key = 507) OR (key = 508) OR (key = 509))
+   ->  BitmapOr
+         ->  Bitmap Index Scan on ipairs_key_idx1
+               Index Cond: (key = 507)
+         ->  Bitmap Index Scan on ipairs_key_idx2
+               Index Cond: (key = 508)
+         ->  Bitmap Index Scan on ipairs_key_idx3
+               Index Cond: (key = 509)
+(9 rows)
+
+-- Variant 5 (should use only ipairs_key_idx4)
+EXPLAIN (COSTS OFF)
+SELECT value = 0
+FROM ipairs
+WHERE key < ANY('{508}'::INT[]);
+                      QUERY PLAN                      
+------------------------------------------------------
+ Bitmap Heap Scan on ipairs
+   Recheck Cond: (key < ANY ('{508}'::integer[]))
+   ->  Bitmap Index Scan on ipairs_key_idx4
+         Index Cond: (key < ANY ('{508}'::integer[]))
+(4 rows)
+
+-- Clean up
+DROP TABLE ipairs;
diff --git a/src/test/regress/sql/bitmapops.sql b/src/test/regress/sql/bitmapops.sql
index 1b175f6ff96..aec5fb5a625 100644
--- a/src/test/regress/sql/bitmapops.sql
+++ b/src/test/regress/sql/bitmapops.sql
@@ -45,3 +45,65 @@ SELECT count(*) FROM bmscantest WHERE a = 1 OR b = 1;
 
 -- clean up
 DROP TABLE bmscantest;
+
+
+-- Test ScalarArrayOpExpr (SAOP) for partial indexes
+
+-- Generate enough data that we can test the use of an index
+CREATE TABLE ipairs(
+   key INT,
+   value INT
+)
+WITH (autovacuum_enabled = false);
+-- Install the partial index on these values
+CREATE INDEX ipairs_key_idx1 ON ipairs(key) WHERE key = 507;
+CREATE INDEX ipairs_key_idx2 ON ipairs(key) WHERE key = 508;
+CREATE INDEX ipairs_key_idx3 ON ipairs(key) WHERE key = 509;
+CREATE INDEX ipairs_key_idx4 ON ipairs(key) WHERE key <= 508; -- Give planner a choice
+
+-- Dummy data to surround ours
+INSERT INTO ipairs(key, value)
+SELECT n, -n
+FROM generate_series(1, 1000) AS tmp(n);
+
+-- Update statistics
+VACUUM (ANALYZE) ipairs;
+
+-- Our specific test data to force the partial index
+EXPLAIN (ANALYZE, COSTS OFF, BUFFERS OFF, SUMMARY OFF, TIMING OFF)
+UPDATE ipairs
+SET value = 0
+WHERE key IN (507,508,509);
+
+-- We want ensure that an index use is always attempted - just for the test
+SET enable_seqscan=false;
+
+-- Variant 1 (works without the SAOP optimiser patch)
+EXPLAIN (COSTS OFF)
+SELECT value = 0
+FROM ipairs
+WHERE
+   key = 507 OR
+   key = 508 OR
+   key = 509;
+
+-- Variant 2 (works only with the SAOP optimiser patch)
+EXPLAIN (COSTS OFF)
+SELECT value = 0
+FROM ipairs
+WHERE key IN (507, 508, 509);
+
+-- Variant 3 (as above)
+EXPLAIN (COSTS OFF)
+SELECT value = 0
+FROM ipairs
+WHERE key = ANY('{507,508,509}'::INT[]);
+
+-- Variant 5 (should use only ipairs_key_idx4)
+EXPLAIN (COSTS OFF)
+SELECT value = 0
+FROM ipairs
+WHERE key < ANY('{508}'::INT[]);
+
+-- Clean up
+DROP TABLE ipairs;

Reply via email to