## v2.0.2 (2026-10-23)

### New Features
- **Tested support for the DuckDB 2.0.x engine** (in addition to 1.5.x): the runtime version guard (`duckdb_assert_library_version`, connection.c) now accepts 1.5.x **or** 2.0.x (`(major == 1 && minor == 5) || (major == 2 && minor == 0)`); anything else (1.4-, 1.6+, 2.1+) is still refused with `duckdb_fdw: unsupported libduckdb <v>, tested range 1.5.x / 2.0.x`. `download_libduckdb.sh` / `.ps1` now build the 2.0+ asset name `duckdb-shared-libs-<platform>-<arch>.tar.gz` (2.0.0 dropped the `libduckdb-*.zip` artifacts; the platform/arch suffixes are unchanged) and extract with `tar` instead of `unzip`. Verified on the WSL PG17 dev cluster: a scratch build linked to the official 2.0.0 shared lib (PG17.9, `USE_PGXS`) compiles and links with zero new errors, the guard accepts `v2.0.0`, and TPC-H sf1 **22/22** is row-for-row identical to a DuckDB CLI reading the same parquet (no behavioral drift across the 1.5→2.0 C API surface, which is a strict superset — all 545 libduckdb symbols the FDW links against are present in 2.0.0). The 2.0.0 CI/test target is in `build_and_test.yml` (separate `libduckdb-2.0` job, `DUCKDB_VERSION=2.0.0`).
- New GUC `duckdb_fdw.keep_connection` (off by default, `PGC_USERSET`, session-level `SET`): when on, the transaction-end callback *retains* the cached DuckDB connections after `COMMIT` — the remote transaction is settled first and `in_xact` cleared — so the next transaction reuses the same handles instead of re-opening the database, re-installing extensions and re-`ATTACH`ing catalogs; on `ABORT` the cache is still torn down after the remote `ROLLBACK`, so a half-open remote state is never handed to the next transaction. With the GUC off (default) behavior is unchanged: every top-level transaction end tears the connection cache down. Trade-off: a retained connection to a file-backed DuckDB database keeps the file write lock held across transactions, so the GUC is most beneficial for quack/remote mode (see README.md).

### Bug Fixes
- README: documented the multi-session consequence of `duckdb_fdw.keep_connection = on` on file-backed databases — a retained connection holds the file write lock for the session's lifetime, so other sessions on the same file get a clean `failed to open DuckDB` (lock contention, not a crash; verified on v2.0.1 + libduckdb 1.5.6) until it releases. The GUC stays session-scoped and off by default; it is only worth enabling where reuse beats the exclusive lock (quack/remote mode).
- Hardened the build against a libduckdb/PG ABI drift: on first connection the FDW now reads `duckdb_library_version()` and rejects versions outside the tested range `[1.5, 1.6)` with `duckdb_fdw: unsupported libduckdb %s, tested range [1.5, 1.6)` (the check runs at first connection, not in `_PG_init`, so merely `LOAD`ing the extension can never fail). Also verified against DuckDB 2.0.0: the guard refuses cleanly with that error (no crash), and 2.0.0 no longer ships the `libduckdb-linux-amd64.zip` artifact anyway.
- Unified the deparser/chunk-scan type mapping on a single source of truth, `duckdb_pg_type_native_chunk()` (duckdb_fdw.h/.c): the deparser's bare-column decision (`duckdb_deparse_target_list`, incl. the TIMESTAMPTZ text-fallback trigger) and the plan-time chunk gate (`duckdb_can_use_chunk_scan`) now both call it instead of two hand-synced OID lists. The runtime exact-width check (`duckdb_chunk_types_ok`) is kept as the second lock (plan-time PG type may pass, runtime physical width must match exactly). `INT2`/`FLOAT4` columns are now emitted bare in the remote SELECT and read natively via the chunk fast path when the DuckDB column is exactly `SMALLINT`/`FLOAT` (previously they were always `CAST(... AS VARCHAR)`, forcing the text fallback and silently triggering the TIMESTAMPTZ protection for unrelated rows).
- `duckdb_execute` now surfaces DuckDB errors to the client (elog ERROR instead of LOG); failed DDL/DML no longer looks like a silent success.
- `INSERT ... RETURNING` on foreign tables now returns the inserted rows: `GetForeignModifyBatchSize` reports batch size 1 when a RETURNING clause, WITH CHECK OPTION, or before/after row trigger is present (previously the batch path swallowed the single-row insert, so RETURNING returned 0 rows while the data landed). Mirrors postgres_fdw.
- Quack (`quack_host`) mode `IMPORT FOREIGN SCHEMA` no longer fails with `Table Function with name duckdb_tables does not exist`: Quack servers get a dedicated branch that creates foreign tables per explicitly listed table (`LIMIT TO (...)`) via `DESCRIBE remote.<schema>.<table>`; without `LIMIT TO` it raises a clear feature-not-supported error with a hint (Quack remotes do not expose their tables in `duckdb_tables()`).
- JOIN pushdown silently dropped per-table predicates: `count(*) FROM lineitem, orders WHERE l_orderkey = o_orderkey AND l_shipdate > '1995-01-01'` returned the unfiltered join row count (6001215 instead of 3424196). The planner attaches single-table quals to each base relation, not the join, and `foreign_join_ok()` only classified `joinrel->baserestrictinfo` — so member-table WHERE clauses were lost when the join was pushed down as one ForeignScan (no local Filter either). TPC-H Q3 returned 1,500,000 rows instead of 1,307. Member baserel conditions are now merged into the join's `remote_conds`/`local_conds` (each member's conditions were already validated at its own classification time), so a non-pushable member condition now surfaces as a local Filter or error instead of vanishing.
- JOIN pushdown now refuses join types the deparser cannot render (`JOIN_SEMI`/`JOIN_ANTI` from EXISTS/NOT EXISTS, ...): previously `duckdb_get_jointype_name()` would `elog(ERROR)` mid-deparse (e.g. TPC-H Q4); now the planner falls back to a local join with correct results.
- Deparse walker refuses expressions containing `SubPlan`/`SubLink`: nested subquery-in-subquery over foreign tables (e.g. TPC-H Q11 HAVING scalar subquery, Q20 nested IN) segfaulted the backend via the fetch loop's PARAM_EXEC path. Such conditions now fall back to local execution.
- Aggregation pushdown over a query carrying a subquery (scalar subquery / IN / EXISTS) now falls back to a local GroupAggregate over the ForeignScan: `sum(x) ... HAVING sum(x) > (SELECT sum(x) FROM foreign)` segfaulted the backend (TPC-H Q11) because the remote aggregate path does not correctly wire the HAVING dependency on the initplan's PARAM_EXEC result. Guarded with `root->parse->hasSubLinks`, so subquery-free join aggregates (TPC-H Q3/Q5/Q10) are unaffected. Q11 now returns the correct 186 rows instead of crashing.
- NOTE: Q11's crash was isolated to the multiplication aggregate + HAVING scalar subquery combination; the guard is broader (any subquery + aggregate pushdown) which trades some pushdown coverage for correctness — verified no regression on Q3/Q5/Q10/Q4/Q19.
- Fixed (2026-09-25) — Q7/Q8/Q9 `variable not found in subplan target list` (expression GROUP BY over a pushed-down join): `extract(year from ...)` is `date_part(...)` in PG's internal form, which is outside the deparse function whitelist, so the GROUP BY cannot be pushed. The join, however, was still pushed as a single ForeignScan whose scan tlist is the grouping target (not the base column inside the expression), leaving a local GroupAggregate on top of a scan that does not emit `l_shipdate` — setrefs then fails. Fix: in `foreign_join_ok()`, refuse join pushdown when any GROUP BY key is not a plain base column (non-Var tlist expr, or `varattno <= 0` whole-row). Such queries fall back to a local join, where each member scan emits its full base columns (correct, the same shape postgres_fdw takes by cost). Joins whose group keys are all plain base columns (Q3/Q5/Q10) still push the aggregation as a single remote scan — no performance regression (measured 0.1s vs 2-4s for the local-join fallback). Verified: Q7=2 / Q8=2 / Q9=15 now match the DuckDB CLI exactly; full 22-query cross-check shows zero new errors vs the pre-guard build (the only deltas are Q7/8/9 going from error to correct).
- Fixed (2026-09-25) — Q2 `variable not found in subplan target list` (3-table join + ORDER BY, no GROUP BY): a join pushed under a projection-capable parent is planned with `CP_IGNORE_TLIST`, so `GetForeignPlan` receives a **NULL** targetlist; a join scan (`scanrelid = 0`) with a NULL tlist breaks core `setrefs`, which maps the upper node's columns against the scan's `fdw_scan_tlist`. The join branch now rebuilds the targetlist from the join's `reltarget` when it is NULL — exactly the columns `build_path_tlist()` would derive from the path, and the non-NULL `fdw_scan_tlist` postgres_fdw guarantees via `build_tlist_to_deparse()`. Joins whose tlist arrives non-NULL are byte-for-byte untouched. Q2 (and every other join) still pushes the join as one remote scan — no local-join fallback, no performance regression; the full 6-column Q2 Remote SQL is `SELECT s_acctbal, s_name, n_name, s_address, s_phone, s_comment FROM (supplier JOIN (region JOIN nation ...) ...) WHERE s_suppkey = 1 AND r_name = 'EUROPE'` with a local Sort.
- Fixed (2026-09-25) — Q13 LEFT JOIN + aggregate returned 41 groups instead of 42: the `o_comment NOT LIKE ...` condition is part of the LEFT JOIN's `ON` clause (it must keep the unmatched side), but the member-predicate merge moved it into the post-join `WHERE`, where `NULL NOT LIKE ... = NULL` filters out exactly the `c_count = 0` group of 50,004 customers with no orders. The reference implementation (postgres_fdw) runs this shape as a *local* join with two independent member scans. Outer joins (LEFT/RIGHT/FULL) now refuse join pushdown in `foreign_join_ok()` and run locally — correct, and the only TPC-H outer-join query is Q13, so no pushdown coverage is lost elsewhere.
- Fixed (2026-09-25) — Q20 segfault at `numeric_out` in `BeginForeignScan`: the non-correlated scalar subquery `(SELECT 0.5*sum(ps_availqty) ...)` becomes an **InitPlan** feeding a PARAM_EXEC parameter in the remote query's `?` placeholders. The parameter was bound in `BeginForeignScan` — i.e. during `ExecutorStart`, *before* any InitPlan runs — so the slot still held Datum 0 with `isnull=false`, and the text-fallback path called `numeric_out(0)` and dereferenced NULL. postgres_fdw avoids this by executing the remote query on first ExecProc (InitPlan already run, slot holds its value). The remote query is now **deferred to first Iterate** when the scan carries remote parameters (`fsplan->fdw_exprs != NIL`), with the result/chunk setup moved with it and `EndForeignScan` guarded against destroying a never-executed result. Scans without remote parameters — every other TPC-H query (verified: no `?` in their Remote SQL) — still execute in `BeginForeignScan` exactly as before.
- **TPC-H 22/22**: with the above, all 22 TPC-H sf1 queries (8 parquet tables, lineitem 6,001,215 rows) now return row-for-row identical results to the DuckDB CLI, including the previously open Q2 (0), Q13 (42, `c_count=0` group of 50,004 present) and Q20 (0, no crash). The Known Issues list below is empty.

### Known Issues (found by the TPC-H 22-query cross-check)
None remaining: Q2 / Q13 / Q20 were the last three open items and are all fixed in this build (see Bug Fixes above). The 22-query cross-check is fully green.

### Bug Fixes (continued)
- `examples/19_readonly_and_returning.sql`: `force_readonly` option value quoted (`'true'`), the bare boolean was a syntax error under `ON_ERROR_STOP`.
- Reading a `TIMESTAMPTZ` column silently returned `2000-01-01 00:00:00` (epoch) whenever the same projection also fetched a complex column (array, JSON, ...). Root cause: such mixed projections fall back to the per-value text path, and the DuckDB 1.5.x C API cannot materialize native `TIMESTAMPTZ` values there (`duckdb_value_varchar` returns NULL, `duckdb_value_timestamp` returns 0). The deparser now detects this situation and emits `CAST(tz AS VARCHAR)` for the timestamp column so the value comes back correctly; pure fixed-width projections keep the fast chunk path unchanged.
- Writing arrays (e.g. `float4[]`, `text[]`) via the Appender path failed with `Failed to cast value: Type VARCHAR with value '{1.5,2.5}'...`: the PG array output format `{a,b}` is not a DuckDB list literal. The appender now converts one-dimensional array output to DuckDB `[...]` form; elements containing quotes or multi-dimensional arrays are passed through unchanged so DuckDB reports a clear error.
- Follow-up to the `TIMESTAMPTZ` fix above: the deparser's pre-scan used the fixed-width chunk whitelist (incl. `SMALLINT`/`REAL`) to decide when to add the `CAST(tz AS VARCHAR)` protection, but the runtime text-fallback decision is made on the *output* DuckDB types — and the deparser itself wraps `SMALLINT`/`REAL`/complex columns in `CAST(... AS VARCHAR)`, forcing the fallback. A projection like `date_trunc('month', timestamptz), count(*) WHERE tinyint_col = 2` therefore fell through and still returned the epoch. The whitelist now equals the columns that genuinely keep native fixed-width output (`BOOL`/`INT4`/`INT8`/`FLOAT8`/`DATE`/`TIMESTAMP`/`TIMESTAMPTZ`); verified against real Hacker News parquet data (1.06M rows, TINYINT `type` column).

### Maintenance
- Fixed (2026-09-26) — `read_json(...)` (and other balanced table-function calls not in the hard-coded substring list, e.g. `read_csv_auto`/`read_blob`) in a foreign table's `table` option was rejected with `relation name contains unsafe characters`: the deparser only special-cased the substrings `read_parquet`/`read_csv`, so `read_json('/path/x.jsonl')` fell through to the dotted-identifier parser and the path's parentheses/dots were flagged unsafe. A new `duckdb_fdw_is_table_function_call()` helper now recognizes any *balanced* table-function call (valid identifier + parens, at least one string-literal argument, all parens closed, no statement chaining or comment sequences) and passes it through verbatim — strictly additive, so every previously-working name is untouched. Verified: `CREATE FOREIGN TABLE ev (...) OPTIONS (table 'read_json(''/.../events.jsonl'')')` now reads the 3,000-line JSONL (count 3000, `WHERE ms > 400` → 594) instead of erroring; TPC-H 22/22 unchanged.
- Fixed (2026-09-26) — reading a custom DuckDB type (e.g. `spatial` `POINT_2D`) declared as a PG `text`/`varchar` column **segfaulted the backend**: the deparser treated `TEXT`/`VARCHAR` as safe-to-read-bare (no `CAST`), so DuckDB returned the native custom-typed value, and the per-value text path (`duckdb_value_varchar`) crashed on a non-VARCHAR logical type. `TEXT`/`VARCHAR` are now emitted as `CAST(col AS VARCHAR)` in the remote SQL — identity for a real varchar (DuckDB optimizes the cast away, no perf regression), and it forces `POINT_2D`/JSON/custom types to serialize to real VARCHAR. Same family as the earlier `TIMESTAMPTZ` deparser fix. Verified: `SELECT p FROM foreign_point_table` now returns `{'x': 105.2, 'y': 30.4}` instead of crashing; 6M-row varchar projection `length(l_comment)` still 0.47ms; TPC-H 22/22 unchanged (Q11 projects `c_comment` varchar and is byte-identical).
- Restricted admin-like helper execution by default and aligned public capability claims with the code-backed chunk-result read path.
- Added transaction-scoped connection cache cleanup and routed foreign path creation through a shared costing helper.
- Added contributor onboarding guidance and deterministic DuckDB bootstrap defaults.

## 2.0.0

### Highlights
- Native DuckDB C API integration
- Chunk-based scan iteration over DuckDB result chunks
- Prepared parameter binding for pushed-down queries
- Appender-based insert path with batch insert hooks
- Remote row estimation via `COUNT(*)`
- Tiered validation profiles (`core`, `integration`, `cloud`, `all`)
- Upgrade script signature repair and release metadata consistency fixes

### Notes
- Historical release dates before this maintenance pass were not reconstructed in this file.
