On 28/07/2026 10:13, Andrei Lepikhov wrote:

Just a rebase onto current master

-- 
regards, Andrei Lepikhov,
pgEdge
From d586afe7821fd2b676d4553bf582d849fa8f3583 Mon Sep 17 00:00:00 2001
From: "Andrei V. Lepikhov" <[email protected]>
Date: Wed, 15 Jul 2026 12:06:36 +1000
Subject: [PATCH v2 1/2] Gate the NestLoop inner side with outer-only join
 clauses
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

In some cases, a LEFT JOIN’s NestLoop might have a clause that depends only on
the outer side of the join. In such a case, if the outer tuple evaluates this
clause as false, the result of the inner side scan operation will not impact
the join’s output: inner side will always be replaced with NULL values.

Treat such an outer-side clause as a ‘gating’ clause and push it into the inner
side as a Result node. It will reduce the number of rescan operations to a bare
minimum when the the gating clause evaluates to a true.

This operation effectively forces parameterisation of the NestLoop JOIN, even if
it was originally plain. To identify such cases, the NestLoop struct was
supplemented with a flag that allows the executor to distinguish a ‘gated’ join
from the parameterised NestLoop and to set the REWIND flag correctly.

No costing change is made: the gating Result inherits its child's cost fields,
and final_cost_nestloop() does not model the skipped inner rescans.
---
 .../postgres_fdw/expected/postgres_fdw.out    |  14 +-
 src/backend/executor/nodeNestloop.c           |  12 +-
 src/backend/optimizer/plan/createplan.c       |  93 +++-
 src/include/nodes/plannodes.h                 |   2 +
 src/test/regress/expected/create_index.out    |  21 +-
 src/test/regress/expected/join.out            | 412 ++++++++++--------
 src/test/regress/expected/predicate.out       | 113 ++---
 src/test/regress/expected/subselect.out       |  17 +-
 src/test/regress/sql/join.sql                 |  27 ++
 9 files changed, 459 insertions(+), 252 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out 
b/contrib/postgres_fdw/expected/postgres_fdw.out
index 517d15cf1fa..ac6d8d851a1 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -2532,16 +2532,18 @@ SELECT q.a, ft2.c1 FROM (SELECT 13 FROM ft1 WHERE c1 = 
13) q(a) RIGHT JOIN ft2 O
 
---------------------------------------------------------------------------------------------------------------------------
  Nested Loop Left Join
    Output: (13), ft2.c1
-   Join Filter: (13 = ft2.c1)
    ->  Foreign Scan on public.ft2
          Output: ft2.c1
          Remote SQL: SELECT "C 1" FROM "S 1"."T 1" WHERE (("C 1" >= 10)) AND 
(("C 1" <= 15)) ORDER BY "C 1" ASC NULLS LAST
-   ->  Materialize
+   ->  Result
          Output: (13)
-         ->  Foreign Scan on public.ft1
-               Output: 13
-               Remote SQL: SELECT NULL FROM "S 1"."T 1" WHERE (("C 1" = 13))
-(11 rows)
+         One-Time Filter: (13 = ft2.c1)
+         ->  Materialize
+               Output: (13)
+               ->  Foreign Scan on public.ft1
+                     Output: 13
+                     Remote SQL: SELECT NULL FROM "S 1"."T 1" WHERE (("C 1" = 
13))
+(13 rows)
 
 SELECT q.a, ft2.c1 FROM (SELECT 13 FROM ft1 WHERE c1 = 13) q(a) RIGHT JOIN ft2 
ON (q.a = ft2.c1) WHERE ft2.c1 BETWEEN 10 AND 15;
  a  | c1 
diff --git a/src/backend/executor/nodeNestloop.c 
b/src/backend/executor/nodeNestloop.c
index da48c9b204b..7da6eb49fc1 100644
--- a/src/backend/executor/nodeNestloop.c
+++ b/src/backend/executor/nodeNestloop.c
@@ -263,12 +263,16 @@ ExecInitNestLoop(NestLoop *node, EState *estate, int 
eflags)
         *
         * If we have no parameters to pass into the inner rel from the outer,
         * tell the inner child that cheap rescans would be good.  If we do have
-        * such parameters, then there is no point in REWIND support at all in 
the
-        * inner child, because it will always be rescanned with fresh parameter
-        * values.
+        * such parameters, there is normally no point in REWIND support in the
+        * inner child, because it will be rescanned with fresh parameter 
values.
+        *
+        * The exception is a 'gated' nested loop join, where the only 
parameters
+        * feed a gating Result that caps the inner subtree and is re-checked 
once
+        * per outer tuple.  The plan below that gate is parameter-independent, 
so
+        * we keep the REWIND flag.
         */
        outerPlanState(nlstate) = ExecInitNode(outerPlan(node), estate, eflags);
-       if (node->nestParams == NIL)
+       if (node->nestParams == NIL || node->keep_inner_rewind)
                eflags |= EXEC_FLAG_REWIND;
        else
                eflags &= ~EXEC_FLAG_REWIND;
diff --git a/src/backend/optimizer/plan/createplan.c 
b/src/backend/optimizer/plan/createplan.c
index 02a888c5996..50e7e71370b 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -4200,8 +4200,11 @@ create_nestloop_plan(PlannerInfo *root,
        Plan       *inner_plan;
        Relids          outerrelids;
        Relids          ojrelids;
+       Relids          joinrelids = best_path->jpath.path.parent->relids;
        List       *tlist = build_path_tlist(root, &best_path->jpath.path);
        List       *joinrestrictclauses = best_path->jpath.joinrestrictinfo;
+       List       *gating_clauses = NIL;
+       bool            keep_inner_rewind = false;
        List       *joinclauses;
        List       *otherclauses;
        List       *nestParams;
@@ -4247,8 +4250,24 @@ create_nestloop_plan(PlannerInfo *root,
        if (IS_OUTER_JOIN(best_path->jpath.jointype))
        {
                extract_actual_join_clauses(joinrestrictclauses,
-                                                                       
best_path->jpath.path.parent->relids,
+                                                                       
joinrelids,
                                                                        
&joinclauses, &otherclauses);
+
+               /*
+                * Collect the join clauses that reference only the outer rel: 
they are
+                * constant for a given outer tuple, so the loop further down 
can gate
+                * the inner side with them instead of re-checking them per 
inner row.
+                */
+               foreach_node(RestrictInfo, rinfo, joinrestrictclauses)
+               {
+                       if (bms_is_empty(rinfo->clause_relids) ||
+                               !bms_is_subset(rinfo->clause_relids, 
outerrelids))
+                               continue;
+
+                       Assert(!RINFO_IS_PUSHED_DOWN(rinfo, joinrelids));
+
+                       gating_clauses = lappend(gating_clauses, rinfo->clause);
+               }
        }
        else
        {
@@ -4257,6 +4276,14 @@ create_nestloop_plan(PlannerInfo *root,
                otherclauses = NIL;
        }
 
+       /*
+        * Pull the outer-only clauses out of joinclauses; they become a gating
+        * qual on the inner side below.  Do this before parameterization, while
+        * the expressions are still un-parameterized.
+        */
+       if (gating_clauses != NIL)
+               joinclauses = list_difference_ptr(joinclauses, gating_clauses);
+
        /* Replace any outer-relation variables with nestloop params */
        if (best_path->jpath.path.param_info)
        {
@@ -4271,6 +4298,68 @@ create_nestloop_plan(PlannerInfo *root,
                                                          
bms_union(best_path->jpath.outerjoinpath->parent->relids,
                                                                                
best_path->jpath.innerjoinpath->parent->relids));
 
+       /*
+        * The gating clauses must be parameterized so the gating Result can
+        * evaluate them against the current outer tuple.
+        */
+       if (gating_clauses != NIL)
+       {
+               Relids  tmpOuterRels = root->curOuterRels;
+               Plan   *subplan = inner_plan;
+
+               /*
+                * Keep the inner side rewindable across rescans, but only when 
this
+                * gate is the sole source of nestloop parameters.
+                */
+               keep_inner_rewind =
+                                       
!bms_overlap(PATH_REQ_OUTER(best_path->jpath.innerjoinpath),
+                                                                               
                outerrelids);
+
+               Assert(bms_is_subset(pull_varnos(root, (Node *) gating_clauses),
+                                                         outerrelids));
+
+               /*
+                * replace_nestloop_params only converts Vars in curOuterRels, 
which
+                * was restored above and no longer covers this join's outer 
relids,
+                * so re-add them across the call.
+                */
+               root->curOuterRels = bms_union(root->curOuterRels, outerrelids);
+               gating_clauses = (List *)
+                       replace_nestloop_params(root, (Node *) gating_clauses);
+               bms_free(root->curOuterRels);
+               root->curOuterRels = tmpOuterRels;
+
+               /*
+                * Avoid stacking Result nodes.  If the inner plan is already a 
Result,
+                * merge our parameterized clauses into its resconstantqual.  
This
+                * mirrors create_gating_plan()'s logic.
+                */
+               if (IsA(subplan, Result))
+               {
+                       Result *existing = (Result *) subplan;
+                       List   *clauses = (List *) existing->resconstantqual;
+
+                       Assert(clauses == NULL || IsA(clauses, List));
+
+                       clauses = list_concat(gating_clauses, clauses);
+                       clauses = order_qual_clauses(root, clauses);
+                       existing->resconstantqual = (Node *) clauses;
+
+                       /* Gating quals could be unsafe, so use the Path's 
safety flag */
+                       existing->plan.parallel_safe = 
best_path->jpath.path.parallel_safe;
+               }
+               else
+               {
+                       inner_plan = (Plan *) 
make_gating_result(subplan->targetlist,
+                                                                               
                         (Node *) gating_clauses,
+                                                                               
                         subplan);
+                       copy_plan_costsize(inner_plan, subplan);
+
+                       /* Gating quals could be unsafe, so use the Path's 
safety flag */
+                       inner_plan->parallel_safe = 
best_path->jpath.path.parallel_safe;
+               }
+       }
+
        /*
         * Identify any nestloop parameters that should be supplied by this join
         * node, and remove them from root->curOuterParams.
@@ -4345,6 +4434,8 @@ create_nestloop_plan(PlannerInfo *root,
                                                          ojrelids,
                                                          
best_path->jpath.inner_unique);
 
+       join_plan->keep_inner_rewind = keep_inner_rewind;
+
        copy_generic_path_info(&join_plan->join.plan, &best_path->jpath.path);
 
        return join_plan;
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 2fe6b61afaf..dce46966685 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -1010,6 +1010,8 @@ typedef struct NestLoop
        Join            join;
        /* list of NestLoopParam nodes */
        List       *nestParams;
+       /* keep inner side rewindable: gate is the sole source of nestloop 
params */
+       bool            keep_inner_rewind;
 } NestLoop;
 
 typedef struct NestLoopParam
diff --git a/src/test/regress/expected/create_index.out 
b/src/test/regress/expected/create_index.out
index 7b2640f0e04..f9db7416306 100644
--- a/src/test/regress/expected/create_index.out
+++ b/src/test/regress/expected/create_index.out
@@ -2260,19 +2260,20 @@ EXPLAIN (COSTS OFF)
 SELECT count(*) FROM tenk1 LEFT JOIN tenk2 ON
   tenk1.hundred = 42 AND (tenk2.thousand = 42 OR tenk2.thousand = 41 OR 
tenk2.tenthous = 2) AND
   tenk2.hundred = tenk1.hundred;
-                                     QUERY PLAN                                
     
-------------------------------------------------------------------------------------
+                                        QUERY PLAN                             
           
+------------------------------------------------------------------------------------------
  Aggregate
    ->  Nested Loop Left Join
-         Join Filter: (tenk1.hundred = 42)
          ->  Index Only Scan using tenk1_hundred on tenk1
-         ->  Memoize
-               Cache Key: tenk1.hundred
-               Cache Mode: logical
-               ->  Index Scan using tenk2_hundred on tenk2
-                     Index Cond: (hundred = tenk1.hundred)
-                     Filter: ((thousand = 42) OR (thousand = 41) OR (tenthous 
= 2))
-(10 rows)
+         ->  Result
+               One-Time Filter: (tenk1.hundred = 42)
+               ->  Memoize
+                     Cache Key: tenk1.hundred
+                     Cache Mode: logical
+                     ->  Index Scan using tenk2_hundred on tenk2
+                           Index Cond: (hundred = tenk1.hundred)
+                           Filter: ((thousand = 42) OR (thousand = 41) OR 
(tenthous = 2))
+(11 rows)
 
 --
 -- Check behavior with duplicate index column contents
diff --git a/src/test/regress/expected/join.out 
b/src/test/regress/expected/join.out
index db4fcc5a5a0..43e73268328 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -2424,21 +2424,22 @@ from int4_tbl t1, int4_tbl t2
   left join int4_tbl t3 on t3.f1 > 0
   left join int4_tbl t4 on t3.f1 > 1
 where t4.f1 is null;
-                      QUERY PLAN                       
--------------------------------------------------------
+                         QUERY PLAN                          
+-------------------------------------------------------------
  Nested Loop
    ->  Nested Loop Left Join
          Filter: (t4.f1 IS NULL)
          ->  Seq Scan on int4_tbl t2
          ->  Materialize
                ->  Nested Loop Left Join
-                     Join Filter: (t3.f1 > 1)
                      ->  Seq Scan on int4_tbl t3
                            Filter: (f1 > 0)
-                     ->  Materialize
-                           ->  Seq Scan on int4_tbl t4
+                     ->  Result
+                           One-Time Filter: (t3.f1 > 1)
+                           ->  Materialize
+                                 ->  Seq Scan on int4_tbl t4
    ->  Seq Scan on int4_tbl t1
-(12 rows)
+(13 rows)
 
 select t1.f1
 from int4_tbl t1, int4_tbl t2
@@ -2454,21 +2455,23 @@ select *
 from int4_tbl t1 left join int4_tbl t2 on true
   left join int4_tbl t3 on t2.f1 > 0
   left join int4_tbl t4 on t3.f1 > 0;
-                      QUERY PLAN                       
--------------------------------------------------------
+                         QUERY PLAN                          
+-------------------------------------------------------------
  Nested Loop Left Join
    ->  Seq Scan on int4_tbl t1
    ->  Materialize
          ->  Nested Loop Left Join
-               Join Filter: (t3.f1 > 0)
                ->  Nested Loop Left Join
-                     Join Filter: (t2.f1 > 0)
                      ->  Seq Scan on int4_tbl t2
+                     ->  Result
+                           One-Time Filter: (t2.f1 > 0)
+                           ->  Materialize
+                                 ->  Seq Scan on int4_tbl t3
+               ->  Result
+                     One-Time Filter: (t3.f1 > 0)
                      ->  Materialize
-                           ->  Seq Scan on int4_tbl t3
-               ->  Materialize
-                     ->  Seq Scan on int4_tbl t4
-(12 rows)
+                           ->  Seq Scan on int4_tbl t4
+(14 rows)
 
 explain (costs off)
 select * from onek t1
@@ -2525,7 +2528,6 @@ select * from int4_tbl t1
                    QUERY PLAN                    
 -------------------------------------------------
  Nested Loop Left Join
-   Join Filter: (t2.f1 = t3.f1)
    ->  Nested Loop Left Join
          ->  Nested Loop Left Join
                ->  Seq Scan on int4_tbl t1
@@ -2533,9 +2535,11 @@ select * from int4_tbl t1
                      ->  Seq Scan on int4_tbl t2
          ->  Materialize
                ->  Seq Scan on int4_tbl t3
-   ->  Materialize
-         ->  Seq Scan on int4_tbl t4
-(11 rows)
+   ->  Result
+         One-Time Filter: (t2.f1 = t3.f1)
+         ->  Materialize
+               ->  Seq Scan on int4_tbl t4
+(12 rows)
 
 explain (costs off)
 select * from int4_tbl t1
@@ -2563,23 +2567,25 @@ select * from int4_tbl t1
   left join (int4_tbl t2 left join int4_tbl t3 on t2.f1 > 0) on t2.f1 > 1
   left join int4_tbl t4 on t2.f1 > 2 and t3.f1 > 3
 where t1.f1 = coalesce(t2.f1, 1);
-                     QUERY PLAN                     
-----------------------------------------------------
+                       QUERY PLAN                       
+--------------------------------------------------------
  Nested Loop Left Join
-   Join Filter: ((t2.f1 > 2) AND (t3.f1 > 3))
    ->  Nested Loop Left Join
-         Join Filter: (t2.f1 > 0)
          ->  Nested Loop Left Join
                Filter: (t1.f1 = COALESCE(t2.f1, 1))
                ->  Seq Scan on int4_tbl t1
                ->  Materialize
                      ->  Seq Scan on int4_tbl t2
                            Filter: (f1 > 1)
+         ->  Result
+               One-Time Filter: (t2.f1 > 0)
+               ->  Materialize
+                     ->  Seq Scan on int4_tbl t3
+   ->  Result
+         One-Time Filter: ((t2.f1 > 2) AND (t3.f1 > 3))
          ->  Materialize
-               ->  Seq Scan on int4_tbl t3
-   ->  Materialize
-         ->  Seq Scan on int4_tbl t4
-(14 rows)
+               ->  Seq Scan on int4_tbl t4
+(16 rows)
 
 explain (costs off)
 select * from int4_tbl t1
@@ -2588,22 +2594,24 @@ select * from int4_tbl t1
                 where t3.f1 is null) s
              left join tenk1 t4 on s.f1 > 1)
     on s.f1 = t1.f1;
-                   QUERY PLAN                    
--------------------------------------------------
+                      QUERY PLAN                       
+-------------------------------------------------------
  Hash Right Join
    Hash Cond: (t2.f1 = t1.f1)
    ->  Nested Loop Left Join
-         Join Filter: (t2.f1 > 1)
          ->  Nested Loop Left Join
-               Join Filter: (t2.f1 > 0)
                Filter: (t3.f1 IS NULL)
                ->  Seq Scan on int4_tbl t2
-               ->  Materialize
-                     ->  Seq Scan on int4_tbl t3
-         ->  Seq Scan on tenk1 t4
+               ->  Result
+                     One-Time Filter: (t2.f1 > 0)
+                     ->  Materialize
+                           ->  Seq Scan on int4_tbl t3
+         ->  Result
+               One-Time Filter: (t2.f1 > 1)
+               ->  Seq Scan on tenk1 t4
    ->  Hash
          ->  Seq Scan on int4_tbl t1
-(13 rows)
+(15 rows)
 
 explain (costs off)
 select * from int4_tbl t1
@@ -2615,20 +2623,22 @@ select * from int4_tbl t1
                            QUERY PLAN                            
 -----------------------------------------------------------------
  Nested Loop Left Join
-   Join Filter: (t2.f1 > 1)
    ->  Hash Right Join
          Hash Cond: (t2.f1 = t1.f1)
          ->  Nested Loop Left Join
-               Join Filter: (t2.f1 > 0)
                Filter: (t2.f1 <> COALESCE(t3.f1, '-1'::integer))
                ->  Seq Scan on int4_tbl t2
-               ->  Materialize
-                     ->  Seq Scan on int4_tbl t3
+               ->  Result
+                     One-Time Filter: (t2.f1 > 0)
+                     ->  Materialize
+                           ->  Seq Scan on int4_tbl t3
          ->  Hash
                ->  Seq Scan on int4_tbl t1
-   ->  Materialize
-         ->  Seq Scan on tenk1 t4
-(14 rows)
+   ->  Result
+         One-Time Filter: (t2.f1 > 1)
+         ->  Materialize
+               ->  Seq Scan on tenk1 t4
+(16 rows)
 
 explain (costs off)
 select * from onek t1
@@ -2680,17 +2690,18 @@ select * from int8_tbl t1
     left join lateral
       (select * from int8_tbl t3 where t3.q1 = t2.q1 offset 0) s
       on t2.q1 = 1;
-                QUERY PLAN                 
--------------------------------------------
+                    QUERY PLAN                    
+--------------------------------------------------
  Nested Loop Left Join
    ->  Seq Scan on int8_tbl t1
    ->  Materialize
          ->  Nested Loop Left Join
-               Join Filter: (t2.q1 = 1)
                ->  Seq Scan on int8_tbl t2
-               ->  Seq Scan on int8_tbl t3
-                     Filter: (q1 = t2.q1)
-(8 rows)
+               ->  Result
+                     One-Time Filter: (t2.q1 = 1)
+                     ->  Seq Scan on int8_tbl t3
+                           Filter: (q1 = t2.q1)
+(9 rows)
 
 explain (costs off)
 select * from int8_tbl t1
@@ -2698,16 +2709,17 @@ select * from int8_tbl t1
     left join lateral
       (select * from generate_series(t2.q1, 100)) s
       on t2.q1 = 1;
-                     QUERY PLAN                     
-----------------------------------------------------
+                        QUERY PLAN                        
+----------------------------------------------------------
  Nested Loop Left Join
    ->  Seq Scan on int8_tbl t1
    ->  Materialize
          ->  Nested Loop Left Join
-               Join Filter: (t2.q1 = 1)
                ->  Seq Scan on int8_tbl t2
-               ->  Function Scan on generate_series
-(7 rows)
+               ->  Result
+                     One-Time Filter: (t2.q1 = 1)
+                     ->  Function Scan on generate_series
+(8 rows)
 
 explain (costs off)
 select * from int8_tbl t1
@@ -2715,16 +2727,17 @@ select * from int8_tbl t1
     left join lateral
       (select t2.q1 from int8_tbl t3) s
       on t2.q1 = 1;
-                QUERY PLAN                 
--------------------------------------------
+                    QUERY PLAN                    
+--------------------------------------------------
  Nested Loop Left Join
    ->  Seq Scan on int8_tbl t1
    ->  Materialize
          ->  Nested Loop Left Join
-               Join Filter: (t2.q1 = 1)
                ->  Seq Scan on int8_tbl t2
-               ->  Seq Scan on int8_tbl t3
-(7 rows)
+               ->  Result
+                     One-Time Filter: (t2.q1 = 1)
+                     ->  Seq Scan on int8_tbl t3
+(8 rows)
 
 explain (costs off)
 select * from onek t1
@@ -2732,20 +2745,21 @@ select * from onek t1
     left join lateral
       (select * from onek t3 where t3.two = t2.two offset 0) s
       on t2.unique1 = 1;
-                    QUERY PLAN                    
---------------------------------------------------
+                       QUERY PLAN                       
+--------------------------------------------------------
  Nested Loop Left Join
    ->  Seq Scan on onek t1
    ->  Materialize
          ->  Nested Loop Left Join
-               Join Filter: (t2.unique1 = 1)
                ->  Seq Scan on onek t2
-               ->  Memoize
-                     Cache Key: t2.two
-                     Cache Mode: binary
-                     ->  Seq Scan on onek t3
-                           Filter: (two = t2.two)
-(11 rows)
+               ->  Result
+                     One-Time Filter: (t2.unique1 = 1)
+                     ->  Memoize
+                           Cache Key: t2.two
+                           Cache Mode: binary
+                           ->  Seq Scan on onek t3
+                                 Filter: (two = t2.two)
+(12 rows)
 
 --
 -- check a case where we formerly got confused by conflicting sort orders
@@ -5404,11 +5418,12 @@ select unique1, x from tenk1 left join 
f_immutable_int4(1) x on unique1 = x;
                      QUERY PLAN                     
 ----------------------------------------------------
  Nested Loop Left Join
-   Join Filter: (tenk1.unique1 = 1)
    ->  Index Only Scan using tenk1_unique1 on tenk1
-   ->  Materialize
-         ->  Result
-(5 rows)
+   ->  Result
+         One-Time Filter: (tenk1.unique1 = 1)
+         ->  Materialize
+               ->  Result
+(6 rows)
 
 explain (costs off)
 select unique1, x from tenk1 right join f_immutable_int4(1) x on unique1 = x;
@@ -5455,17 +5470,17 @@ from nt3 as nt3
     ) as ss2
     on ss2.id = nt3.nt2_id
 where nt3.id = 1 and ss2.b3;
-                  QUERY PLAN                  
-----------------------------------------------
+                   QUERY PLAN                    
+-------------------------------------------------
  Nested Loop Left Join
    Filter: ((nt2.b1 OR ((0) = 42)))
    ->  Index Scan using nt3_pkey on nt3
          Index Cond: (id = 1)
    ->  Nested Loop Left Join
-         Join Filter: (0 = nt2.nt1_id)
          ->  Index Scan using nt2_pkey on nt2
                Index Cond: (id = nt3.nt2_id)
          ->  Result
+               One-Time Filter: (0 = nt2.nt1_id)
 (9 rows)
 
 drop function f_immutable_int4(int);
@@ -5705,7 +5720,6 @@ select count(*) from
 -------------------------------------------------------------------------
  Aggregate
    ->  Nested Loop Left Join
-         Join Filter: (a.unique2 = b.unique1)
          ->  Nested Loop
                ->  Nested Loop
                      ->  Seq Scan on int4_tbl
@@ -5715,9 +5729,11 @@ select count(*) from
                                  Index Cond: (thousand = int4_tbl.f1)
                ->  Index Scan using tenk1_unique1 on tenk1 a
                      Index Cond: (unique1 = b.unique2)
-         ->  Index Only Scan using tenk1_thous_tenthous on tenk1 c
-               Index Cond: (thousand = a.thousand)
-(14 rows)
+         ->  Result
+               One-Time Filter: (a.unique2 = b.unique1)
+               ->  Index Only Scan using tenk1_thous_tenthous on tenk1 c
+                     Index Cond: (thousand = a.thousand)
+(15 rows)
 
 select count(*) from
   tenk1 a join tenk1 b on a.unique1 = b.unique2
@@ -5742,7 +5758,6 @@ select b.unique1 from
    ->  Nested Loop Left Join
          ->  Seq Scan on int4_tbl i2
          ->  Nested Loop Left Join
-               Join Filter: (b.unique1 = 42)
                ->  Nested Loop
                      ->  Nested Loop
                            ->  Seq Scan on int4_tbl i1
@@ -5750,9 +5765,11 @@ select b.unique1 from
                                  Index Cond: ((thousand = i1.f1) AND (tenthous 
= i2.f1))
                      ->  Index Scan using tenk1_unique1 on tenk1 a
                            Index Cond: (unique1 = b.unique2)
-               ->  Index Only Scan using tenk1_thous_tenthous on tenk1 c
-                     Index Cond: (thousand = a.thousand)
-(15 rows)
+               ->  Result
+                     One-Time Filter: (b.unique1 = 42)
+                     ->  Index Only Scan using tenk1_thous_tenthous on tenk1 c
+                           Index Cond: (thousand = a.thousand)
+(16 rows)
 
 select b.unique1 from
   tenk1 a join tenk1 b on a.unique1 = b.unique2
@@ -6051,39 +6068,41 @@ select t1.* from
   on (t1.f1 = b1.d1)
   left join int4_tbl i4
   on (i8.q2 = i4.f1);
-                              QUERY PLAN                              
-----------------------------------------------------------------------
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
  Hash Left Join
    Output: t1.f1
    Hash Cond: (i8.q2 = i4.f1)
    ->  Nested Loop Left Join
          Output: t1.f1, i8.q2
-         Join Filter: (t1.f1 = '***'::text)
          ->  Seq Scan on public.text_tbl t1
                Output: t1.f1
-         ->  Materialize
+         ->  Result
                Output: i8.q2
-               ->  Hash Right Join
+               One-Time Filter: (t1.f1 = '***'::text)
+               ->  Materialize
                      Output: i8.q2
-                     Hash Cond: ((NULL::integer) = i8b1.q2)
-                     ->  Hash Join
-                           Output: i8.q2, (NULL::integer)
-                           Hash Cond: (i8.q1 = i8b2.q1)
-                           ->  Seq Scan on public.int8_tbl i8
-                                 Output: i8.q1, i8.q2
+                     ->  Hash Right Join
+                           Output: i8.q2
+                           Hash Cond: ((NULL::integer) = i8b1.q2)
+                           ->  Hash Join
+                                 Output: i8.q2, (NULL::integer)
+                                 Hash Cond: (i8.q1 = i8b2.q1)
+                                 ->  Seq Scan on public.int8_tbl i8
+                                       Output: i8.q1, i8.q2
+                                 ->  Hash
+                                       Output: i8b2.q1, (NULL::integer)
+                                       ->  Seq Scan on public.int8_tbl i8b2
+                                             Output: i8b2.q1, NULL::integer
                            ->  Hash
-                                 Output: i8b2.q1, (NULL::integer)
-                                 ->  Seq Scan on public.int8_tbl i8b2
-                                       Output: i8b2.q1, NULL::integer
-                     ->  Hash
-                           Output: i8b1.q2
-                           ->  Seq Scan on public.int8_tbl i8b1
                                  Output: i8b1.q2
+                                 ->  Seq Scan on public.int8_tbl i8b1
+                                       Output: i8b1.q2
    ->  Hash
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(30 rows)
+(32 rows)
 
 select t1.* from
   text_tbl t1
@@ -6112,43 +6131,45 @@ select t1.* from
   on (t1.f1 = b1.d1)
   left join int4_tbl i4
   on (i8.q2 = i4.f1);
-                                 QUERY PLAN                                 
-----------------------------------------------------------------------------
+                                    QUERY PLAN                                 
   
+----------------------------------------------------------------------------------
  Hash Left Join
    Output: t1.f1
    Hash Cond: (i8.q2 = i4.f1)
    ->  Nested Loop Left Join
          Output: t1.f1, i8.q2
-         Join Filter: (t1.f1 = '***'::text)
          ->  Seq Scan on public.text_tbl t1
                Output: t1.f1
-         ->  Materialize
+         ->  Result
                Output: i8.q2
-               ->  Hash Right Join
+               One-Time Filter: (t1.f1 = '***'::text)
+               ->  Materialize
                      Output: i8.q2
-                     Hash Cond: ((NULL::integer) = i8b1.q2)
                      ->  Hash Right Join
-                           Output: i8.q2, (NULL::integer)
-                           Hash Cond: (i8b2.q1 = i8.q1)
-                           ->  Nested Loop
-                                 Output: i8b2.q1, NULL::integer
-                                 ->  Seq Scan on public.int8_tbl i8b2
-                                       Output: i8b2.q1, i8b2.q2
-                                 ->  Materialize
-                                       ->  Seq Scan on public.int4_tbl i4b2
-                           ->  Hash
-                                 Output: i8.q1, i8.q2
-                                 ->  Seq Scan on public.int8_tbl i8
+                           Output: i8.q2
+                           Hash Cond: ((NULL::integer) = i8b1.q2)
+                           ->  Hash Right Join
+                                 Output: i8.q2, (NULL::integer)
+                                 Hash Cond: (i8b2.q1 = i8.q1)
+                                 ->  Nested Loop
+                                       Output: i8b2.q1, NULL::integer
+                                       ->  Seq Scan on public.int8_tbl i8b2
+                                             Output: i8b2.q1, i8b2.q2
+                                       ->  Materialize
+                                             ->  Seq Scan on public.int4_tbl 
i4b2
+                                 ->  Hash
                                        Output: i8.q1, i8.q2
-                     ->  Hash
-                           Output: i8b1.q2
-                           ->  Seq Scan on public.int8_tbl i8b1
+                                       ->  Seq Scan on public.int8_tbl i8
+                                             Output: i8.q1, i8.q2
+                           ->  Hash
                                  Output: i8b1.q2
+                                 ->  Seq Scan on public.int8_tbl i8b1
+                                       Output: i8b1.q2
    ->  Hash
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(34 rows)
+(36 rows)
 
 select t1.* from
   text_tbl t1
@@ -6178,46 +6199,48 @@ select t1.* from
   on (t1.f1 = b1.d1)
   left join int4_tbl i4
   on (i8.q2 = i4.f1);
-                                 QUERY PLAN                                 
-----------------------------------------------------------------------------
+                                    QUERY PLAN                                 
   
+----------------------------------------------------------------------------------
  Hash Left Join
    Output: t1.f1
    Hash Cond: (i8.q2 = i4.f1)
    ->  Nested Loop Left Join
          Output: t1.f1, i8.q2
-         Join Filter: (t1.f1 = '***'::text)
          ->  Seq Scan on public.text_tbl t1
                Output: t1.f1
-         ->  Materialize
+         ->  Result
                Output: i8.q2
-               ->  Hash Right Join
+               One-Time Filter: (t1.f1 = '***'::text)
+               ->  Materialize
                      Output: i8.q2
-                     Hash Cond: ((NULL::integer) = i8b1.q2)
                      ->  Hash Right Join
-                           Output: i8.q2, (NULL::integer)
-                           Hash Cond: (i8b2.q1 = i8.q1)
-                           ->  Hash Join
-                                 Output: i8b2.q1, NULL::integer
-                                 Hash Cond: (i8b2.q1 = i4b2.f1)
-                                 ->  Seq Scan on public.int8_tbl i8b2
-                                       Output: i8b2.q1, i8b2.q2
-                                 ->  Hash
-                                       Output: i4b2.f1
-                                       ->  Seq Scan on public.int4_tbl i4b2
+                           Output: i8.q2
+                           Hash Cond: ((NULL::integer) = i8b1.q2)
+                           ->  Hash Right Join
+                                 Output: i8.q2, (NULL::integer)
+                                 Hash Cond: (i8b2.q1 = i8.q1)
+                                 ->  Hash Join
+                                       Output: i8b2.q1, NULL::integer
+                                       Hash Cond: (i8b2.q1 = i4b2.f1)
+                                       ->  Seq Scan on public.int8_tbl i8b2
+                                             Output: i8b2.q1, i8b2.q2
+                                       ->  Hash
                                              Output: i4b2.f1
-                           ->  Hash
-                                 Output: i8.q1, i8.q2
-                                 ->  Seq Scan on public.int8_tbl i8
+                                             ->  Seq Scan on public.int4_tbl 
i4b2
+                                                   Output: i4b2.f1
+                                 ->  Hash
                                        Output: i8.q1, i8.q2
-                     ->  Hash
-                           Output: i8b1.q2
-                           ->  Seq Scan on public.int8_tbl i8b1
+                                       ->  Seq Scan on public.int8_tbl i8
+                                             Output: i8.q1, i8.q2
+                           ->  Hash
                                  Output: i8b1.q2
+                                 ->  Seq Scan on public.int8_tbl i8b1
+                                       Output: i8b1.q2
    ->  Hash
          Output: i4.f1
          ->  Seq Scan on public.int4_tbl i4
                Output: i4.f1
-(37 rows)
+(39 rows)
 
 select t1.* from
   text_tbl t1
@@ -6311,8 +6334,8 @@ select 1 from
   join int4_tbl i42 on ss1.a is null or i8.q1 <> i8.q2
   right join (select 2 as b) ss2
   on ss2.b < i4.f1;
-                        QUERY PLAN                         
------------------------------------------------------------
+                           QUERY PLAN                           
+----------------------------------------------------------------
  Nested Loop Left Join
    ->  Result
    ->  Nested Loop
@@ -6320,16 +6343,17 @@ select 1 from
                Join Filter: NULL::boolean
                Filter: (((1) IS NULL) OR (i8.q1 <> i8.q2))
                ->  Nested Loop Left Join
-                     Join Filter: (i4.f1 IS NOT NULL)
                      ->  Seq Scan on int4_tbl i4
                            Filter: (2 < f1)
-                     ->  Materialize
-                           ->  Seq Scan on int8_tbl i8
+                     ->  Result
+                           One-Time Filter: (i4.f1 IS NOT NULL)
+                           ->  Materialize
+                                 ->  Seq Scan on int8_tbl i8
                ->  Result
                      One-Time Filter: false
          ->  Materialize
                ->  Seq Scan on int4_tbl i42
-(16 rows)
+(17 rows)
 
 --
 -- test for appropriate join order in the presence of lateral references
@@ -6990,7 +7014,6 @@ from int8_tbl t1
                    QUERY PLAN                    
 -------------------------------------------------
  Nested Loop Left Join
-   Join Filter: (t2.q2 < t3.unique2)
    ->  Nested Loop Left Join
          Join Filter: (t2.q1 > t3.unique1)
          ->  Hash Left Join
@@ -7000,9 +7023,11 @@ from int8_tbl t1
                      ->  Seq Scan on int8_tbl t2
          ->  Materialize
                ->  Seq Scan on onek t3
-   ->  Materialize
-         ->  Seq Scan on onek t4
-(13 rows)
+   ->  Result
+         One-Time Filter: (t2.q2 < t3.unique2)
+         ->  Materialize
+               ->  Seq Scan on onek t4
+(14 rows)
 
 -- bug #19460: we need to clean up RestrictInfos more than we had been doing
 explain (costs off)
@@ -7287,11 +7312,12 @@ select 1 from a t1
    ->  Seq Scan on a t1
    ->  Materialize
          ->  Nested Loop Left Join
-               Join Filter: (t2.id = 1)
                ->  Index Only Scan using a_pkey on a t2
                      Index Cond: (id = 1)
-               ->  Seq Scan on a t3
-(8 rows)
+               ->  Result
+                     One-Time Filter: (t2.id = 1)
+                     ->  Seq Scan on a t3
+(9 rows)
 
 -- check join removal works when uniqueness of the join condition is enforced
 -- by a UNION
@@ -8871,14 +8897,15 @@ explain (costs off)
 select * from emp1 t1
    inner join emp1 t2 on t1.id = t2.id
     left join emp1 t3 on t1.id > 1 and t1.id < 2;
-                  QUERY PLAN                  
-----------------------------------------------
+                       QUERY PLAN                       
+--------------------------------------------------------
  Nested Loop Left Join
-   Join Filter: ((t2.id > 1) AND (t2.id < 2))
    ->  Seq Scan on emp1 t2
-   ->  Materialize
-         ->  Seq Scan on emp1 t3
-(5 rows)
+   ->  Result
+         One-Time Filter: ((t2.id > 1) AND (t2.id < 2))
+         ->  Materialize
+               ->  Seq Scan on emp1 t3
+(6 rows)
 
 -- Check that SJE doesn't replace the target relation
 EXPLAIN (COSTS OFF)
@@ -8899,14 +8926,16 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM emp1 t1
    INNER JOIN emp1 t2 ON t1.id = t2.id
     LEFT JOIN emp1 t3 ON t1.code = 1 AND (t2.code = t3.code OR t2.code = 1);
-                                QUERY PLAN                                 
----------------------------------------------------------------------------
+                      QUERY PLAN                       
+-------------------------------------------------------
  Nested Loop Left Join
-   Join Filter: ((t2.code = 1) AND ((t2.code = t3.code) OR (t2.code = 1)))
+   Join Filter: ((t2.code = t3.code) OR (t2.code = 1))
    ->  Seq Scan on emp1 t2
-   ->  Materialize
-         ->  Seq Scan on emp1 t3
-(5 rows)
+   ->  Result
+         One-Time Filter: (t2.code = 1)
+         ->  Materialize
+               ->  Seq Scan on emp1 t3
+(7 rows)
 
     INSERT INTO emp1 VALUES (1, 1), (2, 1);
 WITH t1 AS (SELECT * FROM emp1)
@@ -9081,11 +9110,12 @@ SELECT 1 AS c1 FROM sl sl1 LEFT JOIN (sl AS sl2 NATURAL 
JOIN sl AS sl3)
  Nested Loop Left Join
    ->  Seq Scan on sl sl1
    ->  Nested Loop Left Join
-         Join Filter: sl3.bool_col
          ->  Seq Scan on sl sl3
                Filter: ((a IS NOT NULL) AND (b IS NOT NULL) AND (c IS NOT 
NULL) AND (bool_col IS NOT NULL) AND bool_col)
-         ->  Seq Scan on sl sl4
-(7 rows)
+         ->  Result
+               One-Time Filter: sl3.bool_col
+               ->  Seq Scan on sl sl4
+(8 rows)
 
 -- Check that quals of a jointree node that becomes empty when the self-join
 -- is removed are not lost, and that they don't migrate above an outer join
@@ -11108,3 +11138,43 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
  19000
 (1 row)
 
+-- Outer-only ON-clauses become a gating Result on the inner side; for a
+-- parameter-independent inner, NestLoop.keep_inner_rewind retains REWIND so
+-- a Materialize below the gate replays its buffer across rescans rather than
+-- rebuilding it for every outer tuple that passes the gate.
+create function platform_independent_explain(query text) returns setof text
+language plpgsql as
+$$
+declare
+    ln text;
+begin
+    for ln in
+        execute format('explain (analyze, costs off, summary off, timing off, 
buffers off) %s', query)
+    loop
+        ln := regexp_replace(ln, 'Maximum Storage: \d+', 'Maximum Storage: N');
+        return next ln;
+    end loop;
+end;
+$$;
+set enable_hashjoin = off;
+set enable_mergejoin = off;
+select platform_independent_explain('
+    select count(*) from onek t1 left join int4_tbl t2
+      on (t1.unique1 = t2.f1 and t1.hundred in (1, 2))');
+                        platform_independent_explain                        
+----------------------------------------------------------------------------
+ Aggregate (actual rows=1.00 loops=1)
+   ->  Nested Loop Left Join (actual rows=1000.00 loops=1)
+         Join Filter: (t1.unique1 = t2.f1)
+         Rows Removed by Join Filter: 1000
+         ->  Seq Scan on onek t1 (actual rows=1000.00 loops=1)
+         ->  Result (actual rows=1.00 loops=1000)
+               One-Time Filter: (t1.hundred = ANY ('{1,2}'::integer[]))
+               ->  Materialize (actual rows=5.00 loops=200)
+                     Storage: Memory  Maximum Storage: NkB
+                     ->  Seq Scan on int4_tbl t2 (actual rows=5.00 loops=1)
+(10 rows)
+
+reset enable_hashjoin;
+reset enable_mergejoin;
+drop function platform_independent_explain(text);
diff --git a/src/test/regress/expected/predicate.out 
b/src/test/regress/expected/predicate.out
index feae77cb840..357c1ad1e08 100644
--- a/src/test/regress/expected/predicate.out
+++ b/src/test/regress/expected/predicate.out
@@ -114,10 +114,9 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM pred_tab t1
     FULL JOIN pred_tab t2 ON t1.a = t2.a
     LEFT JOIN pred_tab t3 ON t2.a IS NOT NULL;
-                QUERY PLAN                 
--------------------------------------------
+                 QUERY PLAN                  
+---------------------------------------------
  Nested Loop Left Join
-   Join Filter: (t2.a IS NOT NULL)
    ->  Merge Full Join
          Merge Cond: (t1.a = t2.a)
          ->  Sort
@@ -126,9 +125,11 @@ SELECT * FROM pred_tab t1
          ->  Sort
                Sort Key: t2.a
                ->  Seq Scan on pred_tab t2
-   ->  Materialize
-         ->  Seq Scan on pred_tab t3
-(12 rows)
+   ->  Result
+         One-Time Filter: (t2.a IS NOT NULL)
+         ->  Materialize
+               ->  Seq Scan on pred_tab t3
+(13 rows)
 
 -- Ensure the IS_NULL qual is reduced to constant-FALSE, since a) it's on a NOT
 -- NULL column, and b) its Var is not nullable by any outer joins
@@ -151,18 +152,20 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM pred_tab t1
     LEFT JOIN pred_tab t2 ON t1.a = 1
     LEFT JOIN pred_tab t3 ON t2.a IS NULL;
-                QUERY PLAN                 
--------------------------------------------
+                   QUERY PLAN                    
+-------------------------------------------------
  Nested Loop Left Join
-   Join Filter: (t2.a IS NULL)
    ->  Nested Loop Left Join
-         Join Filter: (t1.a = 1)
          ->  Seq Scan on pred_tab t1
+         ->  Result
+               One-Time Filter: (t1.a = 1)
+               ->  Materialize
+                     ->  Seq Scan on pred_tab t2
+   ->  Result
+         One-Time Filter: (t2.a IS NULL)
          ->  Materialize
-               ->  Seq Scan on pred_tab t2
-   ->  Materialize
-         ->  Seq Scan on pred_tab t3
-(9 rows)
+               ->  Seq Scan on pred_tab t3
+(11 rows)
 
 --
 -- Tests for OR clauses in join clauses
@@ -185,10 +188,9 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM pred_tab t1
     FULL JOIN pred_tab t2 ON t1.a = t2.a
     LEFT JOIN pred_tab t3 ON t2.a IS NOT NULL OR t2.b = 1;
-                    QUERY PLAN                     
----------------------------------------------------
+                         QUERY PLAN                          
+-------------------------------------------------------------
  Nested Loop Left Join
-   Join Filter: ((t2.a IS NOT NULL) OR (t2.b = 1))
    ->  Merge Full Join
          Merge Cond: (t1.a = t2.a)
          ->  Sort
@@ -197,9 +199,11 @@ SELECT * FROM pred_tab t1
          ->  Sort
                Sort Key: t2.a
                ->  Seq Scan on pred_tab t2
-   ->  Materialize
-         ->  Seq Scan on pred_tab t3
-(12 rows)
+   ->  Result
+         One-Time Filter: ((t2.a IS NOT NULL) OR (t2.b = 1))
+         ->  Materialize
+               ->  Seq Scan on pred_tab t3
+(13 rows)
 
 -- Ensure the OR clause is reduced to constant-FALSE when all OR branches are
 -- provably false
@@ -222,18 +226,20 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM pred_tab t1
     LEFT JOIN pred_tab t2 ON t1.a = 1
     LEFT JOIN pred_tab t3 ON t2.a IS NULL OR t2.c IS NULL;
-                    QUERY PLAN                     
----------------------------------------------------
+                         QUERY PLAN                          
+-------------------------------------------------------------
  Nested Loop Left Join
-   Join Filter: ((t2.a IS NULL) OR (t2.c IS NULL))
    ->  Nested Loop Left Join
-         Join Filter: (t1.a = 1)
          ->  Seq Scan on pred_tab t1
+         ->  Result
+               One-Time Filter: (t1.a = 1)
+               ->  Materialize
+                     ->  Seq Scan on pred_tab t2
+   ->  Result
+         One-Time Filter: ((t2.a IS NULL) OR (t2.c IS NULL))
          ->  Materialize
-               ->  Seq Scan on pred_tab t2
-   ->  Materialize
-         ->  Seq Scan on pred_tab t3
-(9 rows)
+               ->  Seq Scan on pred_tab t3
+(11 rows)
 
 --
 -- Tests for NullTest reduction in EXISTS sublink
@@ -244,26 +250,27 @@ SELECT * FROM pred_tab t1
     LEFT JOIN pred_tab t2 ON EXISTS
         (SELECT 1 FROM pred_tab t3, pred_tab t4, pred_tab t5, pred_tab t6
          WHERE t1.a = t3.a AND t6.a IS NOT NULL);
-                       QUERY PLAN                        
----------------------------------------------------------
+                          QUERY PLAN                           
+---------------------------------------------------------------
  Nested Loop Left Join
-   Join Filter: EXISTS(SubPlan exists_1)
    ->  Seq Scan on pred_tab t1
-   ->  Materialize
-         ->  Seq Scan on pred_tab t2
-   SubPlan exists_1
-     ->  Nested Loop
+   ->  Result
+         One-Time Filter: EXISTS(SubPlan exists_1)
+         ->  Materialize
+               ->  Seq Scan on pred_tab t2
+         SubPlan exists_1
            ->  Nested Loop
                  ->  Nested Loop
-                       ->  Seq Scan on pred_tab t4
+                       ->  Nested Loop
+                             ->  Seq Scan on pred_tab t4
+                             ->  Materialize
+                                   ->  Seq Scan on pred_tab t3
+                                         Filter: (t1.a = a)
                        ->  Materialize
-                             ->  Seq Scan on pred_tab t3
-                                   Filter: (t1.a = a)
+                             ->  Seq Scan on pred_tab t5
                  ->  Materialize
-                       ->  Seq Scan on pred_tab t5
-           ->  Materialize
-                 ->  Seq Scan on pred_tab t6
-(17 rows)
+                       ->  Seq Scan on pred_tab t6
+(18 rows)
 
 -- Ensure the IS_NULL qual is reduced to constant-FALSE
 EXPLAIN (COSTS OFF)
@@ -354,15 +361,16 @@ SELECT * FROM pred_tab t1
    ->  Seq Scan on pred_tab t1
    ->  Materialize
          ->  Nested Loop Left Join
-               Join Filter: (t3.b IS NOT NULL)
                ->  Nested Loop Left Join
                      Join Filter: (t2.a = t3.a)
                      ->  Seq Scan on pred_tab t2
                      ->  Materialize
                            ->  Seq Scan on pred_tab_notnull t3
-               ->  Materialize
-                     ->  Seq Scan on pred_tab t4
-(12 rows)
+               ->  Result
+                     One-Time Filter: (t3.b IS NOT NULL)
+                     ->  Materialize
+                           ->  Seq Scan on pred_tab t4
+(13 rows)
 
 SELECT * FROM pred_tab t1
     LEFT JOIN pred_tab t2 ON TRUE
@@ -384,21 +392,22 @@ SELECT * FROM pred_tab t1
     LEFT JOIN pred_tab t2 ON TRUE
     LEFT JOIN pred_tab_notnull t3 ON t2.a = t3.a
     LEFT JOIN pred_tab t4 ON t3.b IS NULL AND t3.a IS NOT NULL;
-                             QUERY PLAN                             
---------------------------------------------------------------------
+                                  QUERY PLAN                                  
+------------------------------------------------------------------------------
  Nested Loop Left Join
    ->  Seq Scan on pred_tab t1
    ->  Materialize
          ->  Nested Loop Left Join
-               Join Filter: ((t3.b IS NULL) AND (t3.a IS NOT NULL))
                ->  Nested Loop Left Join
                      Join Filter: (t2.a = t3.a)
                      ->  Seq Scan on pred_tab t2
                      ->  Materialize
                            ->  Seq Scan on pred_tab_notnull t3
-               ->  Materialize
-                     ->  Seq Scan on pred_tab t4
-(12 rows)
+               ->  Result
+                     One-Time Filter: ((t3.b IS NULL) AND (t3.a IS NOT NULL))
+                     ->  Materialize
+                           ->  Seq Scan on pred_tab t4
+(13 rows)
 
 SELECT * FROM pred_tab t1
     LEFT JOIN pred_tab t2 ON TRUE
diff --git a/src/test/regress/expected/subselect.out 
b/src/test/regress/expected/subselect.out
index ce0ff764417..20ffdf01982 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -3789,16 +3789,17 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM not_null_tab t1
 LEFT JOIN not_null_tab t2
 ON t1.id NOT IN (SELECT id FROM not_null_tab);
-                            QUERY PLAN                            
-------------------------------------------------------------------
+                                 QUERY PLAN                                 
+----------------------------------------------------------------------------
  Nested Loop Left Join
-   Join Filter: (NOT (ANY (t1.id = (hashed SubPlan any_1).col1)))
    ->  Seq Scan on not_null_tab t1
-   ->  Materialize
-         ->  Seq Scan on not_null_tab t2
-   SubPlan any_1
-     ->  Seq Scan on not_null_tab
-(7 rows)
+   ->  Result
+         One-Time Filter: (NOT (ANY (t1.id = (hashed SubPlan any_1).col1)))
+         ->  Materialize
+               ->  Seq Scan on not_null_tab t2
+         SubPlan any_1
+           ->  Seq Scan on not_null_tab
+(8 rows)
 
 -- ANTI JOIN: outer side is defined NOT NULL and is not nulled by outer join,
 -- inner side is defined NOT NULL
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index 9533af8656e..2194d5df64e 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -4271,3 +4271,30 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
     ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand);
 SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
     ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand);
+
+-- Outer-only ON-clauses become a gating Result on the inner side; for a
+-- parameter-independent inner, NestLoop.keep_inner_rewind retains REWIND so
+-- a Materialize below the gate replays its buffer across rescans rather than
+-- rebuilding it for every outer tuple that passes the gate.
+create function platform_independent_explain(query text) returns setof text
+language plpgsql as
+$$
+declare
+    ln text;
+begin
+    for ln in
+        execute format('explain (analyze, costs off, summary off, timing off, 
buffers off) %s', query)
+    loop
+        ln := regexp_replace(ln, 'Maximum Storage: \d+', 'Maximum Storage: N');
+        return next ln;
+    end loop;
+end;
+$$;
+set enable_hashjoin = off;
+set enable_mergejoin = off;
+select platform_independent_explain('
+    select count(*) from onek t1 left join int4_tbl t2
+      on (t1.unique1 = t2.f1 and t1.hundred in (1, 2))');
+reset enable_hashjoin;
+reset enable_mergejoin;
+drop function platform_independent_explain(text);
-- 
2.55.0

From e0d0dc940ad78d3a92810cbaa83abacd9bc3bb50 Mon Sep 17 00:00:00 2001
From: "Andrei V. Lepikhov" <[email protected]>
Date: Fri, 3 Jul 2026 11:58:04 +0200
Subject: [PATCH v2 2/2] pg_plan_advice: look through gating Results on the
 NestLoop inner side

The preceding commit introduced a new plan shape: the inner side of a
NestLoop may now be capped by a gating Result that evaluates outer-only
join clauses as a one-time filter, sitting above any Material or Memoize
node.

pgpa_decompose_join() was unaware of this.
Teach the NestLoop case to descend through a gating Result before the strategy
checks.  This restores the node ordering the rest of the function
expects and classifies the strategy as MATERIALIZE/MEMOIZE again.  A
childless Result is deliberately not descended: it is itself the scan
and carries its own RTIs.
---
 contrib/pg_plan_advice/pgpa_join.c | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/contrib/pg_plan_advice/pgpa_join.c 
b/contrib/pg_plan_advice/pgpa_join.c
index e69c25551a0..f3f2ad62f7c 100644
--- a/contrib/pg_plan_advice/pgpa_join.c
+++ b/contrib/pg_plan_advice/pgpa_join.c
@@ -388,6 +388,16 @@ pgpa_decompose_join(pgpa_plan_walker_context *walker, Plan 
*plan,
 
                case T_NestLoop:
 
+                       /*
+                        * create_nestloop_plan() may have capped the inner 
side with a
+                        * gating Result that evaluates outer-only join clauses 
as a
+                        * one-time filter.  It sits above any Material or 
Memoize node,
+                        * so look through it before determining the join 
strategy.
+                        */
+                       if (elidedinner == NULL && 
is_result_node_with_child(innerplan) &&
+                               ((Result *) innerplan)->resconstantqual != NULL)
+                               elidedinner = pgpa_descend_node(pstmt, 
&innerplan);
+
                        /*
                         * The planner may have chosen to place a Material or 
Memoize node
                         * on the inner side of the NestLoop; if this is 
present, we
-- 
2.55.0

Reply via email to