On Wed, Jul 16, 2025 at 12:55:44AM +0300, Alexander Korotkov wrote:
> On Wed, Jun 4, 2025 at 11:52 PM Alexander Korotkov <[email protected]>
> wrote:
> > On Wed, Jun 4, 2025 at 6:15 PM Alexander Pyhalov
> > <[email protected]> wrote:
> > > Alexander Korotkov писал(а) 2025-06-04 14:29:
> > > > On Wed, Jan 29, 2025 at 11:59 AM Maxim Orlov <[email protected]> wrote:
> > > >> One important note here. This patch will change cast behaviour in case
> > > >> of local and foreign types are mismatched.
> > > >> The problem is if we cannot convert types locally, this does not mean
> > > >> that it is also true for a foreign wrapped data.
> > > >> In any case, it's up to the committer to decide whether this change is
> > > >> needed or not.
> > > >
> > > > I have two question regarding this aspect.
> > > > 1) Is it the same with regular type conversion?
> > >
> > > Yes, it's the same.
> > >
> > > CREATE TYPE enum_of_int_like AS enum('1', '2', '3', '4');
> > > CREATE TABLE conversions(id int, d enum_of_int_like);
> > > CREATE FOREIGN TABLE ft_conversions (id int, d char(1))
> > > SERVER loopback options (table_name 'conversions');
> > > SET plan_cache_mode = force_generic_plan;
> > > PREPARE s(varchar) AS SELECT count(*) FROM ft_conversions where d=$1;
> > > EXPLAIN (VERBOSE, COSTS OFF)
> > > EXECUTE s('1');
> > > QUERY PLAN
> > > -------------------------------------------------------------------------------------------
> > > Foreign Scan
> > > Output: (count(*))
> > > Relations: Aggregate on (public.ft_conversions)
> > > Remote SQL: SELECT count(*) FROM public.conversions WHERE ((d =
> > > $1::character varying))
> > > (4 rows)
> > >
> > > EXECUTE s('1');
> > > ERROR: operator does not exist: public.enum_of_int_like = character
> > > varying
> > > HINT: No operator matches the given name and argument types. You might
> > > need to add explicit type casts.
> > Got it, thank you for the explanation. I thin it's fair that array
> > coercion works the same way as a regular cast.
I agree with that principle. While the above example shows regular and array
casts aligned, Fable 5 found the attached test cases where that alignment is
absent, yielding array-specific wrong query result scenarios. This thread's
commit 62c3b4c introduced those. I think the test patch's "implicit-format
ArrayCoerceExpr" is not meaningfully a regression, because scalars have the
same problem. The other two, "pushed down although the element conversion
calls a cast" and "CoerceViaIO are pushed down", are array-specific
regressions. Scalars don't get corresponding trouble for those two. I'm also
attaching Fable 5's report.
> I've written a commit message for this patch. I'm going to push this
> if no objections.
commit cfbd131 (HEAD -> fdw-arraycoerce-tests)
Author: Noah Misch <[email protected]>
AuthorDate: Fri Jul 10 23:15:15 2026 +0000
Commit: Noah Misch <[email protected]>
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 <[email protected]>
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
# User-visible defects in commit 62c3b4c (postgres_fdw ArrayCoerceExpr
pushdown), still present in master
Audit of 62c3b4c "Support for deparsing of ArrayCoerceExpr node in
contrib/postgres_fdw"
(Alexander Korotkov, 2025-07-18). Code is functionally unchanged in master
(only comment-typo
fix 4c5159a since) and is in REL_19_STABLE, so these ship in PostgreSQL 19
unless fixed.
Method: 23-agent workflow — 12 independent finder lenses (23 raw findings) →
root-cause
dedup (3 clusters) → per-cluster adversarial refuter + independent end-to-end
tracer + live
reproduction on a server built from this tree (master @ a8c2547, PG 19beta1;
loopback FDW
with a second database as the "remote", so local-only catalog objects genuinely
don't exist
remotely; ground truth = forced-local evaluation of the identical predicate) →
completeness
critic. **All three defects: refuter confirmed, tracer confirmed,
live-reproduced.**
---
## 1. (high, wrong results) `foreign_expr_walker` never vets the
element-coercion pathway (`elemexpr`)
**Anchor:** `contrib/postgres_fdw/deparse.c:706-733` (walker case recurses only
into `e->arg`, line 713)
The per-element conversion — a `FuncExpr` of the `pg_cast` function, a
`CoerceViaIO`, or a
`RelabelType` — lives in `e->elemexpr` and is never examined. Unlike
`T_FuncExpr` (:566),
`T_OpExpr` (:614), and `T_ScalarArrayOpExpr` (:654), no `is_shippable()` check
touches the
function that gives the cast its semantics; the `:1050` exit vets only the
result *array type*.
The would-be backstop `contain_mutable_functions` (`is_foreign_expr`, :287)
reaches `elemexpr`
via `expression_tree_walker` but checks only volatility, and IMMUTABLE is the
conventional
declaration for cast functions. `deparseArrayCoerceExpr` (:3544-3556) then
ships bare
`arg::resulttype` (or nothing for implicit format), so the remote re-resolves
the element
coercion against *its* `pg_cast` catalog. Pre-commit, all of this was evaluated
locally
(walker default case rejected the node).
Violates the documented contract (postgres-fdw.sgml ~1168-1173: only built-in /
listed-extension types, operators, functions are shipped) and is inconsistent
with the
scalar form of the identical cast, which the `T_FuncExpr` shippability check
still keeps local.
Live-reproduced (all observed on PG 19beta1):
```sql
-- Remote database "remotedb" (stock, only tables):
CREATE TABLE public.t_text (id int, c text[]);
INSERT INTO t_text VALUES (1,'{12345}'), (2,'{999}');
CREATE TABLE public.t_s (id int, c text);
INSERT INTO t_s VALUES (1,'00005'), (2,'5');
-- Local database:
CREATE EXTENSION postgres_fdw;
CREATE SERVER loopback FOREIGN DATA WRAPPER postgres_fdw OPTIONS (dbname
'remotedb');
CREATE USER MAPPING FOR CURRENT_USER SERVER loopback;
CREATE FOREIGN TABLE ft_text (id int, c text[]) SERVER loopback OPTIONS
(table_name 't_text');
CREATE FOREIGN TABLE ft_s (id int, c text) SERVER loopback OPTIONS
(table_name 't_s');
-- (A) Explicit-cast form: silent wrong results
CREATE FUNCTION text2int(text) RETURNS int LANGUAGE plpgsql IMMUTABLE STRICT
AS 'BEGIN RETURN length($1); END';
CREATE CAST (text AS integer) WITH FUNCTION text2int(text);
EXPLAIN (VERBOSE, COSTS OFF) SELECT id, c FROM ft_text WHERE c::int[] =
ARRAY[5];
-- Remote SQL: SELECT id, c FROM public.t_text WHERE ((c::integer[] =
'{5}'::integer[]))
SELECT id, c FROM ft_text WHERE c::int[] = ARRAY[5];
-- Observed: 0 rows. Correct (pre-commit/local semantics, cf.
-- SELECT * FROM (SELECT * FROM ft_text OFFSET 0) s WHERE c::int[] =
ARRAY[5]):
-- (1,'{12345}') because length('12345') = 5.
SELECT id, c FROM ft_text WHERE c::int[] = ARRAY[12345];
-- Observed: spurious row (1,'{12345}'); locally: 0 rows.
-- (B) New hard error on data the local cast handles:
-- on remote: INSERT INTO t_text VALUES (3,'{abc}');
SELECT id FROM ft_text WHERE c::int[] = ARRAY[5];
-- Observed: ERROR: invalid input syntax for type integer: "abc"
-- CONTEXT: remote SQL command: SELECT id FROM public.t_text WHERE
((c::integer[] = '{5}'::integer[]))
-- Locally-evaluated control returns id=1.
-- (C) Implicit-format branch (cast omitted entirely; see also defect 3):
CREATE FUNCTION int2padded(int) RETURNS text LANGUAGE sql IMMUTABLE STRICT
AS $$ SELECT lpad(CAST($1 AS varchar), 5, '0') $$;
CREATE CAST (integer AS text) WITH FUNCTION int2padded(int) AS IMPLICIT;
SET plan_cache_mode = force_generic_plan;
PREPARE s(int[]) AS SELECT id FROM ft_s WHERE c = ANY($1);
EXPLAIN (VERBOSE, COSTS OFF) EXECUTE s(ARRAY[5]);
-- Remote SQL: SELECT id FROM public.t_s WHERE ((c = ANY ($1::integer[])))
EXECUTE s(ARRAY[5]);
-- Observed: ERROR: operator does not exist: text = integer (remote SQL
command)
-- Correct/pre-commit: id=1 (verified via OFFSET 0 control).
-- Control demonstrating the inconsistency: the identical cast in scalar form
stays local
EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM ft_text WHERE c[1]::int = 5;
-- Filter: ((ft_text.c[1])::integer = 5) (local; correct results)
```
Because pushed-down quals also gate direct UPDATE/DELETE, the wrong-row
selection extends to
wrong rows *modified*.
---
## 2. (high, wrong results) Builtin GUC-sensitive I/O coercions shipped via
`elemexpr`'s `CoerceViaIO` — zero user-defined objects needed
**Anchor:** `contrib/postgres_fdw/deparse.c:706` (same unvetted-`elemexpr`
hole, distinct fix implication)
postgres_fdw deliberately never ships scalar `T_CoerceViaIO` (absent from
walker and
`deparseExpr` dispatch), so `f8col::text` stays a local Filter. But
`f8arr::text[]` is an
ArrayCoerceExpr whose `elemexpr` is a `CoerceViaIO` (string-category I/O
fallback,
parse_coerce.c:3275-3283) — and it's now judged shippable:
`float8out`/`byteaout`/`textin`
are marked immutable in pg_proc.dat despite depending on `extra_float_digits` /
`bytea_output`. postgres_fdw itself forces `SET extra_float_digits = 3` on
every remote
session (connection.c:840) and never syncs `bytea_output`, so local-vs-remote
rendering
diverges deterministically.
Key fix implication: **an `is_shippable()` check on the `elemexpr` function OID
would NOT
catch this** — the I/O functions are builtin. The walker must refuse
non-RelabelType element
coercions (or the deparser must stop delegating them).
Live-reproduced:
```sql
-- Remote (stock): CREATE TABLE public.t1 (id int, f8 float8[], txt text[]);
-- INSERT INTO t1 VALUES (1, ARRAY[(0.1::float8 +
0.2::float8)], ARRAY['0.3']);
-- Local: CREATE FOREIGN TABLE ft1 (id int, f8 float8[], txt text[]) SERVER
loopback OPTIONS (table_name 't1');
SET extra_float_digits = 0;
SELECT id FROM ft1 WHERE f8::text[] = txt;
-- Observed on master: 0 rows. Remote SQL: SELECT id FROM public.t1 WHERE
((f8::text[] = txt))
-- (remote session renders '{0.30000000000000004}' under forced
extra_float_digits=3)
-- Correct (pre-commit local semantics, cf. WHERE (f8::text[] = txt OR
random() < -1)): id=1.
-- bytea variant: remote ALTER DATABASE ... SET bytea_output='escape', local
default 'hex':
-- WHERE barr::text[] = txt → 0 rows pushed down vs id=1 locally.
-- Control: scalar WHERE txt[1] = f8[1]::text stays a local Filter and is
correct.
```
---
## 3. (medium, remote errors) `deparseArrayCoerceExpr` omits
`COERCE_IMPLICIT_CAST` coercions entirely
**Anchor:** `contrib/postgres_fdw/deparse.c:3552`
Emitting no text for implicit-format coercions bets that the remote parser
re-derives the
same coercion during operator resolution. That bet fails independently of any
`elemexpr`
vetting fix:
- **(a)** local implicit pg_cast entry backed by a *builtin* function — every
involved OID is
shippable, yet the remote errors:
```sql
-- Remote (stock): CREATE TABLE public.t (c2 text, c3 int[]); INSERT INTO t
VALUES ('3','{3}');
-- Local:
CREATE FOREIGN TABLE ft (c2 text, c3 int[]) SERVER loopback OPTIONS (table_name
't');
CREATE CAST (integer AS text) WITH FUNCTION pg_catalog.to_hex(integer) AS
IMPLICIT;
SELECT count(*) FROM ft WHERE c2 = ANY (c3);
-- Remote SQL: SELECT count(*) FROM public.t WHERE ((c2 = ANY (c3))) --
coercion vanished
-- Observed: ERROR: operator does not exist: text = integer
-- Correct/pre-commit (to_hex(3)='3'): count = 1.
-- Same via generic plan: PREPARE p(int[]) AS ... WHERE c2 = ANY($1)
-- → Remote SQL ((c2 = ANY ($1::integer[]))), identical error.
```
- **(b)** declared-type mismatch, *no user objects at all*: remote column is an
enum, local
foreign table declares `char(1)`; `PREPARE s(varchar[]) ... WHERE d =
ANY($1)` ships
`d = ANY ($1::character varying[])` and fails
`operator does not exist: public.enum_of_int_like = character varying`, where
pre-commit
it returned 2 rows via a local Filter. This mode was demonstrated in-thread
by the patch
author and flagged by a reviewer pre-commit, then consciously accepted.
Rated medium: loud error rather than silent corruption, and the scalar
implicit-cast FuncExpr
path has long had the same omission policy (this commit extends the exposure to
array
coercions). Unlike `RelabelType`, an ArrayCoerceExpr is not pure relabeling, so
the omission
drops real semantics; a remote can also bind a semantically different operator
(e.g. texteq
vs bpchareq trailing-space handling) rather than erroring.
---
## Follow-up lead from the completeness critic (traced in code, not live-run)
**Array-over-domain casts push a hidden `CoerceToDomain` to the remote**
(medium confidence).
When the target element type is a domain, `elemexpr` contains `CoerceToDomain`
— the one
shape that evades even the volatility backstop: `check_functions_in_node` has no
`T_CoerceToDomain` case and domain CHECK expressions live in the catalog, not
the node tree.
With the domain in a server-listed extension, `is_shippable(resulttype)` passes
(implicit
array types are extension members, pg_type.c:628-635), so `carr::dom[]` ships
while scalar
`c::dom` never does, and the *remote's* domain constraints (remote catalog
state, remote
session GUCs) replace local enforcement — contradicting clauses.c:3428-3440's
rationale for
not const-folding through CoerceToDomain. Traced failure modes: `unrecognized
configuration
parameter` remote errors for GUC-reading constraints; silently weaker
constraint enforcement
under local ALTER DOMAIN / extension version skew. Mitigating: requires the
`extensions`
option, whose docs shift identical-behavior responsibility to the user.
## Checked and found clean / derivative
- **Collation logic** (deparse.c:719-731): exact parity with `T_RelabelType`;
`elemexpr`
cannot introduce a Var-derived collation (`CaseTestExpr` is built with
InvalidOid collation,
parse_coerce.c:947). Affirmatively clean.
- **ORDER BY / GROUP BY / aggregate pushdown, direct UPDATE/DELETE, EPQ
rechecks**: traced;
only derivative manifestations of defects 1-2 (wrong sort/grouping keys,
wrong rows
modified, nondeterministic row drops when a diverging pushed qual is
re-evaluated locally
in an EPQ recheck), not new defect classes.
- **Fix shape implied by the evidence**: restrict pushdown to element coercions
that are pure
relabelings (elemexpr is RelabelType/bare CaseTestExpr) — and if kept, always
emit the cast
rather than omitting implicit format; vetting only the elemexpr function OID
is insufficient
(defect 2), and omission is unsound even when everything is shippable (defect
3).
## Provenance
- Workflow `wf_16d96442-0cf`: 23 agents (cap 30), 12/12 finder lenses returned,
23 raw
findings → 3 root causes, 0 dropped, 0 refuted. Each root cause: adversarial
refuter
**confirmed** + independent end-to-end tracer **confirmed** + empirical
reproducer
**reproduced=yes**. Archives lens independently matched defect 3(b) to the
pre-commit
review thread.
- Empirical setup: reused `tmp_install` of this tree (PG 19beta1, master
a8c2547); fresh
cluster in session scratchpad, unix-socket only; "remote" = second database
over a loopback
postgres_fdw server. Servers stopped afterward; socket dirs removed; data
dirs left in
scratchpad; git tree untouched (`tmp_install/` is gitignored).
- Full machine-readable results:
`/tmp/claude-1000/-home-nm-src-pg-postgresql/afe6d0ac-a2cb-4fa6-9526-676825c85665/tasks/wlbjtr33p.output`