8000 feat(postgres): infer the result key of a statement by colinhacks · Pull Request #160 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

feat(postgres): infer the result key of a statement - #160

Merged
colinhacks merged 6 commits into
mainfrom
result-key
Nov 3, 2026
Merged

colinhacks merged 6 commits into
mainfrom
result-key

Conversation

@colinhacks
@colinhacks colinhacks commented Nov 3, 2026 • 8000
Copy link
Copy Markdown
Owner

Reylo now infers a statement's result key on Postgres: the output columns that identify each row of the result, so that no two rows of a result agree on all of them (a NULL counting as equal to another NULL). Anything that compares two results of the same query can match each row to its earlier version by them, telling a row that changed in place from one row going and another coming.

The analysis carries it as key, and every built module exposes it on its Standard Query object as query["~standard"].query.key, for example ["id"]. It is present only for a result that can hold more than one row, and absent where the cardinality is one, maybeOne or none, and where nothing proves a key, which is always sound. MySQL, SQLite and DuckDB report none yet.

What is a key

  • A table's primary key or unique key, every column selected unchanged and none of them nullable, through joins that add one row at most per row of that table (as the cardinality proof reads them: views, with queries, subqueries and partial unique indexes the where restricts to included). Where each FROM item's key is selected, the keys together.
  • The group by columns that are output columns (not under grouping sets, rollup or cube), and the distinct on columns; a group or distinct on value may be NULL.
  • The whole row of a distinct, or of a union, intersect or except without all; intersect and except keep their left side's keys.
  • A limit or offset keeps any key. A values list, a set-returning call in the select list, union all, or a key column whose name another output column shares leaves none.

A column of an array, JSON or composite type is never part of a key, since two of its values can read alike once decoded, and an intersect or except keeps its left side's keys only through columns neither side converts. Of the keys proven, the one of fewest columns is reported, the earliest columns breaking a tie.

The Vercel preview fails on every main commit since #152, not because of this change.

Checks

  • The oracle checks every run of every statement in oracle/cases against the key: no two rows of a result may agree on all of its columns. The replay of each database's own test suite (--replay) records only row counts and NULLs, so it does not check keys yet. A new case, oracle/cases/result_key, covers each rule, and every existing case's expected.txt prints the key where one is proven ( key (id)).
  • Unit tests in engine/postgres/src/cardinality.rs; a loader test that a built module's Standard Query carries the key, on pg and on node:sqlite.
  • The rule is documented under Cardinality on the Postgres page, with the skill pointing to it.

The analysis carries `key`: the output columns that identify each row
of the result, no two rows agreeing on all of them. Empty for one row
at most, absent where nothing proves one; MySQL, SQLite and DuckDB
report none yet. The oracle checks every run's rows against it.
A built module carries the analysis's result key, and its Standard
Query object lists it as `key`: the output columns that identify each
row, which a driver comparing two results reads to tell a row changed
in place. Absent where no key is proven.
Copilot AI balanced review requested due to automatic review settings November 3, 2026 00:10
@vercel
vercel Bot commented Nov 3, 2026 •
Copy link
Copy Markdown

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

Project Deployment Actions Updated
reylo Error Error Nov 3, 2026 12:29am UTC

Request Review

@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 critical issues. Minor suggestions only.

Reviewed changes

Reviewed the whole PR: Postgres now infers a result key, which reaches the oracle, the loader and the Standard Query object.

  • Key inference — cardinality::result_key collects candidate keys from several sources: set operations without all, the left side of intersect/except, group by, distinct/distinct on, the FROM's unique keys (non-null only), and every FROM item's key together. It reports the shortest and drops any key whose column name is repeated in the output. I checked these against the unique-key catalog query, which already excludes deferrable constraints, inheritance parents and operator classes or collations whose equality differs. That covers what the key needs to be sound.
  • Gating — postgres.rs computes the key only for a select whose cardinality is many. MySQL, SQLite and DuckDB report key: None.
  • Oracle — seed.rs::key_repeats fails a case run where two rows match on every key column. About 40 expected.txt files are re-blessed, and a new result_key case covers each rule. I checked the less obvious re-blessed keys (alias_column_list_shifted, using_merged, right_join_nulled, with_shadow, star_joined) against their SQL, and each is correct.
  • Run time — src/loader.ts writes key into defineQuery, and standardQuery exposes it as query["~standard"].query.key. A loader test covers it on pg and on node:sqlite.
  • Docs — a "Result key" paragraph with a captured example under Cardinality on the Postgres page, plus a bullet in the skill.

The failed Vercel check isn't caused by this PR: the Vercel deploy also fails on main (d3940d3) and on #154–#159.

ℹ️ --replay postgres never checks the key

oracle/src/replay/mod.rs checks each replayed statement for non-null columns and cardinality, but not the new key. It keeps only row counts and NULL flags, not row values. The Postgres regression suite is the oracle's broadest source of statements: inheritance trees, nondeterministic collations, odd types. As things stand, the key rules are checked only on the hand-written cases. CI doesn't run replay, so this doesn't block the PR, but the PR body's "checks every run of every statement" holds only for oracle/cases.

Technical details
# Replay does not check the result key

## Affected sites
- oracle/src/replay/mod.rs:~81 — the replayed result's check covers `nullable` and `cardinality`, not `analysis["key"]`

## Required outcome
- When an analysis carries `key`, a replayed Postgres statement fails as a contradiction if two of its rows match on every key column, a NULL equal to another NULL. This is the rule `seed.rs::key_repeats` applies.

## Suggested approach (optional)
- Have the replay runner keep each row's text values (or a hash of the key columns), and reuse `key_repeats`.

ℹ️ StatementDescriptor in the spec has no key

spec/type-descriptor.ts's StatementDescriptor carries cardinality, which is the closest relative of the new fact, but it has no key. AGENTS.md says the engines' output follows the spec and that a change to the spec is a design decision. So it is worth deciding explicitly whether the result key belongs in the descriptor or stays specific to Reylo's Analysis.

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

Comment thread site/content/docs/postgres.mdx Resolved
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.

🟡 Changes recommended

Critical key-inference flaws report unique keys even when lossy set-operation conversions or result decoding collapse distinct values, making decoded-row matching unreliable.

2 open issues
What changed in this PR

This PR adds Postgres result-key inference to Reylo and exposes the inferred column names through Standard Query, allowing consumers to identify rows across query results.

Changes:

  • Infer result keys from unique constraints, joins, grouping, distinct queries, and set operations.
  • Carry optional key metadata from engine analysis into generated query modules.
  • Add oracle validation, regression cases, runtime assertions, and documentation.
File Description
tests/​standard-query.test.ts Test key exposure and omission.
src/​standard-query.ts Expose optional result-key metadata.
src/​loader.ts Emit inferred keys in generated modules.
src/​index.ts Pass query-definition keys to runtime modules.
src/​execute.ts Add keys to runtime module types.
src/​engine-core.ts Add keys to the analysis interface.
skills/​using-reylo/​SKILL.md Document result-key usage.
site/​lib/​sql-modules.json Capture the new documentation example.
site/​content/​docs/​postgres.mdx Explain result keys with an example.
oracle/​src/​seed.rs Check returned rows for repeated keys.
oracle/​src/​main.rs Print keys in oracle expectations.
oracle/​cases/​unique_keys/​expected.txt Record keys for unique-index cases.
oracle/​cases/​trust_key_operator_class/​expected.txt Record keys for operator-class cases.
oracle/​cases/​trust_catalog_exact_operands/​expected.txt Record the selected primary key.
oracle/​cases/​subquery_keys/​expected.txt Record keys propagated through subqueries.
oracle/​cases/​sequences/​expected.txt Record the shadowing query’s key.
oracle/​cases/​row_varying/​expected.txt Record grouping and distinct keys.
oracle/​cases/​row_values/​expected.txt Record the correlated query’s key.
oracle/​cases/​result_key/​view.sql Cover keys propagated through views.
oracle/​cases/​result_key/​values.sql Cover duplicate VALUES rows.
oracle/​cases/​result_key/​union.sql Cover UNION’s whole-row key.
oracle/​cases/​result_key/​union_all.sql Cover key omission for UNION ALL.
oracle/​cases/​result_key/​to_one_join.sql Cover key preservation across to-one joins.
oracle/​cases/​result_key/​to_many_join.sql Cover repeated keys across to-many joins.
oracle/​cases/​result_key/​to_many_both_keys.sql Cover combined join keys.
oracle/​cases/​result_key/​subquery.sql Cover grouped subquery keys.
oracle/​cases/​result_key/​star.sql Cover keys selected through a star.
oracle/​cases/​result_key/​star_joined.sql Cover duplicate output names in joins.
oracle/​cases/​result_key/​setup.sql Define result-key test tables and view.
oracle/​cases/​result_key/​set_returning.sql Cover key omission for row-expanding calls.
oracle/​cases/​result_key/​partial_unique.sql Cover selection among available unique keys.
oracle/​cases/​result_key/​partial_unique_unfiltered.sql Cover unrestricted partial-index rejection.
oracle/​cases/​result_key/​partial_unique_live.sql Cover filtered, non-null partial keys.
oracle/​cases/​result_key/​nullable_unique.sql Cover nullable unique-key rejection.
oracle/​cases/​result_key/​nullable_unique_filtered.sql Cover null-filtered unique keys.
oracle/​cases/​result_key/​limited.sql Cover key preservation through LIMIT.
oracle/​cases/​result_key/​limited_union.sql Cover limited UNION ALL without a key.
oracle/​cases/​result_key/​left_to_one.sql Cover preserved-side outer-join keys.
oracle/​cases/​result_key/​left_nullable_side.sql Cover nullable-side key rejection.
oracle/​cases/​result_key/​lateral.sql Cover combined lateral-join keys.
oracle/​cases/​result_key/​intersect_all.sql Cover left-key preservation through INTERSECT ALL.
oracle/​cases/​result_key/​group_by.sql Cover grouping-column keys.
oracle/​cases/​result_key/​group_by_position.sql Cover positional grouping keys.
oracle/​cases/​result_key/​group_by_nullable.sql Cover nullable grouping keys.
oracle/​cases/​result_key/​group_by_hidden.sql Cover unselected grouping columns.
oracle/​cases/​result_key/​expected.txt Record result-key regression expectations.
oracle/​cases/​result_key/​except_all.sql Cover left-key preservation through EXCEPT ALL.
oracle/​cases/​result_key/​distinct.sql Cover DISTINCT’s whole-row key.
oracle/​cases/​result_key/​distinct_on.sql Cover DISTINCT ON keys.
oracle/​cases/​result_key/​distinct_on_output_name.sql Cover DISTINCT ON output aliases.
oracle/​cases/​result_key/​cte.sql Cover keys propagated through WITH queries.
oracle/​cases/​result_key/​cross_join_one_side.sql Cover insufficient cross-join key selection.
oracle/​cases/​result_key/​composite_unique.sql Cover complete composite keys.
oracle/​cases/​result_key/​composite_pinned.sql Cover composite keys with fixed components.
oracle/​cases/​result_key/​composite_part.sql Cover incomplete composite-key rejection.
oracle/​cases/​result_key/​by_unique.sql Cover non-null unique keys.
oracle/​cases/​result_key/​by_primary_key.sql Cover selected primary keys.
oracle/​cases/​result_key/​by_key_lookup.sql Cover key omission for single-row lookups.
oracle/​cases/​result_key/​aggregate.sql Cover key omission for scalar aggregates.
oracle/​cases/​qualified_types/​expected.txt Record keys alongside qualified types.
oracle/​cases/​precision_rules/​expected.txt Record view and self-join keys.
oracle/​cases/​postgis_types/​expected.txt Record keys for spatial queries.
oracle/​cases/​pgvector_types/​expected.txt Record keys for vector queries.
oracle/​cases/​pg18_syntax/​expected.txt Record keys for virtual-column queries.
oracle/​cases/​params_once/​expected.txt Record keys for parameterized queries.
oracle/​cases/​param_keys/​expected.txt Record keys alongside parameter diagnostics.
oracle/​cases/​one_row_joins/​expected.txt Record the many-side join key.
oracle/​cases/​nullability/​expected.txt Record grouped aggregate keys.
oracle/​cases/​network_types/​expected.txt Record keys for network-type queries.
oracle/​cases/​narrowing_reach/​expected.txt Record keys after nullability narrowing.
oracle/​cases/​multirange_types/​expected.txt Record keys for multirange queries.
oracle/​cases/​limits/​expected.txt Record keys preserved by limits.
oracle/​cases/​large_queries/​expected.txt Record keys for complex queries.
oracle/​cases/​key_equality/​expected.txt Record keys for equality-sensitive cases.
oracle/​cases/​json_whole_rows/​expected.txt Record the grouped whole-row key.
oracle/​cases/​json_subqueries/​expected.txt Record keys alongside nested JSON results.
oracle/​cases/​json_schema_keys/​expected.txt Record the outer-join key.
oracle/​cases/​json_schema_checks/​expected.txt Record the selected document-table key.
oracle/​cases/​json_lateral_case/​expected.txt Record keys for lateral JSON queries.
oracle/​cases/​json_item_shapes/​expected.txt Record the aggregated WITH-query key.
oracle/​cases/​json_grouped_values/​expected.txt Record the positional grouping key.
oracle/​cases/​json_correlated/​expected.txt Record keys for correlated JSON queries.
oracle/​cases/​json_builders/​expected.txt Record aggregate and subquery keys.
oracle/​cases/​joins/​expected.txt Record distinct, grouping, and set-operation keys.
oracle/​cases/​inference_precision/​expected.txt Record keys alongside existing inference cases.
oracle/​cases/​index_keys/​expected.txt Record primary keys in index-filter cases.
oracle/​cases/​from_set_returning/​expected.txt Record the lateral aggregate key.
oracle/​cases/​from_functions/​expected.txt Record keys for function-join queries.
oracle/​cases/​edge_arithmetic/​expected.txt Record keys alongside arithmetic results.
oracle/​cases/​derived_tables/​expected.txt Record keys through derived-table queries.
oracle/​cases/​deep_statements/​expected.txt Record keys for supported deep statements.
oracle/​cases/​contrib_types/​expected.txt Record keys for extension-type queries.
oracle/​cases/​check_unions/​expected.txt Record the preserved outer-join key.
oracle/​cases/​builtin_functions/​expected.txt Record the grouped function-query key.
oracle/​cases/​arrays/​expected.txt Record keys alongside array results.
oracle/​cases/​aggregate_names/​expected.txt Record grouped and subquery keys.
oracle/​cases/​aggregate_levels/​expected.txt Record keys for nested aggregate queries.
engine/​sqlite/​src/​sqlite.rs Leave SQLite result keys absent.
engine/​postgres/​src/​postgres.rs Attach inferred keys to SELECT analysis.
engine/​postgres/​src/​extension.rs Accept keys from extension analysis.
engine/​postgres/​src/​cardinality.rs Implement key inference and unit coverage.
engine/​mysql/​src/​mysql.rs Leave MySQL result keys absent.
engine/​duckdb/​src/​duckdb.rs Leave DuckDB result keys absent.
engine/​core/​src/​api.rs Add optional serialized result keys.
engine/​core/​src/​analysis.rs Initialize fallback analysis without a key.

Review effort: Balanced

Comment thread engine/postgres/src/cardinality.rs Outdated
}
// A set operation's columns are its left side's, by position and by name.
if s.op == Op::SetopIntersect as i32 || s.op == Op::SetopExcept as i32 {
keys.extend(select_keys(left, trust, &catalog.scoped(&with_names(s), s.with_clause.as_ref(), None), columns));
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Right, and the cardinality proof's own set-operation keys had the same gap. Fixed in 47b1090: an INTERSECT or EXCEPT keeps its l 8000 eft side's keys only through columns where each side passes one table column through and both are of one type, so neither side is converted. That applies to the result key and to the keys the cardinality proof reads from a subquery. A non-ALL operation still has its whole-row key, since its rows are distinct under the result's own types. The regression is oracle/cases/result_key/except_all_converted.sql, now without a key, plus unit tests.

Comment thread engine/postgres/src/postgres.rs Outdated
Comment on lines +3335 to +3336
let columns: Vec<(String, bool)> = columns.iter().map(|c| (c.name.clone(), c.nullable)).collect();
cardinality::result_key(sql, trust, catalog, &columns).filter(|key| !key.is_empty())
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Agreed. Fixed in 47b1090: only a column whose type is a scalar or an enum can be part of a result key. An array, a JSON document or a composite can read two values alike once decoded (array bounds, large JSON numbers), so a key that needs one is dropped, and a whole-row key over one goes too. The other scalars decode to exact text, numbers or bigints. The regression is oracle/cases/result_key/array_unique.sql: a NOT NULL UNIQUE int[] gives no key. The docs page says so too.

A set operation's left side keeps its keys only through columns no side
converts to a common type, where two keys could become equal, in the
result key and in the cardinality proof alike. An array, JSON or
composite column is never part of a result key, since two of its
values can read alike once decoded.
@colinhacks
Copy link
Copy Markdown
Owner Author

On the replay note from the review: that's right. --replay records only row counts and NULL flags, so the key is checked only on oracle/cases for now, and the PR body now says so. Making the replay keep row values to check keys can come in a later change.

@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 critical issues. One minor note inline.

Reviewed changes

This run covers 47b1090, which keeps a result key only where its columns' values read apart.

  • identifies filter. postgres.rs::result_key now marks each output column as able to identify a row only if it is a Type::Scalar (other than record) or a Type::Enum. cardinality::result_key drops any key that uses another column, so arrays, JSON and composites are never key columns.
  • Set-operation conversions. The new unconverted keeps a left-side intersect/except key only where, at every key position, both sides pass through a table column of the same type. This applies to select_keys and also to set_keys, the cardinality proof's keys for a set-operation subquery. That also closes a soundness gap in the cardinality proof: a bigint key turned into float8 could match twice in a join. Wherever the check cannot tell (*, nested set operations, literals), it answers false, which is sound.
  • Oracle cases. Added array_unique.sql and except_all_converted.sql, plus unit tests for a literal and a column converted to float8.
  • Docs wording. The Postgres page and the skill now say Reylo "infers" a key and that array, JSON and composite columns are never part of one. This fixes the earlier wording thread, which is now resolved.

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

// A value read whole as a number, a string or an enum's label stays as distinct as the database
// keeps it; an array (`{1}` and `[0:0]={1}` read alike), a JSON document (two large numbers
// read alike) or a composite may not, so no key holds one.
let identifies: Vec<bool> = columns.iter().map(|c| matches!(c.ty, Type::Scalar { .. } | Type::Enum { .. }) && !matches!(&c.ty, Type::Scalar { name } if name == "record")).collect();
@pullfrog pullfrog Bot 10000 Nov 3, 2026
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.

On Bun.sql, a timestamp or timestamptz column does not stay as distinct as the database keeps it. Bun.sql hands both back as a Date, and instantOfDate/utcText (src/values.ts) can only rebuild them to the millisecond. So select distinct created_at from events (key (created_at)) can return two rows with the same created_at once two events fall within one millisecond. The oracle's key_repeats compares the server's text, so it won't catch this. One fix is to leave timestamp/timestamptz out of identifies. The cost is every timestamp key on every driver, so documenting the Bun.sql exception may be the better trade.

Technical details
# Timestamp keys can repeat on Bun.sql

## Affected sites
- engine/postgres/src/postgres.rs:3338 — `identifies` admits every `Type::Scalar`, `timestamp`/`timestamptz` included
- src/values.ts:297 (`instantOfDate`), src/values.ts:617 (`utcText`) — Bun.sql's `Date` values are rebuilt to millisecond precision

## Required outcome
- The comment's claim ("a value read whole … stays as distinct as the database keeps it") holds for every supported driver, or the docs and skill state the exception.

## Open questions for the human
- Drop timestamp keys everywhere, or document that on Bun.sql a key of a `timestamp`/`timestamptz` column with sub-millisecond values may repeat?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The key belongs to the Standard Query, whose decode takes each Postgres value as the text the server sends (decoder in src/standard-query.ts refuses anything else). A driver running it reads rows raw, Bun.sql included, so a timestamptz keeps its microseconds there and the key holds. The millisecond loss is in Bun.sql's own Date path under .run(), which is that driver's limit and applies to any comparison of its timestamps. I'm leaving timestamp keys in, since dropping them would cost every driver for one driver's .run() rows.

@colinhacks
colinhacks merged commit 2671fe7 into main Nov 3, 2026
14 of 15 checks passed
@colinhacks
colinhacks deleted the result-key branch November 3, 2026 00:41
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