8000 fix(postgres): trust built-in rules where operand types resolve them by colinhacks · Pull Request #112 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

fix(postgres): trust built-in rules where operand types resolve them - #112

Merged
colinhacks merged 15 commits into
mainfrom
fix-operator-trust
Oct 24, 2026
Merged

colinhacks merged 15 commits into
mainfrom
fix-operator-trust

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

Installing almost any common extension switched off much of Postgres inference for the whole database. pgvector, PostGIS, citext, hstore, isn, ltree, cube and seg define their own =, <>, <, >, +, -, *, ||, ? or @>, and Neon's h3, ip4r, semver, unit, roaringbitmap, prefix, rdkit, hll and pgx_ulid their own lower, upper, length, round, sum, avg, max and min. The trust check (TRUST_QUERY) distrusted a name everywhere once any user overload of it existed, so after create extension vector alone, statements over plain built-in columns lost their types:

Statement No extension With pgvector, before After
where id = 5 on a bigint key maybeOne many maybeOne
age + 1, -age number number | null number
email || '!' string string | null string
age > 3, email = 'x' boolean boolean | null boolean
where name = :n, read back string string | null string
sum(age) … group by email bigint bigint | null bigint
a CHECK (status in ('a', 'b')) column "a" | "b" string "a" | "b"

With pgvector, with nine common extensions, and with every extension Supabase's and Neon's Postgres 17 images ship (76 and 83), every statement now types exactly as on a database with no extension.

The rule (engine/postgres/src/resolution.rs): a call of a name a user also defined still reads as the built-in only where the server's own resolution (binary_oper_exact, func_get_detail, func_match_argtypes) can pick nothing but a built-in for the operands' types:

  • The exact step: the operands are of built-in types the build can name (a parameter is checked both untyped and as its described type), a built-in has exactly those argument types, and no user overload has them, its domains read as their base types.
  • Or no user overload is a candidate: none that every operand converts to implicitly, with the conversions computed as can_coerce_type does. The server's ranking of candidates is never modelled.

An operand of an extension's own type (a vector or citext column) still stands down, as do a one-argument call of a name a user's type has (which may be a cast), a user's implicit cast function between built-in types, and a cast to a target a user's cast function has. The same check now governs WHERE narrowing, outer-join reductions, key equality for the cardinality, CHECK values and casts. A server that refuses the trust query leaves every name distrusted.

The trust query is one session-scoped query, as before (about 1,100 rows and 70 ms with nine extensions). Its operators are written OPERATOR(pg_catalog.…), its functions and types are qualified, it converts nothing through I/O (which a CREATE CAST could take), and it calls only exact matches of non-polymorphic built-ins, so no user operator, cast or overload created in pg_catalog can change its rows. A test with hostile overloads of each gives identical rows.

Checked by:

  • An adversarial review in three passes, each of which found counterexamples; every one is fixed and kept as an oracle case: trust_domains, trust_witness_casts, trust_type_names, trust_search_path, trust_function_named_domain, trust_function_named_type, trust_cast_takeover, trust_version_cast and trust_catalog_overloads, beside trust_extensions and trust_user_operators. Every user function in them answers NULL, so a wrong trust fails at run time.
  • A generated set of 3,106 expressions over 28 built-in column types, with user overloads of every shape (enums with implicit casts, arrays, polymorphic, defaults, variadic, implicit cast functions). Of these, 272 return NULL, and all are sound; main itself is unsound on 108 of them, with a user implicit cast function between built-in types, which this fixes. So is main's count with a type named count.
  • The whole oracle passes with --strict-coverage, 141 cases. One existing line changes: trust_outer_join/builtin_equality.sql, where u.email = 'x' is an exact built-in =(text, text) beside the case's user =(text, int4), so the left join is reduced and name and email are non-null.

Some rules stay name-based (the JSON constructors, unnest, array casts, JSON key reads), and with anon installed, its exact duplicates of lower, md5, now and random keep those nullable. The Postgres docs page says built-in calls keep their rules beside an extension's.

Copilot AI balanced review requested due to automatic review settings October 24, 2026 02:22
@pullfrog
pullfrog Bot commented Oct 24, 2026 •
Copy link
Copy Markdown
Contributor

No configured credential can serve this run. Your Claude Pro/Max subscription has hit its usage limit, which resets at 2026-10-26 08:00 UTC. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.

The providers' responses
CLAUDE_CODE_OAUTH_TOKEN: 429: This request would exceed your account's rate limit. Please try again later.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog 8000 | Using claude-opus-5-5

@vercel
vercel Bot commented Oct 24, 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 24, 2026 3:14am 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 commented Oct 24, 2026
Copy link
Copy Markdown
Contributor

No configured credential can serve this run. Your Claude Pro/Max subscription has hit its usage limit, which resets at 2026-10-26 08:00 UTC. Re-trigger after it resets, or add an API key for this provider, which Pullfrog falls back to automatically.

The providers' responses
CLAUDE_CODE_OAUTH_TOKEN: 429: This request would exceed your account's rate limit. Please try again later.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using claude-opus-5-5

An extension's operator or function of a name an inference rule reads
(pgvector's =, PostGIS's =, citext's max) made the rule stand down for
every call of the name. The trust query now reports each user overload
with the types it takes and the built-in types that convert to them, and
a call still reads as the built-in where an exact built-in match wins or
no user overload is among the candidates.
A strict WHERE or ON test reduces an outer join only where every name it
rests on is the built-in one. A test that calls a name a user also
defined now reduces it too where its operands' types, the column's from
the catalog, resolve the call to the built-in.
A cast to a built-in type that a user's cast function also targets
(PostGIS casts geometry to text, bytea and json) kept no nullability
from any source. It now keeps it where the operand is untyped or of a
built-in type that is none of those functions' sources. A CHECK's
strings read the printed text type by its name for the same reason.
The describe reports a column of a domain as of the domain's base type,
which the server matched as the domain: an overload of a user's on the
domain is then the exact match, not the built-in over the base type.
A strict test that reduced an outer join through a cast was resolved by
the shadowed names, which never hold a type's name, so a user's cast
function to the target passed for the built-in cast.
A call of one argument that no overload matches exactly is a cast where
a type has the function's name, which reads each row: a user's aggregate
of the name besides no longer makes the call one row.
The trust query built its rows with bare operators and functions, which
a search path naming pg_catalog after a user's schema resolves to the
user's: a text || of theirs answered every row with NULL tags, and no
name came back shadowed. Every operator, function and type it uses is
now pg_catalog's by name.
The case's = of two int8s now wins over the built-in at run time, so a
claim that trusted it would meet its NULL.
A strict claim and an outer join's witness carry the calls they rest on,
casts among them, and nothing reads their names any more; a call of no
arguments reaches a user's function of none through its own row.
A function named like a type is a cast to it where its one argument
matches no overload exactly. A column of a domain is described as its
base type, which may match exactly where the domain does not, so such a
call proves nothing whatever its argument's type reads as.
The trust query read a function's argument types through text and an
array cast, conversions no built-in pg_cast row makes, which a user's
CREATE CAST may take: one of text to int8 left every function with no
argument types. Each conversion is now one a built-in row makes.
A user's cast of text to int4, which no built-in pg_cast row stands in
the way of, answered the version test, so a server of 18 read as one
whose cast of a JSON null to a number raises, and the cast kept its
argument's nullability. The version is now read with to_number.
Only a call of one argument may be a cast to a type of the function's
name, so a call of none or several aggregates as before, and a name a
type alone has is the built-ins' at every other arity.
An overload created in pg_catalog of narrower types than a polymorphic
built-in, as unnest(oidvector), is a closer match of the trust query's
call and answered it: every function came back with no argument types.
Arrays are now read by subscript and built with ARRAY(SELECT ...), and
every call is an exact match of a built-in of no polymorphic argument.
@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 suggestion inline.

Reviewed changes

There was no earlier Pullfrog review, so this covers the whole PR at 805685e, all 15 commits.

  • Per-call resolution of shadowed names: added resolution.rs, which handles a call of a name a user also defined. Where the operands' types allow only a built-in, the call reads as the built-in. It models the exact match, an untyped operand taking the other's type, and implicit coercion, and it reads a parameter both untyped and typed.
  • New expression nodes in infer.rs: added Expr::Call and Expr::Trusted, so operators, functions, casts and WHERE narrowings over shadowed names resolve against the leaf probe's types. builtin_claim follows call_unfiltered and is stricter where they differ. + - * / % are now typed through Ty::Arith.
  • Rewritten TRUST_QUERY: every operator and type is pg_catalog-qualified, nothing converts through I/O, and no polymorphic built-in is called. If the server refuses the query, every name now counts as shadowed.
  • Outer joins, CHECK values and key equality: these now resolve per operand type (witness_holds, check_values, Catalog::equality). Domain columns are treated as of no type the build can name.
  • Oracle cases and docs: added eleven trust_* cases whose user functions return NULL. One existing expected line changed, in trust_outer_join. Added one sentence to the docs.

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

Some(NodeEnum::ColumnRef(_)) | Some(NodeEnum::SubLink(_)) => operand(t, catalog.domain_bases.contains(&t)),
_ => Operand::Is(t),
};
t == column_type || (compatible(column_type, t) && catalog.equality(operand(column_type, domain), value))
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.

t == column_type (and ValueType::Adapts two lines up) still pins the key without asking Catalog::equality. So a user's = over exactly the key's types still counts: for example public.=(int8, int8) under search_path = public, pg_catalog, which trust_user_operators creates. With that operator answering true, where id = 5::int8 or where id = :p returns every row, yet stays maybeOne. This predates the PR, but the PR now runs key equality through the resolution check. Running these two branches through it as well closes the hole and keeps the extension cases. pgvector's and PostGIS's = never take two built-in types, so (int8, int8) still resolves to the built-in there.

Technical details
# Same-type and untyped key equality skips the resolution check

## Affected sites
- engine/postgres/src/cardinality.rs:2057 — `ValueType::Adapts => true` pins without checking `=`.
- engine/postgres/src/cardinality.rs:2066 — `t == column_type ||` short-circuits before `catalog.equality(...)`.

## Required outcome
- A key compared with a value of its own type, or with a string literal or an untyped parameter, counts only where `=` over `(column, column)` resolves to the built-in (`Overloads::resolve` returns `None` for a user overload of exactly those types).
- A domain-typed key column (`catalog.domains`) reads as `Operand::Opaque` on these paths too.
- Extension-only databases (pgvector, PostGIS, citext) keep `trust_extensions/key_literal.sql` as `maybeOne`.

## Suggested approach
- `Adapts` → `catalog.equality(operand(column_type, domain), Operand::Unknown)`; the `t == column_type` branch → `catalog.equality(operand(column_type, domain), value)`.
- Add a statement to `oracle/cases/trust_user_operators/` such as `select name from t where id = :p`. Because `eq8` answers NULL, the oracle cannot see the wrong row count; a variant that answers `true` would show it.

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 as you say it predates this PR. It's the next change in this series: the follow-up resolves key equality through the same check, for both the untyped (ValueType::Adapts) and exact-type (t == column_type) shortcuts, with an oracle case for a user =(int8, int8) that the search path puts first.

@colinhacks
colinhacks merged commit d6d3386 into main Oct 24, 2026
7 checks passed
@colinhacks
colinhacks deleted the fix-operator-trust branch October 24, 2026 03:22
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