commit cfbd131 (HEAD -> fdw-arraycoerce-tests) Author: Noah Misch AuthorDate: Fri Jul 10 23:15:15 2026 +0000 Commit: Noah Misch 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 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