8000 fix(engine)!: infer tighter types across joins, functions and writes by colinhacks · Pull Request #145 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

fix(engine)!: infer tighter types across joins, functions and writes - #145

Merged
colinhacks merged 22 commits into
mainfrom
engine-battery
Oct 29, 2026
Merged

colinhacks merged 22 commits into
mainfrom
engine-battery

Conversation

@colinhacks
@colinhacks colinhacks commented Oct 29, 2026 •
Copy link
Copy Markdown
Owner

Precision fixes for Postgres inference, found by running real-world queries (sqlc's Postgres test suite and examples, pgtyped's examples, Supabase's postgres-meta catalog queries) through the engine. Every change was checked by the oracle. Three of them correct types that could come back NULL where non-null was claimed.

Could be NULL, was typed non-null (now nullable)

  • Two set-returning calls in one select list run in step, and Postgres fills the shorter one with NULL.
  • row(a, b) = row(…) is non-null only when every field is.
  • current_schema() is NULL when no schema on the search path exists.

Narrowing

  • * and t.* are narrowed by the WHERE's null tests, as written-out columns are.
  • An inner join's own ON narrows the columns it tests, on the sides no outer join can null.
  • The WHERE of an UPDATE or DELETE narrows its RETURNING. This is skipped where a trigger, a rule, a SET or a generated column could change the value.
  • A WITH query read twice is nulled only where an outer join nulls that reading.
  • A recursive WITH query that uses t.*, SEARCH or CYCLE keeps its columns' types.

Functions

  • Built-ins and operators that are non-null on non-null input are now typed that way:
    • extract/date_part, but only for fields that grow with time; month of an infinite timestamp is still NULL;
    • at time zone, make_interval, substring, position, format;
    • in, between, = any(array[…]), is json, grouping();
    • the range tests, the advisory locks, version();
    • first_value over a frame that holds the current row, and ordered-set aggregates under GROUP BY.
  • Set-returning functions in FROM are typed by their rows: generate_series (with parameter bounds, and on the preserved side of a left join), jsonb_each, WITH ORDINALITY, json_table's for ordinality.
  • count(*) in a lateral subquery is non-null.
  • json_build_array, jsonb_build_array, JSON_ARRAY, JSON_OBJECT and array_to_json(array(…)) are typed. Core gains Shape::Tuple for them.

Parameters, lexer and cardinality

  • The lexer reads tags[:n] on Postgres as a slice, not a parameter (engine/core/src/lex.rs and src/lex.ts). Breaking: a query that subscripted by a parameter as tags[:i] now reads i as a column and fails its describe; write tags[(:i)]. The skill says so.
  • A write to a schema-qualified table (public.kv, or a table outside the search path) keeps its parameters' nullability, CHECKs and domains.
  • A parameter written to a column whose CHECK lists values is typed as that union.
  • A unique key compared with lower(:x) or :id::int + 1, or read through a view, bounds the rows to maybeOne. group by () with an aggregate is one.

Also in this PR

  • New oracle cases: narrowing_reach, builtin_functions, from_set_returning, json_builders, computed_keys. New statements in arrays, params_written and check_unions. Other cases' expected.txt changes are the narrower types above.
  • site/content/docs/postgres.mdx states the new rules, and the docs' captured types are updated.
  • The components are rebuilt.

extract and date_part of a field that grows with time, at time zone,
make_interval, substring by bounds, position, format, the range tests,
the advisory locks, the session functions, current_setting of one
argument, in, between, = any (array[...]), is json, grouping(),
first_value and last_value over a frame holding the current row, and
any_value and the ordered-set aggregates under a plain group by now
follow their inputs; system columns are the scanned row's own.

Two set-returning calls in one select list run in step and pad the
shorter with NULL, so neither is non-null there; two row constructors
compare field by field, NULL where a pair of fields is; and
current_schema is NULL where no schema of the search path exists.
A strict set-returning function returns no row for a NULL argument, so
generate_series is non-null whatever its arguments, and the columns of
json(b)_each, json(b)_array_elements, json(b)_object_keys and
regexp_split_to_table are settled from the SQL, lateral ones included.
A with ordinality number and a JSON_TABLE ordinal are never NULL, a
bare name among several FROM items is the function's where the tables
lack it, a function that reads the items before it is probed over them,
and a LATERAL subquery that reads them is read by the rules alone.
# Conflicts:
#	engine/postgres/src/postgres.rs
json_build_array and jsonb_build_array are typed as a tuple of their
items, JSON_ARRAY as one too, or as an array of its items where a NULL
one may be left out, and JSON_OBJECT of literal keys as an object.
array_to_json of ARRAY(SELECT ...) is an array of the subquery's
values, and of array_agg as json_agg is. Each was unknown before.
# Conflicts:
#	engine/postgres/src/infer.rs
#	engine/postgres/src/postgres.rs
# Conflicts:
#	packages/duckdb/engine/engine.core.wasm
#	packages/mysql/engine/engine.core.wasm
#	packages/postgres/engine/engine.core.wasm
#	packages/sqlite/engine/engine.core.wasm
Copilot AI balanced review requested due to automatic review settings October 29, 2026 20:53
@vercel
vercel Bot commented Oct 29, 2026 •
Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
reylo Ignored Ignored Preview Oct 29, 2026 9:43pm 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 insufficient remaining quota.

@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.

ℹ️ One soundness gap in the new rule for set-returning calls that run in step, plus a note on the lexer change.

Reviewed changes

All 152 files at 2ffa02e: Postgres inference, the core lexer and its TypeScript port, catalog queries, oracle cases, docs and the rebuilt components.

  • Lexer: on Postgres, a colon right after a subscript's [ now starts a slice (tags[:n]), in lex.rs and src/lex.ts alike.
  • Narrowing: * and t.* expand to qualified references that the WHERE narrows. An inner join's ON narrows the sides no outer join nulls. The WHERE of an UPDATE or DELETE narrows its RETURNING, guarded against rules, views, row triggers, assigned columns and generated columns.
  • Built-ins: new lists for date parts, the ordered-set aggregates, frame values, the series functions and calls with no argument. Also IN, BETWEEN, = ANY(ARRAY[…]), row comparisons field by field, IS JSON and GROUPING. CURRENT_SCHEMA is now nullable.
  • Set-returning calls in step: targets_lockstep and set_calls mark a built-in set-returning call nullable when the select list holds more than one. The JSON-shape builder does the same.
  • FROM items: columns are known without a probe for FROM_FUNCTIONS, JSON_TABLE and WITH ORDINALITY. Also new: LATERAL subqueries read by the rules, bare names among several items, the extra columns of SEARCH and CYCLE, and a WITH query nulled at each place it is read.
  • Parameters and cardinality: write targets keep their schema, and the catalog queries take a count and resolve names as written. A bare parameter written to a column whose CHECK lists strings is typed as that union. A query with no FROM but a WHERE is maybeOne, GROUP BY () is dropped, and lower(:x) and integer arithmetic match keys.
  • JSON shapes: Shape::Tuple for json[b]_build_array and JSON_ARRAY, plus JSON_OBJECT and array_to_json(ARRAY(…)) / array_to_json(array_agg(…)).

ℹ️ The subscript lexer change is visible to existing queries

On Postgres, a query written as select tags[:i] from t used to bind :i as a parameter. Now it reads i as a column and the result as a slice, so :i drops out of Params and the query fails its describe or changes type. This matches how Postgres reads the SQL, and postgres.mdx documents it. AGENTS.md asks for ! after the scope for a breaking change, and for the skill to be updated with any change users notice, so a maintainer should decide whether this needs either.

Technical details
# The subscript lexer change is visible to existing queries

## Affected sites
- engine/core/src/lex.rs:396 — `_ if last == Last::Subscript && dialect == Dialect::Postgres` now skips the colon
- src/lex.ts:51 — the same rule in the run-time lexer
- skills/using-reylo/SKILL.md — never mentions the `tags[(:i)]` form

## Required outcome
- A maintainer decides whether the PR title takes `!` (`fix(engine)!: …`) and whether the skill should note that a parameter used as a subscript is written `tags[(:i)]`.

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

Comment thread engine/postgres/src/infer.rs Resolved
A set-returning call in a SELECT's ORDER BY, DISTINCT ON or GROUP BY is
an entry of the same target list as the select list's, so Postgres runs
it in step with them and pads the shorter with NULL: SELECT
generate_series(1, 2) ORDER BY generate_series(1, 4) is NULL, NULL, 2,
1. Those clauses, and a window's definition, now count toward it.
@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 gap in the rule for set-returning calls that run in step is closed.

Reviewed changes

The commits since 2ffa02e: the fix for set-returning calls outside the select list, and the rebuilt Postgres component.

  • Set-returning calls in step: targets_lockstep now takes the statement and also counts calls in the SelectSt 8BD0 mt's sort_clause, distinct_clause, group_clause and window_clause. Because set_calls walks the JSON generically, SortBy and WindowDef nodes need no special handling. Over-counting (a repeated ORDER BY call, or a window definition) costs only precision.
  • JSON shapes: jsonshape::plan_with uses the same function, so a JSON field over a set-returning call is nullable under the same rule.
  • Tests: unit assertions in infer.rs and jsonshape.rs, plus four oracle statements in builtin_functions (set_returning_order_by, _distinct_on, _group_by, _order_by_json). Each one types g as int4?.
  • Components: rebuilt the Postgres component.

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

@colinhacks colinhacks changed the title fix(engine): infer tighter types across joins, functions and writes fix(engine)!: infer tighter types across joins, functions and writes Oct 29, 2026
@colinhacks
colinhacks merged commit 7fddec3 into main Oct 29, 2026
12 checks passed
@colinhacks
colinhacks deleted the engine-battery branch October 29, 2026 21:47
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

0