Skip to main content

boatramp_core/
orm.rs

1//! A typed query AST and an injection-safe SQL compiler — the backing for the `orm`
2//! handler binding (`boatramp:handlers/orm`).
3//!
4//! The compiler turns a typed [`Select`] / [`Insert`] / [`Update`] into a `?N`-placeholder
5//! SQL string plus its bound [`SqlValue`] parameters, in order. It is **pure** (no I/O, no
6//! wasm/wit deps) so it is fully unit-testable; the binding runs the result through the same
7//! [`crate::sql::SqlTransaction`] the raw `sql-query` binding uses, which rewrites `?N` to the
8//! engine's native dialect. That shares one execution + dialect substrate across bindings.
9//!
10//! # Expressiveness
11//! [`Expr`] is a recursive scalar expression (column, bound value, aggregate, arithmetic,
12//! a small allow-listed [`Func`] set, JSON key-path extraction, Postgres-only `pgvector`
13//! distance, and a narrow correlated roll-up — a filtered aggregate over one named table) and
14//! [`Predicate`] is a recursive boolean tree (`AND`/`OR`/`NOT` +
15//! comparisons/`BETWEEN`/`IN`/`LIKE`/`IS NULL`). Selects add joins, `GROUP BY`/`HAVING`,
16//! aliases, ordering and pagination; inserts/updates add `RETURNING`. General scalar
17//! subqueries (beyond the correlated roll-up), CTEs and window functions are deliberately out
18//! of scope — they go through the raw `sql-query` escape hatch.
19//!
20//! # Safety
21//! - **Every value binds as a parameter** (`?N`); no value is ever formatted into the SQL.
22//! - **Identifiers are validated** (`[A-Za-z_][A-Za-z0-9_]*`, optionally `table.column`) and
23//!   emitted unquoted — an identifier that isn't a plain name is rejected, so a column/table
24//!   name can't smuggle SQL. Function names come from the closed [`Func`] enum (never a
25//!   free string), so they can't inject either.
26//! - **UPDATE requires a filter** — an unbounded update is refused.
27//!
28//! # Isolation
29//! The project/database boundary is the caller's (the binding opens a per-project database).
30//! An optional per-query [`Scope`] (`column = value`) is the *in-site* row-tenancy seam: on a
31//! read/update it is conjoined into the `WHERE`; on an insert it is forced into every row. It is
32//! guest-declared here (the shim's `Scoped` model); a host-enforced-from-claims variant is a
33//! later enhancement (see plans/PLAN-orm-wit.md §4).
34
35use crate::sql::{Dialect, SqlValue};
36use crate::tenancy::ResolvedScope;
37
38// ---- expressions -----------------------------------------------------------
39
40/// An aggregate function.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Agg {
43    Count,
44    Sum,
45    Avg,
46    Min,
47    Max,
48}
49
50impl Agg {
51    fn keyword(self) -> &'static str {
52        match self {
53            Self::Count => "count",
54            Self::Sum => "sum",
55            Self::Avg => "avg",
56            Self::Min => "min",
57            Self::Max => "max",
58        }
59    }
60}
61
62/// An arithmetic operator (rendered parenthesized, so precedence is explicit).
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum BinOp {
65    Add,
66    Sub,
67    Mul,
68    Div,
69    Mod,
70}
71
72impl BinOp {
73    fn symbol(self) -> &'static str {
74        match self {
75            Self::Add => "+",
76            Self::Sub => "-",
77            Self::Mul => "*",
78            Self::Div => "/",
79            Self::Mod => "%",
80        }
81    }
82}
83
84/// An allow-listed, dialect-portable scalar function. A closed enum (not a free string) so a
85/// function name can never inject and only portable functions are reachable.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Func {
88    Lower,
89    Upper,
90    Length,
91    Trim,
92    Abs,
93    Round,
94    Coalesce,
95    /// `CURRENT_TIMESTAMP` (ANSI); takes no arguments.
96    Now,
97}
98
99impl Func {
100    /// The rendered SQL name, and the accepted argument arity as an inclusive `(min, max)`
101    /// where `max == None` means variadic.
102    fn spec(self) -> (&'static str, usize, Option<usize>) {
103        match self {
104            Self::Lower => ("lower", 1, Some(1)),
105            Self::Upper => ("upper", 1, Some(1)),
106            Self::Length => ("length", 1, Some(1)),
107            Self::Trim => ("trim", 1, Some(1)),
108            Self::Abs => ("abs", 1, Some(1)),
109            Self::Round => ("round", 1, Some(2)),
110            Self::Coalesce => ("coalesce", 2, None),
111            Self::Now => ("current_timestamp", 0, Some(0)),
112        }
113    }
114}
115
116/// A `pgvector` distance metric. A closed enum, so the rendered operator is a compiler
117/// constant (never a guest string) and can't inject. Postgres-only (see [`Expr::Distance`]).
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Metric {
120    /// Cosine distance (`<=>`).
121    Cosine,
122    /// Euclidean / L2 distance (`<->`).
123    L2,
124}
125
126impl Metric {
127    fn operator(self) -> &'static str {
128        match self {
129            Self::Cosine => "<=>",
130            Self::L2 => "<->",
131        }
132    }
133}
134
135/// The argument of a correlated roll-up ([`Expr::RelatedAggregate`]): `*` (only valid for
136/// `count`) or a single validated column. Deliberately not a full [`Expr`] — a correlated
137/// aggregate takes a column or `*`, nothing free-form.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum RelArg {
140    /// `count(*)`.
141    Star,
142    /// `agg(<column>)`.
143    Column(String),
144}
145
146/// A scalar expression: the leaf/branch type used in select lists, comparisons, `SET`,
147/// `GROUP BY`, `ORDER BY` and join conditions.
148#[derive(Debug, Clone, PartialEq)]
149pub enum Expr {
150    /// A column reference (`col` or `table.col`), validated + emitted unquoted.
151    Column(String),
152    /// A literal value — bound as a `?N` parameter, never formatted in.
153    Value(SqlValue),
154    /// `*`, valid only as the argument of `count(*)`.
155    Star,
156    /// An aggregate over an inner expression (use [`Expr::Star`] for `count(*)`).
157    Aggregate(Agg, Box<Self>),
158    /// A parenthesized binary arithmetic expression.
159    Binary(BinOp, Box<Self>, Box<Self>),
160    /// An allow-listed function call.
161    Func(Func, Vec<Self>),
162    /// Extract a text value from a JSON column by a key path (e.g. `["a", "b"]` ⇒ `$.a.b`).
163    /// Rendered per-dialect (SQLite/MySQL `json_extract`, Postgres `#>>`); each key is
164    /// validated as an identifier so the built path can't inject.
165    JsonExtract(Box<Self>, Vec<String>),
166    /// A `pgvector` distance between two vector expressions, rendered `(left <op> right)`.
167    /// **Postgres-only** — SQLite/MySQL have no vector type, so it fails closed
168    /// ([`OrmError::BadExpr`]); there is no correct portable fallback. Usable in a select
169    /// list and in `ORDER BY` (nearest-neighbour search).
170    Distance {
171        left: Box<Self>,
172        right: Box<Self>,
173        metric: Metric,
174    },
175    /// A vector literal — a bracketed float list (`[0.1, 0.2, …]`) bound as a `?N` parameter
176    /// and rendered `?N::vector`. The components are validated as finite numbers; the value
177    /// binds (never formatted in), so it can't inject. **Postgres-only.**
178    VectorLiteral(String),
179    /// A filtered aggregate over a *named* related table, rendered as a scalar subquery
180    /// `(SELECT agg(arg) FROM table WHERE <filter>)` — a correlated roll-up. The correlation
181    /// to the outer row lives in `filter` (e.g. `child.fk = parent.pk`); unlike a
182    /// `LEFT JOIN … GROUP BY` rewrite it never fans out, so several counts per row are just
183    /// several select-list entries. Everything reachable is closed/validated: a closed [`Agg`],
184    /// a [`RelArg`] column-or-`*`, an identifier-checked `table`, and a bound-parameter
185    /// predicate — no arbitrary nested `FROM`, which keeps it mechanically scopable. This is
186    /// the *only* subquery form; general scalar subqueries are deliberately not supported.
187    RelatedAggregate {
188        agg: Agg,
189        arg: RelArg,
190        table: String,
191        filter: Box<Predicate>,
192    },
193    /// A `CASE WHEN <pred> THEN <expr> … [ELSE <expr>] END` (parenthesized). Each branch's
194    /// condition reuses the predicate compiler (bound params). A boolean/comparison `ORDER BY`
195    /// term is expressed portably as `ORDER BY CASE WHEN <cond> THEN 0 ELSE 1 END`.
196    Case {
197        branches: Vec<(Predicate, Self)>,
198        otherwise: Option<Box<Self>>,
199    },
200    /// Extract a JSON value by a **dynamic/bound key**: `(base ->> key)` (key is an expression,
201    /// e.g. a bound param — `labels ->> ?`). Postgres + SQLite; MySQL fails closed (its `->>`
202    /// needs a `$.path`). Distinct from [`Expr::JsonExtract`], which takes a static key path.
203    JsonExtractDyn(Box<Self>, Box<Self>),
204    /// jsonb concat/merge `(left || right)` — **Postgres-only** (elsewhere `||` is string concat,
205    /// so it fails closed). Used for `col = col || ?::jsonb` merge updates.
206    JsonConcat(Box<Self>, Box<Self>),
207    /// A **scalar subquery over a named table**: `(SELECT <column> FROM <table> WHERE <filter>)`.
208    /// The narrow non-aggregate sibling of [`Expr::RelatedAggregate`] (single named table + a
209    /// bound-parameter predicate — mechanically scopable, no arbitrary nested FROM). Used as the
210    /// RHS of a comparison, e.g. `id = (SELECT head_version FROM pack WHERE …)`.
211    RelatedScalar {
212        column: String,
213        table: String,
214        filter: Box<Predicate>,
215    },
216    /// A host-resolved **"is this row the caller's own tenant?"** marker — a `0`/`1`-valued
217    /// expression the guest builds *without naming the tenant column* (which is host-injected and
218    /// hidden). During [`Select::force_scope`] it is lowered, using the same resolved scope the
219    /// tenant predicate uses, to `CASE WHEN (<col> IS NOT NULL AND <col> = <own>) THEN 1 ELSE 0 END`
220    /// — `1` for the tenant's own rows, `0` for the shared (`NULL`) baseline (or another tenant
221    /// under a cross-tenant `all` read). Its purpose is the base-vs-override read: sort the tenant's
222    /// override ahead of the shared base (`ORDER BY is_own DESC`) or select/filter on own-ness,
223    /// without a raw `ORDER BY (tenant_id IS NOT NULL)`. **Fails closed:** if no own-tenant scope is
224    /// applied (an unscoped/`disabled` function), it is never lowered and rendering it is an error.
225    IsOwn,
226}
227
228impl Expr {
229    /// Convenience: a column reference.
230    pub fn col(name: impl Into<String>) -> Self {
231        Self::Column(name.into())
232    }
233    /// Convenience: a bound literal.
234    pub fn val(v: impl Into<SqlValue>) -> Self {
235        Self::Value(v.into())
236    }
237}
238
239// ---- predicates ------------------------------------------------------------
240
241/// A comparison operator.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum CmpOp {
244    Eq,
245    Ne,
246    Lt,
247    Le,
248    Gt,
249    Ge,
250}
251
252impl CmpOp {
253    /// The SQL operator symbol (used by the compiler).
254    fn symbol(self) -> &'static str {
255        match self {
256            Self::Eq => "=",
257            Self::Ne => "<>",
258            Self::Lt => "<",
259            Self::Le => "<=",
260            Self::Gt => ">",
261            Self::Ge => ">=",
262        }
263    }
264}
265
266/// A recursive boolean predicate tree.
267#[derive(Debug, Clone, PartialEq)]
268pub enum Predicate {
269    /// `AND` of all children (an empty list is the always-true identity `1 = 1`).
270    And(Vec<Self>),
271    /// `OR` of all children (an empty list is the always-false identity `1 = 0`).
272    Or(Vec<Self>),
273    /// Negation.
274    Not(Box<Self>),
275    /// `<left> <op> <right>`.
276    Cmp { left: Expr, op: CmpOp, right: Expr },
277    /// `<expr> [NOT] BETWEEN <low> AND <high>`.
278    Between {
279        expr: Expr,
280        low: Expr,
281        high: Expr,
282        negated: bool,
283    },
284    /// `<expr> [NOT] IN (<values>)`. Empty `values` is the corresponding identity
285    /// (`1 = 0` for `IN ()`, `1 = 1` for `NOT IN ()`).
286    In {
287        expr: Expr,
288        values: Vec<Expr>,
289        negated: bool,
290    },
291    /// `<expr> [NOT] LIKE <pattern>`; `insensitive` renders the portable
292    /// `lower(<expr>) LIKE lower(<pattern>)` (no dialect-specific `ILIKE`).
293    Like {
294        expr: Expr,
295        pattern: String,
296        insensitive: bool,
297        negated: bool,
298    },
299    /// `<expr> IS [NOT] NULL`.
300    Null { expr: Expr, negated: bool },
301    /// `<expr> [NOT] IN (SELECT <column> FROM <table> WHERE <filter>)` — a narrow single-named-
302    /// table IN-subquery (the sibling of [`Expr::RelatedScalar`]; same safe-by-construction shape).
303    InSubquery {
304        expr: Expr,
305        column: String,
306        table: String,
307        filter: Box<Self>,
308        negated: bool,
309    },
310}
311
312/// Build an `AND` of the given predicates.
313pub fn all(preds: impl IntoIterator<Item = Predicate>) -> Predicate {
314    Predicate::And(preds.into_iter().collect())
315}
316/// Build an `OR` of the given predicates.
317pub fn any(preds: impl IntoIterator<Item = Predicate>) -> Predicate {
318    Predicate::Or(preds.into_iter().collect())
319}
320
321// ---- select / insert / update ---------------------------------------------
322
323/// The kind of join.
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum JoinKind {
326    Inner,
327    Left,
328}
329
330/// A join: `<kind> JOIN <table>[ AS <alias>] ON <on>`.
331#[derive(Debug, Clone, PartialEq)]
332pub struct Join {
333    pub kind: JoinKind,
334    pub table: String,
335    pub alias: Option<String>,
336    pub on: Predicate,
337}
338
339/// A sort direction.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum Direction {
342    Asc,
343    Desc,
344}
345
346/// An `ORDER BY` term over an expression.
347#[derive(Debug, Clone, PartialEq)]
348pub struct OrderBy {
349    pub expr: Expr,
350    pub dir: Direction,
351}
352
353/// A `SELECT`-list entry: an expression with an optional `AS <alias>`.
354#[derive(Debug, Clone, PartialEq)]
355pub struct SelectItem {
356    pub expr: Expr,
357    pub alias: Option<String>,
358}
359
360/// How a tenant [`Scope`] restricts rows for one operation. The host resolves this from the
361/// per-function/site `db.read`/`db.write` grant (read modes on `SELECT`, write modes on
362/// `INSERT`/`UPDATE`/`DELETE`); a guest never chooses it. `None`-grant (deny) is handled above
363/// the compiler — a compiled query always carries a concrete mode.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
365pub enum ScopeMode {
366    /// `column = value` — the resolved tenant only.
367    #[default]
368    Own,
369    /// `(column = value OR column IS NULL)` — the resolved tenant plus the shared/`NULL` baseline.
370    OwnOrNull,
371    /// `column IS NULL` — the shared/`NULL` baseline only (no tenant rows).
372    NullOnly,
373    /// No tenant predicate — cross-tenant. Only reachable with an explicit `all` grant under the
374    /// operator posture ceiling (both enforced host-side, above this compiler).
375    All,
376}
377
378/// Per-table tenant-key resolution for a [`Scope`] (PLAN-tenancy-principal D2/D3). Legacy / no
379/// project schema ⇒ [`Uniform`](TableKeys::Uniform): every table scopes on [`Scope::column`].
380/// A present project schema ⇒ [`PerTable`](TableKeys::PerTable): the authoritative `table →
381/// `[`ResolvedScope`] map — `Column(col)` scopes that table on `col` (`TenantKeyed` identity tables
382/// on their own PK), `Unscoped` a global table (no predicate), `TenantOrSession { tenant, session }`
383/// the R3 anonymous-first disjunct on two disjoint columns; a table **absent** from the map is
384/// refused ([`OrmError::TenancyUndeclared`], deny-by-default).
385// Not `Eq`: `PerTableTarget` carries bound `SqlValue` literals (public-subset terms), and
386// `SqlValue` is only `PartialEq` (a float variant) — same as `Scope`, which holds `TableKeys`.
387#[derive(Debug, Clone, PartialEq, Default)]
388pub enum TableKeys {
389    #[default]
390    Uniform,
391    PerTable(std::collections::BTreeMap<String, ResolvedScope>),
392    /// A **target read/write** (R4/D8): the same per-table tenant keys as `PerTable`, PLUS (when
393    /// [`require_public`](TableKeys::PerTableTarget::require_public)) a per-table PUBLIC-subset
394    /// confinement conjoined onto every accessed table. Built only by the host for a
395    /// `TenancyClass::Target` fetch; never by a guest.
396    PerTableTarget {
397        keys: std::collections::BTreeMap<String, ResolvedScope>,
398        public: std::collections::BTreeMap<String, Vec<PublicTermSql>>,
399        /// **Target WRITE SET-allowlist (5b), deny-by-default.** The columns a target INSERT/UPDATE
400        /// may set. **Empty ⇒ read-only** — any write force-scoped under this variant is refused
401        /// ([`OrmError::TargetWriteNotGranted`]). Non-empty ⇒ an INSERT force-stamps `tenant = B` and
402        /// (when `require_public`) the public-visibility columns and accepts ONLY these columns from
403        /// the guest; an UPDATE confines its `WHERE` to `tenant = B AND <public>` and may set ONLY
404        /// these columns; a DELETE is always refused. The tenant/visibility columns are never in this
405        /// set, so a target write can neither change ownership nor flip a row's visibility.
406        write: std::collections::BTreeSet<String>,
407        /// Whether a per-table PUBLIC subset is **mandatory** (R4/D8 5c ruling A). `true` for the
408        /// **anonymous** target sources (`domain`/`handle`): a table accessed with **no** declared
409        /// public subset is refused ([`OrmError::PublicSubsetUndeclared`]) — for an unauthenticated
410        /// actor the visibility predicate is the ONLY guard against reaching `B`'s private rows.
411        /// `false` for a **`capability`-only** field: the host-verified, audience-bound capability
412        /// (naming `tid = B` + the granted scope) IS the authorization, so a table with no declared
413        /// subset confines to `tenant = B` alone (no visibility conjunct, no refusal) and the app's
414        /// within-tenant per-client filter stays in-guest. A table that DOES declare a subset is still
415        /// confined by it either way. Never `all`; still exactly one tenant `B`.
416        require_public: bool,
417    },
418}
419
420/// A lowered public-subset visibility term: a [`crate::tenancy::PublicTerm`] whose literal is
421/// already a bound [`SqlValue`] (so it is always a parameter, never interpolated text). Conjoined
422/// onto a target read to confine it to a table's public rows.
423#[derive(Debug, Clone, PartialEq)]
424pub enum PublicTermSql {
425    /// `<column> <op> <bound value>`.
426    Cmp {
427        column: String,
428        op: CmpOp,
429        value: SqlValue,
430    },
431    /// `<column> IS [NOT] NULL`.
432    Null { column: String, negated: bool },
433}
434
435/// Lower a host-held [`crate::tenancy::PublicPredicate`] (types-local literals) into the ORM's
436/// bound-value [`PublicTermSql`] terms. Called by the host when building a target scope; a
437/// `PublicLiteral` becomes a bound `SqlValue` (never interpolated).
438pub fn lower_public_terms(pred: &crate::tenancy::PublicPredicate) -> Vec<PublicTermSql> {
439    use crate::tenancy::{PublicCmp, PublicLiteral, PublicTerm};
440    pred.terms
441        .iter()
442        .map(|t| match t {
443            PublicTerm::Cmp { column, op, value } => {
444                let op = match op {
445                    PublicCmp::Eq => CmpOp::Eq,
446                    PublicCmp::Ne => CmpOp::Ne,
447                    PublicCmp::Lt => CmpOp::Lt,
448                    PublicCmp::Le => CmpOp::Le,
449                    PublicCmp::Gt => CmpOp::Gt,
450                    PublicCmp::Ge => CmpOp::Ge,
451                };
452                let value = match value {
453                    PublicLiteral::Bool(b) => SqlValue::Boolean(*b),
454                    PublicLiteral::Int(n) => SqlValue::Integer(*n),
455                    PublicLiteral::Text(s) => SqlValue::Text(s.clone()),
456                };
457                PublicTermSql::Cmp {
458                    column: column.clone(),
459                    op,
460                    value,
461                }
462            }
463            PublicTerm::Null { column, negated } => PublicTermSql::Null {
464                column: column.clone(),
465                negated: *negated,
466            },
467        })
468        .collect()
469}
470
471/// A host-resolved in-site row-tenancy scope — the applied side of the resolved principal. `value`
472/// is the resolved **own-tenant** fact (`None` ⇒ the actor has no tenant, e.g. a purely anonymous
473/// `Session`-only request); `session` is the resolved anonymous-**session** fact (R3); `mode`
474/// decides how the tenant axis restricts the operation; `keys` resolves the tenant **column(s) per
475/// table** (the project schema — R2/R3/D2). Injected by the host on **every** query node (top-level,
476/// `UNION` branch, `INSERT … SELECT` source), never guest-set. The fail-closed "no fact for a scope
477/// that needs one" decision is made **per table** in the injector (a `Column` table with no tenant
478/// value denies; a `TenantOrSession` table falls back to whichever axis fact is present).
479#[derive(Debug, Clone, PartialEq)]
480pub struct Scope {
481    pub column: String,
482    /// The resolved own-tenant value, or `None` for an anonymous (`Session`-only) actor.
483    pub value: Option<SqlValue>,
484    /// The resolved anonymous-session value (R3), or `None` when the request carries no session
485    /// fact. Only consulted for a [`TableScope::TenantOrSession`](crate::tenancy::TableScope) table.
486    pub session: Option<SqlValue>,
487    pub mode: ScopeMode,
488    /// Per-table key resolution; [`TableKeys::Uniform`] (the default) preserves the pre-schema
489    /// single-column behavior (every table scopes on `column`).
490    pub keys: TableKeys,
491}
492
493impl Scope {
494    /// Resolve how `table` is scoped under the project schema (R2/R3/D2/D3): `Column(col)` ⇒ scope
495    /// on `col`; `Unscoped` ⇒ no predicate; `TenantOrSession{tenant,session}` ⇒ the R3 disjunct;
496    /// `Err(TenancyUndeclared)` ⇒ undeclared (deny-by-default). Legacy `Uniform` keys resolve every
497    /// table to `Column(self.column)`, byte-identical to the pre-schema single-column behavior.
498    fn resolve_table(&self, table: &str) -> Result<ResolvedScope, OrmError> {
499        match &self.keys {
500            TableKeys::Uniform => Ok(ResolvedScope::Column(self.column.clone())),
501            TableKeys::PerTable(m) | TableKeys::PerTableTarget { keys: m, .. } => m
502                .get(table)
503                .cloned()
504                .ok_or_else(|| OrmError::TenancyUndeclared(table.to_string())),
505        }
506    }
507
508    /// The PUBLIC-subset confinement to conjoin for `table` under a **target read** (R4/D8):
509    /// `Ok(None)` when the scope is not a target read (own/session — no public confinement, today's
510    /// behavior). Under a target read where `require_public` (domain/handle, or any `target_or_null`),
511    /// a table with **no** declared public subset is refused ([`OrmError::PublicSubsetUndeclared`],
512    /// deny-by-default) and a declared subset is built as a qualified `AND` (each column qualified by
513    /// `qualifier` for a join/subquery ref, so the confinement composes across every reachable table).
514    /// Under a `capability`-only target (`!require_public`, ruling A) the subset is INERT ⇒ `Ok(None)`:
515    /// confine to `tenant = B` alone (added separately by [`read_pred`](Self::read_pred)) — the
516    /// host-verified, project-audience-bound, label-scoped capability plus the resolver's own in-guest
517    /// per-`sub` filter IS the authorization. This holds whether or not the table DECLARES a subset,
518    /// because a subset authored for the anonymous domain/handle funnel (e.g. `client_id IS NULL`)
519    /// must not narrow a capability read — it would collide with the resolver's own filter and empty
520    /// the result. An empty declared term list ⇒ no predicate (the schema loader rejects an empty
521    /// declared subset). NB: `target_or_null` forces `require_public` (v0.4.8), so its shared
522    /// NULL-base arm still visibility-gates even under a capability; the exemption is plain `target`.
523    fn public_pred(
524        &self,
525        table: &str,
526        qualifier: Option<&str>,
527    ) -> Result<Option<Predicate>, OrmError> {
528        let TableKeys::PerTableTarget {
529            public,
530            require_public,
531            ..
532        } = &self.keys
533        else {
534            return Ok(None);
535        };
536        // Ruling A (v0.4.4), made COMPLETE: on the capability axis the visibility subset is inert —
537        // NO per-table subset is applied, not even a DECLARED one. `tenant = B` (applied separately)
538        // + the capability + the resolver's own in-guest filter is the authorization.
539        if !require_public {
540            return Ok(None);
541        }
542        let terms = match public.get(table) {
543            Some(t) => t,
544            // domain/handle (or target_or_null): the visibility predicate is the ONLY guard for an
545            // anonymous/base-arm actor, so a declared subset is mandatory (deny-by-default).
546            None => return Err(OrmError::PublicSubsetUndeclared(table.to_string())),
547        };
548        let mut preds = Vec::with_capacity(terms.len());
549        for term in terms {
550            match term {
551                PublicTermSql::Cmp { column, op, value } => {
552                    ident(column)?;
553                    preds.push(Predicate::Cmp {
554                        left: Self::col_expr(column, qualifier),
555                        op: *op,
556                        right: Expr::Value(value.clone()),
557                    });
558                }
559                PublicTermSql::Null { column, negated } => {
560                    ident(column)?;
561                    preds.push(Predicate::Null {
562                        expr: Self::col_expr(column, qualifier),
563                        negated: *negated,
564                    });
565                }
566            }
567        }
568        Ok(match preds.len() {
569            0 => None,
570            1 => Some(preds.pop().unwrap()),
571            _ => Some(Predicate::And(preds)),
572        })
573    }
574
575    /// A (possibly-qualified) column expression `<qualifier>.column`.
576    fn col_expr(column: &str, qualifier: Option<&str>) -> Expr {
577        Expr::Column(match qualifier {
578            Some(q) => format!("{q}.{column}"),
579            None => column.to_string(),
580        })
581    }
582
583    /// The **tenant-axis** predicate on `column` (optionally `<qual>.column`) for the resolved mode:
584    /// `Ok(None)` for `All` (no predicate — cross-tenant); `NullOnly` needs no value; `Own`/`OwnOrNull`
585    /// require a resolved own-tenant value and **fail closed** ([`OrmError::TenancyNoPrincipal`]) when
586    /// there is none (a purely anonymous actor reading a plain tenant table). Never binds to another
587    /// table's same-named column — it is qualified by `qual`.
588    ///
589    /// `base_inclusive` folds the shared `column IS NULL` base into the `Own` arm (`Or([col = v, col
590    /// IS NULL])`) — the per-table [`ResolvedScope::TenantOrBase`] read, which reads a table's
591    /// tenant-`NULL` base rows on *any* own/target read regardless of the field mode. It is a no-op for
592    /// `OwnOrNull` (already OR-null), `NullOnly` (base only), and `All` (cross-tenant). It affects only
593    /// the READ predicate; the write stamp is unchanged (a base-inclusive table stamps `tenant = v`,
594    /// never `NULL`).
595    fn tenant_pred(
596        &self,
597        column: &str,
598        qualifier: Option<&str>,
599        base_inclusive: bool,
600    ) -> Result<Option<Predicate>, OrmError> {
601        let is_null = Predicate::Null {
602            expr: Self::col_expr(column, qualifier),
603            negated: false,
604        };
605        let eq = |v: SqlValue| Predicate::Cmp {
606            left: Self::col_expr(column, qualifier),
607            op: CmpOp::Eq,
608            right: Expr::Value(v),
609        };
610        Ok(match self.mode {
611            ScopeMode::All => None,
612            ScopeMode::NullOnly => Some(is_null),
613            ScopeMode::Own => {
614                let v = self.value.clone().ok_or(OrmError::TenancyNoPrincipal)?;
615                Some(if base_inclusive {
616                    Predicate::Or(vec![eq(v), is_null])
617                } else {
618                    eq(v)
619                })
620            }
621            ScopeMode::OwnOrNull => {
622                let v = self.value.clone().ok_or(OrmError::TenancyNoPrincipal)?;
623                Some(Predicate::Or(vec![eq(v), is_null]))
624            }
625        })
626    }
627
628    /// The R3 **disjunct** read predicate for a `TenantOrSession` table: `Or` of the arms for
629    /// whichever axis facts the request carries — `tenant = <own>` (if a tenant fact is present) and
630    /// `session = <sid>` (if a session fact is present) — over the two **disjoint** columns. `All`
631    /// mode ⇒ no predicate (cross-tenant). No fact at all ⇒ **deny** ([`TenancyNoPrincipal`]): a
632    /// `TenantOrSession` read with neither an own nor a session identity fails closed rather than
633    /// running unscoped. Each arm is a plain `col = value` (the session partition IS the
634    /// tenant-`NULL` rows, so no extra NULL arm is added).
635    fn disjunct_pred(
636        &self,
637        tenant_col: &str,
638        session_col: &str,
639        qualifier: Option<&str>,
640    ) -> Result<Option<Predicate>, OrmError> {
641        if matches!(self.mode, ScopeMode::All) {
642            return Ok(None);
643        }
644        let eq = |column: &str, v: SqlValue| Predicate::Cmp {
645            left: Self::col_expr(column, qualifier),
646            op: CmpOp::Eq,
647            right: Expr::Value(v),
648        };
649        let mut arms = Vec::new();
650        if let Some(v) = self.value.clone() {
651            arms.push(eq(tenant_col, v));
652        }
653        if let Some(s) = self.session.clone() {
654            arms.push(eq(session_col, s));
655        }
656        match arms.len() {
657            0 => Err(OrmError::TenancyNoPrincipal),
658            1 => Ok(arms.pop()),
659            _ => Ok(Some(Predicate::Or(arms))),
660        }
661    }
662
663    /// The READ predicate to conjoin for `table` (qualified by `qualifier` in a join/subquery):
664    /// dispatches on the per-table [`ResolvedScope`] — a plain tenant column, an `Unscoped` global
665    /// (no predicate), or the R3 `TenantOrSession` disjunct. `Ok(None)` ⇒ no predicate (the table is
666    /// global, or the mode is cross-tenant `All`). Undeclared / no-principal ⇒ fail closed.
667    fn read_pred(
668        &self,
669        table: &str,
670        qualifier: Option<&str>,
671    ) -> Result<Option<Predicate>, OrmError> {
672        let tenant = match self.resolve_table(table)? {
673            ResolvedScope::Column(col) => {
674                ident(&col)?;
675                self.tenant_pred(&col, qualifier, false)?
676            }
677            ResolvedScope::Unscoped => None,
678            ResolvedScope::TenantOrSession { tenant, session } => {
679                ident(&tenant)?;
680                ident(&session)?;
681                self.disjunct_pred(&tenant, &session, qualifier)?
682            }
683            // Base-inclusive: fold the shared `tenant IS NULL` base into the read on any own/target
684            // read (base_inclusive = true forces the OR-null form even under a plain `Own` mode).
685            ResolvedScope::TenantOrBase { tenant } => {
686                ident(&tenant)?;
687                self.tenant_pred(&tenant, qualifier, true)?
688            }
689        };
690        // R4/D8: under a TARGET read, additionally confine to the table's host-held PUBLIC subset
691        // (deny-by-default if the table declares none). No-op under an own/session read. So a target
692        // read of table `t` becomes `t.tenant = B AND <t's public predicate>`, composed per ref.
693        let public = self.public_pred(table, qualifier)?;
694        let mut out = Predicate::And(Vec::new());
695        conjoin_front(&mut out, tenant);
696        conjoin_front(&mut out, public);
697        Ok(match out {
698            Predicate::And(v) if v.is_empty() => None,
699            p => Some(p),
700        })
701    }
702
703    /// The `(column, value)` a scoped **WRITE** stamps/bounds for `table` — the actor's OWN axis:
704    /// a plain tenant table (or `Uniform`) stamps `default_tenant_key = <own tenant>`; a
705    /// `TenantOrSession` table stamps whichever single axis the actor holds (tenant if authenticated,
706    /// else session) so an anonymous write lands in the session partition and an authenticated write
707    /// in the tenant partition — never both, never cross. `Ok(None)` ⇒ `All` mode (no stamp — a
708    /// posture-vetted cross-tenant write). Fail closed: an `Unscoped` (global) target
709    /// ([`UnscopedWrite`]), an undeclared target ([`TenancyUndeclared`]), or a scoped write with no
710    /// principal ([`TenancyNoPrincipal`]) are refused before any SQL.
711    fn write_target(&self, table: &str) -> Result<Option<(String, SqlValue)>, OrmError> {
712        // The tenant-axis stamp value for this mode (own/own+null → the resolved tenant; null → the
713        // shared baseline; all → no stamp).
714        let tenant_stamp = || -> Result<Option<SqlValue>, OrmError> {
715            Ok(match self.mode {
716                ScopeMode::All => None,
717                ScopeMode::NullOnly => Some(SqlValue::Null),
718                ScopeMode::Own | ScopeMode::OwnOrNull => {
719                    Some(self.value.clone().ok_or(OrmError::TenancyNoPrincipal)?)
720                }
721            })
722        };
723        match self.resolve_table(table)? {
724            ResolvedScope::Column(col) => Ok(tenant_stamp()?.map(|v| (col, v))),
725            // A base-inclusive table WRITES exactly like a plain tenant `Column` table: stamp the
726            // resolved tenant (never `NULL`). The base⊕own fold is read-only — a guest write can
727            // neither create nor update a `NULL`-base row (base rows are operator-seeded via a
728            // privileged `NullOnly`/`All` path, unreachable from a guest's `Own`-mode write).
729            ResolvedScope::TenantOrBase { tenant } => Ok(tenant_stamp()?.map(|v| (tenant, v))),
730            ResolvedScope::Unscoped => Err(OrmError::UnscopedWrite(table.to_string())),
731            ResolvedScope::TenantOrSession { tenant, session } => {
732                if matches!(self.mode, ScopeMode::All) {
733                    return Ok(None);
734                }
735                // A TARGET write carries only the target tenant `B` (no session fact). Stamping
736                // `tenant = B` onto a session-keyed row would silently claim an anon/session-owned row
737                // for `B` and break the anon→promotion model, so refuse deny-by-default
738                // (PLAN-delegable-capabilities, Stage A): a `TenantOrSession` table is written on the
739                // caller's own/session-scoped path, never under a target scope.
740                if self.is_target() {
741                    return Err(OrmError::TargetWriteToSessionTable(table.to_string()));
742                }
743                // Prefer the tenant axis when authenticated; else the session axis for an anon write.
744                if let Some(v) = self.value.clone() {
745                    Ok(Some((tenant, v)))
746                } else if let Some(s) = self.session.clone() {
747                    Ok(Some((session, s)))
748                } else {
749                    Err(OrmError::TenancyNoPrincipal)
750                }
751            }
752        }
753    }
754
755    /// Whether this scope is a **target** scope (`PerTableTarget` — reading/writing another tenant
756    /// `B`'s public subset, R4/D8), vs. the caller's own.
757    pub fn is_target(&self) -> bool {
758        matches!(self.keys, TableKeys::PerTableTarget { .. })
759    }
760
761    /// The target-write SET-allowlist (5b), or `None` when this is not a target scope. An **empty**
762    /// set means the target route is read-only (no `write` grant) — a write force-scoped under it is
763    /// refused. Returned as `Some(&set)` for a target scope so a write path can tell "not a target"
764    /// (own path) from "target, read-only" (refuse) from "target, may set these columns".
765    fn target_write_allowlist(&self) -> Option<&std::collections::BTreeSet<String>> {
766        match &self.keys {
767            TableKeys::PerTableTarget { write, .. } => Some(write),
768            _ => None,
769        }
770    }
771
772    /// Whether a target read/write must apply the per-table PUBLIC visibility subset: `true` for an
773    /// anonymous `domain`/`handle` source or any `target_or_null` (the shared `NULL`-base arm), where
774    /// the subset is the only guard; `false` for a plain `capability`-only target (ruling A — the
775    /// capability is the authorization, so the confinement is `tenant = B` alone). `false` when this
776    /// is not a target scope. Read by the upsert confinement to admit an `ON CONFLICT DO UPDATE` only
777    /// where the DO-UPDATE's `tenant = B` guard matches the (subset-less) plain-UPDATE confinement.
778    fn target_require_public(&self) -> bool {
779        matches!(
780            &self.keys,
781            TableKeys::PerTableTarget {
782                require_public: true,
783                ..
784            }
785        )
786    }
787
788    /// The resolved tenant column for `table` under a target scope (the per-table key, or `None` when
789    /// this is not a target scope or the table has no plain-`Column` tenant key). Used to require that
790    /// a target upsert's conflict target includes the tenant column.
791    fn target_tenant_column(&self, table: &str) -> Option<String> {
792        match &self.keys {
793            TableKeys::PerTableTarget { keys, .. } => match keys.get(table) {
794                Some(ResolvedScope::Column(c)) => Some(c.clone()),
795                // A base-inclusive table has a plain tenant column too (its NULL rows are shared
796                // base); a target upsert on it keys+guards on that column exactly like a Column table.
797                Some(ResolvedScope::TenantOrBase { tenant }) => Some(tenant.clone()),
798                _ => None,
799            },
800            _ => None,
801        }
802    }
803
804    /// Refuse a **target** write to a `TenantOrSession` (anonymous-session-keyed) table. A target
805    /// principal carries only the target tenant `B` (no session fact), so such a row could only be
806    /// stamped `tenant = B` — silently claiming an anon/session-owned row for `B` and breaking the
807    /// anon→promotion model. Called at the top of every target-write path (INSERT/UPDATE) so the
808    /// refusal is **early and self-describing** rather than surfacing later as a public-subset error;
809    /// a no-op for an own scope (a `TenantOrSession` write on the own/session path is legitimate).
810    /// `write_target` keeps the equivalent guard as a fail-closed backstop.
811    /// (PLAN-delegable-capabilities, Stage A.)
812    fn assert_target_table_writable(&self, table: &str) -> Result<(), OrmError> {
813        if !self.is_target() {
814            return Ok(());
815        }
816        if let ResolvedScope::TenantOrSession { .. } = self.resolve_table(table)? {
817            return Err(OrmError::TargetWriteToSessionTable(table.to_string()));
818        }
819        Ok(())
820    }
821
822    /// The `(column, value)` pairs a target **INSERT** must force so the inserted row lands in
823    /// `table`'s PUBLIC subset (5b): each `column = <literal>` public term contributes `(column,
824    /// literal)`, each `column IS NULL` term contributes `(column, NULL)`. A public term the host
825    /// cannot pin to a single value (a range comparison, or `IS NOT NULL`) is not forceable — the
826    /// host cannot guarantee publicness — so the INSERT is refused ([`PublicSubsetNotForceable`]).
827    /// When `require_public` (domain/handle) a table with no declared subset is refused
828    /// ([`PublicSubsetUndeclared`]); under a `capability`-only target (`!require_public`) an undeclared
829    /// subset forces no visibility columns (the row is `tenant = B` + the guest's allowlisted columns —
830    /// the capability is the authorization).
831    fn public_force_cells(&self, table: &str) -> Result<Vec<(String, SqlValue)>, OrmError> {
832        let TableKeys::PerTableTarget {
833            public,
834            require_public,
835            ..
836        } = &self.keys
837        else {
838            return Ok(Vec::new());
839        };
840        let terms = match public.get(table) {
841            Some(t) => t,
842            None if !require_public => return Ok(Vec::new()),
843            None => return Err(OrmError::PublicSubsetUndeclared(table.to_string())),
844        };
845        let mut out = Vec::with_capacity(terms.len());
846        for term in terms {
847            match term {
848                PublicTermSql::Cmp {
849                    column,
850                    op: CmpOp::Eq,
851                    value,
852                } => {
853                    ident(column)?;
854                    out.push((column.clone(), value.clone()));
855                }
856                PublicTermSql::Null {
857                    column,
858                    negated: false,
859                } => {
860                    ident(column)?;
861                    out.push((column.clone(), SqlValue::Null));
862                }
863                // A range comparison or `IS NOT NULL` has no single value to stamp.
864                PublicTermSql::Cmp { .. } | PublicTermSql::Null { .. } => {
865                    return Err(OrmError::PublicSubsetNotForceable(table.to_string()))
866                }
867            }
868        }
869        Ok(out)
870    }
871
872    /// Assert a guest-supplied `column` is settable by a target write on `table` (5b). Two gates,
873    /// both must pass: it is in the route's SET-allowlist, AND it is neither the tenant column nor a
874    /// public-visibility column (the latter a defense-in-depth check so even an operator who wrongly
875    /// listed the tenant/visibility column can't let a target write change ownership or flip
876    /// visibility). A no-op (`Ok`) when this is not a target scope. Fail-closed
877    /// ([`TargetWriteColumnDenied`]).
878    fn assert_target_settable(&self, table: &str, column: &str) -> Result<(), OrmError> {
879        let TableKeys::PerTableTarget {
880            keys,
881            public,
882            write,
883            ..
884        } = &self.keys
885        else {
886            return Ok(());
887        };
888        let denied = || OrmError::TargetWriteColumnDenied(column.to_string());
889        // Gate 0: a write-target column (an INSERT column / an UPDATE SET LHS) must be a BARE column
890        // name — never `table.col`. A qualified name would (a) let `same_col` compare only the last
891        // segment, so `published.x` could slip past the tenant/visibility check on base `x`, and (b)
892        // render invalid SQL. Refuse it fail-closed at compile rather than emit a statement the DB
893        // would reject.
894        if column.contains('.') {
895            return Err(denied());
896        }
897        // Gate 1: must be granted in the SET-allowlist.
898        if !write.iter().any(|c| same_col(c, column)) {
899            return Err(denied());
900        }
901        // Gate 2: never the tenant column (would change ownership).
902        if let Some(rs) = keys.get(table) {
903            let tenant_cols: &[&str] = match rs {
904                ResolvedScope::Column(c) => &[c],
905                ResolvedScope::TenantOrSession { tenant, session } => &[tenant, session],
906                // The tenant column is off-limits to a guest SET; its NULL base rows are never
907                // guest-writable, so a base-inclusive table protects the same one column.
908                ResolvedScope::TenantOrBase { tenant } => &[tenant],
909                ResolvedScope::Unscoped => &[],
910            };
911            if tenant_cols.iter().any(|t| same_col(t, column)) {
912                return Err(denied());
913            }
914        }
915        // Gate 2 (cont.): never a public-visibility column (would flip the row in/out of the subset).
916        if let Some(terms) = public.get(table) {
917            let is_public_col = terms.iter().any(|t| match t {
918                PublicTermSql::Cmp { column: c, .. } | PublicTermSql::Null { column: c, .. } => {
919                    same_col(c, column)
920                }
921            });
922            if is_public_col {
923                return Err(denied());
924            }
925        }
926        Ok(())
927    }
928}
929
930/// A `SELECT`.
931#[derive(Debug, Clone, PartialEq)]
932pub struct Select {
933    pub table: String,
934    pub table_alias: Option<String>,
935    /// Empty ⇒ `SELECT *`.
936    pub columns: Vec<SelectItem>,
937    pub joins: Vec<Join>,
938    pub filter: Option<Predicate>,
939    pub scope: Option<Scope>,
940    pub group_by: Vec<Expr>,
941    pub having: Option<Predicate>,
942    pub distinct: bool,
943    /// `DISTINCT ON (<exprs>)` — **Postgres-only** (fails closed elsewhere). Non-empty takes
944    /// precedence over `distinct`; empty ⇒ inactive.
945    pub distinct_on: Vec<Expr>,
946    pub order: Vec<OrderBy>,
947    pub limit: Option<u32>,
948    pub offset: Option<u32>,
949    /// `UNION [ALL] <query>` — one level (the branch's own `union` is not rendered). Each side
950    /// carries its own scope/filter, so both stay tenant-isolated.
951    pub union: Option<Box<Union>>,
952}
953
954/// A `UNION [ALL]` branch of a [`Select`].
955#[derive(Debug, Clone, PartialEq)]
956pub struct Union {
957    pub all: bool,
958    pub query: Select,
959}
960
961/// A `column = <expr>` assignment (an INSERT cell or an UPDATE SET).
962#[derive(Debug, Clone, PartialEq)]
963pub struct Assignment {
964    pub column: String,
965    pub value: Expr,
966}
967
968/// One row's cells for an INSERT.
969#[derive(Debug, Clone, PartialEq)]
970pub struct RowValues {
971    pub cells: Vec<Assignment>,
972}
973
974/// An `ON CONFLICT (<columns>) DO UPDATE SET <update>` (empty `update` ⇒ `DO NOTHING`).
975#[derive(Debug, Clone, PartialEq)]
976pub struct OnConflict {
977    pub conflict_columns: Vec<String>,
978    pub update: Vec<Assignment>,
979}
980
981/// An `INSERT` (single- or multi-row), optionally an upsert, optionally `RETURNING`.
982#[derive(Debug, Clone, PartialEq)]
983pub struct Insert {
984    pub table: String,
985    pub rows: Vec<RowValues>,
986    pub conflict: Option<OnConflict>,
987    /// Forces `column = value` into every inserted row (adds or overrides).
988    pub scope: Option<Scope>,
989    /// `RETURNING <items>` (empty ⇒ none). Not supported by every engine (e.g. MySQL).
990    pub returning: Vec<SelectItem>,
991    /// `INSERT INTO t (<columns>) <select>` — when set, rows come from a SELECT (`rows` ignored).
992    /// Under a scoped write, [`Insert::force_scope`] read-scopes the source **and** host-forces the
993    /// target tenant column (dropping any guest projection of it), so the written tenant can't be
994    /// forged; without a scope (or `all`) the columns/projection are taken verbatim.
995    pub from_select: Option<(Vec<String>, Box<Select>)>,
996}
997
998/// An `UPDATE`; `filter` is required (an unbounded update is refused).
999#[derive(Debug, Clone, PartialEq)]
1000pub struct Update {
1001    pub table: String,
1002    pub set: Vec<Assignment>,
1003    pub filter: Predicate,
1004    pub scope: Option<Scope>,
1005    pub returning: Vec<SelectItem>,
1006}
1007
1008/// A `DELETE`; `filter` is required (an unbounded delete is refused, mirroring [`Update`]).
1009#[derive(Debug, Clone, PartialEq)]
1010pub struct Delete {
1011    pub table: String,
1012    pub filter: Predicate,
1013    pub scope: Option<Scope>,
1014    pub returning: Vec<SelectItem>,
1015}
1016
1017impl Insert {
1018    /// For a posture-vetted cross-tenant (`all`) INSERT with no host-injected stamp: the single,
1019    /// uniform LITERAL value of scope column `col` across every inserted row — the tenant the row(s)
1020    /// declare — or `None` when it isn't one well-defined literal (an `INSERT … SELECT` source, a
1021    /// row missing `col` or giving it a non-literal, or rows that disagree). Used ONLY to set the
1022    /// RLS tenant GUC to what the write targets (an `all` guest may write any one tenant, GUC-
1023    /// consistent); a `None` simply doesn't re-set the GUC, leaving it at whatever the per-transaction
1024    /// own/session set established (or unset if none) — under `all` that only over-restricts the write
1025    /// (the DB's `WITH CHECK` still confines it), never widens it. The DB is the final arbiter — a
1026    /// wrong value is rejected there.
1027    pub fn uniform_scope_value(&self, col: &str) -> Option<SqlValue> {
1028        if self.from_select.is_some() || self.rows.is_empty() {
1029            return None;
1030        }
1031        let mut found: Option<SqlValue> = None;
1032        for row in &self.rows {
1033            let cell = row.cells.iter().find(|a| same_col(&a.column, col))?;
1034            let v = match &cell.value {
1035                Expr::Value(v) => v.clone(),
1036                _ => return None, // a non-literal (expression/column) → not a single known tenant
1037            };
1038            match &found {
1039                None => found = Some(v),
1040                Some(prev) if *prev == v => {}
1041                Some(_) => return None, // rows declare different tenants → not a single value
1042            }
1043        }
1044        found
1045    }
1046}
1047
1048impl Update {
1049    /// For a posture-vetted cross-tenant (`all`) UPDATE: the single tenant value the WHERE pins scope
1050    /// column `col` to — a top-level `col = <literal>` conjunct (optionally nested in `AND`s) — or
1051    /// `None` when the filter doesn't pin exactly one tenant (an `OR`/`IN`/range/no-op, or conjuncts
1052    /// pinning different values). Used ONLY to set the RLS GUC; a `None` doesn't re-set it, leaving the
1053    /// prior per-transaction value (or unset), which only over-restricts — under `all` the DB's `USING`
1054    /// then matches no row outside that tenant, so a genuinely multi-tenant UPDATE affects nothing. It
1055    /// must pin a single tenant to run; it never silently touches one tenant of a spanning filter.
1056    pub fn pinned_scope_value(&self, col: &str) -> Option<SqlValue> {
1057        fn find(pred: &Predicate, col: &str) -> Option<SqlValue> {
1058            match pred {
1059                Predicate::Cmp {
1060                    left,
1061                    op: CmpOp::Eq,
1062                    right,
1063                } => match (left, right) {
1064                    (Expr::Column(c), Expr::Value(v)) | (Expr::Value(v), Expr::Column(c))
1065                        if same_col(c, col) =>
1066                    {
1067                        Some(v.clone())
1068                    }
1069                    _ => None,
1070                },
1071                Predicate::And(children) => {
1072                    let mut found: Option<SqlValue> = None;
1073                    for ch in children {
1074                        if let Some(v) = find(ch, col) {
1075                            match &found {
1076                                None => found = Some(v),
1077                                Some(prev) if *prev == v => {}
1078                                Some(_) => return None, // contradictory pins → not a single tenant
1079                            }
1080                        }
1081                    }
1082                    found
1083                }
1084                // OR / NOT / IN / BETWEEN / LIKE / NULL / subquery don't pin exactly one tenant.
1085                _ => None,
1086            }
1087        }
1088        find(&self.filter, col)
1089    }
1090}
1091
1092impl Select {
1093    /// Force a host-resolved `scope` onto this `SELECT` **and every nested read node** — its
1094    /// `UNION` branch — so a tenant scope reaches every row source (a union branch left unscoped
1095    /// would leak across tenants). Overwrites any pre-existing scope. This is the host's tenant
1096    /// injection point for reads; the guest never sets a scope of its own.
1097    pub fn force_scope(&mut self, scope: &Scope) -> Result<(), OrmError> {
1098        self.scope = Some(scope.clone());
1099        self.inject_subquery_scope(scope)?;
1100        if let Some(u) = self.union.as_mut() {
1101            u.query.force_scope(scope)?;
1102        }
1103        Ok(())
1104    }
1105}
1106
1107impl Insert {
1108    /// Force the host-resolved tenant scope. `write` stamps the tenant column on a
1109    /// `VALUES`-based insert (per [`ScopeMode`]); for an `INSERT … SELECT`, the `read` scope is
1110    /// forced onto the source query (and its nested unions) so the selected rows stay
1111    /// tenant-isolated, **and** the target tenant column is host-forced too — any guest-supplied
1112    /// tenant column + its projection is dropped and re-appended bound to the resolved value, so a
1113    /// guest can't project another tenant's id into the write (a cross-tenant write forgery).
1114    /// `None` for an axis (cross-tenant `all`) clears that scope — the operation runs unscoped on
1115    /// that axis, by design (an `all` write's `stamp_value()` is `None`, so nothing is forced).
1116    pub fn force_scope(
1117        &mut self,
1118        write: Option<&Scope>,
1119        read: Option<&Scope>,
1120    ) -> Result<(), OrmError> {
1121        // R4/D8 target write (5b): confine BEFORE the own-write logic. A target INSERT accepts only
1122        // the SET-allowlisted columns from the guest and force-stamps the public-visibility columns,
1123        // so the inserted row lands in `B`'s public subset (the `tenant = B` stamp is applied by the
1124        // shared own-write path below, since `write_target` yields `B` for a target scope).
1125        if let Some(w) = write {
1126            if let Some(allow) = w.target_write_allowlist() {
1127                self.confine_target_insert(w, allow.is_empty())?;
1128            }
1129        }
1130        self.scope = write.cloned();
1131        // The write target's per-table stamp `(column, value)` — the actor's OWN axis (Stage 1/R3),
1132        // resolved once for the INSERT…SELECT tenant-projection re-append below. Resolving it enforces
1133        // deny-by-default at bind time (an undeclared target, an `Unscoped` target, or a scoped write
1134        // with no principal are refused). `None` ⇒ `all` mode (no stamp).
1135        let target: Option<(String, SqlValue)> = match write {
1136            Some(w) => w.write_target(&self.table)?,
1137            None => None,
1138        };
1139        // A subquery embedded in a row cell, an upsert `SET` expr, or a `RETURNING` item is a READ
1140        // of another table — scope it to that table so it can't read cross-tenant.
1141        if let Some(r) = read {
1142            for row in &mut self.rows {
1143                for cell in &mut row.cells {
1144                    inject_scope_expr(r, &mut cell.value)?;
1145                }
1146            }
1147            if let Some(c) = self.conflict.as_mut() {
1148                for a in &mut c.update {
1149                    inject_scope_expr(r, &mut a.value)?;
1150                }
1151            }
1152            for it in &mut self.returning {
1153                inject_scope_expr(r, &mut it.expr)?;
1154            }
1155        }
1156        if let Some((cols, src)) = self.from_select.as_mut() {
1157            match read {
1158                Some(r) => src.force_scope(r)?,
1159                None => src.scope = None,
1160            }
1161            // A scoped write owns the axis column written — never trust the guest's target
1162            // projection. Drop any guest-supplied owning-axis column (+ its aligned projection, in
1163            // the source and every union branch) and re-append it bound to the host value. The
1164            // column is the actor's per-table axis key (Stage 1/R3); `all` mode ⇒ `target` is `None`
1165            // ⇒ nothing is forced (a posture-vetted cross-tenant write).
1166            if let Some((column, v)) = &target {
1167                let column = column.clone();
1168                if let Some(i) = cols.iter().position(|c| same_col(c, &column)) {
1169                    cols.remove(i);
1170                    drop_projection_at(src, i);
1171                }
1172                let v = v.clone();
1173                cols.push(column);
1174                push_projection(
1175                    src,
1176                    SelectItem {
1177                        expr: Expr::Value(v),
1178                        alias: None,
1179                    },
1180                );
1181            }
1182        }
1183        Ok(())
1184    }
1185
1186    /// Confine a **target INSERT / upsert** (5b): the guest may set ONLY the route's SET-allowlisted
1187    /// columns, and the host force-stamps the table's public-visibility columns so the inserted row
1188    /// lands in `B`'s public subset. A **capability-axis `ON CONFLICT … DO UPDATE` upsert** is
1189    /// supported (own↔target parity): the INSERT arm is confined exactly as a plain target INSERT,
1190    /// and the DO UPDATE arm is confined to the write allowlist here + guarded to `tenant = B` at
1191    /// compile (`render_conflict`, via the write stamp), so the conflict-row update can never touch
1192    /// another tenant's row. Refused fail-closed on: a read-only target (`empty_allowlist`), an
1193    /// `INSERT … SELECT`, an upsert on an anonymous (`require_public`) target (the DO-UPDATE guard
1194    /// carries no visibility subset — see below), an upsert whose conflict target omits the tenant
1195    /// column, a guest cell outside the allowlist (or the tenant/visibility columns), or a public
1196    /// subset that cannot be forced to a concrete row. (`tenant = B` is stamped by the shared
1197    /// own-write path.)
1198    fn confine_target_insert(
1199        &mut self,
1200        scope: &Scope,
1201        empty_allowlist: bool,
1202    ) -> Result<(), OrmError> {
1203        // A target write may not touch a TenantOrSession (anon-session) table — refuse early and
1204        // self-describingly, before the allowlist/public-subset checks (Stage A).
1205        scope.assert_target_table_writable(&self.table)?;
1206        if empty_allowlist {
1207            return Err(OrmError::TargetWriteNotGranted(self.table.clone()));
1208        }
1209        if self.from_select.is_some() {
1210            return Err(OrmError::TargetWriteUnsupported("INSERT … SELECT"));
1211        }
1212        // A confined `ON CONFLICT … DO UPDATE` upsert IS a valid target write on the CAPABILITY axis
1213        // (the request's `embedSubmitSurvey` latest-wins pattern; own↔target parity). Two hard
1214        // requirements keep it sound; a target upsert that fails either is refused fail-closed:
1215        if let Some(oc) = &self.conflict {
1216            // (a) Capability axis only. On an anonymous `domain`/`handle` source (or any
1217            //     `target_or_null`) a plain UPDATE confines to `tenant = B AND <public subset>`, but
1218            //     the DO-UPDATE guard is `tenant = B` alone (render_conflict has no subset), so such
1219            //     an upsert could update `B`'s NON-public row on a conflict. Refuse it (unchanged
1220            //     from before — only the capability axis, whose plain UPDATE is likewise `tenant = B`
1221            //     alone post-ruling-A, is newly admitted).
1222            if scope.target_require_public() {
1223                return Err(OrmError::TargetWriteUnsupported(
1224                    "ON CONFLICT upsert on an anonymous domain/handle/target_or_null target",
1225                ));
1226            }
1227            // A conflict-target column must be a BARE name (never `t.col`): a qualified name is
1228            // invalid in an `ON CONFLICT` inference clause AND could let a `.`-spelling slip past the
1229            // tenant-key check below on `same_col`'s last-segment compare. Refuse it self-describingly
1230            // at compile (mirrors gate 0 on the SET LHS in `assert_target_settable`).
1231            for c in &oc.conflict_columns {
1232                if c.contains('.') {
1233                    return Err(OrmError::TargetWriteColumnDenied(c.clone()));
1234                }
1235            }
1236            // (b) The conflict target must include the tenant column, so a conflict is always the
1237            //     target tenant's OWN row: `B`'s INSERT still lands when another tenant holds the
1238            //     same tenant-agnostic natural key, and there is no cross-tenant no-op / existence
1239            //     oracle. (The `tenant = B` guard + dropping any SET on the tenant column are applied
1240            //     at compile by render_conflict via the write stamp — belt-and-suspenders here.)
1241            match scope.target_tenant_column(&self.table) {
1242                Some(tcol) if oc.conflict_columns.iter().any(|c| same_col(c, &tcol)) => {}
1243                _ => return Err(OrmError::TargetUpsertKeyMissingTenant(self.table.clone())),
1244            }
1245            // The DO UPDATE SET is confined exactly like a target UPDATE: only allowlisted, non-tenant,
1246            // non-visibility columns may be assigned.
1247            for a in &oc.update {
1248                scope.assert_target_settable(&self.table, &a.column)?;
1249            }
1250        }
1251        // Every guest-supplied cell must be a granted, non-tenant, non-visibility column.
1252        for row in &self.rows {
1253            for cell in &row.cells {
1254                scope.assert_target_settable(&self.table, &cell.column)?;
1255            }
1256        }
1257        // Force the public-visibility columns onto every row (deny-by-default / not-forceable checks
1258        // live in `public_force_cells`). Appended as host literals — the guest cannot have set them
1259        // (they're excluded by `assert_target_settable`), so there is no dup to reconcile.
1260        let forced = scope.public_force_cells(&self.table)?;
1261        for row in &mut self.rows {
1262            for (column, value) in &forced {
1263                row.cells.push(Assignment {
1264                    column: column.clone(),
1265                    value: Expr::Value(value.clone()),
1266                });
1267            }
1268        }
1269        Ok(())
1270    }
1271}
1272
1273/// Remove the projection at index `i` from a `SELECT` and every one-level `UNION` branch, keeping
1274/// the branches' column counts aligned (used by [`Insert::force_scope`]).
1275fn drop_projection_at(s: &mut Select, i: usize) {
1276    if i < s.columns.len() {
1277        s.columns.remove(i);
1278    }
1279    if let Some(u) = s.union.as_mut() {
1280        drop_projection_at(&mut u.query, i);
1281    }
1282}
1283
1284/// Append `item` to a `SELECT`'s projection and every one-level `UNION` branch (so both sides of
1285/// a union source stamp the same host tenant value).
1286fn push_projection(s: &mut Select, item: SelectItem) {
1287    s.columns.push(item.clone());
1288    if let Some(u) = s.union.as_mut() {
1289        push_projection(&mut u.query, item);
1290    }
1291}
1292
1293/// Conjoin `add` (if any) as the FIRST conjunct of `filter` (`filter := add AND filter`). A
1294/// no-op empty-`AND` existing filter is replaced outright, so the scope doesn't trail a spurious
1295/// `AND 1 = 1`.
1296fn conjoin_front(filter: &mut Predicate, add: Option<Predicate>) {
1297    let Some(a) = add else { return };
1298    if matches!(filter, Predicate::And(v) if v.is_empty()) {
1299        *filter = a;
1300    } else {
1301        let existing = std::mem::replace(filter, Predicate::And(Vec::new()));
1302        *filter = Predicate::And(vec![a, existing]);
1303    }
1304}
1305
1306/// Lower an [`Expr::IsOwn`] marker to a concrete `0`/`1` rank using the resolved `scope` — the
1307/// same host-resolved tenant `value` the scope predicate uses. `CASE WHEN (<col> IS NOT NULL AND
1308/// <col> = <own>) THEN 1 ELSE 0 END`: `1` for the caller's own rows, `0` for the shared `NULL`
1309/// baseline (and for other tenants under a cross-tenant `all` read). The `IS NOT NULL` guard keeps
1310/// it a proper boolean (never `NULL`) so `ORDER BY … DESC` is portable (own sorts first) across
1311/// every dialect. The column is unqualified — the base-vs-override read this serves is single-table.
1312fn own_rank_expr(scope: &Scope) -> Expr {
1313    // No resolved own-tenant value (a purely anonymous actor) ⇒ nothing ranks as "own" ⇒ constant 0.
1314    let Some(value) = scope.value.clone() else {
1315        return Expr::Value(SqlValue::Integer(0));
1316    };
1317    let col = || Expr::Column(scope.column.clone());
1318    let own = Predicate::And(vec![
1319        Predicate::Null {
1320            expr: col(),
1321            negated: true,
1322        },
1323        Predicate::Cmp {
1324            left: col(),
1325            op: CmpOp::Eq,
1326            right: Expr::Value(value),
1327        },
1328    ]);
1329    Expr::Case {
1330        branches: vec![(own, Expr::Value(SqlValue::Integer(1)))],
1331        otherwise: Some(Box::new(Expr::Value(SqlValue::Integer(0)))),
1332    }
1333}
1334
1335/// Conjoin the tenant scope for a **subquery's inner `table`** onto its `filter`, resolving that
1336/// table exactly as the top-level [`Select::scope_where_pred`] does (via [`Scope::read_pred`]): the
1337/// declared per-table column, the R3 `TenantOrSession` disjunct, **no** predicate for an `Unscoped`
1338/// reference table, and **refuse** an undeclared table or a scoped ref with no principal
1339/// (deny-by-default). This is what makes a subquery no weaker than a top-level FROM/JOIN ref — under
1340/// a `PerTable` schema a subquery can neither reach an undeclared table nor be scoped on the wrong
1341/// column. Legacy `Uniform` keys resolve to `scope.column` for every table (pre-schema behavior).
1342fn conjoin_subquery_scope(
1343    scope: &Scope,
1344    table: &str,
1345    filter: &mut Predicate,
1346) -> Result<(), OrmError> {
1347    conjoin_front(filter, scope.read_pred(table, Some(table))?);
1348    Ok(())
1349}
1350
1351/// Walk an expression and inject the tenant scope into every **narrow subquery**'s inner filter,
1352/// qualified to that subquery's own table (`<subtable>.col`) and keyed on that table's declared
1353/// per-table column, so a subquery can neither read another tenant's rows nor reach an undeclared
1354/// table. Recurses into a subquery's filter first (nested subqueries scope their own tables). The
1355/// correctness twin of [`Select::scope_where_pred`] for the subquery surface — including its
1356/// deny-by-default, so `Err(TenancyUndeclared)` propagates out and the query fails closed. Also
1357/// lowers any [`Expr::IsOwn`] marker here (where the resolved `scope` is in hand) — so an
1358/// unlowered `IsOwn` reaching the renderer means no scope was applied, and it fails closed.
1359fn inject_scope_expr(scope: &Scope, e: &mut Expr) -> Result<(), OrmError> {
1360    match e {
1361        Expr::IsOwn => *e = own_rank_expr(scope),
1362        Expr::RelatedAggregate { table, filter, .. }
1363        | Expr::RelatedScalar { table, filter, .. } => {
1364            inject_scope_pred(scope, filter)?;
1365            conjoin_subquery_scope(scope, table, filter)?;
1366        }
1367        Expr::Aggregate(_, inner) | Expr::JsonExtract(inner, _) => inject_scope_expr(scope, inner)?,
1368        Expr::Binary(_, l, r) | Expr::JsonExtractDyn(l, r) | Expr::JsonConcat(l, r) => {
1369            inject_scope_expr(scope, l)?;
1370            inject_scope_expr(scope, r)?;
1371        }
1372        Expr::Distance { left, right, .. } => {
1373            inject_scope_expr(scope, left)?;
1374            inject_scope_expr(scope, right)?;
1375        }
1376        Expr::Func(_, args) => {
1377            for a in args.iter_mut() {
1378                inject_scope_expr(scope, a)?;
1379            }
1380        }
1381        Expr::Case {
1382            branches,
1383            otherwise,
1384        } => {
1385            for (when, then) in branches {
1386                inject_scope_pred(scope, when)?;
1387                inject_scope_expr(scope, then)?;
1388            }
1389            if let Some(e) = otherwise {
1390                inject_scope_expr(scope, e)?;
1391            }
1392        }
1393        Expr::Column(_) | Expr::Value(_) | Expr::Star | Expr::VectorLiteral(_) => {}
1394    }
1395    Ok(())
1396}
1397
1398/// Walk a predicate and inject the tenant scope into every narrow subquery (see
1399/// [`inject_scope_expr`]). Propagates `Err(TenancyUndeclared)` from an undeclared subquery table.
1400fn inject_scope_pred(scope: &Scope, p: &mut Predicate) -> Result<(), OrmError> {
1401    match p {
1402        Predicate::InSubquery {
1403            expr,
1404            table,
1405            filter,
1406            ..
1407        } => {
1408            inject_scope_expr(scope, expr)?;
1409            inject_scope_pred(scope, filter)?;
1410            conjoin_subquery_scope(scope, table, filter)?;
1411        }
1412        Predicate::And(v) | Predicate::Or(v) => {
1413            for c in v.iter_mut() {
1414                inject_scope_pred(scope, c)?;
1415            }
1416        }
1417        Predicate::Not(inner) => inject_scope_pred(scope, inner)?,
1418        Predicate::Cmp { left, right, .. } => {
1419            inject_scope_expr(scope, left)?;
1420            inject_scope_expr(scope, right)?;
1421        }
1422        Predicate::Between {
1423            expr, low, high, ..
1424        } => {
1425            inject_scope_expr(scope, expr)?;
1426            inject_scope_expr(scope, low)?;
1427            inject_scope_expr(scope, high)?;
1428        }
1429        Predicate::In { expr, values, .. } => {
1430            inject_scope_expr(scope, expr)?;
1431            for v in values.iter_mut() {
1432                inject_scope_expr(scope, v)?;
1433            }
1434        }
1435        Predicate::Like { expr, .. } | Predicate::Null { expr, .. } => {
1436            inject_scope_expr(scope, expr)?;
1437        }
1438    }
1439    Ok(())
1440}
1441
1442impl Select {
1443    /// Inject the tenant scope into every narrow subquery this SELECT embeds — across ALL of its
1444    /// expr/pred-bearing fields (projection, `DISTINCT ON`, filter, having, group-by, order, and
1445    /// join `ON`s) — so a subquery's own table is scoped, not just the outer FROM. Called by
1446    /// [`Select::force_scope`] after setting the scope. Must stay exhaustive over the Expr/Predicate
1447    /// fields: a missed field is a cross-tenant subquery leak.
1448    fn inject_subquery_scope(&mut self, scope: &Scope) -> Result<(), OrmError> {
1449        for it in &mut self.columns {
1450            inject_scope_expr(scope, &mut it.expr)?;
1451        }
1452        for e in &mut self.distinct_on {
1453            inject_scope_expr(scope, e)?;
1454        }
1455        if let Some(f) = self.filter.as_mut() {
1456            inject_scope_pred(scope, f)?;
1457        }
1458        if let Some(h) = self.having.as_mut() {
1459            inject_scope_pred(scope, h)?;
1460        }
1461        for e in &mut self.group_by {
1462            inject_scope_expr(scope, e)?;
1463        }
1464        for o in &mut self.order {
1465            inject_scope_expr(scope, &mut o.expr)?;
1466        }
1467        for j in &mut self.joins {
1468            inject_scope_pred(scope, &mut j.on)?;
1469        }
1470        Ok(())
1471    }
1472}
1473
1474impl Update {
1475    /// Force a host-resolved write `scope` (conjoined into `WHERE`), also scoping any subquery in
1476    /// the `SET` exprs, filter, and `RETURNING` items. Overwrites any prior scope.
1477    ///
1478    /// **R4/D8 target write (5b):** under a target scope the guest may set ONLY the route's
1479    /// SET-allowlisted columns (never the tenant or a visibility column), and the `WHERE` is confined
1480    /// to `tenant = B AND <public>` — the tenant half via [`single_scope_pred`] at compile, the
1481    /// public half conjoined here — so an UPDATE can touch ONLY `B`'s already-public rows and cannot
1482    /// flip a row in or out of the public subset. A read-only target (empty allowlist) is refused.
1483    pub fn force_scope(&mut self, scope: &Scope) -> Result<(), OrmError> {
1484        if let Some(allow) = scope.target_write_allowlist() {
1485            // A target write may not touch a TenantOrSession (anon-session) table (Stage A).
1486            scope.assert_target_table_writable(&self.table)?;
1487            if allow.is_empty() {
1488                return Err(OrmError::TargetWriteNotGranted(self.table.clone()));
1489            }
1490            for a in &self.set {
1491                scope.assert_target_settable(&self.table, &a.column)?;
1492            }
1493            // Confine to the public subset (the `tenant = B` half is added by the compiler via
1494            // `single_scope_pred`). `public_pred` already enforces deny-by-default for an anonymous
1495            // (`require_public`) target — a subset-less table there returns `Err(PublicSubsetUndeclared)`.
1496            // A `None` here therefore means the capability-only exemption (5c ruling A): no visibility
1497            // conjunct, so the UPDATE is confined to `tenant = B` alone (+ the SET-allowlist above),
1498            // exactly like the capability read/INSERT paths.
1499            if let Some(pred) = scope.public_pred(&self.table, None)? {
1500                conjoin_front(&mut self.filter, Some(pred));
1501            }
1502        }
1503        self.scope = Some(scope.clone());
1504        for a in &mut self.set {
1505            inject_scope_expr(scope, &mut a.value)?;
1506        }
1507        inject_scope_pred(scope, &mut self.filter)?;
1508        for it in &mut self.returning {
1509            inject_scope_expr(scope, &mut it.expr)?;
1510        }
1511        Ok(())
1512    }
1513}
1514
1515impl Delete {
1516    /// Force a host-resolved write `scope` (conjoined into `WHERE`), also scoping any subquery in
1517    /// the filter and `RETURNING` items. Overwrites any prior scope. **A DELETE under a target scope
1518    /// is always refused (5b):** target writes are INSERT/UPDATE only — a cross-tenant delete is
1519    /// never granted.
1520    pub fn force_scope(&mut self, scope: &Scope) -> Result<(), OrmError> {
1521        if scope.is_target() {
1522            return Err(OrmError::TargetDeleteRefused(self.table.clone()));
1523        }
1524        self.scope = Some(scope.clone());
1525        inject_scope_pred(scope, &mut self.filter)?;
1526        for it in &mut self.returning {
1527            inject_scope_expr(scope, &mut it.expr)?;
1528        }
1529        Ok(())
1530    }
1531}
1532
1533/// Why compilation failed.
1534#[derive(Debug, Clone, PartialEq, thiserror::Error)]
1535pub enum OrmError {
1536    /// An identifier was not a plain `[A-Za-z_][A-Za-z0-9_]*` (optionally `table.column`) name.
1537    #[error("invalid identifier: {0:?}")]
1538    InvalidIdentifier(String),
1539    /// The query was structurally empty (no rows to insert, no columns to set, …).
1540    #[error("empty query: {0}")]
1541    Empty(&'static str),
1542    /// A function was called with the wrong number of arguments, or `*` was used outside
1543    /// `count(*)`.
1544    #[error("bad expression: {0}")]
1545    BadExpr(&'static str),
1546    /// A scoped query touched a table with **no** entry in the project's [`TenancySchema`]
1547    /// (deny-by-default, PLAN-tenancy-principal D3): "no key" and "forgot the key" are
1548    /// indistinguishable, so the safe collapse is to refuse rather than run it unscoped or wrongly
1549    /// scoped. `Unscoped` is the explicit, reviewed "this table is global"; an absent table is a
1550    /// misconfiguration the host surfaces (the binding names the component + marker site).
1551    #[error("tenancy: table {0:?} has no declared scope (deny-by-default)")]
1552    TenancyUndeclared(String),
1553    /// A guest WRITE (INSERT/UPDATE/DELETE) targeted a table declared `Unscoped` (global reference
1554    /// data). Reads of an `Unscoped` table are global by design, but writes are **deny-by-default**
1555    /// (a shared-data write is a cross-tenant blast — the [`TableScope::Unscoped`](crate::tenancy::TableScope::Unscoped)
1556    /// contract), so the host refuses them rather than running the write unbounded-by-tenant.
1557    #[error("tenancy: table {0:?} is Unscoped (global reference); guest writes are refused (deny-by-default)")]
1558    UnscopedWrite(String),
1559    /// A scoped read/write needed a resolved principal (an own-tenant value, or — for a
1560    /// `TenantOrSession` table — at least one of the tenant/session facts) but the request carried
1561    /// none. Fail closed: the query is refused rather than run unscoped. (The single-column raw-SQL
1562    /// path reports the equivalent `TenantDenied::NoSource` at the binding.)
1563    #[error("tenancy: no resolved principal for a scoped operation (deny-by-default)")]
1564    TenancyNoPrincipal,
1565    /// A **target read** (R4/D8) touched a table with **no** declared public subset in the project
1566    /// schema. A target scope may only read rows that satisfy each accessed table's host-held public
1567    /// predicate, so a table (root or any joined/subquery ref) that declares none is refused —
1568    /// deny-by-default, the strict analog of [`TenancyUndeclared`]. This is what keeps a target read
1569    /// from ever reaching another tenant's PRIVATE rows through an un-confined table.
1570    #[error(
1571        "tenancy: table {0:?} has no declared public subset for a target read (deny-by-default)"
1572    )]
1573    PublicSubsetUndeclared(String),
1574    /// A WRITE (INSERT/UPDATE) was force-scoped under a **target** scope whose SET-allowlist is empty
1575    /// — i.e. a target route with no `write` grant is read-only (5b, deny-by-default). Refused before
1576    /// any SQL.
1577    #[error("tenancy: target route {0:?} has no write grant (read-only; deny-by-default)")]
1578    TargetWriteNotGranted(String),
1579    /// A target write tried to set a column that is not in the route's SET-allowlist — the tenant
1580    /// column, a public-visibility column, or any other un-granted column. Refused fail-closed so a
1581    /// target write can never change ownership, flip visibility, or touch a non-granted column.
1582    #[error("tenancy: target write may not set column {0:?} (not in the write allowlist)")]
1583    TargetWriteColumnDenied(String),
1584    /// A `DELETE` was attempted under a target scope. Target writes are INSERT/UPDATE only; a target
1585    /// DELETE is always refused (a cross-tenant delete is never granted).
1586    #[error("tenancy: a target-tenant DELETE is refused (target writes are INSERT/UPDATE only)")]
1587    TargetDeleteRefused(String),
1588    /// A target INSERT could not force a table's public subset to a concrete row: a public term that
1589    /// is not `column = <literal>` or `column IS NULL` (e.g. a range or `IS NOT NULL`) has no single
1590    /// value to stamp, so the host cannot guarantee the inserted row lands in the public subset —
1591    /// refused (deny-by-default). Such a subset is read-/update-only, never target-insertable.
1592    #[error("tenancy: target INSERT cannot force table {0:?} into its public subset (a non-equality/non-null public term); refused")]
1593    PublicSubsetNotForceable(String),
1594    /// A target write used a shape the confinement does not support: an `INSERT … SELECT`, an
1595    /// `ON CONFLICT` upsert on an anonymous (domain/handle/`target_or_null`) target, or a `promote`.
1596    /// These could reach rows outside the public subset (a selected source; or a conflict-row DO
1597    /// UPDATE whose `tenant = B` guard carries no visibility subset, so an anonymous upsert could
1598    /// touch `B`'s non-public row). A **capability**-axis `ON CONFLICT … DO UPDATE` upsert IS
1599    /// supported (own↔target parity — see [`Insert::confine_target_insert`]); the rest are refused.
1600    #[error("tenancy: unsupported target write shape ({0}); target writes are a plain INSERT, a confined UPDATE, or a capability-axis ON CONFLICT DO UPDATE upsert")]
1601    TargetWriteUnsupported(&'static str),
1602    /// A capability-axis target upsert (`ON CONFLICT … DO UPDATE`) whose conflict target does NOT
1603    /// include the table's tenant column. Required so a conflict is always a same-tenant (`B`) row:
1604    /// otherwise `B`'s INSERT could conflict with another tenant `A`'s row on a tenant-agnostic
1605    /// natural key, and the `tenant = B`-guarded DO UPDATE would silently no-op — dropping `B`'s
1606    /// write AND leaking that `A` holds that key (a cross-tenant existence oracle). Refused
1607    /// fail-closed; add the tenant column to the conflict target (e.g. `(tenant_id, …)`).
1608    #[error("tenancy: a target upsert's ON CONFLICT target must include the tenant column for table {0:?} (so a conflict is always the target tenant's own row)")]
1609    TargetUpsertKeyMissingTenant(String),
1610    /// A target write (INSERT/UPDATE) touched a `TenantOrSession` (anonymous-session-keyed) table. A
1611    /// target principal carries only the target tenant `B` (no session fact), so the host cannot write
1612    /// such a row session-scoped — it could only stamp `tenant = B`, which would silently claim an
1613    /// anon/session-owned row for `B` and break the anon→promotion model. Refused deny-by-default:
1614    /// write a `TenantOrSession` table on the caller's own/session-scoped path, never under a target
1615    /// scope. (PLAN-delegable-capabilities, Stage A.)
1616    #[error("tenancy: target write may not touch TenantOrSession table {0:?} (no session fact under a target scope — write it on the session-scoped path)")]
1617    TargetWriteToSessionTable(String),
1618}
1619
1620/// The compiled statement: `?N` SQL plus its bound parameters, in placeholder order.
1621pub type Compiled = (String, Vec<SqlValue>);
1622
1623/// Validate a plain identifier or a `table.column` qualified one. Emitted unquoted, so this
1624/// is the *only* thing standing between a caller-supplied name and the SQL text.
1625fn ident(name: &str) -> Result<&str, OrmError> {
1626    let ok = |s: &str| {
1627        let mut cs = s.chars();
1628        matches!(cs.next(), Some(c) if c == '_' || c.is_ascii_alphabetic())
1629            && s.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
1630    };
1631    let valid = match name.split_once('.') {
1632        Some((t, c)) => !t.is_empty() && !c.is_empty() && ok(t) && ok(c),
1633        None => ok(name),
1634    };
1635    if valid {
1636        Ok(name)
1637    } else {
1638        Err(OrmError::InvalidIdentifier(name.to_string()))
1639    }
1640}
1641
1642/// Whether two identifiers name the **same column** the way the engines resolve unquoted names:
1643/// ASCII-case-insensitively, ignoring a leading `table.` qualifier. Used by the tenant-scope
1644/// guards so a guest can't dodge them by re-spelling the tenant column (`TENANT_ID`, `t.tenant_id`)
1645/// — the DB would still resolve it to the tenant column, but a naive `==` would miss it.
1646fn same_col(a: &str, b: &str) -> bool {
1647    let base = |s: &str| s.rsplit('.').next().unwrap_or(s).to_ascii_lowercase();
1648    base(a) == base(b)
1649}
1650
1651/// Accumulates the parameter list and mints `?N` placeholders in order.
1652#[derive(Default)]
1653struct Params(Vec<SqlValue>);
1654
1655impl Params {
1656    fn bind(&mut self, v: SqlValue) -> String {
1657        self.0.push(v);
1658        format!("?{}", self.0.len())
1659    }
1660}
1661
1662/// Render a scalar expression, binding any literals.
1663fn render_expr(e: &Expr, params: &mut Params, dialect: Dialect) -> Result<String, OrmError> {
1664    Ok(match e {
1665        Expr::Column(name) => ident(name)?.to_string(),
1666        Expr::Value(v) => params.bind(v.clone()),
1667        Expr::Star => {
1668            return Err(OrmError::BadExpr(
1669                "`*` is only valid as the count(*) argument",
1670            ))
1671        }
1672        Expr::Aggregate(agg, inner) => {
1673            let arg = match inner.as_ref() {
1674                Expr::Star if *agg == Agg::Count => "*".to_string(),
1675                Expr::Star => return Err(OrmError::BadExpr("`*` is only valid as count(*)")),
1676                other => render_expr(other, params, dialect)?,
1677            };
1678            format!("{}({arg})", agg.keyword())
1679        }
1680        Expr::Binary(op, l, r) => {
1681            format!(
1682                "({} {} {})",
1683                render_expr(l, params, dialect)?,
1684                op.symbol(),
1685                render_expr(r, params, dialect)?
1686            )
1687        }
1688        Expr::Func(f, args) => {
1689            let (name, min, max) = f.spec();
1690            if args.len() < min || max.is_some_and(|m| args.len() > m) {
1691                return Err(OrmError::BadExpr("function called with the wrong arity"));
1692            }
1693            if args.is_empty() {
1694                // Nullary (`current_timestamp`) renders without parentheses (ANSI form).
1695                name.to_string()
1696            } else {
1697                let rendered: Result<Vec<String>, _> = args
1698                    .iter()
1699                    .map(|a| render_expr(a, params, dialect))
1700                    .collect();
1701                format!("{name}({})", rendered?.join(", "))
1702            }
1703        }
1704        Expr::JsonExtract(inner, path) => {
1705            if path.is_empty() {
1706                return Err(OrmError::BadExpr("json extract needs at least one key"));
1707            }
1708            // Each key is validated as an identifier — the built path can't inject.
1709            for k in path {
1710                ident(k)?;
1711            }
1712            let base = render_expr(inner, params, dialect)?;
1713            match dialect {
1714                // Postgres: `(base) #>> '{a,b}'` — keys validated, safe to inline (there is
1715                // no portable way to bind a `text[]` path here).
1716                Dialect::Postgres => format!("({base}) #>> '{{{}}}'", path.join(",")),
1717                // SQLite/MySQL: `json_extract(base, ?N)` with the `$.a.b` path bound.
1718                Dialect::Sqlite | Dialect::Mysql => {
1719                    let p = params.bind(SqlValue::Text(format!("$.{}", path.join("."))));
1720                    format!("json_extract({base}, {p})")
1721                }
1722            }
1723        }
1724        Expr::Distance {
1725            left,
1726            right,
1727            metric,
1728        } => {
1729            if dialect != Dialect::Postgres {
1730                return Err(OrmError::BadExpr("vector distance is Postgres-only"));
1731            }
1732            format!(
1733                "({} {} {})",
1734                render_expr(left, params, dialect)?,
1735                metric.operator(),
1736                render_expr(right, params, dialect)?,
1737            )
1738        }
1739        Expr::VectorLiteral(v) => {
1740            if dialect != Dialect::Postgres {
1741                return Err(OrmError::BadExpr("vector literals are Postgres-only"));
1742            }
1743            let p = params.bind(SqlValue::Text(vector_literal(v)?));
1744            // The `::vector` cast rides through the `?N` placeholder normaliser unchanged.
1745            format!("{p}::vector")
1746        }
1747        Expr::RelatedAggregate {
1748            agg,
1749            arg,
1750            table,
1751            filter,
1752        } => {
1753            let arg_sql = match arg {
1754                RelArg::Star if *agg == Agg::Count => "*".to_string(),
1755                RelArg::Star => return Err(OrmError::BadExpr("`*` is only valid as count(*)")),
1756                RelArg::Column(c) => ident(c)?.to_string(),
1757            };
1758            let table_sql = ident(table)?;
1759            // The correlated filter reuses the ordinary predicate compiler (bound params); the
1760            // WHERE clause delimits it, so it renders unparenthesised (`nested = false`).
1761            let where_sql = render_pred(filter, params, false, dialect)?;
1762            format!(
1763                "(SELECT {}({arg_sql}) FROM {table_sql} WHERE {where_sql})",
1764                agg.keyword()
1765            )
1766        }
1767        Expr::RelatedScalar {
1768            column,
1769            table,
1770            filter,
1771        } => {
1772            let col_sql = ident(column)?;
1773            let table_sql = ident(table)?;
1774            let where_sql = render_pred(filter, params, false, dialect)?;
1775            format!("(SELECT {col_sql} FROM {table_sql} WHERE {where_sql})")
1776        }
1777        Expr::JsonExtractDyn(base, key) => {
1778            if matches!(dialect, Dialect::Mysql) {
1779                return Err(OrmError::BadExpr(
1780                    "dynamic-key json extract (->> <bound>) is not supported on MySQL",
1781                ));
1782            }
1783            format!(
1784                "({} ->> {})",
1785                render_expr(base, params, dialect)?,
1786                render_expr(key, params, dialect)?,
1787            )
1788        }
1789        Expr::JsonConcat(left, right) => {
1790            if dialect != Dialect::Postgres {
1791                return Err(OrmError::BadExpr("json concat (||) is Postgres-only"));
1792            }
1793            format!(
1794                "({} || {})",
1795                render_expr(left, params, dialect)?,
1796                render_expr(right, params, dialect)?,
1797            )
1798        }
1799        Expr::Case {
1800            branches,
1801            otherwise,
1802        } => {
1803            if branches.is_empty() {
1804                return Err(OrmError::BadExpr("CASE has no WHEN branches"));
1805            }
1806            let mut s = String::from("CASE");
1807            for (when, then) in branches {
1808                // Params bind in textual order: each WHEN before its THEN, branches in order,
1809                // ELSE last — matching how `render_pred`/`render_expr` push placeholders.
1810                let w = render_pred(when, params, false, dialect)?;
1811                let t = render_expr(then, params, dialect)?;
1812                s.push_str(&format!(" WHEN {w} THEN {t}"));
1813            }
1814            if let Some(e) = otherwise {
1815                let e = render_expr(e, params, dialect)?;
1816                s.push_str(&format!(" ELSE {e}"));
1817            }
1818            s.push_str(" END");
1819            format!("({s})")
1820        }
1821        // Reaching here means the marker was never lowered — i.e. no own-tenant scope was applied
1822        // to this query (an unscoped / `disabled` / cross-tenant-`all`-without-value function). Fail
1823        // closed rather than emit an unscoped ranking.
1824        Expr::IsOwn => {
1825            return Err(OrmError::BadExpr(
1826                "is_own()/own_first() requires an own-tenant (own or own+null) read scope",
1827            ))
1828        }
1829    })
1830}
1831
1832/// Validate a `pgvector` literal — a bracketed, comma-separated list of finite numbers
1833/// (`[0.1, 0.2]`) — returning it whitespace-normalised. The result binds as a parameter, so
1834/// this is a data-quality gate (a clear early error over a Postgres runtime failure), not an
1835/// injection defence.
1836fn vector_literal(s: &str) -> Result<String, OrmError> {
1837    let inner = s
1838        .trim()
1839        .strip_prefix('[')
1840        .and_then(|x| x.strip_suffix(']'))
1841        .ok_or(OrmError::BadExpr(
1842            "vector literal must be a bracketed list like [0.1, 0.2]",
1843        ))?;
1844    if inner.trim().is_empty() {
1845        return Err(OrmError::BadExpr(
1846            "vector literal must have at least one component",
1847        ));
1848    }
1849    let mut parts = Vec::new();
1850    for part in inner.split(',') {
1851        let p = part.trim();
1852        let f: f64 = p
1853            .parse()
1854            .map_err(|_| OrmError::BadExpr("vector literal component is not a number"))?;
1855        if !f.is_finite() {
1856            return Err(OrmError::BadExpr("vector literal component must be finite"));
1857        }
1858        parts.push(p);
1859    }
1860    Ok(format!("[{}]", parts.join(",")))
1861}
1862
1863/// Render a predicate; `nested` parenthesizes a compound (`AND`/`OR`) so precedence is explicit.
1864fn render_pred(
1865    p: &Predicate,
1866    params: &mut Params,
1867    nested: bool,
1868    dialect: Dialect,
1869) -> Result<String, OrmError> {
1870    let compound = |body: String| {
1871        if nested {
1872            format!("({body})")
1873        } else {
1874            body
1875        }
1876    };
1877    Ok(match p {
1878        Predicate::And(ps) => {
1879            if ps.is_empty() {
1880                "1 = 1".to_string()
1881            } else {
1882                let parts: Result<Vec<String>, _> = ps
1883                    .iter()
1884                    .map(|c| render_pred(c, params, true, dialect))
1885                    .collect();
1886                compound(parts?.join(" AND "))
1887            }
1888        }
1889        Predicate::Or(ps) => {
1890            if ps.is_empty() {
1891                "1 = 0".to_string()
1892            } else {
1893                let parts: Result<Vec<String>, _> = ps
1894                    .iter()
1895                    .map(|c| render_pred(c, params, true, dialect))
1896                    .collect();
1897                compound(parts?.join(" OR "))
1898            }
1899        }
1900        Predicate::Not(inner) => format!("NOT {}", render_pred(inner, params, true, dialect)?),
1901        Predicate::Cmp { left, op, right } => format!(
1902            "{} {} {}",
1903            render_expr(left, params, dialect)?,
1904            op.symbol(),
1905            render_expr(right, params, dialect)?
1906        ),
1907        Predicate::Between {
1908            expr,
1909            low,
1910            high,
1911            negated,
1912        } => format!(
1913            "{} {}BETWEEN {} AND {}",
1914            render_expr(expr, params, dialect)?,
1915            if *negated { "NOT " } else { "" },
1916            render_expr(low, params, dialect)?,
1917            render_expr(high, params, dialect)?
1918        ),
1919        Predicate::In {
1920            expr,
1921            values,
1922            negated,
1923        } => {
1924            if values.is_empty() {
1925                // `IN ()` is a syntax error; render the matching identity.
1926                if *negated { "1 = 1" } else { "1 = 0" }.to_string()
1927            } else {
1928                let lhs = render_expr(expr, params, dialect)?;
1929                let ph: Result<Vec<String>, _> = values
1930                    .iter()
1931                    .map(|v| render_expr(v, params, dialect))
1932                    .collect();
1933                format!(
1934                    "{lhs} {}IN ({})",
1935                    if *negated { "NOT " } else { "" },
1936                    ph?.join(", ")
1937                )
1938            }
1939        }
1940        Predicate::Like {
1941            expr,
1942            pattern,
1943            insensitive,
1944            negated,
1945        } => {
1946            let neg = if *negated { "NOT " } else { "" };
1947            let lhs = render_expr(expr, params, dialect)?;
1948            let pat = params.bind(SqlValue::Text(pattern.clone()));
1949            if *insensitive {
1950                // Portable case-insensitive LIKE (no dialect-specific ILIKE).
1951                format!("lower({lhs}) {neg}LIKE lower({pat})")
1952            } else {
1953                format!("{lhs} {neg}LIKE {pat}")
1954            }
1955        }
1956        Predicate::Null { expr, negated } => format!(
1957            "{} IS {}NULL",
1958            render_expr(expr, params, dialect)?,
1959            if *negated { "NOT " } else { "" }
1960        ),
1961        Predicate::InSubquery {
1962            expr,
1963            column,
1964            table,
1965            filter,
1966            negated,
1967        } => {
1968            let lhs = render_expr(expr, params, dialect)?;
1969            let col_sql = ident(column)?;
1970            let table_sql = ident(table)?;
1971            let where_sql = render_pred(filter, params, false, dialect)?;
1972            let not = if *negated { "NOT " } else { "" };
1973            format!("{lhs} {not}IN (SELECT {col_sql} FROM {table_sql} WHERE {where_sql})")
1974        }
1975    })
1976}
1977
1978/// Render the `WHERE` body from a pre-built scope predicate + optional filter (scope conjoined
1979/// first). The scope predicate is built by the caller — single-table for UPDATE/DELETE
1980/// ([`single_scope_pred`]), multi-table-qualified for a SELECT with joins
1981/// ([`Select::scope_where_pred`]).
1982fn render_where(
1983    scope_pred: Option<Predicate>,
1984    filter: Option<&Predicate>,
1985    params: &mut Params,
1986    dialect: Dialect,
1987) -> Result<Option<String>, OrmError> {
1988    // An empty `AND` filter is a no-op (always true) — drop it so it never adds a spurious
1989    // `AND 1 = 1`. (An empty `OR` means "match nothing" and is kept.)
1990    let filter = filter.filter(|f| !matches!(f, Predicate::And(v) if v.is_empty()));
1991    // A lone clause renders directly (no wrapping `AND`, so a top-level `AND`/`OR` filter
1992    // isn't spuriously parenthesized); scope + filter conjoin as `scope AND (filter)`.
1993    let combined = match (scope_pred, filter) {
1994        (None, None) => return Ok(None),
1995        (Some(s), None) => s,
1996        (None, Some(f)) => f.clone(),
1997        (Some(s), Some(f)) => Predicate::And(vec![s, f.clone()]),
1998    };
1999    Ok(Some(render_pred(&combined, params, false, dialect)?))
2000}
2001
2002/// The single-table scope predicate for an UPDATE/DELETE `WHERE`, bounding the write to the actor's
2003/// OWN partition via [`Scope::write_target`]: a plain tenant table bounds `tenant_col = <own>` (or
2004/// `tenant_col IS NULL` for the explicit null-baseline grant); a `TenantOrSession` table bounds the
2005/// single axis the actor holds (`tenant_col = T` authenticated, else `session_col = S`). `Ok(None)`
2006/// ⇒ `All` (no bound) or no forced scope. An `Unscoped` target is refused (`UnscopedWrite`), an
2007/// undeclared one too (`TenancyUndeclared`), and a scoped write with no principal
2008/// (`TenancyNoPrincipal`) — deny-by-default.
2009fn single_scope_pred(scope: Option<&Scope>, table: &str) -> Result<Option<Predicate>, OrmError> {
2010    let Some(s) = scope else { return Ok(None) };
2011    match s.write_target(table)? {
2012        None => Ok(None), // All — no bound
2013        Some((col, value)) => {
2014            ident(&col)?;
2015            let col_expr = Expr::Column(col);
2016            // A NULL stamp value is the explicit null-baseline grant ⇒ `IS NULL`; any real tenant /
2017            // session value ⇒ `= value`. (Own/session values are never NULL, so this is unambiguous.)
2018            let pred = if matches!(value, SqlValue::Null) {
2019                Predicate::Null {
2020                    expr: col_expr,
2021                    negated: false,
2022                }
2023            } else {
2024                Predicate::Cmp {
2025                    left: col_expr,
2026                    op: CmpOp::Eq,
2027                    right: Expr::Value(value),
2028                }
2029            };
2030            Ok(Some(pred))
2031        }
2032    }
2033}
2034
2035/// Render a select list (empty ⇒ `*`).
2036fn render_select_items(
2037    items: &[SelectItem],
2038    params: &mut Params,
2039    dialect: Dialect,
2040) -> Result<String, OrmError> {
2041    if items.is_empty() {
2042        return Ok("*".to_string());
2043    }
2044    let parts: Result<Vec<String>, _> = items
2045        .iter()
2046        .map(|it| {
2047            let e = render_expr(&it.expr, params, dialect)?;
2048            Ok::<String, OrmError>(match &it.alias {
2049                Some(a) => format!("{e} AS {}", ident(a)?),
2050                None => e,
2051            })
2052        })
2053        .collect();
2054    Ok(parts?.join(", "))
2055}
2056
2057/// Render a `RETURNING` clause, if any.
2058fn render_returning(
2059    items: &[SelectItem],
2060    params: &mut Params,
2061    dialect: Dialect,
2062) -> Result<String, OrmError> {
2063    if items.is_empty() {
2064        Ok(String::new())
2065    } else {
2066        Ok(format!(
2067            " RETURNING {}",
2068            render_select_items(items, params, dialect)?
2069        ))
2070    }
2071}
2072
2073impl Select {
2074    /// A `SELECT * FROM <table>` to refine with the public fields.
2075    pub fn from(table: impl Into<String>) -> Self {
2076        Self {
2077            table: table.into(),
2078            table_alias: None,
2079            columns: Vec::new(),
2080            joins: Vec::new(),
2081            filter: None,
2082            scope: None,
2083            group_by: Vec::new(),
2084            having: None,
2085            distinct: false,
2086            distinct_on: Vec::new(),
2087            order: Vec::new(),
2088            limit: None,
2089            offset: None,
2090            union: None,
2091        }
2092    }
2093
2094    /// Compile to `?N` SQL + bound parameters for the given dialect. A `UNION` branch renders
2095    /// after the body, sharing the placeholder sequence (so binds stay in textual order).
2096    pub fn compile(&self, dialect: Dialect) -> Result<Compiled, OrmError> {
2097        let mut params = Params::default();
2098        let sql = self.render_into(&mut params, dialect)?;
2099        Ok((sql, params.0))
2100    }
2101
2102    /// The scope predicate to conjoin into this SELECT's `WHERE`. With **no joins** it's the
2103    /// single-table (unqualified) predicate. With joins, the per-mode predicate is applied to the
2104    /// FROM table plus each **INNER** join, qualified by its alias-or-name, so a guest can't read a
2105    /// joined table's cross-tenant rows through the projection (a join to a table lacking the tenant
2106    /// column then fails closed at the DB, not leaks). A **LEFT-OUTER** join is confined in its own
2107    /// `ON` at [`Select::force_scope`] instead — a WHERE predicate on the nullable side would
2108    /// collapse the LEFT JOIN to an INNER JOIN — so it is skipped here. `all`/no-scope ⇒ `None`.
2109    fn scope_where_pred(&self) -> Result<Option<Predicate>, OrmError> {
2110        let Some(scope) = &self.scope else {
2111            return Ok(None);
2112        };
2113        if self.joins.is_empty() {
2114            // Single table: its per-table read predicate (the tenant column, the R3 disjunct, or
2115            // `None` for an `Unscoped` global; deny-by-default / no-principal fail closed).
2116            return scope.read_pred(&self.table, None);
2117        }
2118        // Joined: the FROM table and each INNER-joined table are scoped on their OWN resolved key,
2119        // qualified by alias-or-name, so a guest can't read a joined table's cross-tenant rows
2120        // through the projection. LEFT-joined tables are confined in their own `ON` (force_scope),
2121        // NOT here. A ref whose table is undeclared fails closed (deny-by-default); an `Unscoped`
2122        // ref adds no predicate (it is global by declaration).
2123        let refs: Vec<(&str, &str)> = std::iter::once((
2124            self.table.as_str(),
2125            self.table_alias.as_deref().unwrap_or(&self.table),
2126        ))
2127        .chain(
2128            self.joins
2129                .iter()
2130                .filter(|j| matches!(j.kind, JoinKind::Inner))
2131                .map(|j| (j.table.as_str(), j.alias.as_deref().unwrap_or(&j.table))),
2132        )
2133        .collect();
2134        let mut parts: Vec<Predicate> = Vec::with_capacity(refs.len());
2135        for (table, qual) in refs {
2136            ident(qual)?;
2137            if let Some(p) = scope.read_pred(table, Some(qual))? {
2138                parts.push(p);
2139            }
2140        }
2141        Ok((!parts.is_empty()).then_some(Predicate::And(parts)))
2142    }
2143
2144    /// Render the full SELECT (body + any UNION branch) into the shared `params`. Reused by
2145    /// `INSERT … SELECT` so a source select shares the outer placeholder sequence. Module-private
2146    /// because `Params` is (Insert::compile, same module, is the other caller).
2147    fn render_into(&self, params: &mut Params, dialect: Dialect) -> Result<String, OrmError> {
2148        let mut sql = self.render_body(params, dialect)?;
2149        if let Some(u) = &self.union {
2150            let kw = if u.all { "UNION ALL" } else { "UNION" };
2151            let branch = u.query.render_body(params, dialect)?;
2152            sql.push_str(&format!(" {kw} {branch}"));
2153        }
2154        Ok(sql)
2155    }
2156
2157    /// Render one SELECT body (no UNION) into the shared `params`.
2158    fn render_body(&self, params: &mut Params, dialect: Dialect) -> Result<String, OrmError> {
2159        let table = ident(&self.table)?;
2160
2161        // The DISTINCT clause renders before the select list so any bound params order correctly.
2162        let distinct = if !self.distinct_on.is_empty() {
2163            if dialect != Dialect::Postgres {
2164                return Err(OrmError::BadExpr("DISTINCT ON is Postgres-only"));
2165            }
2166            let cols = self
2167                .distinct_on
2168                .iter()
2169                .map(|e| render_expr(e, &mut *params, dialect))
2170                .collect::<Result<Vec<_>, _>>()?;
2171            format!("DISTINCT ON ({}) ", cols.join(", "))
2172        } else if self.distinct {
2173            "DISTINCT ".to_string()
2174        } else {
2175            String::new()
2176        };
2177        let select_list = render_select_items(&self.columns, &mut *params, dialect)?;
2178        let mut sql = format!("SELECT {distinct}{select_list} FROM {table}");
2179        if let Some(a) = &self.table_alias {
2180            sql.push_str(&format!(" AS {}", ident(a)?));
2181        }
2182
2183        for j in &self.joins {
2184            let jt = ident(&j.table)?;
2185            let kw = match j.kind {
2186                JoinKind::Inner => "JOIN",
2187                JoinKind::Left => "LEFT JOIN",
2188            };
2189            sql.push_str(&format!(" {kw} {jt}"));
2190            if let Some(a) = &j.alias {
2191                sql.push_str(&format!(" AS {}", ident(a)?));
2192            }
2193            // A **LEFT**-joined table is confined in its OWN `ON`, not the top-level `WHERE`
2194            // (`scope_where_pred` scopes the FROM table + INNER joins there and deliberately SKIPS
2195            // LEFT joins): a WHERE predicate on the nullable side would collapse the LEFT JOIN to an
2196            // INNER JOIN — dropping the driving row when there is no match, so a
2197            // `COALESCE(joined.col, driving.col)` fallback would never fire. Conjoined into the `ON`,
2198            // an unmatched / other-tenant row instead becomes `NULL` — never a cross-tenant bleed
2199            // (the tenant/public gate is AND-ed into the join condition, and the structured predicate
2200            // renders with correct precedence, so a top-level OR in the guest `ON` cannot widen past
2201            // the gate). Applied at RENDER (not `force_scope`), so confinement never depends on which
2202            // entry point set the scope. INNER joins are confined in the WHERE by `scope_where_pred`.
2203            let qual = j.alias.as_deref().unwrap_or(&j.table);
2204            let on = match (&self.scope, j.kind) {
2205                (Some(scope), JoinKind::Left) => match scope.read_pred(&j.table, Some(qual))? {
2206                    Some(conf) => Predicate::And(vec![j.on.clone(), conf]),
2207                    None => j.on.clone(),
2208                },
2209                _ => j.on.clone(),
2210            };
2211            sql.push_str(&format!(
2212                " ON {}",
2213                render_pred(&on, &mut *params, false, dialect)?
2214            ));
2215        }
2216
2217        if let Some(w) = render_where(
2218            self.scope_where_pred()?,
2219            self.filter.as_ref(),
2220            &mut *params,
2221            dialect,
2222        )? {
2223            sql.push_str(&format!(" WHERE {w}"));
2224        }
2225
2226        if !self.group_by.is_empty() {
2227            let terms: Result<Vec<String>, _> = self
2228                .group_by
2229                .iter()
2230                .map(|e| render_expr(e, &mut *params, dialect))
2231                .collect();
2232            sql.push_str(&format!(" GROUP BY {}", terms?.join(", ")));
2233        }
2234
2235        if let Some(h) = &self.having {
2236            sql.push_str(&format!(
2237                " HAVING {}",
2238                render_pred(h, &mut *params, false, dialect)?
2239            ));
2240        }
2241
2242        if !self.order.is_empty() {
2243            let terms: Result<Vec<String>, _> = self
2244                .order
2245                .iter()
2246                .map(|o| {
2247                    let e = render_expr(&o.expr, &mut *params, dialect)?;
2248                    let d = match o.dir {
2249                        Direction::Asc => "ASC",
2250                        Direction::Desc => "DESC",
2251                    };
2252                    Ok::<String, OrmError>(format!("{e} {d}"))
2253                })
2254                .collect();
2255            sql.push_str(&format!(" ORDER BY {}", terms?.join(", ")));
2256        }
2257
2258        if let Some(n) = self.limit {
2259            sql.push_str(&format!(" LIMIT {n}"));
2260        }
2261        if let Some(n) = self.offset {
2262            sql.push_str(&format!(" OFFSET {n}"));
2263        }
2264
2265        Ok(sql)
2266    }
2267}
2268
2269impl Insert {
2270    /// Compile to `?N` SQL + bound parameters for the given dialect.
2271    pub fn compile(&self, dialect: Dialect) -> Result<Compiled, OrmError> {
2272        let table = ident(&self.table)?;
2273        let mut params = Params::default();
2274
2275        // The write target's per-table stamp `(column, value)` — the actor's OWN axis (tenant when
2276        // authenticated, the anon session for a `TenantOrSession` table otherwise; PLAN R2/R3).
2277        // Resolving it enforces deny-by-default for BOTH the VALUES and INSERT…SELECT forms — an
2278        // undeclared target, an `Unscoped` (global) target, or a scoped write with no principal are
2279        // refused here. `None` ⇒ `all` mode (no stamp — a posture-vetted cross-tenant write) or no
2280        // forced scope.
2281        let stamp: Option<(String, SqlValue)> = match self.scope.as_ref() {
2282            Some(s) => s.write_target(&self.table)?,
2283            None => None,
2284        };
2285
2286        // INSERT … SELECT: rows come from a source query sharing the placeholder sequence.
2287        if let Some((cols, select)) = &self.from_select {
2288            let col_sql = cols
2289                .iter()
2290                .map(|c| ident(c).map(str::to_string))
2291                .collect::<Result<Vec<_>, _>>()?;
2292            if col_sql.is_empty() {
2293                return Err(OrmError::Empty("insert-select has no columns"));
2294            }
2295            let select_sql = select.render_into(&mut params, dialect)?;
2296            let mut sql = format!("INSERT INTO {table} ({}) {select_sql}", col_sql.join(", "));
2297            sql.push_str(&render_conflict(
2298                self.conflict.as_ref(),
2299                table,
2300                stamp.as_ref().map(|(c, v)| (c.as_str(), v)),
2301                &mut params,
2302                dialect,
2303            )?);
2304            sql.push_str(&render_returning(&self.returning, &mut params, dialect)?);
2305            return Ok((sql, params.0));
2306        }
2307
2308        if self.rows.is_empty() {
2309            return Err(OrmError::Empty("insert has no rows"));
2310        }
2311
2312        // Column set: from the first row (+ the scope column if forced), in a stable order.
2313        // Every row is coerced to exactly these columns; the scope value overrides.
2314        let mut columns: Vec<String> = Vec::new();
2315        for a in &self.rows[0].cells {
2316            let c = ident(&a.column)?.to_string();
2317            if !columns.contains(&c) {
2318                columns.push(c);
2319            }
2320        }
2321        // The resolved stamp (own tenant, the null baseline, or the anon session value) forces its
2322        // per-table column into every row. `all` mode / no forced scope stamps nothing (`stamp` is
2323        // `None`). The column match is case/qualifier-insensitive (`same_col`) so a guest can't
2324        // smuggle its own value into the stamped column by re-spelling it (`TENANT_ID`, `t.tenant_id`).
2325        if let Some((column, _)) = &stamp {
2326            let c = ident(column)?.to_string();
2327            if !columns.iter().any(|existing| same_col(existing, &c)) {
2328                columns.push(c);
2329            }
2330        }
2331        if columns.is_empty() {
2332            return Err(OrmError::Empty("insert row has no columns"));
2333        }
2334
2335        let mut value_groups: Vec<String> = Vec::new();
2336        for row in &self.rows {
2337            let mut ph: Vec<String> = Vec::with_capacity(columns.len());
2338            for col in &columns {
2339                // The scope forces its column to the resolved stamp; otherwise take the row's
2340                // cell expr, else NULL.
2341                if let Some((column, value)) = &stamp {
2342                    if same_col(column, col) {
2343                        ph.push(params.bind(value.clone()));
2344                        continue;
2345                    }
2346                }
2347                match row.cells.iter().find(|a| same_col(&a.column, col)) {
2348                    Some(a) => ph.push(render_expr(&a.value, &mut params, dialect)?),
2349                    None => ph.push(params.bind(SqlValue::Null)),
2350                }
2351            }
2352            value_groups.push(format!("({})", ph.join(", ")));
2353        }
2354
2355        let mut sql = format!(
2356            "INSERT INTO {table} ({}) VALUES {}",
2357            columns.join(", "),
2358            value_groups.join(", ")
2359        );
2360
2361        sql.push_str(&render_conflict(
2362            self.conflict.as_ref(),
2363            table,
2364            stamp.as_ref().map(|(c, v)| (c.as_str(), v)),
2365            &mut params,
2366            dialect,
2367        )?);
2368        sql.push_str(&render_returning(&self.returning, &mut params, dialect)?);
2369        Ok((sql, params.0))
2370    }
2371}
2372
2373/// Render an `ON CONFLICT (...) DO NOTHING|UPDATE SET ...` clause (empty when `None`). The
2374/// DO UPDATE assignments bind params, so it takes the shared [`Params`].
2375///
2376/// Under a tenant scope with a stampable value (own/null — `all` bounds nothing), the DO UPDATE is
2377/// **bounded to the tenant's own rows** so a guest upsert can't overwrite another tenant's row via
2378/// a conflict on a non-tenant-partitioned key, and any assignment targeting the scope column is
2379/// **dropped** so the tenant of an existing row is never reassigned. MySQL's `ON DUPLICATE KEY
2380/// UPDATE` can't carry that bound, so a scoped upsert on MySQL is refused (fail-closed).
2381fn render_conflict(
2382    conflict: Option<&OnConflict>,
2383    table: &str,
2384    stamp: Option<(&str, &SqlValue)>,
2385    params: &mut Params,
2386    dialect: Dialect,
2387) -> Result<String, OrmError> {
2388    let Some(oc) = conflict else {
2389        return Ok(String::new());
2390    };
2391    let conflict_cols = oc
2392        .conflict_columns
2393        .iter()
2394        .map(|c| ident(c).map(str::to_string))
2395        .collect::<Result<Vec<_>, _>>()?;
2396    // The resolved write stamp `(column, value)` that must bound the upsert (own/session/null →
2397    // a predicate; `all` / no-scope → `None`, nothing to guard).
2398    let guard = stamp;
2399    let do_nothing = || format!(" ON CONFLICT ({}) DO NOTHING", conflict_cols.join(", "));
2400    if oc.update.is_empty() {
2401        return Ok(do_nothing());
2402    }
2403    if guard.is_some() && matches!(dialect, Dialect::Mysql) {
2404        return Err(OrmError::BadExpr(
2405            "a tenant-scoped upsert (ON CONFLICT DO UPDATE) is unsupported on MySQL \
2406             (ON DUPLICATE KEY UPDATE cannot be bounded to the tenant's rows)",
2407        ));
2408    }
2409    // Drop any assignment to the stamped (tenant/session) column: a guest upsert never reassigns an
2410    // existing row's owning axis. If that leaves nothing to update, degrade to DO NOTHING.
2411    let sets = oc
2412        .update
2413        .iter()
2414        .filter(|a| guard.is_none_or(|(col, _)| !same_col(&a.column, col)))
2415        .map(|a| {
2416            let c = ident(&a.column)?;
2417            Ok::<String, OrmError>(format!("{c} = {}", render_expr(&a.value, params, dialect)?))
2418        })
2419        .collect::<Result<Vec<_>, _>>()?;
2420    if sets.is_empty() {
2421        return Ok(do_nothing());
2422    }
2423    let mut clause = format!(
2424        " ON CONFLICT ({}) DO UPDATE SET {}",
2425        conflict_cols.join(", "),
2426        sets.join(", ")
2427    );
2428    if let Some((col, value)) = guard {
2429        ident(col)?;
2430        // Bound the DO UPDATE to the actor's own partition (Postgres/SQLite support a trailing
2431        // WHERE), keyed on the stamped column — `= value`, or `IS NULL` for the null baseline.
2432        // **Qualify with the target table** (`<table>.<col>`): inside `DO UPDATE` the target table
2433        // and the `excluded` pseudo-relation both expose the tenant column, so a bare `<col>` is
2434        // ambiguous on Postgres (`column reference "<col>" is ambiguous`) and the whole upsert fails.
2435        // `excluded` is never the guard's subject — the guard bounds the row being *updated* — so
2436        // target-qualifying is always correct. (The target table is `ident`-validated by the caller.)
2437        let col_expr = Expr::Column(format!("{table}.{col}"));
2438        let pred = if matches!(value, SqlValue::Null) {
2439            Predicate::Null {
2440                expr: col_expr,
2441                negated: false,
2442            }
2443        } else {
2444            Predicate::Cmp {
2445                left: col_expr,
2446                op: CmpOp::Eq,
2447                right: Expr::Value(value.clone()),
2448            }
2449        };
2450        clause.push_str(&format!(
2451            " WHERE {}",
2452            render_pred(&pred, params, false, dialect)?
2453        ));
2454    }
2455    Ok(clause)
2456}
2457
2458impl Update {
2459    /// Compile to `?N` SQL + bound parameters for the given dialect. An empty `filter` is
2460    /// refused (no unbounded update).
2461    pub fn compile(&self, dialect: Dialect) -> Result<Compiled, OrmError> {
2462        if self.set.is_empty() {
2463            return Err(OrmError::Empty("update has no assignments"));
2464        }
2465        // Guard against an effectively-unbounded update: an empty `AND`/`OR` filter renders to
2466        // a tautology, so with no tenant scope it would touch every row. Refuse it. (A scope
2467        // keeps the update bounded, so an empty filter + scope is allowed.)
2468        let empty_filter =
2469            matches!(&self.filter, Predicate::And(v) | Predicate::Or(v) if v.is_empty());
2470        if empty_filter && self.scope.is_none() {
2471            return Err(OrmError::Empty(
2472                "update has an empty filter (unbounded update refused)",
2473            ));
2474        }
2475        let table = ident(&self.table)?;
2476        let mut params = Params::default();
2477
2478        // A scoped write never reassigns the owning-axis column: drop any `SET <axis column> = …`
2479        // (case/qualifier-insensitively) so a guest can't donate its own rows into another tenant's
2480        // (or session's) partition (mirrors the ON CONFLICT DO UPDATE guard). The column is the
2481        // actor's per-table axis key (Stage 1/R3): `tenant_id` (or the identity PK) authenticated,
2482        // the `session_id` for an anon `TenantOrSession` write. `all` mode ⇒ no drop; an `Unscoped`
2483        // or undeclared or no-principal write is refused (via `write_target`). The WHERE still bounds
2484        // the update to own rows; this bounds what it may *change*.
2485        let scope_col: Option<String> = match self.scope.as_ref() {
2486            Some(s) => s.write_target(&self.table)?.map(|(col, _)| col),
2487            None => None,
2488        };
2489        // SET binds before WHERE so placeholder order matches the parameter order.
2490        let sets: Result<Vec<String>, _> = self
2491            .set
2492            .iter()
2493            .filter(|a| {
2494                scope_col
2495                    .as_deref()
2496                    .is_none_or(|col| !same_col(&a.column, col))
2497            })
2498            .map(|a| {
2499                let c = ident(&a.column)?;
2500                Ok::<String, OrmError>(format!(
2501                    "{c} = {}",
2502                    render_expr(&a.value, &mut params, dialect)?
2503                ))
2504            })
2505            .collect();
2506        let sets = sets?;
2507        if sets.is_empty() {
2508            return Err(OrmError::Empty(
2509                "update has no assignments left after dropping the tenant column",
2510            ));
2511        }
2512        let set_sql = sets.join(", ");
2513
2514        let where_sql = render_where(
2515            single_scope_pred(self.scope.as_ref(), &self.table)?,
2516            Some(&self.filter),
2517            &mut params,
2518            dialect,
2519        )?
2520        .ok_or(OrmError::Empty(
2521            "update has an empty filter (unbounded update refused)",
2522        ))?;
2523
2524        let mut sql = format!("UPDATE {table} SET {set_sql} WHERE {where_sql}");
2525        sql.push_str(&render_returning(&self.returning, &mut params, dialect)?);
2526        Ok((sql, params.0))
2527    }
2528}
2529
2530impl Delete {
2531    /// Compile to `?N` SQL + bound parameters. An empty `filter` with no scope is refused
2532    /// (no unbounded delete), exactly as [`Update::compile`]. A scope keeps it bounded, so an
2533    /// empty filter + scope is allowed.
2534    pub fn compile(&self, dialect: Dialect) -> Result<Compiled, OrmError> {
2535        let empty_filter =
2536            matches!(&self.filter, Predicate::And(v) | Predicate::Or(v) if v.is_empty());
2537        if empty_filter && self.scope.is_none() {
2538            return Err(OrmError::Empty(
2539                "delete has an empty filter (unbounded delete refused)",
2540            ));
2541        }
2542        let table = ident(&self.table)?;
2543        let mut params = Params::default();
2544        let where_sql = render_where(
2545            single_scope_pred(self.scope.as_ref(), &self.table)?,
2546            Some(&self.filter),
2547            &mut params,
2548            dialect,
2549        )?
2550        .ok_or(OrmError::Empty(
2551            "delete has an empty filter (unbounded delete refused)",
2552        ))?;
2553        let mut sql = format!("DELETE FROM {table} WHERE {where_sql}");
2554        sql.push_str(&render_returning(&self.returning, &mut params, dialect)?);
2555        Ok((sql, params.0))
2556    }
2557}
2558
2559/// Compile the deny-by-default **`promote`** verb (PLAN-tenancy-principal D7): claim a returning
2560/// visitor's anonymous-session rows for their now-authenticated tenant. It is a **distinct
2561/// host-mediated verb**, never an [`AccessMode`](crate::tenancy::AccessMode) or a guest-authored
2562/// UPDATE — the guest can name neither the session value nor the cross-axis NULL.
2563///
2564/// Requires `table` to be a [`TableScope::TenantOrSession`](crate::tenancy::TableScope) table AND
2565/// the [`Scope`] to carry BOTH a tenant fact `T` (`value`) and a session fact `S` (`session`) — else
2566/// refused ([`OrmError::TenancyNoPrincipal`] / [`OrmError::BadExpr`]). Lowers to:
2567///
2568/// ```sql
2569/// UPDATE <table> SET <tenant_col> = T WHERE <session_col> = S AND <tenant_col> IS NULL
2570/// ```
2571///
2572/// The `<tenant_col> IS NULL` match is the **anti-widening guard**: promotion can only claim rows
2573/// not yet owned by any tenant, never re-home another tenant's rows into `T`. It is non-escalating,
2574/// idempotent, and race-safe — a second promotion (or a concurrent one that lost) matches nothing.
2575pub fn compile_promote(scope: &Scope, table: &str, dialect: Dialect) -> Result<Compiled, OrmError> {
2576    // `promote` is an OWN-axis session→tenant claim; it is meaningless (and unsafe) under a target
2577    // scope. Refuse it fail-closed so a target route can never move another tenant's rows.
2578    if scope.is_target() {
2579        return Err(OrmError::TargetWriteUnsupported("promote"));
2580    }
2581    let (tenant_col, session_col) = match scope.resolve_table(table)? {
2582        ResolvedScope::TenantOrSession { tenant, session } => (tenant, session),
2583        _ => {
2584            return Err(OrmError::BadExpr(
2585                "promote requires a TenantOrSession table (an anonymous-first table)",
2586            ))
2587        }
2588    };
2589    ident(&tenant_col)?;
2590    ident(&session_col)?;
2591    // BOTH facts are mandatory — promotion is the authenticated claim of one's own anon session.
2592    let tenant = scope.value.clone().ok_or(OrmError::TenancyNoPrincipal)?;
2593    let session = scope.session.clone().ok_or(OrmError::TenancyNoPrincipal)?;
2594    let promote = Update {
2595        table: table.to_string(),
2596        // set the tenant column to the resolved tenant fact.
2597        set: vec![Assignment {
2598            column: tenant_col.clone(),
2599            value: Expr::val(tenant),
2600        }],
2601        // match this session's not-yet-owned rows only (the anti-widening guard).
2602        filter: Predicate::And(vec![
2603            Predicate::Cmp {
2604                left: Expr::Column(session_col),
2605                op: CmpOp::Eq,
2606                right: Expr::val(session),
2607            },
2608            Predicate::Null {
2609                expr: Expr::Column(tenant_col),
2610                negated: false,
2611            },
2612        ]),
2613        // The promotion IS the scope; no additional host force_scope (and the tenant-column SET is
2614        // the sanctioned reassignment-from-NULL, so the usual reassignment SET-drop must NOT fire).
2615        scope: None,
2616        returning: vec![],
2617    };
2618    promote.compile(dialect)
2619}
2620
2621/// A parent-referencing derived-tenant write ([`compile_attach_reference`], PLAN-tenancy-principal
2622/// 5d): insert a row into `child` whose tenant is DERIVED from a `parent` row reachable under the
2623/// caller's current confined scope, gated by `parent.<ref_column> = ref_value`.
2624#[derive(Debug, Clone, PartialEq)]
2625pub struct AttachReference {
2626    /// The table the new row is inserted into.
2627    pub child: String,
2628    /// The referenced parent table (read under the caller's scope).
2629    pub parent: String,
2630    /// The parent selector column: `parent.<ref_column> = <ref_value>` picks the referenced row.
2631    pub ref_column: String,
2632    /// The parent selector value (guest-supplied — a row selector INSIDE the caller's confined scope,
2633    /// never a tenant value).
2634    pub ref_value: SqlValue,
2635    /// The child's non-tenant column assignments (guest values). Under a target scope each column
2636    /// must be in the route's SET-allowlist (never the tenant or a visibility column).
2637    pub set: Vec<Assignment>,
2638}
2639
2640/// Compile `attach_reference` (5d): the host-mediated **derived-tenant** write. Lowers to
2641///
2642/// ```sql
2643/// INSERT INTO <child> (<set cols…>, <child tenant col> [, <child public cols…>])
2644/// SELECT <set vals…>, <parent tenant col> [, <public literals…>]
2645/// FROM <parent> WHERE <parent>.<ref> = ? AND <caller's scope on the parent>
2646/// ```
2647///
2648/// The child's tenant is **projected from the scope-confined parent**, never the guest — so it is
2649/// bounded by the caller's own reach (`own` → {A, NULL}; `target` → {B}, since the parent is confined
2650/// to `tenant = B AND <public>`). An unreachable parent selects zero rows ⇒ zero inserts (a
2651/// fail-closed no-op, never an oracle). Under a **target** scope the child's own public-visibility
2652/// columns are force-stamped and the guest `set` columns are gated by the target write-allowlist
2653/// ([`Scope::assert_target_settable`]), so the inserted row lands in the child's public subset. The
2654/// caller's WRITE grant is enforced above this (the binding's `scope_for(Write)` fails closed for a
2655/// read-only route — so a `handle`-resolved target, whose write axis is denied, can never reach here,
2656/// satisfying G1). `child`/`parent` must be plain `Column`-scoped tenant tables.
2657pub fn compile_attach_reference(
2658    scope: &Scope,
2659    spec: &AttachReference,
2660    dialect: Dialect,
2661) -> Result<Compiled, OrmError> {
2662    ident(&spec.ref_column)?;
2663    // Resolve the child + parent tenant columns — both must be plain tenant `Column` tables (a
2664    // derived-tenant write onto an identity/`Unscoped`/`TenantOrSession` table is out of scope).
2665    let child_tenant = match scope.resolve_table(&spec.child)? {
2666        ResolvedScope::Column(c) => c,
2667        _ => {
2668            return Err(OrmError::BadExpr(
2669                "attach_reference child must be a plain tenant table",
2670            ))
2671        }
2672    };
2673    let parent_tenant = match scope.resolve_table(&spec.parent)? {
2674        ResolvedScope::Column(c) => c,
2675        _ => {
2676            return Err(OrmError::BadExpr(
2677                "attach_reference parent must be a plain tenant table",
2678            ))
2679        }
2680    };
2681    ident(&child_tenant)?;
2682    ident(&parent_tenant)?;
2683
2684    let is_target = scope.is_target();
2685    let mut columns: Vec<String> = Vec::with_capacity(spec.set.len() + 2);
2686    let mut projection: Vec<SelectItem> = Vec::with_capacity(spec.set.len() + 2);
2687    // The guest's non-tenant columns. The host DERIVES the child's tenant column from the parent, so
2688    // the guest may never name it (own OR target) — that would collide with (or try to forge) the
2689    // derived value. Under target, columns are additionally gated by the write-allowlist (never a
2690    // visibility column). Under own, they are only validated as identifiers (the caller writes its
2691    // own rows, exactly as a normal own INSERT).
2692    for a in &spec.set {
2693        if same_col(&a.column, &child_tenant) {
2694            return Err(OrmError::TargetWriteColumnDenied(a.column.clone()));
2695        }
2696        if is_target {
2697            scope.assert_target_settable(&spec.child, &a.column)?;
2698        } else {
2699            ident(&a.column)?;
2700        }
2701        columns.push(a.column.clone());
2702        projection.push(SelectItem {
2703            expr: a.value.clone(),
2704            alias: None,
2705        });
2706    }
2707    // The derived tenant: the child's tenant column is projected from the (scope-confined) parent's
2708    // tenant column — never a guest value.
2709    columns.push(child_tenant);
2710    projection.push(SelectItem {
2711        expr: Expr::Column(parent_tenant),
2712        alias: None,
2713    });
2714    // Under a target scope, force the child's public-visibility columns so the inserted row is itself
2715    // public (deny-by-default: a child table with no declared public subset is refused).
2716    if is_target {
2717        for (col, val) in scope.public_force_cells(&spec.child)? {
2718            columns.push(col);
2719            projection.push(SelectItem {
2720                expr: Expr::Value(val),
2721                alias: None,
2722            });
2723        }
2724    }
2725    // The source: SELECT <projection> FROM parent WHERE parent.<ref> = ?. Read-scoping it confines the
2726    // parent to the caller's reachable set (own: tenant = A [OR NULL]; target: tenant = B AND public),
2727    // so the projected parent tenant is bounded and an unreachable parent yields zero rows.
2728    let mut source = Select {
2729        columns: projection,
2730        filter: Some(Predicate::Cmp {
2731            left: Expr::Column(spec.ref_column.clone()),
2732            op: CmpOp::Eq,
2733            right: Expr::Value(spec.ref_value.clone()),
2734        }),
2735        ..Select::from(spec.parent.clone())
2736    };
2737    source.force_scope(scope)?;
2738    // The INSERT itself carries NO scope (`scope: None`) — the tenant is the projected parent's, not a
2739    // re-stamped scalar. (This is the sanctioned target INSERT…SELECT; a generic one is refused by
2740    // `Insert::force_scope` under a target scope.)
2741    let insert = Insert {
2742        table: spec.child.clone(),
2743        rows: vec![],
2744        conflict: None,
2745        scope: None,
2746        returning: vec![],
2747        from_select: Some((columns, Box::new(source))),
2748    };
2749    insert.compile(dialect)
2750}
2751
2752#[cfg(test)]
2753mod rls_scope_value_tests {
2754    use super::*;
2755
2756    fn v(s: &str) -> SqlValue {
2757        SqlValue::Text(s.to_string())
2758    }
2759    fn cell(col: &str, val: SqlValue) -> Assignment {
2760        Assignment {
2761            column: col.into(),
2762            value: Expr::Value(val),
2763        }
2764    }
2765    fn insert(rows: Vec<Vec<Assignment>>) -> Insert {
2766        Insert {
2767            table: "t".into(),
2768            rows: rows.into_iter().map(|cells| RowValues { cells }).collect(),
2769            conflict: None,
2770            scope: None,
2771            returning: vec![],
2772            from_select: None,
2773        }
2774    }
2775
2776    #[test]
2777    fn insert_uniform_scope_value_extracts_only_a_single_declared_tenant() {
2778        // Single row → the literal.
2779        assert_eq!(
2780            insert(vec![vec![cell("tenant_id", v("A")), cell("body", v("x"))]])
2781                .uniform_scope_value("tenant_id"),
2782            Some(v("A"))
2783        );
2784        // Multi-row agreeing → the shared literal.
2785        assert_eq!(
2786            insert(vec![
2787                vec![cell("tenant_id", v("A")), cell("body", v("x"))],
2788                vec![cell("tenant_id", v("A")), cell("body", v("y"))],
2789            ])
2790            .uniform_scope_value("tenant_id"),
2791            Some(v("A"))
2792        );
2793        // Rows disagree → None (fail-closed).
2794        assert_eq!(
2795            insert(vec![
2796                vec![cell("tenant_id", v("A"))],
2797                vec![cell("tenant_id", v("B"))],
2798            ])
2799            .uniform_scope_value("tenant_id"),
2800            None
2801        );
2802        // Missing column / non-literal → None.
2803        assert_eq!(
2804            insert(vec![vec![cell("body", v("x"))]]).uniform_scope_value("tenant_id"),
2805            None
2806        );
2807        assert_eq!(
2808            insert(vec![vec![cell("tenant_id", v("A")), cell("body", v("x"))]])
2809                .uniform_scope_value("id"),
2810            None
2811        );
2812        // Non-literal value → None.
2813        let non_lit = insert(vec![vec![Assignment {
2814            column: "tenant_id".into(),
2815            value: Expr::col("other"),
2816        }]]);
2817        assert_eq!(non_lit.uniform_scope_value("tenant_id"), None);
2818    }
2819
2820    #[test]
2821    fn update_pinned_scope_value_extracts_only_a_pinned_single_tenant() {
2822        let upd = |filter: Predicate| Update {
2823            table: "t".into(),
2824            set: vec![cell("body", v("x"))],
2825            filter,
2826            scope: None,
2827            returning: vec![],
2828        };
2829        // `col = 'A'` → A.
2830        assert_eq!(
2831            upd(Predicate::Cmp {
2832                left: Expr::col("tenant_id"),
2833                op: CmpOp::Eq,
2834                right: Expr::Value(v("A")),
2835            })
2836            .pinned_scope_value("tenant_id"),
2837            Some(v("A"))
2838        );
2839        // AND with an unrelated conjunct still pins.
2840        assert_eq!(
2841            upd(Predicate::And(vec![
2842                Predicate::Cmp {
2843                    left: Expr::col("tenant_id"),
2844                    op: CmpOp::Eq,
2845                    right: Expr::Value(v("A")),
2846                },
2847                Predicate::Cmp {
2848                    left: Expr::col("active"),
2849                    op: CmpOp::Eq,
2850                    right: Expr::Value(SqlValue::Boolean(true)),
2851                },
2852            ]))
2853            .pinned_scope_value("tenant_id"),
2854            Some(v("A"))
2855        );
2856        // OR does not pin → None (fail-closed).
2857        assert_eq!(
2858            upd(Predicate::Or(vec![
2859                Predicate::Cmp {
2860                    left: Expr::col("tenant_id"),
2861                    op: CmpOp::Eq,
2862                    right: Expr::Value(v("A")),
2863                },
2864                Predicate::Cmp {
2865                    left: Expr::col("tenant_id"),
2866                    op: CmpOp::Eq,
2867                    right: Expr::Value(v("B")),
2868                },
2869            ]))
2870            .pinned_scope_value("tenant_id"),
2871            None
2872        );
2873        // No equality on the column → None.
2874        assert_eq!(
2875            upd(Predicate::Cmp {
2876                left: Expr::col("active"),
2877                op: CmpOp::Eq,
2878                right: Expr::Value(SqlValue::Boolean(true)),
2879            })
2880            .pinned_scope_value("tenant_id"),
2881            None
2882        );
2883    }
2884}
2885
2886#[cfg(test)]
2887mod tests {
2888    use super::*;
2889
2890    fn t(s: &str) -> SqlValue {
2891        SqlValue::Text(s.to_string())
2892    }
2893    fn cmp(col: &str, op: CmpOp, v: SqlValue) -> Predicate {
2894        Predicate::Cmp {
2895            left: Expr::Column(col.into()),
2896            op,
2897            right: Expr::Value(v),
2898        }
2899    }
2900    fn item(e: Expr) -> SelectItem {
2901        SelectItem {
2902            expr: e,
2903            alias: None,
2904        }
2905    }
2906
2907    #[test]
2908    fn select_basic_where_order_limit() {
2909        let q = Select {
2910            columns: vec![item(Expr::col("id")), item(Expr::col("state"))],
2911            filter: Some(cmp("project_id", CmpOp::Eq, t("prj_1"))),
2912            order: vec![OrderBy {
2913                expr: Expr::col("created_at"),
2914                dir: Direction::Desc,
2915            }],
2916            limit: Some(10),
2917            ..Select::from("work_order")
2918        };
2919        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
2920        assert_eq!(
2921            sql,
2922            "SELECT id, state FROM work_order WHERE project_id = ?1 ORDER BY created_at DESC LIMIT 10"
2923        );
2924        assert_eq!(params, vec![t("prj_1")]);
2925    }
2926
2927    #[test]
2928    fn scope_is_anded_and_bound_first() {
2929        let q = Select {
2930            filter: Some(cmp("kind", CmpOp::Eq, t("supplier"))),
2931            scope: Some(Scope {
2932                column: "tenant_id".into(),
2933                value: Some(t("ten_1")),
2934                session: None,
2935                mode: ScopeMode::Own,
2936                keys: TableKeys::Uniform,
2937            }),
2938            ..Select::from("party")
2939        };
2940        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
2941        assert_eq!(
2942            sql,
2943            "SELECT * FROM party WHERE tenant_id = ?1 AND kind = ?2"
2944        );
2945        assert_eq!(params, vec![t("ten_1"), t("supplier")]);
2946    }
2947
2948    #[test]
2949    fn per_table_keys_scope_each_ref_on_its_own_column() {
2950        use std::collections::BTreeMap;
2951        // A settings-page read: storefront_config (Tenant -> tenant_id) LEFT JOIN the identity table
2952        // `tenant` (TenantKeyed -> its own PK `id`). The host injects the RIGHT column per ref (R2).
2953        let q = Select {
2954            table_alias: Some("sc".into()),
2955            joins: vec![Join {
2956                kind: JoinKind::Left,
2957                table: "tenant".into(),
2958                alias: Some("t".into()),
2959                on: Predicate::Cmp {
2960                    left: Expr::col("sc.tenant_id"),
2961                    op: CmpOp::Eq,
2962                    right: Expr::col("t.id"),
2963                },
2964            }],
2965            scope: Some(Scope {
2966                column: "tenant_id".into(),
2967                value: Some(t("acme")),
2968                session: None,
2969                mode: ScopeMode::Own,
2970                keys: TableKeys::PerTable(BTreeMap::from([
2971                    (
2972                        "storefront_config".to_string(),
2973                        ResolvedScope::Column("tenant_id".to_string()),
2974                    ),
2975                    (
2976                        "tenant".to_string(),
2977                        ResolvedScope::Column("id".to_string()),
2978                    ),
2979                ])),
2980            }),
2981            ..Select::from("storefront_config")
2982        };
2983        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
2984        assert!(
2985            sql.contains("sc.tenant_id = ?"),
2986            "base scoped on tenant_id: {sql}"
2987        );
2988        assert!(
2989            sql.contains("t.id = ?"),
2990            "identity table scoped on its own PK: {sql}"
2991        );
2992        assert_eq!(params, vec![t("acme"), t("acme")]);
2993
2994        // An `Unscoped` join (reference data) adds NO tenant predicate; the base still scopes.
2995        let mut q2 = Select {
2996            table_alias: Some("sc".into()),
2997            joins: vec![Join {
2998                kind: JoinKind::Left,
2999                table: "countries".into(),
3000                alias: Some("c".into()),
3001                on: Predicate::Cmp {
3002                    left: Expr::col("sc.country"),
3003                    op: CmpOp::Eq,
3004                    right: Expr::col("c.code"),
3005                },
3006            }],
3007            ..Select::from("storefront_config")
3008        };
3009        q2.force_scope(&Scope {
3010            column: "tenant_id".into(),
3011            value: Some(t("acme")),
3012            session: None,
3013            mode: ScopeMode::Own,
3014            keys: TableKeys::PerTable(BTreeMap::from([
3015                (
3016                    "storefront_config".to_string(),
3017                    ResolvedScope::Column("tenant_id".to_string()),
3018                ),
3019                ("countries".to_string(), ResolvedScope::Unscoped),
3020            ])),
3021        })
3022        .unwrap();
3023        let (sql2, params2) = q2.compile(Dialect::Sqlite).unwrap();
3024        assert!(sql2.contains("sc.tenant_id = ?"), "sql2: {sql2}");
3025        // The `Unscoped` join binds NO tenant value — the sole bind is the base's own tenant — which
3026        // proves `countries` contributed no scope predicate (a substring check on the alias would
3027        // false-match `sc.tenant_id`).
3028        assert_eq!(
3029            params2,
3030            vec![t("acme")],
3031            "unscoped join adds no tenant predicate: {sql2}"
3032        );
3033
3034        // An UNDECLARED table under a present schema is refused (deny-by-default, D3).
3035        let mut q3 = Select::from("secret_table");
3036        q3.force_scope(&Scope {
3037            column: "tenant_id".into(),
3038            value: Some(t("acme")),
3039            session: None,
3040            mode: ScopeMode::Own,
3041            keys: TableKeys::PerTable(BTreeMap::from([(
3042                "orders".to_string(),
3043                ResolvedScope::Column("tenant_id".to_string()),
3044            )])),
3045        })
3046        .unwrap();
3047        assert!(matches!(
3048            q3.compile(Dialect::Sqlite),
3049            Err(OrmError::TenancyUndeclared(tbl)) if tbl == "secret_table"
3050        ));
3051    }
3052
3053    #[test]
3054    fn is_own_lowers_to_a_case_rank_and_orders_own_first() {
3055        // The base-vs-override read: `own+null` + `ORDER BY is_own DESC LIMIT 1` — the tenant's
3056        // override (own) sorts ahead of the shared base (NULL), without the guest naming tenant_id.
3057        let mut q = Select {
3058            columns: vec![item(Expr::col("body"))],
3059            filter: Some(cmp("key_name", CmpOp::Eq, t("k"))),
3060            order: vec![OrderBy {
3061                expr: Expr::IsOwn,
3062                dir: Direction::Desc,
3063            }],
3064            limit: Some(1),
3065            ..Select::from("knowledge_entry")
3066        };
3067        q.force_scope(&Scope {
3068            column: "tenant_id".into(),
3069            value: Some(t("acme")),
3070            session: None,
3071            mode: ScopeMode::OwnOrNull,
3072            keys: TableKeys::Uniform,
3073        })
3074        .unwrap();
3075        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3076        assert_eq!(
3077            sql,
3078            "SELECT body FROM knowledge_entry WHERE (tenant_id = ?1 OR tenant_id IS NULL) \
3079             AND key_name = ?2 ORDER BY (CASE WHEN tenant_id IS NOT NULL AND tenant_id = ?3 \
3080             THEN ?4 ELSE ?5 END) DESC LIMIT 1"
3081        );
3082        // Scope value (own+null), filter, then the is_own rank (own value + 1/0), in textual order.
3083        assert_eq!(
3084            params,
3085            vec![
3086                t("acme"),
3087                t("k"),
3088                t("acme"),
3089                SqlValue::Integer(1),
3090                SqlValue::Integer(0)
3091            ]
3092        );
3093    }
3094
3095    #[test]
3096    fn is_own_in_select_under_all_uses_the_resolved_own_value() {
3097        // Under a cross-tenant `all` read is_own means "MY own" (col = <own>), not "any non-base".
3098        let mut q = Select {
3099            columns: vec![item(Expr::IsOwn)],
3100            ..Select::from("t")
3101        };
3102        q.force_scope(&Scope {
3103            column: "tenant_id".into(),
3104            value: Some(t("acme")),
3105            session: None,
3106            mode: ScopeMode::All,
3107            keys: TableKeys::Uniform,
3108        })
3109        .unwrap();
3110        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3111        assert_eq!(
3112            sql,
3113            "SELECT (CASE WHEN tenant_id IS NOT NULL AND tenant_id = ?1 THEN ?2 ELSE ?3 END) FROM t"
3114        );
3115        assert_eq!(
3116            params,
3117            vec![t("acme"), SqlValue::Integer(1), SqlValue::Integer(0)]
3118        );
3119    }
3120
3121    #[test]
3122    fn is_own_without_a_scope_is_rejected() {
3123        // No force_scope ⇒ the marker is never lowered ⇒ fail closed at compile (never an unscoped
3124        // ranking that could leak whether other tenants exist).
3125        let q = Select {
3126            order: vec![OrderBy {
3127                expr: Expr::IsOwn,
3128                dir: Direction::Desc,
3129            }],
3130            ..Select::from("t")
3131        };
3132        let err = q.compile(Dialect::Sqlite).unwrap_err();
3133        assert!(
3134            matches!(err, OrmError::BadExpr(m) if m.contains("is_own")),
3135            "expected a fail-closed is_own error, got {err:?}"
3136        );
3137    }
3138
3139    #[test]
3140    fn is_own_in_a_filter_does_not_subtract_the_scope_predicate() {
3141        // Using is_own() as a label in WHERE (`WHERE is_own() = 1`, "only my overrides") must keep
3142        // the independent host tenant predicate — the label can never remove a scope conjunct.
3143        let mut q = Select {
3144            filter: Some(Predicate::Cmp {
3145                left: Expr::IsOwn,
3146                op: CmpOp::Eq,
3147                right: Expr::Value(SqlValue::Integer(1)),
3148            }),
3149            ..Select::from("notes")
3150        };
3151        q.force_scope(&Scope {
3152            column: "tenant_id".into(),
3153            value: Some(t("acme")),
3154            session: None,
3155            mode: ScopeMode::OwnOrNull,
3156            keys: TableKeys::Uniform,
3157        })
3158        .unwrap();
3159        let (sql, _) = q.compile(Dialect::Sqlite).unwrap();
3160        // The host scope predicate is conjoined in FRONT, independent of the is_own label.
3161        assert!(
3162            sql.contains("(tenant_id = ?1 OR tenant_id IS NULL) AND"),
3163            "scope predicate must survive the is_own filter: {sql}"
3164        );
3165        assert!(
3166            sql.contains("CASE WHEN tenant_id IS NOT NULL AND tenant_id = ?2 THEN"),
3167            "is_own lowered to the own-rank CASE: {sql}"
3168        );
3169    }
3170
3171    fn scoped_select(mode: ScopeMode) -> Select {
3172        Select {
3173            filter: Some(cmp("kind", CmpOp::Eq, t("supplier"))),
3174            scope: Some(Scope {
3175                column: "tenant_id".into(),
3176                value: Some(t("ten_1")),
3177                session: None,
3178                mode,
3179                keys: TableKeys::Uniform,
3180            }),
3181            ..Select::from("party")
3182        }
3183    }
3184
3185    #[test]
3186    fn scope_mode_own_or_null_admits_the_shared_baseline() {
3187        let (sql, params) = scoped_select(ScopeMode::OwnOrNull)
3188            .compile(Dialect::Sqlite)
3189            .unwrap();
3190        assert_eq!(
3191            sql,
3192            "SELECT * FROM party WHERE (tenant_id = ?1 OR tenant_id IS NULL) AND kind = ?2"
3193        );
3194        assert_eq!(params, vec![t("ten_1"), t("supplier")]);
3195    }
3196
3197    #[test]
3198    fn scope_mode_null_only_sees_only_the_baseline() {
3199        let (sql, params) = scoped_select(ScopeMode::NullOnly)
3200            .compile(Dialect::Sqlite)
3201            .unwrap();
3202        // The resolved tenant value is not bound at all — NULL-only never references it.
3203        assert_eq!(
3204            sql,
3205            "SELECT * FROM party WHERE tenant_id IS NULL AND kind = ?1"
3206        );
3207        assert_eq!(params, vec![t("supplier")]);
3208    }
3209
3210    #[test]
3211    fn scope_mode_all_injects_no_tenant_predicate() {
3212        let (sql, params) = scoped_select(ScopeMode::All)
3213            .compile(Dialect::Sqlite)
3214            .unwrap();
3215        // `all` (cross-tenant) renders exactly as if unscoped — only the guest filter remains.
3216        assert_eq!(sql, "SELECT * FROM party WHERE kind = ?1");
3217        assert_eq!(params, vec![t("supplier")]);
3218    }
3219
3220    #[test]
3221    fn force_scope_reaches_every_union_branch() {
3222        // A union whose branches start unscoped: force_scope must scope BOTH sides, or the
3223        // branch would leak across tenants.
3224        let branch = Select::from("archived_party");
3225        let mut q = Select {
3226            union: Some(Box::new(Union {
3227                all: false,
3228                query: branch,
3229            })),
3230            ..Select::from("party")
3231        };
3232        q.force_scope(&Scope {
3233            column: "tenant_id".into(),
3234            value: Some(t("ten_1")),
3235            session: None,
3236            mode: ScopeMode::Own,
3237            keys: TableKeys::Uniform,
3238        })
3239        .unwrap();
3240        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3241        assert_eq!(
3242            sql,
3243            "SELECT * FROM party WHERE tenant_id = ?1 UNION SELECT * FROM archived_party WHERE tenant_id = ?2"
3244        );
3245        assert_eq!(params, vec![t("ten_1"), t("ten_1")]);
3246    }
3247
3248    #[test]
3249    fn scoped_select_scopes_every_joined_table() {
3250        // A guest joins a victim table hoping to read its cross-tenant rows via the projection.
3251        // force_scope must scope the FROM table AND every joined table (qualified by alias/name).
3252        // A LEFT-joined victim is confined in its OWN `ON` (not the WHERE — that would collapse the
3253        // LEFT JOIN to an INNER JOIN): a cross-tenant victim row then fails the ON and yields NULL,
3254        // never leaking through the projection.
3255        let mut q = Select {
3256            table: "orders".into(),
3257            table_alias: Some("o".into()),
3258            columns: vec![item(Expr::col("v.secret"))],
3259            joins: vec![Join {
3260                kind: JoinKind::Left,
3261                table: "victim".into(),
3262                alias: Some("v".into()),
3263                on: Predicate::Cmp {
3264                    left: Expr::col("v.order_id"),
3265                    op: CmpOp::Eq,
3266                    right: Expr::col("o.id"),
3267                },
3268            }],
3269            ..Select::from("orders")
3270        };
3271        q.force_scope(&Scope {
3272            column: "tenant_id".into(),
3273            value: Some(t("ten_1")),
3274            session: None,
3275            mode: ScopeMode::Own,
3276            keys: TableKeys::Uniform,
3277        })
3278        .unwrap();
3279        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3280        assert_eq!(
3281            sql,
3282            "SELECT v.secret FROM orders AS o LEFT JOIN victim AS v ON v.order_id = o.id \
3283             AND v.tenant_id = ?1 WHERE o.tenant_id = ?2"
3284        );
3285        assert_eq!(params, vec![t("ten_1"), t("ten_1")]);
3286    }
3287
3288    #[test]
3289    fn scoped_returning_and_distinct_on_subqueries_are_scoped() {
3290        let sub = || Expr::RelatedScalar {
3291            column: "balance".into(),
3292            table: "victim".into(),
3293            filter: Box::new(Predicate::And(Vec::new())),
3294        };
3295        let scope = Scope {
3296            column: "tenant_id".into(),
3297            value: Some(t("ten_1")),
3298            session: None,
3299            mode: ScopeMode::Own,
3300            keys: TableKeys::Uniform,
3301        };
3302        // DELETE … RETURNING (subquery) — the RETURNING read must be scoped to victim.
3303        let mut del = Delete {
3304            table: "orders".into(),
3305            filter: cmp("id", CmpOp::Eq, t("o_1")),
3306            scope: None,
3307            returning: vec![item(sub())],
3308        };
3309        del.force_scope(&scope).unwrap();
3310        let (sql, _) = del.compile(Dialect::Sqlite).unwrap();
3311        assert!(
3312            sql.contains("RETURNING (SELECT balance FROM victim WHERE victim.tenant_id = ?"),
3313            "RETURNING subquery unscoped: {sql}"
3314        );
3315        // SELECT DISTINCT ON ((subquery)) — the DISTINCT ON read must be scoped too (PG).
3316        let mut sel = Select {
3317            columns: vec![item(Expr::col("id"))],
3318            distinct_on: vec![sub()],
3319            ..Select::from("orders")
3320        };
3321        sel.force_scope(&scope).unwrap();
3322        // The compiler emits portable `?N` placeholders (the backend rewrites to `$N` on PG).
3323        let (sql, _) = sel.compile(Dialect::Postgres).unwrap();
3324        assert!(
3325            sql.contains("DISTINCT ON ((SELECT balance FROM victim WHERE victim.tenant_id = ?"),
3326            "DISTINCT ON subquery unscoped: {sql}"
3327        );
3328    }
3329
3330    #[test]
3331    fn scoped_select_scopes_a_subquerys_inner_table() {
3332        // A guest embeds a scalar subquery over another table; force_scope must scope the
3333        // subquery's OWN table so it can't read cross-tenant.
3334        let mut q = Select {
3335            columns: vec![item(Expr::RelatedScalar {
3336                column: "balance".into(),
3337                table: "victim".into(),
3338                filter: Box::new(Predicate::And(Vec::new())), // guest filter: none
3339            })],
3340            ..Select::from("orders")
3341        };
3342        q.force_scope(&Scope {
3343            column: "tenant_id".into(),
3344            value: Some(t("ten_1")),
3345            session: None,
3346            mode: ScopeMode::Own,
3347            keys: TableKeys::Uniform,
3348        })
3349        .unwrap();
3350        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3351        // The subquery's WHERE is scoped to victim.tenant_id; the outer to orders (single table).
3352        assert_eq!(
3353            sql,
3354            "SELECT (SELECT balance FROM victim WHERE victim.tenant_id = ?1) \
3355             FROM orders WHERE tenant_id = ?2"
3356        );
3357        assert_eq!(params, vec![t("ten_1"), t("ten_1")]);
3358    }
3359
3360    #[test]
3361    fn insert_select_cannot_forge_the_target_tenant() {
3362        // A guest projects a chosen tenant id into the target `tenant_id` column. force_scope must
3363        // drop that projection and re-bind the host-resolved own value — no cross-tenant forgery.
3364        let source = Select {
3365            columns: vec![
3366                item(Expr::val(t("VICTIM"))), // guest-chosen tenant id
3367                item(Expr::col("total")),
3368            ],
3369            ..Select::from("orders")
3370        };
3371        let mut ins = Insert {
3372            table: "orders".into(),
3373            rows: vec![],
3374            conflict: None,
3375            scope: None,
3376            returning: vec![],
3377            // `TENANT_ID` (case variant) must still be recognized as the tenant column + dropped.
3378            from_select: Some((vec!["TENANT_ID".into(), "total".into()], Box::new(source))),
3379        };
3380        let own = Scope {
3381            column: "tenant_id".into(),
3382            value: Some(t("OWN")),
3383            session: None,
3384            mode: ScopeMode::Own,
3385            keys: TableKeys::Uniform,
3386        };
3387        ins.force_scope(Some(&own), Some(&own)).unwrap();
3388        let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
3389        // The tenant column is re-appended last, bound to OWN; the source is read-scoped to OWN.
3390        assert_eq!(
3391            sql,
3392            "INSERT INTO orders (total, tenant_id) SELECT total, ?1 FROM orders WHERE tenant_id = ?2"
3393        );
3394        assert_eq!(params, vec![t("OWN"), t("OWN")]);
3395        assert!(
3396            !params.contains(&t("VICTIM")),
3397            "the forged tenant never binds"
3398        );
3399    }
3400
3401    #[test]
3402    fn scoped_update_cannot_reassign_the_tenant() {
3403        // A guest tries to donate its own rows to another tenant: SET tenant_id = VICTIM. The
3404        // scope guard drops that assignment (case-insensitively) while the WHERE stays own-bound.
3405        let q = Update {
3406            table: "orders".into(),
3407            set: vec![
3408                Assignment {
3409                    column: "TENANT_ID".into(),
3410                    value: Expr::val(t("VICTIM")),
3411                },
3412                Assignment {
3413                    column: "status".into(),
3414                    value: Expr::val(t("paid")),
3415                },
3416            ],
3417            filter: cmp("id", CmpOp::Eq, t("o_1")),
3418            scope: Some(Scope {
3419                column: "tenant_id".into(),
3420                value: Some(t("OWN")),
3421                session: None,
3422                mode: ScopeMode::Own,
3423                keys: TableKeys::Uniform,
3424            }),
3425            returning: vec![],
3426        };
3427        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3428        assert_eq!(
3429            sql,
3430            "UPDATE orders SET status = ?1 WHERE tenant_id = ?2 AND id = ?3"
3431        );
3432        assert_eq!(params, vec![t("paid"), t("OWN"), t("o_1")]);
3433        assert!(!params.contains(&t("VICTIM")));
3434    }
3435
3436    #[test]
3437    fn scoped_upsert_drops_tenant_reassignment_and_bounds_the_do_update() {
3438        // A guest upsert tries to (a) reassign tenant_id to VICTIM on conflict and (b) overwrite
3439        // another tenant's row via a conflict on a non-tenant key. The scope guard must drop the
3440        // tenant reassignment and bound the DO UPDATE to own rows.
3441        let mut ins = Insert {
3442            table: "orders".into(),
3443            rows: vec![RowValues {
3444                cells: vec![Assignment {
3445                    column: "id".into(),
3446                    value: Expr::val(t("k")),
3447                }],
3448            }],
3449            conflict: Some(OnConflict {
3450                conflict_columns: vec!["id".into()],
3451                update: vec![
3452                    // Case/qualifier-respelled to dodge the drop — must still be caught.
3453                    Assignment {
3454                        column: "TENANT_ID".into(),
3455                        value: Expr::val(t("VICTIM")),
3456                    },
3457                    Assignment {
3458                        column: "total".into(),
3459                        value: Expr::val(SqlValue::Integer(999)),
3460                    },
3461                ],
3462            }),
3463            scope: None,
3464            returning: vec![],
3465            from_select: None,
3466        };
3467        let own = Scope {
3468            column: "tenant_id".into(),
3469            value: Some(t("OWN")),
3470            session: None,
3471            mode: ScopeMode::Own,
3472            keys: TableKeys::Uniform,
3473        };
3474        ins.force_scope(Some(&own), Some(&own)).unwrap();
3475        let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
3476        assert_eq!(
3477            sql,
3478            "INSERT INTO orders (id, tenant_id) VALUES (?1, ?2) \
3479             ON CONFLICT (id) DO UPDATE SET total = ?3 WHERE orders.tenant_id = ?4"
3480        );
3481        // The inserted row stamps OWN; the DO UPDATE is bounded to OWN; VICTIM never binds.
3482        assert_eq!(
3483            params,
3484            vec![t("k"), t("OWN"), SqlValue::Integer(999), t("OWN")]
3485        );
3486        assert!(!params.contains(&t("VICTIM")));
3487        // The same scoped upsert is refused on MySQL (no bounded DO UPDATE).
3488        assert!(matches!(
3489            ins.compile(Dialect::Mysql),
3490            Err(OrmError::BadExpr(_))
3491        ));
3492    }
3493
3494    #[test]
3495    fn upsert_do_update_guard_is_target_table_qualified() {
3496        // The host-injected DO UPDATE partition guard names `<table>.<col>`, never a bare column:
3497        // inside `DO UPDATE` the target table AND the `excluded` pseudo-relation both expose the
3498        // tenant column, so a bare guard is ambiguous on Postgres and the whole upsert fails at
3499        // execution (construens' P48 cutover bug — reproduced on real PG 16). SQLite tolerates the
3500        // bare form, which is why this only surfaced on a Postgres backend.
3501        let build = |mode, value: Option<SqlValue>| {
3502            let mut ins = Insert {
3503                table: "module_config".into(),
3504                rows: vec![RowValues {
3505                    cells: vec![Assignment {
3506                        column: "module".into(),
3507                        value: Expr::val(t("m")),
3508                    }],
3509                }],
3510                // A NON-tenant conflict key: the guard is LOAD-BEARING here — it is the only thing
3511                // stopping a guest upsert from overwriting another tenant's row via the shared key,
3512                // so the fix must qualify it, not drop it.
3513                conflict: Some(OnConflict {
3514                    conflict_columns: vec!["module".into()],
3515                    update: vec![Assignment {
3516                        column: "enabled".into(),
3517                        value: Expr::col("excluded.enabled"),
3518                    }],
3519                }),
3520                scope: None,
3521                returning: vec![],
3522                from_select: None,
3523            };
3524            let s = Scope {
3525                column: "tenant_id".into(),
3526                value,
3527                session: None,
3528                mode,
3529                keys: TableKeys::Uniform,
3530            };
3531            ins.force_scope(Some(&s), Some(&s)).unwrap();
3532            ins
3533        };
3534        // own → `= value`, target-qualified — on BOTH the real (Postgres) backend and SQLite.
3535        for d in [Dialect::Postgres, Dialect::Sqlite] {
3536            let (sql, _) = build(ScopeMode::Own, Some(t("OWN"))).compile(d).unwrap();
3537            assert!(
3538                sql.ends_with(
3539                    "ON CONFLICT (module) DO UPDATE SET enabled = excluded.enabled \
3540                     WHERE module_config.tenant_id = ?3"
3541                ),
3542                "{d:?}: {sql}"
3543            );
3544        }
3545        // null baseline → `IS NULL`, also target-qualified (the identical ambiguity).
3546        let (sql, _) = build(ScopeMode::NullOnly, None)
3547            .compile(Dialect::Postgres)
3548            .unwrap();
3549        assert!(
3550            sql.ends_with(
3551                "ON CONFLICT (module) DO UPDATE SET enabled = excluded.enabled \
3552                 WHERE module_config.tenant_id IS NULL"
3553            ),
3554            "{sql}"
3555        );
3556    }
3557
3558    #[test]
3559    fn insert_null_mode_stamps_null_all_mode_stamps_nothing() {
3560        let base = |mode| Insert {
3561            table: "audit_event".into(),
3562            rows: vec![RowValues {
3563                cells: vec![Assignment {
3564                    column: "detail".into(),
3565                    value: Expr::val(t("x")),
3566                }],
3567            }],
3568            conflict: None,
3569            scope: Some(Scope {
3570                column: "tenant_id".into(),
3571                value: Some(t("ten_1")),
3572                session: None,
3573                mode,
3574                keys: TableKeys::Uniform,
3575            }),
3576            returning: vec![],
3577            from_select: None,
3578        };
3579        // null-only write stamps NULL into the tenant column.
3580        let (sql, params) = base(ScopeMode::NullOnly).compile(Dialect::Sqlite).unwrap();
3581        assert_eq!(
3582            sql,
3583            "INSERT INTO audit_event (detail, tenant_id) VALUES (?1, ?2)"
3584        );
3585        assert_eq!(params, vec![t("x"), SqlValue::Null]);
3586        // all-mode write forces no tenant column — the guest's columns stand verbatim.
3587        let (sql, params) = base(ScopeMode::All).compile(Dialect::Sqlite).unwrap();
3588        assert_eq!(sql, "INSERT INTO audit_event (detail) VALUES (?1)");
3589        assert_eq!(params, vec![t("x")]);
3590    }
3591
3592    #[test]
3593    fn nested_and_or_not_is_parenthesized() {
3594        // scope AND (state IN (..) AND (priority >= ? OR escalated = ?) AND NOT archived)
3595        let q = Select {
3596            filter: Some(all([
3597                Predicate::In {
3598                    expr: Expr::col("state"),
3599                    values: vec![Expr::val(t("po_linked")), Expr::val(t("awarded"))],
3600                    negated: false,
3601                },
3602                any([
3603                    cmp("priority", CmpOp::Ge, SqlValue::Integer(3)),
3604                    cmp("escalated", CmpOp::Eq, SqlValue::Boolean(true)),
3605                ]),
3606                Predicate::Not(Box::new(cmp(
3607                    "archived",
3608                    CmpOp::Eq,
3609                    SqlValue::Boolean(true),
3610                ))),
3611            ])),
3612            scope: Some(Scope {
3613                column: "tenant_id".into(),
3614                value: Some(t("ten_1")),
3615                session: None,
3616                mode: ScopeMode::Own,
3617                keys: TableKeys::Uniform,
3618            }),
3619            ..Select::from("order_to_network")
3620        };
3621        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3622        assert_eq!(
3623            sql,
3624            "SELECT * FROM order_to_network WHERE tenant_id = ?1 AND (state IN (?2, ?3) AND (priority >= ?4 OR escalated = ?5) AND NOT archived = ?6)"
3625        );
3626        assert_eq!(
3627            params,
3628            vec![
3629                t("ten_1"),
3630                t("po_linked"),
3631                t("awarded"),
3632                SqlValue::Integer(3),
3633                SqlValue::Boolean(true),
3634                SqlValue::Boolean(true)
3635            ]
3636        );
3637    }
3638
3639    #[test]
3640    fn group_by_having_with_aggregate_and_alias() {
3641        let q = Select {
3642            columns: vec![
3643                item(Expr::col("network_id")),
3644                SelectItem {
3645                    expr: Expr::Aggregate(Agg::Sum, Box::new(Expr::col("committed_minor"))),
3646                    alias: Some("total".into()),
3647                },
3648            ],
3649            group_by: vec![Expr::col("network_id")],
3650            having: Some(Predicate::Cmp {
3651                left: Expr::Aggregate(Agg::Sum, Box::new(Expr::col("committed_minor"))),
3652                op: CmpOp::Gt,
3653                right: Expr::val(SqlValue::Integer(1000)),
3654            }),
3655            order: vec![OrderBy {
3656                expr: Expr::col("total"),
3657                dir: Direction::Desc,
3658            }],
3659            ..Select::from("order_to_network")
3660        };
3661        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3662        assert_eq!(
3663            sql,
3664            "SELECT network_id, sum(committed_minor) AS total FROM order_to_network GROUP BY network_id HAVING sum(committed_minor) > ?1 ORDER BY total DESC"
3665        );
3666        assert_eq!(params, vec![SqlValue::Integer(1000)]);
3667    }
3668
3669    #[test]
3670    fn join_with_alias_and_column_ref_condition() {
3671        let q = Select {
3672            columns: vec![item(Expr::Aggregate(Agg::Count, Box::new(Expr::Star)))],
3673            joins: vec![Join {
3674                kind: JoinKind::Inner,
3675                table: "element".into(),
3676                alias: Some("e".into()),
3677                on: Predicate::Cmp {
3678                    left: Expr::col("order_to_network.element_id"),
3679                    op: CmpOp::Eq,
3680                    right: Expr::col("e.id"),
3681                },
3682            }],
3683            filter: Some(cmp("order_id", CmpOp::Eq, SqlValue::Integer(7))),
3684            ..Select::from("order_to_network")
3685        };
3686        let (sql, _) = q.compile(Dialect::Sqlite).unwrap();
3687        assert_eq!(
3688            sql,
3689            "SELECT count(*) FROM order_to_network JOIN element AS e ON order_to_network.element_id = e.id WHERE order_id = ?1"
3690        );
3691    }
3692
3693    #[test]
3694    fn between_like_insensitive_and_notin() {
3695        let q = Select {
3696            filter: Some(all([
3697                Predicate::Between {
3698                    expr: Expr::col("amount"),
3699                    low: Expr::val(SqlValue::Integer(10)),
3700                    high: Expr::val(SqlValue::Integer(20)),
3701                    negated: false,
3702                },
3703                Predicate::Like {
3704                    expr: Expr::col("name"),
3705                    pattern: "ac%".into(),
3706                    insensitive: true,
3707                    negated: false,
3708                },
3709                Predicate::In {
3710                    expr: Expr::col("state"),
3711                    values: vec![Expr::val(t("void"))],
3712                    negated: true,
3713                },
3714            ])),
3715            ..Select::from("invoice")
3716        };
3717        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3718        assert_eq!(
3719            sql,
3720            "SELECT * FROM invoice WHERE amount BETWEEN ?1 AND ?2 AND lower(name) LIKE lower(?3) AND state NOT IN (?4)"
3721        );
3722        assert_eq!(
3723            params,
3724            vec![
3725                SqlValue::Integer(10),
3726                SqlValue::Integer(20),
3727                t("ac%"),
3728                t("void")
3729            ]
3730        );
3731    }
3732
3733    #[test]
3734    fn arithmetic_and_functions_in_select_and_set() {
3735        let q = Select {
3736            columns: vec![
3737                SelectItem {
3738                    expr: Expr::Func(Func::Lower, vec![Expr::col("email")]),
3739                    alias: Some("email_lc".into()),
3740                },
3741                item(Expr::Binary(
3742                    BinOp::Mul,
3743                    Box::new(Expr::col("qty")),
3744                    Box::new(Expr::val(SqlValue::Integer(2))),
3745                )),
3746                item(Expr::Func(
3747                    Func::Coalesce,
3748                    vec![Expr::col("nickname"), Expr::val(t("n/a"))],
3749                )),
3750            ],
3751            ..Select::from("account")
3752        };
3753        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3754        assert_eq!(
3755            sql,
3756            "SELECT lower(email) AS email_lc, (qty * ?1), coalesce(nickname, ?2) FROM account"
3757        );
3758        assert_eq!(params, vec![SqlValue::Integer(2), t("n/a")]);
3759    }
3760
3761    #[test]
3762    fn empty_in_and_not_in_are_identities() {
3763        let matches_none = Select {
3764            filter: Some(Predicate::In {
3765                expr: Expr::col("x"),
3766                values: vec![],
3767                negated: false,
3768            }),
3769            ..Select::from("t")
3770        };
3771        assert_eq!(
3772            matches_none.compile(Dialect::Sqlite).unwrap().0,
3773            "SELECT * FROM t WHERE 1 = 0"
3774        );
3775        let matches_all = Select {
3776            filter: Some(Predicate::In {
3777                expr: Expr::col("x"),
3778                values: vec![],
3779                negated: true,
3780            }),
3781            ..Select::from("t")
3782        };
3783        assert_eq!(
3784            matches_all.compile(Dialect::Sqlite).unwrap().0,
3785            "SELECT * FROM t WHERE 1 = 1"
3786        );
3787    }
3788
3789    #[test]
3790    fn insert_with_scope_and_returning() {
3791        let q = Insert {
3792            table: "work_area".into(),
3793            rows: vec![RowValues {
3794                cells: vec![
3795                    Assignment {
3796                        column: "id".into(),
3797                        value: Expr::val(t("wa_1")),
3798                    },
3799                    Assignment {
3800                        column: "project_id".into(),
3801                        value: Expr::val(t("prj_1")),
3802                    },
3803                ],
3804            }],
3805            conflict: None,
3806            scope: Some(Scope {
3807                column: "tenant_id".into(),
3808                value: Some(t("ten_1")),
3809                session: None,
3810                mode: ScopeMode::Own,
3811                keys: TableKeys::Uniform,
3812            }),
3813            returning: vec![item(Expr::col("id"))],
3814            from_select: None,
3815        };
3816        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3817        assert_eq!(
3818            sql,
3819            "INSERT INTO work_area (id, project_id, tenant_id) VALUES (?1, ?2, ?3) RETURNING id"
3820        );
3821        assert_eq!(params, vec![t("wa_1"), t("prj_1"), t("ten_1")]);
3822    }
3823
3824    #[test]
3825    fn upsert_do_update_and_do_nothing() {
3826        let base = |update: Vec<Assignment>| Insert {
3827            table: "country_pack".into(),
3828            rows: vec![RowValues {
3829                cells: vec![
3830                    Assignment {
3831                        column: "country".into(),
3832                        value: Expr::val(t("US")),
3833                    },
3834                    Assignment {
3835                        column: "currency".into(),
3836                        value: Expr::val(t("USD")),
3837                    },
3838                ],
3839            }],
3840            conflict: Some(OnConflict {
3841                conflict_columns: vec!["tenant_id".into(), "country".into()],
3842                update,
3843            }),
3844            scope: None,
3845            returning: vec![],
3846            from_select: None,
3847        };
3848        let (sql_do, _) = base(vec![Assignment {
3849            column: "currency".into(),
3850            value: Expr::val(t("USD")),
3851        }])
3852        .compile(Dialect::Sqlite)
3853        .unwrap();
3854        assert_eq!(
3855            sql_do,
3856            "INSERT INTO country_pack (country, currency) VALUES (?1, ?2) ON CONFLICT (tenant_id, country) DO UPDATE SET currency = ?3"
3857        );
3858        let (sql_nothing, _) = base(vec![]).compile(Dialect::Sqlite).unwrap();
3859        assert_eq!(
3860            sql_nothing,
3861            "INSERT INTO country_pack (country, currency) VALUES (?1, ?2) ON CONFLICT (tenant_id, country) DO NOTHING"
3862        );
3863    }
3864
3865    #[test]
3866    fn update_binds_set_before_where_and_supports_expr_set() {
3867        let q = Update {
3868            table: "counter".into(),
3869            set: vec![Assignment {
3870                column: "hits".into(),
3871                value: Expr::Binary(
3872                    BinOp::Add,
3873                    Box::new(Expr::col("hits")),
3874                    Box::new(Expr::val(SqlValue::Integer(1))),
3875                ),
3876            }],
3877            filter: cmp("id", CmpOp::Eq, t("c_1")),
3878            scope: Some(Scope {
3879                column: "tenant_id".into(),
3880                value: Some(t("ten_1")),
3881                session: None,
3882                mode: ScopeMode::Own,
3883                keys: TableKeys::Uniform,
3884            }),
3885            returning: vec![],
3886        };
3887        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3888        assert_eq!(
3889            sql,
3890            "UPDATE counter SET hits = (hits + ?1) WHERE tenant_id = ?2 AND id = ?3"
3891        );
3892        assert_eq!(params, vec![SqlValue::Integer(1), t("ten_1"), t("c_1")]);
3893    }
3894
3895    #[test]
3896    fn identifier_injection_is_rejected() {
3897        let q = Select {
3898            columns: vec![item(Expr::col("id; DROP TABLE users"))],
3899            ..Select::from("t")
3900        };
3901        assert!(matches!(
3902            q.compile(Dialect::Sqlite),
3903            Err(OrmError::InvalidIdentifier(_))
3904        ));
3905    }
3906
3907    #[test]
3908    fn qualified_identifier_allowed() {
3909        let q = Select {
3910            columns: vec![item(Expr::col("t.id"))],
3911            ..Select::from("t")
3912        };
3913        assert_eq!(q.compile(Dialect::Sqlite).unwrap().0, "SELECT t.id FROM t");
3914    }
3915
3916    #[test]
3917    fn function_arity_is_checked() {
3918        let q = Select {
3919            columns: vec![item(Expr::Func(Func::Lower, vec![]))],
3920            ..Select::from("t")
3921        };
3922        assert!(matches!(
3923            q.compile(Dialect::Sqlite),
3924            Err(OrmError::BadExpr(_))
3925        ));
3926    }
3927
3928    #[test]
3929    fn update_with_empty_all_filter_is_refused() {
3930        let q = Update {
3931            table: "t".into(),
3932            set: vec![Assignment {
3933                column: "x".into(),
3934                value: Expr::val(SqlValue::Integer(1)),
3935            }],
3936            filter: Predicate::And(vec![]),
3937            scope: None,
3938            returning: vec![],
3939        };
3940        // An empty filter with no scope is an effectively-unbounded update → refused.
3941        assert!(matches!(
3942            q.compile(Dialect::Sqlite),
3943            Err(OrmError::Empty(_))
3944        ));
3945    }
3946
3947    #[test]
3948    fn empty_filter_with_scope_is_allowed() {
3949        // A scope keeps it bounded, so an empty filter + scope compiles.
3950        let q = Update {
3951            table: "t".into(),
3952            set: vec![Assignment {
3953                column: "x".into(),
3954                value: Expr::val(SqlValue::Integer(1)),
3955            }],
3956            filter: Predicate::And(vec![]),
3957            scope: Some(Scope {
3958                column: "tenant_id".into(),
3959                value: Some(t("ten_1")),
3960                session: None,
3961                mode: ScopeMode::Own,
3962                keys: TableKeys::Uniform,
3963            }),
3964            returning: vec![],
3965        };
3966        assert_eq!(
3967            q.compile(Dialect::Sqlite).unwrap().0,
3968            "UPDATE t SET x = ?1 WHERE tenant_id = ?2"
3969        );
3970    }
3971
3972    #[test]
3973    fn delete_by_predicate_compiles() {
3974        let q = Delete {
3975            table: "payment".into(),
3976            filter: cmp("id", CmpOp::Eq, t("pay_1")),
3977            scope: None,
3978            returning: vec![],
3979        };
3980        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3981        assert_eq!(sql, "DELETE FROM payment WHERE id = ?1");
3982        assert_eq!(params, vec![t("pay_1")]);
3983    }
3984
3985    #[test]
3986    fn delete_returning_renders() {
3987        // The one DELETE … RETURNING shape (consume-and-read a pending signup). `?N` is emitted
3988        // for every dialect — the backend rewrites to the engine's native placeholder.
3989        let q = Delete {
3990            table: "pending_signup".into(),
3991            filter: cmp("slug", CmpOp::Eq, t("acme")),
3992            scope: None,
3993            returning: vec![item(Expr::col("name")), item(Expr::col("password_hash"))],
3994        };
3995        assert_eq!(
3996            q.compile(Dialect::Postgres).unwrap().0,
3997            "DELETE FROM pending_signup WHERE slug = ?1 RETURNING name, password_hash"
3998        );
3999    }
4000
4001    #[test]
4002    fn delete_with_empty_filter_is_refused() {
4003        // Empty filter, no scope → effectively-unbounded delete → refused (mirrors UPDATE).
4004        let q = Delete {
4005            table: "t".into(),
4006            filter: Predicate::And(vec![]),
4007            scope: None,
4008            returning: vec![],
4009        };
4010        assert!(matches!(
4011            q.compile(Dialect::Sqlite),
4012            Err(OrmError::Empty(_))
4013        ));
4014    }
4015
4016    #[test]
4017    fn delete_empty_filter_with_scope_is_allowed() {
4018        // A scope keeps it bounded, so an empty filter + scope compiles (bulk clear within tenant).
4019        let q = Delete {
4020            table: "t".into(),
4021            filter: Predicate::And(vec![]),
4022            scope: Some(Scope {
4023                column: "tenant_id".into(),
4024                value: Some(t("ten_1")),
4025                session: None,
4026                mode: ScopeMode::Own,
4027                keys: TableKeys::Uniform,
4028            }),
4029            returning: vec![],
4030        };
4031        assert_eq!(
4032            q.compile(Dialect::Sqlite).unwrap().0,
4033            "DELETE FROM t WHERE tenant_id = ?1"
4034        );
4035    }
4036
4037    #[test]
4038    fn delete_rejects_identifier_injection_in_table() {
4039        let q = Delete {
4040            table: "t; DROP TABLE users".into(),
4041            filter: cmp("id", CmpOp::Eq, t("x")),
4042            scope: None,
4043            returning: vec![],
4044        };
4045        assert!(matches!(
4046            q.compile(Dialect::Sqlite),
4047            Err(OrmError::InvalidIdentifier(_))
4048        ));
4049    }
4050
4051    #[test]
4052    fn case_expression_renders_with_bound_params() {
4053        // CASE WHEN state = ? THEN 1 ELSE 0 END as a select item; params bind in textual order.
4054        let q = Select {
4055            columns: vec![item(Expr::Case {
4056                branches: vec![(
4057                    cmp("state", CmpOp::Eq, t("open")),
4058                    Expr::val(SqlValue::Integer(1)),
4059                )],
4060                otherwise: Some(Box::new(Expr::val(SqlValue::Integer(0)))),
4061            })],
4062            ..Select::from("t")
4063        };
4064        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
4065        assert_eq!(
4066            sql,
4067            "SELECT (CASE WHEN state = ?1 THEN ?2 ELSE ?3 END) FROM t"
4068        );
4069        assert_eq!(
4070            params,
4071            vec![t("open"), SqlValue::Integer(1), SqlValue::Integer(0)]
4072        );
4073    }
4074
4075    #[test]
4076    fn distinct_on_renders_on_postgres_and_fails_closed_elsewhere() {
4077        let q = Select {
4078            distinct_on: vec![Expr::col("key")],
4079            columns: vec![item(Expr::col("key")), item(Expr::col("val"))],
4080            ..Select::from("consent_state")
4081        };
4082        assert_eq!(
4083            q.compile(Dialect::Postgres).unwrap().0,
4084            "SELECT DISTINCT ON (key) key, val FROM consent_state"
4085        );
4086        // No portable rewrite on SQLite/MySQL — fail closed.
4087        assert!(matches!(
4088            q.compile(Dialect::Sqlite),
4089            Err(OrmError::BadExpr(_))
4090        ));
4091    }
4092
4093    #[test]
4094    fn empty_case_is_rejected() {
4095        let q = Select {
4096            columns: vec![item(Expr::Case {
4097                branches: vec![],
4098                otherwise: None,
4099            })],
4100            ..Select::from("t")
4101        };
4102        assert!(matches!(
4103            q.compile(Dialect::Sqlite),
4104            Err(OrmError::BadExpr(_))
4105        ));
4106    }
4107
4108    #[test]
4109    fn json_extract_dyn_binds_the_key() {
4110        // labels ->> ?  (bound key). Postgres + SQLite render `->>`; MySQL fails closed.
4111        let q = Select {
4112            columns: vec![item(Expr::JsonExtractDyn(
4113                Box::new(Expr::col("labels")),
4114                Box::new(Expr::val(t("en"))),
4115            ))],
4116            ..Select::from("vocabulary_term")
4117        };
4118        for d in [Dialect::Postgres, Dialect::Sqlite] {
4119            assert_eq!(
4120                q.compile(d).unwrap().0,
4121                "SELECT (labels ->> ?1) FROM vocabulary_term"
4122            );
4123        }
4124        assert!(matches!(
4125            q.compile(Dialect::Mysql),
4126            Err(OrmError::BadExpr(_))
4127        ));
4128    }
4129
4130    #[test]
4131    fn json_concat_merge_is_postgres_only() {
4132        // UPDATE request SET brief_state = brief_state || ?::jsonb WHERE id = ?
4133        let q = Update {
4134            table: "request".into(),
4135            set: vec![Assignment {
4136                column: "brief_state".into(),
4137                value: Expr::JsonConcat(
4138                    Box::new(Expr::col("brief_state")),
4139                    Box::new(Expr::val(SqlValue::Json("{\"a\":1}".into()))),
4140                ),
4141            }],
4142            filter: cmp("id", CmpOp::Eq, t("req_1")),
4143            scope: None,
4144            returning: vec![],
4145        };
4146        assert_eq!(
4147            q.compile(Dialect::Postgres).unwrap().0,
4148            "UPDATE request SET brief_state = (brief_state || ?1) WHERE id = ?2"
4149        );
4150        assert!(matches!(
4151            q.compile(Dialect::Sqlite),
4152            Err(OrmError::BadExpr(_))
4153        ));
4154    }
4155
4156    #[test]
4157    fn union_renders_both_bodies_with_shared_params() {
4158        // slug-reservation check across two tables; the branches share the ?N sequence.
4159        let q = Select {
4160            columns: vec![item(Expr::col("slug"))],
4161            filter: Some(cmp("slug", CmpOp::Eq, t("acme"))),
4162            union: Some(Box::new(Union {
4163                all: false,
4164                query: Select {
4165                    columns: vec![item(Expr::col("slug"))],
4166                    filter: Some(cmp("slug", CmpOp::Eq, t("acme"))),
4167                    ..Select::from("reserved_slug")
4168                },
4169            })),
4170            ..Select::from("pending_signup")
4171        };
4172        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
4173        assert_eq!(
4174            sql,
4175            "SELECT slug FROM pending_signup WHERE slug = ?1 \
4176             UNION SELECT slug FROM reserved_slug WHERE slug = ?2"
4177        );
4178        assert_eq!(params, vec![t("acme"), t("acme")]);
4179    }
4180
4181    #[test]
4182    fn insert_from_select_shares_params_and_carries_no_auto_scope() {
4183        // INSERT INTO ref (a, b) SELECT x, y FROM src WHERE id = ? (attach_reference shape).
4184        let q = Insert {
4185            table: "portfolio_ref".into(),
4186            rows: vec![],
4187            conflict: None,
4188            scope: None,
4189            returning: vec![],
4190            from_select: Some((
4191                vec!["a".into(), "b".into()],
4192                Box::new(Select {
4193                    columns: vec![item(Expr::col("x")), item(Expr::col("y"))],
4194                    filter: Some(cmp("id", CmpOp::Eq, t("pi_1"))),
4195                    ..Select::from("portfolio_item")
4196                }),
4197            )),
4198        };
4199        let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
4200        assert_eq!(
4201            sql,
4202            "INSERT INTO portfolio_ref (a, b) SELECT x, y FROM portfolio_item WHERE id = ?1"
4203        );
4204        assert_eq!(params, vec![t("pi_1")]);
4205    }
4206
4207    #[test]
4208    fn related_scalar_and_in_subquery_render() {
4209        // id = (SELECT head_version FROM pack WHERE id = ?1)
4210        let q = Select {
4211            columns: vec![item(Expr::col("id"))],
4212            filter: Some(Predicate::Cmp {
4213                left: Expr::col("id"),
4214                op: CmpOp::Eq,
4215                right: Expr::RelatedScalar {
4216                    column: "head_version".into(),
4217                    table: "pack".into(),
4218                    filter: Box::new(cmp("id", CmpOp::Eq, t("pk_1"))),
4219                },
4220            }),
4221            ..Select::from("pack_version")
4222        };
4223        assert_eq!(
4224            q.compile(Dialect::Sqlite).unwrap().0,
4225            "SELECT id FROM pack_version WHERE id = (SELECT head_version FROM pack WHERE id = ?1)"
4226        );
4227
4228        // doc_id IN (SELECT id FROM document WHERE tenant_id = ?1)
4229        let q2 = Select {
4230            columns: vec![item(Expr::col("x"))],
4231            filter: Some(Predicate::InSubquery {
4232                expr: Expr::col("doc_id"),
4233                column: "id".into(),
4234                table: "document".into(),
4235                filter: Box::new(cmp("tenant_id", CmpOp::Eq, t("ten_1"))),
4236                negated: false,
4237            }),
4238            ..Select::from("access")
4239        };
4240        assert_eq!(
4241            q2.compile(Dialect::Sqlite).unwrap().0,
4242            "SELECT x FROM access WHERE doc_id IN (SELECT id FROM document WHERE tenant_id = ?1)"
4243        );
4244    }
4245
4246    #[test]
4247    fn now_renders_without_parens() {
4248        let q = Select {
4249            columns: vec![item(Expr::Func(Func::Now, vec![]))],
4250            ..Select::from("t")
4251        };
4252        assert_eq!(
4253            q.compile(Dialect::Sqlite).unwrap().0,
4254            "SELECT current_timestamp FROM t"
4255        );
4256    }
4257
4258    fn json_query() -> Select {
4259        Select {
4260            columns: vec![item(Expr::JsonExtract(
4261                Box::new(Expr::col("metadata")),
4262                vec!["status".into()],
4263            ))],
4264            filter: Some(Predicate::Cmp {
4265                left: Expr::JsonExtract(
4266                    Box::new(Expr::col("metadata")),
4267                    vec!["a".into(), "b".into()],
4268                ),
4269                op: CmpOp::Eq,
4270                right: Expr::val(t("x")),
4271            }),
4272            ..Select::from("doc")
4273        }
4274    }
4275
4276    #[test]
4277    fn json_extract_sqlite_and_mysql_bind_the_path() {
4278        for d in [Dialect::Sqlite, Dialect::Mysql] {
4279            let (sql, params) = json_query().compile(d).unwrap();
4280            assert_eq!(
4281                sql,
4282                "SELECT json_extract(metadata, ?1) FROM doc WHERE json_extract(metadata, ?2) = ?3"
4283            );
4284            assert_eq!(params, vec![t("$.status"), t("$.a.b"), t("x")]);
4285        }
4286    }
4287
4288    #[test]
4289    fn json_extract_postgres_inlines_the_validated_path() {
4290        let (sql, params) = json_query().compile(Dialect::Postgres).unwrap();
4291        assert_eq!(
4292            sql,
4293            "SELECT (metadata) #>> '{status}' FROM doc WHERE (metadata) #>> '{a,b}' = ?1"
4294        );
4295        assert_eq!(params, vec![t("x")]);
4296    }
4297
4298    #[test]
4299    fn json_extract_key_injection_is_rejected() {
4300        let q = Select {
4301            columns: vec![item(Expr::JsonExtract(
4302                Box::new(Expr::col("m")),
4303                vec!["a'); DROP TABLE t--".into()],
4304            ))],
4305            ..Select::from("doc")
4306        };
4307        assert!(matches!(
4308            q.compile(Dialect::Postgres),
4309            Err(OrmError::InvalidIdentifier(_))
4310        ));
4311    }
4312
4313    // ---- pgvector distance (Postgres-only) -----------------------------------
4314
4315    fn knn_query() -> Select {
4316        // Nearest-neighbour: `ORDER BY embedding <=> [q] LIMIT k`.
4317        Select {
4318            columns: vec![item(Expr::col("id"))],
4319            order: vec![OrderBy {
4320                expr: Expr::Distance {
4321                    left: Box::new(Expr::col("embedding")),
4322                    right: Box::new(Expr::VectorLiteral("[0.1, 0.2, 0.3]".into())),
4323                    metric: Metric::Cosine,
4324                },
4325                dir: Direction::Asc,
4326            }],
4327            limit: Some(5),
4328            ..Select::from("doc")
4329        }
4330    }
4331
4332    #[test]
4333    fn distance_orders_by_cosine_nearest_neighbour_on_postgres() {
4334        let (sql, params) = knn_query().compile(Dialect::Postgres).unwrap();
4335        assert_eq!(
4336            sql,
4337            "SELECT id FROM doc ORDER BY (embedding <=> ?1::vector) ASC LIMIT 5"
4338        );
4339        // The literal binds as a parameter (whitespace-normalised), never formatted in.
4340        assert_eq!(params, vec![t("[0.1,0.2,0.3]")]);
4341    }
4342
4343    #[test]
4344    fn distance_l2_in_select_list_on_postgres() {
4345        let q = Select {
4346            columns: vec![
4347                item(Expr::col("id")),
4348                SelectItem {
4349                    expr: Expr::Distance {
4350                        left: Box::new(Expr::col("embedding")),
4351                        right: Box::new(Expr::VectorLiteral("[-1, 2e0, 3.5]".into())),
4352                        metric: Metric::L2,
4353                    },
4354                    alias: Some("dist".into()),
4355                },
4356            ],
4357            ..Select::from("doc")
4358        };
4359        let (sql, params) = q.compile(Dialect::Postgres).unwrap();
4360        assert_eq!(
4361            sql,
4362            "SELECT id, (embedding <-> ?1::vector) AS dist FROM doc"
4363        );
4364        assert_eq!(params, vec![t("[-1,2e0,3.5]")]);
4365    }
4366
4367    #[test]
4368    fn distance_fails_closed_off_postgres() {
4369        for d in [Dialect::Sqlite, Dialect::Mysql] {
4370            assert!(
4371                matches!(knn_query().compile(d), Err(OrmError::BadExpr(_))),
4372                "vector distance must be rejected on {d:?}"
4373            );
4374        }
4375    }
4376
4377    #[test]
4378    fn vector_literal_fails_closed_off_postgres() {
4379        for d in [Dialect::Sqlite, Dialect::Mysql] {
4380            let q = Select {
4381                columns: vec![item(Expr::VectorLiteral("[1, 2]".into()))],
4382                ..Select::from("doc")
4383            };
4384            assert!(
4385                matches!(q.compile(d), Err(OrmError::BadExpr(_))),
4386                "vector literal must be rejected on {d:?}"
4387            );
4388        }
4389    }
4390
4391    #[test]
4392    fn malformed_vector_literal_is_rejected() {
4393        // No brackets, non-numeric, empty, unclosed, empty component, and non-finite
4394        // (`inf`/`NaN` parse as floats but must be refused).
4395        for bad in [
4396            "1,2",
4397            "[a, b]",
4398            "[]",
4399            "[1, 2",
4400            "[1,,2]",
4401            "[Infinity]",
4402            "[1, NaN]",
4403        ] {
4404            let q = Select {
4405                columns: vec![item(Expr::VectorLiteral(bad.to_string()))],
4406                ..Select::from("doc")
4407            };
4408            assert!(
4409                matches!(q.compile(Dialect::Postgres), Err(OrmError::BadExpr(_))),
4410                "expected {bad:?} to be rejected"
4411            );
4412        }
4413    }
4414
4415    // ---- correlated roll-ups (related-aggregate) -----------------------------
4416
4417    /// `agg(arg) FROM table WHERE child.fk = parent.pk [AND extra]` — the correlation is a
4418    /// column-to-column comparison in the subquery's filter.
4419    fn related(agg: Agg, arg: RelArg, table: &str, filter: Predicate) -> Expr {
4420        Expr::RelatedAggregate {
4421            agg,
4422            arg,
4423            table: table.into(),
4424            filter: Box::new(filter),
4425        }
4426    }
4427    fn correlate(fk: &str, pk: &str) -> Predicate {
4428        Predicate::Cmp {
4429            left: Expr::col(fk),
4430            op: CmpOp::Eq,
4431            right: Expr::col(pk),
4432        }
4433    }
4434
4435    #[test]
4436    fn related_aggregate_single_correlated_count() {
4437        // construens subgraph-chain:701 — count of child rows per parent, no fan-out.
4438        let q = Select {
4439            columns: vec![
4440                item(Expr::col("id")),
4441                SelectItem {
4442                    expr: related(
4443                        Agg::Count,
4444                        RelArg::Star,
4445                        "element",
4446                        correlate("element.order_id", "work_order.id"),
4447                    ),
4448                    alias: Some("element_count".into()),
4449                },
4450            ],
4451            ..Select::from("work_order")
4452        };
4453        let (sql, params) = q.compile(Dialect::Postgres).unwrap();
4454        assert_eq!(
4455            sql,
4456            "SELECT id, (SELECT count(*) FROM element WHERE element.order_id = work_order.id) AS element_count FROM work_order"
4457        );
4458        assert!(params.is_empty());
4459    }
4460
4461    #[test]
4462    fn related_aggregate_two_counts_bind_distinct_params_and_dont_fan_out() {
4463        // Two correlated counts in one SELECT — each its own subquery (no join, no fan-out),
4464        // and their bound filters take distinct `?N` in left-to-right order.
4465        let with_status = |child: &str, fk: &str, status: &str| {
4466            related(
4467                Agg::Count,
4468                RelArg::Star,
4469                child,
4470                Predicate::And(vec![
4471                    correlate(fk, "party.id"),
4472                    Predicate::Cmp {
4473                        left: Expr::col("status"),
4474                        op: CmpOp::Eq,
4475                        right: Expr::val(t(status)),
4476                    },
4477                ]),
4478            )
4479        };
4480        let q = Select {
4481            columns: vec![
4482                item(with_status("party_role", "party_role.party_id", "active")),
4483                item(with_status(
4484                    "party_qualification",
4485                    "party_qualification.party_id",
4486                    "valid",
4487                )),
4488            ],
4489            ..Select::from("party")
4490        };
4491        let (sql, params) = q.compile(Dialect::Postgres).unwrap();
4492        assert_eq!(
4493            sql,
4494            "SELECT \
4495             (SELECT count(*) FROM party_role WHERE party_role.party_id = party.id AND status = ?1), \
4496             (SELECT count(*) FROM party_qualification WHERE party_qualification.party_id = party.id AND status = ?2) \
4497             FROM party"
4498        );
4499        assert_eq!(params, vec![t("active"), t("valid")]);
4500    }
4501
4502    #[test]
4503    fn related_aggregate_with_temporal_or_filter() {
4504        // construens subgraph-chain:731 — correlated count with a temporal `valid_to` filter.
4505        let q = Select {
4506            columns: vec![SelectItem {
4507                expr: related(
4508                    Agg::Count,
4509                    RelArg::Star,
4510                    "party_qualification",
4511                    Predicate::And(vec![
4512                        correlate("party_qualification.party_id", "party.id"),
4513                        Predicate::Or(vec![
4514                            Predicate::Null {
4515                                expr: Expr::col("valid_to"),
4516                                negated: false,
4517                            },
4518                            Predicate::Cmp {
4519                                left: Expr::col("valid_to"),
4520                                op: CmpOp::Gt,
4521                                right: Expr::val(t("2026-01-01")),
4522                            },
4523                        ]),
4524                    ]),
4525                ),
4526                alias: Some("active_quals".into()),
4527            }],
4528            ..Select::from("party")
4529        };
4530        let (sql, params) = q.compile(Dialect::Postgres).unwrap();
4531        assert_eq!(
4532            sql,
4533            "SELECT (SELECT count(*) FROM party_qualification WHERE party_qualification.party_id = party.id AND (valid_to IS NULL OR valid_to > ?1)) AS active_quals FROM party"
4534        );
4535        assert_eq!(params, vec![t("2026-01-01")]);
4536    }
4537
4538    #[test]
4539    fn related_aggregate_max_over_a_column_is_portable() {
4540        // A correlated MAX over a column (construens subgraph-chain:1932 shape) — an ordinary
4541        // subquery, portable across engines (not Postgres-specific like vector distance).
4542        let q = Select {
4543            columns: vec![SelectItem {
4544                expr: related(
4545                    Agg::Max,
4546                    RelArg::Column("total_minor".into()),
4547                    "line_item",
4548                    correlate("line_item.order_id", "order_summary.id"),
4549                ),
4550                alias: Some("max_total".into()),
4551            }],
4552            ..Select::from("order_summary")
4553        };
4554        let (sql, _) = q.compile(Dialect::Sqlite).unwrap();
4555        assert_eq!(
4556            sql,
4557            "SELECT (SELECT max(total_minor) FROM line_item WHERE line_item.order_id = order_summary.id) AS max_total FROM order_summary"
4558        );
4559    }
4560
4561    #[test]
4562    fn related_aggregate_star_is_count_only() {
4563        let q = Select {
4564            columns: vec![item(related(
4565                Agg::Sum,
4566                RelArg::Star,
4567                "t",
4568                correlate("t.fk", "p.id"),
4569            ))],
4570            ..Select::from("p")
4571        };
4572        assert!(matches!(
4573            q.compile(Dialect::Postgres),
4574            Err(OrmError::BadExpr(_))
4575        ));
4576    }
4577
4578    #[test]
4579    fn related_aggregate_table_injection_is_rejected() {
4580        let q = Select {
4581            columns: vec![item(related(
4582                Agg::Count,
4583                RelArg::Star,
4584                "element; DROP TABLE users",
4585                correlate("element.order_id", "p.id"),
4586            ))],
4587            ..Select::from("p")
4588        };
4589        assert!(matches!(
4590            q.compile(Dialect::Postgres),
4591            Err(OrmError::InvalidIdentifier(_))
4592        ));
4593    }
4594
4595    #[test]
4596    fn target_read_conjoins_the_public_subset_and_composes_across_joins() {
4597        use std::collections::BTreeMap;
4598        // A target read of tenant `B`: products (Tenant→tenant_id) LEFT JOIN reviews
4599        // (Tenant→tenant_id). Each ref is confined to `tenant_id = B` AND its OWN public predicate,
4600        // qualified per ref — so a target read can never reach B's private rows through any table.
4601        let keys = BTreeMap::from([
4602            (
4603                "products".to_string(),
4604                ResolvedScope::Column("tenant_id".to_string()),
4605            ),
4606            (
4607                "reviews".to_string(),
4608                ResolvedScope::Column("tenant_id".to_string()),
4609            ),
4610        ]);
4611        let public = BTreeMap::from([
4612            (
4613                "products".to_string(),
4614                vec![
4615                    PublicTermSql::Cmp {
4616                        column: "published".into(),
4617                        op: CmpOp::Eq,
4618                        value: SqlValue::Boolean(true),
4619                    },
4620                    PublicTermSql::Null {
4621                        column: "deleted_at".into(),
4622                        negated: false,
4623                    },
4624                ],
4625            ),
4626            (
4627                "reviews".to_string(),
4628                vec![PublicTermSql::Cmp {
4629                    column: "visible".into(),
4630                    op: CmpOp::Eq,
4631                    value: SqlValue::Boolean(true),
4632                }],
4633            ),
4634        ]);
4635        let mut q = Select {
4636            table_alias: Some("p".into()),
4637            joins: vec![Join {
4638                kind: JoinKind::Left,
4639                table: "reviews".into(),
4640                alias: Some("r".into()),
4641                on: Predicate::Cmp {
4642                    left: Expr::col("p.id"),
4643                    op: CmpOp::Eq,
4644                    right: Expr::col("r.product_id"),
4645                },
4646            }],
4647            ..Select::from("products")
4648        };
4649        q.force_scope(&Scope {
4650            column: "tenant_id".into(),
4651            value: Some(t("B")),
4652            session: None,
4653            mode: ScopeMode::Own,
4654            keys: TableKeys::PerTableTarget {
4655                keys: keys.clone(),
4656                public: public.clone(),
4657                write: std::collections::BTreeSet::new(),
4658                require_public: true,
4659            },
4660        })
4661        .unwrap();
4662        let (sql, _params) = q.compile(Dialect::Sqlite).unwrap();
4663        // Base ref `p`: tenant + its public terms, all qualified `p.`.
4664        assert!(sql.contains("p.tenant_id = ?"), "base tenant scope: {sql}");
4665        assert!(sql.contains("p.published = ?"), "base public term: {sql}");
4666        assert!(
4667            sql.contains("p.deleted_at IS NULL"),
4668            "base public null term: {sql}"
4669        );
4670        // Joined ref `r`: tenant + ITS OWN public term, qualified `r.` (composition across the join).
4671        assert!(
4672            sql.contains("r.tenant_id = ?"),
4673            "joined tenant scope: {sql}"
4674        );
4675        assert!(sql.contains("r.visible = ?"), "joined public term: {sql}");
4676    }
4677
4678    #[test]
4679    fn target_read_of_a_table_with_no_public_subset_is_refused() {
4680        use std::collections::BTreeMap;
4681        // Deny-by-default: a target read touching a table that declares NO public subset is refused
4682        // (PublicSubsetUndeclared) — the strict analog of an undeclared tenant key, and what keeps a
4683        // target read from reaching a private table.
4684        let keys = BTreeMap::from([(
4685            "secret_table".to_string(),
4686            ResolvedScope::Column("tenant_id".to_string()),
4687        )]);
4688        let deny = Scope {
4689            column: "tenant_id".into(),
4690            value: Some(t("B")),
4691            session: None,
4692            mode: ScopeMode::Own,
4693            keys: TableKeys::PerTableTarget {
4694                keys,
4695                public: BTreeMap::new(), // no public subset for secret_table
4696                write: std::collections::BTreeSet::new(),
4697                require_public: true,
4698            },
4699        };
4700        let mut q = Select::from("secret_table");
4701        // The refusal surfaces at force_scope (join/subquery refs) or compile (base ref).
4702        let err = q
4703            .force_scope(&deny)
4704            .err()
4705            .or_else(|| q.compile(Dialect::Sqlite).err());
4706        assert!(
4707            matches!(&err, Some(OrmError::PublicSubsetUndeclared(t)) if t == "secret_table"),
4708            "expected PublicSubsetUndeclared, got {err:?}"
4709        );
4710    }
4711
4712    #[test]
4713    fn own_read_is_unaffected_by_the_public_injection() {
4714        // An own read (PerTable, not PerTableTarget) conjoins NO public predicate — byte-identical
4715        // to pre-Stage-5. (Regression fence: the target path must not leak into the own path.)
4716        use std::collections::BTreeMap;
4717        let mut q = Select::from("products");
4718        q.force_scope(&Scope {
4719            column: "tenant_id".into(),
4720            value: Some(t("A")),
4721            session: None,
4722            mode: ScopeMode::Own,
4723            keys: TableKeys::PerTable(BTreeMap::from([(
4724                "products".to_string(),
4725                ResolvedScope::Column("tenant_id".to_string()),
4726            )])),
4727        })
4728        .unwrap();
4729        let (sql, _p) = q.compile(Dialect::Sqlite).unwrap();
4730        assert!(sql.contains("tenant_id = ?"));
4731        assert!(
4732            !sql.contains("published") && !sql.contains("IS NULL"),
4733            "own read must carry no public confinement: {sql}"
4734        );
4735    }
4736
4737    #[test]
4738    fn tenant_or_base_folds_the_null_base_on_reads_but_not_writes() {
4739        // v0.4.16: `pack` is base-inclusive (its tenant-NULL rows are shared base); `product` is a
4740        // plain tenant table. On the OWN axis a read of `pack` folds `(tenant = A OR tenant IS NULL)`
4741        // — A's rows ⊕ the shared base — while `product` stays `tenant = A` alone. The fold is per
4742        // TABLE, so a query touching both confines each correctly (no field-level widening).
4743        use std::collections::BTreeMap;
4744        let scope = Scope {
4745            column: "tenant_id".into(),
4746            value: Some(t("A")),
4747            session: None,
4748            mode: ScopeMode::Own,
4749            keys: TableKeys::PerTable(BTreeMap::from([
4750                (
4751                    "pack".to_string(),
4752                    ResolvedScope::TenantOrBase {
4753                        tenant: "tenant_id".into(),
4754                    },
4755                ),
4756                (
4757                    "product".to_string(),
4758                    ResolvedScope::Column("tenant_id".into()),
4759                ),
4760            ])),
4761        };
4762        // Base-inclusive read: (tenant = A OR tenant IS NULL).
4763        let mut pack = Select::from("pack");
4764        pack.force_scope(&scope).unwrap();
4765        let (psql, _p) = pack.compile(Dialect::Sqlite).unwrap();
4766        assert!(
4767            psql.contains("tenant_id = ?")
4768                && psql.contains("tenant_id IS NULL")
4769                && psql.contains(" OR "),
4770            "base-inclusive read folds the NULL base: {psql}"
4771        );
4772        // Plain tenant read: tenant = A alone (no base fold — the fold is per-table, not per-field).
4773        let mut prod = Select::from("product");
4774        prod.force_scope(&scope).unwrap();
4775        let (dsql, _p) = prod.compile(Dialect::Sqlite).unwrap();
4776        assert!(
4777            dsql.contains("tenant_id = ?") && !dsql.contains("IS NULL"),
4778            "a plain tenant table alongside a base-inclusive one stays tenant-only: {dsql}"
4779        );
4780        // WRITE stamps the resolved tenant (never NULL) — a guest can't create a base row.
4781        let mut ins = Insert {
4782            table: "pack".into(),
4783            rows: vec![RowValues {
4784                cells: vec![Assignment {
4785                    column: "name".into(),
4786                    value: Expr::val(t("p")),
4787                }],
4788            }],
4789            conflict: None,
4790            scope: None,
4791            returning: vec![],
4792            from_select: None,
4793        };
4794        ins.force_scope(Some(&scope), Some(&scope)).unwrap();
4795        let (isql, iparams) = ins.compile(Dialect::Sqlite).unwrap();
4796        assert!(
4797            isql.contains("tenant_id"),
4798            "write stamps the tenant column: {isql}"
4799        );
4800        assert!(
4801            iparams.contains(&t("A")) && !iparams.contains(&SqlValue::Null),
4802            "write stamps tenant = A, never a NULL base row: {iparams:?}"
4803        );
4804    }
4805
4806    #[test]
4807    fn target_read_capability_drops_a_declared_subset_confining_tenant_only() {
4808        // construens embed bug (ruling A made COMPLETE): under a capability-only target read
4809        // (require_public=false), a table that DECLARES a public subset (authored for the anonymous
4810        // funnel) must confine to `tenant = B` ALONE — the subset is NOT AND-ed on, so a resolver's
4811        // own within-tenant filter on that column isn't collided into the empty set. `tenant = B`
4812        // (applied separately) stays mandatory on the base ref AND every joined ref.
4813        use std::collections::BTreeMap;
4814        let keys = BTreeMap::from([
4815            (
4816                "products".to_string(),
4817                ResolvedScope::Column("tenant_id".to_string()),
4818            ),
4819            (
4820                "reviews".to_string(),
4821                ResolvedScope::Column("tenant_id".to_string()),
4822            ),
4823        ]);
4824        let public = BTreeMap::from([
4825            (
4826                "products".to_string(),
4827                vec![
4828                    PublicTermSql::Cmp {
4829                        column: "published".into(),
4830                        op: CmpOp::Eq,
4831                        value: SqlValue::Boolean(true),
4832                    },
4833                    PublicTermSql::Null {
4834                        column: "deleted_at".into(),
4835                        negated: false,
4836                    },
4837                ],
4838            ),
4839            (
4840                "reviews".to_string(),
4841                vec![PublicTermSql::Cmp {
4842                    column: "visible".into(),
4843                    op: CmpOp::Eq,
4844                    value: SqlValue::Boolean(true),
4845                }],
4846            ),
4847        ]);
4848        let cap = Scope {
4849            column: "tenant_id".into(),
4850            value: Some(t("B")),
4851            session: None,
4852            mode: ScopeMode::Own,
4853            keys: TableKeys::PerTableTarget {
4854                keys,
4855                public,
4856                write: std::collections::BTreeSet::new(),
4857                require_public: false, // capability axis (ruling A)
4858            },
4859        };
4860        let mut q = Select {
4861            table_alias: Some("p".into()),
4862            joins: vec![Join {
4863                kind: JoinKind::Inner,
4864                table: "reviews".into(),
4865                alias: Some("r".into()),
4866                on: Predicate::Cmp {
4867                    left: Expr::col("r.product_id"),
4868                    op: CmpOp::Eq,
4869                    right: Expr::col("p.id"),
4870                },
4871            }],
4872            ..Select::from("products")
4873        };
4874        q.force_scope(&cap).unwrap();
4875        let (sql, _p) = q.compile(Dialect::Sqlite).unwrap();
4876        assert!(
4877            sql.contains("p.tenant_id = ?"),
4878            "base tenant scope kept: {sql}"
4879        );
4880        assert!(
4881            sql.contains("r.tenant_id = ?"),
4882            "joined tenant scope kept: {sql}"
4883        );
4884        assert!(
4885            !sql.contains("published") && !sql.contains("deleted_at") && !sql.contains("visible"),
4886            "no declared subset term may appear on the capability axis: {sql}"
4887        );
4888    }
4889
4890    // ---- 5b: target WRITES (INSERT + UPDATE with a SET-allowlist; DELETE refused) --------------
4891
4892    /// A target-WRITE scope for `products`: tenant key `tenant_id`, public subset `published = true
4893    /// AND deleted_at IS NULL`, SET-allowlist = the given columns.
4894    fn target_write_scope(write: &[&str]) -> Scope {
4895        use std::collections::{BTreeMap, BTreeSet};
4896        Scope {
4897            column: "tenant_id".into(),
4898            value: Some(t("tenant_B")),
4899            session: None,
4900            mode: ScopeMode::Own,
4901            keys: TableKeys::PerTableTarget {
4902                keys: BTreeMap::from([(
4903                    "products".to_string(),
4904                    ResolvedScope::Column("tenant_id".to_string()),
4905                )]),
4906                public: BTreeMap::from([(
4907                    "products".to_string(),
4908                    vec![
4909                        PublicTermSql::Cmp {
4910                            column: "published".into(),
4911                            op: CmpOp::Eq,
4912                            value: SqlValue::Boolean(true),
4913                        },
4914                        PublicTermSql::Null {
4915                            column: "deleted_at".into(),
4916                            negated: false,
4917                        },
4918                    ],
4919                )]),
4920                write: write
4921                    .iter()
4922                    .map(ToString::to_string)
4923                    .collect::<BTreeSet<_>>(),
4924                require_public: true,
4925            },
4926        }
4927    }
4928
4929    /// A **capability-axis** target-WRITE scope for `client_survey` (`require_public = false`, ruling
4930    /// A): tenant key `tenant_id`, NO declared public subset, SET-allowlist = the given columns. This
4931    /// is the axis on which an `ON CONFLICT … DO UPDATE` upsert is admitted.
4932    fn target_write_scope_cap(write: &[&str]) -> Scope {
4933        use std::collections::{BTreeMap, BTreeSet};
4934        Scope {
4935            column: "tenant_id".into(),
4936            value: Some(t("tenant_B")),
4937            session: None,
4938            mode: ScopeMode::Own,
4939            keys: TableKeys::PerTableTarget {
4940                keys: BTreeMap::from([(
4941                    "client_survey".to_string(),
4942                    ResolvedScope::Column("tenant_id".to_string()),
4943                )]),
4944                public: BTreeMap::new(), // capability axis: no visibility subset (ruling A)
4945                write: write
4946                    .iter()
4947                    .map(ToString::to_string)
4948                    .collect::<BTreeSet<_>>(),
4949                require_public: false,
4950            },
4951        }
4952    }
4953
4954    fn target_insert(cells: Vec<Assignment>) -> Insert {
4955        Insert {
4956            table: "products".into(),
4957            rows: vec![RowValues { cells }],
4958            conflict: None,
4959            scope: None,
4960            returning: vec![],
4961            from_select: None,
4962        }
4963    }
4964
4965    #[test]
4966    fn target_insert_forces_tenant_and_public_and_accepts_only_allowlisted_columns() {
4967        // The guest sets only the allowlisted `title`; the host force-stamps tenant=B, published=true,
4968        // deleted_at=NULL — so the inserted row lands squarely in B's public subset.
4969        let scope = target_write_scope(&["title"]);
4970        let mut ins = target_insert(vec![Assignment {
4971            column: "title".into(),
4972            value: Expr::val(t("Hello")),
4973        }]);
4974        ins.force_scope(Some(&scope), Some(&scope)).unwrap();
4975        let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
4976        assert!(sql.contains("tenant_id"), "{sql}");
4977        assert!(sql.contains("published"), "{sql}");
4978        assert!(sql.contains("deleted_at"), "{sql}");
4979        assert!(
4980            params.contains(&t("tenant_B")),
4981            "tenant forced to B: {params:?}"
4982        );
4983        assert!(
4984            params.contains(&SqlValue::Boolean(true)),
4985            "published forced true: {params:?}"
4986        );
4987        assert!(
4988            params.contains(&SqlValue::Null),
4989            "deleted_at forced NULL: {params:?}"
4990        );
4991        assert!(params.contains(&t("Hello")), "guest title kept: {params:?}");
4992    }
4993
4994    #[test]
4995    fn target_insert_refuses_a_non_allowlisted_column() {
4996        // `price` is not in the SET-allowlist ⇒ refused (a target write may set only granted columns).
4997        let scope = target_write_scope(&["title"]);
4998        let mut ins = target_insert(vec![
4999            Assignment {
5000                column: "title".into(),
5001                value: Expr::val(t("x")),
5002            },
5003            Assignment {
5004                column: "price".into(),
5005                value: Expr::val(SqlValue::Integer(9)),
5006            },
5007        ]);
5008        let err = ins.force_scope(Some(&scope), Some(&scope)).unwrap_err();
5009        assert!(
5010            matches!(err, OrmError::TargetWriteColumnDenied(ref c) if c == "price"),
5011            "{err:?}"
5012        );
5013    }
5014
5015    #[test]
5016    fn target_insert_refuses_setting_the_tenant_or_visibility_column() {
5017        // Even if the guest tries to set tenant_id or published directly, it's denied (they're never
5018        // in the allowlist; and `assert_target_settable` refuses them structurally regardless).
5019        for bad in ["tenant_id", "published", "deleted_at"] {
5020            let scope = target_write_scope(&["title", bad]); // even if wrongly granted...
5021            let mut ins = target_insert(vec![Assignment {
5022                column: bad.into(),
5023                value: Expr::val(t("x")),
5024            }]);
5025            let err = ins.force_scope(Some(&scope), Some(&scope)).unwrap_err();
5026            assert!(
5027                matches!(err, OrmError::TargetWriteColumnDenied(ref c) if c == bad),
5028                "{bad}: {err:?}"
5029            );
5030        }
5031    }
5032
5033    #[test]
5034    fn target_insert_with_no_write_grant_is_refused() {
5035        let scope = target_write_scope(&[]); // read-only
5036        let mut ins = target_insert(vec![Assignment {
5037            column: "title".into(),
5038            value: Expr::val(t("x")),
5039        }]);
5040        let err = ins.force_scope(Some(&scope), Some(&scope)).unwrap_err();
5041        assert!(
5042            matches!(err, OrmError::TargetWriteNotGranted(ref t) if t == "products"),
5043            "{err:?}"
5044        );
5045    }
5046
5047    #[test]
5048    fn target_write_to_a_tenant_or_session_table_is_refused() {
5049        use std::collections::{BTreeMap, BTreeSet};
5050        // `state_scope` is a TenantOrSession (anon-session) table. A target principal carries only
5051        // tenant B (no session fact), so a target write here could only stamp `tenant = B` — silently
5052        // claiming an anon/session-owned row for B and breaking anon→promotion. It must be refused
5053        // early + self-describingly (Stage A), NOT surfaced as a public-subset error and NOT silently
5054        // stamped. Both INSERT and UPDATE are covered.
5055        let scope = Scope {
5056            column: "tenant_id".into(),
5057            value: Some(t("tenant_B")),
5058            session: None,
5059            mode: ScopeMode::Own,
5060            keys: TableKeys::PerTableTarget {
5061                keys: BTreeMap::from([(
5062                    "state_scope".to_string(),
5063                    ResolvedScope::TenantOrSession {
5064                        tenant: "tenant_id".to_string(),
5065                        session: "session_id".to_string(),
5066                    },
5067                )]),
5068                public: BTreeMap::new(),
5069                write: BTreeSet::from(["note".to_string()]),
5070                require_public: true,
5071            },
5072        };
5073        // INSERT
5074        let mut ins = Insert {
5075            table: "state_scope".into(),
5076            rows: vec![RowValues {
5077                cells: vec![Assignment {
5078                    column: "note".into(),
5079                    value: Expr::val(t("x")),
5080                }],
5081            }],
5082            conflict: None,
5083            scope: None,
5084            returning: vec![],
5085            from_select: None,
5086        };
5087        let err = ins.force_scope(Some(&scope), Some(&scope)).unwrap_err();
5088        assert!(
5089            matches!(err, OrmError::TargetWriteToSessionTable(ref x) if x == "state_scope"),
5090            "INSERT should refuse a target write to a TenantOrSession table: {err:?}"
5091        );
5092        // UPDATE
5093        let mut upd = Update {
5094            table: "state_scope".into(),
5095            set: vec![Assignment {
5096                column: "note".into(),
5097                value: Expr::val(t("y")),
5098            }],
5099            filter: cmp("id", CmpOp::Eq, t("s1")),
5100            scope: None,
5101            returning: vec![],
5102        };
5103        let err = upd.force_scope(&scope).unwrap_err();
5104        assert!(
5105            matches!(err, OrmError::TargetWriteToSessionTable(ref x) if x == "state_scope"),
5106            "UPDATE should refuse a target write to a TenantOrSession table: {err:?}"
5107        );
5108    }
5109
5110    #[test]
5111    fn target_insert_select_and_upsert_are_refused() {
5112        let scope = target_write_scope(&["title"]);
5113        let mut ins = target_insert(vec![Assignment {
5114            column: "title".into(),
5115            value: Expr::val(t("x")),
5116        }]);
5117        ins.from_select = Some((vec!["title".into()], Box::new(Select::from("products"))));
5118        assert!(matches!(
5119            ins.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5120            OrmError::TargetWriteUnsupported("INSERT … SELECT")
5121        ));
5122        // An upsert on an ANONYMOUS (require_public — domain/handle/target_or_null) target stays
5123        // refused: the DO-UPDATE `tenant = B` guard carries no visibility subset.
5124        let mut ins2 = target_insert(vec![Assignment {
5125            column: "title".into(),
5126            value: Expr::val(t("x")),
5127        }]);
5128        ins2.conflict = Some(OnConflict {
5129            conflict_columns: vec!["tenant_id".into(), "id".into()],
5130            update: vec![Assignment {
5131                column: "title".into(),
5132                value: Expr::col("excluded.title"),
5133            }],
5134        });
5135        assert!(matches!(
5136            ins2.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5137            OrmError::TargetWriteUnsupported(
5138                "ON CONFLICT upsert on an anonymous domain/handle/target_or_null target"
5139            )
5140        ));
5141    }
5142
5143    #[test]
5144    fn target_upsert_capability_confines_do_update_and_guards_tenant_b() {
5145        // v0.4.15 (own↔target parity): a capability-axis `ON CONFLICT (tenant_id, …) DO UPDATE`
5146        // upsert compiles — the INSERT stamps tenant=B, the DO UPDATE SET is allowlisted, and the
5147        // conflict-update is guarded to `tenant = B` so it can never touch another tenant's row.
5148        // The natural-key cells (project_id, client_id) are inserted by the guest, so they're in the
5149        // write-allowlist alongside the mutated `score` (a target INSERT cell must be allowlisted).
5150        let scope = target_write_scope_cap(&["score", "comment", "project_id", "client_id"]);
5151        let mut ins = Insert {
5152            table: "client_survey".into(),
5153            rows: vec![RowValues {
5154                cells: vec![
5155                    Assignment {
5156                        column: "project_id".into(),
5157                        value: Expr::val(t("p1")),
5158                    },
5159                    Assignment {
5160                        column: "client_id".into(),
5161                        value: Expr::val(t("cli_a")),
5162                    },
5163                    Assignment {
5164                        column: "score".into(),
5165                        value: Expr::val(SqlValue::Integer(5)),
5166                    },
5167                ],
5168            }],
5169            conflict: Some(OnConflict {
5170                conflict_columns: vec!["tenant_id".into(), "project_id".into(), "client_id".into()],
5171                update: vec![Assignment {
5172                    column: "score".into(),
5173                    value: Expr::col("excluded.score"),
5174                }],
5175            }),
5176            scope: None,
5177            returning: vec![],
5178            from_select: None,
5179        };
5180        ins.force_scope(Some(&scope), Some(&scope)).unwrap();
5181        let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
5182        assert!(
5183            sql.contains("ON CONFLICT (tenant_id, project_id, client_id) DO UPDATE SET"),
5184            "upsert compiled: {sql}"
5185        );
5186        assert!(
5187            sql.contains("score = excluded.score"),
5188            "SET allowlisted col: {sql}"
5189        );
5190        assert!(
5191            sql.contains("client_survey.tenant_id ="),
5192            "DO UPDATE guarded to tenant = B (qualified): {sql}"
5193        );
5194        assert!(
5195            params.iter().filter(|p| **p == t("tenant_B")).count() >= 2,
5196            "tenant B stamped on INSERT AND bound in the DO-UPDATE guard: {params:?}"
5197        );
5198    }
5199
5200    #[test]
5201    fn target_upsert_capability_requires_tenant_in_conflict_key() {
5202        // Deny-by-default: a capability upsert whose conflict target OMITS the tenant column is
5203        // refused — else B's INSERT could conflict with tenant A's row on a tenant-agnostic key.
5204        let scope = target_write_scope_cap(&["score", "project_id", "client_id"]);
5205        let mut ins = Insert {
5206            table: "client_survey".into(),
5207            rows: vec![RowValues {
5208                cells: vec![Assignment {
5209                    column: "score".into(),
5210                    value: Expr::val(SqlValue::Integer(5)),
5211                }],
5212            }],
5213            conflict: Some(OnConflict {
5214                conflict_columns: vec!["project_id".into(), "client_id".into()], // NO tenant_id
5215                update: vec![Assignment {
5216                    column: "score".into(),
5217                    value: Expr::col("excluded.score"),
5218                }],
5219            }),
5220            scope: None,
5221            returning: vec![],
5222            from_select: None,
5223        };
5224        assert!(matches!(
5225            ins.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5226            OrmError::TargetUpsertKeyMissingTenant(t) if t == "client_survey"
5227        ));
5228        // A `.`-qualified conflict-key column is refused self-describingly at compile (invalid ON
5229        // CONFLICT syntax + can't be allowed to slip past the tenant-key check on a last-segment match).
5230        let mut qual = Insert {
5231            table: "client_survey".into(),
5232            rows: vec![RowValues {
5233                cells: vec![Assignment {
5234                    column: "score".into(),
5235                    value: Expr::val(SqlValue::Integer(5)),
5236                }],
5237            }],
5238            conflict: Some(OnConflict {
5239                conflict_columns: vec!["cs.tenant_id".into(), "project_id".into()],
5240                update: vec![Assignment {
5241                    column: "score".into(),
5242                    value: Expr::col("excluded.score"),
5243                }],
5244            }),
5245            scope: None,
5246            returning: vec![],
5247            from_select: None,
5248        };
5249        assert!(matches!(
5250            qual.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5251            OrmError::TargetWriteColumnDenied(c) if c == "cs.tenant_id"
5252        ));
5253    }
5254
5255    #[test]
5256    fn target_upsert_capability_refuses_a_non_allowlisted_or_tenant_do_update_set() {
5257        // The DO UPDATE SET is confined exactly like a target UPDATE: a non-allowlisted column, or the
5258        // tenant column, in the SET is refused (can't change ownership or touch a non-granted column).
5259        let scope = target_write_scope_cap(&["score"]);
5260        let mk = |set_col: &str| Insert {
5261            table: "client_survey".into(),
5262            rows: vec![RowValues {
5263                cells: vec![Assignment {
5264                    column: "score".into(),
5265                    value: Expr::val(SqlValue::Integer(5)),
5266                }],
5267            }],
5268            conflict: Some(OnConflict {
5269                conflict_columns: vec!["tenant_id".into(), "project_id".into()],
5270                update: vec![Assignment {
5271                    column: set_col.into(),
5272                    value: Expr::val(SqlValue::Integer(9)),
5273                }],
5274            }),
5275            scope: None,
5276            returning: vec![],
5277            from_select: None,
5278        };
5279        let mut bad_col = mk("secret"); // not in the allowlist
5280        assert!(matches!(
5281            bad_col.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5282            OrmError::TargetWriteColumnDenied(_)
5283        ));
5284        let mut tenant_set = mk("tenant_id"); // the tenant column itself
5285        assert!(matches!(
5286            tenant_set
5287                .force_scope(Some(&scope), Some(&scope))
5288                .unwrap_err(),
5289            OrmError::TargetWriteColumnDenied(_)
5290        ));
5291    }
5292
5293    #[test]
5294    fn target_update_confines_to_the_public_subset_and_enforces_the_allowlist() {
5295        let scope = target_write_scope(&["title"]);
5296        let mut upd = Update {
5297            table: "products".into(),
5298            set: vec![Assignment {
5299                column: "title".into(),
5300                value: Expr::val(t("new")),
5301            }],
5302            filter: cmp("id", CmpOp::Eq, t("p1")),
5303            scope: None,
5304            returning: vec![],
5305        };
5306        upd.force_scope(&scope).unwrap();
5307        let (sql, params) = upd.compile(Dialect::Sqlite).unwrap();
5308        // WHERE = tenant = B AND (public terms) AND (guest filter).
5309        assert!(sql.contains("tenant_id = ?"), "tenant confinement: {sql}");
5310        assert!(sql.contains("published = ?"), "public confinement: {sql}");
5311        assert!(
5312            sql.contains("deleted_at IS NULL"),
5313            "public null confinement: {sql}"
5314        );
5315        assert!(sql.contains("SET title = ?"), "{sql}");
5316        assert!(params.contains(&t("tenant_B")), "{params:?}");
5317    }
5318
5319    #[test]
5320    fn target_update_capability_no_subset_confines_tenant_only_not_refused() {
5321        // 5c ruling A: a capability-only (require_public=false) UPDATE on a table with NO declared
5322        // public subset confines to `tenant = B` (+ the SET-allowlist) — NOT refused (v0.4.3 bug the
5323        // review caught: it errored PublicSubsetUndeclared, breaking capability write-embeds).
5324        use std::collections::{BTreeMap, BTreeSet};
5325        let scope = Scope {
5326            column: "tenant_id".into(),
5327            value: Some(t("tenant_B")),
5328            session: None,
5329            mode: ScopeMode::Own,
5330            keys: TableKeys::PerTableTarget {
5331                keys: BTreeMap::from([(
5332                    "invoices".to_string(),
5333                    ResolvedScope::Column("tenant_id".into()),
5334                )]),
5335                public: BTreeMap::new(), // no subset for invoices
5336                write: BTreeSet::from(["amount".to_string()]),
5337                require_public: false, // capability
5338            },
5339        };
5340        let mut upd = Update {
5341            table: "invoices".into(),
5342            set: vec![Assignment {
5343                column: "amount".into(),
5344                value: Expr::val(SqlValue::Integer(5)),
5345            }],
5346            filter: cmp("id", CmpOp::Eq, t("inv1")),
5347            scope: None,
5348            returning: vec![],
5349        };
5350        upd.force_scope(&scope).unwrap();
5351        let (sql, _params) = upd.compile(Dialect::Sqlite).unwrap();
5352        assert!(
5353            sql.contains("tenant_id = ?"),
5354            "tenant=B confinement present: {sql}"
5355        );
5356        assert!(!sql.contains("published"), "no visibility conjunct: {sql}");
5357        assert!(sql.contains("SET amount = ?"), "{sql}");
5358        // A non-allowlisted column is still refused under the exemption.
5359        let mut bad = Update {
5360            table: "invoices".into(),
5361            set: vec![Assignment {
5362                column: "tenant_id".into(),
5363                value: Expr::val(t("evil")),
5364            }],
5365            filter: cmp("id", CmpOp::Eq, t("inv1")),
5366            scope: None,
5367            returning: vec![],
5368        };
5369        assert!(
5370            bad.force_scope(&scope).is_err(),
5371            "tenant column still un-settable"
5372        );
5373    }
5374
5375    #[test]
5376    fn target_update_refuses_a_non_allowlisted_or_visibility_set() {
5377        for bad in ["price", "published", "tenant_id"] {
5378            let scope = target_write_scope(&["title"]);
5379            let mut upd = Update {
5380                table: "products".into(),
5381                set: vec![Assignment {
5382                    column: bad.into(),
5383                    value: Expr::val(t("x")),
5384                }],
5385                filter: cmp("id", CmpOp::Eq, t("p1")),
5386                scope: None,
5387                returning: vec![],
5388            };
5389            assert!(
5390                matches!(upd.force_scope(&scope).unwrap_err(), OrmError::TargetWriteColumnDenied(ref c) if c == bad),
5391                "{bad}"
5392            );
5393        }
5394    }
5395
5396    #[test]
5397    fn target_write_refuses_a_qualified_column() {
5398        // A dotted write-target column (`published.x`) must be refused at compile — never rendered
5399        // (it would sneak past the last-segment `same_col` visibility check and emit invalid SQL).
5400        let scope = target_write_scope(&["title"]);
5401        let mut ins = target_insert(vec![Assignment {
5402            column: "published.x".into(),
5403            value: Expr::val(t("x")),
5404        }]);
5405        assert!(matches!(
5406            ins.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5407            OrmError::TargetWriteColumnDenied(ref c) if c == "published.x"
5408        ));
5409        let mut upd = Update {
5410            table: "products".into(),
5411            set: vec![Assignment {
5412                column: "title.y".into(),
5413                value: Expr::val(t("x")),
5414            }],
5415            filter: cmp("id", CmpOp::Eq, t("p1")),
5416            scope: None,
5417            returning: vec![],
5418        };
5419        assert!(matches!(
5420            upd.force_scope(&scope).unwrap_err(),
5421            OrmError::TargetWriteColumnDenied(ref c) if c == "title.y"
5422        ));
5423    }
5424
5425    #[test]
5426    fn target_delete_is_always_refused() {
5427        let scope = target_write_scope(&["title"]);
5428        let mut del = Delete {
5429            table: "products".into(),
5430            filter: cmp("id", CmpOp::Eq, t("p1")),
5431            scope: None,
5432            returning: vec![],
5433        };
5434        assert!(matches!(
5435            del.force_scope(&scope).unwrap_err(),
5436            OrmError::TargetDeleteRefused(t) if t == "products"
5437        ));
5438    }
5439
5440    #[test]
5441    fn target_promote_is_refused() {
5442        let scope = target_write_scope(&["title"]);
5443        assert!(matches!(
5444            compile_promote(&scope, "products", Dialect::Sqlite).unwrap_err(),
5445            OrmError::TargetWriteUnsupported("promote")
5446        ));
5447    }
5448
5449    // ---- 5d: attach_reference (derived-tenant write) ------------------------------------------
5450
5451    fn attach_spec() -> AttachReference {
5452        AttachReference {
5453            child: "favorites".into(),
5454            parent: "products".into(),
5455            ref_column: "id".into(),
5456            ref_value: t("prod_1"),
5457            set: vec![Assignment {
5458                column: "note".into(),
5459                value: Expr::val(t("nice")),
5460            }],
5461        }
5462    }
5463
5464    #[test]
5465    fn attach_reference_own_derives_tenant_from_the_scoped_parent() {
5466        use std::collections::BTreeMap;
5467        // An OWN scope over `favorites` (child) + `products` (parent), both keyed on tenant_id.
5468        let scope = Scope {
5469            column: "tenant_id".into(),
5470            value: Some(t("A")),
5471            session: None,
5472            mode: ScopeMode::Own,
5473            keys: TableKeys::PerTable(BTreeMap::from([
5474                (
5475                    "favorites".to_string(),
5476                    ResolvedScope::Column("tenant_id".into()),
5477                ),
5478                (
5479                    "products".to_string(),
5480                    ResolvedScope::Column("tenant_id".into()),
5481                ),
5482            ])),
5483        };
5484        let (sql, params) =
5485            compile_attach_reference(&scope, &attach_spec(), Dialect::Sqlite).unwrap();
5486        // The child tenant is projected from the parent; the source is confined to the caller's own
5487        // tenant (so the derived tenant is bounded; an unreachable product selects nothing).
5488        assert_eq!(
5489            sql,
5490            "INSERT INTO favorites (note, tenant_id) SELECT ?1, tenant_id FROM products \
5491             WHERE tenant_id = ?2 AND id = ?3"
5492        );
5493        assert_eq!(params, vec![t("nice"), t("A"), t("prod_1")]);
5494    }
5495
5496    #[test]
5497    fn attach_reference_target_confines_parent_to_b_public_and_forces_child_public() {
5498        // A TARGET write scope: parent `products` confined to B + public; child `favorites` gets its
5499        // tenant from the parent (=B) + its own public columns force-stamped; `note` is allowlisted.
5500        use std::collections::{BTreeMap, BTreeSet};
5501        let public_terms = vec![PublicTermSql::Cmp {
5502            column: "visible".into(),
5503            op: CmpOp::Eq,
5504            value: SqlValue::Boolean(true),
5505        }];
5506        let scope = Scope {
5507            column: "tenant_id".into(),
5508            value: Some(t("tenant_B")),
5509            session: None,
5510            mode: ScopeMode::Own,
5511            keys: TableKeys::PerTableTarget {
5512                keys: BTreeMap::from([
5513                    (
5514                        "favorites".to_string(),
5515                        ResolvedScope::Column("tenant_id".into()),
5516                    ),
5517                    (
5518                        "products".to_string(),
5519                        ResolvedScope::Column("tenant_id".into()),
5520                    ),
5521                ]),
5522                public: BTreeMap::from([
5523                    ("favorites".to_string(), public_terms.clone()),
5524                    (
5525                        "products".to_string(),
5526                        vec![PublicTermSql::Cmp {
5527                            column: "published".into(),
5528                            op: CmpOp::Eq,
5529                            value: SqlValue::Boolean(true),
5530                        }],
5531                    ),
5532                ]),
5533                write: BTreeSet::from(["note".to_string()]),
5534                require_public: true,
5535            },
5536        };
5537        let (sql, params) =
5538            compile_attach_reference(&scope, &attach_spec(), Dialect::Sqlite).unwrap();
5539        // Child gets note + tenant(from parent) + forced visible=true; the source (products) is
5540        // confined to tenant=B AND published=true (the base-table predicate is unqualified).
5541        assert!(
5542            sql.contains("INSERT INTO favorites (note, tenant_id, visible)"),
5543            "{sql}"
5544        );
5545        assert!(
5546            sql.contains("SELECT ?1, tenant_id, ?2 FROM products"),
5547            "{sql}"
5548        );
5549        assert!(
5550            sql.contains("published = ?") && sql.contains("tenant_id = ?"),
5551            "parent confined: {sql}"
5552        );
5553        assert!(sql.contains("AND id = ?"), "ref selector present: {sql}");
5554        assert!(
5555            params.contains(&t("tenant_B")),
5556            "parent confined to B: {params:?}"
5557        );
5558        assert!(
5559            params.contains(&SqlValue::Boolean(true)),
5560            "child visible forced + parent published: {params:?}"
5561        );
5562    }
5563
5564    #[test]
5565    fn attach_reference_target_refuses_a_non_allowlisted_or_visibility_set() {
5566        use std::collections::{BTreeMap, BTreeSet};
5567        let scope = Scope {
5568            column: "tenant_id".into(),
5569            value: Some(t("tenant_B")),
5570            session: None,
5571            mode: ScopeMode::Own,
5572            keys: TableKeys::PerTableTarget {
5573                keys: BTreeMap::from([
5574                    (
5575                        "favorites".to_string(),
5576                        ResolvedScope::Column("tenant_id".into()),
5577                    ),
5578                    (
5579                        "products".to_string(),
5580                        ResolvedScope::Column("tenant_id".into()),
5581                    ),
5582                ]),
5583                public: BTreeMap::from([
5584                    (
5585                        "favorites".to_string(),
5586                        vec![PublicTermSql::Cmp {
5587                            column: "visible".into(),
5588                            op: CmpOp::Eq,
5589                            value: SqlValue::Boolean(true),
5590                        }],
5591                    ),
5592                    (
5593                        "products".to_string(),
5594                        vec![PublicTermSql::Cmp {
5595                            column: "published".into(),
5596                            op: CmpOp::Eq,
5597                            value: SqlValue::Boolean(true),
5598                        }],
5599                    ),
5600                ]),
5601                write: BTreeSet::from(["note".to_string()]),
5602                require_public: true,
5603            },
5604        };
5605        // `price` isn't allowlisted; `visible` is a visibility column — both refused.
5606        for bad in ["price", "visible", "tenant_id"] {
5607            let spec = AttachReference {
5608                set: vec![Assignment {
5609                    column: bad.into(),
5610                    value: Expr::val(t("x")),
5611                }],
5612                ..attach_spec()
5613            };
5614            assert!(
5615                matches!(
5616                    compile_attach_reference(&scope, &spec, Dialect::Sqlite).unwrap_err(),
5617                    OrmError::TargetWriteColumnDenied(ref c) if c == bad
5618                ),
5619                "{bad}"
5620            );
5621        }
5622    }
5623
5624    #[test]
5625    fn attach_reference_own_refuses_the_guest_naming_the_tenant_column() {
5626        use std::collections::BTreeMap;
5627        // Even under OWN (no write-allowlist gating), the guest may not name the child's tenant
5628        // column in `set` — the host derives it from the parent; a guest value would collide/forge.
5629        let scope = Scope {
5630            column: "tenant_id".into(),
5631            value: Some(t("A")),
5632            session: None,
5633            mode: ScopeMode::Own,
5634            keys: TableKeys::PerTable(BTreeMap::from([
5635                (
5636                    "favorites".to_string(),
5637                    ResolvedScope::Column("tenant_id".into()),
5638                ),
5639                (
5640                    "products".to_string(),
5641                    ResolvedScope::Column("tenant_id".into()),
5642                ),
5643            ])),
5644        };
5645        let spec = AttachReference {
5646            set: vec![Assignment {
5647                column: "tenant_id".into(),
5648                value: Expr::val(t("VICTIM")),
5649            }],
5650            ..attach_spec()
5651        };
5652        assert!(matches!(
5653            compile_attach_reference(&scope, &spec, Dialect::Sqlite).unwrap_err(),
5654            OrmError::TargetWriteColumnDenied(ref c) if c == "tenant_id"
5655        ));
5656    }
5657
5658    #[test]
5659    fn attach_reference_refuses_a_non_column_table() {
5660        use std::collections::BTreeMap;
5661        // `countries` is Unscoped ⇒ not a plain tenant table ⇒ refused as a child/parent.
5662        let scope = Scope {
5663            column: "tenant_id".into(),
5664            value: Some(t("A")),
5665            session: None,
5666            mode: ScopeMode::Own,
5667            keys: TableKeys::PerTable(BTreeMap::from([
5668                (
5669                    "favorites".to_string(),
5670                    ResolvedScope::Column("tenant_id".into()),
5671                ),
5672                ("countries".to_string(), ResolvedScope::Unscoped),
5673            ])),
5674        };
5675        let spec = AttachReference {
5676            parent: "countries".into(),
5677            ..attach_spec()
5678        };
5679        assert!(compile_attach_reference(&scope, &spec, Dialect::Sqlite).is_err());
5680    }
5681}