Repository navigation
fix(postgres): trust built-in rules where operand types resolve them - #112
Conversation
|
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
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
cb9f87c to
108747e
Compare
|
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
|
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.
108747e to
805685e
Compare
There was a problem hiding this comment.
ℹ️ 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: addedExpr::CallandExpr::Trusted, so operators, functions, casts and WHERE narrowings over shadowed names resolve against the leaf probe's types.builtin_claimfollowscall_unfilteredand is stricter where they differ.+ - * / %are now typed throughTy::Arith. - Rewritten
TRUST_QUERY: every operator and type ispg_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, intrust_outer_join. Added one sentence to the docs.
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)) |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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.

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 ownlower,upper,length,round,sum,avg,maxandmin. The trust check (TRUST_QUERY) distrusted a name everywhere once any user overload of it existed, so aftercreate extension vectoralone, statements over plain built-in columns lost their types:where id = 5on a bigint keyage + 1,-agenumbernumber | nullnumberemail || '!'stringstring | nullstringage > 3,email = 'x'booleanboolean | nullbooleanwhere name = :n, read backstringstring | nullstringsum(age) … group by emailbigintbigint | nullbigintCHECK (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:can_coerce_typedoes. The server's ranking of candidates is never modelled.An operand of an extension's own type (a
vectororcitextcolumn) 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 aCREATE CASTcould take), and it calls only exact matches of non-polymorphic built-ins, so no user operator, cast or overload created inpg_catalogcan change its rows. A test with hostile overloads of each gives identical rows.Checked by:
trust_domains,trust_witness_casts,trust_type_names,trust_search_path,trust_function_named_domain,trust_function_named_type,trust_cast_takeover,trust_version_castandtrust_catalog_overloads, besidetrust_extensionsandtrust_user_operators. Every user function in them answers NULL, so a wrong trust fails at run time.mainitself is unsound on 108 of them, with a user implicit cast function between built-in types, which this fixes. So ismain's count with a type namedcount.--strict-coverage, 141 cases. One existing line changes:trust_outer_join/builtin_equality.sql, whereu.email = 'x'is an exact built-in=(text, text)beside the case's user=(text, int4), so the left join is reduced andnameandemailare non-null.Some rules stay name-based (the JSON constructors,
unnest, array casts, JSON key reads), and withanoninstalled, its exact duplicates oflower,md5,nowandrandomkeep those nullable. The Postgres docs page says built-in calls keep their rules beside an extension's.