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 {"props":{"docsUrl":"https://docs.github.com/get-started/accessibility/keyboard-shortcuts"}}
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
852773d
fix(postgres): trust a shadowed name where its operand types resolve it
colinhacks Oct 23, 2026
90511be
fix(postgres): reduce an outer join by a test its operand types trust
colinhacks Oct 23, 2026
7139f63
fix(postgres): trust a cast a user's cast function cannot be the one of
colinhacks Oct 23, 2026
971365c
docs(site): say an extension leaves built-in calls the built-in's
colinhacks Oct 23, 2026
5518f9c
fix(postgres): match a user's overload on a domain by its base type
colinhacks Oct 23, 2026
c5a002f
fix(postgres): check a test's cast against the cast targets
colinhacks Oct 23, 2026
331b070
fix(postgres): read no aggregate where a type has the function's name
colinhacks Oct 23, 2026
8fe851f
fix(postgres): read the trust query's operators from pg_catalog alone
colinhacks Oct 24, 2026
cade22b
test(oracle): run the user-operator case with public before pg_catalog
colinhacks Oct 24, 2026
81dc959
refactor(postgres): drop the trust check's unread names
colinhacks Oct 24, 2026
9163d02
fix(postgres): read no one-argument call as the built-in beside its type
colinhacks Oct 24, 2026
c3055c8
fix(postgres): convert nothing in the trust query a user's cast may take
colinhacks Oct 24, 2026
92e8ac5
fix(postgres): read the server's version in the trust query by function
colinhacks Oct 24, 2026
47dccd5
fix(postgres): keep count(*) an aggregate beside a type named count
colinhacks Oct 24, 2026
805685e
fix(postgres): call no polymorphic built-in in the trust query
colinhacks Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions engine/postgres/src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ pub enum Filled {
Position(usize),
}

/// One strict test that took a table off [`Analysis::nullable_tables`]: the column of it the test
/// is strict in, as written, and the operators, functions and casts its strictness rests on, with
/// their operands, which say for which types a user's of such a name is not the one.
#[derive(Debug, Clone, PartialEq)]
pub struct Witness {
pub column: Vec<String>,
pub(crate) calls: Vec<crate::infer::StrictCall>,
}

impl From<crate::infer::Strict> for Witness {
fn from(s: crate::infer::Strict) -> Witness {
Witness { column: s.column, calls: s.calls }
}
}

#[derive(Debug, Clone)]
pub struct Analysis {
pub kind: StatementKind,
Expand Down Expand Up @@ -65,11 +80,11 @@ pub struct Analysis {
pub nullable_tables: Vec<String>,
/// The tables a strict top-level WHERE, or a strict ON conjunct of an inner join above the
/// outer join that nulls them, took off `nullable_tables`, each with every witness that did:
/// the names of the operators, functions and casts its strictness rests on (none for `IS NOT
/// NULL`; see `infer::Strict`). A test proves the joined row exists only when those are the
/// built-in ones, which the parse alone cannot know: a host with the catalog's word puts back
/// a table no witness holds for.
pub reductions: Vec<(String, Vec<Vec<String>>)>,
/// the operators, functions and casts its strictness rests on (none for `IS NOT NULL`; see
/// `infer::Strict`). A test proves the joined row exists only when those are the built-in
/// ones, which the parse alone cannot know: a host with the catalog's word puts back a table
/// no witness holds for.
pub reductions: Vec<(String, Vec<Witness>)>,
/// Each WITH query's name beside every relation it reads, through the WITH queries it reads
/// in turn. The describe traces a column read through the query to its table, so an outer
/// join that nulls the query nulls those tables too ([`Analysis::nulled_tables`]).
Expand Down Expand Up @@ -324,7 +339,7 @@ fn analyze_here(sql: &str) -> Result<Analysis, AnalyzeError> {
// qualifier is that one occurrence's name in the top-level FROM: a test of `a.id` in a
// self-join, or of a subquery that goes by the table's name, says nothing of the occurrence
// the join nulls.
let mut reductions: Vec<(String, Vec<Vec<String>>)> = Vec::new();
let mut reductions: Vec<(String, Vec<Witness>)> = Vec::new();
let &str| occurrences.get(rel) == Some(&1);
if let NodeEnum::SelectStmt(s) = top {
if let Some(w) = s.where_clause.as_deref() {
Expand All @@ -334,10 +349,10 @@ fn analyze_here(sql: &str) -> Result<Analysis, AnalyzeError> {
if !once(rel) {
return true;
}
let witnesses: Vec<Vec<String>> = strict
let witnesses: Vec<Witness> = strict
.iter()
.filter(|st| matches!(st.column.as_slice(), [q, _] if aliases.get(q).is_some_and(|r| r == rel)))
.map(|st| st.names.clone())
.map(|st| Witness::from(st.clone()))
.collect();
if witnesses.is_empty() {
return true;
Expand Down Expand Up @@ -534,7 +549,7 @@ fn alias_map(from: &[protobuf::Node]) -> HashMap<String, String> {
/// row they return, though an outer join among them nulls it: the witnesses of the inner join's
/// ON conjuncts strict in it that reject its null extension last (`None` when no outer join
/// below nulls it, or when one nulls it again above).
fn reduced_by_joins(item: &protobuf::Node, rel: &str) -> Option<Vec<Vec<String>>> {
fn reduced_by_joins(item: &protobuf::Node, rel: &str) -> Option<Vec<Witness>> {
/// The joins from `node` down to the relation, each with whether the relation is on its
/// right side, and the relation's alias.
fn path<'a>(node: &'a protobuf::Node, rel: &str, out: &mut Vec<(&'a protobuf::JoinExpr, bool)>) -> Option<String> {
Expand All @@ -557,7 +572,7 @@ fn reduced_by_joins(item: &protobuf::Node, rel: &str) -> Option<Vec<Vec<String>>
let mut joins = Vec::new();
let alias = path(item, rel, &mut joins)?;
let mut nulled = false;
let mut witnesses: Vec<Vec<String>> = Vec::new();
let mut witnesses: Vec<Witness> = Vec::new();
for (join, right) in joins.iter().rev() {
let kind = join.jointype;
if (kind == JoinType::JoinLeft as i32 && *right) || (kind == JoinType::JoinRight as i32 && !*right) || kind == JoinType::JoinFull as i32 {
Expand All @@ -566,14 +581,14 @@ fn reduced_by_joins(item: &protobuf::Node, rel: &str) -> Option<Vec<Vec<String>>
continue;
}
if nulled && kind == JoinType::JoinInner as i32 && join.using_clause.is_empty() && !join.is_natural {
let strict: Vec<Vec<String>> = join
let strict: Vec<Witness> = join
.quals
.as_deref()
.map(crate::infer::strict_in)
.unwrap_or_default()
.into_iter()
.filter(|st| matches!(st.column.as_slice(), [q, _] if *q == alias))
.map(|st| st.names)
.map(Witness::from)
.collect();
if !strict.is_empty() {
nulled = false;
Expand Down
50 changes: 39 additions & 11 deletions engine/postgres/src/cardinality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@

use crate::dialect::Dialect;
use crate::infer::{self, Trust};
use crate::resolution::{Kind, Operand};
use crate::jsonshape::last_string;
use crate::levels::{Columns, Level, Withs};
use crate::pgtypes::oid;
Expand Down Expand Up @@ -133,6 +134,14 @@ pub struct Catalog {
/// Whether a user or an extension defined an operator named `=`, which may then be the one a
/// comparison of other types than the key column's resolves to.
pub equality_shadowed: bool,
/// What the trust check says of the overloads of the names a user also defined, which says
/// for which operand types such an `=` still resolves to the built-in.
pub overloads: Option<crate::resolution::Shared>,
/// Per relation as named in `column_types`, its columns whose type is a domain, by name.
pub domains: std::collections::HashMap<String, HashSet<String>>,
/// The base types of every such column: a column of one of these types, as `column_types`
/// gives it, may be one of a domain.
pub domain_bases: HashSet<u32>,
/// Per relation, the events (`TRIGGER_ROW_INSERT`, `_UPDATE`, `_DELETE` as bits) for which it,
/// or a partition or an inheritance child of it, has an enabled row trigger that runs BEFORE
/// the change or INSTEAD of it. Such a trigger can return NULL and skip the row, so an INSERT
Expand Down Expand Up @@ -276,15 +285,15 @@ pub const UNIQUE_KEYS_QUERY: &str = concat!(

/// `$1` is `text[]` of relation names as the statement writes them: every column of each, `(the
/// name, column names, their type oids, whether each one's collation is deterministic, whether the
/// relation is a sequence)`, a domain's column by its base type, whose equality it uses. What the
/// other side of an equality is when it is a column (`u.id = p.author_id`), which relation a bare
/// column belongs to, whether a column's equality is identity, and whether the relation holds
/// exactly one row.
/// relation is a sequence, their declared type oids)`, a domain's column by its base type, whose
/// equality it uses, and then by the domain. What the other side of an equality is when it is a
/// column (`u.id = p.author_id`), which relation a bare column belongs to, whether a column's
/// equality is identity, and whether the relation holds exactly one row.
pub const COLUMNS_QUERY: &str = concat!(
"SELECT x.name, array_agg(a.attname::text ORDER BY a.attnum), \
array_agg((CASE WHEN t.typtype = 'd' THEN t.typbasetype ELSE a.atttypid END)::int8 ORDER BY a.attnum), \
array_agg(coalesce((SELECT o.collisdeterministic FROM pg_collation o WHERE o.oid = a.attcollation), true) ORDER BY a.attnum), \
bool_and(c.relkind = 'S') \
bool_and(c.relkind = 'S'), array_agg(a.atttypid::int8 ORDER BY a.attnum) \
FROM unnest($1::text[]) AS x(name) \
JOIN pg_class c ON c.oid = ",
relation_named!("x.name"),
Expand Down Expand Up @@ -438,7 +447,7 @@ fn always_true(quals: &protobuf::Node, catalog: &Catalog) -> bool {
};
match quals.node.as_ref() {
Some(NodeEnum::AConst(c)) => !c.isnull && matches!(&c.val, Some(protobuf::a_const::Val::Boolval(b)) if b.boolval),
Some(NodeEnum::AExpr(e)) if is_equality(e) && e.name.len() == 1 && !catalog.equality_shadowed => match (e.lexpr.as_deref().and_then(integer), e.rexpr.as_deref().and_then(integer)) {
Some(NodeEnum::AExpr(e)) if is_equality(e) && e.name.len() == 1 && catalog.equality(Operand::Is(oid::INT4), Operand::Is(oid::INT4)) => match (e.lexpr.as_deref().and_then(integer), e.rexpr.as_deref().and_then(integer)) {
(Some(l), Some(r)) => l == r,
_ => false,
},
Expand Down Expand Up @@ -1450,9 +1459,10 @@ fn operator_names(aexpr: &Value) -> Vec<String> {
}

/// Whether an operator is one of the built-in spellings the trust check asks about, and no user
/// or extension defined one of that name: the built-in operators are never volatile.
/// or extension defined one of that name that is volatile: the built-in operators never are.
fn trusted_operator(name: &str, catalog: &Catalog) -> bool {
(infer::POSTGRES_OPERATORS.contains(&name) || infer::POSTGRES_STRICT_OPERATORS.contains(&name)) && !catalog.user_operators.contains(name)
(infer::POSTGRES_OPERATORS.contains(&name) || infer::POSTGRES_STRICT_OPERATORS.contains(&name))
&& (!catalog.user_operators.contains(name) || catalog.overloads.as_ref().and_then(|o| o.0.operator_volatile(name)) == Some(false))
}

/// Whether a cast may call a user's cast function: one to a type outside the built-in targets no
Expand Down Expand Up @@ -2030,7 +2040,9 @@ fn catalog_name(rv: &protobuf::RangeVar) -> String {
/// So the value's type must be one the column's operator family compares as it is
/// ([`compatible`]). A literal or a parameter the server types from the column adapts to it.
/// - Where a user or an extension defined an operator named `=`, a comparison across two types may
/// resolve to theirs, so only a value of the column's own type, or one that adapts to it, counts.
/// resolve to theirs, so only a value of the column's own type, or one that adapts to it, counts,
/// or one of a type whose comparison with the column's resolves to the built-in `=`
/// ([`crate::resolution`]): a column of a domain, or a value that may be one, proves nothing so.
/// - A value whose type the build cannot name counts only for a column whose type no implicit cast
/// maps to a wider one with loss ([`ANY_VALUE_TYPES`]). A column, or a scalar subquery's
/// column, on the other side has the type `type_of` gives it, from the catalog.
Expand All @@ -2043,11 +2055,27 @@ fn equality_bounds(catalog: &Catalog, relation: &str, column: &str, value: &prot
let Some(column_type) = catalog.column_types.get(relation).and_then(|types| types.get(column)).copied() else { return true };
match value_type(value, catalog, type_of) {
ValueType::Adapts => true,
ValueType::Is(t) => t == column_type || (!catalog.equality_shadowed && compatible(column_type, t)),
ValueType::Is(t) => {
let domain = catalog.domains.get(relation).is_some_and(|d| d.contains(column));
let operand = |t: u32, domain: bool| if domain { Operand::Opaque } else { Operand::Is(t) };
let value = match value.node.as_ref() {
Some(NodeEnum::ParamRef(_)) => Operand::Param(Some(t)),
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.

}
ValueType::Unnamed => !catalog.equality_shadowed && ANY_VALUE_TYPES.contains(&column_type),
}
}

impl Catalog {
/// Whether `=` over operands of these types can only resolve to the built-in one.
fn equality(&self, left: Operand, right: Operand) -> bool {
!self.equality_shadowed || self.overloads.as_ref().is_some_and(|o| o.0.resolve(Kind::Infix, "=", &[left, right]).is_some())
}
}

/// What the build knows of a compared value's type.
enum ValueType {
/// The server types it from the column it is compared with: a string literal, an uncast
Expand All @@ -2062,7 +2090,7 @@ fn value_type(value: &protobuf::Node, catalog: &Catalog, type_of: &dyn Fn(&proto
Some(NodeEnum::ColumnRef(_)) | Some(NodeEnum::SubLink(_)) => type_of(value).map_or(ValueType::Unnamed, ValueType::Is),
Some(NodeEnum::AConst(c)) => match &c.val {
Some(protobuf::a_const::Val::Ival(_)) => ValueType::Is(oid::INT4),
Some(protobuf::a_const::Val::Fval(f)) => ValueType::Is(if f.fval.parse::<i64>().is_ok() { oid::INT8 } else { oid::NUMERIC }),
Some(protobuf::a_const::Val::Fval(f)) => crate::infer::postgres_number(&f.fval).map_or(ValueType::Unnamed, ValueType::Is),
Some(protobuf::a_const::Val::Boolval(_)) => ValueType::Is(oid::BOOL),
_ => ValueType::Adapts,
},
Expand Down
Loading
Loading
0