From a98093268fba62e18a203c92c3804dc0c3c3a965 Mon Sep 17 00:00:00 2001 From: Alexander Korotkov 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 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)