-- NULL semantics of the IN family (ScalarArrayOpExpr pushdown). -- -- ClickHouse evaluates IN under two-valued logic: a probe that finds no -- match yields 0 even when the searched set contains a NULL, where Postgres -- computes NULL, which changes negation semantics (NOT(NULL) -> NULL). -- The has()/countEqual() functions also match a NULL probe against a NULL -- element as if it were an ordinary value. Every shape below ships regardless: -- deparse.c uses the cheap native form when it can prove neither side can be -- NULL, and a guarded CASE otherwise that checks at runtime instead, computing -- Postgres's exact three-valued answer (NULL, not FALSE, or worse, TRUE) -- in every context. These tests ensure both forms deparse correctly and that -- the rows returned match local (Postgres) semantics either way. CREATE SERVER in_null_svr FOREIGN DATA WRAPPER clickhouse_fdw OPTIONS(dbname 'in_null_test', driver 'binary'); CREATE USER MAPPING FOR CURRENT_USER SERVER in_null_svr; CREATE SERVER in_null_admin FOREIGN DATA WRAPPER clickhouse_fdw; CREATE USER MAPPING FOR CURRENT_USER SERVER in_null_admin; CALL clickhouse_perform('in_null_admin', 'DROP DATABASE IF EXISTS in_null_test'); CALL clickhouse_perform('in_null_admin', 'CREATE DATABASE in_null_test'); -- xn imports as a plain (nullable) int column, xp as int NOT NULL, and arr -- as int[]; its elements' nullability is never provable at plan time CALL clickhouse_perform('in_null_admin', 'CREATE TABLE in_null_test.tnull (id Int32, xn Nullable(Int32), xp Int32, arr Array(Int32)) ENGINE = MergeTree ORDER BY id'); CALL clickhouse_perform('in_null_admin', $$ INSERT INTO in_null_test.tnull VALUES (1, 1, 1, [1, 500]), (2, 2, 2, [500]), (3, NULL, 3, []) $$); CREATE SCHEMA in_null_test; IMPORT FOREIGN SCHEMA in_null_test FROM SERVER in_null_svr INTO in_null_test; SET SESSION search_path = in_null_test,public; -- The import must carry ClickHouse nullability into the declarations the -- never-null proofs rely on \d tnull -- ============================================================ -- 1. IN over a constant list: always pushable in a WHERE condition -- ============================================================ -- NULL-free list is exactly equivalent to the native IN EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xn IN (1, 500) ORDER BY id; SELECT id FROM tnull WHERE xn IN (1, 500) ORDER BY id; -- A NULL in the list can't be proven absent, so this ships via a guarded -- CASE instead of the native IN, computing the real answer at runtime EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xn IN (1, NULL) ORDER BY id; SELECT id FROM tnull WHERE xn IN (1, NULL) ORDER BY id; -- ============================================================ -- 2. NOT IN over a constant list: always pushable, native when NULL-free -- ============================================================ -- ClickHouse's native NOT IN yields NULL for a NULL probe, so a NULL-free -- list is exact: id 3 (xn IS NULL) is dropped on both systems EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xn NOT IN (1, 500) ORDER BY id; SELECT id FROM tnull WHERE xn NOT IN (1, 500) ORDER BY id; -- With a NULL in the list, ClickHouse's native NOT IN would return id 2 -- where Postgres returns no rows (every result is NULL or FALSE): the -- guarded CASE computes Postgres's answer instead of using the native form EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xn NOT IN (1, NULL) ORDER BY id; SELECT id FROM tnull WHERE xn NOT IN (1, NULL) ORDER BY id; -- NOT over IN is the same thing arriving unrewritten EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE NOT (xn IN (1, NULL)) ORDER BY id; SELECT id FROM tnull WHERE NOT (xn IN (1, NULL)) ORDER BY id; EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE NOT (xn IN (1, 500)) ORDER BY id; SELECT id FROM tnull WHERE NOT (xn IN (1, 500)) ORDER BY id; -- ============================================================ -- 3. Non-constant arrays: the has()/countEqual() deparse forms -- ============================================================ -- has() matches a NULL probe against a NULL element where Postgres computes -- NULL, so = ANY uses the cheap has() form only with a provably non-NULL -- probe; xp is declared NOT NULL EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xp = ANY(ARRAY[xn, 5]) ORDER BY id; SELECT id FROM tnull WHERE xp = ANY(ARRAY[xn, 5]) ORDER BY id; -- A nullable probe against an array that may hold a NULL would turn -- Postgres's NULL (id 3: NULL = ANY({NULL,5})) into TRUE via has(): the -- guarded CASE ships instead, computing the real NULL at runtime EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xn = ANY(ARRAY[xn, 5]) ORDER BY id; SELECT id FROM tnull WHERE xn = ANY(ARRAY[xn, 5]) ORDER BY id; -- Basic arithmetic over provably non-NULL inputs is itself provably -- non-NULL (it returns a value or raises; see never_null_opfuncs), so an -- expression element doesn't cost the cheap form... EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xp = ANY(ARRAY[xp + 0, 500]) ORDER BY id; SELECT id FROM tnull WHERE xp = ANY(ARRAY[xp + 0, 500]) ORDER BY id; -- ...while the same arithmetic over a nullable input proves nothing: -- guarded (id 3: 3 = ANY({NULL,500}) is NULL, dropped either way) EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xp = ANY(ARRAY[xn + 0, 500]) ORDER BY id; SELECT id FROM tnull WHERE xp = ANY(ARRAY[xn + 0, 500]) ORDER BY id; -- countEqual() = 0 turns any NULL involved into TRUE (id 3: 3 <> ALL of -- {NULL,5} is NULL on Postgres): <> ALL uses the cheap countEqual() form -- only with both sides provably non-NULL EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xp <> ALL(ARRAY[xn, 5]) ORDER BY id; SELECT id FROM tnull WHERE xp <> ALL(ARRAY[xn, 5]) ORDER BY id; -- ...and with both sides proven, the cheap form ships EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xp <> ALL(ARRAY[xp, 500]) ORDER BY id; SELECT id FROM tnull WHERE xp <> ALL(ARRAY[xp, 500]) ORDER BY id; -- `!=` is just `<>` by the time the parser hands it to us (transformed at -- parse analysis, before any FDW code sees the tree); confirm it deparses -- identically rather than falling through chfdw_is_equal_op's string match EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xp != ALL(ARRAY[xp, 500]) ORDER BY id; SELECT id FROM tnull WHERE xp != ALL(ARRAY[xp, 500]) ORDER BY id; -- <> ANY deparses by counting elements rather than has() (which would -- compute <> ALL instead): the cheap form ships with both sides proven -- non-NULL EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xp <> ANY(ARRAY[xp, 500]) ORDER BY id; SELECT id FROM tnull WHERE xp <> ANY(ARRAY[xp, 500]) ORDER BY id; -- Neither side is proven non-NULL here, but this still ships: a guarded -- CASE checks at runtime whether the probe or an array element is NULL and -- returns a genuine NULL there instead of requiring a plan-time proof EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xn <> ANY(ARRAY[xn, 500]) ORDER BY id; SELECT id FROM tnull WHERE xn <> ANY(ARRAY[xn, 500]) ORDER BY id; -- A provably empty array is exact via the cheap form regardless of the -- probe's nullability (has()/countEqual() degenerate correctly on an -- empty array by arithmetic): id 3's NULL probe joins ids 1/2 under the -- same FALSE flag, since <> ANY(empty) is FALSE for every probe on both -- systems, NULL probe included EXPLAIN (VERBOSE, COSTS OFF) SELECT xn <> ANY(ARRAY[]::int[]) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; SELECT xn <> ANY(ARRAY[]::int[]) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; -- A non-constant array can be empty at *runtime*, and Postgres resolves an -- empty array before even looking at the probe: id 3's NULL = ANY('{}') is -- FALSE, not NULL, so it must group with id 2. The guarded CASE answers -- NULL for a NULL probe only against a non-empty array and falls through -- to the empty-array answer otherwise EXPLAIN (VERBOSE, COSTS OFF) SELECT xn = ANY(arr) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; SELECT xn = ANY(arr) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; -- ...and the ALL side of the same coin: id 3's NULL <> ALL('{}') is TRUE EXPLAIN (VERBOSE, COSTS OFF) SELECT xn <> ALL(arr) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; SELECT xn <> ALL(arr) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; -- = ALL ships via the cheap form with both sides proven non-NULL... EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xp = ALL(ARRAY[xp]) ORDER BY id; SELECT id FROM tnull WHERE xp = ALL(ARRAY[xp]) ORDER BY id; -- ...but countEqual() counts NULL as equal to NULL (id 3 would come back -- TRUE where Postgres computes NULL = ALL({NULL}) as NULL): the guarded -- CASE ships instead EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xn = ALL(ARRAY[xn]) ORDER BY id; SELECT id FROM tnull WHERE xn = ALL(ARRAY[xn]) ORDER BY id; -- ============================================================ -- 4. Value contexts no longer observe any divergence -- ============================================================ -- A NULL-free list needs no guard: the native IN is already exact EXPLAIN (VERBOSE, COSTS OFF) SELECT xn IN (1, 500) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; SELECT xn IN (1, 500) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; -- A NULL in the list previously forced this local, since the value -- position demands exactness the native IN can't give; the guarded CASE -- now ships instead, returning a genuine NULL so id 3 lands in the same -- group Postgres would put it in EXPLAIN (VERBOSE, COSTS OFF) SELECT xn IN (1, NULL) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; SELECT xn IN (1, NULL) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; -- <> ANY ships the same way, aggregation included, despite neither side -- being proven non-NULL EXPLAIN (VERBOSE, COSTS OFF) SELECT xn <> ANY(ARRAY[xn, 500]) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; SELECT xn <> ANY(ARRAY[xn, 500]) AS flag, count(*) FROM tnull GROUP BY flag ORDER BY flag; -- An aggregate FILTER only qualifies rows, so it keeps truth-context rules EXPLAIN (VERBOSE, COSTS OFF) SELECT count(*) FILTER (WHERE xn IN (1, NULL)) FROM tnull; SELECT count(*) FILTER (WHERE xn IN (1, NULL)) FROM tnull; -- ============================================================ -- 5. Shippability-walker regressions fixed alongside the rules -- ============================================================ -- A NULL array constant survives const-folding when the probe is a column; -- inspecting it for the IN-list deparse would detoast a null datum. Local. EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE xn = ANY(NULL::int[]) ORDER BY id; SELECT id FROM tnull WHERE xn = ANY(NULL::int[]) ORDER BY id; -- A CASE arg WHEN .. with an unshippable tested expression shipped without -- its branches ever being checked. Local, with correct rows. EXPLAIN (VERBOSE, COSTS OFF) SELECT id FROM tnull WHERE CASE pg_backend_pid() WHEN 0 THEN xp = 1 ELSE xp > 0 END ORDER BY id; SELECT id FROM tnull WHERE CASE pg_backend_pid() WHEN 0 THEN xp = 1 ELSE xp > 0 END ORDER BY id; -- Cleanup SET SESSION search_path = public; DROP SCHEMA in_null_test CASCADE; CALL clickhouse_perform('in_null_admin', 'DROP DATABASE in_null_test'); DROP USER MAPPING FOR CURRENT_USER SERVER in_null_svr; DROP SERVER in_null_svr;