From a98093268fba62e18a203c92c3804dc0c3c3a965 Mon Sep 17 00:00:00 2001
From: Alexander Korotkov <akorotkov@postgresql.org>
Date: Sun, 12 Jul 2026 19:42:08 +0300
Subject: [PATCH v1] postgres_fdw: don't push down non-relabeling
 ArrayCoerceExpr

Commit 62c3b4cd9ddc taught postgres_fdw to push down ArrayCoerceExpr, but
foreign_expr_walker() only recursed into the input array expression and
never examined elemexpr, the per-element conversion that gives the
coercion its semantics.  deparseArrayCoerceExpr() then shipped a bare
"arg::resulttype" cast, or nothing at all for an implicit-format
coercion, leaving the remote server to re-resolve the element conversion
against its own catalogs and session state.

This produced wrong results or remote errors whenever the element
conversion was not a plain relabeling, and it was inconsistent with how
postgres_fdw treats the equivalent scalar coercions.  An ArrayCoerceExpr
was shipped even when its elemexpr was a cast function (whose
shippability was never checked), a CoerceViaIO (e.g. float8out or
byteaout, which depend on extra_float_digits / bytea_output that
postgres_fdw sets differently on the remote session), or a
CoerceToDomain (which pushes domain enforcement to the remote catalog).
By contrast, a scalar CoerceViaIO is never shipped, and a scalar cast
function is shipped only when it is shippable.

Restrict pushdown to element coercions that are a plain relabeling, that
is, elemexpr is a RelabelType or a bare CaseTestExpr.  Any other element
coercion is now evaluated locally.  This keeps the common
binary-coercible case pushed down, including "col = ANY($1)" with a
varchar[]-to-text[] relabeling, which is the case 62c3b4cd9ddc set out to
optimize.

Pushing down shippable element cast functions, to reach parity with the
scalar case, is left out here for simplicity.

Reported-by: Noah Misch <noah@leadboat.com>
Discussion: https://postgr.es/m/20260711024234.43.noahmisch%40microsoft.com
Backpatch-through: 19
---
 contrib/postgres_fdw/deparse.c                | 20 +++++
 .../postgres_fdw/expected/postgres_fdw.out    | 75 +++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     | 35 +++++++++
 3 files changed, 130 insertions(+)

diff --git a/contrib/postgres_fdw/deparse.c b/contrib/postgres_fdw/deparse.c
index 2dcc6c8af1b..9d1932230e7 100644
--- a/contrib/postgres_fdw/deparse.c
+++ b/contrib/postgres_fdw/deparse.c
@@ -707,6 +707,26 @@ foreign_expr_walker(Node *node,
 			{
 				ArrayCoerceExpr *e = (ArrayCoerceExpr *) node;
 
+				/*
+				 * Push down only when the per-element coercion is a plain
+				 * relabeling, that is, elemexpr is a RelabelType or a bare
+				 * CaseTestExpr.  Any other element coercion -- a cast
+				 * function, an I/O conversion (CoerceViaIO), or a domain
+				 * coercion -- is kept local.  We ship only a bare
+				 * "arg::resulttype" cast (nothing at all for an
+				 * implicit-format coercion), so a non-relabeling conversion
+				 * would be re-resolved against the remote server's catalogs
+				 * and session state and could silently change the result.
+				 * This matches the handling of the scalar coercions for the
+				 * I/O and domain cases (never shipped); an element cast
+				 * function is kept local too, which is more conservative than
+				 * the scalar case (a scalar cast function is shipped when it
+				 * is shippable).
+				 */
+				if (!IsA(e->elemexpr, RelabelType) &&
+					!IsA(e->elemexpr, CaseTestExpr))
+					return false;
+
 				/*
 				 * Recurse to input subexpression.
 				 */
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 048f624ba0c..991501245f9 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -1219,6 +1219,81 @@ EXECUTE s(ARRAY['1','2']);
 
 DEALLOCATE s;
 RESET plan_cache_mode;
+-- An ArrayCoerceExpr is pushed down only when its per-element coercion is
+-- a plain relabeling.  An element cast function, an I/O conversion, or a
+-- domain coercion is instead evaluated locally.
+CREATE TABLE loct_acx (id int, ta text[], f8 float8[], txt text[], t text, ia int[], vc varchar[]);
+INSERT INTO loct_acx VALUES (1, '{12345}', '{0.30000000000000004}', '{0.3}', '5', '{5}', '{5}');
+CREATE FOREIGN TABLE ft_acx (id int, ta text[], f8 float8[], txt text[], t text, ia int[], vc varchar[])
+  SERVER loopback OPTIONS (table_name 'loct_acx');
+-- element coercion via a cast function: not shippable, stays local
+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);
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM ft_acx WHERE ta::int[] = ARRAY[5];
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Foreign Scan on public.ft_acx
+   Output: id
+   Filter: ((ft_acx.ta)::integer[] = '{5}'::integer[])
+   Remote SQL: SELECT id, ta FROM public.loct_acx
+(4 rows)
+
+DROP CAST (text AS integer);
+DROP FUNCTION acx_text2int(text);
+-- implicit-format element coercion (a cast function): stays local, and the
+-- coercion is not silently dropped from an otherwise pushed-down qual
+CREATE CAST (integer AS text) WITH FUNCTION pg_catalog.to_hex(integer) AS IMPLICIT;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM ft_acx WHERE t = ANY (ia);
+                     QUERY PLAN                      
+-----------------------------------------------------
+ Foreign Scan on public.ft_acx
+   Output: id
+   Filter: (ft_acx.t = ANY ((ft_acx.ia)::text[]))
+   Remote SQL: SELECT id, t, ia FROM public.loct_acx
+(4 rows)
+
+DROP CAST (integer AS text);
+-- element coercion via a GUC-sensitive I/O conversion: stays local, so the
+-- result matches local evaluation despite the forced remote extra_float_digits
+SET extra_float_digits = 0;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM ft_acx WHERE f8::text[] = txt;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Foreign Scan on public.ft_acx
+   Output: id
+   Filter: ((ft_acx.f8)::text[] = ft_acx.txt)
+   Remote SQL: SELECT id, f8, txt FROM public.loct_acx
+(4 rows)
+
+SELECT id FROM ft_acx WHERE f8::text[] = txt;
+ id 
+----
+  1
+(1 row)
+
+RESET extra_float_digits;
+-- a plain relabeling element coercion (varchar[] to text[]) is still pushed down
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM ft_acx WHERE t = ANY (vc);
+                             QUERY PLAN                              
+---------------------------------------------------------------------
+ Foreign Scan on public.ft_acx
+   Output: id
+   Remote SQL: SELECT id FROM public.loct_acx WHERE ((t = ANY (vc)))
+(3 rows)
+
+SELECT id FROM ft_acx WHERE t = ANY (vc);
+ id 
+----
+  1
+(1 row)
+
+DROP FOREIGN TABLE ft_acx;
+DROP TABLE loct_acx;
 -- 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 ed2c8b58e60..454deb03d69 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -485,6 +485,41 @@ EXECUTE s(ARRAY['1','2']);
 DEALLOCATE s;
 RESET plan_cache_mode;
 
+-- An ArrayCoerceExpr is pushed down only when its per-element coercion is
+-- a plain relabeling.  An element cast function, an I/O conversion, or a
+-- domain coercion is instead evaluated locally.
+CREATE TABLE loct_acx (id int, ta text[], f8 float8[], txt text[], t text, ia int[], vc varchar[]);
+INSERT INTO loct_acx VALUES (1, '{12345}', '{0.30000000000000004}', '{0.3}', '5', '{5}', '{5}');
+CREATE FOREIGN TABLE ft_acx (id int, ta text[], f8 float8[], txt text[], t text, ia int[], vc varchar[])
+  SERVER loopback OPTIONS (table_name 'loct_acx');
+-- element coercion via a cast function: not shippable, stays local
+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);
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM ft_acx WHERE ta::int[] = ARRAY[5];
+DROP CAST (text AS integer);
+DROP FUNCTION acx_text2int(text);
+-- implicit-format element coercion (a cast function): stays local, and the
+-- coercion is not silently dropped from an otherwise pushed-down qual
+CREATE CAST (integer AS text) WITH FUNCTION pg_catalog.to_hex(integer) AS IMPLICIT;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM ft_acx WHERE t = ANY (ia);
+DROP CAST (integer AS text);
+-- element coercion via a GUC-sensitive I/O conversion: stays local, so the
+-- result matches local evaluation despite the forced remote extra_float_digits
+SET extra_float_digits = 0;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM ft_acx WHERE f8::text[] = txt;
+SELECT id FROM ft_acx WHERE f8::text[] = txt;
+RESET extra_float_digits;
+-- a plain relabeling element coercion (varchar[] to text[]) is still pushed down
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT id FROM ft_acx WHERE t = ANY (vc);
+SELECT id FROM ft_acx WHERE t = ANY (vc);
+DROP FOREIGN TABLE ft_acx;
+DROP TABLE loct_acx;
+
 -- a regconfig constant referring to this text search configuration
 -- is initially unshippable
 CREATE TEXT SEARCH CONFIGURATION public.custom_search
-- 
2.50.1 (Apple Git-155)

