Skip to content

fix(postgres): mark the null-extended side of every outer join - #4380

Open
heios wants to merge 2 commits into
transact-rs:mainfrom
heios:fix/pg-right-join-nullability
Open

fix(postgres): mark the null-extended side of every outer join#4380
heios wants to merge 2 commits into
transact-rs:mainfrom
heios:fix/pg-right-join-nullability

Conversation

@heios

@heios heios commented Aug 14, 2026

Copy link
Copy Markdown

Does your PR solve an issue?

fixes #367

Is this a breaking change?

Yes. For a column brought in by an outer join, the generated type can widen
from T to Option<T>, and it can also narrow from Option<T> to T.

A column on the null-extended side widens to Option<T>. As in #566, few
callers should break. Such a column raises UnexpectedNull at runtime today,
unless the query already overrides the column with foo as "foo?".

A column on the preserved side narrows to T. Code that matches on the
Option must drop the match, or override the column with foo as "foo!".

Problem

visit_plan has two faults.

First, it marks the wrong child of a Right join node. The planner often
turns a plain LEFT JOIN into such a node, so PostgreSQL and sqlx disagree
about which column is nullable:

create table tweet       (id bigint primary key, text text not null);
create table tweet_reply (id bigint primary key, tweet_id bigint not null,
                          text text not null);
insert into tweet values (1, 'hello');

select tweet.text, tweet_reply.text
  from tweet left join tweet_reply on tweet_reply.tweet_id = tweet.id;

 text  | text
-------+------
 hello |          -- tweet_reply.text is NULL

PostgreSQL returns NULL for tweet_reply.text. sqlx reports
nullable: [true, false], which says that tweet.text is the nullable column
and that tweet_reply.text cannot be NULL. Both answers are wrong.

The plan shows why. The planner commuted the LEFT JOIN into a Right join
node:

Hash Join  | Join Type: Right           | Output: [tweet.text, tweet_reply.text]
  Seq Scan | Parent Relationship: Outer | Output: [tweet_reply.id, ..., tweet_reply.text]
  Hash     | Parent Relationship: Inner | Output: [tweet.text, tweet.id]

visit_plan marks the child that carries Parent Relationship: Inner, which is
the Hash node over tweet. Under Join Type: Right that child is the
preserved side, and the Outer child is the null-extended side.

Parent Relationship gives a child's position, not the preserved side.
ExplainNode labels the left child Outer and the right child Inner for
every node type. Join Type is what names the preserved side.

Second fault: visit_plan descends only through join nodes. A Limit, a
Sort, an Aggregate, or a plain inner join above an outer join ends the walk,
and no column is marked at all. This fault also applies to a Left join node, so
select t.text, r.text from tweet t left join tweet_reply r on ... limit 5
reports both columns as NOT NULL.

Fix

visit_plan now passes a null_extended flag to the null-extended input of
each outer join, and visits every child.

Testing

I added four cases to test_describe_outer_join_nullable, and each one fails
before the fix. Every case asserts the preserved side as well as the
null-extended side. The limit case and the order by case are the same query,
and the planner gives them opposite join types, so the pair covers a Left node
and a Right node. CI covers PostgreSQL 13 and 17. I also ran 14, 15, 16 and 18
locally.

I also checked both repros from #367. On the schema in the original report,
describe now marks c2.color_uuid as nullable, which removes the
UnexpectedNullError that report shows, and it stops marking the preserved
o.object_uuid as nullable. On the repro in the most recent comment,
describe now reports [false, true] instead of [true, false].

heios added 2 commits August 14, 2026 05:11
The planner commutes a plain `LEFT JOIN` into a `Right` join node, and it puts
non-join nodes such as `Limit` above joins. Nullability inference marks the
wrong side in the first shape, and marks nothing in the second shape.
PostgreSQL defines `JOIN_RIGHT` as "pairs + unmatched RHS tuples", so the Outer
child of a `Right` join node is the null-extended side, not the Inner child.

`visit_plan` now passes a `null_extended` flag to the null-extended input, and
visits every child, so a node such as `Limit` no longer ends the walk.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[query macros] faulty null inference involving left joins in Postgres

1 participant