Re: reconsider_full_join_clause fails to consider if COALESCE() arguments are NULL-preserving

From: Tomas Vondra <tomas(at)vondra(dot)me>
To: Ayush Tiwari <ayushtiwari(dot)slg01(at)gmail(dot)com>
Cc: PostgreSQL Hackers <pgsql-hackers(at)postgresql(dot)org>
Subject: Re: reconsider_full_join_clause fails to consider if COALESCE() arguments are NULL-preserving
Date: 2026-11-09 13:41:52
Message-ID: 79838a9f-2281-43f3-9dae-219ba80e2adb@vondra.me
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

On 11/9/26 06:47, Ayush Tiwari wrote:
> Hi,
>
> On Mon, 9 Nov 2026 at 02:37, Tomas Vondra <tomas(at)vondra(dot)me
> <mailto:tomas(at)vondra(dot)me>> wrote:
>>
>> Hi,
>>
>> While working on [1], an AI review pointed out a separate long-standing
>> bug in reconsider_full_join_clause(). Consider a query like this:
>>
>>     SELECT *
>>     FROM t1 FULL JOIN t2 ON (t1.id <http://t1.id> = t2.id <http://t2.id>)
>>     WHERE COALESCE(t1.id <http://t1.id>, t2.id <http://t2.id>) = 12345
>>
>> The function correctly replaces the COALESCE() clause with two clauses
>> pushed down to the join inputs, and removes the join clause, as if
>>
>>     SELECT *
>>     FROM (SELECT * FROM t1 WHERE t1.id <http://t1.id> = 12345)
>>     FULL JOIN (SELECT * FROM t2 WHERE t2.id <http://t2.id> = 12345)
>>
>> So far all good - such transformation is correct (this example does not
>> even need fixes from [1]). But the coalesce arguments may not be plain
>> Vars. Consider this instead:
>>
>>     SELECT *
>>     FROM t1 FULL JOIN t2 ON (t1.id <http://t1.id> = COALESCE(t2.id
> <http://t2.id>, 12345))
>>     WHERE COALESCE(t1.id <http://t1.id>, COALESCE(t2.id <http://
> t2.id>, 12345)) = 12345
>>
>> Well, reconsider_full_join_clause() still pushed down the clauses to the
>> join inputs etc. as if
>>
>>     SELECT *
>>     FROM (SELECT * FROM t1 WHERE t1.id <http://t1.id> = 12345)
>>     FULL JOIN (SELECT * FROM t2 WHERE COALESCE(t2.id <http://
> t2.id>,12345) = 12345)
>>
>> But this is wrong, because the COALESCE() gets rid of NULLs. If t2.id
> <http://t2.id> is
>> NULL ("id" does not need to be a PK), the row incorrectly matches. A
>> trivial example:
>>
>>     CREATE TABLE t1 (id INT);
>>     CREATE TABLE t2 (id INT);
>>
>>     INSERT INTO t1 VALUES (NULL);
>>     INSERT INTO t2 VALUES (NULL);
>>
>>     SELECT t1.id <http://t1.id> as x, t2.id <http://t2.id> as y
>>       FROM t1 FULL JOIN t2 ON (t1.id <http://t1.id> = COALESCE(t2.id
> <http://t2.id>, 12345))
>>      WHERE COALESCE(t1.id <http://t1.id>, COALESCE(t2.id <http://
> t2.id>, 12345)) = 12345;
>>
>>      x | y
>>     ---+---
>>        |
>>     (1 row)
>>
>>     -- OFFSET 0 to block the pushdown, result should not change
>>     SELECT * FROM (
>>      SELECT t1.id <http://t1.id> as x, t2.id <http://t2.id> as y
>>        FROM t1 FULL JOIN t2 ON (t1.id <http://t1.id> = COALESCE(t2.id
> <http://t2.id>, 12345)) OFFSET 0
>>     ) foo WHERE COALESCE(foo.x, COALESCE(foo.y, 12345)) = 12345;
>>
>>      x | y
>>     ---+---
>>        |
>>        |
>>     (2 rows)
>>
>> COALESCE is not the only way to get this issue, CASE can have the same
>> effect (example in the patch). There might be more ways to hit it ...
>>
>> This requires the t1.id <http://t1.id> = COALESCE(t2.id <http://
> t2.id>, 12345) join clause, it works
>> OK without that. IIUC this is because it establishes a matching EC,
>> which is necessary for the misbehavior.
>
> Thanks for the detailed explanation.  This is almost the first time I
> didn't have to go and search for the whole issue to understand what the
> case is about.
>

TBH this is a very new topic for me too, so I mostly wrote this
explanation for myself in the first place, to clarify my understanding
of how it works. And indeed I found a bunch of gaps in my understanding.
But it also means the explanation may be wrong / missing pieces, so I'd
encourage you to go through the code and verify I got it right - it'd be
a very useful review.

>>
>> This query pattern (with COALESCE in the join clause) is pretty odd, but
>> what do I know? People (and tools) sometimes write pretty crazy stuff.
>>
>>
>> AFAICS to fix this, reconsider_full_join_clause() needs to ask if both
>> sides of the clause (or operands for COALESCE) preserve NULLs. The patch
>> adds that check before the loop, same place as [1]. The principle is the
>> same - unsafe expression -> return false -> keep the original clause.
>>
>> The trouble is, I don't think we have a way to check this property yet.
>> There are places doing something similar, but nothing that'd check if an
>> expression is null-preserving (recursively).
>>
>> After a fair amount of trial and error I came up with a new helper
>> is_full_join_expr_null_preserving doing this. It pretty much descends
>> into CoalesceExpr, and checks if the arguments are NULL-preserving.
>>
>> Still, once we get a non-CoalesceExpr expression, we need to know if
>> it's null-preserving. The conservative way to do that is
>>
>>     return !contain_nonstrict_functions(node) &&
>>         contain_vars_of_level(node, 0);
>>
>> Which means it may contain only strict functions (NULL -> NULL) and some
>> vars. This rules out constants, functions that discard NULLs, etc. It
>> also rejects strict-behaving functions that are not labeled as such, but
>> I don't know how to do better.
>>
>> There's also a special case for PlaceHolderVar, to preserve the
>> optimization for expressions calculated below the join (as the result
>> will be nulled no matter the expression).
>>
>> Maybe we could improve this further, but this query pattern is pretty
>> strange, so it should be enough to not return incorrect results. I don't
>> think it's worth more effort.
>
> +1, Ig being conservative is fine here.  If there's a report for some real
> query that needs more, we can teach it then.
>
>> The patch is pretty small. I've spent a bit of time with sqlfiddle to
>> check we get the same behavior as other databases (MSSQL and Oracle).
>> And that we produce incorrect results without the fix.
>>
>> My questions are pretty much:
>>
>> 1) Is is_full_join_expr_null_preserving() necessary? Or is there some
>> existing piece of code checking the expression preserves NULLs? Possibly
>> with some additional smartness I haven't thought of?
>
> I don't think we have one.  I looked at find_nonnullable_rels() first,
> but FALSE is good enough for that one, and here we need an actual NULL.
> So, yeah the function looks necessary to me.
>
>> 2) Is is_full_join_expr_null_preserving() correct?
>
> Looks right to me.  If all the COALESCE args go NULL, so does the
> COALESCE.  The PHV case is fine too, since it's the value computed below
> the join that gets nulled.  IIUC that part relies on the side check from
> [1] though, so I assume this will go on top of it?

OK

>
> Regards,
> Ayush
>
> PS. I did trip over the JsonExpr strictness bug while testing (a NULL
> PASSING arg can still give you a non-NULL result), but that's already
> being fixed in [2], so nothing to do here.
>

Ah, interesting.

Thanks!

--
Tomas Vondra

In response to

Browse pgsql-hackers by date

  From Date Subject
Next Message Jakub Wartak 2026-11-09 13:49:11 Re: aio tests failing on newer Linux kernels
Previous Message Robert Haas 2026-11-09 13:28:58 Re: Show 'Rows Removed by Filter' with two fractional digits in EXPLAIN