Skip to content

feat(postgres): infer more expressions non-null - #15

Merged
colinhacks merged 2 commits into
mainfrom
precision-rules
Oct 22, 2026
Merged

colinhacks merged 2 commits into
mainfrom
precision-rules

Conversation

@colinhacks

Copy link
Copy Markdown
Owner

Seven rules where the Postgres analysis was sound but wider than it needed to be. Each was measured against SafeQL's generator test suite, where it closes 29 of the 51 statements whose types differed, and each has statements in a new oracle case, oracle/cases/precision_rules, that the oracle runs over seeded edge values.

Statement Before Now
select first_name like 'A%' from member boolean | null boolean
select id & 3, array[id] @> array[1], to_jsonb(m) ? 'id' from member m each | null non-null
select coalesce(array_agg(id), array[]::int[]) from member number[] | null number[]
select m.id, b.first_name from member m left join member b on b.id = m.manager_id id: number | null id: number (first_name stays nullable)
select (select 1), (select count(*) from member limit 1) nullable non-null
select id from v_outer (a view over a view) number | null number
select g from generate_series(1, 3) as g, select unnest(array[[1, 2], [3, 4]]) nullable non-null
  • Operators (infer.rs): LIKE, ILIKE and SIMILAR TO (which the parse marks as kinds of their own), the regular expressions, and the bit, shift, containment, overlap and JSON key operators, plus the prefix |/, ||/, @ and ~, are non-null over non-null operands. Every built-in overload of each was read from pg_operator on PostgreSQL 18: each is strict and returns a value or raises for non-null input. # is left out, since line_interpt, lseg_interpt and box_intersect return NULL where nothing intersects, and so are ->, ->> and #>>. An operator a user or an extension defines under one of these names turns the rule off, as before.
  • Casts to an array type keep an array non-null.
  • A table read on both sides of an outer join: the describe names a column's table, never which reading of it, so every column used to be nullable. A top-level alias.column now names its reading (aliased_columns); everything else stays as it was.
  • Scalar subqueries: one with no FROM and no WHERE whose target calls only functions the rules know (so no set-returning function) is one row, and so is an aggregate kept by LIMIT n with n >= 1.
  • A view over a view: a probe of a view's statement now reads the catalog rows of the relations it names that the top statement does not, which gives it the inner view's definition.
  • Set-returning functions: generate_series and generate_subscripts give non-null values, and unnest(array[…]) does where every element written is non-null.
  • Docs: the Postgres page's nullability rules.

Five existing oracle cases were re-blessed, each now more precise and each checked: a deeply nested no-FROM subquery, generate_series in a derived table and beside an ordered-set aggregate, the preserved side of a self join, and a stored row read through a view.

Verified locally: the engine's native tests, every oracle case on Postgres 18.6, SQLite and MySQL 8.4 with --strict-coverage, the rebuilt packages/postgres/engine.wasm, nub run typecheck, the loader's tests with every lab installed, the package build and the docs site.

Copilot AI balanced review requested due to automatic review settings October 22, 2026 17:24
@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
reylo Ready Ready Preview Oct 22, 2026 7:49pm UTC

Request Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Three of the new rules claim non-null where Postgres returns NULL. I confirmed each against the engine's plan/resolve and on PostgreSQL 16. Details are inline.

Reviewed changes

I reviewed the whole PR: the seven precision rules in the Postgres engine, the new precision_rules oracle case, the five re-blessed cases and the docs.

  • Operators: the pattern matches, bit/shift/containment/overlap/JSON key operators and the prefix |/ ||/ @ ~ now prove non-null. All of them are still shadow-checked by name through trust() and trusted_operator.
  • Array casts: x::T[] now carries its operand's nullability.
  • Self joins: aliased_columns resolves a top-level alias.column to its reading when a table is repeated under an outer join.
  • Scalar subqueries: one_row handles a no-FROM select, and keeps_one_row lets an aggregate through a LIMIT n >= 1.
  • Nested views: probe prefetches the relations a view's statement reads.
  • Set-returning functions: generate_series and generate_subscripts join POSTGRES_TOTAL, and unnest(ARRAY[…]) reads its written elements.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread engine/postgres/src/infer.rs Resolved
Comment thread engine/postgres/src/infer.rs Resolved
Comment thread engine/postgres/src/infer.rs Resolved
Comment thread engine/postgres/src/infer.rs Resolved

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The rebase adds no new issues, but the four threads from the earlier review are still open. infer.rs and jsonshape.rs are byte-for-byte the same as before.

Reviewed changes

Since the last review, the branch was rebased onto 7994836, which brings in main's per-column own_provenance. I checked how the conflicts were resolved.

  • Merged the self-join reading with own_provenance: degraded is now hidden || !own for each column, and aliased_columns still runs only when there is no set operation. Without a set operation, own is all true, so the result matches the previously reviewed commit. With one, aliased is empty and main's rule decides.
  • Renamed the declared-nullability closure from own to declared, so it no longer clashes with main's own. The direct path and the reading path call it the same way.
  • Merged the docs: main's new bullet on with queries and derived tables is kept next to this PR's outer-join, operator, set-returning and scalar-subquery bullets.

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

- like, ilike, similar to, the regular expressions, and the bit,
  shift, containment, overlap and json key operators are non-null over
  non-null operands (not #, whose geometric overloads may be null)
- a cast to an array type keeps an array non-null: array[]::int[]
- a table read on both sides of an outer join is told apart by the
  alias a top-level column names
- a scalar subquery with no from, or an aggregate kept by limit n, is
  one row
- a view over a view keeps its columns' not null
- generate_series and unnest(array[...]) give non-null values
A scalar subquery's target must call no function or operator that may
return a set, since one can leave the row out. unnest(ARRAY[...]) needs
every element to be a scalar, not an array. An array cast checks the
array type's own cast and drops elements a JSON null cast makes NULL.
unnest is now asked of the catalog.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The four soundness threads from the earlier review are fixed and resolved, and cargo test -p reylo-engine-postgres --lib passes (144 tests) at beeeba4.

Reviewed changes

This run reviewed beeeba4, which narrows the three rules that claimed non-null where Postgres returns NULL and adds unnest to the trust check.

  • Split the one-value check into one_value: one_row (no FROM) and keeps_one_row (the aggregate path, in both infer.rs and jsonshape.rs) now both reject a target that calls a set-returning function, a function the rules don't know, or an operator outside the built-in lists. Aggregate arguments, ORDER BY and FILTER are not walked, which is sound because Postgres refuses a set-returning call there.
  • Guarded the unnest(ARRAY[…]) elements with POSTGRES_SCALARS: an element of an array type, or of a type the build can't name, no longer proves the result non-null.
  • Tightened array casts: the rule now needs trust.cast("_T") as well as trust.cast("T"). On PostgreSQL 18, elements_not_jsonb wraps each built element of a number or boolean array cast in NotJsonb, and it unwraps the same variants that items_nullable does.
  • Added POSTGRES_SET_RETURNING to postgres_function_names: a user's or an extension's unnest now turns the rule off.
  • Added oracle statements and re-blessed precision_rules: array_cast_elements, scalar_set_returning, unnest_array_element and unnest_column_element are new, and the docs bullets are updated to match.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@colinhacks
colinhacks merged commit 0ac13f3 into main Oct 22, 2026
4 checks passed
@colinhacks
colinhacks deleted the precision-rules branch October 22, 2026 20:03
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.

2 participants