commit cfbd131 (HEAD -> fdw-arraycoerce-tests)
Author:     Noah Misch <noah@leadboat.com>
AuthorDate: Fri Jul 10 23:15:15 2026 +0000
Commit:     Noah Misch <noah@leadboat.com>
CommitDate: Sat Jul 11 02:17:46 2026 +0000

    Add tests exposing postgres_fdw ArrayCoerceExpr pushdown defects.
    
    Commit 62c3b4c taught postgres_fdw to push down ArrayCoerceExpr, but
    foreign_expr_walker() never examines elemexpr, the expression carrying
    the per-element conversion semantics, and deparseArrayCoerceExpr()
    ships the conversion as a bare ::type cast, omitting it entirely for
    implicit-format casts.  The remote server therefore re-resolves the
    element conversion against its own catalogs and session state.  Record
    the current defective behavior:
    
    * An array cast whose element conversion uses a user-defined cast
      function ships without any shippability check, while the equivalent
      scalar cast correctly stays local.  A same-database loopback shares
      the local catalogs, so these tests can pin only the pushdown
      decision, not the wrong results and errors a real remote yields.
    
    * An implicit-format ArrayCoerceExpr disappears from the remote query
      entirely; against a stock remote server, the recorded query fails
      with "operator does not exist: text = integer".
    
    * Element conversions performed by CoerceViaIO reach I/O functions
      that are marked immutable but depend on GUCs; scalar CoerceViaIO is
      deliberately never shipped.  Since postgres_fdw forces
      extra_float_digits=3 on remote sessions, this one yields wrong query
      results even in the loopback setup, with no user-defined objects
      involved.
    
    The expected output records today's wrong behavior; comments mark the
    outputs that should change when the defects are fixed.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014RSuqAwty57pReZcdArvqb
---
 contrib/postgres_fdw/expected/postgres_fdw.out | 160 +++++++++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql      |  86 +++++++++++++
 2 files changed, 246 insertions(+)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 5ebae1c..79fb60d 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -1219,6 +1219,166 @@ EXECUTE s(ARRAY['1','2']);
 
 DEALLOCATE s;
 RESET plan_cache_mode;
+-- ===================================================================
+-- test defects in ArrayCoerceExpr pushdown
+-- ===================================================================
+-- foreign_expr_walker() never examines elemexpr, which carries the
+-- per-element conversion semantics of an ArrayCoerceExpr, and
+-- deparseArrayCoerceExpr() ships the conversion as a bare ::type cast,
+-- omitting it entirely for implicit casts.  The remote end therefore
+-- re-resolves the element conversion against its own catalogs and
+-- session state.  The following record the current defective behavior;
+-- outputs marked BUG are wrong and should change when this is fixed.
+CREATE TABLE acx_tbl1 (id int, ta text[]);
+INSERT INTO acx_tbl1 VALUES (1, '{12345}'), (2, '{999}');
+CREATE FOREIGN TABLE acx_ft1 (id int, ta text[])
+  SERVER loopback OPTIONS (table_name 'acx_tbl1');
+CREATE FUNCTION acx_text2int(text) RETURNS int
+  LANGUAGE plpgsql IMMUTABLE STRICT AS 'BEGIN RETURN length($1); END';
+CREATE CAST (text AS integer) WITH FUNCTION acx_text2int(text);
+-- BUG: pushed down although the element conversion calls a cast
+-- function that was never vetted for shippability; a real remote
+-- server would apply its own text-to-integer coercion instead of
+-- acx_text2int (the loopback "remote" shares our catalogs, hiding
+-- the wrong results/errors this causes)
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft1 WHERE ta::int[] = ARRAY[5];
+                                       QUERY PLAN                                        
+-----------------------------------------------------------------------------------------
+ Foreign Scan on public.acx_ft1
+   Output: id
+   Remote SQL: SELECT id FROM public.acx_tbl1 WHERE ((ta::integer[] = '{5}'::integer[]))
+(3 rows)
+
+SELECT id FROM acx_ft1 WHERE ta::int[] = ARRAY[5];
+ id 
+----
+  1
+(1 row)
+
+-- the same cast in scalar form is correctly evaluated locally
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft1 WHERE ta[1]::int = 5;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Foreign Scan on public.acx_ft1
+   Output: id
+   Filter: ((acx_ft1.ta[1])::integer = 5)
+   Remote SQL: SELECT id, ta FROM public.acx_tbl1
+(4 rows)
+
+SELECT id FROM acx_ft1 WHERE ta[1]::int = 5;
+ id 
+----
+  1
+(1 row)
+
+DROP CAST (text AS integer);
+-- BUG: an implicit-format ArrayCoerceExpr vanishes from the remote
+-- query, making the remote re-run operator resolution that can pick a
+-- different operator or fail; here the loopback "remote" resolves via
+-- the same user-created implicit cast, but a stock remote server fails
+-- with "operator does not exist: text = integer"
+CREATE TABLE acx_tbl2 (id int, t text, ia int[]);
+INSERT INTO acx_tbl2 VALUES (1, '5', '{5}'), (2, '999', '{10}');
+CREATE FOREIGN TABLE acx_ft2 (id int, t text, ia int[])
+  SERVER loopback OPTIONS (table_name 'acx_tbl2');
+CREATE CAST (integer AS text) WITH FUNCTION pg_catalog.to_hex(integer) AS IMPLICIT;
+-- with no parameter involved, the omitted conversion leaves no trace
+-- in the remote query at all
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft2 WHERE t = ANY (ia);
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Foreign Scan on public.acx_ft2
+   Output: id
+   Remote SQL: SELECT id FROM public.acx_tbl2 WHERE ((t = ANY (ia)))
+(3 rows)
+
+SELECT id FROM acx_ft2 WHERE t = ANY (ia);
+ id 
+----
+  1
+(1 row)
+
+-- in the parameterized form, the $1::integer[] below is only
+-- deparseParam's type label for the parameter (its pre-coercion type);
+-- the omitted conversion should have appended ::text[] after it
+SET plan_cache_mode = force_generic_plan;
+PREPARE acx_p(int[]) AS SELECT id FROM acx_ft2 WHERE t = ANY ($1);
+EXPLAIN (VERBOSE, COSTS OFF)
+EXECUTE acx_p('{5}');
+                                   QUERY PLAN                                   
+--------------------------------------------------------------------------------
+ Foreign Scan on public.acx_ft2
+   Output: id
+   Remote SQL: SELECT id FROM public.acx_tbl2 WHERE ((t = ANY ($1::integer[])))
+(3 rows)
+
+EXECUTE acx_p('{5}');
+ id 
+----
+  1
+(1 row)
+
+DEALLOCATE acx_p;
+RESET plan_cache_mode;
+DROP CAST (integer AS text);
+-- BUG: element conversions performed by CoerceViaIO are pushed down,
+-- reaching I/O functions that are marked immutable but depend on GUCs,
+-- while scalar CoerceViaIO is deliberately never shipped.  postgres_fdw
+-- forces extra_float_digits = 3 on remote sessions, so the pushed-down
+-- qual computes a different answer than local evaluation: wrong results
+-- with no user-defined objects involved.
+CREATE TABLE acx_tbl3 (id int, f8 float8[], txt text[]);
+INSERT INTO acx_tbl3 VALUES (1, '{0.30000000000000004}', '{0.3}');
+CREATE FOREIGN TABLE acx_ft3 (id int, f8 float8[], txt text[])
+  SERVER loopback OPTIONS (table_name 'acx_tbl3');
+SET extra_float_digits = 0;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft3 WHERE f8::text[] = txt;
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ Foreign Scan on public.acx_ft3
+   Output: id
+   Remote SQL: SELECT id FROM public.acx_tbl3 WHERE ((f8::text[] = txt))
+(3 rows)
+
+-- BUG: finds no rows
+SELECT id FROM acx_ft3 WHERE f8::text[] = txt;
+ id 
+----
+(0 rows)
+
+-- adding a volatile clause forces local evaluation, which finds the row
+SELECT id FROM acx_ft3 WHERE f8::text[] = txt OR random() < -1;
+ id 
+----
+  1
+(1 row)
+
+-- the same conversion in scalar form is correctly evaluated locally
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft3 WHERE f8[1]::text = txt[1];
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Foreign Scan on public.acx_ft3
+   Output: id
+   Filter: ((acx_ft3.f8[1])::text = acx_ft3.txt[1])
+   Remote SQL: SELECT id, f8, txt FROM public.acx_tbl3
+(4 rows)
+
+SELECT id FROM acx_ft3 WHERE f8[1]::text = txt[1];
+ id 
+----
+  1
+(1 row)
+
+RESET extra_float_digits;
+-- clean up
+DROP FOREIGN TABLE acx_ft1, acx_ft2, acx_ft3;
+DROP TABLE acx_tbl1, acx_tbl2, acx_tbl3;
+DROP FUNCTION acx_text2int(text);
 -- a regconfig constant referring to this text search configuration
 -- is initially unshippable
 CREATE TEXT SEARCH CONFIGURATION public.custom_search
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index e868da0..a06ec70 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -485,6 +485,92 @@ EXECUTE s(ARRAY['1','2']);
 DEALLOCATE s;
 RESET plan_cache_mode;
 
+-- ===================================================================
+-- test defects in ArrayCoerceExpr pushdown
+-- ===================================================================
+-- foreign_expr_walker() never examines elemexpr, which carries the
+-- per-element conversion semantics of an ArrayCoerceExpr, and
+-- deparseArrayCoerceExpr() ships the conversion as a bare ::type cast,
+-- omitting it entirely for implicit casts.  The remote end therefore
+-- re-resolves the element conversion against its own catalogs and
+-- session state.  The following record the current defective behavior;
+-- outputs marked BUG are wrong and should change when this is fixed.
+CREATE TABLE acx_tbl1 (id int, ta text[]);
+INSERT INTO acx_tbl1 VALUES (1, '{12345}'), (2, '{999}');
+CREATE FOREIGN TABLE acx_ft1 (id int, ta text[])
+  SERVER loopback OPTIONS (table_name 'acx_tbl1');
+CREATE FUNCTION acx_text2int(text) RETURNS int
+  LANGUAGE plpgsql IMMUTABLE STRICT AS 'BEGIN RETURN length($1); END';
+CREATE CAST (text AS integer) WITH FUNCTION acx_text2int(text);
+-- BUG: pushed down although the element conversion calls a cast
+-- function that was never vetted for shippability; a real remote
+-- server would apply its own text-to-integer coercion instead of
+-- acx_text2int (the loopback "remote" shares our catalogs, hiding
+-- the wrong results/errors this causes)
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft1 WHERE ta::int[] = ARRAY[5];
+SELECT id FROM acx_ft1 WHERE ta::int[] = ARRAY[5];
+-- the same cast in scalar form is correctly evaluated locally
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft1 WHERE ta[1]::int = 5;
+SELECT id FROM acx_ft1 WHERE ta[1]::int = 5;
+DROP CAST (text AS integer);
+
+-- BUG: an implicit-format ArrayCoerceExpr vanishes from the remote
+-- query, making the remote re-run operator resolution that can pick a
+-- different operator or fail; here the loopback "remote" resolves via
+-- the same user-created implicit cast, but a stock remote server fails
+-- with "operator does not exist: text = integer"
+CREATE TABLE acx_tbl2 (id int, t text, ia int[]);
+INSERT INTO acx_tbl2 VALUES (1, '5', '{5}'), (2, '999', '{10}');
+CREATE FOREIGN TABLE acx_ft2 (id int, t text, ia int[])
+  SERVER loopback OPTIONS (table_name 'acx_tbl2');
+CREATE CAST (integer AS text) WITH FUNCTION pg_catalog.to_hex(integer) AS IMPLICIT;
+-- with no parameter involved, the omitted conversion leaves no trace
+-- in the remote query at all
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft2 WHERE t = ANY (ia);
+SELECT id FROM acx_ft2 WHERE t = ANY (ia);
+-- in the parameterized form, the $1::integer[] below is only
+-- deparseParam's type label for the parameter (its pre-coercion type);
+-- the omitted conversion should have appended ::text[] after it
+SET plan_cache_mode = force_generic_plan;
+PREPARE acx_p(int[]) AS SELECT id FROM acx_ft2 WHERE t = ANY ($1);
+EXPLAIN (VERBOSE, COSTS OFF)
+EXECUTE acx_p('{5}');
+EXECUTE acx_p('{5}');
+DEALLOCATE acx_p;
+RESET plan_cache_mode;
+DROP CAST (integer AS text);
+
+-- BUG: element conversions performed by CoerceViaIO are pushed down,
+-- reaching I/O functions that are marked immutable but depend on GUCs,
+-- while scalar CoerceViaIO is deliberately never shipped.  postgres_fdw
+-- forces extra_float_digits = 3 on remote sessions, so the pushed-down
+-- qual computes a different answer than local evaluation: wrong results
+-- with no user-defined objects involved.
+CREATE TABLE acx_tbl3 (id int, f8 float8[], txt text[]);
+INSERT INTO acx_tbl3 VALUES (1, '{0.30000000000000004}', '{0.3}');
+CREATE FOREIGN TABLE acx_ft3 (id int, f8 float8[], txt text[])
+  SERVER loopback OPTIONS (table_name 'acx_tbl3');
+SET extra_float_digits = 0;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft3 WHERE f8::text[] = txt;
+-- BUG: finds no rows
+SELECT id FROM acx_ft3 WHERE f8::text[] = txt;
+-- adding a volatile clause forces local evaluation, which finds the row
+SELECT id FROM acx_ft3 WHERE f8::text[] = txt OR random() < -1;
+-- the same conversion in scalar form is correctly evaluated locally
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM acx_ft3 WHERE f8[1]::text = txt[1];
+SELECT id FROM acx_ft3 WHERE f8[1]::text = txt[1];
+RESET extra_float_digits;
+
+-- clean up
+DROP FOREIGN TABLE acx_ft1, acx_ft2, acx_ft3;
+DROP TABLE acx_tbl1, acx_tbl2, acx_tbl3;
+DROP FUNCTION acx_text2int(text);
+
 -- a regconfig constant referring to this text search configuration
 -- is initially unshippable
 CREATE TEXT SEARCH CONFIGURATION public.custom_search
