Skip to main content

pylon_core/ir/
compiler.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20use crate::error::{
21    Position, PyQLError, PyQLResolutionError, PyQLTypeError, PyQLUnknownFieldError, PyQLUnknownTypeError,
22};
23use crate::parse::ast::{self, Expr, Literal, NonesOrder, ShapeElement, ShapeOp, SortDirection, Stmt};
24use crate::schema::{
25    FunctionDescriptor, LinkDescriptor, MultiLinkDescriptor, PropertyDescriptor, SchemaDescriptor, SearchBackend,
26    TypeDescriptor,
27};
28
29use std::collections::{HashMap, HashSet};
30
31use super::{
32    IrArraySource, IrAssertedPointer, IrBinOp, IrComputedGlobalCte, IrComputedPointer, IrConflict, IrCteDef, IrDelete,
33    IrExpr, IrFor, IrForIterator, IrFreeExpr, IrFtsSearch, IrFunctionCall, IrFunctionSelect, IrGlobalCte, IrGroup,
34    IrGroupOutput, IrGroupProjection, IrIfElse, IrInsert, IrLinkProp, IrLiteral, IrLockClause, IrLockStrength,
35    IrLockWait, IrMultiLinkClear, IrMultiLinkJoin, IrMultiLinkMutation, IrMultiLinkPointer, IrMultiLinkValueSource,
36    IrMultiLinkValues, IrNulls, IrOutput, IrPathJoin, IrPathResult, IrPathSelect, IrPolyFanout, IrPolyImplementor,
37    IrRowSource, IrScalarPointer, IrScalarSetPointer, IrSelect, IrSessionGlobalCte, IrShapePointer,
38    IrSingleLinkCorrelation, IrSingleLinkPointer, IrSort, IrSortDir, IrSource, IrStmt, IrTypeCast, IrUnaryOp, IrUpdate,
39    IrVectorSearch, SearchEnqueueInfo, TupleCastShape, VectorEnqueueInfo,
40};
41
42// ── Compiled-clause tuples ──────────────────────────────────────────────────────
43//
44// The three helpers that pull a statement's trailing clauses apart all return
45// the same shape: the pieces in source order, each optional because a query
46// need not spell any of them out. Named here so the signatures read as one
47// concept rather than as an anonymous 4-tuple repeated three times.
48
49/// `(filter, order_by, offset, limit)` — a plain `select`'s trailing clauses.
50type SelectModifiers = (Option<IrExpr>, Vec<IrSort>, Option<IrExpr>, Option<IrExpr>);
51
52/// `(filter, distance/rank direction, offset, limit)` — the trailing clauses of
53/// a `vector::search`/`fts::search` select, where `order by` is constrained to
54/// the search score rather than an arbitrary sort list.
55type SearchModifiers = (Option<IrExpr>, Option<IrSortDir>, Option<IrExpr>, Option<IrExpr>);
56
57/// `(type name, shape elements, sub-statement, link-property alias)` — the
58/// parts of an expression that names a type and optionally shapes it.
59type TypeAndShape<'e> = (String, &'e [ShapeElement], Option<&'e Stmt>, Option<String>);
60
61// ── Public entry point ──────────────────────────────────────────────────────────
62
63/// Compile a parsed PyQL statement against the schema, using default session
64/// config (see `crate::ir::SessionConfig`) — used throughout this crate's own
65/// tests and schema-time compilation, which never has a live client-supplied
66/// config to honor. `compile_with_config` is the real entry a live query
67/// request uses.
68/// Returns the IR plan and the ordered list of parameter names (matching $1, $2, …).
69pub fn compile(stmt: &Stmt, schema: &SchemaDescriptor) -> Result<IrOutput, PyQLError> {
70    compile_with_config(stmt, schema, &crate::ir::SessionConfig::default())
71}
72
73/// Like `compile`, but honors a caller-supplied `SessionConfig` (e.g.
74/// `allow_user_specified_id`) for this one statement.
75pub fn compile_with_config(
76    stmt: &Stmt,
77    schema: &SchemaDescriptor,
78    config: &crate::ir::SessionConfig,
79) -> Result<IrOutput, PyQLError> {
80    let mut c = Compiler::with_config(schema, config.clone());
81
82    // Unwrap top-level WITH block: compile each CTE binding, then the main statement.
83    let (ctes, ir) = if let Stmt::With(w) = stmt {
84        // Special case: `with search := vector::search(…); select search { … } …`
85        // Merge into a single IrVectorSearch rather than going through the CTE machinery.
86        if let Some(ir) = try_compile_vs_with_pattern(&mut c, w)? {
87            return Ok(IrOutput {
88                subtype_fanouts: c.subtype_fanouts(),
89                stmt: ir,
90                params: c.params,
91                param_tuple_types: c.param_tuple_types,
92                ctes: vec![],
93                global_ctes: c.global_ctes,
94                warnings: c.warnings,
95                uses_globals_arg: c.used_globals_arg,
96            });
97        }
98        // Special case: `with search := fts::search(…); select search { … } …`
99        if let Some(ir) = try_compile_fts_with_pattern(&mut c, w)? {
100            return Ok(IrOutput {
101                subtype_fanouts: c.subtype_fanouts(),
102                stmt: ir,
103                params: c.params,
104                param_tuple_types: c.param_tuple_types,
105                ctes: vec![],
106                global_ctes: c.global_ctes,
107                warnings: c.warnings,
108                uses_globals_arg: c.used_globals_arg,
109            });
110        }
111
112        let mut cte_defs = vec![];
113        for alias in &w.aliases {
114            if let Some(declared) = declared_pointers_of(&alias.expr) {
115                c.cte_declared_pointers.insert(alias.name.clone(), declared);
116            }
117            if c.bind_group(&alias.name, &alias.expr)? {
118                continue;
119            }
120            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
121            let sql_name = c.claim_cte_sql_name(&alias.name);
122            let type_name = c.register_cte(&alias.name, &ir_stmt);
123            // What the binding hoisted (`p := existing ?? (insert …)`) goes
124            // right before it: a CTE only sees the ones ahead of it.
125            cte_defs.append(&mut c.hoisted_ctes);
126            cte_defs.push(IrCteDef {
127                name: sql_name,
128                stmt: ir_stmt,
129                type_name,
130                correlated_to: None,
131            });
132        }
133        let main = c.compile_stmt(&w.stmt)?;
134        (cte_defs, main)
135    } else {
136        (vec![], c.compile_stmt(stmt)?)
137    };
138
139    let mut ctes = ctes;
140    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
141    Ok(IrOutput {
142        subtype_fanouts: c.subtype_fanouts(),
143        stmt: ir,
144        params: c.params,
145        param_tuple_types: c.param_tuple_types,
146        ctes,
147        global_ctes: c.global_ctes,
148        warnings: c.warnings,
149        uses_globals_arg: c.used_globals_arg,
150    })
151}
152
153/// Detect `with <var> := vector::search(Type, $vec); select <var> { object { … }, distance }`.
154/// When matched, compile the whole thing to a single `IrVectorSearch`.
155fn try_compile_vs_with_pattern(c: &mut Compiler<'_>, w: &ast::WithStmt) -> Result<Option<IrStmt>, PyQLError> {
156    // Only handle exactly one alias that is a bare function call (not a subquery).
157    if w.aliases.len() != 1 {
158        return Ok(None);
159    }
160    let alias_def = &w.aliases[0];
161    let fc = match &alias_def.expr {
162        Expr::FunctionCall(fc) => fc,
163        _ => return Ok(None),
164    };
165    if fc.module.as_deref() != Some("vector") || fc.name != "search" {
166        return Ok(None);
167    }
168
169    // Main statement must be `select <alias_name> { … }`.
170    let select_stmt = match w.stmt.as_ref() {
171        Stmt::Select(s) => s,
172        _ => return Ok(None),
173    };
174    // Unwrap optional Shape wrapper around the result expression.
175    let (elements, result_inner): (&[ast::ShapeElement], &Expr) = match &select_stmt.result {
176        Expr::Shape(sh) => {
177            let inner = sh.expr.as_ref().unwrap_or(&select_stmt.result);
178            (sh.elements.as_slice(), inner)
179        }
180        other => (&[], other),
181    };
182    // The inner expression should be a path referencing the WITH alias.
183    match result_inner {
184        Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
185            if let ast::PathStep::Name(n) = &p.steps[0] {
186                if n != &alias_def.name {
187                    return Ok(None);
188                }
189            } else {
190                return Ok(None);
191            }
192        }
193        _ => return Ok(None),
194    }
195
196    if let Some(ir) = c.try_compile_vector_search(fc, elements, select_stmt)? {
197        Ok(Some(IrStmt::VectorSearch(ir)))
198    } else {
199        Ok(None)
200    }
201}
202
203fn try_compile_fts_with_pattern(c: &mut Compiler<'_>, w: &ast::WithStmt) -> Result<Option<IrStmt>, PyQLError> {
204    if w.aliases.len() != 1 {
205        return Ok(None);
206    }
207    let alias_def = &w.aliases[0];
208    let fc = match &alias_def.expr {
209        Expr::FunctionCall(fc) => fc,
210        _ => return Ok(None),
211    };
212    if fc.module.as_deref() != Some("fts") || fc.name != "search" {
213        return Ok(None);
214    }
215
216    let select_stmt = match w.stmt.as_ref() {
217        Stmt::Select(s) => s,
218        _ => return Ok(None),
219    };
220    let (elements, result_inner): (&[ast::ShapeElement], &Expr) = match &select_stmt.result {
221        Expr::Shape(sh) => {
222            let inner = sh.expr.as_ref().unwrap_or(&select_stmt.result);
223            (sh.elements.as_slice(), inner)
224        }
225        other => (&[], other),
226    };
227    match result_inner {
228        Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
229            if let ast::PathStep::Name(n) = &p.steps[0] {
230                if n != &alias_def.name {
231                    return Ok(None);
232                }
233            } else {
234                return Ok(None);
235            }
236        }
237        _ => return Ok(None),
238    }
239
240    if let Some(ir) = c.try_compile_fts_search(fc, elements, select_stmt)? {
241        Ok(Some(IrStmt::FtsSearch(ir)))
242    } else {
243        Ok(None)
244    }
245}
246
247/// How many times a path may expand a computed pointer into its own path
248/// before we call it a cycle. Chains are normal (a computed over a computed);
249/// a chain this long is a schema that refers to itself.
250/// AND a path traversal's own FILTER together with whatever conditions the
251/// computed pointers spliced into it contributed. Every join in a path
252/// select is inner, so a spliced computed's filter means the same thing as a
253/// WHERE condition on the whole traversal.
254/// `__subject__.p ?? (insert T { … })` — a rewrite that fills `p` with a new
255/// object when the statement gives it none. The insert has no place in an
256/// expression, so on insert it becomes `p := (insert …)` instead; `None`
257/// for any other rewrite.
258fn subject_default_insert(handler: &str, pointer: &str) -> Option<Expr> {
259    fn holds_insert(expr: &Expr) -> bool {
260        match expr {
261            Expr::SubQuery(stmt) => match stmt.as_ref() {
262                Stmt::Insert(_) => true,
263                Stmt::Select(sel) => holds_insert(&sel.result),
264                _ => false,
265            },
266            _ => false,
267        }
268    }
269    let Ok(Expr::BinOp(b)) = crate::parse::parse_pointer_expr(handler) else {
270        return None;
271    };
272    let reads_itself = matches!(&b.left, Expr::Path(p) if matches!(p.steps.as_slice(),
273        [ast::PathStep::Name(subject), ast::PathStep::Name(name)] if subject == "__subject__" && name == pointer));
274    (matches!(b.op, ast::BinOpKind::Coalesce) && reads_itself && holds_insert(&b.right)).then_some(b.right)
275}
276
277/// A sub-select's own `limit 1`: what makes it one object rather than a set.
278fn limits_to_one(sel: Option<&ast::SelectStmt>) -> bool {
279    sel.is_some_and(|sel| matches!(sel.limit, Some(Expr::Literal(ast::Literal::Int(1)))))
280}
281
282/// True when a select can yield at most one row: `limit 1`, or a filter that
283/// pins an exclusive property (`.id = …`) in one of its `and`-ed conditions.
284fn selects_at_most_one(sel: &ast::SelectStmt, td: &TypeDescriptor) -> bool {
285    fn pins_exclusive(expr: &Expr, td: &TypeDescriptor) -> bool {
286        let Expr::BinOp(b) = expr else { return false };
287        match b.op {
288            ast::BinOpKind::And => pins_exclusive(&b.left, td) || pins_exclusive(&b.right, td),
289            ast::BinOpKind::Eq => [&b.left, &b.right].into_iter().any(|side| {
290                matches!(side, Expr::Path(p) if p.partial
291                    && matches!(p.steps.as_slice(), [ast::PathStep::Name(name)]
292                        if td.properties.iter().any(|d| &d.name == name && (d.is_exclusive || d.is_pk))))
293            }),
294            _ => false,
295        }
296    }
297    matches!(sel.limit, Some(Expr::Literal(ast::Literal::Int(1))))
298        || sel.filter.as_ref().is_some_and(|f| pins_exclusive(f, td))
299}
300
301/// `(select .teams … limit 1) if cond else {}` as `(select .teams filter cond …
302/// limit 1)`, carrying along a shape written on the branch or on the whole
303/// if/else. `None` unless exactly one branch is empty and the other is a
304/// relative walk or a select over one, and `cond` reads no relative path --
305/// moved into the branch's filter, one would read the branch's rows instead.
306fn guarded_object_branch(expr: &Expr) -> Option<Expr> {
307    fn reads_relative(expr: &Expr) -> bool {
308        match expr {
309            Expr::Path(p) => p.partial,
310            Expr::Literal(_) | Expr::Parameter(_) | Expr::Global(_) => false,
311            Expr::BinOp(b) => reads_relative(&b.left) || reads_relative(&b.right),
312            Expr::UnaryOp(u) => reads_relative(&u.operand),
313            Expr::TypeCast(c) => reads_relative(&c.expr),
314            Expr::FunctionCall(f) => f.args.iter().chain(f.kwargs.iter().map(|(_, v)| v)).any(reads_relative),
315            Expr::IfElse(ie) => {
316                reads_relative(&ie.if_expr) || reads_relative(&ie.condition) || reads_relative(&ie.else_expr)
317            }
318            _ => true,
319        }
320    }
321    fn empty(expr: &Expr) -> bool {
322        match expr {
323            Expr::Set(items) => items.is_empty(),
324            Expr::TypeCast(c) => matches!(&c.expr, Expr::Set(items) if items.is_empty()),
325            _ => false,
326        }
327    }
328    let (ie, outer_shape) = match expr {
329        Expr::IfElse(ie) => (ie.as_ref(), None),
330        Expr::Shape(sh) => match sh.expr.as_ref()? {
331            Expr::IfElse(ie) => (ie.as_ref(), Some(sh.elements.clone())),
332            _ => return None,
333        },
334        _ => return None,
335    };
336    if reads_relative(&ie.condition) {
337        return None;
338    }
339    let (branch, condition) = match (empty(&ie.if_expr), empty(&ie.else_expr)) {
340        (false, true) => (&ie.if_expr, ie.condition.clone()),
341        (true, false) => (
342            &ie.else_expr,
343            Expr::UnaryOp(Box::new(ast::UnaryOp {
344                op: ast::UnaryOpKind::Not,
345                operand: ie.condition.clone(),
346            })),
347        ),
348        _ => return None,
349    };
350    let (branch, branch_shape) = match branch {
351        Expr::Shape(sh) => (sh.expr.as_ref()?, Some(sh.elements.clone())),
352        other => (other, None),
353    };
354    let mut select = match branch {
355        Expr::Path(p) if p.partial => ast::SelectStmt {
356            result: branch.clone(),
357            filter: None,
358            order_by: vec![],
359            offset: None,
360            limit: None,
361            lock: None,
362        },
363        Expr::SubQuery(stmt) => match stmt.as_ref() {
364            Stmt::Select(sel) if matches!(&sel.result, Expr::Path(p) if p.partial) => sel.clone(),
365            _ => return None,
366        },
367        _ => return None,
368    };
369    select.filter = Some(match select.filter.take() {
370        Some(filter) => Expr::BinOp(Box::new(ast::BinOp {
371            left: filter,
372            op: ast::BinOpKind::And,
373            right: condition,
374        })),
375        None => condition,
376    });
377    let guarded = Expr::SubQuery(Box::new(Stmt::Select(select)));
378    Some(match outer_shape.or(branch_shape) {
379        Some(elements) => Expr::Shape(Box::new(ast::ShapeExpr {
380            expr: Some(guarded),
381            elements,
382            marker_offset: None,
383        })),
384        None => guarded,
385    })
386}
387
388/// `.grants.account = a and not .grants.deleted` as the multi-link `grants`
389/// and the condition on one of its elements (`.account = a and not
390/// .deleted`, a bare `.grants` becoming the element's `.id`). `None` unless
391/// every relative path in the condition walks that same multi-link — one
392/// reading the outer row would rebind to the element inside the sub-select —
393/// and the condition holds no sub-statement.
394fn per_element_of_one_multilink(condition: &Expr, td: &TypeDescriptor) -> Option<(Vec<ast::PathStep>, Expr)> {
395    // The steps that reach the elements: a link name, or a backlink with the
396    // type intersection that narrows it (`.<brand[is Assignment]`).
397    fn prefix_len(steps: &[ast::PathStep]) -> Option<usize> {
398        match steps {
399            [ast::PathStep::Name(_), ..] => Some(1),
400            [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_), ..] => Some(2),
401            _ => None,
402        }
403    }
404    fn rewrite(expr: &Expr, link: &mut Option<Vec<ast::PathStep>>) -> Option<Expr> {
405        Some(match expr {
406            Expr::Path(p) if p.partial => {
407                let split = prefix_len(&p.steps)?;
408                let prefix = p.steps[..split].to_vec();
409                if *link.get_or_insert_with(|| prefix.clone()) != prefix {
410                    return None;
411                }
412                // A condition on the link itself (`.posts = p`, no tail) has
413                // no element to rebind; `all` over one takes the per-element
414                // route in `try_elementwise_in` instead.
415                let rest = p.steps[split..].to_vec();
416                if rest.is_empty() {
417                    return None;
418                }
419                Expr::Path(ast::Path {
420                    steps: rest,
421                    partial: true,
422                })
423            }
424            Expr::Path(_) | Expr::Literal(_) | Expr::Parameter(_) | Expr::Global(_) => expr.clone(),
425            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
426                left: rewrite(&b.left, link)?,
427                op: b.op.clone(),
428                // A set literal on the right of `in` is the haystack the test
429                // reads, not a second set the quantifier ranges over, so it
430                // carries through whole. Rewritten as an ordinary operand it
431                // would be refused below, which is what made `all(.posts.title
432                // in {'a', 'b'})` fall back to the `any` reading.
433                right: match (&b.op, &b.right) {
434                    (ast::BinOpKind::In | ast::BinOpKind::NotIn, set @ Expr::Set(_)) => set.clone(),
435                    (_, other) => rewrite(other, link)?,
436                },
437            })),
438            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
439                op: u.op.clone(),
440                operand: rewrite(&u.operand, link)?,
441            })),
442            Expr::TypeCast(c) => Expr::TypeCast(Box::new(ast::TypeCast {
443                expr: rewrite(&c.expr, link)?,
444                ty: c.ty.clone(),
445            })),
446            Expr::TypeIs { expr, ty } => Expr::TypeIs {
447                expr: Box::new(rewrite(expr, link)?),
448                ty: ty.clone(),
449            },
450            // A set-returning call (`array_unpack`) is a second set the
451            // quantifier ranges over, so turning the condition into one asked
452            // per link element would change what it asks.
453            Expr::FunctionCall(f)
454                if crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
455                    .iter()
456                    .any(|d| d.returns_set()) =>
457            {
458                return None;
459            }
460            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
461                module: f.module.clone(),
462                name: f.name.clone(),
463                args: f.args.iter().map(|a| rewrite(a, link)).collect::<Option<_>>()?,
464                kwargs: f
465                    .kwargs
466                    .iter()
467                    .map(|(k, v)| Some((k.clone(), rewrite(v, link)?)))
468                    .collect::<Option<_>>()?,
469            }),
470            _ => return None,
471        })
472    }
473    let mut link = None;
474    let element_condition = rewrite(condition, &mut link)?;
475    let link = link?;
476    let many = match link.first()? {
477        ast::PathStep::Name(name) => td.multilinks.iter().any(|m| &m.name == name),
478        ast::PathStep::Backlink(_) => true,
479        _ => false,
480    };
481    many.then_some((link, element_condition))
482}
483
484/// A shape's subject written in a longer form than it needs, rewritten to the
485/// form the select routes already take; `None` when there is nothing to
486/// rewrite.
487///
488/// - `(select E) { … }` with no clauses of its own is `E { … }`, for the
489///   subjects only a select over them spells: an if/else, a union, a shape.
490/// - `X { a := … } { a: { … } }` is one shape. The outer one says what comes
491///   back; a name it lists that the inner one computed keeps that computation
492///   and takes the outer one's nested shape and modifiers.
493fn flatten_shape_subject(expr: &Expr) -> Option<Expr> {
494    let Expr::Shape(outer) = expr else { return None };
495    match outer.expr.as_ref()? {
496        Expr::SubQuery(stmt) => {
497            let Stmt::Select(sel) = stmt.as_ref() else { return None };
498            let bare = sel.filter.is_none()
499                && sel.order_by.is_empty()
500                && sel.offset.is_none()
501                && sel.limit.is_none()
502                && sel.lock.is_none();
503            if !bare || !matches!(sel.result, Expr::IfElse(_) | Expr::Union(_, _) | Expr::Shape(_)) {
504                return None;
505            }
506            let mut flat = outer.as_ref().clone();
507            flat.expr = Some(sel.result.clone());
508            Some(Expr::Shape(Box::new(flat)))
509        }
510        Expr::Shape(inner) => {
511            let elements = outer
512                .elements
513                .iter()
514                .map(|el| {
515                    let declared = match (&el.compexpr, el.path.steps.as_slice()) {
516                        (None, [ast::PathStep::Name(name)]) => inner.elements.iter().find(|d| {
517                            d.compexpr.is_some()
518                                && matches!(d.path.steps.as_slice(), [ast::PathStep::Name(n)] if n == name)
519                        }),
520                        _ => None,
521                    };
522                    match declared {
523                        Some(d) => ShapeElement {
524                            nested: el.nested.clone().or_else(|| d.nested.clone()),
525                            filter: el.filter.clone().or_else(|| d.filter.clone()),
526                            order_by: if el.order_by.is_empty() {
527                                d.order_by.clone()
528                            } else {
529                                el.order_by.clone()
530                            },
531                            offset: el.offset.clone().or_else(|| d.offset.clone()),
532                            limit: el.limit.clone().or_else(|| d.limit.clone()),
533                            ..d.clone()
534                        },
535                        None => el.clone(),
536                    }
537                })
538                .collect();
539            Some(Expr::Shape(Box::new(ast::ShapeExpr {
540                expr: inner.expr.clone(),
541                elements,
542                marker_offset: inner.marker_offset,
543            })))
544        }
545        _ => None,
546    }
547}
548
549/// The name `.key.<name>` of a group is read through while its shape
550/// compiles — not one a query can spell, so it shadows nothing.
551fn group_key_binding(name: &str) -> String {
552    format!("<group key {name}>")
553}
554
555fn and_conditions(filter: Option<IrExpr>, extra: Vec<IrExpr>) -> Option<IrExpr> {
556    extra.into_iter().fold(filter, |acc, cond| match acc {
557        Some(existing) => Some(IrExpr::BinOp(Box::new(IrBinOp {
558            left: existing,
559            op: ast::BinOpKind::And,
560            right: cond,
561        }))),
562        None => Some(cond),
563    })
564}
565
566const MAX_COMPUTED_SPLICES: usize = 32;
567
568/// `sum`, `any`, `all` and `array_agg` over no rows: SQL gives NULL, PyQL the
569/// empty set's own value. `'{}'` rather than `ARRAY[]`, whose element type
570/// PostgreSQL cannot infer on its own — inside a `coalesce` it takes the one
571/// the aggregate already carries.
572fn aggregate_over_nothing_sql(name: &str) -> Option<&'static str> {
573    match name {
574        "sum" => Some("0"),
575        "any" => Some("false"),
576        "all" => Some("true"),
577        "array_agg" => Some("'{}'"),
578        _ => None,
579    }
580}
581
582fn aggregate_over_nothing(name: &str, aggregate: IrExpr) -> IrExpr {
583    let Some(value) = aggregate_over_nothing_sql(name) else {
584        return aggregate;
585    };
586    IrExpr::FunctionCall(IrFunctionCall {
587        return_pg_type: None,
588        schema: None,
589        name: "coalesce".to_string(),
590        args: vec![aggregate, IrExpr::RawSql(value.to_string())],
591        sql_template: None,
592    })
593}
594
595/// An expression whose value is a whole set, gathered as an array.
596/// A set-valued walk gathered as an array, read back as a scalar subquery
597/// instead — what an ordering comparison against a single value needs.
598///
599/// `(.installation.<installation[is RateLimitState].backoff_until < now) ?? true`
600/// is conduit's backoff gate, and it reads element-wise: empty set →
601/// `{}` → the `??` supplies `true`. Rendered as an array the comparison is
602/// `timestamptz[] < timestamptz`, which PostgreSQL has no operator for, so the
603/// statement failed outright. As a scalar subquery the empty case is NULL, the
604/// comparison is NULL, and `COALESCE` supplies the default — right for
605/// the nought-or-one sets these walks actually produce (an `exclusive`
606/// constraint guarantees it here). A genuinely many-valued walk now raises
607/// PostgreSQL's own "more than one row returned by a subquery" rather than
608/// comparing element-wise; that is a narrower gap than emitting SQL that
609/// cannot run at all.
610fn set_walk_as_scalar(expr: IrExpr) -> IrExpr {
611    match expr {
612        IrExpr::ArrayFromSelect(source) => match *source {
613            IrArraySource::PathSelect(ps) => IrExpr::PathSubquery(ps),
614            other => IrExpr::ArrayFromSelect(Box::new(other)),
615        },
616        other => other,
617    }
618}
619
620fn yields_array(expr: &IrExpr) -> bool {
621    match expr {
622        IrExpr::ArrayFromSelect(_) => true,
623        IrExpr::IfElse(ie) => {
624            (yields_array(&ie.if_) || matches!(ie.if_, IrExpr::Null))
625                && (yields_array(&ie.else_) || matches!(ie.else_, IrExpr::Null))
626                && !(matches!(ie.if_, IrExpr::Null) && matches!(ie.else_, IrExpr::Null))
627        }
628        _ => false,
629    }
630}
631
632/// Look through the `coalesce` an aggregate over no rows is wrapped in — see
633/// `aggregate_over_nothing`. The default only stands in for the aggregate, so
634/// the type is the aggregate's.
635fn through_coalesce(expr: &IrExpr) -> &IrExpr {
636    match expr {
637        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "coalesce" => {
638            f.args.first().map(through_coalesce).unwrap_or(expr)
639        }
640        other => other,
641    }
642}
643
644fn ir_value_type_name(expr: &IrExpr) -> String {
645    ir_value_type(expr).unwrap_or_default()
646}
647
648/// The pg type a bound value carries, including the array types
649/// `infer_ir_type` has no spelling for — a `with` binding that loses its
650/// array-ness resolves no overload at all, so `contains(ids, .id)` reports a
651/// call nobody wrote.
652fn ir_value_type(expr: &IrExpr) -> Option<String> {
653    let expr = through_coalesce(expr);
654    match expr {
655        IrExpr::Array(elements) => {
656            let element = elements.first().and_then(infer_ir_type).unwrap_or("text");
657            Some(format!("{}[]", literal_sentinel_to_pg(element)))
658        }
659        // `ids := array_agg(r.id)` binds an array of what it aggregates, which
660        // is what lets `contains(ids, .id)` pick the array overload over `str`'s.
661        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "array_agg" => {
662            let element = f.args.first().and_then(infer_ir_type)?;
663            Some(format!("{}[]", literal_sentinel_to_pg(element)))
664        }
665        // `array_agg((select …))` aggregates inside a correlated select, so
666        // the array is what that select projects rather than the call itself.
667        IrExpr::PathSubquery(ps) => match &ps.result {
668            IrPathResult::Scalar(inner, _) => ir_value_type(inner),
669            IrPathResult::Object { .. } => None,
670        },
671        IrExpr::ArrayFromSelect(source) => array_source_element_type(source).map(|element| format!("{element}[]")),
672        // `xs if cond else ys` is typed by whichever branch can say — the
673        // other is routinely the empty set.
674        IrExpr::IfElse(ie) => ir_value_type(&ie.if_).or_else(|| ir_value_type(&ie.else_)),
675        other => infer_ir_type(other).map(|t| t.to_string()),
676    }
677}
678
679/// The pg type of one row of an `ARRAY(SELECT …)`. `None` for a source that
680/// aggregates whole objects: those rows are composites with no scalar name.
681fn array_source_element_type(source: &IrArraySource) -> Option<String> {
682    match source {
683        IrArraySource::Select(sel) => match sel.rows.as_slice() {
684            [IrRowSource::Bound { shape, .. }] => match shape.first() {
685                // Mirrors the emitter: the first scalar pointer is the
686                // element, and a shape with none of them projects the id.
687                Some(IrShapePointer::Scalar(scalar)) => Some(scalar.pg_type.clone()),
688                _ => Some("uuid".to_string()),
689            },
690            _ => None,
691        },
692        IrArraySource::PathSelect(ps) => match &ps.result {
693            IrPathResult::Scalar(expr, _) => infer_ir_type(expr).map(|t| literal_sentinel_to_pg(t).to_string()),
694            IrPathResult::Object { .. } => None,
695        },
696        _ => None,
697    }
698}
699
700/// The computed pointers a `with` binding's own shape declares, if any.
701fn declared_pointers_of(expr: &Expr) -> Option<Vec<ShapeElement>> {
702    // `(select T { x := … }) if cond else {}` declares what its select does.
703    if let Expr::IfElse(ie) = expr {
704        return declared_pointers_of(&ie.if_expr).or_else(|| declared_pointers_of(&ie.else_expr));
705    }
706    let Expr::SubQuery(stmt) = expr else {
707        return None;
708    };
709    let shape = match innermost_select(stmt)? {
710        ast::SelectStmt {
711            result: Expr::Shape(sh),
712            ..
713        } => sh,
714        _ => return None,
715    };
716    let declared: Vec<ShapeElement> = shape
717        .elements
718        .iter()
719        .filter(|el| el.compexpr.is_some())
720        .cloned()
721        .collect();
722    (!declared.is_empty()).then_some(declared)
723}
724
725/// The select a statement ultimately is, past any `with` blocks.
726fn innermost_select(stmt: &Stmt) -> Option<&ast::SelectStmt> {
727    match stmt {
728        Stmt::Select(sel) => Some(sel),
729        Stmt::With(w) => innermost_select(&w.stmt),
730        _ => None,
731    }
732}
733
734fn cte_stmt_type(stmt: &IrStmt) -> String {
735    match stmt {
736        IrStmt::Insert(ins) => ins.target.type_name.clone(),
737        IrStmt::Update(upd) => upd.target.type_name.clone(),
738        IrStmt::Delete(del) => del.target.type_name.clone(),
739        IrStmt::Select(sel) => match sel.rows.first() {
740            Some(IrRowSource::Bound { source, .. }) => source.type_name.clone(),
741            // Infer the scalar pg_type from the first free item so the type
742            // is available for UNION mismatch error messages. Returns empty
743            // string if unknown.
744            Some(IrRowSource::Free(IrFreeExpr::Scalar(expr))) => ir_value_type_name(expr),
745            _ => String::new(),
746        },
747        // What a path select *yields*, not what it starts from: `with xs :=
748        // (select Person.name)` binds text, not `default::Person`, and the
749        // difference decides whether a reference to it reads the CTE's
750        // `result` column or its `id` (see `resolve_name_ref`).
751        IrStmt::PathSelect(ps) => match &ps.result {
752            IrPathResult::Scalar(expr, _) => ir_value_type_name(expr),
753            IrPathResult::Object { type_name, .. } => type_name.clone(),
754        },
755        IrStmt::For(f) => cte_stmt_type(&f.body),
756        IrStmt::ScalarUnion(branches) => branches.first().map(cte_stmt_type).unwrap_or_default(),
757        IrStmt::Group(g) => g.source.type_name.clone(),
758        IrStmt::FunctionSelect(fs) => fs.type_name.clone(),
759        IrStmt::VectorSearch(vs) => format!("__vs__{}", vs.source.type_name),
760        IrStmt::FtsSearch(fs) => format!("__fts__{}", fs.source.type_name),
761    }
762}
763
764/// Compile a WITH binding value: a subquery becomes its statement; any other
765/// expression is wrapped in a synthetic `select expr` so it can be used as a CTE.
766fn compile_cte_binding(c: &mut Compiler<'_>, expr: &Expr) -> Result<IrStmt, PyQLError> {
767    let stmt = compile_cte_binding_stmt(c, expr)?;
768    Ok(hoist_binding_dml(c, stmt))
769}
770
771/// `g := (insert T { … }) if cond else {}` — the binding's value compiles to a
772/// select *over* a mutation. A data-modifying statement may only sit at the top
773/// level of a `WITH`, never inside another CTE's body, so the mutation becomes a
774/// CTE of its own ahead of this binding — where `g := existing ?? (insert T { …
775/// })` already puts one — and the binding reads its rows back from there.
776/// Emitted as-is, the mutation was dropped and the binding read the whole table.
777fn hoist_binding_dml(c: &mut Compiler<'_>, stmt: IrStmt) -> IrStmt {
778    let IrStmt::Select(mut select) = stmt else {
779        return stmt;
780    };
781    let Some(dml) = select
782        .dml_source
783        .take_if(|dml| matches!(dml.as_ref(), IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::Delete(_)))
784    else {
785        return IrStmt::Select(select);
786    };
787    let cte_name = c.fresh_nested_cte_name();
788    for row in &mut select.rows {
789        if let IrRowSource::Bound { source, .. } = row {
790            source.table = format!("@cte:{cte_name}");
791            // The CTE's rows are already the concrete ones the mutation
792            // touched, each carrying its own `__type__`.
793            source.poly = None;
794        }
795    }
796    c.hoisted_ctes.push(IrCteDef {
797        name: cte_name,
798        type_name: cte_stmt_type(&dml),
799        stmt: *dml,
800        correlated_to: None,
801    });
802    IrStmt::Select(select)
803}
804
805fn compile_cte_binding_stmt(c: &mut Compiler<'_>, expr: &Expr) -> Result<IrStmt, PyQLError> {
806    if let Expr::SubQuery(s) = expr {
807        let stmt = c.compile_stmt(s)?;
808        if let IrStmt::Group(grp) = &stmt
809            && !matches!(grp.output, IrGroupOutput::Elements)
810        {
811            return Err(c.type_err("a `group` cannot be bound in a `with` except to iterate it with `for`"));
812        }
813        return Ok(stmt);
814    }
815    // `existing ?? (insert Tag { … })` — on sets `A ?? B` is `A if exists A
816    // else B`, the upsert idiom the if/else route already guards the
817    // mutation for.
818    let expr = match expr {
819        Expr::BinOp(b)
820            if b.op == ast::BinOpKind::Coalesce
821                && matches!(&b.right, Expr::SubQuery(s) if matches!(s.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))) =>
822        {
823            &Expr::IfElse(Box::new(ast::IfElse {
824                if_expr: b.left.clone(),
825                condition: Expr::UnaryOp(Box::new(ast::UnaryOp {
826                    op: ast::UnaryOpKind::Exists,
827                    operand: b.left.clone(),
828                })),
829                else_expr: b.right.clone(),
830            }))
831        }
832        other => other,
833    };
834    // Non-statement expression (e.g. `<default::Company><uuid>'...'`):
835    // treat as `select expr`.
836    let fake_sel = ast::SelectStmt {
837        result: expr.clone(),
838        filter: None,
839        order_by: vec![],
840        offset: None,
841        limit: None,
842        lock: None,
843    };
844    c.compile_stmt(&Stmt::Select(fake_sel))
845}
846
847/// Qualified names of the functions that take `GLOBALS_ARG`.
848///
849/// A function needs it when its body reads a session global, *or* when it calls
850/// a function that needs it — so this is a fixpoint, not a single pass: the
851/// first pass finds the direct readers, later passes find their callers. A body
852/// that fails to compile is skipped; that failure is reported by validation,
853/// and guessing at its globals here would only produce a second, worse error.
854pub fn functions_needing_globals(schema: &SchemaDescriptor) -> &std::collections::HashSet<String> {
855    schema
856        .functions_needing_globals
857        .get_or_init(|| find_functions_needing_globals(schema))
858}
859
860fn find_functions_needing_globals(schema: &SchemaDescriptor) -> std::collections::HashSet<String> {
861    let mut needs: std::collections::HashSet<String> = std::collections::HashSet::new();
862    loop {
863        let mut changed = false;
864        for fd in &schema.functions {
865            let qualified = format!("{}::{}", fd.module, fd.name);
866            if needs.contains(&qualified) {
867                continue;
868            }
869            if let Ok(out) = compile_fn_body_with(fd, schema, &needs)
870                && out.uses_globals_arg
871            {
872                needs.insert(qualified);
873                changed = true;
874            }
875        }
876        if !changed {
877            return needs;
878        }
879    }
880}
881
882/// Compile the PyQL body of a user-defined function for DDL emission.
883///
884/// Sets `fn_params` on the compiler so that parameter names resolve as `FnParam`
885/// nodes rather than raising "expression is not valid in free SELECT context".
886pub fn compile_fn_body(
887    fn_desc: &crate::schema::FunctionDescriptor,
888    schema: &SchemaDescriptor,
889) -> Result<super::IrOutput, crate::error::PyQLError> {
890    compile_fn_body_with(fn_desc, schema, functions_needing_globals(schema))
891}
892
893/// `compile_fn_body` with the globals-argument set supplied, so the fixpoint in
894/// `functions_needing_globals` can call this without recursing into itself.
895pub fn compile_fn_body_with(
896    fn_desc: &crate::schema::FunctionDescriptor,
897    schema: &SchemaDescriptor,
898    fns_needing_globals: &std::collections::HashSet<String>,
899) -> Result<super::IrOutput, crate::error::PyQLError> {
900    use crate::parse;
901    use crate::parse::ast::Stmt;
902
903    let body = fn_desc.body.trim().to_string();
904    // A body that is already a statement stands on its own; only a bare
905    // expression needs the `select` that turns it into one. Wrapping a
906    // statement instead would bury it as a sub-select and lose its clauses.
907    let starts_a_stmt = ["select", "with", "for", "group", "insert", "update", "delete"]
908        .iter()
909        .any(|keyword| {
910            body.len() > keyword.len()
911                && body[..keyword.len()].eq_ignore_ascii_case(keyword)
912                && !body.as_bytes()[keyword.len()].is_ascii_alphanumeric()
913                && body.as_bytes()[keyword.len()] != b'_'
914        });
915    let body = if starts_a_stmt {
916        body
917    } else {
918        format!("select {}", body)
919    };
920
921    let ast = parse::parse(&body)?;
922    let mut c = Compiler::new(schema);
923    c.in_fn_body = true;
924    c.fns_needing_globals = Some(fns_needing_globals.clone());
925    for p in &fn_desc.params {
926        c.fn_params.insert(p.name.clone(), p.pg_type.clone());
927    }
928
929    let (ctes, ir) = if let Stmt::With(w) = &ast {
930        let mut cte_defs = vec![];
931        for alias in &w.aliases {
932            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
933            let type_name = cte_stmt_type(&ir_stmt);
934            c.cte_types.insert(alias.name.clone(), type_name.clone());
935            c.note_cte_cardinality(&alias.name, &ir_stmt);
936            cte_defs.push(super::IrCteDef {
937                name: alias.name.clone(),
938                stmt: ir_stmt,
939                type_name,
940                correlated_to: None,
941            });
942        }
943        let main = c.compile_stmt(&w.stmt)?;
944        (cte_defs, main)
945    } else {
946        (vec![], c.compile_stmt(&ast)?)
947    };
948
949    let mut ctes = ctes;
950    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
951    Ok(super::IrOutput {
952        subtype_fanouts: c.subtype_fanouts(),
953        stmt: ir,
954        params: c.params,
955        param_tuple_types: c.param_tuple_types,
956        ctes,
957        global_ctes: c.global_ctes,
958        warnings: c.warnings,
959        uses_globals_arg: c.used_globals_arg,
960    })
961}
962
963/// Compile a schema `Trigger`'s `handler` PyQL statement for DDL emission —
964/// same shape as `compile_fn_body`, but instead of `fn_params` this binds
965/// `__new__`/`__old__` as the inserted/updated/deleted row (see
966/// `Compiler::special_anchors`'s own doc comment), gated by `on_mask`
967/// (Pylon's `On` bitmask: 1=Insert, 2=Update, 4=Delete): `__old__` is bound whenever
968/// `on_mask` does *not* include Insert (so Update-only, Delete-only, and
969/// Update+Delete all get it — but never a mask that includes Insert, even
970/// combined with Update, since a shared trigger function has no old row on
971/// the Insert branch of that combination); `__new__` is bound whenever
972/// `on_mask` does *not* include Delete, by the mirror-image argument. A
973/// mask combining Insert and Delete (with no Update) legally binds
974/// neither. Referencing an anchor that isn't bound is a compile error, not
975/// a runtime NULL.
976/// One compiled rewrite: the column it writes and the value it writes there.
977pub struct RewriteAssignment {
978    pub pointer: String,
979    pub column: String,
980    pub ir: IrExpr,
981    pub sql: String,
982    /// True when the expression reads one of the owner's multi-links. Those
983    /// rows live in a junction table the statement writes *after* the row
984    /// itself, so a `BEFORE` trigger reading one sums an empty set — see
985    /// `export::rewrite_trigger_infos`.
986    pub reads_a_multi_link: bool,
987}
988
989/// A type's rewrites for one event (`on_mask`: 1 = insert, 2 = update), each
990/// compiled against the row being written (`NEW`) as `(column, SQL)`, for
991/// the `BEFORE` trigger that applies them. Evaluated on the row as the
992/// statement left it, every rewrite sees the others' inputs, not their
993/// results.
994pub fn compile_rewrite_assignments(
995    type_name: &str,
996    on_mask: u8,
997    schema: &SchemaDescriptor,
998) -> Result<Vec<RewriteAssignment>, crate::error::PyQLError> {
999    let mut c = Compiler::new(schema);
1000    c.in_fn_body = true;
1001    let owner_td = c.resolve_type(type_name)?;
1002    c.special_anchors
1003        .insert("__new__".to_string(), (owner_td, "NEW".to_string()));
1004    if on_mask & 1 == 0 {
1005        c.special_anchors
1006            .insert("__old__".to_string(), (owner_td, "OLD".to_string()));
1007    }
1008    let pointers = owner_td
1009        .properties
1010        .iter()
1011        .map(|p| (p.name.clone(), p.name.clone(), &p.rewrites))
1012        .chain(
1013            owner_td
1014                .links
1015                .iter()
1016                .filter(|l| !l.is_junction_backed())
1017                .map(|l| (l.name.clone(), format!("{}_id", l.name), &l.rewrites)),
1018        );
1019    let fanouts = c.subtype_fanouts();
1020    let mut out = Vec::new();
1021    for (name, column, rewrites) in pointers {
1022        for rw in rewrites.iter().filter(|rw| rw.on & on_mask != 0) {
1023            // Carried out by `compile_insert` as an assignment.
1024            if on_mask == 1 && subject_default_insert(&rw.handler, &name).is_some() {
1025                continue;
1026            }
1027            let expr = crate::parse::parse_pointer_expr(&rw.handler)?;
1028            let ir = c.compile_expr(&expr, owner_td, "NEW")?;
1029            if !c.params.is_empty() || !c.hoisted_ctes.is_empty() {
1030                return Err(PyQLError::Type(PyQLTypeError {
1031                    message: format!(
1032                        "the rewrite of '{type_name}.{name}' has nowhere to bind a parameter or a binding"
1033                    ),
1034                    position: Position { line: 0, col: 0 },
1035                }));
1036            }
1037            let sql = crate::sql::emit_expr_with_fanouts(&ir, &fanouts);
1038            // Read off the emitted SQL rather than the expression: a walk
1039            // reaches a junction through computeds, subtypes and `through`
1040            // types, and what it actually joins is the one answer all of
1041            // those agree on.
1042            let junctions = c.junction_tables_of(owner_td)?;
1043            let reads_a_multi_link = junctions.iter().any(|table| sql.contains(table.as_str()));
1044            out.push(RewriteAssignment {
1045                pointer: name.clone(),
1046                column: column.clone(),
1047                ir,
1048                sql,
1049                reads_a_multi_link,
1050            });
1051        }
1052    }
1053    Ok(out)
1054}
1055
1056pub fn compile_trigger_handler(
1057    handler: &str,
1058    type_name: &str,
1059    on_mask: u8,
1060    schema: &SchemaDescriptor,
1061) -> Result<super::IrOutput, crate::error::PyQLError> {
1062    use crate::parse;
1063    use crate::parse::ast::Stmt;
1064
1065    let ast = parse::parse(handler.trim())?;
1066    let mut c = Compiler::new(schema);
1067    // A trigger has no parameters to bind a session global to; it reads them
1068    // from `GLOBALS_ARG`, which its function declares from `pylon.globals`.
1069    c.in_fn_body = true;
1070    // The trigger's own type — `__new__`/`__old__` always resolve against
1071    // this, regardless of what type happens to be the ambient `td` where
1072    // the anchor is textually used (e.g. inside `insert Note { note :=
1073    // __new__.name }`, the ambient td at that point is `Note`, not this).
1074    let owner_td = c.resolve_type(type_name)?;
1075    if on_mask & 4 == 0 {
1076        c.special_anchors
1077            .insert("__new__".to_string(), (owner_td, "NEW".to_string()));
1078    }
1079    if on_mask & 1 == 0 {
1080        c.special_anchors
1081            .insert("__old__".to_string(), (owner_td, "OLD".to_string()));
1082    }
1083
1084    let (ctes, ir) = if let Stmt::With(w) = &ast {
1085        let mut cte_defs = vec![];
1086        for alias in &w.aliases {
1087            // `resolved := (__new__.status = …)` — a value read off the row
1088            // the trigger fired for, which a CTE has no way to see; it
1089            // stands in for its value wherever the name is used.
1090            if !matches!(alias.expr, ast::Expr::SubQuery(_)) {
1091                let anchor = if on_mask & 4 == 0 { "NEW" } else { "OLD" };
1092                let value = c.compile_expr(&alias.expr, owner_td, anchor)?;
1093                c.inline_bindings.insert(alias.name.clone(), value);
1094                continue;
1095            }
1096            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
1097            let sql_name = c.claim_cte_sql_name(&alias.name);
1098            let cte_type_name = cte_stmt_type(&ir_stmt);
1099            c.cte_types.insert(alias.name.clone(), cte_type_name.clone());
1100            c.note_cte_cardinality(&alias.name, &ir_stmt);
1101            cte_defs.push(super::IrCteDef {
1102                name: sql_name,
1103                stmt: ir_stmt,
1104                type_name: cte_type_name,
1105                correlated_to: None,
1106            });
1107        }
1108        let main = c.compile_stmt(&w.stmt)?;
1109        (cte_defs, main)
1110    } else {
1111        (vec![], c.compile_stmt(&ast)?)
1112    };
1113
1114    let recursive_kind = recursive_dml_event(&ir, type_name)
1115        .or_else(|| ctes.iter().find_map(|cte| recursive_dml_event(&cte.stmt, type_name)));
1116    if let Some(kind) = recursive_kind
1117        && kind & on_mask != 0
1118    {
1119        return Err(crate::error::PyQLError::Fragment(crate::error::PyQLFragmentError {
1120            message: format!(
1121                "trigger on {type_name} is recursive: its handler {}s its own type, \
1122                     which this trigger also fires on",
1123                dml_event_word(kind),
1124            ),
1125            context: type_name.to_string(),
1126            position: crate::error::Position { line: 0, col: 0 },
1127        }));
1128    }
1129
1130    let mut ctes = ctes;
1131    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
1132    Ok(super::IrOutput {
1133        subtype_fanouts: c.subtype_fanouts(),
1134        stmt: ir,
1135        params: c.params,
1136        param_tuple_types: c.param_tuple_types,
1137        ctes,
1138        global_ctes: c.global_ctes,
1139        warnings: c.warnings,
1140        uses_globals_arg: c.used_globals_arg,
1141    })
1142}
1143
1144fn dml_event_word(kind: u8) -> &'static str {
1145    match kind {
1146        1 => "insert",
1147        2 => "update",
1148        4 => "delete",
1149        _ => "mutate",
1150    }
1151}
1152
1153/// Walks a compiled trigger handler's IR looking for a nested `INSERT`/
1154/// `UPDATE`/`DELETE` targeting `owner_type` — the same type the trigger
1155/// itself is declared on. Returns that DML's own event bit (1/2/4) the
1156/// first time one is found, so the caller can check it against the
1157/// trigger's own `on_mask`: a handler that inserts into its own type only
1158/// matters if this trigger *also* fires on Insert — the mask determines
1159/// what would actually refire, not just "does it touch itself at all".
1160///
1161/// Deliberately not exhaustive: covers the direct statement, a `SELECT
1162/// (INSERT/UPDATE/DELETE …) { … }` wrapper, and `for x in … union (…)`
1163/// loop bodies — the shapes every trigger handler in this codebase's own
1164/// test suite actually uses. A DML nested inside a free tuple/set literal
1165/// (`select { (insert A {...}), (insert B {...}) }`) isn't walked; a
1166/// handler written that way relies on Postgres's own runtime recursion-
1167/// depth guard instead, same fallback as before this check existed.
1168fn recursive_dml_event(stmt: &IrStmt, owner_type: &str) -> Option<u8> {
1169    match stmt {
1170        IrStmt::Insert(ins) if ins.target.type_name == owner_type => Some(1),
1171        IrStmt::Update(upd) if upd.target.type_name == owner_type => Some(2),
1172        IrStmt::Delete(del) if del.target.type_name == owner_type => Some(4),
1173        IrStmt::Select(sel) => sel
1174            .dml_source
1175            .as_deref()
1176            .and_then(|inner| recursive_dml_event(inner, owner_type)),
1177        IrStmt::For(for_stmt) => recursive_dml_event(&for_stmt.body, owner_type),
1178        _ => None,
1179    }
1180}
1181
1182/// Compile a single PyQL expression in the context of a named type.
1183/// Used for schema fragments: computed pointers, rewrite handlers, constraint exprs.
1184pub fn compile_expr_in_type(
1185    expr: &Expr,
1186    type_name: &str,
1187    schema: &SchemaDescriptor,
1188) -> Result<(IrExpr, Vec<String>), PyQLError> {
1189    let mut c = Compiler::new(schema);
1190    let td = c.resolve_type(type_name)?;
1191    let alias = c.fresh_alias();
1192    let ir = c.compile_expr(expr, td, &alias)?;
1193    Ok((ir, c.params))
1194}
1195
1196/// Compile a schema-declared computed pointer the same way a shape that
1197/// included it would, for validation.
1198///
1199/// Returns the scalar expression when the computed is scalar-valued, and
1200/// `None` when it compiles to an object (link) pointer — `(select .emails
1201/// filter .primary limit 1)` and friends, which have no scalar type for a
1202/// declared return type to be checked against.
1203pub fn compile_computed_in_type(
1204    cd: &crate::schema::ComputedDescriptor,
1205    type_name: &str,
1206    schema: &SchemaDescriptor,
1207) -> Result<Option<IrExpr>, PyQLError> {
1208    let mut c = Compiler::new(schema);
1209    let td = c.resolve_type(type_name)?;
1210    let module = td.module.clone();
1211    let alias = c.fresh_alias();
1212    match c.compile_declared_computed(cd, td, &alias, &module, None, &[])? {
1213        IrShapePointer::Computed(p) => Ok(Some(p.expr)),
1214        _ => Ok(None),
1215    }
1216}
1217
1218/// Like `compile_expr_in_type` but uses an empty table alias, so that column
1219/// references emit as bare column names (`"col"` rather than `"a1"."col"`).
1220/// Used for fill expressions in migration UPDATE SET clauses.
1221pub fn compile_expr_unaliased(
1222    expr: &Expr,
1223    type_name: &str,
1224    schema: &SchemaDescriptor,
1225) -> Result<(IrExpr, Vec<String>), PyQLError> {
1226    let mut c = Compiler::new(schema);
1227    let td = c.resolve_type(type_name)?;
1228    let ir = c.compile_expr(expr, td, "")?;
1229    Ok((ir, c.params))
1230}
1231
1232/// Compile a PyQL scalar expression to a SQL string for use as a column DEFAULT.
1233///
1234/// Wraps the expression in `SELECT <expr>`, compiles it as a free scalar, and
1235/// returns the emitted SQL expression (without the SELECT wrapper).
1236pub fn compile_scalar_default(pyql: &str, schema: &SchemaDescriptor) -> Result<String, String> {
1237    compile_scalar_default_typed(pyql, schema).map(|(sql, _ir)| sql)
1238}
1239
1240/// Like `compile_scalar_default`, but also returns the compiled `IrExpr` —
1241/// used by schema-type-consistency validation (`crate::validate`) to infer
1242/// the default's actual produced type via `infer_ir_type`.
1243/// Compile a PyQL boolean expression in a type's context into the SQL a CHECK
1244/// constraint needs.
1245///
1246/// Columns emit unqualified (via `compile_expr_unaliased`), which is what a
1247/// table-level CHECK wants — it has no alias to qualify against.
1248pub fn compile_constraint_expr(
1249    pyql: &str,
1250    type_name: &str,
1251    schema: &SchemaDescriptor,
1252) -> Result<String, crate::error::PyQLError> {
1253    use crate::parse::ast::Stmt;
1254    let ast = crate::parse::parse(&format!("SELECT {pyql}"))?;
1255    let Stmt::Select(sel) = &ast else {
1256        return Err(PyQLError::Type(PyQLTypeError {
1257            message: "constraint expression must be an expression".into(),
1258            position: Position { line: 0, col: 0 },
1259        }));
1260    };
1261    let (ir, _params) = compile_expr_unaliased(&sel.result, type_name, schema)?;
1262    Ok(crate::sql::emit_expr(&ir))
1263}
1264
1265pub fn compile_scalar_default_typed(pyql: &str, schema: &SchemaDescriptor) -> Result<(String, IrExpr), String> {
1266    use crate::parse::ast::Stmt;
1267    let full = format!("SELECT {}", pyql);
1268    let ast = crate::parse::parse(&full).map_err(|e| e.message)?;
1269    let Stmt::Select(sel) = &ast else {
1270        return Err("default expression must be a select statement".into());
1271    };
1272    let mut c = Compiler::new(schema);
1273    let ir = c.compile_free_expr(&sel.result).map_err(|e| e.to_string())?;
1274    let sql = crate::sql::emit_expr(&ir);
1275    Ok((sql, ir))
1276}
1277
1278/// What in `expr` disqualifies it from being a column DEFAULT, if anything.
1279///
1280/// PostgreSQL evaluates a column default with no row and no query in scope,
1281/// so it rejects a sub-select outright ("cannot use subquery in DEFAULT
1282/// expression") and has nothing to resolve another column's name against.
1283pub fn default_blocker(expr: &IrExpr) -> Option<&'static str> {
1284    use IrExpr as E;
1285    match expr {
1286        E::Subquery(_)
1287        | E::ObjectSubquery(_)
1288        | E::ObjectPathSubquery(_)
1289        | E::ObjectPathUnion { .. }
1290        | E::PathSubquery(_)
1291        | E::FnSubquery(_)
1292        | E::ArrayFromSelect(_)
1293        | E::ScalarSubquery(_)
1294        | E::AggOverQuery { .. }
1295        | E::AggOverCte { .. }
1296        | E::ExistsOverCte { .. }
1297        | E::SetOp { .. }
1298        | E::AggOverSet { .. }
1299        | E::CteRef { .. }
1300        | E::CteFieldRef { .. }
1301        | E::GlobalRef { .. } => Some("a sub-select"),
1302        E::ColumnRef { .. } => Some("a reference to another pointer"),
1303        E::Param { .. } | E::GlobalParam { .. } => Some("a query parameter"),
1304        E::ForVar { .. } => Some("a for-loop variable"),
1305        E::FnParam { .. } => Some("a function parameter"),
1306        E::BinOp(b) => default_blocker(&b.left).or_else(|| default_blocker(&b.right)),
1307        E::UnaryOp(u) => default_blocker(&u.operand),
1308        E::TypeCast(c) => default_blocker(&c.expr),
1309        E::IfElse(i) => default_blocker(&i.condition)
1310            .or_else(|| default_blocker(&i.if_))
1311            .or_else(|| default_blocker(&i.else_)),
1312        E::FunctionCall(f) => f.args.iter().find_map(default_blocker),
1313        E::Array(items) | E::Tuple(items) => items.iter().find_map(default_blocker),
1314        E::NamedTuple { fields, .. } => fields.iter().find_map(|(_, e)| default_blocker(e)),
1315        E::Subscript { expr, index, .. } => default_blocker(expr).or_else(|| default_blocker(index)),
1316        E::JsonbField { expr, .. } | E::JsonbIndex { expr, .. } => default_blocker(expr),
1317        E::Slice { expr, lower, upper, .. } => default_blocker(expr)
1318            .or_else(|| lower.as_deref().and_then(default_blocker))
1319            .or_else(|| upper.as_deref().and_then(default_blocker)),
1320        E::Literal(_) | E::Null | E::EnumLiteral { .. } | E::RawSql(_) => None,
1321    }
1322}
1323
1324/// The SQL for a pointer's PyQL default as a column DEFAULT clause, or `None`
1325/// when a column DEFAULT cannot hold it — it reads a session global, selects
1326/// an object, or otherwise needs the query around it. Those are applied by
1327/// `compile_insert` instead; see `inlined_pointer_defaults`.
1328pub fn column_default_sql(pyql: &str, schema: &SchemaDescriptor) -> Option<String> {
1329    let (sql, ir) = compile_scalar_default_typed(pyql, schema).ok()?;
1330    default_blocker(&ir).is_none().then_some(sql)
1331}
1332
1333/// The pointers whose PyQL default an insert has to expand into its own shape,
1334/// paired with that default, because `column_default_sql` cannot render it.
1335///
1336/// Expanding a default means adding one shape element per unspecified pointer,
1337/// which is what makes an object-returning default like
1338/// `created_by := account_of_transaction()` work at all. Here the defaults a
1339/// column DEFAULT does hold keep using it, so only the remainder needs the
1340/// query.
1341pub fn inlined_pointer_defaults(td: &TypeDescriptor, schema: &SchemaDescriptor) -> Vec<(String, String)> {
1342    let properties = td
1343        .properties
1344        .iter()
1345        .filter(|p| !p.is_pk)
1346        .filter_map(|p| p.default_pyql.as_ref().map(|d| (p.name.clone(), d.clone())));
1347    let links = td
1348        .links
1349        .iter()
1350        .filter(|l| !l.is_junction_backed())
1351        .filter_map(|l| l.default_pyql.as_ref().map(|d| (l.name.clone(), d.clone())));
1352    properties
1353        .chain(links)
1354        .filter(|(_, pyql)| column_default_sql(pyql, schema).is_none())
1355        .collect()
1356}
1357
1358/// One inlined default as the insert shape carries it: `pointer := <default>`.
1359fn default_shape_element(pointer: &str, value: Expr) -> ShapeElement {
1360    ShapeElement {
1361        path: ast::Path::relative(pointer),
1362        splat: None,
1363        nested: None,
1364        compexpr: Some(value),
1365        op: ShapeOp::Assign,
1366        filter: None,
1367        order_by: vec![],
1368        offset: None,
1369        limit: None,
1370        marker_offset: None,
1371    }
1372}
1373
1374/// One inlined default, compiled the way `compile_insert` expands it into an
1375/// insert's shape — so a default that cannot compile there is caught by schema
1376/// validation rather than by the first insert that omits the pointer.
1377///
1378/// One pointer at a time, because the caller reporting the failure has to be
1379/// able to name which declaration caused it.
1380pub fn compile_inlined_default(
1381    type_name: &str,
1382    pointer: &str,
1383    pyql: &str,
1384    schema: &SchemaDescriptor,
1385) -> Result<(String, IrExpr), PyQLError> {
1386    let mut c = Compiler::new(schema);
1387    let td = c.resolve_type(type_name)?;
1388    let alias = c.fresh_alias();
1389    let element = default_shape_element(pointer, crate::parse::parse_pointer_expr(pyql)?);
1390    let mut assignments = c.compile_assignments(&[element], td, &alias)?;
1391    Ok(assignments.remove(0))
1392}
1393
1394// ── Compiler context ────────────────────────────────────────────────────────────
1395
1396struct Compiler<'a> {
1397    schema: &'a SchemaDescriptor,
1398    /// Ordered parameter names — index + 1 is the $N position in SQL.
1399    params: Vec<String>,
1400    /// Positionally matching `params`, grown with it: the tuple type a
1401    /// parameter is cast to, recorded by `tuple_cast`.
1402    param_tuple_types: Vec<Option<crate::query::ParamTupleType>>,
1403    alias_counter: usize,
1404    /// CTE names registered in the enclosing WITH block → qualified type name.
1405    cte_types: HashMap<String, String>,
1406    /// `with g := (group …)` bindings. A group's rows are no schema object, so
1407    /// nothing can read them as a CTE; a `for` over one compiles the group
1408    /// again in its own terms instead.
1409    group_bindings: HashMap<String, IrGroup>,
1410    /// CTE names bound to a single free row (free object/scalar/tuple, not
1411    /// a schema object) → that row's `IrFreeExpr` — lets `root.field`
1412    /// resolve to `IrExpr::CteFieldRef` when `root` is such a binding,
1413    /// instead of failing as an unresolvable schema-path root.
1414    cte_free_items: HashMap<String, IrFreeExpr>,
1415    /// CTE names whose binding can hold more than one row. Comparing a value
1416    /// against one has to become set membership: `IrExpr::CteRef` reads the
1417    /// binding as a scalar subquery, which Postgres aborts on the second row
1418    /// ("more than one row returned by a subquery used as an expression").
1419    multi_row_ctes: HashSet<String>,
1420    /// FOR loop variables in scope: variable name → pg_type of the scalar iterator.
1421    for_vars: HashMap<String, String>,
1422    /// For-loop variables that iterate objects, by qualified type name. The
1423    /// variable itself holds the row's key (see `compile_for`), so a path
1424    /// rooted at one reads its table back by that key.
1425    for_var_types: HashMap<String, String>,
1426    /// For a loop over a `with` binding, the binding each variable's rows come
1427    /// from. The rows a sibling CTE has just written are not in the base table
1428    /// yet -- Postgres runs every CTE against the snapshot the statement
1429    /// started with -- so a walk off the variable has to read the binding.
1430    for_var_ctes: HashMap<String, String>,
1431    /// The name each loop variable's iterator CTE is emitted under. Two
1432    /// sibling loops may use the same variable name, and a WITH clause cannot
1433    /// hold the same CTE name twice.
1434    for_var_slots: HashMap<String, String>,
1435    /// The condition a `(insert …) if cond else {}` puts on the insert about
1436    /// to be compiled — see `IrInsert::guard`.
1437    pending_insert_guard: Option<Expr>,
1438    /// The condition a guarded `update` has to carry itself, for the same
1439    /// reason an insert does: a data-modifying CTE runs whether or not
1440    /// anything reads it, so filtering what is read back changes nothing.
1441    pending_update_guard: Option<Expr>,
1442    /// See `pending_update_guard` — the same, for a guarded delete.
1443    pending_delete_guard: Option<Expr>,
1444    /// Pointers a `with` binding declared in its own shape (`offering := (
1445    /// select Offering { publisher := … })`). They exist nowhere on the type,
1446    /// so a later `offering { publisher }` has to find them here.
1447    cte_declared_pointers: HashMap<String, Vec<ShapeElement>>,
1448    /// Those of the binding whose shape is being compiled right now.
1449    active_declared_pointers: Vec<ShapeElement>,
1450    /// Computed pointers a path is reading as a value right now, as
1451    /// `module::Type.name` — one that names itself would otherwise recurse
1452    /// without end.
1453    expanding_computeds: Vec<String>,
1454    /// The schema-bound selects currently being compiled, innermost last.
1455    ///
1456    /// Only consulted for `detached`: an absolute `TypeName.prop` inside a
1457    /// detached select means the *enclosing* select's row, so the innermost
1458    /// entry is skipped and the next matching one used.
1459    anchors: Vec<SelectAnchor>,
1460    /// CTE definitions a nested `with` contributed from somewhere the
1461    /// emitter has no WITH clause of its own — an expression, say. They are
1462    /// appended to the statement's own CTEs at the top-level boundary.
1463    hoisted_ctes: Vec<IrCteDef>,
1464    /// What each hoisted binding was defined as, so the same one arriving
1465    /// twice (one computed inlined from two places) is recognised as the same
1466    /// rather than emitted twice under one CTE name.
1467    hoisted_binding_sources: HashMap<String, Expr>,
1468    /// The WITH name a binding is actually emitted under. Two `for` bodies may
1469    /// each declare `line`; one WITH clause cannot hold that name twice, so the
1470    /// second is emitted under a suffixed one and every reference to it reads
1471    /// from here.
1472    cte_sql_names: HashMap<String, String>,
1473    /// Every WITH name this statement will carry, whoever asked for it — a
1474    /// user binding, a loop's iterator, a hoisted mutation. One set so no two
1475    /// can claim the same name and no guard has to know about the others.
1476    cte_namespace: std::collections::HashSet<String>,
1477    /// Loop slots whose variable has been read since the last time this was
1478    /// cleared — see `for_var_ref`.
1479    for_vars_read: std::collections::HashSet<String>,
1480    /// SQL CTE name → the loop slot its rows belong to one iteration of, for
1481    /// every per-iteration binding registered so far.
1482    correlated_bindings: HashMap<String, String>,
1483    /// Slots of the per-iteration bindings read since this was last cleared.
1484    /// Interior mutability because a read is noticed from `&self` name
1485    /// resolution — see `cte_sql_name`.
1486    correlated_reads: std::cell::RefCell<std::collections::HashSet<String>>,
1487    /// The loops whose bodies are being compiled, innermost last.
1488    for_scope: Vec<String>,
1489    /// `(through type, junction alias)` of the multi-link whose own
1490    /// modifiers are being compiled — what a bare `@prop` in `filter
1491    /// (@primary = true)` resolves against. `None` on the stack means a link
1492    /// with no through type, which has no link properties at all.
1493    link_prop_scope: Vec<Option<(String, String)>>,
1494    /// Set when `compile_stmt` strips a select-level `detached`, and taken by
1495    /// the `compile_path_modifiers` that compiles that select's own clauses.
1496    pending_detached: bool,
1497    /// Set while a path continues past a sub-select's own subject, e.g. the
1498    /// `.plan.tier` of `(select .licences filter not exists .ended_at limit
1499    /// 1).plan.tier`: the subject's landing row is what FILTER/ORDER BY/LIMIT
1500    /// scope to, not the type the whole path ends on.
1501    modifier_anchor: Option<(String, String)>,
1502    /// The `order by` the *outer* select wrote around such a path, which
1503    /// speaks about what the path projects rather than about the anchored
1504    /// subject — `(select Cart filter .id = $c).applied_promotions order by
1505    /// .code` orders applications, having filtered carts.
1506    tail_sorts: Vec<ast::SortExpr>,
1507    /// WITH bindings that name a path off the enclosing object. They read that
1508    /// object's alias, which a CTE emitted ahead of the FROM clause cannot
1509    /// see, so they stand in for their value wherever the name is used.
1510    inline_bindings: std::collections::HashMap<String, IrExpr>,
1511    /// True while compiling a `@pylon.function` body. A session global cannot
1512    /// be a query parameter there — a `CREATE FUNCTION` body has nothing to
1513    /// bind one to — so it is read out of the `__pylon_json_globals__`
1514    /// argument instead. See `GLOBALS_ARG`.
1515    in_fn_body: bool,
1516    /// Qualified names of functions that take the globals argument, so a call
1517    /// to one can be given it.
1518    ///
1519    /// Computed on first use rather than up front: working it out means
1520    /// compiling every function body in the schema, and the overwhelming
1521    /// majority of queries never call a user function at all. Function-body
1522    /// compilation seeds it explicitly (`compile_fn_body_with`), which is also
1523    /// what keeps the fixpoint from recursing into itself.
1524    fns_needing_globals: Option<std::collections::HashSet<String>>,
1525    /// Set when this body read a global or forwarded the argument to a callee
1526    /// — i.e. when the function being compiled needs the argument itself.
1527    used_globals_arg: bool,
1528    /// User-defined function parameters in scope (only set during body compilation).
1529    fn_params: HashMap<String, String>,
1530    /// `__new__`/`__old__` row-context bindings, only set during trigger-handler
1531    /// compilation (`compile_trigger_handler`) — maps the anchor name to
1532    /// *the trigger's own type* and the table alias its properties/links
1533    /// should resolve against (`"NEW"`/`"OLD"`). Stored explicitly (not just
1534    /// the alias string) because `__new__.x`/`__old__.x` can appear nested
1535    /// inside a sub-statement targeting a *different* type (e.g. `insert Note
1536    /// { note := __new__.name }` — the ambient `td` at that point is `Note`,
1537    /// not the trigger's own type), so resolution can't rely on whatever
1538    /// `td` happens to be in scope where the anchor is used. An anchor not
1539    /// legal for the trigger's `on` mask (e.g. `__old__` in an Insert-only
1540    /// trigger) is simply absent from this map, and fails resolution the
1541    /// same way any other unknown identifier does.
1542    special_anchors: HashMap<String, (&'a TypeDescriptor, String)>,
1543    /// Global CTEs collected during compilation (session and computed), in dependency order.
1544    global_ctes: Vec<IrGlobalCte>,
1545    /// Nested DML hoisted out of a link-assignment value currently being
1546    /// compiled (`author := (select (insert Person {...}) { id })`) — see
1547    /// `compile_link_subquery`'s doc comment. `compile_insert`/
1548    /// `compile_update` save-and-clear this on entry and drain it back into
1549    /// their own `IrInsert`/`IrUpdate::nested_ctes` on exit, so nesting
1550    /// (a nested insert whose own link value nests another insert) attaches
1551    /// each level's discoveries to the right statement.
1552    pending_nested_ctes: Vec<IrCteDef>,
1553    /// Junction tables this statement also *writes*, mapped to the CTE that
1554    /// wrote them — installed only while a walk off a hoisted mutation is
1555    /// being compiled. Postgres does not show a sibling CTE's inserts in the
1556    /// base table, so `(update T set { multi += … }).multi` has to read the
1557    /// rows back out of the append CTE or it sees none of them.
1558    junction_read_overrides: HashMap<String, JunctionReadOverride>,
1559    nested_cte_counter: usize,
1560    /// Non-fatal warnings collected during compilation.
1561    warnings: Vec<String>,
1562    /// Depth of `any()`/`all()` arguments currently being compiled. Wrapping a
1563    /// set-valued comparison in one of those *is* the explicit intent the
1564    /// multi-link FILTER warning asks for, so the warning stays quiet inside.
1565    explicit_set_depth: usize,
1566    /// Of those, the `all()` ones. `any()` is what an EXISTS already says, so
1567    /// a set-valued comparison may fold into one there; `all()` needs the
1568    /// answers one at a time to reduce them, since an EXISTS over a negated
1569    /// test is not the same question. See `try_elementwise_in`.
1570    universal_set_depth: usize,
1571    /// User-configurable session options — see `SessionConfig`. Always
1572    /// `default()` for every entry point except `compile_with_config`.
1573    config: crate::ir::SessionConfig,
1574    /// Whether a shape compiled right now gets an `id` it did not ask for —
1575    /// see `prepend_implicit_id`. True at the top level and cleared for the
1576    /// two places that cannot carry it: a mutation's own body, where a link
1577    /// value compiles to a one-column correlated subquery that a second column
1578    /// would break, and under a cast to `json`, which is an output sink whose
1579    /// text an added key would change.
1580    implicit_id_in_shapes: bool,
1581    /// Whether the expression being compiled stands where a *value* is
1582    /// expected — a computed pointer's right-hand side, say — rather than
1583    /// where a condition is. `in` asks its question once per element of its
1584    /// left operand, so over a set it yields a set of booleans; only a
1585    /// condition collapses that set to the single "any of them" answer an
1586    /// EXISTS gives. Set by `in_value_position`, cleared by `as_condition`.
1587    value_position: bool,
1588}
1589
1590/// One schema-bound select in the enclosing chain — see `Compiler::anchors`.
1591struct SelectAnchor {
1592    type_name: String,
1593    qualified: String,
1594    alias: String,
1595    detached: bool,
1596    /// The ancestor an inherited computed is declared on, set only for the
1597    /// anchor that computed is compiled under.
1598    ///
1599    /// A computed is compiled once, against the type that declares it, so
1600    /// `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)` reads its own
1601    /// row there and keeps doing so through every subtype that inherits it.
1602    /// Pylon materialises an inherited computed onto each subtype and compiles
1603    /// it again there, where `BrandAddon` no longer names the subject -- and a
1604    /// bare type name that anchors nothing reads the whole table. Naming a
1605    /// supertype does *not* otherwise mean the subject
1606    /// (`select BrandAddonBundle { n := count(BrandAddon) }` is the full count,
1607    /// while the same shape naming `BrandAddonBundle` is 1), so this is scoped
1608    /// to the one anchor rather than folded into the general name match.
1609    declared_on: Option<(String, String)>,
1610}
1611
1612impl SelectAnchor {
1613    /// Whether this anchor answers to `root` as a path root.
1614    fn answers_to(&self, root: &str) -> bool {
1615        self.type_name == root
1616            || self.qualified == root
1617            || self
1618                .declared_on
1619                .as_ref()
1620                .is_some_and(|(name, qualified)| name == root || qualified == root)
1621    }
1622}
1623
1624/// The synthetic argument carrying session globals into a function body.
1625///
1626/// A session global is normally a query parameter, which a `CREATE FUNCTION`
1627/// body cannot have — it would emit a bare `$1` nothing binds. Instead the
1628/// caller packs the globals into one jsonb value and passes it as a leading
1629/// argument. One opaque argument rather than one
1630/// per global keeps the function's signature independent of which globals its
1631/// body happens to mention, so editing a body does not churn its signature
1632/// (and, with PostgreSQL overloading, leave a stale one behind).
1633pub const GLOBALS_ARG: &str = "__pylon_json_globals__";
1634
1635/// SQL reading one global out of `GLOBALS_ARG`.
1636fn globals_arg_read(qualified: &str, pg_type: &str) -> String {
1637    let key = qualified.replace('\'', "''");
1638    if let Some(element) = pg_type.strip_suffix("[]") {
1639        // `->>` would hand back the JSON array's text, not an array, so the
1640        // elements are unpacked instead. The `jsonb_typeof` guard matters: an
1641        // unset global arrives as JSON null, and unpacking that raises "cannot
1642        // extract elements from a scalar". The `coalesce` keeps an empty array
1643        // an empty array rather than letting `array_agg` turn it into NULL.
1644        format!(
1645            "(case when jsonb_typeof({GLOBALS_ARG} -> '{key}') = 'array' \
1646             then coalesce((select array_agg(value::{element}) \
1647             from jsonb_array_elements_text({GLOBALS_ARG} -> '{key}') as value), '{{}}'::{pg_type}) \
1648             else null end)"
1649        )
1650    } else {
1651        format!("(({GLOBALS_ARG} ->> '{key}')::{pg_type})")
1652    }
1653}
1654
1655impl<'a> Compiler<'a> {
1656    fn new(schema: &'a SchemaDescriptor) -> Self {
1657        Self::with_config(schema, crate::ir::SessionConfig::default())
1658    }
1659
1660    fn with_config(schema: &'a SchemaDescriptor, config: crate::ir::SessionConfig) -> Self {
1661        Compiler {
1662            schema,
1663            params: vec![],
1664            param_tuple_types: vec![],
1665            alias_counter: 0,
1666            cte_types: HashMap::new(),
1667            group_bindings: HashMap::new(),
1668            cte_free_items: HashMap::new(),
1669            multi_row_ctes: HashSet::new(),
1670            for_vars: HashMap::new(),
1671            for_var_types: HashMap::new(),
1672            for_var_ctes: HashMap::new(),
1673            for_var_slots: HashMap::new(),
1674            pending_insert_guard: None,
1675            pending_update_guard: None,
1676            pending_delete_guard: None,
1677            cte_declared_pointers: HashMap::new(),
1678            active_declared_pointers: vec![],
1679            expanding_computeds: vec![],
1680            fn_params: HashMap::new(),
1681            special_anchors: HashMap::new(),
1682            global_ctes: vec![],
1683            pending_nested_ctes: vec![],
1684            junction_read_overrides: HashMap::new(),
1685            nested_cte_counter: 0,
1686            warnings: vec![],
1687            explicit_set_depth: 0,
1688            universal_set_depth: 0,
1689            config,
1690            implicit_id_in_shapes: true,
1691            value_position: false,
1692            anchors: Vec::new(),
1693            link_prop_scope: Vec::new(),
1694            hoisted_ctes: Vec::new(),
1695            hoisted_binding_sources: HashMap::new(),
1696            cte_sql_names: HashMap::new(),
1697            cte_namespace: std::collections::HashSet::new(),
1698            for_vars_read: std::collections::HashSet::new(),
1699            correlated_bindings: HashMap::new(),
1700            correlated_reads: std::cell::RefCell::new(std::collections::HashSet::new()),
1701            for_scope: Vec::new(),
1702            pending_detached: false,
1703            modifier_anchor: None,
1704            tail_sorts: vec![],
1705            inline_bindings: std::collections::HashMap::new(),
1706            in_fn_body: false,
1707            fns_needing_globals: None,
1708            used_globals_arg: false,
1709        }
1710    }
1711
1712    /// Register a compiled WITH binding under `name`: records its type (or
1713    /// empty string for a free binding) in `cte_types`, and — when it's a
1714    /// single free row — its `IrFreeExpr` in `cte_free_items` so a later
1715    /// `name.field` reference can resolve to `IrExpr::CteFieldRef`.
1716    /// True when `name` is bound to a value rather than to an object set —
1717    /// a scalar WITH binding, or one inlined by `bind_inline_if_correlated`.
1718    fn is_value_binding(&self, name: &str) -> bool {
1719        self.inline_bindings.contains_key(name) || self.cte_types.get(name).is_some_and(|t| !t.contains("::"))
1720    }
1721
1722    /// Bind `name` to the value of a relative path off the enclosing object —
1723    /// `with handle_id := .id`. Returns false when the binding is anything
1724    /// else, which the caller hoists into a CTE as usual.
1725    /// `ctx` is the row the enclosing sub-select is correlated to, when it
1726    /// has one: a relative path in its bindings reads that row, not whichever
1727    /// select happens to enclose it.
1728    fn bind_inline_if_correlated(
1729        &mut self,
1730        name: &str,
1731        expr: &Expr,
1732        ctx: Option<(&TypeDescriptor, &str)>,
1733    ) -> Result<bool, PyQLError> {
1734        if (self.anchors.is_empty() && ctx.is_none()) || !matches!(expr, Expr::Path(p) if p.partial) {
1735            return Ok(false);
1736        }
1737        let ir = self.compile_expr_ctx(expr, ctx)?;
1738        self.inline_bindings.insert(name.to_string(), ir);
1739        Ok(true)
1740    }
1741
1742    /// The WITH name `name` is emitted under — itself, unless it collided.
1743    fn cte_sql_name(&self, name: &str) -> String {
1744        let sql_name = self
1745            .cte_sql_names
1746            .get(name)
1747            .cloned()
1748            .unwrap_or_else(|| name.to_string());
1749        if let Some(slot) = self.correlated_bindings.get(&sql_name) {
1750            self.correlated_reads.borrow_mut().insert(slot.clone());
1751        }
1752        sql_name
1753    }
1754
1755    /// The `@cte:` source sentinel for `cte`, noting the read so that a
1756    /// binding compiled around it inherits the iteration `cte` belongs to.
1757    fn cte_source_table(&self, cte: &str) -> String {
1758        if let Some(slot) = self.correlated_bindings.get(cte) {
1759            self.correlated_reads.borrow_mut().insert(slot.clone());
1760        }
1761        format!("@cte:{cte}")
1762    }
1763
1764    /// Compile a `with` binding, reporting the loop slot it reads if it reads
1765    /// one. A binding that names the loop variable holds a different value
1766    /// each iteration, which is not something a statement-level CTE can be.
1767    fn compile_binding_in_scope(&mut self, expr: &Expr) -> Result<(IrStmt, Option<String>), PyQLError> {
1768        let before = std::mem::take(&mut self.for_vars_read);
1769        let before_correlated = std::mem::take(&mut *self.correlated_reads.borrow_mut());
1770        let ir_stmt = compile_cte_binding(self, expr);
1771        let mut read = std::mem::replace(&mut self.for_vars_read, before);
1772        let correlated_read = std::mem::replace(&mut *self.correlated_reads.borrow_mut(), before_correlated);
1773        // Reading a per-iteration binding makes this one per-iteration too,
1774        // even where it never names the loop variable itself: its rows are
1775        // picked by the iteration key the one it reads carries.
1776        read.extend(correlated_read.iter().cloned());
1777        // The innermost loop read is the one it belongs to; an outer loop's
1778        // variable is reachable from there anyway.
1779        let correlated_to = self
1780            .for_scope
1781            .iter()
1782            .rev()
1783            .find(|slot| read.contains(*slot))
1784            .map(|slot| format!("_for_{slot}"));
1785        self.for_vars_read.extend(read);
1786        self.correlated_reads.borrow_mut().extend(correlated_read);
1787        Ok((ir_stmt?, correlated_to))
1788    }
1789
1790    /// Claim a WITH name, suffixing `base` until it is free. Names the SQL
1791    /// emitter derives from a claimed one — `x__ids`, `x__rows`,
1792    /// `x__ml_add_0` — are claimed with it, which is why anything under a
1793    /// taken name's `__` prefix counts as taken too.
1794    fn claim_in_cte_namespace(&mut self, base: &str) -> String {
1795        // A suffixed candidate is itself an extension of `base`, so `base` is
1796        // the one claimed name that never rules its own suffixes out.
1797        let taken = |namespace: &std::collections::HashSet<String>, candidate: &String| {
1798            namespace.contains(candidate)
1799                || namespace
1800                    .iter()
1801                    .any(|claimed| claimed != base && candidate.starts_with(&format!("{claimed}__")))
1802        };
1803        let mut candidate = base.to_string();
1804        let mut suffix = 1;
1805        while taken(&self.cte_namespace, &candidate) {
1806            candidate = format!("{base}__{suffix}");
1807            suffix += 1;
1808        }
1809        self.cte_namespace.insert(candidate.clone());
1810        candidate
1811    }
1812
1813    /// Claim a WITH name the compiler spelled itself — a loop's iterator, a
1814    /// hoisted mutation. These already live in the underscore-prefixed space
1815    /// that user names are kept out of, so they are claimed as written.
1816    fn claim_generated_cte_name(&mut self, preferred: &str) -> String {
1817        self.claim_in_cte_namespace(preferred)
1818    }
1819
1820    /// Claim a WITH name for a `with` binding, recording the name to read it
1821    /// back by when the one it asked for was already spoken for.
1822    fn claim_cte_sql_name(&mut self, name: &str) -> String {
1823        // Every generated WITH name starts with an underscore. Keeping user
1824        // names out of that space is what makes the two sets disjoint, so a
1825        // binding named `_dml` cannot collide with the wrapper of that name.
1826        let base = match name.starts_with('_') {
1827            true => format!("w{name}"),
1828            false => name.to_string(),
1829        };
1830        let claimed = self.claim_in_cte_namespace(&base);
1831        if claimed == name {
1832            self.cte_sql_names.remove(name);
1833        } else {
1834            self.cte_sql_names.insert(name.to_string(), claimed.clone());
1835        }
1836        claimed
1837    }
1838
1839    fn register_cte(&mut self, name: &str, ir_stmt: &IrStmt) -> String {
1840        let type_name = cte_stmt_type(ir_stmt);
1841        self.cte_types.insert(name.to_string(), type_name.clone());
1842        if let IrStmt::Select(sel) = ir_stmt
1843            && let [IrRowSource::Free(item)] = sel.rows.as_slice()
1844        {
1845            self.cte_free_items.insert(name.to_string(), item.clone());
1846        }
1847        self.note_cte_cardinality(name, ir_stmt);
1848        type_name
1849    }
1850
1851    /// True when `sel`'s filter pins an exclusive property to one value, which
1852    /// makes it single-valued however many rows the table holds. It is why
1853    /// `(select Installation filter .id = <uuid>$x).connector.provider.staff`
1854    /// is a value rather than a one-element set.
1855    fn pins_an_exclusive_property(&self, sel: &IrSelect) -> bool {
1856        let [IrRowSource::Bound { source, .. }] = sel.rows.as_slice() else {
1857            return false;
1858        };
1859        let Some(filter) = &sel.filter else { return false };
1860        let Ok(td) = self.resolve_type(&source.type_name) else {
1861            return false;
1862        };
1863        Self::pins_exclusive(td, filter)
1864    }
1865
1866    /// Walks the conjuncts of `filter` for an equality against an exclusive or
1867    /// primary-key column. Only `and` is descended: under `or` either side
1868    /// could match a different row, so neither pins anything.
1869    fn pins_exclusive(td: &TypeDescriptor, filter: &IrExpr) -> bool {
1870        let IrExpr::BinOp(binop) = filter else { return false };
1871        match binop.op {
1872            ast::BinOpKind::And => Self::pins_exclusive(td, &binop.left) || Self::pins_exclusive(td, &binop.right),
1873            ast::BinOpKind::Eq => {
1874                let column = match (&binop.left, &binop.right) {
1875                    (IrExpr::ColumnRef { column, .. }, _) => column,
1876                    (_, IrExpr::ColumnRef { column, .. }) => column,
1877                    _ => return false,
1878                };
1879                td.properties
1880                    .iter()
1881                    .any(|p| &p.name == column && (p.is_exclusive || p.is_pk))
1882            }
1883            _ => false,
1884        }
1885    }
1886
1887    /// Record `name` when its binding can hold more than one row, so a later
1888    /// comparison against it compiles to membership rather than a scalar
1889    /// subquery read. Anything whose row count isn't statically one counts as
1890    /// multi: a needless membership test still answers correctly, a needless
1891    /// scalar read aborts the query.
1892    fn note_cte_cardinality(&mut self, name: &str, ir_stmt: &IrStmt) {
1893        let single = match ir_stmt {
1894            IrStmt::Insert(_) => true,
1895            IrStmt::Select(sel) => {
1896                matches!(sel.limit, Some(IrExpr::Literal(IrLiteral::Int(1))))
1897                    || matches!(
1898                        sel.rows.as_slice(),
1899                        [IrRowSource::Free(
1900                            IrFreeExpr::Scalar(_)
1901                                | IrFreeExpr::FreeObject(_)
1902                                | IrFreeExpr::NamedTupleRow(_)
1903                                | IrFreeExpr::Tuple(_)
1904                        )]
1905                    )
1906                    || self.pins_an_exclusive_property(sel)
1907            }
1908            _ => false,
1909        };
1910        if !single {
1911            self.multi_row_ctes.insert(name.to_string());
1912        }
1913    }
1914
1915    /// Resolve `root.field1.field2. ... fieldN` where `root` is a WITH-bound
1916    /// free object (e.g. `with x := { a := { b := 1 } } select x.a.b`) —
1917    /// `None` when `root` isn't such a binding, so callers fall back to
1918    /// their normal path resolution. The first step reads the CTE's own
1919    /// per-field column (`IrExpr::CteFieldRef`, materialized once); any
1920    /// further steps index into that value as jsonb (`IrExpr::JsonbField`),
1921    /// since a nested free-object *field* is jsonb the moment it's not the
1922    /// top-level row itself — validated statically wherever the nesting is
1923    /// itself a free-object literal (so a typo like `x.a.typo` still gets a
1924    /// compile error instead of silently returning SQL NULL).
1925    fn resolve_cte_field_chain(&self, root: &str, steps: &[&str]) -> Option<Result<IrExpr, PyQLError>> {
1926        let (first, rest) = steps.split_first()?;
1927        let fields = match self.cte_free_items.get(root)? {
1928            IrFreeExpr::FreeObject(fields) => fields,
1929            _ => return None,
1930        };
1931        let mut current: &IrExpr = match fields.iter().find(|(n, _)| n == first) {
1932            Some((_, e)) => e,
1933            None => {
1934                return Some(Err(
1935                    self.type_err(&format!("free object '{root}' has no field '{first}'"))
1936                ));
1937            }
1938        };
1939        let mut expr = IrExpr::CteFieldRef {
1940            name: root.to_string(),
1941            field: first.to_string(),
1942            pg_type: infer_ir_type(current).map(str::to_string),
1943        };
1944        for step in rest {
1945            if let IrExpr::NamedTuple {
1946                fields: nested,
1947                is_free_object: true,
1948            } = current
1949            {
1950                match nested.iter().find(|(n, _)| n == step) {
1951                    Some((_, next)) => current = next,
1952                    None => {
1953                        return Some(Err(
1954                            self.type_err(&format!("{step} is not a member of the nested free object"))
1955                        ));
1956                    }
1957                }
1958            }
1959            expr = IrExpr::JsonbField {
1960                expr: Box::new(expr),
1961                field: step.to_string(),
1962            };
1963        }
1964        Some(Ok(expr))
1965    }
1966
1967    /// `resolve_cte_field_chain`, but taking the whole `root.f1.f2...` path
1968    /// directly — `None` when the path isn't an absolute multi-step name
1969    /// chain (so, in particular, whenever a step is anything other than a
1970    /// plain name, e.g. a type intersection or backlink).
1971    fn resolve_cte_path(&self, p: &ast::Path) -> Option<Result<IrExpr, PyQLError>> {
1972        if p.partial || p.steps.len() < 2 {
1973            return None;
1974        }
1975        let ast::PathStep::Name(root) = &p.steps[0] else {
1976            return None;
1977        };
1978        let mut steps = Vec::with_capacity(p.steps.len() - 1);
1979        for step in &p.steps[1..] {
1980            match step {
1981                ast::PathStep::Name(n) => steps.push(n.as_str()),
1982                _ => return None,
1983            }
1984        }
1985        self.resolve_cte_field_chain(root, &steps)
1986    }
1987
1988    /// Return the CTE name if `expr` is a bare identifier that matches a registered CTE.
1989    fn resolve_cte_name<'e>(&self, expr: &'e Expr) -> Option<&'e str> {
1990        if let Expr::Path(p) = expr
1991            && !p.partial
1992            && p.steps.len() == 1
1993            && let ast::PathStep::Name(n) = &p.steps[0]
1994            && self.cte_types.contains_key(n.as_str())
1995        {
1996            return Some(n.as_str());
1997        }
1998        None
1999    }
2000
2001    /// A reference to the loop variable `name`, under the CTE name its loop
2002    /// was emitted with. Recorded as read, which is what tells a `with`
2003    /// binding compiled around it that it belongs to one iteration rather
2004    /// than to the statement.
2005    fn for_var_ref(&mut self, name: &str) -> IrExpr {
2006        let slot = self
2007            .for_var_slots
2008            .get(name)
2009            .cloned()
2010            .unwrap_or_else(|| name.to_string());
2011        self.for_vars_read.insert(slot.clone());
2012        let pg_type = self.for_vars.get(name).cloned();
2013        IrExpr::ForVar { name: slot, pg_type }
2014    }
2015
2016    fn fresh_alias(&mut self) -> String {
2017        let a = format!("t{}", self.alias_counter);
2018        self.alias_counter += 1;
2019        a
2020    }
2021
2022    /// A distinct naming scheme from `fresh_alias`'s `t0`, `t1`, ... (table
2023    /// aliases) so a hoisted nested-DML CTE name can never collide with one
2024    /// — see `pending_nested_ctes`.
2025    fn fresh_nested_cte_name(&mut self) -> String {
2026        let n = self.nested_cte_counter;
2027        self.nested_cte_counter += 1;
2028        self.claim_generated_cte_name(&format!("_nested_dml_{n}"))
2029    }
2030
2031    /// Register a named parameter and return its 0-based index.
2032    fn param_index(&mut self, name: &str) -> usize {
2033        if let Some(i) = self.params.iter().position(|n| n == name) {
2034            return i;
2035        }
2036        let i = self.params.len();
2037        self.params.push(name.to_string());
2038        self.param_tuple_types.push(None);
2039        i
2040    }
2041
2042    /// Build a cast to a tuple type, remembering the member names when what
2043    /// is being cast is a parameter.
2044    ///
2045    /// A client holds such a value positionally as often as by name — a
2046    /// Python tuple, a named-tuple instance — and jsonb keys its named
2047    /// members, so the names have to travel out of compilation with the
2048    /// parameter (see `crate::query::ParamTupleType`). Taking the *compiled*
2049    /// inner expression is what makes this reliable: a parameter is the only
2050    /// thing that reaches `IrExpr::Param`, however the cast was written.
2051    fn tuple_cast(&mut self, expr: IrExpr, pg_type: String, tuple_shape: Option<TupleCastShape>) -> IrExpr {
2052        if let IrExpr::Param { index } = &expr
2053            && let Some(shape) = &tuple_shape
2054        {
2055            self.param_tuple_types[*index] = Some(crate::query::ParamTupleType {
2056                is_array: pg_type.ends_with("[]"),
2057                type_name: shape.type_name.clone(),
2058                members: shape.members.clone(),
2059            });
2060        }
2061        IrExpr::TypeCast(Box::new(IrTypeCast {
2062            expr,
2063            pg_type,
2064            tuple_shape,
2065        }))
2066    }
2067
2068    // ── Global variable resolution ────────────────────────────────────────────────
2069
2070    /// `scalar_type` is a PyQL-style type-name string built by the Python
2071    /// walker's `_pyql_type_name` (e.g. `"std::str"`, `"std::uuid"`,
2072    /// `"cal::local_date"`, `"default::Gender"`, `"array<std::str>"`) — never
2073    /// a bare Python class name. Mirrors every shape `_pyql_type_name` can
2074    /// produce for a `Global[T]` annotation.
2075    fn resolve_global_pg_type(&self, scalar_type: &str) -> String {
2076        // `datetime` and `std::datetime` name the same type.
2077        let builtin = match scalar_type.strip_prefix("std::").unwrap_or(scalar_type) {
2078            "str" => Some("text"),
2079            "int16" => Some("int2"),
2080            "int32" => Some("int4"),
2081            "int64" => Some("int8"),
2082            "float32" => Some("float4"),
2083            "float64" => Some("float8"),
2084            "decimal" => Some("numeric"),
2085            "bool" => Some("boolean"),
2086            "datetime" => Some("timestamptz"),
2087            "cal::local_datetime" => Some("timestamp"),
2088            "cal::local_date" => Some("date"),
2089            "cal::local_time" => Some("time"),
2090            "uuid" => Some("uuid"),
2091            "bytes" => Some("bytea"),
2092            "json" => Some("jsonb"),
2093            "duration" => Some("interval"),
2094            _ => None,
2095        };
2096        if let Some(t) = builtin {
2097            return t.to_string();
2098        }
2099        if let Some(inner) = scalar_type.strip_prefix("array<").and_then(|s| s.strip_suffix('>')) {
2100            return format!("{}[]", self.resolve_global_pg_type(inner));
2101        }
2102        if scalar_type.starts_with("tuple<") {
2103            return "jsonb".to_string();
2104        }
2105        if let Some(ed) = self.resolve_enum(scalar_type) {
2106            return format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name);
2107        }
2108        if self.resolve_named_tuple(scalar_type).is_some() {
2109            return "jsonb".to_string();
2110        }
2111        // Fall back to a registered custom scalar lookup.
2112        self.schema
2113            .scalars
2114            .iter()
2115            .find(|s| s.name == scalar_type || format!("{}::{}", s.module, s.name) == scalar_type)
2116            .map(|s| s.pg_type.clone())
2117            .unwrap_or_else(|| "text".to_string())
2118    }
2119
2120    /// When `global name` (or `global name { shape }`) appears as the top-level
2121    /// SELECT subject, inline the computed expression rather than going through a
2122    /// CTE — this returns a full object, not just its id.
2123    fn try_compile_global_select(
2124        &mut self,
2125        outer: &ast::SelectStmt,
2126        result: &Expr,
2127        _distinct: bool,
2128    ) -> Result<Option<IrStmt>, PyQLError> {
2129        let (global_name, shape_elements): (&str, &[ast::ShapeElement]) = match result {
2130            Expr::Global(name) => (name.as_str(), &[]),
2131            Expr::Shape(sh) => match sh.expr.as_ref() {
2132                Some(Expr::Global(name)) => (name.as_str(), sh.elements.as_slice()),
2133                _ => return Ok(None),
2134            },
2135            _ => return Ok(None),
2136        };
2137
2138        let global = self
2139            .schema
2140            .globals
2141            .iter()
2142            .find(|g| g.name == global_name || format!("{}::{}", g.module, g.name) == global_name);
2143        let global = match global {
2144            Some(g) => g.clone(),
2145            None => return Ok(None),
2146        };
2147        let computed_expr = match global.computed_expr {
2148            Some(e) => e,
2149            None => return Ok(None), // scalar session global — fall through
2150        };
2151
2152        let inner_ast = crate::parse::parse(&computed_expr)?;
2153        let inner_sel = match inner_ast {
2154            Stmt::Select(sel) => sel,
2155            _ => return Ok(None),
2156        };
2157
2158        let merged_result = if shape_elements.is_empty() {
2159            inner_sel.result.clone()
2160        } else {
2161            Expr::Shape(Box::new(ast::ShapeExpr {
2162                expr: Some(inner_sel.result.clone()),
2163                elements: shape_elements.to_vec(),
2164                marker_offset: None,
2165            }))
2166        };
2167
2168        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2169            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2170                left: a.clone(),
2171                op: ast::BinOpKind::And,
2172                right: b.clone(),
2173            }))),
2174            (Some(a), None) => Some(a.clone()),
2175            (None, b) => b.clone(),
2176        };
2177
2178        let merged = ast::SelectStmt {
2179            result: merged_result,
2180            filter: merged_filter,
2181            order_by: if outer.order_by.is_empty() {
2182                inner_sel.order_by.clone()
2183            } else {
2184                outer.order_by.clone()
2185            },
2186            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2187            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2188            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2189        };
2190
2191        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2192        Ok(Some(ir))
2193    }
2194
2195    /// Reading one field off a free object. A free shape is a real object
2196    /// type, so `{ device := d { id }, … }.device` is an ordinary
2197    /// path step through a pointer and yields whatever that pointer holds —
2198    /// an object stays an object. Extracting it out of the jsonb the free
2199    /// object would otherwise build flattens it back to raw JSON. Any field
2200    /// left unread still runs: a mutation among them is a data-modifying CTE,
2201    /// which Postgres executes whether or not the outer query reads it.
2202    fn project_free_object_field(expr: IrExpr, field: &str) -> IrExpr {
2203        if let IrExpr::NamedTuple {
2204            fields,
2205            is_free_object: true,
2206        } = &expr
2207            && let Some((_, value)) = fields.iter().find(|(name, _)| name == field)
2208        {
2209            return value.clone();
2210        }
2211        IrExpr::JsonbField {
2212            expr: Box::new(expr),
2213            field: field.to_string(),
2214        }
2215    }
2216
2217    /// The operands of a union written entirely of relative paths, which are
2218    /// correlated to the enclosing row and so cannot be hoisted.
2219    fn union_of_relative_paths(expr: &Expr) -> Option<Vec<ast::Path>> {
2220        fn walk(expr: &Expr, out: &mut Vec<ast::Path>) -> bool {
2221            match expr {
2222                Expr::Union(a, b) => walk(a, out) && walk(b, out),
2223                Expr::Path(p) if p.partial => {
2224                    out.push(p.clone());
2225                    true
2226                }
2227                _ => false,
2228            }
2229        }
2230        if !matches!(expr, Expr::Union(_, _)) {
2231            return None;
2232        }
2233        let mut out = vec![];
2234        walk(expr, &mut out).then_some(out)
2235    }
2236
2237    /// `([is Conditional].configs ?? [is Loop].configs)` — the operands of a
2238    /// coalesce, when every one is a path off the enclosing row. Unlike a
2239    /// union each operand but the first only contributes when the ones before
2240    /// it are empty, which is what the caller guards them with.
2241    fn coalesce_of_relative_paths(expr: &Expr) -> Option<Vec<ast::Path>> {
2242        fn walk(expr: &Expr, out: &mut Vec<ast::Path>) -> bool {
2243            match expr {
2244                Expr::BinOp(b) if b.op == ast::BinOpKind::Coalesce => walk(&b.left, out) && walk(&b.right, out),
2245                Expr::Path(p) if p.partial => {
2246                    out.push(p.clone());
2247                    true
2248                }
2249                _ => false,
2250            }
2251        }
2252        if !matches!(expr, Expr::BinOp(b) if b.op == ast::BinOpKind::Coalesce) {
2253            return None;
2254        }
2255        let mut out = vec![];
2256        walk(expr, &mut out).then_some(out)
2257    }
2258
2259    /// `(select Licence filter …) { id }` — a shape written after a
2260    /// parenthesised sub-select rather than inside it.
2261    ///
2262    /// The two mean the same thing, so the shape is pushed onto the inner
2263    /// statement's own result and the whole thing compiled as the select it
2264    /// already was. A `with` block is carried through to its inner statement.
2265    fn shape_over_subquery(&mut self, sh: &ast::ShapeExpr) -> Result<Option<IrExpr>, PyQLError> {
2266        fn push_shape(stmt: &Stmt, elements: &[ShapeElement]) -> Option<Stmt> {
2267            match stmt {
2268                Stmt::Select(sel) => {
2269                    if matches!(sel.result, Expr::Shape(_)) {
2270                        return None;
2271                    }
2272                    let mut shaped = sel.clone();
2273                    shaped.result = Expr::Shape(Box::new(ast::ShapeExpr {
2274                        expr: Some(sel.result.clone()),
2275                        elements: elements.to_vec(),
2276                        marker_offset: None,
2277                    }));
2278                    Some(Stmt::Select(shaped))
2279                }
2280                Stmt::With(w) => {
2281                    let inner = push_shape(&w.stmt, elements)?;
2282                    let mut carried = w.clone();
2283                    carried.stmt = Box::new(inner);
2284                    Some(Stmt::With(carried))
2285                }
2286                _ => None,
2287            }
2288        }
2289        let Some(Expr::SubQuery(stmt)) = sh.expr.as_ref() else {
2290            return Ok(None);
2291        };
2292        let Some(shaped) = push_shape(stmt.as_ref(), &sh.elements) else {
2293            return Ok(None);
2294        };
2295        let single = matches!(
2296            innermost_select(&shaped).and_then(|s| s.limit.as_ref()),
2297            Some(Expr::Literal(ast::Literal::Int(1)))
2298        );
2299        let IrStmt::Select(select) = self.compile_stmt(&shaped)? else {
2300            return Ok(None);
2301        };
2302        if !matches!(select.rows.as_slice(), [IrRowSource::Bound { .. }]) {
2303            return Ok(None);
2304        }
2305        Ok(Some(if single {
2306            IrExpr::ObjectSubquery(Box::new(select))
2307        } else {
2308            IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(Box::new(select))))
2309        }))
2310    }
2311
2312    /// `(select … limit 1).account { id, name }` — a shape written on what a
2313    /// projection off a sub-select lands on. Returns the sub-statement and
2314    /// the field chain, so the shape can ride along with the splice instead
2315    /// of being rejected as a shape in expression position.
2316    fn shape_over_subquery_projection(sh: &ast::ShapeExpr) -> Option<(&Stmt, Vec<String>)> {
2317        let inner = sh.expr.as_ref()?;
2318        if !matches!(inner, Expr::FieldAccess { .. }) {
2319            return None;
2320        }
2321        let (base, fields) = Self::peel_field_access_chain(inner);
2322        match base {
2323            Expr::SubQuery(stmt) => Some((stmt.as_ref(), fields)),
2324            _ => None,
2325        }
2326    }
2327
2328    /// Peel nested `Expr::FieldAccess` layers (`X.a.b` parses as
2329    /// `FieldAccess{FieldAccess{X, "a"}, "b"}`) into the innermost root
2330    /// expression plus the ordered chain of field names.
2331    /// The junction tables `dml` writes, mapped to the CTE the emitter names
2332    /// for each append (`{cte}__ml_add_{i}`, see `emit_ml_append_cte`). A walk
2333    /// off that mutation reads them from there, because Postgres shows a
2334    /// sibling CTE's inserts nowhere else. Only appends carrying no link
2335    /// properties qualify: the append CTE returns just the two id columns, so
2336    /// a walk reading `@prop` off one would name a column it does not have.
2337    fn junction_overrides_for(dml: &IrStmt, cte_name: &str) -> HashMap<String, JunctionReadOverride> {
2338        let appends = match dml {
2339            IrStmt::Insert(ins) => &ins.multi_link_appends,
2340            IrStmt::Update(upd) => &upd.multi_link_appends,
2341            _ => return HashMap::new(),
2342        };
2343        appends
2344            .iter()
2345            .enumerate()
2346            .filter(|(_, append)| append.values.link_props.is_empty())
2347            .map(|(i, append)| {
2348                // The targets themselves may also have been inserted by this
2349                // statement, in which case they are only in their own CTE too.
2350                let targets = match &append.values.source {
2351                    IrMultiLinkValueSource::CteRef(target_cte) => Some(format!("@cte:{target_cte}")),
2352                    _ => None,
2353                };
2354                (
2355                    append.junction_table.clone(),
2356                    JunctionReadOverride {
2357                        junction: format!("@cte:{cte_name}__ml_add_{i}"),
2358                        targets,
2359                    },
2360                )
2361            })
2362            .collect()
2363    }
2364
2365    /// `(select …).provider[is Individual].staff` — a walk off a sub-select
2366    /// that includes a step with no expression form of its own. The select is
2367    /// hoisted into the statement's own WITH and the walk re-rooted at that
2368    /// binding, which is the `with i := (select …) select i.provider[is …].staff`
2369    /// spelling that already compiles.
2370    ///
2371    /// `None` when this is not that shape — in particular a walk made only of
2372    /// `.field` steps, which the `FieldAccess` arm already handles and must go
2373    /// on handling: re-rooting one of those reads the trailing field as jsonb
2374    /// off an object id instead of as a column.
2375    fn try_compile_walk_off_subquery(
2376        &mut self,
2377        expr: &Expr,
2378        ctx: Option<(&TypeDescriptor, &str)>,
2379    ) -> Result<Option<IrExpr>, PyQLError> {
2380        if !matches!(expr, Expr::PathStepOn { .. } | Expr::FieldAccess { .. }) {
2381            return Ok(None);
2382        }
2383        let (base, steps) = Self::peel_path_step_chain(expr);
2384        if !steps.iter().any(|step| !matches!(step, ast::PathStep::Name(_))) {
2385            return Ok(None);
2386        }
2387        let Expr::SubQuery(inner_stmt) = base else {
2388            return Ok(None);
2389        };
2390        // Bound exactly as a `with` alias would be, not via `compile_stmt`.
2391        let inner = compile_cte_binding(self, &Expr::SubQuery(inner_stmt.clone()))?;
2392        let cte_name = self.fresh_nested_cte_name();
2393        let overrides = Self::junction_overrides_for(&inner, &cte_name);
2394        let type_name = self.register_cte(&cte_name, &inner);
2395        self.hoisted_ctes.push(IrCteDef {
2396            name: cte_name.clone(),
2397            stmt: inner,
2398            type_name,
2399            correlated_to: None,
2400        });
2401        let mut path_steps = vec![ast::PathStep::Name(cte_name)];
2402        path_steps.extend(steps);
2403        let path = ast::Path {
2404            steps: path_steps,
2405            partial: false,
2406        };
2407        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
2408        let ir = match ctx {
2409            Some((td, alias)) => self.compile_path(&path, td, alias),
2410            None => self.compile_free_path(&path),
2411        };
2412        self.junction_read_overrides = previous;
2413        Ok(Some(ir?))
2414    }
2415
2416    /// Peel a mixed `.field` / `[is T]` / `@prop` / `.<backlink` walk off a
2417    /// base that is not itself a path, outermost step last. The counterpart of
2418    /// `peel_field_access_chain` for a chain that contains a step with no
2419    /// expression form of its own (see `ast::Expr::PathStepOn`).
2420    fn peel_path_step_chain(expr: &Expr) -> (&Expr, Vec<ast::PathStep>) {
2421        let mut steps = Vec::new();
2422        let mut current = expr;
2423        loop {
2424            match current {
2425                Expr::FieldAccess { expr: inner, field } => {
2426                    steps.push(ast::PathStep::Name(field.clone()));
2427                    current = inner;
2428                }
2429                Expr::PathStepOn { expr: inner, step } => {
2430                    steps.push((**step).clone());
2431                    current = inner;
2432                }
2433                _ => break,
2434            }
2435        }
2436        steps.reverse();
2437        (current, steps)
2438    }
2439
2440    fn peel_field_access_chain(expr: &Expr) -> (&Expr, Vec<String>) {
2441        let mut fields = Vec::new();
2442        let mut current = expr;
2443        while let Expr::FieldAccess { expr: inner, field } = current {
2444            fields.push(field.clone());
2445            current = inner;
2446        }
2447        fields.reverse();
2448        (current, fields)
2449    }
2450
2451    /// Resolve `expr` to the inner `select Type filter ...` statement it
2452    /// stands for, if any — either a literal subquery (`(select Type filter
2453    /// ...)`) or a computed global whose defining expression is such a
2454    /// select. Only bare object-type results are recognized (`select Type
2455    /// ...`, not `select Type { shape }` or a free expression) — that's the
2456    /// only shape `.field` access after it can be spliced onto as an
2457    /// additional path step.
2458    fn resolve_field_owner_select(&self, expr: &Expr) -> Option<ast::SelectStmt> {
2459        let sel = match expr {
2460            Expr::SubQuery(stmt) => match stmt.as_ref() {
2461                Stmt::Select(sel) => sel.clone(),
2462                _ => return None,
2463            },
2464            Expr::Global(name) => {
2465                let global = self
2466                    .schema
2467                    .globals
2468                    .iter()
2469                    .find(|g| g.name == *name || format!("{}::{}", g.module, g.name) == *name)?;
2470                let computed_expr = global.computed_expr.as_ref()?;
2471                match crate::parse::parse(computed_expr).ok()? {
2472                    Stmt::Select(sel) => sel,
2473                    _ => return None,
2474                }
2475            }
2476            _ => return None,
2477        };
2478        // `(select detached T filter … limit 1).field` — at the top level the
2479        // marker says nothing a field access changes, and the path underneath
2480        // is the subject to splice the chain onto.
2481        let mut sel = sel;
2482        if let Expr::Detached(inner) = &sel.result {
2483            sel.result = inner.as_ref().clone();
2484        }
2485        match &sel.result {
2486            Expr::Path(p) if !p.partial => Some(sel),
2487            _ => None,
2488        }
2489    }
2490
2491    /// `global name.field` or `(select Type filter ...).field` (and deeper
2492    /// chains like `.link.field`) used as the top-level SELECT subject:
2493    /// rather than treating `.field` as jsonb extraction on an opaque value
2494    /// (which only makes sense for tuple-typed properties — see
2495    /// `resolve_property_tuple_shape`), splice the field chain onto the
2496    /// inner select as additional path steps and recompile as an ordinary
2497    /// path-select. Mirrors `try_compile_global_select`'s filter/modifier
2498    /// merge, generalized to a field-access result instead of a bare/shape
2499    /// global reference.
2500    /// `(select T filter … limit 1).a.b { … }` compiled as though it were
2501    /// written `with head := (select T filter … limit 1) select head.a.b { … }`:
2502    /// the head becomes a binding of its own, so its own row count stays on it
2503    /// and the walk off it keeps every row it reaches.
2504    fn compile_walk_off_bound_head(
2505        &mut self,
2506        outer: &ast::SelectStmt,
2507        inner_sel: &ast::SelectStmt,
2508        fields: &[String],
2509        trailing_shape: &[ShapeElement],
2510    ) -> Result<Option<IrStmt>, PyQLError> {
2511        let head = Expr::SubQuery(Box::new(Stmt::Select(inner_sel.clone())));
2512        let inner = compile_cte_binding(self, &head)?;
2513        let cte_name = self.fresh_nested_cte_name();
2514        let overrides = Self::junction_overrides_for(&inner, &cte_name);
2515        let type_name = self.register_cte(&cte_name, &inner);
2516        self.hoisted_ctes.push(IrCteDef {
2517            name: cte_name.clone(),
2518            stmt: inner,
2519            type_name,
2520            correlated_to: None,
2521        });
2522        let mut steps = vec![ast::PathStep::Name(cte_name)];
2523        steps.extend(fields.iter().cloned().map(ast::PathStep::Name));
2524        let walk = Expr::Path(ast::Path { steps, partial: false });
2525        let result = if trailing_shape.is_empty() {
2526            walk
2527        } else {
2528            Expr::Shape(Box::new(ast::ShapeExpr {
2529                expr: Some(walk),
2530                elements: trailing_shape.to_vec(),
2531                marker_offset: None,
2532            }))
2533        };
2534        let rerooted = ast::SelectStmt {
2535            result,
2536            filter: outer.filter.clone(),
2537            order_by: outer.order_by.clone(),
2538            offset: outer.offset.clone(),
2539            limit: outer.limit.clone(),
2540            lock: outer.lock.clone(),
2541        };
2542        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
2543        let ir = self.compile_stmt(&Stmt::Select(rerooted));
2544        self.junction_read_overrides = previous;
2545        Ok(Some(ir?))
2546    }
2547
2548    fn try_compile_field_access_select(
2549        &mut self,
2550        outer: &ast::SelectStmt,
2551        result: &Expr,
2552    ) -> Result<Option<IrStmt>, PyQLError> {
2553        // `(select T).chapter { … }` — the parser folds a trailing shape around
2554        // the whole field access, so the chain has to be peeled out of it or
2555        // the select reads as one with no type for a subject.
2556        let (result, trailing_shape): (&Expr, &[ShapeElement]) = match result {
2557            Expr::Shape(sh) => match sh.expr.as_ref() {
2558                Some(inner @ Expr::FieldAccess { .. }) => (inner, sh.elements.as_slice()),
2559                _ => (result, &[]),
2560            },
2561            other => (other, &[]),
2562        };
2563        let (root, fields) = Self::peel_field_access_chain(result);
2564        if fields.is_empty() {
2565            return Ok(None);
2566        }
2567        let inner_sel = match self.resolve_field_owner_select(root) {
2568            Some(sel) => sel,
2569            None => return Ok(None),
2570        };
2571        let Expr::Path(type_path) = &inner_sel.result else {
2572            return Ok(None);
2573        };
2574        // A sub-select's own `limit`/`offset` counts its rows, not the walk's:
2575        // `(select Cart filter .id = $c limit 1).applied_promotions` is every
2576        // promotion of that one cart. Spliced onto the walk the count lands on
2577        // the result instead and takes one promotion, so the head is bound on
2578        // its own and walked from there — the `with` spelling, which scopes it.
2579        if inner_sel.limit.is_some() || inner_sel.offset.is_some() {
2580            return self.compile_walk_off_bound_head(outer, &inner_sel, &fields, trailing_shape);
2581        }
2582        let mut steps = type_path.steps.clone();
2583        let field_count = fields.len();
2584        steps.extend(fields.into_iter().map(ast::PathStep::Name));
2585        let merged_result = Expr::Path(ast::Path { steps, partial: false });
2586
2587        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2588            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2589                left: a.clone(),
2590                op: ast::BinOpKind::And,
2591                right: b.clone(),
2592            }))),
2593            (Some(a), None) => Some(a.clone()),
2594            (None, b) => b.clone(),
2595        };
2596
2597        let merged = ast::SelectStmt {
2598            result: merged_result,
2599            filter: merged_filter,
2600            order_by: if outer.order_by.is_empty() {
2601                inner_sel.order_by.clone()
2602            } else {
2603                outer.order_by.clone()
2604            },
2605            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2606            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2607            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2608        };
2609
2610        // The inner select's own filter and ordering speak about its subject,
2611        // not about what the field chain projects off it: `(select Individual
2612        // filter .id = $a).credentials.password` filters Individuals, not
2613        // Credentials. `tail` is what pins them there. Only safe when the
2614        // outer select contributed no filter of its own — the merge folds
2615        // both into one clause, and an outer filter does belong at the end.
2616        // An outer `order by` belongs at the end too, so it rides along
2617        // separately rather than costing the inner filter its subject.
2618        if outer.filter.is_none() {
2619            let merged = ast::SelectStmt {
2620                order_by: inner_sel.order_by.clone(),
2621                ..merged
2622            };
2623            let Expr::Path(merged_path) = &merged.result else {
2624                unreachable!("built as a path just above")
2625            };
2626            let ps = self.compile_path_select_with_tail(
2627                &merged,
2628                merged_path,
2629                trailing_shape,
2630                false,
2631                field_count,
2632                &outer.order_by,
2633            )?;
2634            return Ok(Some(IrStmt::PathSelect(ps)));
2635        }
2636
2637        let merged = if trailing_shape.is_empty() {
2638            merged
2639        } else {
2640            ast::SelectStmt {
2641                result: Expr::Shape(Box::new(ast::ShapeExpr {
2642                    expr: Some(merged.result.clone()),
2643                    elements: trailing_shape.to_vec(),
2644                    marker_offset: None,
2645                })),
2646                ..merged
2647            }
2648        };
2649        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2650        Ok(Some(ir))
2651    }
2652
2653    fn try_compile_alias_select(
2654        &mut self,
2655        outer: &ast::SelectStmt,
2656        result: &Expr,
2657        distinct: bool,
2658    ) -> Result<Option<IrStmt>, PyQLError> {
2659        // Extract the bare path name and any outer shape elements.
2660        let (path_name, shape_elements): (&str, &[ast::ShapeElement]) = match result {
2661            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
2662                if let ast::PathStep::Name(n) = &p.steps[0] {
2663                    (n.as_str(), &[])
2664                } else {
2665                    return Ok(None);
2666                }
2667            }
2668            Expr::Shape(sh) => match sh.expr.as_ref() {
2669                Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => {
2670                    if let ast::PathStep::Name(n) = &p.steps[0] {
2671                        (n.as_str(), sh.elements.as_slice())
2672                    } else {
2673                        return Ok(None);
2674                    }
2675                }
2676                _ => return Ok(None),
2677            },
2678            _ => return Ok(None),
2679        };
2680
2681        // Match against schema aliases (bare name or module::name).
2682        let alias = self
2683            .schema
2684            .aliases
2685            .iter()
2686            .find(|a| a.name == path_name || format!("{}::{}", a.module, a.name) == path_name);
2687        let alias = match alias {
2688            Some(a) => a.clone(),
2689            None => return Ok(None),
2690        };
2691
2692        let inner_ast = crate::parse::parse(&alias.expr)?;
2693        let inner_sel = match inner_ast {
2694            Stmt::Select(sel) => sel,
2695            _ => return Err(self.type_err(&format!("alias '{}' expression must be a select statement", alias.name))),
2696        };
2697
2698        // Merge outer shape / filter / modifiers over the alias's select.
2699        // `inner_sel.result` is itself an `Expr::Shape` whenever the
2700        // alias's own body declares a shape (e.g. `select Type { field }
2701        // order by ... limit ...`, a legitimate, documented pattern — an
2702        // alias's own body may combine a shape with modifiers) — wrapping
2703        // it wholesale as the outer shape's own `expr` would nest a Shape
2704        // inside a Shape, which the compiler's SELECT-subject resolution
2705        // rejects ("expected a type name as SELECT subject", confirmed
2706        // live). The outer shape must bind to the same underlying *type*
2707        // reference the alias's own shape does, not to the alias's shape
2708        // node itself — so unwrap through it first.
2709        let inner_base = match &inner_sel.result {
2710            Expr::Shape(sh) => sh.expr.clone(),
2711            other => Some(other.clone()),
2712        };
2713        let merged_result = if shape_elements.is_empty() {
2714            inner_sel.result.clone()
2715        } else {
2716            Expr::Shape(Box::new(ast::ShapeExpr {
2717                expr: inner_base,
2718                elements: shape_elements.to_vec(),
2719                marker_offset: None,
2720            }))
2721        };
2722
2723        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2724            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2725                left: a.clone(),
2726                op: ast::BinOpKind::And,
2727                right: b.clone(),
2728            }))),
2729            (Some(a), None) => Some(a.clone()),
2730            (None, b) => b.clone(),
2731        };
2732
2733        let merged = ast::SelectStmt {
2734            result: merged_result,
2735            filter: merged_filter,
2736            order_by: if outer.order_by.is_empty() {
2737                inner_sel.order_by.clone()
2738            } else {
2739                outer.order_by.clone()
2740            },
2741            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2742            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2743            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2744        };
2745
2746        let _ = distinct; // alias selects honour the outer distinct if applied
2747        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2748        Ok(Some(ir))
2749    }
2750
2751    fn compile_global(&mut self, raw_name: &str) -> Result<IrExpr, PyQLError> {
2752        let global = self
2753            .schema
2754            .globals
2755            .iter()
2756            .find(|g| g.name == raw_name || format!("{}::{}", g.module, g.name) == raw_name);
2757        let global = global
2758            .ok_or_else(|| {
2759                PyQLError::Resolution(PyQLResolutionError::UnknownField(PyQLUnknownFieldError {
2760                    message: format!("unknown global: {:?}", raw_name),
2761                    position: Position { line: 0, col: 0 },
2762                }))
2763            })?
2764            .clone();
2765
2766        let qualified = format!("{}::{}", global.module, global.name);
2767        let cte_name = format!("__global__{}", qualified);
2768
2769        if let Some(computed_expr) = global.computed_expr {
2770            // Computed global — check for duplicate before compiling
2771            if self.global_ctes.iter().any(|g| g.cte_name() == cte_name) {
2772                return Ok(IrExpr::GlobalRef { cte_name });
2773            }
2774            // Recursively compile the PyQL expression (shares params and global_ctes)
2775            let inner_ast = crate::parse::parse(&computed_expr)?;
2776            let inner_stmt = self.compile_stmt(&inner_ast)?;
2777            self.global_ctes
2778                .push(IrGlobalCte::Computed(Box::new(IrComputedGlobalCte {
2779                    cte_name: cte_name.clone(),
2780                    qualified_name: qualified,
2781                    stmt: inner_stmt,
2782                })));
2783            Ok(IrExpr::GlobalRef { cte_name })
2784        } else if self.in_fn_body {
2785            // Inside a function body there is no parameter to bind, so the
2786            // value is read out of the `__pylon_json_globals__` argument the
2787            // caller packs.
2788            let pg_type = self.resolve_global_pg_type(&global.scalar_type);
2789            self.used_globals_arg = true;
2790            // Wrapped in a cast rather than returned bare: `RawSql` carries no
2791            // type, so overload resolution fell back to text and picked the
2792            // `str` `find` for an `array<str>` global (`strpos(text[], text)
2793            // does not exist`).
2794            Ok(IrExpr::TypeCast(Box::new(super::IrTypeCast {
2795                expr: IrExpr::RawSql(globals_arg_read(&qualified, &pg_type)),
2796                pg_type,
2797                tuple_shape: None,
2798            })))
2799        } else {
2800            // Session global — allocate parameter slot
2801            let pg_type = self.resolve_global_pg_type(&global.scalar_type);
2802            let param_name = format!("__global__{}", qualified);
2803            let index = self.param_index(&param_name);
2804            // Register CTE only once
2805            if !self.global_ctes.iter().any(|g| g.cte_name() == cte_name) {
2806                self.global_ctes.push(IrGlobalCte::Session(IrSessionGlobalCte {
2807                    cte_name,
2808                    qualified_name: qualified,
2809                    param_index: index,
2810                    pg_type: pg_type.clone(),
2811                }));
2812            }
2813            Ok(IrExpr::GlobalParam { index, pg_type })
2814        }
2815    }
2816
2817    // ── Schema lookups ────────────────────────────────────────────────────────────
2818
2819    /// The object type a WITH binding names, if it binds one — object type
2820    /// strings are qualified, free/scalar bindings' are not.
2821    fn cte_object_type(&self, name: &str) -> Option<String> {
2822        self.cte_types.get(name).filter(|t| t.contains("::")).cloned()
2823    }
2824
2825    /// A path reaching a property stands for the values it finds, and a row
2826    /// whose property is unset finds none — so that row contributes nothing
2827    /// rather than a NULL of its own: `select Webhook.headers`, over two
2828    /// webhooks one of which has none, is *one* result.
2829    ///
2830    /// Every path select, not only the statement's own: aggregated into an
2831    /// array (`.<account[is Webhook].headers`) the same empty used to be a
2832    /// NULL element, which Postgres refuses outright for an array-typed one
2833    /// ("cannot accumulate null arrays"). A link step already behaves this
2834    /// way — its join drops the row — and a shape is the opposite case by
2835    /// design: there the object is the result, so `Webhook { headers }` still
2836    /// reads the unset one as `None`.
2837    fn drop_empty_results(&self, path_select: &mut IrPathSelect) {
2838        let IrPathResult::Scalar(expr, _) = &path_select.result else {
2839            return;
2840        };
2841        if !self.result_can_be_empty(path_select, expr) {
2842            return;
2843        }
2844        let guard = ir_is_not_null(expr.clone());
2845        path_select.filter = and_conditions(path_select.filter.take(), vec![guard]);
2846    }
2847
2848    /// Whether a path's scalar result can be an empty set at all: a jsonb
2849    /// member of a tuple property and a computed pointer always can, a
2850    /// stored property only when its own declaration is optional — guarding
2851    /// a required one would add a condition to every path select that can
2852    /// never be false. A value computed from the row (an aggregate, a
2853    /// literal, a test) is not a set the path reaches and is never empty.
2854    fn result_can_be_empty(&self, path_select: &IrPathSelect, expr: &IrExpr) -> bool {
2855        match expr {
2856            IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::Subquery(_) => true,
2857            IrExpr::TypeCast(cast) => self.result_can_be_empty(path_select, &cast.expr),
2858            IrExpr::ColumnRef { alias, column, .. } => self
2859                .path_step_type(path_select, alias)
2860                .and_then(|td| td.properties.iter().find(|p| p.name == *column))
2861                // An alias this doesn't resolve (a CTE-rooted path) keeps the
2862                // set semantics rather than the column's: dropping an empty
2863                // is the rule, and the guard is what expresses it.
2864                .is_none_or(|property| property.nullable),
2865            _ => false,
2866        }
2867    }
2868
2869    /// The type an alias inside a path stands for — the root, or whatever a
2870    /// join step reached.
2871    fn path_step_type(&self, path_select: &IrPathSelect, alias: &str) -> Option<&'a TypeDescriptor> {
2872        let source = std::iter::once(&path_select.root)
2873            .chain(path_select.joins.iter().map(IrPathJoin::target))
2874            .find(|source| source.alias == alias)?;
2875        self.resolve_type(&source.type_name).ok()
2876    }
2877
2878    fn resolve_type(&self, name: &str) -> Result<&'a TypeDescriptor, PyQLError> {
2879        // Accept both "TypeName" and "module::TypeName"
2880        self.schema
2881            .types
2882            .iter()
2883            .find(|t| t.name == name || format!("{}::{}", t.module, t.name) == name)
2884            .ok_or_else(|| {
2885                PyQLError::Resolution(PyQLResolutionError::UnknownType(PyQLUnknownTypeError {
2886                    message: format!("unknown type '{name}'"),
2887                    position: Position { line: 0, col: 0 },
2888                }))
2889            })
2890    }
2891
2892    /// A schema enum, or failing that one the stdlib defines (`std::Endian`).
2893    /// The stdlib's are `&'static`, which coerces to `&'a` for any shorter
2894    /// `'a`, so both live behind one lookup and every site that recognizes
2895    /// enum member access picks them up without its own special case.
2896    fn resolve_enum(&self, name: &str) -> Option<&'a crate::schema::EnumDescriptor> {
2897        self.schema
2898            .enums
2899            .iter()
2900            .find(|e| e.name == name || format!("{}::{}", e.module, e.name) == name)
2901            .or_else(|| crate::stdlib::lookup_enum(name))
2902    }
2903
2904    /// Resolve a `Channel` by name (bare or `module::Name`) — used by
2905    /// `notify()` to find the declared payload shape its second argument
2906    /// must match.
2907    fn resolve_channel(&self, name: &str) -> Option<&'a crate::schema::ChannelDescriptor> {
2908        self.schema.find_channel(name)
2909    }
2910
2911    /// Resolve a registered custom scalar (`pylon.scalar(..., name=...)` or
2912    /// the `@pylon.scalar` decorator form) by name — used to recognize a
2913    /// cast target as that scalar's own PostgreSQL DOMAIN (see
2914    /// `resolve_cast_pg_type`); an anonymous (unregistered) scalar has no
2915    /// name reachable here at all, so it's never a valid cast target.
2916    fn resolve_scalar(&self, name: &str) -> Option<&'a crate::schema::ScalarDescriptor> {
2917        self.schema
2918            .scalars
2919            .iter()
2920            .find(|s| s.name == name || format!("{}::{}", s.module, s.name) == name)
2921    }
2922
2923    /// Resolve a registered (nominal) `@pylon.named_tuple` type by name — used only
2924    /// to recognize a cast target as a named tuple (member structure isn't
2925    /// validated here; the value is trusted the same way a plain `<json>` cast is).
2926    fn resolve_named_tuple(&self, name: &str) -> Option<&'a crate::schema::NamedTupleDescriptor> {
2927        self.schema
2928            .named_tuples
2929            .iter()
2930            .find(|nt| nt.name == name || format!("{}::{}", nt.module, nt.name) == name)
2931    }
2932
2933    /// Convert a schema-level `TupleMemberDescriptor` into a decode-time
2934    /// `JsonMember` — recurses for a nested tuple member, resolving a nested
2935    /// *nominal* member's own registered members too.
2936    fn tuple_member_to_json_member(&self, m: &crate::schema::TupleMemberDescriptor) -> crate::query::JsonMember {
2937        use crate::schema::TupleMemberKind;
2938        let kind = match &m.kind {
2939            TupleMemberKind::Scalar { pg_type } if pg_type == "numeric" => crate::query::JsonMemberKind::Decimal,
2940            TupleMemberKind::Scalar { .. } => crate::query::JsonMemberKind::Scalar,
2941            TupleMemberKind::Enum { module, name } => crate::query::JsonMemberKind::Enum {
2942                enum_type: format!("{}::{}", module, name),
2943            },
2944            TupleMemberKind::NamedTuple { module, name } => {
2945                let qname = format!("{}::{}", module, name);
2946                let nested_members = self
2947                    .resolve_named_tuple(&qname)
2948                    .map(|nt| {
2949                        nt.members
2950                            .iter()
2951                            .map(|mm| self.tuple_member_to_json_member(mm))
2952                            .collect()
2953                    })
2954                    .unwrap_or_default();
2955                crate::query::JsonMemberKind::Tuple {
2956                    type_name: Some(qname),
2957                    members: nested_members,
2958                }
2959            }
2960            TupleMemberKind::Tuple { members } => crate::query::JsonMemberKind::Tuple {
2961                type_name: None,
2962                members: members.iter().map(|mm| self.tuple_member_to_json_member(mm)).collect(),
2963            },
2964        };
2965        crate::query::JsonMember {
2966            key: m.name.clone(),
2967            kind,
2968        }
2969    }
2970
2971    /// Convert a parsed cast-target `ast::TupleTypeElement` into a decode-time
2972    /// `JsonMember` — the cast-syntax counterpart of `tuple_member_to_json_member`.
2973    fn ast_tuple_element_to_json_member(&self, elem: &ast::TupleTypeElement) -> crate::query::JsonMember {
2974        crate::query::JsonMember {
2975            key: elem.name.clone(),
2976            kind: self.ast_type_expr_to_json_member_kind(&elem.ty),
2977        }
2978    }
2979
2980    fn ast_type_expr_to_json_member_kind(&self, ty: &ast::TypeExpr) -> crate::query::JsonMemberKind {
2981        if let ast::TypeExpr::Tuple { elements } = ty {
2982            return crate::query::JsonMemberKind::Tuple {
2983                type_name: None,
2984                members: elements
2985                    .iter()
2986                    .map(|e| self.ast_tuple_element_to_json_member(e))
2987                    .collect(),
2988            };
2989        }
2990        let Some((module, name)) = ty.as_named() else {
2991            return crate::query::JsonMemberKind::Scalar;
2992        };
2993        let qname = match module {
2994            Some(m) => format!("{}::{}", m, name),
2995            None => name.to_string(),
2996        };
2997        if let Some(ed) = self.resolve_enum(&qname) {
2998            return crate::query::JsonMemberKind::Enum {
2999                enum_type: format!("{}::{}", ed.module, ed.name),
3000            };
3001        }
3002        if let Some(nt) = self.resolve_named_tuple(&qname) {
3003            return crate::query::JsonMemberKind::Tuple {
3004                type_name: Some(format!("{}::{}", nt.module, nt.name)),
3005                members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
3006            };
3007        }
3008        if matches!(type_expr_to_pg(ty).as_deref(), Ok("numeric")) {
3009            return crate::query::JsonMemberKind::Decimal;
3010        }
3011        crate::query::JsonMemberKind::Scalar
3012    }
3013
3014    /// Resolve a cast's own target-type shape for decode-time `ShapeNode`
3015    /// building — a structural `tuple<...>` cast resolves its elements
3016    /// directly; a nominal `<module::Name>` cast resolves via the registered
3017    /// `NamedTupleDescriptor`'s members. `None` for a plain scalar/enum
3018    /// cast target.
3019    ///
3020    /// An `array<tuple<…>>` cast resolves to its *element's* shape, the way a
3021    /// property of that type does (see `resolve_property_tuple_shape`): the
3022    /// array-ness lives on the cast's `pg_type`.
3023    fn resolve_tuple_cast_shape(&self, ty: &ast::TypeExpr) -> Option<TupleCastShape> {
3024        match ty {
3025            ast::TypeExpr::Array { element } => self.resolve_tuple_cast_shape(element),
3026            ast::TypeExpr::Tuple { elements } => Some(TupleCastShape {
3027                type_name: None,
3028                members: elements
3029                    .iter()
3030                    .map(|e| self.ast_tuple_element_to_json_member(e))
3031                    .collect(),
3032            }),
3033            _ => {
3034                let (module, name) = ty.as_named()?;
3035                let qname = match module {
3036                    Some(m) => format!("{}::{}", m, name),
3037                    None => name.to_string(),
3038                };
3039                let nt = self.resolve_named_tuple(&qname)?;
3040                Some(TupleCastShape {
3041                    type_name: Some(format!("{}::{}", nt.module, nt.name)),
3042                    members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
3043                })
3044            }
3045        }
3046    }
3047
3048    /// Render a cast target `TypeExpr` for error messages, e.g.
3049    /// `tuple<std::int64, std::str>` or `default::Point` — used by the
3050    /// compile-time tuple-index bounds check.
3051    fn type_expr_to_display_str(&self, ty: &ast::TypeExpr) -> String {
3052        match ty {
3053            ast::TypeExpr::Tuple { elements } => {
3054                let inner = elements
3055                    .iter()
3056                    .map(|e| match &e.name {
3057                        Some(n) => format!("{}: {}", n, self.type_expr_to_display_str(&e.ty)),
3058                        None => self.type_expr_to_display_str(&e.ty),
3059                    })
3060                    .collect::<Vec<_>>()
3061                    .join(", ");
3062                format!("tuple<{}>", inner)
3063            }
3064            ast::TypeExpr::Array { element } => format!("array<{}>", self.type_expr_to_display_str(element)),
3065            ast::TypeExpr::Named { module, name } => match module {
3066                Some(m) => format!("{}::{}", m, name),
3067                None => type_expr_to_pg(ty)
3068                    .map(|pg| pg_type_to_pyql(&pg).to_string())
3069                    .unwrap_or_else(|_| name.clone()),
3070            },
3071        }
3072    }
3073
3074    /// Resolve a property's tuple-type shape for decode-time `ShapeNode`
3075    /// building — a nominal `__nt__:module::Name` `pg_type` marker resolves
3076    /// via the registered `NamedTupleDescriptor`'s own members; a structural
3077    /// `pylon.Tuple[...]` property carries its own `tuple_members` directly.
3078    ///
3079    /// An `array<tuple<…>>` property resolves to its *element's* shape, since
3080    /// that is what one value of its `jsonb[]` column holds; the array-ness
3081    /// stays on `pg_type`, which every caller already has.
3082    fn resolve_property_tuple_shape(&self, prop: &PropertyDescriptor) -> Option<TupleCastShape> {
3083        let marker = prop.pg_type.strip_suffix("[]").unwrap_or(&prop.pg_type);
3084        if let Some(qname) = marker.strip_prefix("__nt__:") {
3085            let nt = self.resolve_named_tuple(qname)?;
3086            return Some(TupleCastShape {
3087                type_name: Some(qname.to_string()),
3088                members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
3089            });
3090        }
3091        prop.tuple_members.as_ref().map(|members| TupleCastShape {
3092            type_name: None,
3093            members: members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
3094        })
3095    }
3096
3097    /// Emit a bare `<pg_type>expr` scalar cast as a free-select statement — shared
3098    /// by the enum/named-tuple/structural-tuple cast cases in `compile_stmt`'s
3099    /// top-level `Expr::TypeCast` handling. For a structural tuple cast whose
3100    /// source is itself a tuple/named-tuple literal, applies each element's
3101    /// own cast by position (see `try_compile_tuple_literal_cast_ctx`)
3102    /// instead of jsonb-wrapping the raw uncast literal values.
3103    fn scalar_cast_free_select(
3104        &mut self,
3105        tc: &ast::TypeCast,
3106        pg_type: String,
3107        distinct: bool,
3108    ) -> Result<IrStmt, PyQLError> {
3109        let cast_expr = match &tc.ty {
3110            ast::TypeExpr::Tuple { elements } => {
3111                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
3112                match self.try_compile_tuple_literal_cast_ctx(elements, &tc.expr, None)? {
3113                    // The literal-decompose path already applies each element's own
3114                    // cast — still wrap in TypeCast so `tuple_shape` reaches SQL
3115                    // emission for decode-time ShapeNode building (jsonb_build_*
3116                    // already produces jsonb, so the outer `::jsonb` is a no-op).
3117                    Some(ir) => IrExpr::TypeCast(Box::new(IrTypeCast {
3118                        expr: ir,
3119                        pg_type,
3120                        tuple_shape,
3121                    })),
3122                    None => {
3123                        let inner = self.compile_expr_ctx(&tc.expr, None)?;
3124                        self.tuple_cast(inner, pg_type, tuple_shape)
3125                    }
3126                }
3127            }
3128            _ => {
3129                let inner = self.compile_expr_ctx(&tc.expr, None)?;
3130                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
3131                self.tuple_cast(inner, pg_type, tuple_shape)
3132            }
3133        };
3134        Ok(IrStmt::Select(IrSelect {
3135            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(cast_expr))],
3136            filter: None,
3137            order_by: vec![],
3138            offset: None,
3139            limit: None,
3140            distinct,
3141            dml_source: None,
3142            polymorphic: false,
3143            poly_implementors: vec![],
3144            poly_columns: vec![],
3145            lock: None,
3146        }))
3147    }
3148
3149    /// Resolve a cast's target `pg_type` string — shared by both `Expr::TypeCast`
3150    /// compile sites (`compile_free_expr`/`compile_expr`). A structural tuple
3151    /// always resolves to jsonb; an array resolves to its element's own pg_type
3152    /// with a `[]` suffix — a real Postgres array, not jsonb, so it decodes
3153    /// natively (the driver already returns a Python list) with no per-member
3154    /// shape-tracking needed the way tuples require; a named type checks enum,
3155    /// then registered named tuple, then falls back to the built-in
3156    /// scalar/pgvector/cal type list.
3157    fn resolve_cast_pg_type(&self, ty: &ast::TypeExpr) -> Result<String, PyQLError> {
3158        if matches!(ty, ast::TypeExpr::Tuple { .. }) {
3159            return Ok("jsonb".to_string());
3160        }
3161        if let ast::TypeExpr::Array { element } = ty {
3162            let element_pg = self.resolve_cast_pg_type(element)?;
3163            return Ok(format!("{}[]", element_pg));
3164        }
3165        let (module, name) = ty.as_named().expect("checked above: not Tuple/Array");
3166        let qname = match module {
3167            Some(m) => format!("{}::{}", m, name),
3168            None => name.to_string(),
3169        };
3170        if let Some(ed) = self.resolve_enum(&qname) {
3171            return Ok(format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name));
3172        }
3173        if self.resolve_named_tuple(&qname).is_some() {
3174            return Ok("jsonb".to_string());
3175        }
3176        // A registered custom scalar casts directly to its own DOMAIN (not
3177        // just its base type) — Postgres enforces the domain's CHECK right
3178        // at cast time, the same way it would on column assignment (see
3179        // `PropertyDescriptor.column_type`'s own doc comment for why this
3180        // is safe to do everywhere else too).
3181        if let Some(sd) = self.resolve_scalar(&qname) {
3182            return Ok(format!("{}.\"{}\"", crate::sql::pg_schema_str(&sd.module), sd.name));
3183        }
3184        type_expr_to_pg(ty)
3185    }
3186
3187    /// Cast one tuple-type element's source value to `target_ty` — recurses
3188    /// via `try_compile_tuple_literal_cast_ctx` when both the element's own
3189    /// type and its source value are themselves a nested tuple/named-tuple
3190    /// literal, so nesting applies per-element casts all the way down;
3191    /// otherwise a plain scalar/enum/nominal-named-tuple cast.
3192    fn compile_tuple_element_cast_ctx(
3193        &mut self,
3194        target_ty: &ast::TypeExpr,
3195        value: &Expr,
3196        ctx: Option<(&TypeDescriptor, &str)>,
3197    ) -> Result<IrExpr, PyQLError> {
3198        if let ast::TypeExpr::Tuple { elements } = target_ty
3199            && let Some(ir) = self.try_compile_tuple_literal_cast_ctx(elements, value, ctx)?
3200        {
3201            return Ok(ir);
3202        }
3203        let inner = self.compile_expr_ctx(value, ctx)?;
3204        let pg_type = self.resolve_cast_pg_type(target_ty)?;
3205        Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
3206            expr: inner,
3207            pg_type,
3208            tuple_shape: None,
3209        })))
3210    }
3211
3212    /// When casting a tuple/named-tuple *literal* to a structural tuple type,
3213    /// apply each target element's own cast to its corresponding source value
3214    /// by position — e.g. `<tuple<int64, str>>('1', 3)` must coerce '1' to
3215    /// int64 and 3 to str, not just jsonb-wrap the raw literal values
3216    /// unchanged. Returns `None` when the source isn't a literal tuple/named-
3217    /// tuple of matching arity (e.g. a `$param`) — the whole value already
3218    /// arrives pre-shaped in that case, so the caller's generic jsonb-cast
3219    /// path handles it instead.
3220    fn try_compile_tuple_literal_cast_ctx(
3221        &mut self,
3222        target_elements: &[ast::TupleTypeElement],
3223        source: &Expr,
3224        ctx: Option<(&TypeDescriptor, &str)>,
3225    ) -> Result<Option<IrExpr>, PyQLError> {
3226        let source_values: Vec<&Expr> = match source {
3227            Expr::Tuple(vals) if vals.len() == target_elements.len() => vals.iter().collect(),
3228            Expr::NamedTuple(fields) if fields.len() == target_elements.len() => {
3229                fields.iter().map(|(_, v)| v).collect()
3230            }
3231            _ => return Ok(None),
3232        };
3233        let named = target_elements.iter().all(|e| e.name.is_some());
3234        let mut casted = Vec::with_capacity(target_elements.len());
3235        for (elem, value) in target_elements.iter().zip(source_values) {
3236            casted.push(self.compile_tuple_element_cast_ctx(&elem.ty, value, ctx)?);
3237        }
3238        if named {
3239            let fields = target_elements
3240                .iter()
3241                .zip(casted)
3242                .map(|(e, v)| (e.name.clone().unwrap(), v))
3243                .collect();
3244            Ok(Some(IrExpr::NamedTuple {
3245                fields,
3246                is_free_object: false,
3247            }))
3248        } else {
3249            Ok(Some(IrExpr::Tuple(casted)))
3250        }
3251    }
3252
3253    /// When casting an array *literal* to `array<T>`, apply the element
3254    /// type's own cast to each element by position — e.g.
3255    /// `<array<int64>>['1', '3']` must coerce each string element to int64,
3256    /// not just emit a raw untyped `ARRAY[...]`. Reuses
3257    /// `compile_tuple_element_cast_ctx` for the per-element cast since
3258    /// casting "this value to this target type" is exactly the same
3259    /// operation regardless of whether the target is a tuple element or an
3260    /// array element (including decomposing a nested tuple-literal element).
3261    /// Returns `None` when the source isn't a literal array (e.g. a
3262    /// `$param` or a sub-select) — the caller's generic cast path handles
3263    /// those instead.
3264    fn try_compile_array_literal_cast_ctx(
3265        &mut self,
3266        element_ty: &ast::TypeExpr,
3267        source: &Expr,
3268        ctx: Option<(&TypeDescriptor, &str)>,
3269    ) -> Result<Option<IrExpr>, PyQLError> {
3270        let Expr::Array(elems) = source else { return Ok(None) };
3271        let casted = elems
3272            .iter()
3273            .map(|e| self.compile_tuple_element_cast_ctx(element_ty, e, ctx))
3274            .collect::<Result<Vec<_>, _>>()?;
3275        Ok(Some(IrExpr::Array(casted)))
3276    }
3277
3278    fn compile_enum_access(&self, type_ref: &str, variant: &str) -> Result<IrExpr, PyQLError> {
3279        let ed = self
3280            .resolve_enum(type_ref)
3281            .ok_or_else(|| self.type_err(&format!("unknown type '{}'", type_ref)))?;
3282        if !ed.members.iter().any(|m| m == variant) {
3283            return Err(self.type_err(&format!(
3284                "enum '{}::{}' has no member '{}'",
3285                ed.module, ed.name, variant
3286            )));
3287        }
3288        // A stdlib enum has no PostgreSQL enum type to cast to — the function
3289        // taking it switches on the label as text.
3290        let pg_type = if crate::stdlib::lookup_enum(&format!("{}::{}", ed.module, ed.name)).is_some() {
3291            "text".to_string()
3292        } else {
3293            format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name)
3294        };
3295        Ok(IrExpr::EnumLiteral {
3296            pg_type,
3297            variant: variant.to_string(),
3298        })
3299    }
3300
3301    /// The row source a name reads from: the binding it is bound to, if any,
3302    /// else the type's own table.
3303    fn row_source_table(&self, name: &str, td: &TypeDescriptor) -> String {
3304        if let Some(cte) = self.for_var_ctes.get(name) {
3305            return self.cte_source_table(cte);
3306        }
3307        if self.cte_object_type(name).is_some() {
3308            return self.cte_source_table(&self.cte_sql_name(name));
3309        }
3310        td.table.clone()
3311    }
3312
3313    /// The type a path's root names — a real type, or the object type a
3314    /// `with` binding stands for.
3315    ///
3316    /// A binding is a row source just like a type name, so anything that
3317    /// re-resolves a root after `compile_path_select` has already accepted it
3318    /// has to look it up the same way, or a bound alias reads as an unknown
3319    /// type.
3320    fn resolve_path_root(&self, root_name: &str) -> Result<&'a TypeDescriptor, PyQLError> {
3321        if let Some(qualified) = self.for_var_types.get(root_name) {
3322            return self.resolve_type(qualified);
3323        }
3324        match self.cte_types.get(root_name).filter(|t| t.contains("::")).cloned() {
3325            Some(bound) => self.resolve_type(&bound),
3326            None => self.resolve_type(root_name),
3327        }
3328    }
3329
3330    fn find_poly_implementors(&self, iface_qname: &str) -> Vec<IrPolyImplementor> {
3331        self.schema
3332            .types
3333            .iter()
3334            .filter(|t| !t.abstract_ && Self::is_or_implements(t, iface_qname))
3335            .map(|t| IrPolyImplementor {
3336                type_name: format!("{}::{}", t.module, t.name),
3337                table: t.table.clone(),
3338                module: t.module.clone(),
3339            })
3340            .collect()
3341    }
3342
3343    fn resolve_property<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t PropertyDescriptor> {
3344        td.properties.iter().find(|p| p.name == name)
3345    }
3346
3347    fn resolve_link<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t LinkDescriptor> {
3348        td.links.iter().find(|l| l.name == name)
3349    }
3350
3351    fn resolve_multilink<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t MultiLinkDescriptor> {
3352        td.multilinks.iter().find(|m| m.name == name)
3353    }
3354
3355    /// `@prop` read off the junction row of the multi-link currently in
3356    /// scope (`link_prop_scope`). Only its own modifiers put one there —
3357    /// anywhere else there is no junction row to read.
3358    fn compile_link_prop_ref(&mut self, prop_name: &str) -> Result<IrExpr, PyQLError> {
3359        let Some(scope) = self.link_prop_scope.last().cloned() else {
3360            return Err(self.type_err(&format!(
3361                "'@{prop_name}' is a link property, so it is only valid in the modifiers or shape of the \
3362                 link it belongs to, e.g. 'locators: {{ … }} filter @{prop_name}'"
3363            )));
3364        };
3365        let Some((through_qname, junction_alias)) = scope else {
3366            return Err(self.type_err(&format!(
3367                "this link has no link properties, so '@{prop_name}' cannot be read — \
3368                 declare the link with a `Through[...]` type to give it some"
3369            )));
3370        };
3371        let through_td = self.resolve_type(&through_qname)?;
3372        let Some(prop) = Self::resolve_property(through_td, prop_name) else {
3373            return Err(self.field_err(prop_name, &through_qname));
3374        };
3375        Ok(IrExpr::ColumnRef {
3376            alias: junction_alias,
3377            column: prop.name.clone(),
3378            pg_type: prop.pg_type.clone(),
3379        })
3380    }
3381
3382    /// Whether a link declared with `target` reaches the type named
3383    /// `current_qname` — directly, or because that type implements the
3384    /// interface the link points at. A link to an interface accepts every
3385    /// implementor, so a backlink from one is exactly as valid as a backlink
3386    /// from the interface itself.
3387    fn link_target_reaches(&self, target: &str, current_qname: &str) -> bool {
3388        if target == current_qname {
3389            return true;
3390        }
3391        self.resolve_type(current_qname)
3392            .is_ok_and(|td| td.interfaces.iter().any(|i| i == target))
3393    }
3394
3395    /// A computed pointer visible on `td` — its own, or one declared on an
3396    /// interface it implements. An interface's computeds are not copied into
3397    /// its implementors the way its stored pointers are, so without the
3398    /// second lookup `.account.tier` reported that `tier` was missing while
3399    /// suggesting `tier` (the suggester already searched interfaces).
3400    fn resolve_computed(&self, td: &TypeDescriptor, name: &str) -> Option<crate::schema::ComputedDescriptor> {
3401        if let Some(cd) = td.computed.iter().find(|c| c.name == name) {
3402            return Some(cd.clone());
3403        }
3404        td.interfaces
3405            .iter()
3406            .filter_map(|iface| self.resolve_type(iface).ok())
3407            .find_map(|itd| itd.computed.iter().find(|c| c.name == name))
3408            .cloned()
3409    }
3410
3411    // ── Statement dispatch ────────────────────────────────────────────────────────
3412
3413    fn compile_stmt(&mut self, stmt: &Stmt) -> Result<IrStmt, PyQLError> {
3414        match stmt {
3415            Stmt::Select(s) => {
3416                let (distinct, result) = match &s.result {
3417                    Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => (true, &u.operand),
3418                    // `detached` marks this select as independent of the one
3419                    // enclosing it. At the top level that is already true, but
3420                    // for a select nested in a filter it is the whole point:
3421                    // it is what lets `select X filter not exists (select
3422                    // detached X filter .k = X.k)` mean "no *other* X", rather
3423                    // than comparing the inner row with itself. Stripping the
3424                    // wrapper outright made that anti-join a tautology
3425                    // (`"t1"."label" = "t1"."label"`) with no error.
3426                    Expr::Detached(inner) => {
3427                        self.pending_detached = true;
3428                        (false, inner.as_ref())
3429                    }
3430                    other => (false, other),
3431                };
3432
3433                // `select assert_distinct(f(…)) { … }` — the function's rows,
3434                // with the assert run once over all of them: the select's own
3435                // filter narrows what comes back, not what is checked.
3436                if let Expr::Shape(sh) = result
3437                    && let Some(Expr::FunctionCall(assert)) = &sh.expr
3438                    && assert.module.as_deref().is_none_or(|m| m == "std")
3439                    && matches!(assert.name.as_str(), "assert_exists" | "assert_distinct")
3440                    && let [Expr::FunctionCall(inner)] = assert.args.as_slice()
3441                    && let Some(mut fs) = self.try_compile_fn_object_select(inner, &sh.elements, s, distinct)?
3442                {
3443                    let message = self.assert_message(assert, None)?;
3444                    let unchecked = ast::SelectStmt {
3445                        result: Expr::FunctionCall(inner.clone()),
3446                        filter: None,
3447                        order_by: vec![],
3448                        offset: None,
3449                        limit: None,
3450                        lock: None,
3451                    };
3452                    let every_row = self
3453                        .try_compile_fn_object_select(inner, &[], &unchecked, false)?
3454                        .ok_or_else(|| self.type_err("assert subject is not an object-returning function"))?;
3455                    let checked = IrExpr::FunctionCall(IrFunctionCall {
3456                        return_pg_type: None,
3457                        schema: Some("_pylon".to_string()),
3458                        name: assert.name.clone(),
3459                        args: std::iter::once(IrExpr::ArrayFromSelect(Box::new(IrArraySource::StmtColumn {
3460                            stmt: Box::new(IrStmt::FunctionSelect(every_row)),
3461                            column: "id".to_string(),
3462                        })))
3463                        .chain(message)
3464                        .collect(),
3465                        sql_template: None,
3466                    });
3467                    let check = IrExpr::BinOp(Box::new(IrBinOp {
3468                        left: IrExpr::FunctionCall(IrFunctionCall {
3469                            return_pg_type: None,
3470                            schema: None,
3471                            name: "cardinality".to_string(),
3472                            args: vec![checked],
3473                            sql_template: None,
3474                        }),
3475                        op: ast::BinOpKind::Ge,
3476                        right: IrExpr::Literal(IrLiteral::Int(0)),
3477                    }));
3478                    fs.filter = and_conditions(fs.filter, vec![check]);
3479                    return Ok(IrStmt::FunctionSelect(fs));
3480                }
3481                if let Some(flattened) = flatten_shape_subject(result) {
3482                    let mut flat = s.clone();
3483                    flat.result = match distinct {
3484                        true => Expr::UnaryOp(Box::new(ast::UnaryOp {
3485                            op: ast::UnaryOpKind::Distinct,
3486                            operand: flattened,
3487                        })),
3488                        false => flattened,
3489                    };
3490                    return self.compile_stmt(&Stmt::Select(flat));
3491                }
3492                if let Expr::Shape(sh) = result
3493                    && let Some(Expr::SubQuery(inner)) = &sh.expr
3494                    && let Stmt::Group(g) = inner.as_ref()
3495                {
3496                    return self.compile_group_projection(s, g, &sh.elements).map(IrStmt::Group);
3497                }
3498
3499                // `select alias_name [{ shape }]` — inline the alias expression.
3500                if let Some(ir) = self.try_compile_alias_select(s, result, distinct)? {
3501                    return Ok(ir);
3502                }
3503
3504                // `select global name [{ shape }]` — inline the computed expression.
3505                if let Some(ir) = self.try_compile_global_select(s, result, distinct)? {
3506                    return Ok(ir);
3507                }
3508
3509                // `global name.field` / `(select Type filter ...).field` — splice the
3510                // field access onto the inner type-select as an additional path step.
3511                if let Some(ir) = self.try_compile_field_access_select(s, result)? {
3512                    return Ok(ir);
3513                }
3514
3515                // select fn() { shape } — user-defined object-returning function with shape.
3516                if let Expr::Shape(sh) = result
3517                    && let Some(Expr::FunctionCall(fc)) = sh.expr.as_ref()
3518                    && let Some(ir) = self.try_compile_fn_object_select(fc, &sh.elements, s, distinct)?
3519                {
3520                    return Ok(IrStmt::FunctionSelect(ir));
3521                }
3522
3523                // select fn() — bare user-defined object-returning function (no shape).
3524                if let Expr::FunctionCall(fc) = result
3525                    && let Some(ir) = self.try_compile_fn_object_select(fc, &[], s, distinct)?
3526                {
3527                    return Ok(IrStmt::FunctionSelect(ir));
3528                }
3529
3530                // select vector::search(Type, $vec) { object { … }, distance }
3531                if let Expr::Shape(sh) = result
3532                    && let Some(Expr::FunctionCall(fc)) = sh.expr.as_ref()
3533                {
3534                    if let Some(ir) = self.try_compile_vector_search(fc, &sh.elements, s)? {
3535                        return Ok(IrStmt::VectorSearch(ir));
3536                    }
3537                    if let Some(ir) = self.try_compile_fts_search(fc, &sh.elements, s)? {
3538                        return Ok(IrStmt::FtsSearch(ir));
3539                    }
3540                }
3541                // bare vector::search / fts::search without shape
3542                if let Expr::FunctionCall(fc) = result {
3543                    if let Some(ir) = self.try_compile_vector_search(fc, &[], s)? {
3544                        return Ok(IrStmt::VectorSearch(ir));
3545                    }
3546                    if let Some(ir) = self.try_compile_fts_search(fc, &[], s)? {
3547                        return Ok(IrStmt::FtsSearch(ir));
3548                    }
3549                }
3550
3551                // (<Module::Type>expr) { shape } — parenthesised id-lookup with shape.
3552                // The parens are stripped by the parser, leaving Shape { expr: TypeCast }.
3553                // Only meaningful for a named schema type — a structural tuple cast
3554                // never denotes an object-type lookup.
3555                if let Expr::Shape(sh) = result
3556                    && let Some(Expr::TypeCast(tc)) = sh.expr.as_ref()
3557                    && let Some((module, name)) = tc.ty.as_named()
3558                    && module
3559                        .map(|m| !["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"].contains(&m))
3560                        .unwrap_or(false)
3561                {
3562                    let id_filter = Expr::BinOp(Box::new(ast::BinOp {
3563                        left: Expr::Path(ast::Path::relative("id")),
3564                        op: ast::BinOpKind::Eq,
3565                        right: tc.expr.clone(),
3566                    }));
3567                    let merged_filter = match &s.filter {
3568                        None => Some(id_filter),
3569                        Some(existing) => Some(Expr::BinOp(Box::new(ast::BinOp {
3570                            left: id_filter,
3571                            op: ast::BinOpKind::And,
3572                            right: existing.clone(),
3573                        }))),
3574                    };
3575                    // Keep the module qualification so an unknown
3576                    // target reports its full name, not a bare one.
3577                    let qname = match module {
3578                        Some(m) => format!("{}::{}", m, name),
3579                        None => name.to_string(),
3580                    };
3581                    let synthetic = ast::SelectStmt {
3582                        result: Expr::Shape(Box::new(ast::ShapeExpr {
3583                            expr: Some(Expr::Path(ast::Path::absolute(&qname))),
3584                            elements: sh.elements.clone(),
3585                            marker_offset: None,
3586                        })),
3587                        filter: merged_filter,
3588                        order_by: s.order_by.clone(),
3589                        offset: s.offset.clone(),
3590                        limit: s.limit.clone(),
3591                        lock: s.lock.clone(),
3592                    };
3593                    return self
3594                        .compile_select(&synthetic, &synthetic.result, distinct)
3595                        .map(IrStmt::Select);
3596                }
3597                // <Module::Type>expr — schema object lookup by id, or a scalar cast
3598                // (enum, registered named tuple, or a bare structural `tuple<...>`).
3599                // Stdlib modules are handled by compile_free_expr; only user schema
3600                // modules (or a structural tuple, which has no module at all) route here.
3601                const STDLIB_MODULES: &[&str] = &["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"];
3602                if let Expr::TypeCast(tc) = result {
3603                    // A structural tuple cast is always a plain scalar (jsonb) cast —
3604                    // never an object-type lookup — so it's handled directly, before
3605                    // any of the qname-based (named-type-only) checks below.
3606                    if matches!(&tc.ty, ast::TypeExpr::Tuple { .. }) {
3607                        return self.scalar_cast_free_select(tc, "jsonb".to_string(), distinct);
3608                    }
3609                    if let Some((module, name)) = tc.ty.as_named() {
3610                        let qname = match module {
3611                            Some(m) => format!("{}::{}", m, name),
3612                            None => name.to_string(),
3613                        };
3614                        if let Some(ed) = self.resolve_enum(&qname) {
3615                            let pg_type = format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name);
3616                            return self.scalar_cast_free_select(tc, pg_type, distinct);
3617                        }
3618                        if self.resolve_named_tuple(&qname).is_some() {
3619                            return self.scalar_cast_free_select(tc, "jsonb".to_string(), distinct);
3620                        }
3621                        if let Some(sd) = self.resolve_scalar(&qname) {
3622                            let pg_type = format!("{}.\"{}\"", crate::sql::pg_schema_str(&sd.module), sd.name);
3623                            return self.scalar_cast_free_select(tc, pg_type, distinct);
3624                        }
3625                        if module.map(|m| !STDLIB_MODULES.contains(&m)).unwrap_or(false) {
3626                            return self.compile_schema_cast_select(s, tc).map(IrStmt::Select);
3627                        }
3628                    }
3629                }
3630                // Path traversal: `select TypeName.link.prop` or `select TypeName.link { shape }`.
3631                if let Expr::Path(p) = result {
3632                    if !p.partial
3633                        && p.steps.len() == 2
3634                        && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
3635                        && self.resolve_enum(type_ref).is_some()
3636                    {
3637                        let expr = self.compile_enum_access(type_ref, variant)?;
3638                        return Ok(IrStmt::Select(IrSelect {
3639                            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3640                            filter: None,
3641                            order_by: vec![],
3642                            offset: None,
3643                            limit: None,
3644                            distinct,
3645                            dml_source: None,
3646                            polymorphic: false,
3647                            poly_implementors: vec![],
3648                            poly_columns: vec![],
3649                            lock: None,
3650                        }));
3651                    }
3652                    // `root.field1.field2...` where `root` is a WITH-bound
3653                    // free object (not a real/CTE-bound schema type) —
3654                    // resolve to a field reference (possibly chained through
3655                    // nested free objects) instead of falling into
3656                    // compile_path_select, which only knows schema paths.
3657                    if let Some(resolved) = self.resolve_cte_path(p) {
3658                        let expr = resolved?;
3659                        return Ok(IrStmt::Select(IrSelect {
3660                            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3661                            filter: None,
3662                            order_by: vec![],
3663                            offset: None,
3664                            limit: None,
3665                            distinct,
3666                            dml_source: None,
3667                            polymorphic: false,
3668                            poly_implementors: vec![],
3669                            poly_columns: vec![],
3670                            lock: None,
3671                        }));
3672                    }
3673                    if !p.partial && p.steps.len() > 1 {
3674                        return self.compile_path_select(s, p, &[], distinct).map(IrStmt::PathSelect);
3675                    }
3676                }
3677                if let Expr::Shape(sh) = result
3678                    && let Some(Expr::Path(p)) = sh.expr.as_ref()
3679                    && !p.partial
3680                    && p.steps.len() > 1
3681                {
3682                    return self
3683                        .compile_path_select(s, p, &sh.elements, distinct)
3684                        .map(IrStmt::PathSelect);
3685                }
3686                // assert_exists/assert_distinct with SubQuery arg → set-returning assert
3687                if let Expr::FunctionCall(f) = result
3688                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
3689                    && matches!(f.name.as_str(), "assert_exists" | "assert_distinct")
3690                    && !f.args.is_empty()
3691                    && let Expr::SubQuery(inner_stmt) = &f.args[0]
3692                {
3693                    let offset = s.offset.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
3694                    let limit = s.limit.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
3695                    let message = self.assert_message(f, None)?;
3696                    // A set of objects has to come back as rows, not as the
3697                    // bare ids the array form carries — so the assert vets the
3698                    // ids and the type's own rows are selected by them.
3699                    if let Ok(type_name) = self.dml_subject_type(inner_stmt)
3700                        && let Ok(td) = self.resolve_type(&type_name)
3701                    {
3702                        let td_module = td.module.clone();
3703                        let td_name = td.name.clone();
3704                        let td_table = td.table.clone();
3705                        let pk = td
3706                            .properties
3707                            .iter()
3708                            .find(|p| p.is_pk)
3709                            .map(|p| (p.name.clone(), p.pg_type.clone()))
3710                            .unwrap_or_else(|| ("id".to_string(), "uuid".to_string()));
3711                        let inner_ir = self.compile_stmt(inner_stmt)?;
3712                        let alias = self.fresh_alias();
3713                        let vetted = IrExpr::FunctionCall(IrFunctionCall {
3714                            return_pg_type: None,
3715                            schema: Some("_pylon".to_string()),
3716                            name: f.name.clone(),
3717                            args: std::iter::once(IrExpr::ArrayFromSelect(Box::new(IrArraySource::StmtColumn {
3718                                stmt: Box::new(inner_ir),
3719                                column: pk.0.clone(),
3720                            })))
3721                            .chain(message)
3722                            .collect(),
3723                            sql_template: None,
3724                        });
3725                        let filter = IrExpr::BinOp(Box::new(IrBinOp {
3726                            left: IrExpr::ColumnRef {
3727                                alias: alias.clone(),
3728                                column: pk.0.clone(),
3729                                pg_type: pk.1.clone(),
3730                            },
3731                            op: ast::BinOpKind::In,
3732                            right: vetted,
3733                        }));
3734                        let shape = vec![IrShapePointer::Scalar(IrScalarPointer {
3735                            implicit_id: false,
3736                            marker_offset: None,
3737                            alias: "id".to_string(),
3738                            column: pk.0,
3739                            pg_type: pk.1,
3740                            tuple_shape: None,
3741                        })];
3742                        let source = IrSource {
3743                            poly: None,
3744                            type_name: format!("{td_module}::{td_name}"),
3745                            table: td_table,
3746                            alias,
3747                        };
3748                        let mut select = IrSelect::schema_bound(source, shape, Some(filter));
3749                        select.offset = offset;
3750                        select.limit = limit;
3751                        select.distinct = distinct;
3752                        return Ok(IrStmt::Select(select));
3753                    }
3754                    let inner = self.compile_subquery_to_array_source(inner_stmt)?;
3755                    return Ok(IrStmt::Select(IrSelect {
3756                        rows: vec![IrRowSource::Free(IrFreeExpr::AssertSet {
3757                            fn_name: f.name.clone(),
3758                            inner: Box::new(inner),
3759                            message,
3760                        })],
3761                        filter: None,
3762                        order_by: vec![],
3763                        offset,
3764                        limit,
3765                        distinct,
3766                        dml_source: None,
3767                        polymorphic: false,
3768                        poly_implementors: vec![],
3769                        poly_columns: vec![],
3770                        lock: None,
3771                    }));
3772                }
3773                // `array_agg(array_unpack(teams.permissions))` — the unpacking
3774                // has to happen in a row source, so the aggregate is left with
3775                // no walk of its own and the select stands free.
3776                if let Some(expr) = self.aggregate_over_unpacked(s, result, distinct)? {
3777                    return Ok(IrStmt::Select(IrSelect {
3778                        rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3779                        filter: None,
3780                        order_by: vec![],
3781                        offset: None,
3782                        limit: None,
3783                        distinct: false,
3784                        dml_source: None,
3785                        polymorphic: false,
3786                        poly_implementors: vec![],
3787                        poly_columns: vec![],
3788                        lock: None,
3789                    }));
3790                }
3791                // `select individuals in accounts` — `in` answers once per
3792                // element of the set on its left, so that set is this
3793                // select's row source and the membership test is what each
3794                // row carries. Read as a plain value the left side collapses
3795                // into a single scalar subquery, which Postgres rejects the
3796                // moment the binding holds more than one row. The comparison
3797                // is on identity, so the walk the source needs is `.id`.
3798                if let Expr::BinOp(b) = result
3799                    && matches!(b.op, ast::BinOpKind::In | ast::BinOpKind::NotIn)
3800                    && let Some(root) = self.object_set_name(&b.left)
3801                {
3802                    let rewritten = Expr::BinOp(Box::new(ast::BinOp {
3803                        left: Expr::Path(ast::Path {
3804                            steps: vec![ast::PathStep::Name(root.clone()), ast::PathStep::Name("id".into())],
3805                            partial: false,
3806                        }),
3807                        op: b.op.clone(),
3808                        right: b.right.clone(),
3809                    }));
3810                    return self
3811                        .compile_expr_as_path_select(s, &rewritten, &root, distinct)
3812                        .map(IrStmt::PathSelect);
3813                }
3814                // Expression containing a type-rooted path: `select fn(TypeName.link.prop, ...)`.
3815                if let Some(root) = self.find_path_root_in_expr(result) {
3816                    return self
3817                        .compile_expr_as_path_select(s, result, &root, distinct)
3818                        .map(IrStmt::PathSelect);
3819                }
3820                // `select TypeName is CheckType` — iterate source type, return bool per row.
3821                if let Expr::TypeIs { expr, ty } = result
3822                    && let Expr::Path(p) = expr.as_ref()
3823                    && !p.partial
3824                    && p.steps.len() == 1
3825                    && let ast::PathStep::Name(src_name) = &p.steps[0]
3826                    && self.resolve_name_ref(src_name, true).is_none()
3827                {
3828                    let src_td = self.resolve_type(src_name)?;
3829                    {
3830                        let src_qname = format!("{}::{}", src_td.module, src_td.name);
3831                        let (ty_module, ty_name) = ty
3832                            .as_named()
3833                            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
3834                        let check_module = ty_module.unwrap_or(&src_td.module);
3835                        let check_name = format!("{}::{}", check_module, ty_name);
3836                        self.resolve_type(&check_name)?;
3837                        let check_qname = check_name;
3838                        let src_table = src_td.table.clone();
3839                        let src_alias = self.fresh_alias();
3840                        let src_td = self.resolve_type(&src_qname)?;
3841                        let poly_implementors = if self.is_polymorphic(src_td) {
3842                            self.find_poly_implementors(&src_qname)
3843                        } else {
3844                            vec![]
3845                        };
3846                        let bool_expr = self.type_check_bool_expr(&src_qname, &check_qname, src_td, &src_alias);
3847                        let ps = IrPathSelect {
3848                            root: IrSource {
3849                                poly: None,
3850                                type_name: src_qname,
3851                                table: src_table,
3852                                alias: src_alias,
3853                            },
3854                            joins: vec![],
3855                            result: IrPathResult::Scalar(bool_expr, None),
3856                            filter: s
3857                                .filter
3858                                .as_ref()
3859                                .map(|_| Err(self.type_err("FILTER is not supported on type-is SELECT")))
3860                                .transpose()?,
3861                            order_by: vec![],
3862                            offset: None,
3863                            limit: None,
3864                            distinct,
3865                            poly_implementors,
3866                        };
3867                        return Ok(IrStmt::PathSelect(ps));
3868                    }
3869                }
3870                if let Expr::Union(a, b) = result
3871                    && !distinct
3872                    && s.filter.is_none()
3873                    && s.order_by.is_empty()
3874                    && s.offset.is_none()
3875                    && s.limit.is_none()
3876                    && (self.is_scalar_walk(a) || self.is_scalar_walk(b))
3877                {
3878                    return self.compile_scalar_union(result);
3879                }
3880                // `select (l.addon { c := … }).c` — the values, one row each.
3881                if !distinct
3882                    && s.filter.is_none()
3883                    && s.order_by.is_empty()
3884                    && s.offset.is_none()
3885                    && s.limit.is_none()
3886                    && let Some(ps) = self.compile_shape_field_select(result, None)?
3887                {
3888                    return Ok(IrStmt::PathSelect(ps));
3889                }
3890                // `select (update T … ).link { … }` — the subject walks off a
3891                // mutation. Hoist the mutation into the statement's own WITH
3892                // and re-root the walk at that binding, which is the
3893                // `with a := (update …) select a.link { … }` spelling that
3894                // already compiles. A walk off a plain sub-select needs none
3895                // of this.
3896                if let Expr::Shape(sh) = result
3897                    && let Some(subject) = sh.expr.as_ref()
3898                    && !matches!(subject, Expr::Path(_))
3899                {
3900                    let (base, fields) = Self::peel_field_access_chain(subject);
3901                    if let Expr::SubQuery(inner_stmt) = base
3902                        && !fields.is_empty()
3903                        && matches!(inner_stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
3904                    {
3905                        let inner = self.compile_stmt(inner_stmt)?;
3906                        let cte_name = self.fresh_nested_cte_name();
3907                        // The walk may read a junction this very mutation
3908                        // appended to; those rows live only in the append CTE.
3909                        let overrides = Self::junction_overrides_for(&inner, &cte_name);
3910                        let type_name = self.register_cte(&cte_name, &inner);
3911                        self.hoisted_ctes.push(IrCteDef {
3912                            name: cte_name.clone(),
3913                            stmt: inner,
3914                            type_name,
3915                            correlated_to: None,
3916                        });
3917                        let mut steps = vec![ast::PathStep::Name(cte_name)];
3918                        steps.extend(fields.into_iter().map(ast::PathStep::Name));
3919                        let rerooted = ast::SelectStmt {
3920                            result: Expr::Shape(Box::new(ast::ShapeExpr {
3921                                expr: Some(Expr::Path(ast::Path { steps, partial: false })),
3922                                elements: sh.elements.clone(),
3923                                marker_offset: None,
3924                            })),
3925                            filter: s.filter.clone(),
3926                            order_by: s.order_by.clone(),
3927                            offset: s.offset.clone(),
3928                            limit: s.limit.clone(),
3929                            lock: s.lock.clone(),
3930                        };
3931                        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
3932                        let compiled = self.compile_stmt(&Stmt::Select(rerooted));
3933                        self.junction_read_overrides = previous;
3934                        return compiled;
3935                    }
3936                }
3937                // `select (select (A union B) { … } limit 1) { … }` — a nested
3938                // select whose own subject is a union has no single type name
3939                // for the outer select to take. Hoisting it into a binding and
3940                // reading that back is the `with l := (select …) select l { … }`
3941                // spelling, which already compiles.
3942                if let Expr::Shape(sh) = result
3943                    && let Some(Expr::SubQuery(inner_stmt)) = sh.expr.as_ref()
3944                    && matches!(inner_stmt.as_ref(), Stmt::Select(_))
3945                    && self.dml_subject_type(inner_stmt).is_err()
3946                {
3947                    let inner = self.compile_stmt(inner_stmt)?;
3948                    let cte_name = self.fresh_nested_cte_name();
3949                    let type_name = self.register_cte(&cte_name, &inner);
3950                    self.hoisted_ctes.push(IrCteDef {
3951                        name: cte_name.clone(),
3952                        stmt: inner,
3953                        type_name,
3954                        correlated_to: None,
3955                    });
3956                    let rerooted = ast::SelectStmt {
3957                        result: Expr::Shape(Box::new(ast::ShapeExpr {
3958                            expr: Some(Expr::Path(ast::Path {
3959                                steps: vec![ast::PathStep::Name(cte_name)],
3960                                partial: false,
3961                            })),
3962                            elements: sh.elements.clone(),
3963                            marker_offset: None,
3964                        })),
3965                        filter: s.filter.clone(),
3966                        order_by: s.order_by.clone(),
3967                        offset: s.offset.clone(),
3968                        limit: s.limit.clone(),
3969                        lock: s.lock.clone(),
3970                    };
3971                    return self.compile_stmt(&Stmt::Select(rerooted));
3972                }
3973                // `select (for … union …)` — the select adds nothing the loop has
3974                // not already produced, so it *is* the loop. Written with a
3975                // shape or modifiers of its own the loop is this select's
3976                // source instead, and a mutating one has to be bound to a CTE
3977                // first — see `bind_mutating_loop_source`.
3978                if let Expr::SubQuery(inner) = result
3979                    && matches!(inner.as_ref(), Stmt::For(_))
3980                    && !distinct
3981                    && s.filter.is_none()
3982                    && s.order_by.is_empty()
3983                    && s.offset.is_none()
3984                    && s.limit.is_none()
3985                {
3986                    return self.compile_stmt(inner);
3987                }
3988                // Catch mixed object/scalar UNION before dispatching further.
3989                self.check_union_type_compat(result)?;
3990                if self.is_free_result(result) {
3991                    self.compile_free_select(s, result, distinct).map(IrStmt::Select)
3992                } else {
3993                    self.compile_select(s, result, distinct).map(IrStmt::Select)
3994                }
3995            }
3996            Stmt::Insert(s) => self.compile_insert(s).map(IrStmt::Insert),
3997            Stmt::Update(s) => self.compile_update(s).map(IrStmt::Update),
3998            Stmt::Delete(s) => self.compile_delete(s).map(IrStmt::Delete),
3999            Stmt::Group(s) => self.compile_group(s).map(IrStmt::Group),
4000            // Nested WITH blocks (e.g. inside a subquery): inline the CTE types
4001            // into the current compiler scope so references resolve correctly.
4002            // The actual CTE SQL is handled at the top-level compile() boundary.
4003            Stmt::With(w) => {
4004                for alias in &w.aliases {
4005                    if self.bind_inline_if_correlated(&alias.name, &alias.expr, None)?
4006                        || self.bind_group(&alias.name, &alias.expr)?
4007                    {
4008                        continue;
4009                    }
4010                    let (ir_inner, correlated_to) = self.compile_binding_in_scope(&alias.expr)?;
4011                    let sql_name = self.claim_cte_sql_name(&alias.name);
4012                    if let Some(slot) = correlated_to.as_deref().and_then(|i| i.strip_prefix("_for_")) {
4013                        self.correlated_bindings.insert(sql_name.clone(), slot.to_string());
4014                    }
4015                    let type_name = self.register_cte(&alias.name, &ir_inner);
4016                    self.hoisted_binding_sources
4017                        .insert(sql_name.clone(), alias.expr.clone());
4018                    self.hoisted_ctes.push(IrCteDef {
4019                        name: sql_name,
4020                        stmt: ir_inner,
4021                        type_name,
4022                        correlated_to,
4023                    });
4024                }
4025                self.compile_stmt(&w.stmt)
4026            }
4027            Stmt::For(f) => match self.compile_group_elements(f)? {
4028                Some(grp) => Ok(IrStmt::Group(grp)),
4029                None => self.compile_for(f).map(IrStmt::For),
4030            },
4031            // `analyze` only changes how the query is *executed* (see
4032            // `analyze.rs`) — the inner statement's IR is identical either
4033            // way, so this layer just unwraps and compiles it normally.
4034            Stmt::Analyze(inner) => self.compile_stmt(inner),
4035        }
4036    }
4037
4038    /// `<Module::Type>expr` in SELECT position is a schema object lookup:
4039    /// select the object whose `id` equals `expr`.  Semantically identical to
4040    /// `SELECT Type FILTER .id = expr` plus any modifiers on the outer SELECT.
4041    fn compile_schema_cast_select(&mut self, sel: &ast::SelectStmt, tc: &ast::TypeCast) -> Result<IrSelect, PyQLError> {
4042        let id_filter = Expr::BinOp(Box::new(ast::BinOp {
4043            left: Expr::Path(ast::Path::relative("id")),
4044            op: ast::BinOpKind::Eq,
4045            right: tc.expr.clone(),
4046        }));
4047        let merged_filter = match &sel.filter {
4048            None => Some(id_filter),
4049            Some(existing) => Some(Expr::BinOp(Box::new(ast::BinOp {
4050                left: id_filter,
4051                op: ast::BinOpKind::And,
4052                right: existing.clone(),
4053            }))),
4054        };
4055        // Callers only reach this function after confirming `tc.ty` is `Named`
4056        // (a structural tuple is never an object-type lookup).
4057        let (module, name) = tc
4058            .ty
4059            .as_named()
4060            .ok_or_else(|| self.type_err("cannot use a tuple or array type as a schema object cast"))?;
4061        // Keep the module qualification in the synthetic path so a genuinely
4062        // unknown target (neither an object type, enum, scalar, nor named
4063        // tuple) reports its full name via `resolve_type`'s own error,
4064        // instead of silently dropping the module and reporting a bare name.
4065        let qname = match module {
4066            Some(m) => format!("{}::{}", m, name),
4067            None => name.to_string(),
4068        };
4069        let synthetic = ast::SelectStmt {
4070            result: Expr::Path(ast::Path::absolute(&qname)),
4071            filter: merged_filter,
4072            order_by: sel.order_by.clone(),
4073            offset: sel.offset.clone(),
4074            limit: sel.limit.clone(),
4075            lock: sel.lock.clone(),
4076        };
4077        self.compile_select(&synthetic, &synthetic.result, false)
4078    }
4079
4080    // ── PATH SELECT ───────────────────────────────────────────────────────────────
4081
4082    /// `select TypeName.link.prop` / `select TypeName.link { shape }`.
4083    /// Walks the path, building JOIN steps, then projects the final pointer or object.
4084    /// `compile_shape` with the type the walk landed on anchored, so a
4085    /// relative path written inside the shape — a `with` binding's value, a
4086    /// nested sub-select's filter — has an enclosing object to resolve
4087    /// against. A plain `select` pushes the same anchor for its own shape.
4088    fn compile_shape_anchored(
4089        &mut self,
4090        elements: &[ShapeElement],
4091        td: &'a TypeDescriptor,
4092        alias: &str,
4093        module: &str,
4094    ) -> Result<Vec<IrShapePointer>, PyQLError> {
4095        self.anchors.push(SelectAnchor {
4096            type_name: td.name.clone(),
4097            qualified: format!("{}::{}", td.module, td.name),
4098            alias: alias.to_string(),
4099            detached: false,
4100            declared_on: None,
4101        });
4102        let result = self.compile_shape(elements, td, alias, module);
4103        self.anchors.pop();
4104        result
4105    }
4106
4107    fn compile_path_select(
4108        &mut self,
4109        sel: &ast::SelectStmt,
4110        path: &ast::Path,
4111        shape_elements: &[ShapeElement],
4112        distinct: bool,
4113    ) -> Result<IrPathSelect, PyQLError> {
4114        self.compile_path_select_with_tail(sel, path, shape_elements, distinct, 0, &[])
4115    }
4116
4117    /// `compile_path_select` where the last `tail` steps were appended by a
4118    /// projection off a sub-select — `(select … limit 1).plan.tier` — so
4119    /// `sel`'s own modifiers belong to the step before them, not to the end
4120    /// of the path.
4121    fn compile_path_select_with_tail(
4122        &mut self,
4123        sel: &ast::SelectStmt,
4124        path: &ast::Path,
4125        shape_elements: &[ShapeElement],
4126        distinct: bool,
4127        tail: usize,
4128        tail_sorts: &[ast::SortExpr],
4129    ) -> Result<IrPathSelect, PyQLError> {
4130        let outer_anchor = self.modifier_anchor.take();
4131        // Swapped rather than assigned, so a path select compiled inside this
4132        // one gets its own (empty) list and hands these back untouched.
4133        let outer_sorts = std::mem::replace(&mut self.tail_sorts, tail_sorts.to_vec());
4134        let mut result = self.compile_path_select_inner(sel, path, shape_elements, distinct, tail);
4135        self.tail_sorts = outer_sorts;
4136        self.modifier_anchor = outer_anchor;
4137        if let Ok(path_select) = &mut result {
4138            self.resolve_join_fanouts(path_select);
4139            self.drop_empty_results(path_select);
4140        }
4141        result
4142    }
4143
4144    fn compile_path_select_inner(
4145        &mut self,
4146        sel: &ast::SelectStmt,
4147        path: &ast::Path,
4148        shape_elements: &[ShapeElement],
4149        distinct: bool,
4150        mut tail: usize,
4151    ) -> Result<IrPathSelect, PyQLError> {
4152        use ast::PathStep;
4153
4154        let root_name = match &path.steps[0] {
4155            PathStep::Name(n) => n.as_str(),
4156            _ => return Err(self.type_err("path traversal must start with a type name")),
4157        };
4158        // A WITH-block CTE bound to an object type (e.g. `with user :=
4159        // (select global current_user) select user.gender`) can be
4160        // traversed just like a real type name — resolve its underlying
4161        // type and source from the `@cte:` sentinel table (the same
4162        // mechanism `compile_select`'s bare-CTE-object-select case uses)
4163        // instead of failing with "unknown type '{root_name}'".
4164        let root_td = self.resolve_path_root(root_name)?;
4165        let root_alias = self.fresh_alias();
4166        let root = IrSource {
4167            poly: None,
4168            type_name: format!("{}::{}", root_td.module, root_td.name),
4169            table: self.row_source_table(root_name, root_td),
4170            alias: root_alias.clone(),
4171        };
4172        // `for c in (select Person) union (select c.name)` — the loop variable
4173        // holds the row's key, so the walk starts from that row rather than
4174        // from the whole table.
4175        let for_var_root = self.for_var_types.contains_key(root_name).then(|| {
4176            IrExpr::BinOp(Box::new(IrBinOp {
4177                left: IrExpr::ColumnRef {
4178                    alias: root_alias.clone(),
4179                    column: "id".to_string(),
4180                    pg_type: "uuid".to_string(),
4181                },
4182                op: ast::BinOpKind::Eq,
4183                right: self.for_var_ref(root_name),
4184            }))
4185        });
4186
4187        let mut joins: Vec<IrPathJoin> = vec![];
4188        let mut current_td = root_td;
4189        let mut current_alias = root_alias;
4190
4191        // Owned rather than borrowed: traversing *through* a computed
4192        // pointer splices that computed's own path in place of the step
4193        // naming it (see the computed branch at the bottom of the loop).
4194        let mut steps: Vec<PathStep> = path.steps[1..].to_vec();
4195        // `(step index, filter)` a spliced computed contributed, to compile
4196        // against the type that step lands on once the loop reaches it.
4197        let mut pending_filters: Vec<(usize, Expr)> = vec![];
4198        let mut extra_conditions: Vec<IrExpr> = for_var_root.into_iter().collect();
4199        // The junction the traversal last crossed, for a bare `@prop` in the
4200        // select's own modifiers.
4201        let mut junction_scope: Option<(String, String)> = None;
4202        let mut splices = 0usize;
4203        let mut idx = 0;
4204        if tail > 0 && tail >= steps.len() {
4205            self.modifier_anchor = Some((
4206                format!("{}::{}", current_td.module, current_td.name),
4207                current_alias.clone(),
4208            ));
4209        }
4210        while idx < steps.len() {
4211            let owned_step = steps[idx].clone();
4212            let step = &owned_step;
4213            let n_steps = steps.len();
4214            let is_last = |extra: usize| idx + extra == n_steps - 1;
4215            if tail > 0 && idx == n_steps - tail {
4216                self.modifier_anchor = Some((
4217                    format!("{}::{}", current_td.module, current_td.name),
4218                    current_alias.clone(),
4219                ));
4220            }
4221
4222            // A spliced computed's own filter belongs to the step it landed
4223            // on, which is the one just processed.
4224            while let Some(pos) = pending_filters.iter().position(|(i, _)| *i + 1 == idx) {
4225                let (_, f) = pending_filters.remove(pos);
4226                let cond = self.as_condition(|c| c.compile_expr(&f, current_td, &current_alias))?;
4227                extra_conditions.push(cond);
4228            }
4229
4230            // Type intersection standalone (not after backlink): narrows current_td.
4231            if let PathStep::TypeIntersection(type_ref) = step {
4232                let type_name = match &type_ref.module {
4233                    Some(m) => format!("{}::{}", m, type_ref.name),
4234                    None => type_ref.name.clone(),
4235                };
4236                let narrowed = self.resolve_type(&type_name)?;
4237                // Narrowing to a type with a relation of its own moves to that
4238                // relation, joined on the shared id: an interface's view has
4239                // none of its implementors' own columns, and two unrelated
4240                // types share no ids at all, so the join finds nothing — which
4241                // is exactly what an impossible intersection yields. A mixin
4242                // has no relation; its columns are already on the current row.
4243                let narrowed_has_relation = !Self::backs_no_relation(narrowed);
4244                if narrowed_has_relation && narrowed.table != current_td.table {
4245                    let target_alias = self.fresh_alias();
4246                    let target = IrSource {
4247                        poly: None,
4248                        type_name: format!("{}::{}", narrowed.module, narrowed.name),
4249                        table: narrowed.table.clone(),
4250                        alias: target_alias.clone(),
4251                    };
4252                    joins.push(IrPathJoin::Single {
4253                        source_alias: current_alias.clone(),
4254                        fk_col: "id".to_string(),
4255                        target,
4256                    });
4257                    current_alias = target_alias;
4258                }
4259                current_td = narrowed;
4260                idx += 1;
4261                continue;
4262            }
4263
4264            // Backlink: .<link_name — optionally followed by [is OwnerType].
4265            if let PathStep::Backlink(link_name) = step {
4266                // Peek ahead: if the next step is [is Type], use it to identify the owner.
4267                let owner_hint = steps.get(idx + 1).and_then(|s| {
4268                    if let PathStep::TypeIntersection(tr) = s {
4269                        Some(tr.clone())
4270                    } else {
4271                        None
4272                    }
4273                });
4274                let consumed_extra = if owner_hint.is_some() { 1 } else { 0 };
4275
4276                let owner_td: &TypeDescriptor = if let Some(ref tr) = owner_hint {
4277                    let type_name = match &tr.module {
4278                        Some(m) => format!("{}::{}", m, tr.name),
4279                        None => tr.name.clone(),
4280                    };
4281                    let td = self.resolve_type(&type_name)?;
4282                    let current_qname = format!("{}::{}", current_td.module, current_td.name);
4283                    let link_targets_current =
4284                        td.links
4285                            .iter()
4286                            .any(|l| l.name == *link_name && self.link_target_reaches(&l.target, &current_qname))
4287                            || td.multilinks.iter().any(|ml| {
4288                                ml.name == *link_name && self.link_target_reaches(&ml.target, &current_qname)
4289                            });
4290                    if link_targets_current {
4291                        td
4292                    } else {
4293                        // `.<memberships[is account::Account]` — declared further
4294                        // down, by the one type under the narrowing that has it.
4295                        let narrowed = format!("{}::{}", td.module, td.name);
4296                        let declaring: Vec<&'a TypeDescriptor> = self
4297                            .schema
4298                            .types
4299                            .iter()
4300                            .filter(|t| !t.abstract_ && Self::is_or_implements(t, &narrowed))
4301                            .filter(|t| self.declares_backlink(t, link_name, &current_qname))
4302                            .collect();
4303                        match Self::without_inherited_owners(declaring).as_slice() {
4304                            [only] => only,
4305                            _ => {
4306                                return Err(self.type_err(&format!(
4307                                    "link '{}::{}' does not target '{}'; backlink is not valid here",
4308                                    type_name, link_name, current_qname,
4309                                )));
4310                            }
4311                        }
4312                    }
4313                } else {
4314                    // No hint — search for any type whose link/multilink targets current_td.
4315                    let current_qname = format!("{}::{}", current_td.module, current_td.name);
4316                    self.schema
4317                        .types
4318                        .iter()
4319                        // The base, not a subtype inheriting the link from it.
4320                        .filter(|t| {
4321                            t.bases.is_empty()
4322                                || !t.bases.iter().any(|base| {
4323                                    self.resolve_type(base).is_ok_and(|b| {
4324                                        b.links.iter().any(|l| l.name == *link_name)
4325                                            || b.multilinks.iter().any(|ml| ml.name == *link_name)
4326                                    })
4327                                })
4328                        })
4329                        .find(|t| {
4330                            t.links
4331                                .iter()
4332                                .any(|l| l.name == *link_name && self.link_target_reaches(&l.target, &current_qname))
4333                                || t.multilinks.iter().any(|ml| {
4334                                    ml.name == *link_name && self.link_target_reaches(&ml.target, &current_qname)
4335                                })
4336                        })
4337                        .ok_or_else(|| {
4338                            self.type_err(&format!(
4339                                "no type has a link '{}' targeting '{}'",
4340                                link_name, current_qname,
4341                            ))
4342                        })?
4343                };
4344
4345                let target_alias = self.fresh_alias();
4346                let target = IrSource {
4347                    poly: None,
4348                    type_name: format!("{}::{}", owner_td.module, owner_td.name),
4349                    table: owner_td.table.clone(),
4350                    alias: target_alias.clone(),
4351                };
4352
4353                // Determine if the link is single (FK), junction-backed
4354                // single (same shape as a backlinked multi-link), or multi
4355                // (junction).
4356                if let Some(l) = owner_td.links.iter().find(|l| l.name == *link_name) {
4357                    if l.is_junction_backed() {
4358                        let junction_alias = self.fresh_alias();
4359                        let (junction_table, module, owner_col, current_col, _) =
4360                            self.link_junction_info(owner_td, l)?;
4361                        joins.push(IrPathJoin::BacklinkMulti {
4362                            source_alias: current_alias.clone(),
4363                            junction_alias,
4364                            junction_table,
4365                            module,
4366                            owner_col,
4367                            current_col,
4368                            target,
4369                        });
4370                    } else {
4371                        joins.push(IrPathJoin::BacklinkSingle {
4372                            source_alias: current_alias.clone(),
4373                            fk_col: format!("{}_id", link_name),
4374                            target,
4375                        });
4376                    }
4377                } else {
4378                    let ml = owner_td
4379                        .multilinks
4380                        .iter()
4381                        .find(|ml| ml.name == *link_name)
4382                        .unwrap()
4383                        .clone();
4384                    let junction_alias = self.fresh_alias();
4385                    let (junction_table, module, _, _, _) = self.multilink_junction_info(owner_td, &ml)?;
4386                    joins.push(IrPathJoin::BacklinkMulti {
4387                        source_alias: current_alias.clone(),
4388                        junction_alias,
4389                        junction_table,
4390                        module,
4391                        owner_col: "source".to_string(),
4392                        current_col: "target".to_string(),
4393                        target,
4394                    });
4395                }
4396
4397                if is_last(consumed_extra) {
4398                    let shape =
4399                        self.compile_shape_anchored(shape_elements, owner_td, &target_alias, &owner_td.module.clone())?;
4400                    let result = IrPathResult::Object {
4401                        alias: target_alias.clone(),
4402                        type_name: format!("{}::{}", owner_td.module, owner_td.name),
4403                        shape,
4404                    };
4405                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4406                        sel,
4407                        owner_td,
4408                        &target_alias,
4409                        junction_scope.clone(),
4410                        shape_elements,
4411                    )?;
4412                    return Ok(IrPathSelect {
4413                        root,
4414                        joins,
4415                        result,
4416                        filter: and_conditions(filter, extra_conditions),
4417                        order_by,
4418                        offset,
4419                        limit,
4420                        distinct,
4421                        poly_implementors: vec![],
4422                    });
4423                }
4424                current_td = owner_td;
4425                current_alias = target_alias;
4426                idx += 1 + consumed_extra;
4427                continue;
4428            }
4429
4430            let step_name = match step {
4431                PathStep::Name(n) => n.as_str(),
4432                _ => return Err(self.type_err("only name steps are supported in path traversal")),
4433            };
4434
4435            // `__type__` is a virtual scalar property holding the
4436            // fully-qualified type name: a real discriminator column on a
4437            // polymorphic (interface) source, a constant on a concrete one.
4438            if step_name == "__type__" {
4439                if !is_last(0) {
4440                    return Err(
4441                        self.type_err("'__type__' is the type's name, not an object — it cannot be traversed further")
4442                    );
4443                }
4444                let polymorphic = self.is_polymorphic(current_td);
4445                let expr = if polymorphic {
4446                    IrExpr::ColumnRef {
4447                        alias: current_alias.clone(),
4448                        column: "__type__".to_string(),
4449                        pg_type: "text".to_string(),
4450                    }
4451                } else {
4452                    IrExpr::Literal(IrLiteral::Str(format!("{}::{}", current_td.module, current_td.name)))
4453                };
4454                // A CTE-backed root already carries the discriminator column
4455                // from the binding's own polymorphic expansion; re-expanding it
4456                // here would read the base type again and drop the binding's
4457                // filter.
4458                let poly_implementors = if polymorphic && joins.is_empty() && !root.table.starts_with("@cte:") {
4459                    self.find_poly_implementors(&root.type_name.clone())
4460                } else {
4461                    vec![]
4462                };
4463                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4464                    sel,
4465                    current_td,
4466                    &current_alias,
4467                    junction_scope.clone(),
4468                    shape_elements,
4469                )?;
4470                return Ok(IrPathSelect {
4471                    root,
4472                    joins,
4473                    result: IrPathResult::Scalar(expr, None),
4474                    filter: and_conditions(filter, extra_conditions),
4475                    order_by,
4476                    offset,
4477                    limit,
4478                    distinct,
4479                    poly_implementors,
4480                });
4481            }
4482
4483            // Check scalar property first.
4484            if let Some(p) = current_td.properties.iter().find(|p| p.name == step_name) {
4485                if !is_last(0) {
4486                    // Named tuple properties (nominal `__nt__:` marker) and structural
4487                    // tuple properties (`tuple_members`) both allow further field
4488                    // access via jsonb operators. An `array<tuple<…>>` property does
4489                    // not: there is no single tuple for `.headers.name` to read a
4490                    // field out of, so it falls through to the error below.
4491                    if (p.pg_type.starts_with("__nt__:") || p.tuple_members.is_some()) && !p.pg_type.ends_with("[]") {
4492                        let base = IrExpr::ColumnRef {
4493                            alias: current_alias.clone(),
4494                            column: p.name.clone(),
4495                            pg_type: p.pg_type.clone(),
4496                        };
4497                        let remaining = &steps[idx + 1..];
4498                        let mut ir: IrExpr = base;
4499                        for step in remaining {
4500                            let field = match step {
4501                                ast::PathStep::Name(n) => n.clone(),
4502                                _ => return Err(self.type_err("only field name steps are valid inside a named tuple")),
4503                            };
4504                            ir = IrExpr::JsonbField {
4505                                expr: Box::new(ir),
4506                                field,
4507                            };
4508                        }
4509                        let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4510                            sel,
4511                            current_td,
4512                            &current_alias,
4513                            junction_scope.clone(),
4514                            shape_elements,
4515                        )?;
4516                        return Ok(IrPathSelect {
4517                            root,
4518                            joins,
4519                            result: IrPathResult::Scalar(ir, None),
4520                            filter: and_conditions(filter, extra_conditions),
4521                            order_by,
4522                            offset,
4523                            limit,
4524                            distinct,
4525                            poly_implementors: vec![],
4526                        });
4527                    }
4528                    return Err(self.type_err(&format!(
4529                        "'{step_name}' is a scalar property, not a link — cannot traverse further"
4530                    )));
4531                }
4532                let tuple_shape = self.resolve_property_tuple_shape(p);
4533                let result = IrPathResult::Scalar(
4534                    IrExpr::ColumnRef {
4535                        alias: current_alias.clone(),
4536                        column: p.name.clone(),
4537                        pg_type: p.pg_type.clone(),
4538                    },
4539                    tuple_shape,
4540                );
4541                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4542                    sel,
4543                    current_td,
4544                    &current_alias,
4545                    junction_scope.clone(),
4546                    shape_elements,
4547                )?;
4548                return Ok(IrPathSelect {
4549                    root,
4550                    joins,
4551                    result,
4552                    filter: and_conditions(filter, extra_conditions),
4553                    order_by,
4554                    offset,
4555                    limit,
4556                    distinct,
4557                    poly_implementors: vec![],
4558                });
4559            }
4560
4561            // Single link.
4562            if let Some(l) = Self::resolve_link(current_td, step_name) {
4563                let target_td = self.resolve_type(&l.target)?;
4564                let target_alias = self.fresh_alias();
4565                let target = IrSource {
4566                    poly: None,
4567                    type_name: format!("{}::{}", target_td.module, target_td.name),
4568                    table: target_td.table.clone(),
4569                    alias: target_alias.clone(),
4570                };
4571                if l.is_junction_backed() {
4572                    // Same join shape a multi-link's own path step uses
4573                    // (D1) — `PRIMARY KEY (source)` on the junction table
4574                    // already guarantees at most one matching row, so no
4575                    // extra cardinality handling is needed here.
4576                    let join = self.build_multilink_join(current_td, &l.name, &l.target, &l.through)?;
4577                    let junction_alias = self.fresh_alias();
4578                    junction_scope = l.through.clone().map(|t| (t, junction_alias.clone()));
4579                    joins.push(IrPathJoin::Multi {
4580                        source_alias: current_alias.clone(),
4581                        junction_alias,
4582                        join,
4583                        target,
4584                    });
4585                } else {
4586                    joins.push(IrPathJoin::Single {
4587                        source_alias: current_alias.clone(),
4588                        fk_col: format!("{}_id", l.name),
4589                        target,
4590                    });
4591                }
4592                if is_last(0) {
4593                    let shape = self.compile_shape_anchored(
4594                        shape_elements,
4595                        target_td,
4596                        &target_alias,
4597                        &target_td.module.clone(),
4598                    )?;
4599                    let result = IrPathResult::Object {
4600                        alias: target_alias.clone(),
4601                        type_name: format!("{}::{}", target_td.module, target_td.name),
4602                        shape,
4603                    };
4604                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4605                        sel,
4606                        target_td,
4607                        &target_alias,
4608                        junction_scope.clone(),
4609                        shape_elements,
4610                    )?;
4611                    return Ok(IrPathSelect {
4612                        root,
4613                        joins,
4614                        result,
4615                        filter: and_conditions(filter, extra_conditions),
4616                        order_by,
4617                        offset,
4618                        limit,
4619                        distinct,
4620                        poly_implementors: vec![],
4621                    });
4622                }
4623                current_td = target_td;
4624                current_alias = target_alias;
4625                idx += 1;
4626                continue;
4627            }
4628
4629            // Multi-link.
4630            if let Some(ml) = Self::resolve_multilink(current_td, step_name) {
4631                let target_td = self.resolve_type(&ml.target)?;
4632                let target_alias = self.fresh_alias();
4633                let junction_alias = self.fresh_alias();
4634                // Set only while walking off a mutation this statement made:
4635                // both the junction rows and, when they were inserted here
4636                // too, the targets live in CTEs rather than their tables.
4637                let override_for_step = self
4638                    .junction_read_overrides
4639                    .get(&self.owner_junction(current_td, &ml.name, None))
4640                    .cloned();
4641                let target = IrSource {
4642                    poly: None,
4643                    type_name: format!("{}::{}", target_td.module, target_td.name),
4644                    table: match override_for_step.as_ref().and_then(|o| o.targets.clone()) {
4645                        Some(cte) => cte,
4646                        None => target_td.table.clone(),
4647                    },
4648                    alias: target_alias.clone(),
4649                };
4650                let join_info = if let Some(through_qname) = &ml.through {
4651                    let through_td = self.resolve_type(through_qname)?;
4652                    if through_td.junction {
4653                        // See `junction_info_for`'s doc comment: owner-derived,
4654                        // never `through_td.table` itself.
4655                        IrMultiLinkJoin::Standard {
4656                            junction_table: self.owner_junction(current_td, &ml.name, Some(through_td)),
4657                            module: current_td.module.clone(),
4658                        }
4659                    } else {
4660                        let source_qname = format!("{}::{}", current_td.module, current_td.name);
4661                        let source_col = through_td
4662                            .links
4663                            .iter()
4664                            .find(|l| l.target == source_qname)
4665                            .ok_or_else(|| {
4666                                PyQLError::Type(PyQLTypeError {
4667                                    message: format!("through type {through_qname} has no link to {source_qname}"),
4668                                    position: Position { line: 0, col: 0 },
4669                                })
4670                            })?
4671                            .name
4672                            .clone();
4673                        let target_col = through_td
4674                            .links
4675                            .iter()
4676                            .find(|l| l.target == ml.target && l.name != source_col)
4677                            .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
4678                            .ok_or_else(|| {
4679                                PyQLError::Type(PyQLTypeError {
4680                                    message: format!(
4681                                        "through type {through_qname} has no link to target {}",
4682                                        ml.target
4683                                    ),
4684                                    position: Position { line: 0, col: 0 },
4685                                })
4686                            })?
4687                            .name
4688                            .clone();
4689                        IrMultiLinkJoin::Through {
4690                            junction_table: through_td.table.clone(),
4691                            module: through_td.module.clone(),
4692                            source_col,
4693                            target_col,
4694                        }
4695                    }
4696                } else {
4697                    let junction_table = self.owner_junction(current_td, &ml.name, None);
4698                    IrMultiLinkJoin::Standard {
4699                        junction_table: match &override_for_step {
4700                            Some(o) => o.junction.clone(),
4701                            None => junction_table,
4702                        },
4703                        module: current_td.module.clone(),
4704                    }
4705                };
4706                junction_scope = ml.through.clone().map(|t| (t, junction_alias.clone()));
4707                joins.push(IrPathJoin::Multi {
4708                    source_alias: current_alias.clone(),
4709                    junction_alias,
4710                    join: join_info,
4711                    target,
4712                });
4713                if is_last(0) {
4714                    let shape = self.compile_shape_anchored(
4715                        shape_elements,
4716                        target_td,
4717                        &target_alias,
4718                        &target_td.module.clone(),
4719                    )?;
4720                    let result = IrPathResult::Object {
4721                        alias: target_alias.clone(),
4722                        type_name: format!("{}::{}", target_td.module, target_td.name),
4723                        shape,
4724                    };
4725                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4726                        sel,
4727                        target_td,
4728                        &target_alias,
4729                        junction_scope.clone(),
4730                        shape_elements,
4731                    )?;
4732                    return Ok(IrPathSelect {
4733                        root,
4734                        joins,
4735                        result,
4736                        filter: and_conditions(filter, extra_conditions),
4737                        order_by,
4738                        offset,
4739                        limit,
4740                        distinct,
4741                        poly_implementors: vec![],
4742                    });
4743                }
4744                current_td = target_td;
4745                current_alias = target_alias;
4746                idx += 1;
4747                continue;
4748            }
4749
4750            // A computed pointer declared on the type reached so far (or on
4751            // an interface it implements) — or, at the root only, one the
4752            // shape in scope declared, which is on no type at all:
4753            // `r { revision := … }` read back as `.revision.created_at`.
4754            let declared = match self.resolve_computed(current_td, step_name) {
4755                Some(cd) => Some(crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?),
4756                None if idx == 0 => self
4757                    .active_declared_pointers
4758                    .iter()
4759                    .find(|d| path_leaf(&d.path).is_ok_and(|n| n == step_name))
4760                    .and_then(|d| d.compexpr.clone()),
4761                None => None,
4762            };
4763            if let Some(parsed) = declared {
4764                // `(select .parents … limit 1).parent` reads as the select over
4765                // the walk those two make together.
4766                let rewritten = Self::field_access_over_select(&parsed);
4767                let expr_ast = match &rewritten {
4768                    Some((sel, _)) => Expr::SubQuery(Box::new(Stmt::Select(sel.clone()))),
4769                    None => parsed,
4770                };
4771                let field_steps = rewritten.as_ref().map(|(_, n)| *n).unwrap_or(0);
4772
4773                // Traversing *through* it: a computed has no stored column
4774                // for a join to hang off, but if it just names a link — with
4775                // or without a filter — its own path can take the step's
4776                // place, and its filter rides along on the spliced segment.
4777                // Chaining computeds this way is routine, so the splice
4778                // re-enters the loop and expands again if it lands on
4779                // another one.
4780                // As the last step too, when what it names lands on an object:
4781                // the shape and the select's own modifiers belong to that
4782                // object, and reading it as an expression instead binds them to
4783                // the type that declares the computed.
4784                if let Some((p, _, modifiers)) = Self::pointer_subject(&expr_ast)
4785                    && p.partial
4786                    && !p.steps.is_empty()
4787                    && (!is_last(0)
4788                        || self
4789                            .walk_path_types(current_td, &p.steps, MAX_COMPUTED_SPLICES)
4790                            .1
4791                            .is_some())
4792                {
4793                    // Order/offset/limit of its own pick one row *per source
4794                    // row*, which no plain join expresses — so the computed's
4795                    // own traversal becomes a correlated LATERAL and the path
4796                    // continues from its result.
4797                    if let Some(m) = modifiers
4798                        && (m.limit.is_some() || m.offset.is_some() || !m.order_by.is_empty())
4799                    {
4800                        let mut inner_steps =
4801                            vec![PathStep::Name(format!("{}::{}", current_td.module, current_td.name))];
4802                        inner_steps.extend(p.steps.iter().cloned());
4803                        let inner_path = ast::Path {
4804                            steps: inner_steps,
4805                            partial: false,
4806                        };
4807                        let inner_sel = ast::SelectStmt {
4808                            result: Expr::Path(inner_path.clone()),
4809                            filter: m.filter.clone(),
4810                            order_by: m.order_by.clone(),
4811                            offset: m.offset.clone(),
4812                            limit: m.limit.clone(),
4813                            lock: None,
4814                        };
4815                        let mut inner =
4816                            self.compile_path_select_with_tail(&inner_sel, &inner_path, &[], false, field_steps, &[])?;
4817                        Self::correlate_path_select(&mut inner, &current_alias);
4818                        let IrPathResult::Object { type_name, .. } = &inner.result else {
4819                            return Err(self.type_err(&format!(
4820                                "computed pointer '{step_name}' is a scalar — a path cannot continue through it"
4821                            )));
4822                        };
4823                        let target_td = self.resolve_type(&type_name.clone())?;
4824                        let target_alias = self.fresh_alias();
4825                        let target = IrSource {
4826                            poly: None,
4827                            type_name: format!("{}::{}", target_td.module, target_td.name),
4828                            table: target_td.table.clone(),
4829                            alias: target_alias.clone(),
4830                        };
4831                        joins.push(IrPathJoin::Lateral {
4832                            inner: Box::new(inner),
4833                            target,
4834                        });
4835                        if is_last(0) {
4836                            let module = target_td.module.clone();
4837                            let shape =
4838                                self.compile_shape_anchored(shape_elements, target_td, &target_alias, &module)?;
4839                            let result = IrPathResult::Object {
4840                                alias: target_alias.clone(),
4841                                type_name: format!("{}::{}", target_td.module, target_td.name),
4842                                shape,
4843                            };
4844                            let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4845                                sel,
4846                                target_td,
4847                                &target_alias,
4848                                junction_scope,
4849                                shape_elements,
4850                            )?;
4851                            return Ok(IrPathSelect {
4852                                root,
4853                                joins,
4854                                result,
4855                                filter: and_conditions(filter, extra_conditions),
4856                                order_by,
4857                                offset,
4858                                limit,
4859                                distinct,
4860                                poly_implementors: vec![],
4861                            });
4862                        }
4863                        current_td = target_td;
4864                        current_alias = target_alias;
4865                        idx += 1;
4866                        continue;
4867                    }
4868                    splices += 1;
4869                    if splices > MAX_COMPUTED_SPLICES {
4870                        return Err(self.type_err(&format!(
4871                            "computed pointer '{step_name}' expands into itself — \
4872                             a path cannot be resolved through a cycle of computed pointers"
4873                        )));
4874                    }
4875                    let filter = modifiers.and_then(|m| m.filter.clone());
4876                    let spliced = p.steps.clone();
4877                    if tail > 0 && idx >= steps.len() - tail {
4878                        tail += spliced.len() - 1;
4879                    }
4880                    let landing = idx + spliced.len() - 1;
4881                    steps.splice(idx..idx + 1, spliced);
4882                    // Every filter already queued for a later step shifts
4883                    // along with it.
4884                    let shift = landing - idx;
4885                    for (i, _) in pending_filters.iter_mut() {
4886                        if *i > idx {
4887                            *i += shift;
4888                        }
4889                    }
4890                    if let Some(f) = filter {
4891                        pending_filters.push((landing, f));
4892                    }
4893                    continue;
4894                }
4895
4896                // A computed backed by an object-returning function —
4897                // `translation := latest(.id)`. There is no path to splice
4898                // in, so the call itself becomes the next row source: a
4899                // LATERAL join, since its arguments read the alias the
4900                // traversal has reached.
4901                if let Some((fc, modifiers)) = Self::function_subject(&expr_ast)
4902                    && let Some(fd) = self.resolve_object_fn(fc)
4903                {
4904                    if fd.params.len() != fc.args.len() {
4905                        return Err(self.type_err(&format!(
4906                            "function '{}::{}' expects {} argument(s), got {}",
4907                            fd.module,
4908                            fd.name,
4909                            fd.params.len(),
4910                            fc.args.len()
4911                        )));
4912                    }
4913                    let (fn_module, fn_name, return_type_name) =
4914                        (fd.module.clone(), fd.name.clone(), fd.return_pg_type.clone());
4915                    let mut args = fc
4916                        .args
4917                        .iter()
4918                        .map(|a| self.compile_expr(a, current_td, &current_alias))
4919                        .collect::<Result<Vec<_>, _>>()?;
4920                    let qualified = format!("{fn_module}::{fn_name}");
4921                    if let Some(globals) = self.globals_arg_for_call(&qualified)? {
4922                        args.insert(0, globals);
4923                    }
4924                    let target_td = self.resolve_type(&return_type_name)?;
4925                    let target_alias = self.fresh_alias();
4926                    let target = IrSource {
4927                        poly: None,
4928                        type_name: format!("{}::{}", target_td.module, target_td.name),
4929                        table: target_td.table.clone(),
4930                        alias: target_alias.clone(),
4931                    };
4932                    joins.push(IrPathJoin::Function {
4933                        fn_module,
4934                        fn_name,
4935                        args,
4936                        target,
4937                    });
4938                    if let Some(f) = modifiers.and_then(|m| m.filter.clone()) {
4939                        let cond = self.as_condition(|c| c.compile_expr(&f, target_td, &target_alias))?;
4940                        extra_conditions.push(cond);
4941                    }
4942                    if is_last(0) {
4943                        let shape = self.compile_shape_anchored(
4944                            shape_elements,
4945                            target_td,
4946                            &target_alias,
4947                            &target_td.module.clone(),
4948                        )?;
4949                        let result = IrPathResult::Object {
4950                            alias: target_alias.clone(),
4951                            type_name: format!("{}::{}", target_td.module, target_td.name),
4952                            shape,
4953                        };
4954                        let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4955                            sel,
4956                            target_td,
4957                            &target_alias,
4958                            junction_scope.clone(),
4959                            shape_elements,
4960                        )?;
4961                        return Ok(IrPathSelect {
4962                            root,
4963                            joins,
4964                            result,
4965                            filter: and_conditions(filter, extra_conditions),
4966                            order_by,
4967                            offset,
4968                            limit,
4969                            distinct,
4970                            poly_implementors: vec![],
4971                        });
4972                    }
4973                    current_td = target_td;
4974                    current_alias = target_alias;
4975                    idx += 1;
4976                    continue;
4977                }
4978
4979                if !is_last(0) {
4980                    return Err(self.type_err(&format!(
4981                        "'{step_name}' is a computed pointer — it has no stored column to traverse further through"
4982                    )));
4983                }
4984                let expanding = format!("{}::{}.{step_name}", current_td.module, current_td.name);
4985                if self.expanding_computeds.contains(&expanding) {
4986                    return Err(self.type_err(&format!(
4987                        "computed pointer '{step_name}' expands into itself — \
4988                         a path cannot be resolved through a cycle of computed pointers"
4989                    )));
4990                }
4991                self.expanding_computeds.push(expanding);
4992                let expr = self.compile_expr(&expr_ast, current_td, &current_alias);
4993                self.expanding_computeds.pop();
4994                let expr = expr?;
4995                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4996                    sel,
4997                    current_td,
4998                    &current_alias,
4999                    junction_scope.clone(),
5000                    shape_elements,
5001                )?;
5002                return Ok(IrPathSelect {
5003                    root,
5004                    joins,
5005                    result: IrPathResult::Scalar(expr, None),
5006                    filter: and_conditions(filter, extra_conditions),
5007                    order_by,
5008                    offset,
5009                    limit,
5010                    distinct,
5011                    poly_implementors: vec![],
5012                });
5013            }
5014
5015            return Err(self.field_err(step_name, &format!("{}::{}", current_td.module, current_td.name)));
5016        }
5017
5018        // Every step consumed with nothing returned means the path *ended* on a
5019        // narrowing (`a.respondents[is GuestRespondent]`, or a bare `[is T]`).
5020        // The loop has already joined to the narrowed relation, so what it
5021        // landed on is the answer — read with the shape, like any other object
5022        // step.
5023        if matches!(steps.last(), Some(PathStep::TypeIntersection(_))) {
5024            let module = current_td.module.clone();
5025            let type_name = format!("{}::{}", current_td.module, current_td.name);
5026            let shape = self.compile_shape_anchored(shape_elements, current_td, &current_alias, &module)?;
5027            let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
5028                sel,
5029                current_td,
5030                &current_alias,
5031                junction_scope.clone(),
5032                shape_elements,
5033            )?;
5034            return Ok(IrPathSelect {
5035                root,
5036                joins,
5037                result: IrPathResult::Object {
5038                    alias: current_alias,
5039                    type_name,
5040                    shape,
5041                },
5042                filter: and_conditions(filter, extra_conditions),
5043                order_by,
5044                offset,
5045                limit,
5046                distinct,
5047                poly_implementors: vec![],
5048            });
5049        }
5050
5051        // Should be unreachable: steps is non-empty (we checked len > 1 before dispatch).
5052        Err(self.type_err("empty path traversal"))
5053    }
5054
5055    /// `compile_path_modifiers` with the junction the traversal last crossed
5056    /// in scope, so a bare `@prop` in the select's own FILTER/ORDER BY can
5057    /// read it. Pushes nothing when there is no junction, leaving `@prop` to
5058    /// report that it has no link to belong to.
5059    fn compile_path_modifiers_scoped(
5060        &mut self,
5061        sel: &ast::SelectStmt,
5062        td: &TypeDescriptor,
5063        alias: &str,
5064        junction: Option<(String, String)>,
5065        shape: &[ShapeElement],
5066    ) -> Result<SelectModifiers, PyQLError> {
5067        let pushed = junction.is_some();
5068        if pushed {
5069            self.link_prop_scope.push(junction);
5070        }
5071        // `select .applied_promotions { name := .promotion.name } order by .name`
5072        // — the shape's own computeds are in scope for the clauses beside it,
5073        // the same as for a schema select. Added only now, after the shape has
5074        // been compiled, so one shape pointer still cannot read another.
5075        let outer_declared = self.active_declared_pointers.clone();
5076        self.active_declared_pointers
5077            .extend(shape.iter().filter(|el| el.compexpr.is_some()).cloned());
5078        let anchored = self.modifier_anchor.take();
5079        let tail_sorts = std::mem::take(&mut self.tail_sorts);
5080        let result = match &anchored {
5081            Some((qualified, anchor_alias)) => {
5082                let anchor_td = self.resolve_type(qualified)?;
5083                let anchor_alias = anchor_alias.clone();
5084                self.compile_path_modifiers(sel, anchor_td, &anchor_alias)
5085            }
5086            None => self.compile_path_modifiers(sel, td, alias),
5087        };
5088        let result = result.and_then(|(filter, mut order_by, offset, limit)| {
5089            if !tail_sorts.is_empty() {
5090                let sorting = ast::SelectStmt {
5091                    result: sel.result.clone(),
5092                    filter: None,
5093                    order_by: tail_sorts,
5094                    offset: None,
5095                    limit: None,
5096                    lock: None,
5097                };
5098                let (_, sorts, _, _) = self.compile_path_modifiers(&sorting, td, alias)?;
5099                order_by = sorts;
5100            }
5101            Ok((filter, order_by, offset, limit))
5102        });
5103        if pushed {
5104            self.link_prop_scope.pop();
5105        }
5106        self.active_declared_pointers = outer_declared;
5107        result
5108    }
5109
5110    fn compile_path_modifiers(
5111        &mut self,
5112        sel: &ast::SelectStmt,
5113        td: &TypeDescriptor,
5114        alias: &str,
5115    ) -> Result<SelectModifiers, PyQLError> {
5116        self.anchors.push(SelectAnchor {
5117            type_name: td.name.clone(),
5118            qualified: format!("{}::{}", td.module, td.name),
5119            alias: alias.to_string(),
5120            detached: std::mem::take(&mut self.pending_detached),
5121            declared_on: None,
5122        });
5123        let result = self.compile_path_modifiers_inner(sel, td, alias);
5124        self.anchors.pop();
5125        result
5126    }
5127
5128    fn compile_path_modifiers_inner(
5129        &mut self,
5130        sel: &ast::SelectStmt,
5131        td: &TypeDescriptor,
5132        alias: &str,
5133    ) -> Result<SelectModifiers, PyQLError> {
5134        let filter = sel
5135            .filter
5136            .as_ref()
5137            .map(|f| self.as_condition(|c| c.compile_expr(f, td, alias)))
5138            .transpose()?;
5139        let order_by = sel
5140            .order_by
5141            .iter()
5142            .map(|s| self.compile_sort(s, td, alias))
5143            .collect::<Result<Vec<_>, _>>()?;
5144        let offset = sel
5145            .offset
5146            .as_ref()
5147            .map(|e| self.compile_expr(e, td, alias))
5148            .transpose()?;
5149        let limit = sel
5150            .limit
5151            .as_ref()
5152            .map(|e| self.compile_expr(e, td, alias))
5153            .transpose()?;
5154        Ok((filter, order_by, offset, limit))
5155    }
5156
5157    // ── Expression-over-type dispatch ─────────────────────────────────────────────
5158
5159    /// The name of the set of objects `expr` stands for, when it is nothing
5160    /// but that name: a `with` binding of objects, or a type. Anything that
5161    /// walks off one (`accounts.owner`) is a path, not the set itself, and
5162    /// `find_path_root_in_expr` is what reads those.
5163    fn object_set_name(&self, expr: &Expr) -> Option<String> {
5164        let Expr::Path(p) = expr else { return None };
5165        if p.partial || p.steps.len() != 1 {
5166            return None;
5167        }
5168        let ast::PathStep::Name(name) = &p.steps[0] else {
5169            return None;
5170        };
5171        if self.cte_object_type(name).is_some() {
5172            return Some(name.clone());
5173        }
5174        // A bare name is a type only when nothing nearer claims it — the same
5175        // precedence `resolve_name_ref` gives for-vars, bindings and function
5176        // parameters over a schema type of the same name.
5177        let bound = self.for_vars.contains_key(name)
5178            || self.cte_free_items.contains_key(name)
5179            || self.inline_bindings.contains_key(name)
5180            || self.cte_types.contains_key(name)
5181            || self.fn_params.contains_key(name);
5182        if !bound && self.resolve_type(name).is_ok() {
5183            return Some(name.clone());
5184        }
5185        None
5186    }
5187
5188    /// Recursively find the first absolute path (non-partial, multi-step) in an expression
5189    /// and return its root type name if it resolves to a known schema type.
5190    fn find_path_root_in_expr(&self, expr: &Expr) -> Option<String> {
5191        match expr {
5192            Expr::Path(p) if !p.partial && p.steps.len() > 1 => {
5193                if let ast::PathStep::Name(root) = &p.steps[0]
5194                    && (self.resolve_type(root).is_ok() || self.cte_object_type(root).is_some())
5195                {
5196                    return Some(root.clone());
5197                }
5198                None
5199            }
5200            Expr::FunctionCall(f) => f.args.iter().find_map(|a| self.find_path_root_in_expr(a)),
5201            Expr::BinOp(b) => self
5202                .find_path_root_in_expr(&b.left)
5203                .or_else(|| self.find_path_root_in_expr(&b.right)),
5204            Expr::UnaryOp(u) => self.find_path_root_in_expr(&u.operand),
5205            _ => None,
5206        }
5207    }
5208
5209    /// Rewrite absolute paths rooted at `root_name` to relative (partial) paths.
5210    fn rewrite_abs_to_partial(expr: Expr, root_name: &str) -> Expr {
5211        match expr {
5212            Expr::Path(ref p) if !p.partial => {
5213                if let ast::PathStep::Name(first) = &p.steps[0]
5214                    && first == root_name
5215                    && p.steps.len() > 1
5216                {
5217                    return Expr::Path(ast::Path {
5218                        steps: p.steps[1..].to_vec(),
5219                        partial: true,
5220                    });
5221                }
5222                expr
5223            }
5224            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
5225                module: f.module,
5226                name: f.name,
5227                args: f
5228                    .args
5229                    .into_iter()
5230                    .map(|a| Self::rewrite_abs_to_partial(a, root_name))
5231                    .collect(),
5232                kwargs: f.kwargs,
5233            }),
5234            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
5235                left: Self::rewrite_abs_to_partial(b.left, root_name),
5236                op: b.op,
5237                right: Self::rewrite_abs_to_partial(b.right, root_name),
5238            })),
5239            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
5240                op: u.op,
5241                operand: Self::rewrite_abs_to_partial(u.operand, root_name),
5242            })),
5243            other => other,
5244        }
5245    }
5246
5247    /// The pointers a sub-select's shape declares, as the relative paths they
5248    /// stand for: `{ handle := [is Handle].handle }` → `handle` → `[is
5249    /// Handle].handle`. `None` when any element is something other than a
5250    /// named pointer defined by a relative path, which this substitution
5251    /// cannot stand in for.
5252    fn shape_alias_paths(elements: &[ShapeElement]) -> Option<Vec<(String, ast::Path)>> {
5253        let mut defs = Vec::with_capacity(elements.len());
5254        for element in elements {
5255            let [ast::PathStep::Name(name)] = element.path.steps.as_slice() else {
5256                return None;
5257            };
5258            match &element.compexpr {
5259                Some(Expr::Path(p)) if p.partial => defs.push((name.clone(), p.clone())),
5260                _ => return None,
5261            }
5262        }
5263        Some(defs)
5264    }
5265
5266    /// Replace each `.alias` a sub-select's shape declares with the path it
5267    /// stands for, so the select's own FILTER/ORDER BY read the same thing the
5268    /// shape projects.
5269    fn substitute_shape_aliases(expr: Expr, defs: &[(String, ast::Path)]) -> Expr {
5270        match expr {
5271            Expr::Path(ref p) if p.partial => {
5272                let Some(ast::PathStep::Name(first)) = p.steps.first() else {
5273                    return expr;
5274                };
5275                let Some((_, definition)) = defs.iter().find(|(name, _)| name == first) else {
5276                    return expr;
5277                };
5278                let mut steps = definition.steps.clone();
5279                steps.extend(p.steps[1..].iter().cloned());
5280                Expr::Path(ast::Path { steps, partial: true })
5281            }
5282            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
5283                module: f.module,
5284                name: f.name,
5285                args: f
5286                    .args
5287                    .into_iter()
5288                    .map(|a| Self::substitute_shape_aliases(a, defs))
5289                    .collect(),
5290                kwargs: f.kwargs,
5291            }),
5292            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
5293                left: Self::substitute_shape_aliases(b.left, defs),
5294                op: b.op,
5295                right: Self::substitute_shape_aliases(b.right, defs),
5296            })),
5297            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
5298                op: u.op,
5299                operand: Self::substitute_shape_aliases(u.operand, defs),
5300            })),
5301            other => other,
5302        }
5303    }
5304
5305    /// `array_agg(array_unpack(X))` — the aggregate runs over the elements the
5306    /// array unpacks to. PostgreSQL rejects a set-returning call inside an
5307    /// aggregate ("aggregate function calls cannot contain set-returning
5308    /// function calls"), so the unpacking moves to a row source of its own and
5309    /// the aggregate reads that from outside. `None` when the result is not an
5310    /// aggregate over an unpacked array, leaving the ordinary routes to it.
5311    fn aggregate_over_unpacked(
5312        &mut self,
5313        sel: &ast::SelectStmt,
5314        result: &Expr,
5315        distinct: bool,
5316    ) -> Result<Option<IrExpr>, PyQLError> {
5317        let Expr::FunctionCall(f) = result else {
5318            return Ok(None);
5319        };
5320        let [Expr::FunctionCall(unpack)] = f.args.as_slice() else {
5321            return Ok(None);
5322        };
5323        if !f.kwargs.is_empty()
5324            || !unpack.kwargs.is_empty()
5325            || unpack.module.as_deref().unwrap_or("std") != "std"
5326            || unpack.name != "array_unpack"
5327        {
5328            return Ok(None);
5329        }
5330        let [array] = unpack.args.as_slice() else {
5331            return Ok(None);
5332        };
5333        let namespace = f.module.as_deref().unwrap_or("std");
5334        let Some(sql_name) =
5335            crate::stdlib::lookup(namespace, &f.name)
5336                .into_iter()
5337                .find_map(|d| match &d.impl_strategy {
5338                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => Some(sql_name.to_string()),
5339                    _ => None,
5340                })
5341        else {
5342            return Ok(None);
5343        };
5344
5345        // A walk is unpacked one row at a time, so the unnest belongs in that
5346        // walk's own projection; anything else is a single array standing on
5347        // its own and unnests directly.
5348        let elements = match array {
5349            Expr::Path(path) if !path.partial && path.steps.len() > 1 => {
5350                let walk = ast::Path {
5351                    partial: false,
5352                    steps: path.steps.clone(),
5353                };
5354                let mut path_select = self.compile_path_select(sel, &walk, &[], distinct)?;
5355                let IrPathResult::Scalar(column, _) = path_select.result else {
5356                    return Ok(None);
5357                };
5358                path_select.result = IrPathResult::Scalar(
5359                    IrExpr::FunctionCall(IrFunctionCall {
5360                        return_pg_type: None,
5361                        schema: None,
5362                        name: "unnest".to_string(),
5363                        args: vec![column],
5364                        sql_template: None,
5365                    }),
5366                    None,
5367                );
5368                IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(path_select))))
5369            }
5370            _ if sel.filter.is_none()
5371                && sel.order_by.is_empty()
5372                && sel.offset.is_none()
5373                && sel.limit.is_none()
5374                && !distinct =>
5375            {
5376                self.compile_expr_ctx(array, None)?
5377            }
5378            _ => return Ok(None),
5379        };
5380
5381        // Over no rows SQL's aggregates give NULL where PyQL's give the empty
5382        // set's own value.
5383        let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
5384        Ok(Some(IrExpr::FunctionCall(IrFunctionCall {
5385            return_pg_type: None,
5386            schema: None,
5387            name: sql_name.clone(),
5388            args: vec![elements],
5389            sql_template: Some(format!(
5390                "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
5391            )),
5392        })))
5393    }
5394
5395    /// Compile an expression that contains a type-rooted absolute path as a flat
5396    /// path select, iterating over the root type and projecting the expression as
5397    /// a scalar computed pointer.
5398    fn compile_expr_as_path_select(
5399        &mut self,
5400        sel: &ast::SelectStmt,
5401        result: &Expr,
5402        root_type_name: &str,
5403        distinct: bool,
5404    ) -> Result<IrPathSelect, PyQLError> {
5405        // `array_agg(a.sessions.id)` — a single-argument aggregate over a
5406        // path. The aggregate applies to the set the path traverses *to*, so
5407        // the traversal becomes this select's own row source and the aggregate
5408        // wraps the column it lands on. Compiling the path as an expression
5409        // instead yields the array it stands for, and aggregating that nests it
5410        // one level deep.
5411        // `array_agg(distinct X.p)` — the DISTINCT belongs *inside* the
5412        // aggregate. The unary `distinct` emits its operand unchanged (it is
5413        // normally applied where the set is built, which an aggregate argument
5414        // is not), so left here it silently keeps the duplicates.
5415        let (agg_arg, agg_distinct) = match result {
5416            Expr::FunctionCall(f) if f.args.len() == 1 => match &f.args[0] {
5417                Expr::UnaryOp(u) if matches!(u.op, ast::UnaryOpKind::Distinct) => (Some(&u.operand), true),
5418                other => (Some(other), false),
5419            },
5420            _ => (None, false),
5421        };
5422        if let Expr::FunctionCall(f) = result
5423            && f.args.len() == 1
5424            && let Some(Expr::Path(p)) = agg_arg
5425            && !p.partial
5426            && p.steps.len() > 1
5427        {
5428            let arg_path = ast::Path {
5429                partial: false,
5430                steps: p.steps.clone(),
5431            };
5432            let mut ps = self.compile_path_select(sel, &arg_path, &[], distinct)?;
5433            // A walk onto objects (`count(.<brand[is Licence])`) is counted by
5434            // its rows, which is its key. Falling through instead put the
5435            // aggregate back through the expression route that sent it here.
5436            let aggregated = match ps.result.clone() {
5437                IrPathResult::Scalar(column, _) => Some(column),
5438                IrPathResult::Object { alias, .. } => Some(IrExpr::ColumnRef {
5439                    alias,
5440                    column: "id".to_string(),
5441                    pg_type: "uuid".to_string(),
5442                }),
5443            };
5444            if let Some(column) = aggregated {
5445                let mut args = vec![column];
5446                if !f.kwargs.is_empty() {
5447                    if !matches!(f.name.as_str(), "assert_single" | "assert_exists" | "assert_distinct") {
5448                        return Err(self.type_err(&format!(
5449                            "function '{}' does not take named arguments, got '{}'",
5450                            f.name, f.kwargs[0].0
5451                        )));
5452                    }
5453                    args.extend(self.assert_message(f, None)?);
5454                }
5455                let mut call = self.resolve_fn_call(f.module.as_deref(), &f.name, args)?;
5456                if agg_distinct {
5457                    let IrExpr::FunctionCall(fc) = &mut call else {
5458                        return Err(self.type_err(&format!(
5459                            "'{}' does not take a distinct argument — it does not resolve to an aggregate",
5460                            f.name
5461                        )));
5462                    };
5463                    if fc.schema.is_some() || fc.sql_template.is_some() {
5464                        return Err(self.type_err(&format!(
5465                            "'distinct' inside '{}' is not supported — only a plain SQL aggregate can take it",
5466                            f.name
5467                        )));
5468                    }
5469                    fc.sql_template = Some(format!("{}(DISTINCT $1)", fc.name));
5470                }
5471                ps.result = IrPathResult::Scalar(aggregate_over_nothing(&f.name, call), None);
5472                return Ok(ps);
5473            }
5474        }
5475
5476        // BinOp where one side is the absolute type-rooted path:
5477        // Build the join chain for the path side, then apply the comparison
5478        // element-wise so we get one boolean per traversal element (not one EXISTS per root).
5479        if let Expr::BinOp(b) = result {
5480            let (path_expr, value_expr, flip) = match (&b.left, &b.right) {
5481                (Expr::Path(p), v) if !p.partial => (p, v, false),
5482                (v, Expr::Path(p)) if !p.partial => (p, v, true),
5483                _ => return self.compile_expr_as_path_select_fallback(sel, result, root_type_name, distinct),
5484            };
5485            // Compile value side first so we can report its type in errors
5486            let root_td = match self.cte_object_type(root_type_name) {
5487                Some(t) => self.resolve_type(&t)?,
5488                None => self.resolve_type(root_type_name)?,
5489            };
5490            // Build PathSelect for the path (builds root + joins + scalar/object result)
5491            let ast_path = ast::Path {
5492                partial: false,
5493                steps: path_expr.steps.clone(),
5494            };
5495            let mut ps = self.compile_path_select(sel, &ast_path, &[], distinct)?;
5496            let val_ir = self.compile_expr(value_expr, root_td, &ps.root.alias)?;
5497            // Extract the scalar result from the path
5498            let scalar_col = match ps.result {
5499                IrPathResult::Scalar(e, _) => e,
5500                IrPathResult::Object { type_name, .. } => {
5501                    let val_type = infer_ir_type(&val_ir).map(pg_type_to_pyql).unwrap_or("unknown");
5502                    return Err(PyQLError::Type(PyQLTypeError {
5503                        message: format!(
5504                            "operator '{}' cannot be applied to operands of type '{}' and '{}'",
5505                            b.op, type_name, val_type,
5506                        ),
5507                        position: Position { line: 0, col: 0 },
5508                    }));
5509                }
5510            };
5511            let (l, r) = if flip {
5512                (val_ir, scalar_col)
5513            } else {
5514                (scalar_col, val_ir)
5515            };
5516            ps.result = IrPathResult::Scalar(
5517                IrExpr::BinOp(Box::new(IrBinOp {
5518                    left: true_division_operand(&b.op, l, &r),
5519                    op: b.op.clone(),
5520                    right: r,
5521                })),
5522                None,
5523            );
5524            return Ok(ps);
5525        }
5526        self.compile_expr_as_path_select_fallback(sel, result, root_type_name, distinct)
5527    }
5528
5529    fn compile_expr_as_path_select_fallback(
5530        &mut self,
5531        sel: &ast::SelectStmt,
5532        result: &Expr,
5533        root_type_name: &str,
5534        distinct: bool,
5535    ) -> Result<IrPathSelect, PyQLError> {
5536        let cte_object_type = self.cte_object_type(root_type_name);
5537        let td = match &cte_object_type {
5538            Some(t) => self.resolve_type(t)?,
5539            None => self.resolve_type(root_type_name)?,
5540        };
5541        let alias = self.fresh_alias();
5542        let root = IrSource {
5543            poly: None,
5544            type_name: format!("{}::{}", td.module, td.name),
5545            table: match &cte_object_type {
5546                Some(_) => self.cte_source_table(root_type_name),
5547                None => td.table.clone(),
5548            },
5549            alias: alias.clone(),
5550        };
5551        let rewritten = Self::rewrite_abs_to_partial(result.clone(), root_type_name);
5552        let expr = self.compile_expr(&rewritten, td, &alias)?;
5553        let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, td, &alias)?;
5554        Ok(IrPathSelect {
5555            root,
5556            joins: vec![],
5557            result: IrPathResult::Scalar(expr, None),
5558            filter,
5559            order_by,
5560            offset,
5561            limit,
5562            distinct,
5563            poly_implementors: vec![],
5564        })
5565    }
5566
5567    /// Compile a SubQuery stmt to an `IrArraySource` for use in assert functions.
5568    /// The result is `ARRAY(SELECT scalar FROM compiled_inner)`.
5569    fn compile_subquery_to_array_source(&mut self, stmt: &Stmt) -> Result<IrArraySource, PyQLError> {
5570        match self.compile_stmt(stmt)? {
5571            IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
5572                Ok(IrArraySource::Select(s))
5573            }
5574            IrStmt::PathSelect(ps) => Ok(IrArraySource::PathSelect(Box::new(ps))),
5575            _ => Err(self.type_err("assert functions require a schema-bound SELECT as argument")),
5576        }
5577    }
5578
5579    /// `exists` in either schema-bound (FILTER, computed pointer, etc. —
5580    /// `ctx = Some((td, alias))`) or free (`ctx = None`) expression context.
5581    /// The `Path`-based arms (backlink / prop / link / multilink existence)
5582    /// only apply schema-bound — guarded on `ctx.is_some()` — since none of
5583    /// those concepts exist without a schema type in scope; everything else
5584    /// (`Parameter`, `TypeCast`, `SubQuery`, and the scalar-expression
5585    /// fallback) is identical either way and just threads `ctx` through.
5586    fn compile_exists_ctx(
5587        &mut self,
5588        operand: &Expr,
5589        ctx: Option<(&TypeDescriptor, &str)>,
5590    ) -> Result<IrExpr, PyQLError> {
5591        match operand {
5592            // exists $param  /  exists <type>$param → $N IS NOT NULL
5593            Expr::Parameter(name) => {
5594                let idx = self.param_index(name);
5595                Ok(ir_is_not_null(IrExpr::Param { index: idx }))
5596            }
5597            // exists <type>expr → expr IS NOT NULL (cast result is always a scalar)
5598            Expr::TypeCast(_) => {
5599                let inner = self.compile_expr_ctx(operand, ctx)?;
5600                Ok(ir_is_not_null(inner))
5601            }
5602
5603            // exists .<link[is Type] → EXISTS(SELECT 1 FROM type WHERE type.link_id = alias.id)
5604            Expr::Path(p)
5605                if ctx.is_some() && p.partial && matches!(p.steps.first(), Some(ast::PathStep::Backlink(_))) =>
5606            {
5607                let (td, alias) = ctx.unwrap();
5608                let current_qname = format!("{}::{}", td.module, td.name);
5609                let exists = self.compile_backlink_as_exists(&p.steps, None, &current_qname, alias)?;
5610                Ok(exists)
5611            }
5612
5613            // exists .prop → alias.col IS NOT NULL
5614            // exists .link → alias.link_id IS NOT NULL
5615            // exists .multilink → EXISTS(SELECT 1 FROM junction WHERE src = alias.id)
5616            Expr::Path(p) if ctx.is_some() && p.partial && p.steps.len() == 1 => {
5617                let (td, alias) = ctx.unwrap();
5618                // `exists @dismissed` — a link property is a column on the
5619                // junction row the multi-link's own modifiers are being
5620                // compiled against, so whether it is set is an ordinary null
5621                // check on it.
5622                if let ast::PathStep::LinkProp(prop_name) = &p.steps[0] {
5623                    let prop = self.compile_link_prop_ref(prop_name)?;
5624                    return Ok(ir_is_not_null(prop));
5625                }
5626                // `filter exists .n` where the shape in scope declared `n` —
5627                // the pointer is on no type, so it is read as the expression it
5628                // was written as.
5629                if let ast::PathStep::Name(n) = &p.steps[0]
5630                    && Self::resolve_property(td, n).is_none()
5631                    && Self::resolve_link(td, n).is_none()
5632                    && Self::resolve_multilink(td, n).is_none()
5633                    && self.resolve_computed(td, n).is_none()
5634                    && let Some(expr) = self
5635                        .active_declared_pointers
5636                        .iter()
5637                        .find(|d| path_leaf(&d.path).is_ok_and(|name| name == n))
5638                        .and_then(|d| d.compexpr.clone())
5639                {
5640                    let inner = self.compile_expr(&expr, td, alias)?;
5641                    return Ok(ir_is_not_null(inner));
5642                }
5643                // `exists [is contact::IndividualContact]` — a bare narrowing
5644                // is the current row seen as that type, so whether it exists is
5645                // whether the row *is* one.
5646                if let ast::PathStep::TypeIntersection(type_ref) = &p.steps[0] {
5647                    let narrowed = self.resolve_type(&type_ref.qualified_name())?;
5648                    let check = format!("{}::{}", narrowed.module, narrowed.name);
5649                    let source = format!("{}::{}", td.module, td.name);
5650                    return Ok(self.type_check_bool_expr(&source, &check, td, alias));
5651                }
5652                let pointer_name = match &p.steps[0] {
5653                    ast::PathStep::Name(n) => n.as_str(),
5654                    _ => return Err(self.type_err("exists: invalid path step")),
5655                };
5656                if let Some(prop) = Self::resolve_property(td, pointer_name) {
5657                    return Ok(ir_is_not_null(IrExpr::ColumnRef {
5658                        alias: alias.to_string(),
5659                        column: prop.name.clone(),
5660                        pg_type: prop.pg_type.clone(),
5661                    }));
5662                }
5663                if let Some(link) = Self::resolve_link(td, pointer_name) {
5664                    if link.is_junction_backed() {
5665                        return self.compile_junction_link_exists_check(link, td, alias);
5666                    }
5667                    return Ok(ir_is_not_null(IrExpr::ColumnRef {
5668                        alias: alias.to_string(),
5669                        column: format!("{}_id", link.name),
5670                        pg_type: "uuid".to_string(),
5671                    }));
5672                }
5673                if Self::resolve_multilink(td, pointer_name).is_some() {
5674                    return self.compile_multilink_exists_check(pointer_name, td, alias);
5675                }
5676                // `exists .primary_email` — a computed pointer stands for the
5677                // path it names, and whether *that* is empty is the question.
5678                // Checked last, so a stored pointer of the same name still
5679                // wins; without it the suggester could see the name the
5680                // resolver had just reported as unknown.
5681                if let Some(cd) = self.resolve_computed(td, pointer_name) {
5682                    let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
5683                    return self.compile_exists_ctx(&expr_ast, ctx);
5684                }
5685                Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
5686            }
5687
5688            // `exists ((select .prices filter …))` — a sub-select over a
5689            // relative path is relative to the enclosing object, so it needs
5690            // the same rooting and correlation `count((select .prices))`
5691            // already gets. Compiled without them it resolves in free context
5692            // and reports the pointer as unknown.
5693            Expr::SubQuery(stmt)
5694                if ctx.is_some()
5695                    && matches!(
5696                        stmt.as_ref(),
5697                        Stmt::Select(inner) if matches!(&inner.result, Expr::Path(p) if p.partial)
5698                    ) =>
5699            {
5700                let Stmt::Select(inner) = stmt.as_ref() else {
5701                    unreachable!("checked by the guard")
5702                };
5703                let Expr::Path(path) = &inner.result else {
5704                    unreachable!("checked by the guard")
5705                };
5706                let (td, alias) = ctx.expect("checked by the guard");
5707                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
5708                steps.extend(path.steps.iter().cloned());
5709                let full_path = ast::Path { steps, partial: false };
5710                let rooted = ast::SelectStmt {
5711                    result: Expr::Path(full_path.clone()),
5712                    filter: inner.filter.clone(),
5713                    order_by: inner.order_by.clone(),
5714                    offset: inner.offset.clone(),
5715                    limit: inner.limit.clone(),
5716                    lock: None,
5717                };
5718                let mut ps = self.compile_path_select(&rooted, &full_path, &[], false)?;
5719                Self::correlate_path_select(&mut ps, alias);
5720                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5721                    op: ast::UnaryOpKind::Exists,
5722                    operand: IrExpr::PathSubquery(Box::new(ps)),
5723                })))
5724            }
5725
5726            // exists (select ...) → EXISTS(SELECT 1 FROM ...)
5727            Expr::SubQuery(stmt) => self.compile_subquery_exists(stmt),
5728
5729            // `exists licenses` — a binding names a set, so it exists when it
5730            // has a row; the fallback's scalar subquery aborts on the second.
5731            operand if let Some(name) = self.resolve_cte_name(operand) => {
5732                let object = self.cte_types.get(name).is_some_and(|bound| bound.contains("::"));
5733                Ok(IrExpr::ExistsOverCte {
5734                    cte: name.to_string(),
5735                    column: (!object).then(|| "v".to_string()),
5736                })
5737            }
5738
5739            // Fallback: any scalar expression → expr IS NOT NULL. When `ctx`
5740            // is `None` and `operand` is a partial `Path` not caught above
5741            // (guards failed since ctx.is_some() was false), this recurses
5742            // into the free-context Path handling, which itself produces the
5743            // "not valid in free SELECT" error — same as before the merge.
5744            other => {
5745                let inner = self.compile_expr_ctx(other, ctx)?;
5746                // A set operation already knows how to ask whether it yields
5747                // anything; going through the array would build it first.
5748                if let IrExpr::SetOp { op, left, right, .. } = inner {
5749                    return Ok(IrExpr::SetOp {
5750                        op,
5751                        left,
5752                        right,
5753                        mode: super::SetOpMode::Exists,
5754                    });
5755                }
5756                Ok(ir_is_not_null(inner))
5757            }
5758        }
5759    }
5760
5761    /// Compile `exists (select ...)` → `EXISTS(SELECT 1 FROM ... WHERE ...)`.
5762    fn compile_subquery_exists(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
5763        match self.compile_stmt(stmt)? {
5764            IrStmt::Select(s) => {
5765                let mut rows = s.rows;
5766                if rows.len() != 1 {
5767                    return Err(self.type_err("exists requires a schema-bound SELECT expression"));
5768                }
5769                let IrRowSource::Bound { source, .. } = rows.remove(0) else {
5770                    return Err(self.type_err("exists requires a schema-bound SELECT expression"));
5771                };
5772                let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(source, vec![], s.filter)));
5773                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5774                    op: ast::UnaryOpKind::Exists,
5775                    operand: inner,
5776                })))
5777            }
5778            IrStmt::PathSelect(ps) => {
5779                // EXISTS(SELECT 1 FROM root [JOINs] WHERE filter)
5780                // Reuse the path select but signal "exists" via a dedicated IR node
5781                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5782                    op: ast::UnaryOpKind::Exists,
5783                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(ps.root, vec![], ps.filter))),
5784                })))
5785            }
5786            _ => Err(self.type_err("exists requires a SELECT expression")),
5787        }
5788    }
5789
5790    /// `EXISTS(SELECT 1 FROM junction WHERE junction.source = alias.id)` for a multi-link.
5791    /// Build the `IrSelect` over the junction/FK-target rows for a multilink,
5792    /// correlated to the current row (`alias.id`) — shared by `exists
5793    /// .multilink` and `count(.multilink)`.
5794    /// `EXISTS(SELECT 1 FROM junction WHERE junction.<source_col> = alias.id)`,
5795    /// correlated to the current row — shared by a multi-link's own `exists
5796    /// .multilink`/`count(.multilink)` and a junction-backed single link's
5797    /// `exists .link` (D2: same junction-info resolution either way).
5798    fn junction_correlation_select(
5799        &mut self,
5800        td: &TypeDescriptor,
5801        alias: &str,
5802        name: &str,
5803        target: &str,
5804        through: &Option<String>,
5805    ) -> Result<IrSelect, PyQLError> {
5806        let jt_alias = self.fresh_alias();
5807        let (jt_table, jt_module, jt_src_col, _, _) = self.junction_info_for(td, name, target, through)?;
5808
5809        let filter = IrExpr::BinOp(Box::new(IrBinOp {
5810            left: IrExpr::ColumnRef {
5811                alias: jt_alias.clone(),
5812                column: jt_src_col,
5813                pg_type: "uuid".to_string(),
5814            },
5815            op: ast::BinOpKind::Eq,
5816            right: IrExpr::ColumnRef {
5817                alias: alias.to_string(),
5818                column: "id".to_string(),
5819                pg_type: "uuid".to_string(),
5820            },
5821        }));
5822        Ok(IrSelect::schema_bound(
5823            IrSource {
5824                poly: None,
5825                type_name: format!("{}::__jt__", jt_module),
5826                table: jt_table,
5827                alias: jt_alias,
5828            },
5829            vec![],
5830            Some(filter),
5831        ))
5832    }
5833
5834    fn multilink_correlation_select(
5835        &mut self,
5836        ml_name: &str,
5837        td: &TypeDescriptor,
5838        alias: &str,
5839    ) -> Result<IrSelect, PyQLError> {
5840        let ml = Self::resolve_multilink(td, ml_name).unwrap();
5841        let (name, target, through) = (ml.name.clone(), ml.target.clone(), ml.through.clone());
5842        self.junction_correlation_select(td, alias, &name, &target, &through)
5843    }
5844
5845    fn compile_multilink_exists_check(
5846        &mut self,
5847        ml_name: &str,
5848        td: &TypeDescriptor,
5849        alias: &str,
5850    ) -> Result<IrExpr, PyQLError> {
5851        let inner = self.multilink_correlation_select(ml_name, td, alias)?;
5852        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5853            op: ast::UnaryOpKind::Exists,
5854            operand: IrExpr::Subquery(Box::new(inner)),
5855        })))
5856    }
5857
5858    /// Same as `compile_multilink_exists_check`, for `exists .link` where
5859    /// `link` is a junction-backed single link.
5860    fn compile_junction_link_exists_check(
5861        &mut self,
5862        l: &LinkDescriptor,
5863        td: &TypeDescriptor,
5864        alias: &str,
5865    ) -> Result<IrExpr, PyQLError> {
5866        let (name, target, through) = (l.name.clone(), l.target.clone(), l.through.clone());
5867        let inner = self.junction_correlation_select(td, alias, &name, &target, &through)?;
5868        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5869            op: ast::UnaryOpKind::Exists,
5870            operand: IrExpr::Subquery(Box::new(inner)),
5871        })))
5872    }
5873
5874    /// A junction-backed single link's target id, as a scalar correlated
5875    /// subquery — `(SELECT jt.<target_col> FROM junction AS jt WHERE
5876    /// jt.<source_col> = alias.id)`. Stands in wherever a plain single
5877    /// link's `{name}_id` FK column would otherwise be referenced directly
5878    /// as a scalar uuid expression (bare `.link` in a filter/order-by,
5879    /// `.link.id`, or correlating `.link.<other prop>`).
5880    fn junction_target_id_expr(
5881        &mut self,
5882        td: &TypeDescriptor,
5883        l: &LinkDescriptor,
5884        alias: &str,
5885    ) -> Result<IrExpr, PyQLError> {
5886        let (name, target, through) = (l.name.clone(), l.target.clone(), l.through.clone());
5887        let (jt_table, jt_module, jt_src_col, jt_tgt_col, _) = self.junction_info_for(td, &name, &target, &through)?;
5888        let jt_alias = self.fresh_alias();
5889        let filter = IrExpr::BinOp(Box::new(IrBinOp {
5890            left: IrExpr::ColumnRef {
5891                alias: jt_alias.clone(),
5892                column: jt_src_col,
5893                pg_type: "uuid".to_string(),
5894            },
5895            op: ast::BinOpKind::Eq,
5896            right: IrExpr::ColumnRef {
5897                alias: alias.to_string(),
5898                column: "id".to_string(),
5899                pg_type: "uuid".to_string(),
5900            },
5901        }));
5902        let select = IrSelect::schema_bound(
5903            IrSource {
5904                poly: None,
5905                type_name: format!("{}::__jt__", jt_module),
5906                table: jt_table,
5907                alias: jt_alias,
5908            },
5909            vec![IrShapePointer::Scalar(IrScalarPointer {
5910                implicit_id: false,
5911                marker_offset: None,
5912                alias: "target".to_string(),
5913                column: jt_tgt_col,
5914                pg_type: "uuid".to_string(),
5915                tuple_shape: None,
5916            })],
5917            Some(filter),
5918        );
5919        Ok(IrExpr::Subquery(Box::new(select)))
5920    }
5921
5922    /// Returns true when the SELECT result expression is not a schema type reference.
5923    /// Check that a UNION expression doesn't mix object types with scalars.
5924    /// Called before dispatch so we can give a clear error instead of "expected a type name".
5925    fn check_union_type_compat(&self, expr: &Expr) -> Result<(), PyQLError> {
5926        let Expr::Union(a, b) = expr else { return Ok(()) };
5927        let a_free = self.is_free_result(a);
5928        let b_free = self.is_free_result(b);
5929        if a_free != b_free {
5930            let left = self.union_operand_type_display(a);
5931            let right = self.union_operand_type_display(b);
5932            return Err(PyQLError::Type(PyQLTypeError {
5933                message: format!(
5934                    "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
5935                    left, right,
5936                ),
5937                position: Position { line: 0, col: 0 },
5938            }));
5939        }
5940        Ok(())
5941    }
5942
5943    fn union_operand_type_display(&self, expr: &Expr) -> String {
5944        match expr {
5945            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
5946                if let ast::PathStep::Name(n) = &p.steps[0]
5947                    && let Some(t) = self.cte_types.get(n.as_str())
5948                {
5949                    if t.contains("::") {
5950                        return t.clone(); // object CTE: "default::Person"
5951                    }
5952                    if !t.is_empty() {
5953                        return pg_type_to_pyql(t).to_string(); // scalar CTE: "std::int64"
5954                    }
5955                }
5956                // Bare type name reference
5957                if let Ok(td) = self.resolve_type(
5958                    p.steps
5959                        .first()
5960                        .and_then(|s| {
5961                            if let ast::PathStep::Name(n) = s {
5962                                Some(n.as_str())
5963                            } else {
5964                                None
5965                            }
5966                        })
5967                        .unwrap_or(""),
5968                ) {
5969                    return format!("{}::{}", td.module, td.name);
5970                }
5971            }
5972            Expr::Literal(Literal::Int(_)) => return "std::int64".to_string(),
5973            Expr::Literal(Literal::Str(_)) => return "std::str".to_string(),
5974            Expr::Literal(Literal::Float(_)) => return "std::float64".to_string(),
5975            Expr::Literal(Literal::Bool(_)) => return "std::bool".to_string(),
5976            _ => {}
5977        }
5978        "unknown".to_string()
5979    }
5980
5981    fn is_free_result(&self, expr: &Expr) -> bool {
5982        let expr = match expr {
5983            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => &u.operand,
5984            Expr::Detached(inner) => inner.as_ref(),
5985            other => other,
5986        };
5987        match expr {
5988            Expr::Path(p) if !p.partial => {
5989                if p.steps.len() == 1
5990                    && let ast::PathStep::Name(n) = &p.steps[0]
5991                {
5992                    // A for-loop variable over values is a scalar; one over
5993                    // objects stands for a row, and `select c` is a schema-
5994                    // bound select of its type.
5995                    if self.for_vars.contains_key(n.as_str()) {
5996                        return !self.for_var_types.contains_key(n.as_str());
5997                    }
5998                    // Scalar CTE: type string has no "::" (object types always do).
5999                    if self.is_value_binding(n.as_str()) {
6000                        return true;
6001                    }
6002                    // Function parameter — only populated while compiling a
6003                    // function body (see `compile_fn_body`), so a bare `select a`
6004                    // where `a` is a param must resolve as the param, not be
6005                    // misread as a schema-type-name select.
6006                    if self.fn_params.contains_key(n.as_str()) {
6007                        return true;
6008                    }
6009                }
6010                false
6011            }
6012            Expr::Shape(s) if s.expr.is_some() => false,
6013            Expr::SubQuery(_) => false,
6014            Expr::Union(a, b) => self.is_free_result(a) && self.is_free_result(b),
6015            // `A if cond else B` picks between two *sets*; when those are
6016            // object sets the result is an object set too, not the scalar an
6017            // if/else over values gives. An empty branch says nothing about
6018            // which it is, so it does not get a vote.
6019            Expr::IfElse(ie) => {
6020                let empty = |e: &Expr| matches!(e, Expr::Set(items) if items.is_empty());
6021                match (empty(&ie.if_expr), empty(&ie.else_expr)) {
6022                    (true, true) => true,
6023                    (true, false) => self.is_free_result(&ie.else_expr),
6024                    (false, true) => self.is_free_result(&ie.if_expr),
6025                    (false, false) => self.is_free_result(&ie.if_expr) || self.is_free_result(&ie.else_expr),
6026                }
6027            }
6028            _ => true,
6029        }
6030    }
6031
6032    /// True when `expr` is a bare 1-step name bound to a free (non-object)
6033    /// WITH binding — the same "scalar CTE" check `is_free_result` uses for
6034    /// its `Expr::Path` arm, factored out so `Expr::Shape`'s handling of
6035    /// "shape applied to a non-object" can reuse it too.
6036    fn is_free_cte_ref(&self, expr: &Expr) -> bool {
6037        let Expr::Path(p) = expr else { return false };
6038        if p.partial || p.steps.len() != 1 {
6039            return false;
6040        }
6041        let ast::PathStep::Name(n) = &p.steps[0] else {
6042            return false;
6043        };
6044        self.is_value_binding(n.as_str())
6045    }
6046
6047    // ── FREE SELECT ───────────────────────────────────────────────────────────────
6048
6049    /// A mutation standing where a value is expected. Postgres cannot run DML
6050    /// in a value list, so it becomes a data-modifying CTE — which it runs to
6051    /// completion whether or not the outer query reads it — and what stands
6052    /// here reads the ids back, so the value still names the rows the
6053    /// mutation touched.
6054    fn dml_as_value(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
6055        let (cte_name, type_name) = self.hoist_dml_as_cte(stmt)?;
6056        // Read back through a subquery rather than naming the CTE's column
6057        // directly: a free select has no FROM of its own for the reference to
6058        // resolve against.
6059        let source = IrSource {
6060            poly: None,
6061            type_name,
6062            table: self.cte_source_table(&cte_name),
6063            alias: self.fresh_alias(),
6064        };
6065        Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
6066            source,
6067            // An empty shape would emit the `SELECT 1` an EXISTS wants, which
6068            // says nothing about which rows the value stands for.
6069            vec![IrShapePointer::Scalar(IrScalarPointer {
6070                implicit_id: false,
6071                marker_offset: None,
6072                alias: "id".to_string(),
6073                column: "id".to_string(),
6074                pg_type: "uuid".to_string(),
6075                tuple_shape: None,
6076            })],
6077            None,
6078        ))))
6079    }
6080
6081    fn collect_union_items(&mut self, expr: &Expr, items: &mut Vec<IrFreeExpr>) -> Result<(), PyQLError> {
6082        match expr {
6083            Expr::Union(a, b) => {
6084                self.collect_union_items(a, items)?;
6085                self.collect_union_items(b, items)?;
6086            }
6087            Expr::Set(exprs) => {
6088                for e in exprs {
6089                    self.collect_union_items(e, items)?;
6090                }
6091            }
6092            // `select { (update A set …), (update B set …) }` — several
6093            // mutations in one statement. Postgres cannot run DML in a value
6094            // list, so each becomes a data-modifying CTE, which it runs to
6095            // completion whether or not the outer query reads it; the item
6096            // itself reads back the id, so the set still stands for the rows
6097            // the mutations touched.
6098            Expr::SubQuery(stmt) if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
6099                items.push(IrFreeExpr::Scalar(self.dml_as_value(stmt.as_ref())?));
6100            }
6101            other => {
6102                items.push(IrFreeExpr::Scalar(self.compile_free_expr(other)?));
6103            }
6104        }
6105        Ok(())
6106    }
6107
6108    /// `metadata := metadata { author }` — a shape over a `with` binding or a
6109    /// for-loop variable. Both name a row source, so the shape reads their
6110    /// rows; a bare type name is deliberately not accepted here, because in an
6111    /// enclosing scope that prefix is factored (see `outer_anchor`) and would
6112    /// mean the outer row rather than the whole table.
6113    fn shape_over_binding(&mut self, expr: &Expr) -> Result<Option<IrExpr>, PyQLError> {
6114        let Expr::Shape(sh) = expr else {
6115            return Ok(None);
6116        };
6117        let Some(Expr::Path(p)) = sh.expr.as_ref() else {
6118            return Ok(None);
6119        };
6120        let Some(ast::PathStep::Name(root)) = p.steps.first() else {
6121            return Ok(None);
6122        };
6123        if p.partial || (self.cte_object_type(root).is_none() && !self.for_var_types.contains_key(root)) {
6124            return Ok(None);
6125        }
6126        self.free_object_link_field(expr)
6127    }
6128
6129    /// A free object's field holding an object with a shape (`{ device := d
6130    /// { id } }`). A free shape is a real object type whose fields are real
6131    /// pointers, so an object field stays an object; without
6132    /// this it would compile to the object's bare id, which is not what was
6133    /// asked for.
6134    fn free_object_link_field(&mut self, expr: &Expr) -> Result<Option<IrExpr>, PyQLError> {
6135        let Expr::Shape(sh) = expr else {
6136            return Ok(None);
6137        };
6138        let Some(Expr::Path(p)) = sh.expr.as_ref() else {
6139            return Ok(None);
6140        };
6141        if p.partial {
6142            return Ok(None);
6143        }
6144        let rest_steps = p.steps.as_slice();
6145        let Some(ast::PathStep::Name(name)) = rest_steps.first() else {
6146            return Ok(None);
6147        };
6148        let cte_name = self.cte_object_type(name).map(|_| name.clone());
6149        let Ok(td) = self.resolve_path_root(name) else {
6150            return Ok(None);
6151        };
6152        let table = self.row_source_table(name, td);
6153        // `account := resource.account { id }` — the field holds what a walk
6154        // off the binding lands on, so it is that walk with the shape on it
6155        // rather than a plain read of the binding.
6156        if rest_steps.len() > 1 {
6157            let synthetic = ast::SelectStmt {
6158                result: Expr::Path(p.clone()),
6159                filter: None,
6160                order_by: vec![],
6161                offset: None,
6162                limit: None,
6163                lock: None,
6164            };
6165            let path_select = self.compile_path_select(&synthetic, p, &sh.elements, false)?;
6166            return Ok(Some(IrExpr::ObjectPathSubquery(Box::new(path_select))));
6167        }
6168        let alias = self.fresh_alias();
6169        let source = IrSource {
6170            poly: self.poly_fanout_for(&format!("{}::{}", td.module, td.name)),
6171            type_name: format!("{}::{}", td.module, td.name),
6172            table,
6173            alias: alias.clone(),
6174        };
6175        // Same as reading the binding by name: its own shape may have
6176        // declared pointers that exist on no type.
6177        let outer_declared = std::mem::replace(
6178            &mut self.active_declared_pointers,
6179            cte_name
6180                .as_deref()
6181                .and_then(|n| self.cte_declared_pointers.get(n).cloned())
6182                .unwrap_or_default(),
6183        );
6184        let shape = self.compile_shape(&sh.elements, td, &alias, &td.module);
6185        self.active_declared_pointers = outer_declared;
6186        // A for-loop variable holds one row's key, so the select has to be
6187        // narrowed to it -- unfiltered it would read the whole table.
6188        let filter = self.for_var_types.contains_key(name).then(|| {
6189            IrExpr::BinOp(Box::new(IrBinOp {
6190                left: IrExpr::ColumnRef {
6191                    alias: alias.clone(),
6192                    column: "id".to_string(),
6193                    pg_type: "uuid".to_string(),
6194                },
6195                op: ast::BinOpKind::Eq,
6196                right: self.for_var_ref(name),
6197            }))
6198        });
6199        Ok(Some(IrExpr::ObjectSubquery(Box::new(IrSelect::schema_bound(
6200            source, shape?, filter,
6201        )))))
6202    }
6203
6204    /// One field of a free object written as a whole SELECT's result. A
6205    /// mutation is allowed here for the same reason it is allowed as an item
6206    /// of a free set — see `dml_as_value`.
6207    fn free_object_field(&mut self, expr: &Expr) -> Result<IrExpr, PyQLError> {
6208        if let Some(object) = self.free_object_link_field(expr)? {
6209            return Ok(object);
6210        }
6211        match expr {
6212            Expr::SubQuery(stmt) if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
6213                self.dml_as_value(stmt.as_ref())
6214            }
6215            other => self.compile_free_expr(other),
6216        }
6217    }
6218
6219    fn compile_free_select(
6220        &mut self,
6221        sel: &ast::SelectStmt,
6222        result_expr: &Expr,
6223        distinct: bool,
6224    ) -> Result<IrSelect, PyQLError> {
6225        // `{ a := 1 } if cond else {}` — read as a scalar `CASE` it comes back
6226        // as a bare record instead of an object, so the object is the row and
6227        // the condition decides whether there is one.
6228        if let Expr::IfElse(ie) = result_expr {
6229            let is_empty_set = |expr: &Expr| matches!(expr, Expr::Set(items) if items.is_empty());
6230            let is_free_object = |expr: &Expr| matches!(expr, Expr::Shape(sh) if sh.expr.is_none());
6231            let guarded = if is_free_object(&ie.if_expr) && is_empty_set(&ie.else_expr) {
6232                Some((&ie.if_expr, false))
6233            } else if is_empty_set(&ie.if_expr) && is_free_object(&ie.else_expr) {
6234                Some((&ie.else_expr, true))
6235            } else {
6236                None
6237            };
6238            if let Some((object, negated)) = guarded {
6239                let condition = self.compile_free_expr(&ie.condition)?;
6240                let condition = if negated {
6241                    IrExpr::UnaryOp(Box::new(IrUnaryOp {
6242                        op: ast::UnaryOpKind::Not,
6243                        operand: condition,
6244                    }))
6245                } else {
6246                    condition
6247                };
6248                let mut select = self.compile_free_select(sel, object, distinct)?;
6249                select.filter = and_conditions(select.filter, vec![condition]);
6250                return Ok(select);
6251            }
6252        }
6253        let items: Vec<IrFreeExpr> = match result_expr {
6254            Expr::Union(_, _) | Expr::Set(_) => {
6255                let mut union_items = vec![];
6256                self.collect_union_items(result_expr, &mut union_items)?;
6257                // Type-check UNION operands: all scalar branches must be in the same type family.
6258                let mut first: Option<(String, String)> = None; // (pg_type, pyql_name)
6259                for item in &union_items {
6260                    if let IrFreeExpr::Scalar(expr) = item
6261                        && let Some(t) = infer_ir_type(expr)
6262                    {
6263                        let t = t.to_string();
6264                        if let Some((ft, fq)) = &first {
6265                            if !types_compatible(ft, &t) {
6266                                return Err(PyQLError::Type(PyQLTypeError {
6267                                    message: format!(
6268                                        "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
6269                                        fq,
6270                                        pg_type_to_pyql(&t),
6271                                    ),
6272                                    position: Position { line: 0, col: 0 },
6273                                }));
6274                            }
6275                        } else {
6276                            first = Some((t.clone(), pg_type_to_pyql(&t).to_string()));
6277                        }
6278                    }
6279                }
6280                union_items
6281            }
6282            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
6283                if let ast::PathStep::Name(n) = &p.steps[0] {
6284                    if self
6285                        .cte_types
6286                        .get(n.as_str())
6287                        .map(|t| !t.contains("::"))
6288                        .unwrap_or(false)
6289                    {
6290                        vec![IrFreeExpr::CtePassthrough(n.clone())]
6291                    } else {
6292                        vec![IrFreeExpr::Scalar(self.compile_free_expr(result_expr)?)]
6293                    }
6294                } else {
6295                    vec![IrFreeExpr::Scalar(self.compile_free_expr(result_expr)?)]
6296                }
6297            }
6298            Expr::Shape(s) if s.expr.is_none() => {
6299                let fields = s
6300                    .elements
6301                    .iter()
6302                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
6303                        let name = path_leaf(&el.path)?.to_string();
6304                        let expr = el.compexpr.as_ref().ok_or_else(|| {
6305                            self.type_err("free object field must have a value expression (':= expr')")
6306                        })?;
6307                        Ok((name, self.free_object_field(expr)?))
6308                    })
6309                    .collect::<Result<Vec<_>, _>>()?;
6310                vec![IrFreeExpr::FreeObject(fields)]
6311            }
6312            Expr::Tuple(exprs) => {
6313                // An element may hold an object with a shape
6314                // (`select (offering { id }, revision { id })`), which is the
6315                // same question a free object's field asks.
6316                let ir = exprs
6317                    .iter()
6318                    .map(|e| self.free_object_field(e))
6319                    .collect::<Result<_, _>>()?;
6320                vec![IrFreeExpr::Tuple(ir)]
6321            }
6322            Expr::NamedTuple(fields) => {
6323                let ir = fields
6324                    .iter()
6325                    .map(|(name, e)| Ok((name.clone(), self.free_object_field(e)?)))
6326                    .collect::<Result<Vec<_>, PyQLError>>()?;
6327                // jsonb has no member kind for an object, so a tuple holding
6328                // one is emitted as a composite row instead; the rest stay on
6329                // the jsonb encoding they have always had.
6330                // An array of objects (`brands := array_agg((select b { * }))`)
6331                // flattens in jsonb the same way.
6332                let holds_an_object = ir.iter().any(|(_, e)| match e {
6333                    IrExpr::ObjectSubquery(_) | IrExpr::ObjectPathSubquery(_) => true,
6334                    IrExpr::ArrayFromSelect(source) => match source.as_ref() {
6335                        IrArraySource::ObjectSelect(_) | IrArraySource::ObjectFunction(_) | IrArraySource::Group(_) => {
6336                            true
6337                        }
6338                        IrArraySource::PathSelect(ps) => matches!(ps.result, IrPathResult::Object { .. }),
6339                        _ => false,
6340                    },
6341                    _ => false,
6342                });
6343                if holds_an_object {
6344                    vec![IrFreeExpr::NamedTupleRow(ir)]
6345                } else {
6346                    vec![IrFreeExpr::Scalar(IrExpr::NamedTuple {
6347                        fields: ir,
6348                        is_free_object: false,
6349                    })]
6350                }
6351            }
6352            other => vec![IrFreeExpr::Scalar(self.compile_free_expr(other)?)],
6353        };
6354
6355        let order_by = sel
6356            .order_by
6357            .iter()
6358            .map(|s| self.compile_sort_ctx(s, None))
6359            .collect::<Result<Vec<_>, _>>()?;
6360
6361        let offset = sel.offset.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
6362        let limit = sel.limit.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
6363        let filter = sel.filter.as_ref().map(|f| self.compile_free_filter(f)).transpose()?;
6364
6365        Ok(IrSelect {
6366            rows: items.into_iter().map(IrRowSource::Free).collect(),
6367            filter,
6368            order_by,
6369            offset,
6370            limit,
6371            distinct,
6372            dml_source: None,
6373            polymorphic: false,
6374            poly_implementors: vec![],
6375            poly_columns: vec![],
6376            lock: None,
6377        })
6378    }
6379
6380    /// The FILTER of a free SELECT, which has no row to apply itself to: it
6381    /// gates the result the select already produced.
6382    ///
6383    /// A condition over a type-rooted path is a *set* of booleans, one per row
6384    /// of that type, so the result survives when any of them holds — the same
6385    /// reading PyQL gives it, and the reason for the warning: a filter is
6386    /// meant to be one boolean, and `any()` says so outright.
6387    fn compile_free_filter(&mut self, filter: &Expr) -> Result<IrExpr, PyQLError> {
6388        self.as_condition(|c| c.compile_free_filter_inner(filter))
6389    }
6390
6391    fn compile_free_filter_inner(&mut self, filter: &Expr) -> Result<IrExpr, PyQLError> {
6392        let Some(root) = self.find_path_root_in_expr(filter) else {
6393            return self.compile_free_expr(filter);
6394        };
6395        // A binding roots a path here as readily as a type name does; resolved
6396        // as a type it is simply unknown.
6397        let td = self.resolve_path_root(&root)?;
6398        let table = self.row_source_table(&root, td);
6399        let alias = self.fresh_alias();
6400        let source = IrSource {
6401            poly: None,
6402            type_name: format!("{}::{}", td.module, td.name),
6403            table,
6404            alias: alias.clone(),
6405        };
6406        let condition = self.compile_expr(&Self::rewrite_abs_to_partial(filter.clone(), &root), td, &alias)?;
6407        self.warnings.push(format!(
6408            "possibly more than one element returned by an expression in a FILTER clause \
6409             (every '{root}'); wrap with any() to make intent explicit",
6410        ));
6411        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
6412            op: ast::UnaryOpKind::Exists,
6413            operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(source, vec![], Some(condition)))),
6414        })))
6415    }
6416
6417    // ── SELECT ────────────────────────────────────────────────────────────────────
6418
6419    /// The `(qualified type, source table)` of each branch of an object-set
6420    /// union, or `None` if any branch is something other than a direct
6421    /// reference to an object set (a WITH binding or a bare type name).
6422    /// The type a union of object sets carries: the one branch type every
6423    /// other branch is or implements. `A union (insert B)` where B implements
6424    /// A is an A-set, which is how PyQL reads it; requiring the branches
6425    /// to name the same type refuses a get-or-create over an interface.
6426    fn common_union_type(&self, branches: &[(String, String)]) -> Option<String> {
6427        let covers_all = |candidate: &String| {
6428            branches.iter().all(|(t, _)| {
6429                t == candidate
6430                    || self
6431                        .resolve_type(t)
6432                        .map(|td| Self::is_or_implements(td, candidate))
6433                        .unwrap_or(false)
6434            })
6435        };
6436        if let Some(branch) = branches.iter().map(|(t, _)| t.clone()).find(&covers_all) {
6437            return Some(branch);
6438        }
6439        // None of the branches is the others' supertype, but they may still
6440        // share one — `IncomingTransformer union OutgoingTransformer` are both
6441        // `Transformer`. The first branch's ancestors are the only candidates,
6442        // since a common type has to be among them.
6443        let first = self.resolve_type(&branches.first()?.0).ok()?;
6444        first
6445            .interfaces
6446            .iter()
6447            .chain(first.parents.iter())
6448            .find(|ancestor| covers_all(ancestor))
6449            .cloned()
6450    }
6451
6452    fn object_union_branches(&self, expr: &Expr) -> Option<Vec<(String, String)>> {
6453        fn flatten<'e>(expr: &'e Expr, out: &mut Vec<&'e Expr>) {
6454            match expr {
6455                Expr::Union(a, b) => {
6456                    flatten(a, out);
6457                    flatten(b, out);
6458                }
6459                other => out.push(other),
6460            }
6461        }
6462        if !matches!(expr, Expr::Union(_, _)) {
6463            return None;
6464        }
6465        let mut operands: Vec<&Expr> = vec![];
6466        flatten(expr, &mut operands);
6467
6468        let mut branches: Vec<(String, String)> = vec![];
6469        for operand in operands {
6470            let Expr::Path(path) = operand else { return None };
6471            if path.partial || path.steps.len() != 1 {
6472                return None;
6473            }
6474            let ast::PathStep::Name(name) = &path.steps[0] else {
6475                return None;
6476            };
6477            match self.cte_object_type(name) {
6478                Some(qualified) => branches.push((qualified, self.cte_source_table(&self.cte_sql_name(name)))),
6479                None => match self.resolve_type(name) {
6480                    Ok(td) => branches.push((format!("{}::{}", td.module, td.name), td.table.clone())),
6481                    Err(_) => return None,
6482                },
6483            }
6484        }
6485        Some(branches)
6486    }
6487
6488    /// `select (a union b) { shape }` where every branch is a set of the same
6489    /// object type — a WITH binding or a bare type name. Each branch becomes
6490    /// its own bound row; the SQL layer unions them in the FROM clause, so the
6491    /// shape and modifiers are compiled once, against the shared type.
6492    ///
6493    /// `Ok(None)` when the result isn't an object union, leaving the ordinary
6494    /// single-source path (and its own errors) in charge.
6495    /// Give every operand of an object union a name to be read by.
6496    ///
6497    /// `object_union_branches` recognises operands that already name a
6498    /// relation -- a type, or a `with` binding -- because a union is emitted
6499    /// as one relation per branch. An operand written inline (a sub-select, or
6500    /// an insert in a get-or-create) names nothing yet, so it is hoisted into
6501    /// a CTE of its own and replaced by that name, leaving an ordinary union
6502    /// of names behind.
6503    fn name_union_operands(&mut self, expr: &Expr) -> Result<Option<Expr>, PyQLError> {
6504        let Expr::Union(left, right) = expr else {
6505            return Ok(None);
6506        };
6507        let mut rewritten = false;
6508        let mut name_one = |compiler: &mut Self, operand: &Expr| -> Result<Expr, PyQLError> {
6509            match operand {
6510                Expr::Union(_, _) => match compiler.name_union_operands(operand)? {
6511                    Some(inner) => {
6512                        rewritten = true;
6513                        Ok(inner)
6514                    }
6515                    None => Ok(operand.clone()),
6516                },
6517                Expr::SubQuery(stmt) => {
6518                    let (cte_name, type_name) = compiler.hoist_dml_as_cte(stmt.as_ref())?;
6519                    // An insert names its subject unqualified (`Credentials`),
6520                    // a select yields an already-qualified name; a branch that
6521                    // resolves to no object type is not one of these at all.
6522                    let Ok(td) = compiler.resolve_type(&type_name) else {
6523                        return Ok(operand.clone());
6524                    };
6525                    compiler
6526                        .cte_types
6527                        .insert(cte_name.clone(), format!("{}::{}", td.module, td.name));
6528                    rewritten = true;
6529                    Ok(Expr::Path(ast::Path {
6530                        steps: vec![ast::PathStep::Name(cte_name)],
6531                        partial: false,
6532                    }))
6533                }
6534                // `a.answers.question union a.current_question` — a walk names
6535                // no relation either, so it is hoisted the same way an inline
6536                // select is.
6537                Expr::Path(path) if !path.partial && path.steps.len() > 1 => {
6538                    let synthetic = Stmt::Select(ast::SelectStmt {
6539                        result: operand.clone(),
6540                        filter: None,
6541                        order_by: vec![],
6542                        offset: None,
6543                        limit: None,
6544                        lock: None,
6545                    });
6546                    let Ok((cte_name, type_name)) = compiler.hoist_dml_as_cte(&synthetic) else {
6547                        return Ok(operand.clone());
6548                    };
6549                    let Ok(td) = compiler.resolve_type(&type_name) else {
6550                        return Ok(operand.clone());
6551                    };
6552                    compiler
6553                        .cte_types
6554                        .insert(cte_name.clone(), format!("{}::{}", td.module, td.name));
6555                    rewritten = true;
6556                    Ok(Expr::Path(ast::Path {
6557                        steps: vec![ast::PathStep::Name(cte_name)],
6558                        partial: false,
6559                    }))
6560                }
6561                other => Ok(other.clone()),
6562            }
6563        };
6564        let left = name_one(self, left)?;
6565        let right = name_one(self, right)?;
6566        if !rewritten {
6567            return Ok(None);
6568        }
6569        Ok(Some(Expr::Union(Box::new(left), Box::new(right))))
6570    }
6571
6572    /// `A if cond else B` over object sets, as the union it is.
6573    ///
6574    /// Picking between two *sets* is not the scalar `CASE` an if/else over
6575    /// values compiles to: it stands for A's rows when the condition holds and
6576    /// B's when it does not, which is `(A filter cond) union (B filter not
6577    /// cond)`. Rewritten here rather than given its own IR, so it reaches the
6578    /// union machinery that already knows how to read one relation per branch.
6579    /// Hand a guarded mutation its condition: an insert folds it into its own
6580    /// `WHERE`, an update or a delete ANDs it onto the rows it narrows to.
6581    fn set_mutation_guard(&mut self, branch: &Expr, condition: Expr) {
6582        let stmt = match branch {
6583            Expr::SubQuery(stmt) => Some(stmt.as_ref()),
6584            Expr::Shape(sh) => match sh.expr.as_ref() {
6585                Some(Expr::SubQuery(stmt)) => Some(stmt.as_ref()),
6586                _ => None,
6587            },
6588            _ => None,
6589        };
6590        match stmt {
6591            Some(Stmt::Update(_)) => self.pending_update_guard = Some(condition),
6592            Some(Stmt::Delete(_)) => self.pending_delete_guard = Some(condition),
6593            _ => self.pending_insert_guard = Some(condition),
6594        }
6595    }
6596
6597    fn object_if_else_as_union(&mut self, expr: &Expr) -> Option<Expr> {
6598        let Expr::IfElse(ie) = expr else {
6599            return None;
6600        };
6601        fn is_empty_set(expr: &Expr) -> bool {
6602            matches!(expr, Expr::Set(items) if items.is_empty())
6603        }
6604        let yields_objects = |branch: &Expr| match branch {
6605            Expr::Path(p) if !p.partial && p.steps.len() == 1 => match &p.steps[0] {
6606                ast::PathStep::Name(n) => self.cte_object_type(n).is_some() || self.resolve_type(n).is_ok(),
6607                _ => false,
6608            },
6609            Expr::SubQuery(stmt) => {
6610                // A select over a walk (`select resource.revisions`) yields
6611                // objects too, and names no type for `dml_subject_type` to
6612                // report; what it lands on is what the branch carries.
6613                if let Some(ast::SelectStmt {
6614                    result: Expr::Path(path),
6615                    ..
6616                }) = innermost_select(stmt.as_ref())
6617                    && !path.partial
6618                    && path.steps.len() > 1
6619                    && let Some(ast::PathStep::Name(root)) = path.steps.first()
6620                    && let Ok(root_td) = self.resolve_path_root(root)
6621                {
6622                    return matches!(
6623                        self.walk_path_types(root_td, &path.steps[1..], MAX_COMPUTED_SPLICES),
6624                        (_, Some(_))
6625                    );
6626                }
6627                matches!(
6628                    self.dml_subject_type(stmt.as_ref()),
6629                    Ok(name) if self.resolve_type(&name).is_ok()
6630                )
6631            }
6632            // A branch may carry the shape the result is read with.
6633            Expr::Shape(sh) => match sh.expr.as_ref() {
6634                Some(Expr::SubQuery(stmt)) => {
6635                    matches!(self.dml_subject_type(stmt.as_ref()), Ok(name) if self.resolve_type(&name).is_ok())
6636                }
6637                Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => match &p.steps[0] {
6638                    ast::PathStep::Name(n) => self.cte_object_type(n).is_some() || self.resolve_type(n).is_ok(),
6639                    _ => false,
6640                },
6641                _ => false,
6642            },
6643            _ => false,
6644        };
6645        // `{}` contributes no rows, so the union degenerates to the other
6646        // branch under its own guard -- which is what the full rewrite
6647        // (`SELECT A WHERE Cond UNION ALL SELECT B WHERE NOT Cond`) reduces
6648        // to when one side is empty.
6649        // A mutation under a condition is *not* handled here. Guarding the
6650        // branch only filters what is read back: the mutation is its own CTE
6651        // and Postgres runs it regardless, so `(insert …) if cond else {}`
6652        // would insert even when the condition is false -- silently, which is
6653        // far worse than the compile error it replaced. It needs the
6654        // condition folded into the mutation itself.
6655        fn mutating_stmt(branch: &Expr) -> Option<&Stmt> {
6656            match branch {
6657                Expr::SubQuery(stmt) => Some(stmt.as_ref()),
6658                Expr::Shape(sh) => match sh.expr.as_ref() {
6659                    Some(Expr::SubQuery(stmt)) => Some(stmt.as_ref()),
6660                    _ => None,
6661                },
6662                _ => None,
6663            }
6664            .filter(|stmt| matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)))
6665        }
6666        // Guarding a branch only filters what is read back, while the
6667        // mutation is its own CTE that Postgres runs regardless -- so the
6668        // condition has to go into the mutation itself, which is what
6669        // `set_mutation_guard` arranges for all three.
6670        let (if_yields_objects, else_yields_objects) = (yields_objects(&ie.if_expr), yields_objects(&ie.else_expr));
6671        let guard = |branch: &Expr, condition: Expr| {
6672            Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
6673                result: branch.clone(),
6674                filter: Some(condition),
6675                order_by: vec![],
6676                offset: None,
6677                limit: None,
6678                lock: None,
6679            })))
6680        };
6681        let negated = Expr::UnaryOp(Box::new(ast::UnaryOp {
6682            op: ast::UnaryOpKind::Not,
6683            operand: ie.condition.clone(),
6684        }));
6685        let guarded_insert = |branch: &Expr, other: &Expr| {
6686            mutating_stmt(branch).is_some() && matches!(other, Expr::Set(items) if items.is_empty())
6687        };
6688        if guarded_insert(&ie.if_expr, &ie.else_expr) {
6689            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6690            return Some(ie.if_expr.clone());
6691        }
6692        if guarded_insert(&ie.else_expr, &ie.if_expr) {
6693            let negated = Expr::UnaryOp(Box::new(ast::UnaryOp {
6694                op: ast::UnaryOpKind::Not,
6695                operand: ie.condition.clone(),
6696            }));
6697            self.set_mutation_guard(&ie.else_expr, negated);
6698            return Some(ie.else_expr.clone());
6699        }
6700        // `(insert …) if not exists existing else (update existing set …)` —
6701        // the upsert idiom. Both branches mutate, so both carry their own
6702        // condition; each statement kind has its own pending slot, so two of
6703        // different kinds can be guarded at once. Two of the *same* kind would
6704        // have one guard overwrite the other, and fall through to the refusal
6705        // below rather than compile to a mutation that runs regardless.
6706        if let (Some(if_stmt), Some(else_stmt)) = (mutating_stmt(&ie.if_expr), mutating_stmt(&ie.else_expr))
6707            && std::mem::discriminant(if_stmt) != std::mem::discriminant(else_stmt)
6708        {
6709            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6710            self.set_mutation_guard(&ie.else_expr, negated);
6711            return Some(Expr::Union(
6712                Box::new(ie.if_expr.clone()),
6713                Box::new(ie.else_expr.clone()),
6714            ));
6715        }
6716        // `existing if exists existing else (insert …)` — the insert still has
6717        // to carry the condition itself, but the other branch is a set of its
6718        // own, so the result is the full rewrite: both branches under
6719        // opposite guards, unioned.
6720        let reads_objects = |branch: &Expr, yields: bool| mutating_stmt(branch).is_none() && yields;
6721        if mutating_stmt(&ie.if_expr).is_some() && reads_objects(&ie.else_expr, else_yields_objects) {
6722            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6723            return Some(Expr::Union(
6724                Box::new(ie.if_expr.clone()),
6725                Box::new(guard(&ie.else_expr, negated)),
6726            ));
6727        }
6728        if mutating_stmt(&ie.else_expr).is_some() && reads_objects(&ie.if_expr, if_yields_objects) {
6729            self.set_mutation_guard(&ie.else_expr, negated);
6730            return Some(Expr::Union(
6731                Box::new(guard(&ie.if_expr, ie.condition.clone())),
6732                Box::new(ie.else_expr.clone()),
6733            ));
6734        }
6735        if mutating_stmt(&ie.if_expr).is_some() || mutating_stmt(&ie.else_expr).is_some() {
6736            return None;
6737        }
6738        let (if_empty, else_empty) = (is_empty_set(&ie.if_expr), is_empty_set(&ie.else_expr));
6739        if if_empty && else_empty {
6740            return None;
6741        }
6742        if !(if_empty || if_yields_objects) || !(else_empty || else_yields_objects) {
6743            return None;
6744        }
6745        if else_empty {
6746            return Some(guard(&ie.if_expr, ie.condition.clone()));
6747        }
6748        if if_empty {
6749            return Some(guard(&ie.else_expr, negated));
6750        }
6751        Some(Expr::Union(
6752            Box::new(guard(&ie.if_expr, ie.condition.clone())),
6753            Box::new(guard(&ie.else_expr, negated)),
6754        ))
6755    }
6756
6757    /// `granted ?? existing` over object sets is the choice `A if exists A
6758    /// else B` spells, so it reaches the same union machinery. A scalar
6759    /// coalesce rewritten this way is simply not an object union, and the
6760    /// caller falls back to compiling what was written.
6761    fn object_coalesce_as_if_else(expr: &Expr) -> Option<Expr> {
6762        fn as_if_else(b: &ast::BinOp) -> Option<Expr> {
6763            (b.op == ast::BinOpKind::Coalesce).then(|| {
6764                Expr::IfElse(Box::new(ast::IfElse {
6765                    condition: Expr::UnaryOp(Box::new(ast::UnaryOp {
6766                        op: ast::UnaryOpKind::Exists,
6767                        operand: b.left.clone(),
6768                    })),
6769                    if_expr: b.left.clone(),
6770                    else_expr: b.right.clone(),
6771                }))
6772            })
6773        }
6774        match expr {
6775            Expr::BinOp(b) => as_if_else(b),
6776            Expr::Shape(sh) => match sh.expr.as_ref() {
6777                Some(Expr::BinOp(b)) => Some(Expr::Shape(Box::new(ast::ShapeExpr {
6778                    expr: Some(as_if_else(b)?),
6779                    elements: sh.elements.clone(),
6780                    marker_offset: sh.marker_offset,
6781                }))),
6782                _ => None,
6783            },
6784            _ => None,
6785        }
6786    }
6787
6788    fn try_compile_object_union_select(
6789        &mut self,
6790        sel: &ast::SelectStmt,
6791        result_expr: &Expr,
6792        distinct: bool,
6793    ) -> Result<Option<IrSelect>, PyQLError> {
6794        let coalesced;
6795        let result_expr = match Self::object_coalesce_as_if_else(result_expr) {
6796            Some(rewritten) => {
6797                coalesced = rewritten;
6798                &coalesced
6799            }
6800            None => result_expr,
6801        };
6802        let as_union;
6803        let (union_expr, shape_elements): (&Expr, &[ShapeElement]) = match result_expr {
6804            Expr::Union(_, _) => (result_expr, &[]),
6805            Expr::IfElse(_) => match self.object_if_else_as_union(result_expr) {
6806                // One branch empty leaves a single guarded set, not a union;
6807                // that is an ordinary select over what the branch names.
6808                Some(rewritten @ Expr::SubQuery(_)) => {
6809                    return self.compile_select(sel, &rewritten, distinct).map(Some);
6810                }
6811                Some(rewritten) if !matches!(rewritten, Expr::Union(_, _)) => {
6812                    return self.compile_select(sel, &rewritten, distinct).map(Some);
6813                }
6814                Some(rewritten) => {
6815                    as_union = rewritten;
6816                    (&as_union, &[] as &[ShapeElement])
6817                }
6818                None => return Ok(None),
6819            },
6820            Expr::Shape(shape) => match &shape.expr {
6821                Some(inner @ Expr::Union(_, _)) => (inner, shape.elements.as_slice()),
6822                Some(inner @ Expr::IfElse(_)) => match self.object_if_else_as_union(inner) {
6823                    Some(Expr::SubQuery(stmt)) => {
6824                        let shaped = Expr::Shape(Box::new(ast::ShapeExpr {
6825                            expr: Some(Expr::SubQuery(stmt)),
6826                            elements: shape.elements.clone(),
6827                            marker_offset: None,
6828                        }));
6829                        return self.compile_select(sel, &shaped, distinct).map(Some);
6830                    }
6831                    Some(rewritten) if !matches!(rewritten, Expr::Union(_, _)) => {
6832                        let shaped = match rewritten {
6833                            Expr::Shape(_) => rewritten,
6834                            other => Expr::Shape(Box::new(ast::ShapeExpr {
6835                                expr: Some(other),
6836                                elements: shape.elements.clone(),
6837                                marker_offset: None,
6838                            })),
6839                        };
6840                        return self.compile_select(sel, &shaped, distinct).map(Some);
6841                    }
6842                    Some(rewritten) => {
6843                        as_union = rewritten;
6844                        (&as_union, shape.elements.as_slice())
6845                    }
6846                    None => return Ok(None),
6847                },
6848                _ => return Ok(None),
6849            },
6850            _ => return Ok(None),
6851        };
6852
6853        let named;
6854        let union_expr = match self.name_union_operands(union_expr)? {
6855            Some(rewritten) => {
6856                named = rewritten;
6857                &named
6858            }
6859            None => union_expr,
6860        };
6861        let Some(branches) = self.object_union_branches(union_expr) else {
6862            return Ok(None);
6863        };
6864
6865        let Some(first_type) = self.common_union_type(&branches) else {
6866            let (first_type, _) = &branches[0];
6867            let (other, _) = branches
6868                .iter()
6869                .find(|(t, _)| t != first_type)
6870                .expect("no common type means at least two differ");
6871            return Err(self.type_err(&format!(
6872                "operator 'UNION' cannot be applied to operands of type '{first_type}' and '{other}'"
6873            )));
6874        };
6875        let first_type = &first_type;
6876        if sel.lock.is_some() {
6877            return Err(self.type_err(
6878                "FOR UPDATE/SHARE cannot be used on a UNION — its rows come from more than one \
6879                 source, which a single locking clause can't target",
6880            ));
6881        }
6882        let td = self.resolve_type(first_type)?;
6883        let alias = self.fresh_alias();
6884
6885        self.anchors.push(SelectAnchor {
6886            type_name: td.name.clone(),
6887            qualified: format!("{}::{}", td.module, td.name),
6888            alias: alias.clone(),
6889            detached: std::mem::take(&mut self.pending_detached),
6890            declared_on: None,
6891        });
6892        let clauses = (|compiler: &mut Self| -> Result<_, PyQLError> {
6893            let shape = compiler.compile_shape(shape_elements, td, &alias, &td.module)?;
6894            let filter = sel
6895                .filter
6896                .as_ref()
6897                .map(|f| compiler.compile_expr(f, td, &alias))
6898                .transpose()?;
6899            let order_by = sel
6900                .order_by
6901                .iter()
6902                .map(|o| compiler.compile_sort(o, td, &alias))
6903                .collect::<Result<Vec<_>, _>>()?;
6904            let offset = sel
6905                .offset
6906                .as_ref()
6907                .map(|e| compiler.compile_expr(e, td, &alias))
6908                .transpose()?;
6909            let limit = sel
6910                .limit
6911                .as_ref()
6912                .map(|e| compiler.compile_expr(e, td, &alias))
6913                .transpose()?;
6914            Ok((shape, filter, order_by, offset, limit))
6915        })(self);
6916        self.anchors.pop();
6917        let (shape, mut filter, order_by, offset, limit) = clauses?;
6918        // A select whose subject is a for-loop variable reads the one row that
6919        // variable holds, not the whole table.
6920        if let Some(var) = Self::subject_name(result_expr).filter(|n| self.for_var_types.contains_key(n)) {
6921            let narrowed = IrExpr::BinOp(Box::new(IrBinOp {
6922                left: IrExpr::ColumnRef {
6923                    alias: alias.clone(),
6924                    column: "id".to_string(),
6925                    pg_type: "uuid".to_string(),
6926                },
6927                op: ast::BinOpKind::Eq,
6928                right: self.for_var_ref(&var),
6929            }));
6930            filter = and_conditions(filter, vec![narrowed]);
6931        }
6932
6933        // `brands union offerings` — types that only share an ancestor carry
6934        // different columns, so each branch is read as that ancestor: its
6935        // columns, and the concrete type each row has. An abstract branch's
6936        // rows already say which type they are.
6937        let heterogeneous = branches.iter().any(|(t, _)| t != first_type);
6938        let common_columns = if heterogeneous {
6939            self.collect_poly_info(first_type).1
6940        } else {
6941            vec![]
6942        };
6943        let rows = branches
6944            .into_iter()
6945            .map(|(type_name, table)| IrRowSource::Bound {
6946                source: IrSource {
6947                    poly: if heterogeneous {
6948                        self.poly_fanout_for(&type_name)
6949                    } else {
6950                        None
6951                    },
6952                    type_name,
6953                    table,
6954                    alias: alias.clone(),
6955                },
6956                shape: shape.clone(),
6957            })
6958            .collect();
6959
6960        Ok(Some(IrSelect {
6961            rows,
6962            filter,
6963            order_by,
6964            offset,
6965            limit,
6966            distinct,
6967            dml_source: None,
6968            polymorphic: false,
6969            poly_implementors: vec![],
6970            poly_columns: common_columns,
6971            lock: None,
6972        }))
6973    }
6974
6975    /// The bare name a select's result is written as, whether it carries a
6976    /// shape or not.
6977    fn subject_name(result_expr: &Expr) -> Option<String> {
6978        let path = match result_expr {
6979            Expr::Path(p) => p,
6980            Expr::Shape(sh) => match sh.expr.as_ref()? {
6981                Expr::Path(p) => p,
6982                _ => return None,
6983            },
6984            _ => return None,
6985        };
6986        if path.partial {
6987            return None;
6988        }
6989        match path.steps.as_slice() {
6990            [ast::PathStep::Name(n)] => Some(n.clone()),
6991            _ => None,
6992        }
6993    }
6994
6995    fn compile_select(
6996        &mut self,
6997        sel: &ast::SelectStmt,
6998        result_expr: &Expr,
6999        distinct: bool,
7000    ) -> Result<IrSelect, PyQLError> {
7001        if let Some(union_select) = self.try_compile_object_union_select(sel, result_expr, distinct)? {
7002            return Ok(union_select);
7003        }
7004        if let Some(rerooted) = self.bind_mutating_loop_source(result_expr)? {
7005            return self.compile_select(sel, &rerooted, distinct);
7006        }
7007        let (type_name, shape_elements, inner_stmt, cte_name) = self.extract_type_and_shape(result_expr)?;
7008        let td = self.resolve_type(&type_name)?;
7009        let alias = self.fresh_alias();
7010        let table = match cte_name {
7011            Some(ref cte) => self.cte_source_table(cte),
7012            None => Self::subject_name(result_expr)
7013                .map(|name| self.row_source_table(&name, td))
7014                .unwrap_or_else(|| td.table.clone()),
7015        };
7016        let source = IrSource {
7017            poly: self.poly_fanout_for(&format!("{}::{}", td.module, td.name)),
7018            type_name: format!("{}::{}", td.module, td.name),
7019            table,
7020            alias: alias.clone(),
7021        };
7022
7023        // On the stack for as long as this select's own clauses are being
7024        // compiled, so a `detached` select nested in one of them can find the
7025        // row it is being compared against. See `enclosing_anchor`.
7026        self.anchors.push(SelectAnchor {
7027            type_name: td.name.clone(),
7028            qualified: format!("{}::{}", td.module, td.name),
7029            alias: alias.clone(),
7030            detached: std::mem::take(&mut self.pending_detached),
7031            declared_on: None,
7032        });
7033        // A binding read back by name brings whatever its own shape declared
7034        // (`offering { publisher }`), which is on no type and has to be found
7035        // through the binding it was written on.
7036        let declared = match cte_name.as_deref() {
7037            Some(name) => self.cte_declared_pointers.get(name).cloned().unwrap_or_default(),
7038            // `select (select T { x := … }) filter .x…` — a nested select's
7039            // shape declares pointers on no type, just as a binding's does, and
7040            // the outer select's clauses read them the same way.
7041            None => inner_stmt
7042                .and_then(innermost_select)
7043                .and_then(|inner| match &inner.result {
7044                    Expr::Shape(sh) => Some(sh.elements.clone()),
7045                    _ => None,
7046                })
7047                .unwrap_or_default(),
7048        };
7049        // `*` on a binding takes in what the binding's shape computed, too.
7050        let splat_over_declared: Vec<ShapeElement>;
7051        let shape_elements = if shape_elements
7052            .iter()
7053            .any(|el| el.splat.is_some() && !matches!(el.path.steps.first(), Some(ast::PathStep::TypeIntersection(_))))
7054        {
7055            let named = |name: &str| {
7056                shape_elements
7057                    .iter()
7058                    .any(|el| el.splat.is_none() && path_leaf(&el.path).is_ok_and(|n| n == name))
7059            };
7060            let extra: Vec<ShapeElement> = declared
7061                .iter()
7062                .filter(|d| d.compexpr.is_some())
7063                .filter_map(|d| {
7064                    let name = path_leaf(&d.path).ok()?;
7065                    (!named(name)).then(|| ShapeElement {
7066                        path: ast::Path::relative(name),
7067                        splat: None,
7068                        nested: None,
7069                        compexpr: None,
7070                        op: ast::ShapeOp::Assign,
7071                        filter: None,
7072                        order_by: vec![],
7073                        offset: None,
7074                        limit: None,
7075                        marker_offset: None,
7076                    })
7077                })
7078                .collect();
7079            splat_over_declared = shape_elements.iter().cloned().chain(extra).collect();
7080            &splat_over_declared[..]
7081        } else {
7082            shape_elements
7083        };
7084        let outer_declared = std::mem::replace(&mut self.active_declared_pointers, declared);
7085        let clauses = (|compiler: &mut Self| -> Result<_, PyQLError> {
7086            let shape = compiler.compile_shape(shape_elements, td, &alias, &td.module)?;
7087            // This select's own shape declares pointers the same way, and its
7088            // clauses read them the same way: `select T { x := … } order by .x`
7089            // is legal, while one shape pointer reading another is not. Added
7090            // only now, after the shape is compiled, so the scope stops at the
7091            // clauses.
7092            compiler
7093                .active_declared_pointers
7094                .extend(shape_elements.iter().filter(|el| el.compexpr.is_some()).cloned());
7095            let filter = sel
7096                .filter
7097                .as_ref()
7098                .map(|f| compiler.as_condition(|c| c.compile_expr(f, td, &alias)))
7099                .transpose()?;
7100            let order_by = sel
7101                .order_by
7102                .iter()
7103                .map(|s| compiler.compile_sort(s, td, &alias))
7104                .collect::<Result<Vec<_>, _>>()?;
7105            let offset = sel
7106                .offset
7107                .as_ref()
7108                .map(|e| compiler.compile_expr(e, td, &alias))
7109                .transpose()?;
7110            let limit = sel
7111                .limit
7112                .as_ref()
7113                .map(|e| compiler.compile_expr(e, td, &alias))
7114                .transpose()?;
7115            Ok((shape, filter, order_by, offset, limit))
7116        })(self);
7117        self.anchors.pop();
7118        self.active_declared_pointers = outer_declared;
7119        let (shape, mut filter, order_by, offset, limit) = clauses?;
7120
7121        // A select whose subject is a for-loop variable reads the one row that
7122        // variable holds; unfiltered it would read the whole table.
7123        if let Some(var) = Self::subject_name(result_expr).filter(|n| self.for_var_types.contains_key(n)) {
7124            let narrowed = IrExpr::BinOp(Box::new(IrBinOp {
7125                left: IrExpr::ColumnRef {
7126                    alias: alias.clone(),
7127                    column: "id".to_string(),
7128                    pg_type: "uuid".to_string(),
7129                },
7130                op: ast::BinOpKind::Eq,
7131                right: self.for_var_ref(&var),
7132            }));
7133            filter = and_conditions(filter, vec![narrowed]);
7134        }
7135
7136        // Compile the inner DML if this is a SELECT-over-DML / SELECT-over-SELECT.
7137        let dml_source = inner_stmt.map(|s| self.compile_stmt(s).map(Box::new)).transpose()?;
7138        let mut shape = shape;
7139        if let Some(dml) = &dml_source {
7140            // `SELECT (INSERT …)` has no user-written CTE name: the emitter
7141            // wraps the DML in one of its own (`sql::DML_CTE`), and the
7142            // junction CTEs are named after it.
7143            let dml_cte = cte_name.as_deref().unwrap_or(crate::sql::DML_CTE);
7144            Self::read_nested_links_from_their_ctes(dml, Some(dml_cte), &mut shape);
7145        }
7146
7147        let polymorphic = self.is_polymorphic(td);
7148        let (poly_implementors, poly_columns) = if polymorphic {
7149            let iface_qname = format!("{}::{}", td.module, td.name);
7150            (self.find_poly_implementors(&iface_qname), Self::poly_dml_columns(td))
7151        } else {
7152            (vec![], vec![])
7153        };
7154
7155        // `FOR UPDATE`/`FOR SHARE`/... — only valid when every output row
7156        // maps 1:1 to a single physical table row, the same restriction
7157        // Postgres itself enforces (it rejects the same combinations with
7158        // its own "FOR UPDATE is not allowed with ..." errors). `DISTINCT`
7159        // and an interface (polymorphic) target both break that mapping;
7160        // `SELECT (INSERT/UPDATE/DELETE …) { ... }` has nothing left to
7161        // lock, since the DML already ran by the time this SELECT reads it.
7162        let lock = match &sel.lock {
7163            None => None,
7164            Some(lc) => {
7165                if distinct {
7166                    return Err(self.type_err(
7167                        "FOR UPDATE/SHARE cannot be combined with DISTINCT — Postgres can't \
7168                         guarantee the result rows map 1:1 to physical table rows",
7169                    ));
7170                }
7171                if polymorphic {
7172                    return Err(self.type_err(
7173                        "FOR UPDATE/SHARE cannot be used on an interface type — its rows span \
7174                         multiple underlying tables, which a single locking clause can't target",
7175                    ));
7176                }
7177                if dml_source.is_some() {
7178                    return Err(self.type_err(
7179                        "FOR UPDATE/SHARE cannot be used on SELECT (INSERT/UPDATE/DELETE …) — \
7180                         there's nothing left to lock once the DML has already run",
7181                    ));
7182                }
7183                Some(IrLockClause {
7184                    strength: match lc.strength {
7185                        ast::LockStrength::Update => IrLockStrength::Update,
7186                        ast::LockStrength::NoKeyUpdate => IrLockStrength::NoKeyUpdate,
7187                        ast::LockStrength::Share => IrLockStrength::Share,
7188                        ast::LockStrength::KeyShare => IrLockStrength::KeyShare,
7189                    },
7190                    wait: match lc.wait {
7191                        ast::LockWait::Block => IrLockWait::Block,
7192                        ast::LockWait::NoWait => IrLockWait::NoWait,
7193                        ast::LockWait::SkipLocked => IrLockWait::SkipLocked,
7194                    },
7195                })
7196            }
7197        };
7198
7199        Ok(IrSelect {
7200            rows: vec![IrRowSource::Bound { source, shape }],
7201            filter,
7202            order_by,
7203            offset,
7204            limit,
7205            distinct,
7206            dml_source,
7207            polymorphic,
7208            poly_implementors,
7209            poly_columns,
7210            lock,
7211        })
7212    }
7213
7214    /// Unwrap `Shape(expr, elements)` or bare `Path` from a SELECT result.
7215    /// Returns (type_name, shape_elements, optional_inner_stmt, optional_cte_name).
7216    /// The inner stmt is Some when the subject is `(INSERT …)` / `(SELECT …)` etc.
7217    /// The cte_name is Some when the subject is a WITH-block CTE reference.
7218    fn extract_type_and_shape<'e>(&self, expr: &'e Expr) -> Result<TypeAndShape<'e>, PyQLError> {
7219        match expr {
7220            Expr::Shape(s) => {
7221                // s.expr is Option<Expr> (not Box), so use as_ref() not as_deref()
7222                let (type_name, cte_name, inner) = match s.expr.as_ref() {
7223                    Some(Expr::SubQuery(stmt)) => (self.dml_subject_type(stmt)?, None, Some(stmt.as_ref())),
7224                    Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => {
7225                        if let ast::PathStep::Name(n) = &p.steps[0] {
7226                            if let Some(t) = self.for_var_types.get(n.as_str()) {
7227                                (t.clone(), None, None)
7228                            } else if let Some(t) = self.cte_types.get(n.as_str()) {
7229                                if t.contains("::") {
7230                                    (t.clone(), Some(n.clone()), None)
7231                                } else {
7232                                    (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7233                                }
7234                            } else {
7235                                (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7236                            }
7237                        } else {
7238                            (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7239                        }
7240                    }
7241                    Some(inner) => (self.expr_as_type_name(inner)?, None, None),
7242                    None => {
7243                        return Err(PyQLError::Type(PyQLTypeError {
7244                            message: "shape without subject expression".into(),
7245                            position: Position { line: 0, col: 0 },
7246                        }));
7247                    }
7248                };
7249                Ok((type_name, &s.elements, inner, cte_name))
7250            }
7251            // Bare `SELECT (DML)` without an outer shape
7252            Expr::SubQuery(stmt) => Ok((self.dml_subject_type(stmt)?, &[], Some(stmt.as_ref()), None)),
7253            // Bare CTE object reference: `select cte_name`
7254            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
7255                if let ast::PathStep::Name(n) = &p.steps[0] {
7256                    if let Some(t) = self.for_var_types.get(n.as_str()) {
7257                        return Ok((t.clone(), &[], None, None));
7258                    }
7259                    if let Some(t) = self.cte_types.get(n.as_str())
7260                        && t.contains("::")
7261                    {
7262                        return Ok((t.clone(), &[], None, Some(n.clone())));
7263                    }
7264                }
7265                Ok((self.expr_as_type_name(expr)?, &[], None, None))
7266            }
7267            _ => Ok((self.expr_as_type_name(expr)?, &[], None, None)),
7268        }
7269    }
7270
7271    /// True when `expr` is known to hold json, so `expr[…]` reaches into it
7272    /// with `->` rather than indexing a string or an array. A loop variable
7273    /// carries no type in the IR, so its `for` registration answers for it.
7274    fn is_json_expr(&self, ast: &Expr, ir: &IrExpr) -> bool {
7275        if matches!(infer_ir_type(ir), Some("jsonb")) {
7276            return true;
7277        }
7278        let Expr::Path(path) = ast else {
7279            return false;
7280        };
7281        if path.partial || path.steps.len() != 1 {
7282            return false;
7283        }
7284        let ast::PathStep::Name(name) = &path.steps[0] else {
7285            return false;
7286        };
7287        self.for_vars.get(name.as_str()).is_some_and(|t| t == "jsonb")
7288    }
7289
7290    /// The pg type one row of a set-returning call yields. An overload that
7291    /// names its element type (`json_array_unpack` yields json) answers for
7292    /// itself; one declared `set of any` (`array_unpack`) takes the type from
7293    /// the array it unpacks. `None` when the call is not set-returning, or
7294    /// when neither the overload nor the argument names a scalar type.
7295    fn set_returning_element_type(&mut self, expr: &Expr) -> Result<Option<String>, PyQLError> {
7296        if !expr_returns_set(expr) {
7297            return Ok(None);
7298        }
7299        let Expr::FunctionCall(call) = expr else {
7300            return Ok(None);
7301        };
7302        // Overloads that disagree on the element type say nothing between
7303        // them, so only one answer shared by all of them counts.
7304        let mut declared = crate::stdlib::lookup(call.module.as_deref().unwrap_or("std"), &call.name)
7305            .into_iter()
7306            .map(|descriptor| match &descriptor.return_type {
7307                crate::stdlib::PylonType::Set(element) => element.scalar_pg_type(),
7308                _ => None,
7309            });
7310        if let Some(element) = declared.next().flatten()
7311            && declared.all(|other| other == Some(element))
7312        {
7313            return Ok(Some(element.to_string()));
7314        }
7315        let [argument] = call.args.as_slice() else {
7316            return Ok(None);
7317        };
7318        let compiled = self.compile_free_expr(argument)?;
7319        let Some(array_type) = infer_ir_type(&compiled) else {
7320            return Ok(None);
7321        };
7322        Ok(array_type
7323            .strip_suffix("[]")
7324            .map(|element| literal_sentinel_to_pg(element).to_string()))
7325    }
7326
7327    fn compile_for(&mut self, f: &ast::ForStmt) -> Result<IrFor, PyQLError> {
7328        // A loop straight over a binding: the rows are that binding's, so a
7329        // walk off the variable reads it rather than the type's own table.
7330        let iterated_binding = self.resolve_cte_name(&f.iterator).map(|name| self.cte_sql_name(name));
7331        // Compile the iterator to determine what one loop variable binds to.
7332        let (iterator, pg_type, yielded_object_type) = match &f.iterator {
7333            Expr::Set(elems) => {
7334                let compiled: Result<Vec<_>, _> = elems.iter().map(|e| self.compile_free_expr(e)).collect();
7335                let exprs = compiled?;
7336                let raw = exprs.first().and_then(|e| infer_ir_type(e)).unwrap_or("text");
7337                let pg_type = literal_sentinel_to_pg(raw).to_string();
7338                (
7339                    IrForIterator::Values {
7340                        exprs,
7341                        pg_type: pg_type.clone(),
7342                    },
7343                    pg_type,
7344                    None,
7345                )
7346            }
7347            // A derived set — every row of it, not the single value a scalar
7348            // subquery in a one-row `VALUES` would collapse it to.
7349            Expr::SubQuery(stmt) => {
7350                let inner = self.compile_stmt(stmt)?;
7351                if !matches!(inner, IrStmt::Select(_) | IrStmt::PathSelect(_)) {
7352                    return Err(self.type_err(
7353                        "for-loop iterator: only a select can be iterated over — \
7354                         bind the statement in a `with` first",
7355                    ));
7356                }
7357                let yielded = cte_stmt_type(&inner);
7358                let scalar = !yielded.contains("::");
7359                let pg_type = if scalar {
7360                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7361                    literal_sentinel_to_pg(raw).to_string()
7362                } else {
7363                    "uuid".to_string()
7364                };
7365                (
7366                    IrForIterator::Query {
7367                        stmt: Box::new(inner),
7368                        scalar,
7369                    },
7370                    pg_type,
7371                    (!scalar).then_some(yielded),
7372                )
7373            }
7374            // A WITH binding names a set, so the loop runs once per row of
7375            // it — not once over the single value a scalar subquery would
7376            // collapse it to.
7377            Expr::Path(p)
7378                if !p.partial
7379                    && p.steps.len() == 1
7380                    && matches!(&p.steps[0], ast::PathStep::Name(n) if self.cte_types.contains_key(n.as_str())) =>
7381            {
7382                let ast::PathStep::Name(name) = &p.steps[0] else {
7383                    unreachable!("checked by the guard")
7384                };
7385                let yielded = self.cte_types.get(name.as_str()).cloned().unwrap_or_default();
7386                let scalar = !yielded.contains("::");
7387                let pg_type = if scalar {
7388                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7389                    literal_sentinel_to_pg(raw).to_string()
7390                } else {
7391                    "uuid".to_string()
7392                };
7393                let source = IrSource {
7394                    poly: None,
7395                    type_name: yielded.clone(),
7396                    table: self.cte_source_table(&self.cte_sql_name(name)),
7397                    alias: self.fresh_alias(),
7398                };
7399                (
7400                    IrForIterator::Query {
7401                        stmt: Box::new(IrStmt::Select(IrSelect::schema_bound(source, vec![], None))),
7402                        scalar,
7403                    },
7404                    pg_type,
7405                    (!scalar).then_some(yielded),
7406                )
7407            }
7408            // `for o in invitations.organizations union (…)` — a walk off a
7409            // binding names a set to iterate just as a sub-select does; it is
7410            // only written without the parentheses.
7411            Expr::Path(p) if !p.partial && p.steps.len() > 1 => {
7412                let synthetic = ast::SelectStmt {
7413                    result: Expr::Path(p.clone()),
7414                    filter: None,
7415                    order_by: vec![],
7416                    offset: None,
7417                    limit: None,
7418                    lock: None,
7419                };
7420                let inner = self.compile_stmt(&Stmt::Select(synthetic))?;
7421                let yielded = cte_stmt_type(&inner);
7422                let scalar = !yielded.contains("::");
7423                let pg_type = if scalar {
7424                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7425                    literal_sentinel_to_pg(raw).to_string()
7426                } else {
7427                    "uuid".to_string()
7428                };
7429                (
7430                    IrForIterator::Query {
7431                        stmt: Box::new(inner),
7432                        scalar,
7433                    },
7434                    pg_type,
7435                    (!scalar).then_some(yielded),
7436                )
7437            }
7438            other => {
7439                let e = self.compile_free_expr(other)?;
7440                // A set-returning call is registered as `set of any`, so its
7441                // own type says nothing — the rows are the argument array's
7442                // elements, which is where the type comes from.
7443                let raw = match self.set_returning_element_type(other)? {
7444                    Some(element) => element,
7445                    None => literal_sentinel_to_pg(infer_ir_type(&e).unwrap_or("text")).to_string(),
7446                };
7447                let pg_type = raw;
7448                let iterator = if expr_returns_set(other) {
7449                    IrForIterator::SetReturning {
7450                        expr: e,
7451                        pg_type: pg_type.clone(),
7452                    }
7453                } else {
7454                    IrForIterator::Values {
7455                        exprs: vec![e],
7456                        pg_type: pg_type.clone(),
7457                    }
7458                };
7459                (iterator, pg_type, None)
7460            }
7461        };
7462
7463        // The CTE name this loop's iterator is emitted under, claimed from the
7464        // statement's one namespace so no sibling loop and no binding can end
7465        // up under it too. The emitter spells it `_for_<slot>`.
7466        let slot = self
7467            .claim_generated_cte_name(&format!("_for_{}", f.var))
7468            .strip_prefix("_for_")
7469            .unwrap_or(&f.var)
7470            .to_string();
7471        let prev_slot = self.for_var_slots.insert(f.var.clone(), slot.clone());
7472        // Register the for variable so the body can reference it.
7473        let prev = self.for_vars.insert(f.var.clone(), pg_type.clone());
7474        let prev_cte = match iterated_binding {
7475            Some(name) => self.for_var_ctes.insert(f.var.clone(), name),
7476            None => self.for_var_ctes.remove(&f.var),
7477        };
7478        let prev_type = match yielded_object_type {
7479            Some(qualified) => self.for_var_types.insert(f.var.clone(), qualified),
7480            None => self.for_var_types.remove(&f.var),
7481        };
7482        let hoisted_before = self.hoisted_ctes.len();
7483        self.for_scope.push(slot.clone());
7484        let body = self.compile_stmt(&f.body);
7485        self.for_scope.pop();
7486        let body = body?;
7487        let body_ctes: Vec<IrCteDef> = self.hoisted_ctes.split_off(hoisted_before);
7488        // Restore previous for-var (or remove if none existed).
7489        match prev {
7490            Some(old) => {
7491                self.for_vars.insert(f.var.clone(), old);
7492            }
7493            None => {
7494                self.for_vars.remove(&f.var);
7495            }
7496        }
7497        match prev_type {
7498            Some(old) => {
7499                self.for_var_types.insert(f.var.clone(), old);
7500            }
7501            None => {
7502                self.for_var_types.remove(&f.var);
7503            }
7504        }
7505        match prev_cte {
7506            Some(old) => {
7507                self.for_var_ctes.insert(f.var.clone(), old);
7508            }
7509            None => {
7510                self.for_var_ctes.remove(&f.var);
7511            }
7512        }
7513        match prev_slot {
7514            Some(old) => {
7515                self.for_var_slots.insert(f.var.clone(), old);
7516            }
7517            None => {
7518                self.for_var_slots.remove(&f.var);
7519            }
7520        }
7521
7522        // Checked here rather than left to the SQL emitter: `emit_for_stmt`
7523        // only implements these three body kinds, and reaching it with any
7524        // other one used to abort the process instead of reporting a PyQL
7525        // error the caller could act on.
7526        let body_kind = match &body {
7527            IrStmt::Insert(_) | IrStmt::Select(_) | IrStmt::PathSelect(_) => None,
7528            // An update is driven from the iteration itself (`UPDATE … FROM
7529            // <iterated set>`) rather than from a LATERAL, which cannot hold
7530            // DML. A multi-link mutation inside one would need its junction
7531            // rows driven from the iteration too, which it is not yet.
7532            // An append whose value is the loop variable itself can be driven
7533            // from the iteration (`emit_for_ml_append_cte`); anything more
7534            // would need its junction rows correlated in a way they are not.
7535            IrStmt::Update(upd)
7536                if upd.assignments.is_empty()
7537                    && upd.rewrites.is_empty()
7538                    && !upd.multi_link_appends.is_empty()
7539                    && upd
7540                        .multi_link_appends
7541                        .iter()
7542                        .all(|a| crate::sql::append_value_is_the_loop_variable(&a.values, &f.var))
7543                    && upd.multi_link_clears.is_empty()
7544                    && upd.multi_link_replaces.is_empty()
7545                    && upd.multi_link_removals.is_empty()
7546                    && upd.poly_implementors.is_empty() =>
7547            {
7548                None
7549            }
7550            // An interface target fans out into one UPDATE per implementor,
7551            // each driven from the iteration the same way.
7552            IrStmt::Update(upd)
7553                if upd.multi_link_appends.is_empty()
7554                    && upd.multi_link_clears.is_empty()
7555                    && upd.multi_link_replaces.is_empty()
7556                    && upd.multi_link_removals.is_empty() =>
7557            {
7558                None
7559            }
7560            IrStmt::Update(_) if std::env::var("PYLON_DBG_FORUPD").is_ok() => None,
7561            // `update x set { ml += (insert T { … }) }` — the insert runs once
7562            // per iteration, driven from the same rows the update is, and the
7563            // junction pairs each with the id that iteration generated. Only
7564            // appends: a clear/remove/replace has no such pairing.
7565            IrStmt::Update(upd)
7566                if upd.assignments.is_empty()
7567                    && upd.rewrites.is_empty()
7568                    && !upd.multi_link_appends.is_empty()
7569                    && upd.multi_link_clears.is_empty()
7570                    && upd.multi_link_replaces.is_empty()
7571                    && upd.multi_link_removals.is_empty()
7572                    && upd.poly_implementors.is_empty()
7573                    && upd
7574                        .multi_link_appends
7575                        .iter()
7576                        .all(|a| crate::sql::per_iteration_insert(a, &upd.nested_ctes).is_some()) =>
7577            {
7578                None
7579            }
7580            IrStmt::Update(_) => Some("update"),
7581            IrStmt::Delete(_) => Some("delete"),
7582            // `for line in … union (for component in … union (insert …))` —
7583            // one iterator CTE per loop, joined on the key the inner carries.
7584            // Only an insert innermost: anything else would need its own rows
7585            // driven from the pair, which they are not.
7586            IrStmt::For(inner) if matches!(inner.body.as_ref(), IrStmt::Insert(_)) => None,
7587            IrStmt::For(_) => Some("nested for"),
7588            IrStmt::Group(_) => Some("group"),
7589            _ => Some("this statement"),
7590        };
7591        if let Some(kind) = body_kind {
7592            return Err(self.type_err(&format!(
7593                "for-loop body: {kind} is not supported as a `for` body — use insert or select"
7594            )));
7595        }
7596
7597        Ok(IrFor {
7598            var_name: slot,
7599            iterator,
7600            body: Box::new(body),
7601            body_ctes,
7602        })
7603    }
7604
7605    fn compile_group(&mut self, g: &ast::GroupStmt) -> Result<IrGroup, PyQLError> {
7606        // Resolve the subject type — may be a schema type, a CTE alias, or a
7607        // walk. A walk (`group a.names by …`) names rows rather than a type:
7608        // the group runs over what it lands on, narrowed below to the rows it
7609        // yields, the same way `update a.preferences` narrows its own target.
7610        // The steps used to be joined with `::` and looked up as a type name,
7611        // which reported `a.names` as an unknown type `a::names`.
7612        let mut walked: Option<&ast::Path> = None;
7613        let (type_name, cte_name) = match &g.subject {
7614            Expr::Path(p) if !p.partial => match p.steps.as_slice() {
7615                [ast::PathStep::Name(n)] => {
7616                    if let Some(t) = self.cte_types.get(n.as_str()) {
7617                        (t.clone(), Some(n.clone()))
7618                    } else {
7619                        (n.clone(), None)
7620                    }
7621                }
7622                [ast::PathStep::Name(root), rest @ ..]
7623                    if !rest.is_empty()
7624                        && let Ok(root_td) = self.resolve_path_root(root)
7625                        && let (_, Some(target)) = self.walk_path_types(root_td, rest, MAX_COMPUTED_SPLICES) =>
7626                {
7627                    walked = Some(p);
7628                    (format!("{}::{}", target.module, target.name), None)
7629                }
7630                _ => {
7631                    return Err(PyQLError::Type(PyQLTypeError {
7632                        message: format!("unsupported group subject: {:?}", g.subject),
7633                        position: Position { line: 0, col: 0 },
7634                    }));
7635                }
7636            },
7637            _ => {
7638                return Err(PyQLError::Type(PyQLTypeError {
7639                    message: "group subject must be a type name".to_string(),
7640                    position: Position { line: 0, col: 0 },
7641                }));
7642            }
7643        };
7644
7645        let td = self.resolve_type(&type_name)?;
7646        let alias = self.fresh_alias();
7647        let fq_type_name = format!("{}::{}", td.module, td.name);
7648        let table = match cte_name {
7649            Some(ref cte) => self.cte_source_table(cte),
7650            None => td.table.clone(),
7651        };
7652        let source = IrSource {
7653            poly: self.poly_fanout_for(&fq_type_name),
7654            type_name: fq_type_name.clone(),
7655            table,
7656            alias: alias.clone(),
7657        };
7658        let module = td.module.clone();
7659
7660        // Compile the element shape. No explicit shape → implicit { id }.
7661        let shape = self.compile_shape(g.shape.as_deref().unwrap_or(&[]), td, &alias, &module)?;
7662
7663        // Build a map from using-alias → compiled expression.
7664        let td = self.resolve_type(&type_name)?;
7665        let mut using_map: HashMap<String, IrExpr> = HashMap::new();
7666        for (alias_name, expr) in &g.using {
7667            let ir = self.compile_expr(expr, td, &alias)?;
7668            using_map.insert(alias_name.clone(), ir);
7669        }
7670
7671        // Compile BY keys: each is either an Ident (using-alias ref) or a partial Path (.prop).
7672        let mut keys: Vec<(String, IrExpr)> = vec![];
7673        let td = self.resolve_type(&type_name)?;
7674        for by_expr in &g.by {
7675            match by_expr {
7676                Expr::Path(p) if p.partial && p.steps.len() == 1 => {
7677                    if let ast::PathStep::Name(prop) = &p.steps[0] {
7678                        // `.prop` shorthand: infer alias = prop name, expr = column ref.
7679                        let ir = self.compile_expr(by_expr, td, &alias)?;
7680                        keys.push((prop.clone(), ir));
7681                    } else {
7682                        return Err(PyQLError::Type(PyQLTypeError {
7683                            message: "group by path must be a simple property".to_string(),
7684                            position: Position { line: 0, col: 0 },
7685                        }));
7686                    }
7687                }
7688                Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
7689                    if let ast::PathStep::Name(name) = &p.steps[0] {
7690                        // Bare ident: must be a using alias.
7691                        let ir = using_map.get(name).ok_or_else(|| {
7692                            PyQLError::Type(PyQLTypeError {
7693                                message: format!("group by references unknown alias '{}'", name),
7694                                position: Position { line: 0, col: 0 },
7695                            })
7696                        })?;
7697                        keys.push((name.clone(), ir.clone()));
7698                    } else {
7699                        return Err(PyQLError::Type(PyQLTypeError {
7700                            message: "group by identifier must be a simple name".to_string(),
7701                            position: Position { line: 0, col: 0 },
7702                        }));
7703                    }
7704                }
7705                _ => {
7706                    return Err(PyQLError::Type(PyQLTypeError {
7707                        message: format!("unsupported group by expression: {:?}", by_expr),
7708                        position: Position { line: 0, col: 0 },
7709                    }));
7710                }
7711            }
7712        }
7713
7714        // `filter` restricts the grouped rows; `order by`/`offset`/`limit`
7715        // apply within each group, so they're compiled against the same
7716        // element scope the shape is.
7717        let td = self.resolve_type(&type_name)?;
7718        let synthetic = ast::SelectStmt {
7719            result: g.subject.clone(),
7720            filter: g.filter.clone(),
7721            order_by: g.order_by.clone(),
7722            offset: g.offset.clone(),
7723            limit: g.limit.clone(),
7724            lock: None,
7725        };
7726        let (filter, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7727        // A walked subject groups only the rows the walk reaches, not every
7728        // row of the type it happens to land on.
7729        let filter = match walked {
7730            Some(subject) => {
7731                let subject = subject.clone();
7732                let rows = self.compile_subject_path_rows(&subject, &alias)?;
7733                and_conditions(filter, vec![rows])
7734            }
7735            None => filter,
7736        };
7737
7738        Ok(IrGroup {
7739            source,
7740            shape,
7741            keys,
7742            filter,
7743            order_by,
7744            offset,
7745            limit,
7746            output: IrGroupOutput::Groups,
7747        })
7748    }
7749
7750    fn bind_group(&mut self, name: &str, expr: &Expr) -> Result<bool, PyQLError> {
7751        let Expr::SubQuery(stmt) = expr else {
7752            return Ok(false);
7753        };
7754        let Stmt::Group(g) = stmt.as_ref() else {
7755            return Ok(false);
7756        };
7757        let grp = self.compile_group(g)?;
7758        self.group_bindings.insert(name.to_string(), grp);
7759        Ok(true)
7760    }
7761
7762    /// `select (group S using k := E by k) { n := count(.elements) }`. Each
7763    /// pointer is compiled over the grouped rows of `S`: `.key.k` stands for
7764    /// `E` itself, which is constant within a group, and an aggregate over
7765    /// `.elements.p` for the same aggregate over `.p`, which the `GROUP BY`
7766    /// then takes per group.
7767    fn compile_group_projection(
7768        &mut self,
7769        sel: &ast::SelectStmt,
7770        g: &ast::GroupStmt,
7771        elements: &[ShapeElement],
7772    ) -> Result<IrGroup, PyQLError> {
7773        if sel.filter.is_some() {
7774            return Err(self.type_err("a shape over a `group` does not support filter"));
7775        }
7776        if !g.order_by.is_empty() || g.offset.is_some() || g.limit.is_some() {
7777            return Err(self.type_err(
7778                "a `group` read through a shape does not support order by, offset or limit on its elements",
7779            ));
7780        }
7781        let mut grp = self.compile_group(g)?;
7782        // `.key.k` reads the key as the `GROUP BY` already compiled it:
7783        // compiling it a second time would give a walk fresh aliases, and
7784        // Postgres only accepts a grouped expression it can match verbatim.
7785        let keys: Vec<String> = grp.keys.iter().map(|(name, _)| name.clone()).collect();
7786        for (name, key) in &grp.keys {
7787            self.inline_bindings.insert(group_key_binding(name), key.clone());
7788        }
7789        let compiled = self.compile_group_projection_parts(sel, &grp, &keys, elements);
7790        for name in &keys {
7791            self.inline_bindings.remove(&group_key_binding(name));
7792        }
7793        let (pointers, order_by, offset, limit) = compiled?;
7794        grp.output = IrGroupOutput::Projection(Box::new(IrGroupProjection {
7795            pointers,
7796            order_by,
7797            offset,
7798            limit,
7799        }));
7800        Ok(grp)
7801    }
7802
7803    #[allow(clippy::type_complexity)]
7804    fn compile_group_projection_parts(
7805        &mut self,
7806        sel: &ast::SelectStmt,
7807        grp: &IrGroup,
7808        keys: &[String],
7809        elements: &[ShapeElement],
7810    ) -> Result<(Vec<IrShapePointer>, Vec<IrSort>, Option<IrExpr>, Option<IrExpr>), PyQLError> {
7811        let td = self.resolve_type(&grp.source.type_name)?;
7812        let mut rewritten = Vec::with_capacity(elements.len());
7813        for element in elements {
7814            let (Some(compexpr), [ast::PathStep::Name(_)]) = (&element.compexpr, element.path.steps.as_slice()) else {
7815                return Err(self.type_err(
7816                    "only computed pointers (`name := …`) can be read off a `group` — e.g. `k := .key.name`",
7817                ));
7818            };
7819            let mut element = element.clone();
7820            element.compexpr = Some(self.rewrite_group_refs(compexpr, keys, td)?);
7821            rewritten.push(element);
7822        }
7823        let alias = grp.source.alias.clone();
7824        let module = td.module.clone();
7825        // `td` is here so `.key`/`.elements` resolve, but the row this
7826        // projects is the group's, not one of `td`'s: an `id` added to it
7827        // reads a column no GROUP BY covers.
7828        let pointers = self.without_implicit_id(|this| this.compile_shape(&rewritten, td, &alias, &module))?;
7829        let order_by = sel
7830            .order_by
7831            .iter()
7832            .map(|sort| {
7833                Ok(ast::SortExpr {
7834                    expr: self.rewrite_group_refs(&sort.expr, keys, td)?,
7835                    direction: sort.direction.clone(),
7836                    nones: sort.nones.clone(),
7837                })
7838            })
7839            .collect::<Result<Vec<_>, PyQLError>>()?;
7840        let synthetic = ast::SelectStmt {
7841            result: Expr::Path(ast::Path::absolute(grp.source.type_name.clone())),
7842            filter: None,
7843            order_by,
7844            offset: sel.offset.clone(),
7845            limit: sel.limit.clone(),
7846            lock: None,
7847        };
7848        let (_, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7849        Ok((pointers, order_by, offset, limit))
7850    }
7851
7852    fn rewrite_group_refs(&self, expr: &Expr, keys: &[String], td: &TypeDescriptor) -> Result<Expr, PyQLError> {
7853        const AGGREGATES: &[&str] = &["count", "sum", "min", "max", "avg", "array_agg", "all", "any"];
7854        let rewrite = |e: &Expr| self.rewrite_group_refs(e, keys, td);
7855        Ok(match expr {
7856            Expr::Path(p) if p.partial => {
7857                let name = match p.steps.first() {
7858                    Some(ast::PathStep::Name(name)) => name.as_str(),
7859                    _ => "",
7860                };
7861                match (name, p.steps.get(1..).unwrap_or_default()) {
7862                    ("key", [ast::PathStep::Name(key)]) if keys.contains(key) => {
7863                        Expr::Path(ast::Path::absolute(group_key_binding(key)))
7864                    }
7865                    ("key", [ast::PathStep::Name(key)]) => {
7866                        return Err(self.type_err(&format!("'{key}' is not a key of this group")));
7867                    }
7868                    ("elements", _) => {
7869                        return Err(self.type_err(
7870                            "`.elements` of a group can only be read through an aggregate over one \
7871                             of their properties — e.g. `count(.elements)`, `sum(.elements.amount)`",
7872                        ));
7873                    }
7874                    _ => {
7875                        return Err(self.type_err(&format!(
7876                            "a group has no pointer '{name}' — read `.key.<name>` or aggregate over `.elements`"
7877                        )));
7878                    }
7879                }
7880            }
7881            Expr::FunctionCall(f)
7882                if f.args.len() == 1
7883                    && f.kwargs.is_empty()
7884                    && f.module.as_deref().is_none_or(|m| m == "std")
7885                    && AGGREGATES.contains(&f.name.as_str())
7886                    && let Expr::Path(p) = &f.args[0]
7887                    && p.partial
7888                    && matches!(p.steps.first(), Some(ast::PathStep::Name(n)) if n == "elements") =>
7889            {
7890                let property = match p.steps.get(1..).unwrap_or_default() {
7891                    [] => "id",
7892                    [ast::PathStep::Name(prop)] if td.properties.iter().any(|d| &d.name == prop) => prop.as_str(),
7893                    _ => {
7894                        return Err(self.type_err(&format!(
7895                            "'{}' over a group's elements only reads one of their own properties",
7896                            f.name
7897                        )));
7898                    }
7899                };
7900                Expr::FunctionCall(ast::FunctionCall {
7901                    module: f.module.clone(),
7902                    name: f.name.clone(),
7903                    args: vec![Expr::Path(ast::Path::relative(property))],
7904                    kwargs: vec![],
7905                })
7906            }
7907            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
7908                module: f.module.clone(),
7909                name: f.name.clone(),
7910                args: f.args.iter().map(rewrite).collect::<Result<_, _>>()?,
7911                kwargs: f
7912                    .kwargs
7913                    .iter()
7914                    .map(|(k, v)| Ok((k.clone(), rewrite(v)?)))
7915                    .collect::<Result<_, PyQLError>>()?,
7916            }),
7917            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
7918                left: rewrite(&b.left)?,
7919                op: b.op.clone(),
7920                right: rewrite(&b.right)?,
7921            })),
7922            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
7923                op: u.op.clone(),
7924                operand: rewrite(&u.operand)?,
7925            })),
7926            Expr::TypeCast(c) => Expr::TypeCast(Box::new(ast::TypeCast {
7927                expr: rewrite(&c.expr)?,
7928                ty: c.ty.clone(),
7929            })),
7930            Expr::IfElse(ie) => Expr::IfElse(Box::new(ast::IfElse {
7931                if_expr: rewrite(&ie.if_expr)?,
7932                condition: rewrite(&ie.condition)?,
7933                else_expr: rewrite(&ie.else_expr)?,
7934            })),
7935            other => other.clone(),
7936        })
7937    }
7938
7939    /// `for g in (group S by k) union (select g.elements order by … limit n)`
7940    /// — the first `n` rows of `S` per key. `None` for any other `for`.
7941    fn compile_group_elements(&mut self, f: &ast::ForStmt) -> Result<Option<IrGroup>, PyQLError> {
7942        let Stmt::Select(body) = f.body.as_ref() else {
7943            return Ok(None);
7944        };
7945        let unmodified =
7946            body.filter.is_none() && body.order_by.is_empty() && body.offset.is_none() && body.limit.is_none();
7947        let (inner, shape) = match &body.result {
7948            Expr::Shape(sh) if unmodified => match &sh.expr {
7949                Some(Expr::SubQuery(stmt)) => match stmt.as_ref() {
7950                    Stmt::Select(inner) => (inner, Some(sh.elements.as_slice())),
7951                    _ => return Ok(None),
7952                },
7953                _ => return Ok(None),
7954            },
7955            _ => (body, None),
7956        };
7957        let reads_elements = matches!(&inner.result, Expr::Path(p) if !p.partial
7958            && matches!(p.steps.as_slice(), [ast::PathStep::Name(var), ast::PathStep::Name(elements)]
7959                if var == &f.var && elements == "elements"));
7960        if !reads_elements {
7961            return Ok(None);
7962        }
7963        let mut grp = match &f.iterator {
7964            Expr::SubQuery(stmt) => match stmt.as_ref() {
7965                Stmt::Group(g) => self.compile_group(g)?,
7966                _ => return Ok(None),
7967            },
7968            Expr::Path(p) if !p.partial => match p.steps.as_slice() {
7969                [ast::PathStep::Name(name)] => match self.group_bindings.get(name) {
7970                    Some(grp) => grp.clone(),
7971                    None => return Ok(None),
7972                },
7973                _ => return Ok(None),
7974            },
7975            _ => return Ok(None),
7976        };
7977        let ordered = !inner.order_by.is_empty() || inner.offset.is_some() || inner.limit.is_some();
7978        if ordered && (!grp.order_by.is_empty() || grp.offset.is_some() || grp.limit.is_some()) {
7979            return Err(self.type_err(
7980                "a group that already orders or limits its elements cannot be ordered or limited again by a `for` over it",
7981            ));
7982        }
7983        let td = self.resolve_type(&grp.source.type_name)?;
7984        let alias = grp.source.alias.clone();
7985        let synthetic = ast::SelectStmt {
7986            result: Expr::Path(ast::Path::absolute(grp.source.type_name.clone())),
7987            ..inner.clone()
7988        };
7989        let (filter, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7990        grp.filter = and_conditions(grp.filter, filter.into_iter().collect());
7991        if ordered {
7992            grp.order_by = order_by;
7993            grp.offset = offset;
7994            grp.limit = limit;
7995        }
7996        if let Some(shape) = shape {
7997            let module = td.module.clone();
7998            grp.shape = self.compile_shape(shape, td, &alias, &module)?;
7999        }
8000        grp.output = IrGroupOutput::Elements;
8001        Ok(Some(grp))
8002    }
8003
8004    /// The key a single-link assignment stores, for the values that name a
8005    /// row rather than compute one. `None` for anything the generic
8006    /// expression route already handles.
8007    fn compile_link_key(&mut self, expr: &Expr, td: &TypeDescriptor, alias: &str) -> Result<Option<IrExpr>, PyQLError> {
8008        // A one-element set is that element — `credentials := {
8009        // (insert Credentials { … }) }` says the same thing as assigning the
8010        // insert directly.
8011        let value = match expr {
8012            Expr::Set(elements) if elements.len() == 1 => &elements[0],
8013            other => other,
8014        };
8015        match value {
8016            // `link := (select { <T>a.id, <T>b.id } limit 1)` — a set literal
8017            // of keys, read for the one value its clauses leave.
8018            Expr::SubQuery(inner_stmt)
8019                if matches!(inner_stmt.as_ref(), Stmt::Select(sel) if matches!(sel.result, Expr::Set(_))) =>
8020            {
8021                let IrStmt::Select(select) = self.compile_stmt(inner_stmt)? else {
8022                    return Ok(None);
8023                };
8024                if !select.rows.iter().all(|r| matches!(r, IrRowSource::Free(IrFreeExpr::Scalar(_)))) {
8025                    return Ok(None);
8026                }
8027                Ok(Some(IrExpr::ScalarSubquery(Box::new(select))))
8028            }
8029            // Link assignment via subquery: `company := (SELECT Company FILTER ...)`
8030            // Compile as a scalar subquery returning the target pk (the FK uuid).
8031            Expr::SubQuery(inner_stmt) => self.compile_link_subquery(inner_stmt).map(Some),
8032            // `created_by := account_of_transaction()` — an object-returning
8033            // function names the row to link to, so what is stored is its key,
8034            // the same as for a select. The schema itself writes this as a
8035            // link default.
8036            Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &["id".to_string()], None, None),
8037            // `credentials := (update c set { … }) if exists(c) else (insert
8038            // Credentials { … })` — each mutation carries its own condition,
8039            // so exactly one branch yields the row to link.
8040            Expr::IfElse(ie)
8041                if [&ie.if_expr, &ie.else_expr].iter().any(|b| {
8042                    matches!(b, Expr::SubQuery(s) if matches!(s.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)))
8043                }) =>
8044            {
8045                let Some(rewritten) = self.object_if_else_as_union(value) else {
8046                    return Ok(None);
8047                };
8048                self.compile_link_key(&rewritten, td, alias)
8049            }
8050            Expr::Union(a, b) => {
8051                let (Some(left), Some(right)) = (self.compile_link_key(a, td, alias)?, self.compile_link_key(b, td, alias)?)
8052                else {
8053                    return Ok(None);
8054                };
8055                // A hoisted mutation's key is read by subquery: joined into
8056                // the statement instead, the branch that wrote nothing would
8057                // leave no row to update.
8058                let hoisted = |key: IrExpr| match key {
8059                    IrExpr::ColumnRef { alias, column, .. } if column == "id" => IrExpr::CteRef {
8060                        name: alias,
8061                        scalar: false,
8062                        pg_type: None,
8063                    },
8064                    other => other,
8065                };
8066                Ok(Some(IrExpr::FunctionCall(IrFunctionCall {
8067                    return_pg_type: None,
8068                    schema: None,
8069                    name: "coalesce".to_string(),
8070                    args: vec![hoisted(left), hoisted(right)],
8071                    sql_template: None,
8072                })))
8073            }
8074            // `by := account_of_transaction() if cond else {}` — each branch
8075            // names a row (or none) the same way.
8076            Expr::IfElse(ie) => {
8077                if ![&ie.if_expr, &ie.else_expr].iter().any(|b| matches!(b, Expr::FunctionCall(_))) {
8078                    return Ok(None);
8079                }
8080                Ok(Some(IrExpr::IfElse(Box::new(IrIfElse {
8081                    condition: self.compile_expr(&ie.condition, td, alias)?,
8082                    if_: self.compile_link_branch(&ie.if_expr, td, alias)?,
8083                    else_: self.compile_link_branch(&ie.else_expr, td, alias)?,
8084                }))))
8085            }
8086            _ => Ok(None),
8087        }
8088    }
8089
8090    fn compile_link_branch(&mut self, branch: &Expr, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
8091        if matches!(branch, Expr::Set(elements) if elements.is_empty()) {
8092            return Ok(IrExpr::Null);
8093        }
8094        match self.compile_link_key(branch, td, alias)? {
8095            Some(key) => Ok(key),
8096            None => self.compile_expr(branch, td, alias),
8097        }
8098    }
8099
8100    /// `.teams { owning := … }.owning` — a walk, and a pointer its shape
8101    /// declares read back off each row it lands on. `None` for anything
8102    /// else, including a field the shape does not compute.
8103    fn compile_shape_field_select(
8104        &mut self,
8105        expr: &Expr,
8106        ctx: Option<(&TypeDescriptor, &str)>,
8107    ) -> Result<Option<IrPathSelect>, PyQLError> {
8108        let Expr::FieldAccess { expr: inner, field } = expr else {
8109            return Ok(None);
8110        };
8111        let Expr::Shape(sh) = inner.as_ref() else {
8112            return Ok(None);
8113        };
8114        let Some(Expr::Path(base)) = sh.expr.as_ref() else {
8115            return Ok(None);
8116        };
8117        let Some(value) = sh
8118            .elements
8119            .iter()
8120            .find(|el| matches!(el.path.steps.as_slice(), [ast::PathStep::Name(n)] if n == field))
8121            .and_then(|el| el.compexpr.clone())
8122        else {
8123            return Ok(None);
8124        };
8125        let (rooted, correlate) = if base.partial {
8126            let Some((td, alias)) = ctx else {
8127                return Ok(None);
8128            };
8129            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
8130            steps.extend(base.steps.iter().cloned());
8131            (ast::Path { steps, partial: false }, Some(alias.to_string()))
8132        } else if base.steps.len() > 1 {
8133            (base.clone(), None)
8134        } else if let [ast::PathStep::Name(root)] = base.steps.as_slice()
8135            && let Ok(root_td) = self.resolve_path_root(root)
8136        {
8137            // A walk needs a step: `T[is T]` lands on the same objects.
8138            let narrowing = ast::PathStep::TypeIntersection(ast::ObjectRef {
8139                module: Some(root_td.module.clone()),
8140                name: root_td.name.clone(),
8141            });
8142            (
8143                ast::Path {
8144                    steps: vec![base.steps[0].clone(), narrowing],
8145                    partial: false,
8146                },
8147                None,
8148            )
8149        } else {
8150            return Ok(None);
8151        };
8152        let synthetic = ast::SelectStmt {
8153            result: Expr::Path(rooted.clone()),
8154            filter: None,
8155            order_by: vec![],
8156            offset: None,
8157            limit: None,
8158            lock: None,
8159        };
8160        let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
8161        if let Some(alias) = correlate {
8162            Self::correlate_path_select(&mut ps, &alias);
8163        }
8164        let IrPathResult::Object { alias, type_name, .. } = &ps.result else {
8165            return Err(self.type_err(&format!(
8166                "'{field}' is read off a walk that lands on a value, which has no shape"
8167            )));
8168        };
8169        let (alias, type_name) = (alias.clone(), type_name.clone());
8170        let td = self.resolve_type(&type_name)?;
8171        let value = self.compile_expr(&value, td, &alias)?;
8172        // A computed standing for a set is an array per row; the field reads
8173        // its elements, so the rows of all of them make up one set.
8174        let value = if yields_array(&value) {
8175            IrExpr::FunctionCall(IrFunctionCall {
8176                return_pg_type: None,
8177                schema: None,
8178                name: "unnest".to_string(),
8179                args: vec![value],
8180                sql_template: None,
8181            })
8182        } else {
8183            value
8184        };
8185        ps.result = IrPathResult::Scalar(value, None);
8186        Ok(Some(ps))
8187    }
8188
8189    /// `l.addon.id` — an absolute walk that lands on a property, so it yields
8190    /// values rather than objects.
8191    fn is_scalar_walk(&self, expr: &Expr) -> bool {
8192        let Expr::Path(p) = expr else { return false };
8193        let (Some(ast::PathStep::Name(root)), false) = (p.steps.first(), p.partial) else {
8194            return false;
8195        };
8196        let (Some(ast::PathStep::Name(leaf)), [_, middle @ .., _]) = (p.steps.last(), p.steps.as_slice()) else {
8197            return false;
8198        };
8199        let Ok(root_td) = self.resolve_path_root(root) else {
8200            return false;
8201        };
8202        let owner = if middle.is_empty() {
8203            Some(root_td)
8204        } else {
8205            self.walk_path_types(root_td, middle, MAX_COMPUTED_SPLICES).1
8206        };
8207        owner.is_some_and(|td| Self::resolve_property(td, leaf).is_some())
8208    }
8209
8210    /// `a.id union b.id` — each operand compiled as the select it stands
8211    /// for, the rows of all of them concatenated.
8212    fn compile_scalar_union(&mut self, expr: &Expr) -> Result<IrStmt, PyQLError> {
8213        fn operands<'e>(expr: &'e Expr, out: &mut Vec<&'e Expr>) {
8214            match expr {
8215                Expr::Union(a, b) => {
8216                    operands(a, out);
8217                    operands(b, out);
8218                }
8219                other => out.push(other),
8220            }
8221        }
8222        let mut exprs = vec![];
8223        operands(expr, &mut exprs);
8224        let mut branches = Vec::with_capacity(exprs.len());
8225        for operand in exprs {
8226            let stmt = match operand {
8227                Expr::SubQuery(stmt) => stmt.as_ref().clone(),
8228                other => Stmt::Select(ast::SelectStmt {
8229                    result: other.clone(),
8230                    filter: None,
8231                    order_by: vec![],
8232                    offset: None,
8233                    limit: None,
8234                    lock: None,
8235                }),
8236            };
8237            branches.push(self.compile_stmt(&stmt)?);
8238        }
8239        let types: Vec<String> = branches.iter().map(cte_stmt_type).collect();
8240        let first = types.iter().find(|t| !t.is_empty()).cloned().unwrap_or_default();
8241        if let Some(other) = types
8242            .iter()
8243            .find(|t| t.contains("::") || (!t.is_empty() && **t != first))
8244        {
8245            let display = |t: &str| {
8246                if t.contains("::") {
8247                    t.to_string()
8248                } else {
8249                    pg_type_to_pyql(t).to_string()
8250                }
8251            };
8252            return Err(self.type_err(&format!(
8253                "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
8254                display(&first),
8255                display(other)
8256            )));
8257        }
8258        Ok(IrStmt::ScalarUnion(branches))
8259    }
8260
8261    /// Extract the target type name from a DML or inner SELECT statement.
8262    fn dml_subject_type(&self, stmt: &Stmt) -> Result<String, PyQLError> {
8263        match stmt {
8264            Stmt::Insert(ins) => Ok(ins.subject.qualified_name()),
8265            Stmt::Update(upd) => self.subject_type_name(&upd.subject),
8266            Stmt::Delete(del) => self.subject_type_name(&del.subject),
8267            Stmt::With(w) => self.dml_subject_type(&w.stmt),
8268            Stmt::For(f) => self.dml_subject_type(&f.body),
8269            Stmt::Analyze(inner) => self.dml_subject_type(inner),
8270            Stmt::Group(g) => self.expr_as_type_name(&g.subject),
8271            Stmt::Select(sel) => {
8272                // <Module::Type>expr — type name comes from the cast target
8273                if let Expr::TypeCast(tc) = &sel.result
8274                    && let Some((module, name)) = tc.ty.as_named()
8275                    && module.map(|m| m != "std").unwrap_or(false)
8276                {
8277                    return Ok(name.to_string());
8278                }
8279                // `select resource.revisions` — a walk names no type of its
8280                // own; what it lands on is the type the statement yields.
8281                if let Expr::Path(path) = &sel.result
8282                    && !path.partial
8283                    && path.steps.len() > 1
8284                    && let Some(ast::PathStep::Name(root)) = path.steps.first()
8285                    && let Ok(root_td) = self.resolve_path_root(root)
8286                    && let (_, Some(target)) = self.walk_path_types(root_td, &path.steps[1..], MAX_COMPUTED_SPLICES)
8287                {
8288                    return Ok(format!("{}::{}", target.module, target.name));
8289                }
8290                // SELECT-over-SELECT: get the type from the inner select's result
8291                let (type_name, _, _, _) = self.extract_type_and_shape(&sel.result)?;
8292                Ok(type_name)
8293            }
8294        }
8295    }
8296
8297    /// `id in (…)` over the rows a DML subject path traverses to, so a
8298    /// mutation written against a traversal touches those rows and no others.
8299    fn compile_subject_path_rows(&mut self, subject: &ast::Path, target_alias: &str) -> Result<IrExpr, PyQLError> {
8300        // Traversing to `.id` rather than stopping at the objects: a path that
8301        // ends on a link or a type intersection has nothing to project, and the
8302        // ids are what the narrowing compares against anyway.
8303        let mut steps = subject.steps.clone();
8304        steps.push(ast::PathStep::Name("id".to_string()));
8305        let ids = ast::Path { steps, partial: false };
8306        let synthetic = ast::SelectStmt {
8307            result: Expr::Path(ids.clone()),
8308            filter: None,
8309            order_by: vec![],
8310            offset: None,
8311            limit: None,
8312            lock: None,
8313        };
8314        let rows = self.compile_path_select(&synthetic, &ids, &[], false)?;
8315        Ok(IrExpr::BinOp(Box::new(IrBinOp {
8316            left: IrExpr::ColumnRef {
8317                alias: target_alias.to_string(),
8318                column: "id".to_string(),
8319                pg_type: "uuid".to_string(),
8320            },
8321            op: ast::BinOpKind::In,
8322            right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(rows)))),
8323        })))
8324    }
8325
8326    /// The type a DML subject names, however it is written: a type, a `with`
8327    /// binding, or a traversal that ends on one.
8328    fn subject_type_name(&self, subject: &Expr) -> Result<String, PyQLError> {
8329        if let Expr::Path(p) = subject
8330            && p.steps.len() > 1
8331            && let Some(ast::PathStep::Name(root)) = p.steps.first()
8332            && let Ok(root_td) = self.resolve_path_root(root)
8333            && let (_, Some(target)) = self.walk_path_types(root_td, &p.steps[1..], MAX_COMPUTED_SPLICES)
8334        {
8335            return Ok(format!("{}::{}", target.module, target.name));
8336        }
8337        // A subject that is just a `with` binding names rows, not a type, so
8338        // the type is the one the binding was bound to. `compile_update`
8339        // wants the binding's own name (it narrows the update to those rows);
8340        // a reader asking what type the statement yields wants this.
8341        if let Expr::Path(p) = subject
8342            && !p.partial
8343            && let [ast::PathStep::Name(root)] = p.steps.as_slice()
8344            && let Some(bound) = self.cte_object_type(root)
8345        {
8346            return Ok(bound);
8347        }
8348        self.expr_as_type_name(subject)
8349    }
8350
8351    fn expr_as_type_name(&self, expr: &Expr) -> Result<String, PyQLError> {
8352        if std::env::var("PYLON_DBG_SUBJ").is_ok() {
8353            eprintln!(
8354                "DBG subj {:.90?}
8355{}",
8356                expr,
8357                std::backtrace::Backtrace::force_capture()
8358            );
8359        }
8360        match expr {
8361            // `detached T` names the same type; the prefix only says the set
8362            // is not correlated with the enclosing one.
8363            Expr::Detached(inner) => self.expr_as_type_name(inner),
8364            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
8365                if let ast::PathStep::Name(n) = &p.steps[0] {
8366                    return Ok(n.clone());
8367                }
8368                Err(self.type_err("expected a type name"))
8369            }
8370            // `select (a union b)` as a SELECT-over-SELECT subject: every
8371            // branch carries the same object type, and that is the subject.
8372            Expr::Union(_, _) => {
8373                let branches = self
8374                    .object_union_branches(expr)
8375                    .ok_or_else(|| self.type_err("expected a type name as SELECT subject"))?;
8376                self.common_union_type(&branches)
8377                    .ok_or_else(|| self.type_err("expected a type name as SELECT subject"))
8378            }
8379            // `placed.line_items` — a walk names no type of its own; the type
8380            // meant is the one it lands on, which is how `dml_subject_type`
8381            // already reads the same path.
8382            Expr::Path(p)
8383                if !p.partial
8384                    && p.steps.len() > 1
8385                    && let Some(ast::PathStep::Name(root)) = p.steps.first()
8386                    && let Ok(root_td) = self.resolve_path_root(root)
8387                    && let (_, Some(target)) = self.walk_path_types(root_td, &p.steps[1..], MAX_COMPUTED_SPLICES) =>
8388            {
8389                Ok(format!("{}::{}", target.module, target.name))
8390            }
8391            _ => Err(self.type_err("expected a type name as SELECT subject")),
8392        }
8393    }
8394
8395    // ── INSERT ────────────────────────────────────────────────────────────────────
8396
8397    fn compile_insert(&mut self, ins: &ast::InsertStmt) -> Result<IrInsert, PyQLError> {
8398        // Taken before anything nested is compiled, so a nested statement
8399        // cannot pick up a guard meant for this one — `compile_update` and
8400        // `compile_delete` take theirs the same way.
8401        let pending_guard = self.pending_insert_guard.take();
8402        // Isolate this insert's own nested-DML discoveries (see
8403        // `pending_nested_ctes`'s doc comment) from whatever an enclosing
8404        // compile (e.g. this insert itself being the nested DML inside an
8405        // *outer* insert's link value) had pending, so each level attaches
8406        // only its own CTEs to its own `IrInsert`.
8407        let outer_pending_nested_ctes = std::mem::take(&mut self.pending_nested_ctes);
8408        let type_name = ins.subject.qualified_name();
8409        let td = self.resolve_type(&type_name)?;
8410        if td.abstract_ && td.materialized {
8411            return Err(self.type_err(&format!(
8412                "cannot insert into interface type '{}::{}'; insert into a concrete type instead",
8413                td.module, td.name
8414            )));
8415        }
8416        let alias = self.fresh_alias();
8417        let target = IrSource {
8418            poly: None,
8419            type_name: format!("{}::{}", td.module, td.name),
8420            table: td.table.clone(),
8421            alias: alias.clone(),
8422        };
8423
8424        // Multi-link shape elements (`tags := ...` / `tags += ...`) populate
8425        // the junction table once the row exists; everything else goes
8426        // through the normal scalar/link assignment path.
8427        let mut shape = ins.shape.clone();
8428        for link in &td.links {
8429            let unset = !shape.iter().any(|el| path_leaf(&el.path).is_ok_and(|n| n == link.name));
8430            for rw in link.rewrites.iter().filter(|rw| rw.on & 1 != 0) {
8431                if let Some(value) = subject_default_insert(&rw.handler, &link.name)
8432                    && unset
8433                {
8434                    shape.push(ShapeElement {
8435                        path: ast::Path::relative(&link.name),
8436                        splat: None,
8437                        nested: None,
8438                        compexpr: Some(value),
8439                        op: ShapeOp::Assign,
8440                        filter: None,
8441                        order_by: vec![],
8442                        offset: None,
8443                        limit: None,
8444                        marker_offset: None,
8445                    });
8446                }
8447            }
8448        }
8449        // Pointer defaults the shape leaves out. Every default is applied by
8450        // expanding it into the insert's own shape;
8451        // the ones a column DEFAULT can hold are left to it here, so only the
8452        // rest — a default reading a session global, or selecting the object to
8453        // link to — is expanded.
8454        for (pointer, pyql) in inlined_pointer_defaults(td, self.schema) {
8455            if shape.iter().any(|el| path_leaf(&el.path).is_ok_and(|n| n == pointer)) {
8456                continue;
8457            }
8458            let value = crate::parse::parse_pointer_expr(&pyql)?;
8459            shape.push(default_shape_element(&pointer, value));
8460        }
8461        let mut multi_link_appends = vec![];
8462        let mut scalar_elements: Vec<ShapeElement> = vec![];
8463        for el in &shape {
8464            let pointer_name = match path_leaf(&el.path) {
8465                Ok(n) => n,
8466                Err(_) => {
8467                    scalar_elements.push(el.clone());
8468                    continue;
8469                }
8470            };
8471            if let Some(ml) = Self::resolve_multilink(td, pointer_name) {
8472                match el.op {
8473                    ShapeOp::Remove => {
8474                        return Err(self.type_err(&format!(
8475                            "cannot use `-=` for multi-link '{pointer_name}' in an insert; \
8476                         there is nothing to remove from yet"
8477                        )));
8478                    }
8479                    ShapeOp::Assign | ShapeOp::Append => {
8480                        if let Some(expr) = &el.compexpr {
8481                            let (jt, module, src_col, tgt_col, through_td) =
8482                                self.own_multilink_junction_info(td, ml)?;
8483                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8484                            multi_link_appends.push(IrMultiLinkMutation {
8485                                junction_table: jt,
8486                                module,
8487                                source_col: src_col,
8488                                target_col: tgt_col,
8489                                values,
8490                                single: false,
8491                            });
8492                        }
8493                    }
8494                }
8495            } else if let Some(l) = Self::resolve_link(td, pointer_name).filter(|l| l.is_junction_backed()) {
8496                // A junction-backed single link is "a multi-link capped to
8497                // one row" (D1) — on insert there's nothing to replace yet,
8498                // so `:=` populates the junction table the same way a
8499                // multi-link's own `:=`/`+=` does above.
8500                if matches!(el.op, ShapeOp::Remove) {
8501                    return Err(self.type_err(&format!(
8502                        "cannot use `-=` for link '{pointer_name}' in an insert; \
8503                         there is nothing to remove from yet"
8504                    )));
8505                }
8506                if let Some(expr) = &el.compexpr {
8507                    // `:= {}` / `:= <Type>{}` at insert time — same as
8508                    // omitting the pointer entirely: nothing to append, no
8509                    // junction row created yet.
8510                    if !is_empty_set_expr(expr) {
8511                        let (jt, module, src_col, tgt_col, through_td) = self.own_link_junction_info(td, l)?;
8512                        let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8513                        multi_link_appends.push(IrMultiLinkMutation {
8514                            junction_table: jt,
8515                            module,
8516                            source_col: src_col,
8517                            target_col: tgt_col,
8518                            values,
8519                            single: true,
8520                        });
8521                    }
8522                }
8523            } else {
8524                scalar_elements.push(el.clone());
8525            }
8526        }
8527
8528        let assignments = self.compile_assignments(&scalar_elements, td, &alias)?;
8529        // Rewrites apply in the table's own `BEFORE` trigger — see
8530        // `export::rewrite_trigger_infos`.
8531        let rewrites = vec![];
8532        let unless_conflict = match ins.unless_conflict.as_ref() {
8533            Some(uc) => {
8534                let (conflict, else_appends) = self.compile_conflict(uc, td)?;
8535                multi_link_appends.extend(else_appends);
8536                Some(conflict)
8537            }
8538            None => None,
8539        };
8540        let returning = Self::pk_returning(td);
8541        let type_name = format!("{}::{}", td.module, td.name);
8542        let enqueue_vector = td
8543            .vector_indexes
8544            .iter()
8545            .map(|vi| VectorEnqueueInfo {
8546                type_name: type_name.clone(),
8547                index_name: vi.index_name.clone(),
8548            })
8549            .collect();
8550        let enqueue_search = collect_search_enqueue(td, &type_name, "index");
8551        let mut nested_ctes = std::mem::replace(&mut self.pending_nested_ctes, outer_pending_nested_ctes);
8552
8553        let guard = match pending_guard {
8554            Some(condition) => Some(self.compile_expr(&condition, td, &alias)?),
8555            None => None,
8556        };
8557        // A nested insert exists only to fill this row's values, so it writes
8558        // when this one does and not otherwise. Without the condition it would
8559        // leave a row behind with nothing pointing at it. Applied to any
8560        // depth: a line item's own attributes are two loops down.
8561        if let Some(condition) = &guard {
8562            fn cte_refs(appends: &[IrMultiLinkMutation], into: &mut Vec<String>) {
8563                into.extend(appends.iter().filter_map(|append| match &append.values.source {
8564                    IrMultiLinkValueSource::CteRef(name) => Some(name.clone()),
8565                    _ => None,
8566                }));
8567            }
8568            fn carry_guard(stmt: &mut IrStmt, condition: &IrExpr, pending: &mut Vec<String>) {
8569                // A loop's own bindings can mutate too — a line item's
8570                // attributes are bound inside the loop that writes the line.
8571                if let IrStmt::For(loop_) = stmt {
8572                    for def in &mut loop_.body_ctes {
8573                        carry_guard(&mut def.stmt, condition, pending);
8574                    }
8575                }
8576                let nested = match stmt {
8577                    IrStmt::Insert(nested) => Some(nested),
8578                    IrStmt::For(loop_) => match loop_.body.as_mut() {
8579                        IrStmt::Insert(nested) => Some(nested),
8580                        _ => None,
8581                    },
8582                    _ => None,
8583                };
8584                let Some(nested) = nested else { return };
8585                if nested.guard.is_none() {
8586                    nested.guard = Some(condition.clone());
8587                }
8588                cte_refs(&nested.multi_link_appends, pending);
8589                for def in &mut nested.nested_ctes {
8590                    carry_guard(&mut def.stmt, condition, pending);
8591                }
8592            }
8593            // A multi-link's rows are hoisted beside this statement rather than
8594            // into its own nested CTEs, so they are reached by name instead.
8595            let mut pending: Vec<String> = vec![];
8596            cte_refs(&multi_link_appends, &mut pending);
8597            for def in &mut nested_ctes {
8598                carry_guard(&mut def.stmt, condition, &mut pending);
8599            }
8600            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
8601            while let Some(name) = pending.pop() {
8602                if !seen.insert(name.clone()) {
8603                    continue;
8604                }
8605                if let Some(index) = self.hoisted_ctes.iter().position(|def| def.name == name) {
8606                    carry_guard(&mut self.hoisted_ctes[index].stmt, condition, &mut pending);
8607                }
8608            }
8609        }
8610        // Only a `for` body's nested insert needs to name its own id (see
8611        // `IrInsert::id_default_sql`); carried here because the schema is in
8612        // scope, and ignored everywhere else.
8613        let id_default_sql =
8614            Self::resolve_property(td, "id").map(|p| p.default_sql.clone().unwrap_or_else(|| "uuidv7()".to_string()));
8615        Ok(IrInsert {
8616            guard,
8617            target,
8618            assignments,
8619            unless_conflict,
8620            rewrites,
8621            id_default_sql,
8622            returning,
8623            enqueue_vector,
8624            enqueue_search,
8625            multi_link_appends,
8626            nested_ctes,
8627        })
8628    }
8629
8630    fn compile_assignments(
8631        &mut self,
8632        elements: &[ShapeElement],
8633        td: &TypeDescriptor,
8634        alias: &str,
8635    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8636        self.compile_assignments_inner(elements, td, alias, false)
8637    }
8638
8639    fn compile_assignments_for_update(
8640        &mut self,
8641        elements: &[ShapeElement],
8642        td: &TypeDescriptor,
8643        alias: &str,
8644    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8645        self.compile_assignments_inner(elements, td, alias, true)
8646    }
8647
8648    fn compile_assignments_inner(
8649        &mut self,
8650        elements: &[ShapeElement],
8651        td: &TypeDescriptor,
8652        alias: &str,
8653        deny_readonly: bool,
8654    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8655        // A value assigned in a mutation body is not output, and a link value
8656        // among them compiles to a one-column correlated subquery — an implicit
8657        // `id` beside the column it selects would make that subquery illegal.
8658        self.without_implicit_id(|this| this.compile_assignments_rows(elements, td, alias, deny_readonly))
8659    }
8660
8661    fn compile_assignments_rows(
8662        &mut self,
8663        elements: &[ShapeElement],
8664        td: &TypeDescriptor,
8665        alias: &str,
8666        deny_readonly: bool,
8667    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8668        elements
8669            .iter()
8670            .map(|el| {
8671                let pointer_name = path_leaf(&el.path)?;
8672                let expr = el.compexpr.as_ref().ok_or_else(|| {
8673                    PyQLError::Type(PyQLTypeError {
8674                        message: format!("INSERT pointer '{pointer_name}' has no value expression"),
8675                        position: Position { line: 0, col: 0 },
8676                    })
8677                })?;
8678
8679                // Validate the pointer exists
8680                let column = if let Some(p) = Self::resolve_property(td, pointer_name) {
8681                    // A primary-key ("id") property: an UPDATE never allows
8682                    // reassigning it (deny_readonly is true there, regardless
8683                    // of the session config); an INSERT only allows an
8684                    // explicit value when allow_user_specified_id is set.
8685                    if p.is_pk && (deny_readonly || !self.config.allow_user_specified_id) {
8686                        return Err(PyQLError::Type(PyQLTypeError {
8687                            message: "cannot assign to property 'id'".to_string(),
8688                            position: Position { line: 0, col: 0 },
8689                        }));
8690                    }
8691                    if deny_readonly && p.is_readonly {
8692                        return Err(PyQLError::Type(PyQLTypeError {
8693                            message: format!("cannot update property '{pointer_name}': it is declared as read-only"),
8694                            position: Position { line: 0, col: 0 },
8695                        }));
8696                    }
8697                    p.name.clone()
8698                } else if let Some(l) = Self::resolve_link(td, pointer_name) {
8699                    if deny_readonly && l.is_readonly {
8700                        return Err(PyQLError::Type(PyQLTypeError {
8701                            message: format!("cannot update link '{pointer_name}': it is declared as read-only"),
8702                            position: Position { line: 0, col: 0 },
8703                        }));
8704                    }
8705                    if l.is_junction_backed() {
8706                        // Reachable only via `UNLESS CONFLICT ... ELSE (UPDATE
8707                        // ... SET { ... })` — `compile_insert`/`compile_update`'s
8708                        // own shape-classification loop intercepts a junction-
8709                        // backed link before it ever reaches this generic
8710                        // scalar-assignment path; the ELSE clause has no
8711                        // junction-mutation mechanism of its own to reuse.
8712                        return Err(PyQLError::Type(PyQLTypeError {
8713                            message: format!(
8714                                "'{pointer_name}' is a junction-backed link and cannot be \
8715                                 assigned inside an UNLESS CONFLICT ELSE clause"
8716                            ),
8717                            position: Position { line: 0, col: 0 },
8718                        }));
8719                    }
8720                    // Link assignment via subquery: `company := (SELECT Company FILTER ...)`
8721                    // Compile as a scalar subquery returning the target pk (the FK uuid).
8722                    // A one-element set is that element — `credentials := {
8723                    // (insert Credentials { … }) }` says the same thing as
8724                    // assigning the insert directly.
8725                    let fk_col = format!("{}_id", l.name);
8726                    if let Some(ir_expr) = self.compile_link_key(expr, td, alias)? {
8727                        return Ok((fk_col, ir_expr));
8728                    }
8729                    fk_col
8730                } else if Self::resolve_multilink(td, pointer_name).is_some() {
8731                    // Same reach as the junction-backed link above: the
8732                    // shape-classification loop in `compile_insert` and
8733                    // `compile_update` takes multi-links first, so one only
8734                    // arrives here from an `UNLESS CONFLICT ... ELSE` clause,
8735                    // which has no junction-mutation mechanism to reuse.
8736                    // Saying the pointer does not exist sends the reader
8737                    // looking for a typo in a name that is plainly right.
8738                    return Err(PyQLError::Type(PyQLTypeError {
8739                        message: format!(
8740                            "'{pointer_name}' is a multi-link and cannot be mutated inside an \
8741                             UNLESS CONFLICT ELSE clause"
8742                        ),
8743                        position: Position { line: 0, col: 0 },
8744                    }));
8745                } else if Self::resolve_multilink(td, pointer_name).is_some() {
8746                    return Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)));
8747                } else if self.resolve_computed(td, pointer_name).is_some() {
8748                    // Reported as "no link or property 'x'. Did you mean 'x'?"
8749                    // before: the suggester's name list includes computeds,
8750                    // the resolution above does not, so it handed back the very
8751                    // name it had just refused.
8752                    return Err(self.type_err(&format!(
8753                        "cannot assign to '{pointer_name}': it is a computed pointer on \
8754                         {}::{}, which has no stored column to write",
8755                        td.module, td.name
8756                    )));
8757                } else {
8758                    return Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)));
8759                };
8760
8761                // `{}` (empty set) in assignment position means NULL.
8762                let ir_expr = if matches!(expr, Expr::Set(v) if v.is_empty()) {
8763                    IrExpr::Null
8764                } else {
8765                    self.compile_expr(expr, td, alias)?
8766                };
8767                Ok((column, ir_expr))
8768            })
8769            .collect()
8770    }
8771
8772    // ── UPDATE ────────────────────────────────────────────────────────────────────
8773
8774    fn compile_update(&mut self, upd: &ast::UpdateStmt) -> Result<IrUpdate, PyQLError> {
8775        // Taken before anything nested is compiled, so a nested statement
8776        // cannot pick up a guard meant for this one.
8777        let pending_guard = self.pending_update_guard.take();
8778        // See `compile_insert`'s identical save/restore of `pending_nested_ctes`.
8779        let outer_pending_nested_ctes = std::mem::take(&mut self.pending_nested_ctes);
8780        // `update (select T filter …).link set { … }` — the subject walks off a
8781        // sub-select rather than off a named root. The select is hoisted into
8782        // the statement's own WITH and the walk re-rooted at that binding,
8783        // giving the `with s := (select …) update s.link set { … }` spelling
8784        // the path branch below already handles.
8785        if !matches!(&upd.subject, Expr::Path(_)) {
8786            let (base, fields) = Self::peel_field_access_chain(&upd.subject);
8787            if let Expr::SubQuery(inner_stmt) = base
8788                && !fields.is_empty()
8789                && matches!(inner_stmt.as_ref(), Stmt::Select(_))
8790            {
8791                let inner = self.compile_stmt(inner_stmt)?;
8792                let cte_name = self.fresh_nested_cte_name();
8793                let type_name = self.register_cte(&cte_name, &inner);
8794                self.hoisted_ctes.push(IrCteDef {
8795                    name: cte_name.clone(),
8796                    stmt: inner,
8797                    type_name,
8798                    correlated_to: None,
8799                });
8800                let mut steps = vec![ast::PathStep::Name(cte_name)];
8801                steps.extend(fields.into_iter().map(ast::PathStep::Name));
8802                let rerooted = ast::UpdateStmt {
8803                    subject: Expr::Path(ast::Path { steps, partial: false }),
8804                    filter: upd.filter.clone(),
8805                    shape: upd.shape.clone(),
8806                };
8807                self.pending_nested_ctes = outer_pending_nested_ctes;
8808                self.pending_update_guard = pending_guard;
8809                return self.compile_update(&rerooted);
8810            }
8811        }
8812        // `update account.preferences[is IndividualPreferences] set …` — the
8813        // subject is a traversal rather than a name, so the rows to update are
8814        // the ones it lands on: the table is the type the walk ends on, and the
8815        // update is narrowed to the ids the traversal yields.
8816        if let Expr::Path(subject) = &upd.subject
8817            && subject.steps.len() > 1
8818            && let Some(ast::PathStep::Name(root)) = subject.steps.first()
8819            && let Ok(root_td) = self.resolve_path_root(root)
8820            && let (_, Some(target_td)) = self.walk_path_types(root_td, &subject.steps[1..], MAX_COMPUTED_SPLICES)
8821        {
8822            let narrowed = ast::UpdateStmt {
8823                subject: Expr::Path(ast::Path {
8824                    steps: vec![ast::PathStep::Name(format!("{}::{}", target_td.module, target_td.name))],
8825                    partial: false,
8826                }),
8827                filter: upd.filter.clone(),
8828                shape: upd.shape.clone(),
8829            };
8830            self.pending_nested_ctes = outer_pending_nested_ctes;
8831            self.pending_update_guard = pending_guard;
8832            let mut ir = self.compile_update(&narrowed)?;
8833            // Built after the update, so the comparison names that update's own
8834            // alias: with a nested statement's CTE in the FROM, a bare `id`
8835            // could mean either relation.
8836            let rows = self.compile_subject_path_rows(subject, &ir.target.alias)?;
8837            ir.filter = Some(and_conditions(ir.filter.take(), vec![rows]).expect("row set is present"));
8838            return Ok(ir);
8839        }
8840        let type_name = self.expr_as_type_name(&upd.subject)?;
8841        // `update account set …` where `account` is a `with` binding: the
8842        // binding names the rows to update, so it decides the table *and*
8843        // narrows the update to its own rows. Without the narrowing this would
8844        // resolve to the type and rewrite every row in the table.
8845        let bound_rows = self.cte_object_type(&type_name);
8846        // `for p in … union (update p set …)` — the same for a loop variable,
8847        // which names the one row this iteration holds. Without it the UPDATE
8848        // went out with no WHERE at all and rewrote every row of the table.
8849        let iterated = self
8850            .for_var_types
8851            .contains_key(&type_name)
8852            .then(|| self.for_var_ref(&type_name));
8853        let td = self.resolve_path_root(&type_name)?;
8854        let alias = self.fresh_alias();
8855        let target = IrSource {
8856            poly: None,
8857            type_name: format!("{}::{}", td.module, td.name),
8858            table: td.table.clone(),
8859            alias: alias.clone(),
8860        };
8861
8862        let declared_filter = upd
8863            .filter
8864            .as_ref()
8865            .map(|f| self.as_condition(|c| c.compile_expr(f, td, &alias)))
8866            .transpose()?;
8867        let filter = match bound_rows {
8868            Some(_) => {
8869                let membership = IrExpr::BinOp(Box::new(IrBinOp {
8870                    left: IrExpr::ColumnRef {
8871                        alias: alias.clone(),
8872                        column: "id".to_string(),
8873                        pg_type: "uuid".to_string(),
8874                    },
8875                    op: ast::BinOpKind::In,
8876                    right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::Select(IrSelect::schema_bound(
8877                        IrSource {
8878                            poly: None,
8879                            type_name: format!("{}::{}", td.module, td.name),
8880                            table: format!("@cte:{type_name}"),
8881                            alias: self.fresh_alias(),
8882                        },
8883                        vec![],
8884                        None,
8885                    )))),
8886                }));
8887                Some(and_conditions(declared_filter, vec![membership]).expect("membership is present"))
8888            }
8889            None => declared_filter,
8890        };
8891        let filter = match iterated {
8892            Some(value) => {
8893                let this_row = IrExpr::BinOp(Box::new(IrBinOp {
8894                    left: IrExpr::ColumnRef {
8895                        alias: alias.clone(),
8896                        column: "id".to_string(),
8897                        pg_type: "uuid".to_string(),
8898                    },
8899                    op: ast::BinOpKind::Eq,
8900                    right: value,
8901                }));
8902                Some(and_conditions(filter, vec![this_row]).expect("the row condition is present"))
8903            }
8904            None => filter,
8905        };
8906        // `(update cart set { … }) if not exists(existing) else {}` — the
8907        // condition narrows the rows this update touches. Left to filter what
8908        // is read back instead, the update would still run.
8909        let filter = match pending_guard {
8910            Some(condition) => {
8911                let guard = self.compile_expr(&condition, td, &alias)?;
8912                and_conditions(filter, vec![guard])
8913            }
8914            None => filter,
8915        };
8916
8917        // Classify shape elements by kind.
8918        let mut multi_link_clears = vec![];
8919        let mut multi_link_replaces = vec![];
8920        let mut multi_link_appends = vec![];
8921        let mut multi_link_removals = vec![];
8922        let mut scalar_elements: Vec<ShapeElement> = vec![];
8923
8924        for el in &upd.shape {
8925            let pointer_name = match path_leaf(&el.path) {
8926                Ok(n) => n,
8927                Err(_) => {
8928                    scalar_elements.push(el.clone());
8929                    continue;
8930                }
8931            };
8932
8933            if let Some(ml) = Self::resolve_multilink(td, pointer_name) {
8934                let (jt, module, src_col, tgt_col, through_td) = self.own_multilink_junction_info(td, ml)?;
8935
8936                match el.op {
8937                    ShapeOp::Assign => {
8938                        let is_empty = el
8939                            .compexpr
8940                            .as_ref()
8941                            .map(|e| matches!(e, Expr::Set(v) if v.is_empty()))
8942                            .unwrap_or(false);
8943                        if is_empty {
8944                            // := {} — clear all junction rows
8945                            multi_link_clears.push(IrMultiLinkClear {
8946                                junction_table: jt,
8947                                module,
8948                                source_col: src_col,
8949                            });
8950                        } else if let Some(expr) = &el.compexpr {
8951                            // := expr — replace (clear + insert)
8952                            multi_link_clears.push(IrMultiLinkClear {
8953                                junction_table: jt.clone(),
8954                                module: module.clone(),
8955                                source_col: src_col.clone(),
8956                            });
8957                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8958                            multi_link_replaces.push(IrMultiLinkMutation {
8959                                junction_table: jt,
8960                                module,
8961                                source_col: src_col,
8962                                target_col: tgt_col,
8963                                values,
8964                                single: false,
8965                            });
8966                        }
8967                    }
8968                    ShapeOp::Append => {
8969                        if let Some(expr) = &el.compexpr {
8970                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8971                            multi_link_appends.push(IrMultiLinkMutation {
8972                                junction_table: jt,
8973                                module,
8974                                source_col: src_col,
8975                                target_col: tgt_col,
8976                                values,
8977                                single: false,
8978                            });
8979                        }
8980                    }
8981                    ShapeOp::Remove => {
8982                        if let Some(expr) = &el.compexpr {
8983                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8984                            if has_any_link_props(&values) {
8985                                return Err(self.type_err(
8986                                    "link properties (`@prop := value`) cannot be assigned \
8987                                     when removing a link (`-=`)",
8988                                ));
8989                            }
8990                            multi_link_removals.push(IrMultiLinkMutation {
8991                                junction_table: jt,
8992                                module,
8993                                source_col: src_col,
8994                                target_col: tgt_col,
8995                                values,
8996                                single: false,
8997                            });
8998                        }
8999                    }
9000                }
9001            } else if let Some(l) = Self::resolve_link(td, pointer_name).filter(|l| l.is_junction_backed()) {
9002                // Same replace-via-clear-then-insert shape a multi-link's
9003                // own `:=` uses — only `:=` is meaningful for a single
9004                // link, whether FK-backed or junction-backed.
9005                if !matches!(el.op, ShapeOp::Assign) {
9006                    return Err(self.type_err(&format!(
9007                        "'{pointer_name}' is a single link; only `:=` is supported, not `+=`/`-=`"
9008                    )));
9009                }
9010                let (jt, module, src_col, tgt_col, through_td) = self.own_link_junction_info(td, l)?;
9011                let is_empty = el.compexpr.as_ref().map(is_empty_set_expr).unwrap_or(false);
9012                if is_empty {
9013                    // := {} — clear the junction row
9014                    multi_link_clears.push(IrMultiLinkClear {
9015                        junction_table: jt,
9016                        module,
9017                        source_col: src_col,
9018                    });
9019                } else if let Some(expr) = &el.compexpr {
9020                    multi_link_clears.push(IrMultiLinkClear {
9021                        junction_table: jt.clone(),
9022                        module: module.clone(),
9023                        source_col: src_col.clone(),
9024                    });
9025                    let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
9026                    multi_link_replaces.push(IrMultiLinkMutation {
9027                        junction_table: jt,
9028                        module,
9029                        source_col: src_col,
9030                        target_col: tgt_col,
9031                        values,
9032                        single: true,
9033                    });
9034                }
9035            } else {
9036                scalar_elements.push(el.clone());
9037            }
9038        }
9039
9040        let assignments = self.compile_assignments_for_update(&scalar_elements, td, &alias)?;
9041        // See the insert's own: rewrites apply in the table's `BEFORE` trigger.
9042        let rewrites = vec![];
9043        let returning = Self::pk_returning(td);
9044
9045        let (poly_implementors, poly_columns) = if self.is_polymorphic(td) {
9046            (
9047                self.find_poly_implementors(&format!("{}::{}", td.module, td.name)),
9048                Self::poly_dml_columns(td),
9049            )
9050        } else {
9051            (vec![], vec![])
9052        };
9053
9054        // Only enqueue indexes whose source pointers are touched by this update.
9055        let written_cols: std::collections::HashSet<&str> = assignments.iter().map(|(c, _)| c.as_str()).collect();
9056        let type_name = format!("{}::{}", td.module, td.name);
9057        let enqueue_vector: Vec<VectorEnqueueInfo> = td
9058            .vector_indexes
9059            .iter()
9060            .filter(|vi| vi.pointers.iter().any(|f| written_cols.contains(f.as_str())))
9061            .map(|vi| VectorEnqueueInfo {
9062                type_name: type_name.clone(),
9063                index_name: vi.index_name.clone(),
9064            })
9065            .collect();
9066        let enqueue_search = collect_search_enqueue(td, &type_name, "index");
9067        // Nested-DML CTEs (see IrUpdate::nested_ctes) compose with every
9068        // other UPDATE shape — multi-link mutation, interface-type fan-out,
9069        // and vector/search enqueue each have their own emitter branch
9070        // (emit_update_stmt / emit_poly_update_stmt) that now prepends
9071        // these CTEs and adds the FROM clause a hoisted CTE reference needs.
9072        let nested_ctes = std::mem::replace(&mut self.pending_nested_ctes, outer_pending_nested_ctes);
9073
9074        Ok(IrUpdate {
9075            target,
9076            filter,
9077            assignments,
9078            rewrites,
9079            returning,
9080            multi_link_clears,
9081            multi_link_replaces,
9082            multi_link_appends,
9083            multi_link_removals,
9084            poly_implementors,
9085            poly_columns,
9086            enqueue_vector,
9087            enqueue_search,
9088            nested_ctes,
9089        })
9090    }
9091
9092    /// Extract junction table info for a multi-link (or a junction-backed
9093    /// single link, which shares this exact storage shape — D2):
9094    /// (junction_table, module, source_col, target_col, through_td).
9095    /// `through_td` is the junction type's own TypeDescriptor for a
9096    /// `Through[...]` link (needed to validate/compile `@prop := expr`
9097    /// link-property assignments against its real properties) — `None` for
9098    /// a Standard (implicit) junction table, which has no user-declared
9099    /// properties at all.
9100    fn junction_info_for(
9101        &mut self,
9102        td: &TypeDescriptor,
9103        name: &str,
9104        target: &str,
9105        through: &Option<String>,
9106    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9107        let (junction_table, module, source_col, target_col, through_td) =
9108            self.own_junction_info_for(td, name, target, through)?;
9109        let owner_derived = through_td.is_none_or(|through_td| through_td.junction);
9110        let junction_table = if owner_derived {
9111            self.owner_junction(td, name, through_td)
9112        } else {
9113            junction_table
9114        };
9115        Ok((junction_table, module, source_col, target_col, through_td))
9116    }
9117
9118    /// Every junction table a read of `td`'s multi-links can name, quoted as
9119    /// the emitter spells it. A `through` type brings its own table; an
9120    /// owner-derived junction is named after the owner, and a subtype's
9121    /// carries that subtype's table, so each implementor contributes one.
9122    fn junction_tables_of(&mut self, td: &TypeDescriptor) -> Result<Vec<String>, PyQLError> {
9123        let mut tables = vec![];
9124        for multilink in &td.multilinks {
9125            match &multilink.through {
9126                Some(through) => {
9127                    let through_td = self.resolve_type(through)?;
9128                    tables.push(format!("\"{}\"", through_td.table));
9129                }
9130                None => {
9131                    tables.push(format!("\"{}.{}\"", td.table, multilink.name));
9132                    for implementor in self.find_poly_implementors(&format!("{}::{}", td.module, td.name)) {
9133                        tables.push(format!("\"{}.{}\"", implementor.table, multilink.name));
9134                    }
9135                }
9136            }
9137        }
9138        Ok(tables)
9139    }
9140
9141    /// The junction a read of `td`'s multi-link `name` goes through: its own
9142    /// table, or, when `td` has subtypes, the union of theirs beside it.
9143    fn owner_junction(&self, td: &TypeDescriptor, name: &str, through_td: Option<&TypeDescriptor>) -> String {
9144        let own = format!("{}.{}", td.table, name);
9145        if !self.has_subtypes(td) {
9146            return own;
9147        }
9148        let tables = self
9149            .find_poly_implementors(&format!("{}::{}", td.module, td.name))
9150            .into_iter()
9151            .map(|implementor| (implementor.module, format!("{}.{}", implementor.table, name)))
9152            .collect::<Vec<_>>();
9153        let mut columns = vec!["source".to_string(), "target".to_string()];
9154        if let Some(through_td) = through_td {
9155            columns.extend(Self::poly_dml_columns(through_td).into_iter().filter(|c| c != "id"));
9156        }
9157        super::inherited_junction(&tables, &columns)
9158    }
9159
9160    /// `junction_info_for` naming the owner's own junction table, which is
9161    /// the one a write to that owner's rows goes to.
9162    fn own_junction_info_for(
9163        &mut self,
9164        td: &TypeDescriptor,
9165        name: &str,
9166        target: &str,
9167        through: &Option<String>,
9168    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9169        match through {
9170            None => Ok((
9171                format!("{}.{}", td.table, name),
9172                td.module.clone(),
9173                "source".to_string(),
9174                "target".to_string(),
9175                None,
9176            )),
9177            Some(through_qname) => {
9178                let through_td = self.resolve_type(through_qname)?;
9179                let src_type = format!("{}::{}", td.module, td.name);
9180                let source_col = through_td
9181                    .links
9182                    .iter()
9183                    .find(|l| l.target == src_type)
9184                    .map(|l| l.name.clone())
9185                    .unwrap_or_else(|| "source".to_string());
9186                // A self-referencing through-link (source type == target
9187                // type, e.g. Person.friends via a PersonFriend with two
9188                // Person-typed links) would otherwise match the same link
9189                // for both sides — prefer a differently-named one first,
9190                // matching the tie-break already used for the read-side
9191                // join resolution elsewhere in this file.
9192                let target_col = through_td
9193                    .links
9194                    .iter()
9195                    .find(|l| l.target == target && l.name != source_col)
9196                    .or_else(|| through_td.links.iter().find(|l| l.target == target))
9197                    .map(|l| l.name.clone())
9198                    .unwrap_or_else(|| "target".to_string());
9199                // A dedicated `@pylon.junction` type has no physical table
9200                // of its own — `emit_one_junction_table` always names it
9201                // `"{owner.table}.{name}"`, one per *owner* (so that e.g.
9202                // an interface-inherited junction-backed link gets one
9203                // physically separate table per concrete implementor,
9204                // never a single table shared — and thus impossibly
9205                // FK'd — across several). `through_td.table` only holds
9206                // the right name here by accident, for the common case of
9207                // exactly one owner ever referencing that junction type
9208                // (the Python walker pre-renames it for that one owner).
9209                // A non-junction "through" type, in contrast, *is* a real,
9210                // independently-queryable object with its own genuine
9211                // table — `through_td.table` is correct for that case and
9212                // must stay as-is.
9213                let (junction_table, junction_module) = if through_td.junction {
9214                    (format!("{}.{}", td.table, name), td.module.clone())
9215                } else {
9216                    (through_td.table.clone(), through_td.module.clone())
9217                };
9218                Ok((
9219                    junction_table,
9220                    junction_module,
9221                    source_col,
9222                    target_col,
9223                    Some(through_td),
9224                ))
9225            }
9226        }
9227    }
9228
9229    fn multilink_junction_info(
9230        &mut self,
9231        td: &TypeDescriptor,
9232        ml: &MultiLinkDescriptor,
9233    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9234        self.junction_info_for(td, &ml.name, &ml.target, &ml.through)
9235    }
9236
9237    fn own_multilink_junction_info(
9238        &mut self,
9239        td: &TypeDescriptor,
9240        ml: &MultiLinkDescriptor,
9241    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9242        self.own_junction_info_for(td, &ml.name, &ml.target, &ml.through)
9243    }
9244
9245    fn own_link_junction_info(
9246        &mut self,
9247        td: &TypeDescriptor,
9248        l: &LinkDescriptor,
9249    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9250        self.own_junction_info_for(td, &l.name, &l.target, &l.through)
9251    }
9252
9253    /// Same as `multilink_junction_info`, for a junction-backed single link.
9254    fn link_junction_info(
9255        &mut self,
9256        td: &TypeDescriptor,
9257        l: &LinkDescriptor,
9258    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
9259        self.junction_info_for(td, &l.name, &l.target, &l.through)
9260    }
9261
9262    /// Build the `IrMultiLinkJoin` a multi-link or junction-backed single
9263    /// link's forward read-side join uses (`IrPathJoin::Multi`, a shape's
9264    /// `IrMultiLinkPointer`, or — for a junction-backed single link — the
9265    /// junction variant of `IrSingleLinkCorrelation`).
9266    fn build_multilink_join(
9267        &mut self,
9268        td: &TypeDescriptor,
9269        name: &str,
9270        target: &str,
9271        through: &Option<String>,
9272    ) -> Result<IrMultiLinkJoin, PyQLError> {
9273        if let Some(through_qname) = through {
9274            let through_td = self.resolve_type(through_qname)?;
9275            if through_td.junction {
9276                // See `junction_info_for`'s doc comment: a dedicated
9277                // junction type's physical table is always owner-derived
9278                // (one per concrete implementor), never `through_td.table`
9279                // itself.
9280                Ok(IrMultiLinkJoin::Standard {
9281                    junction_table: self.owner_junction(td, name, Some(through_td)),
9282                    module: td.module.clone(),
9283                })
9284            } else {
9285                let source_qname = format!("{}::{}", td.module, td.name);
9286                let source_col = through_td
9287                    .links
9288                    .iter()
9289                    .find(|l| l.target == source_qname)
9290                    .ok_or_else(|| {
9291                        PyQLError::Type(PyQLTypeError {
9292                            message: format!("through type {through_qname} has no link to {source_qname}"),
9293                            position: Position { line: 0, col: 0 },
9294                        })
9295                    })?
9296                    .name
9297                    .clone();
9298                let target_col = through_td
9299                    .links
9300                    .iter()
9301                    .find(|l| l.target == target && l.name != source_col)
9302                    .or_else(|| through_td.links.iter().find(|l| l.target == target))
9303                    .ok_or_else(|| {
9304                        PyQLError::Type(PyQLTypeError {
9305                            message: format!("through type {through_qname} has no link to target {target}"),
9306                            position: Position { line: 0, col: 0 },
9307                        })
9308                    })?
9309                    .name
9310                    .clone();
9311                Ok(IrMultiLinkJoin::Through {
9312                    junction_table: through_td.table.clone(),
9313                    module: through_td.module.clone(),
9314                    source_col,
9315                    target_col,
9316                })
9317            }
9318        } else {
9319            let junction_table = self.owner_junction(td, name, None);
9320            Ok(IrMultiLinkJoin::Standard {
9321                junction_table: match self.junction_read_overrides.get(&junction_table) {
9322                    Some(o) => o.junction.clone(),
9323                    None => junction_table,
9324                },
9325                module: td.module.clone(),
9326            })
9327        }
9328    }
9329
9330    /// Compile the RHS of a multilink `+=`, `-=`, or `:= expr` into an
9331    /// `IrMultiLinkValues`. `td`/`alias` are the record being updated (link-
9332    /// property value expressions like `@weight := <float64>$w` compile
9333    /// against this scope, same as any other UPDATE SET assignment — they
9334    /// cannot reference the linked target's own properties, only the outer
9335    /// record's or bound params/literals). `through_td` is the junction
9336    /// type's own TypeDescriptor for a `Through[...]` multi-link, or `None`
9337    /// for a Standard junction (which has no properties to assign).
9338    fn compile_multilink_values(
9339        &mut self,
9340        expr: &Expr,
9341        td: &'a TypeDescriptor,
9342        alias: &str,
9343        through_td: Option<&'a TypeDescriptor>,
9344    ) -> Result<IrMultiLinkValues, PyQLError> {
9345        // Same reason as `compile_assignments_inner`: these rows are the
9346        // junction's target ids, not output, and each value is read as one
9347        // column.
9348        self.without_implicit_id(|this| this.compile_multilink_values_inner(expr, td, alias, through_td))
9349    }
9350
9351    fn compile_multilink_values_inner(
9352        &mut self,
9353        expr: &Expr,
9354        td: &'a TypeDescriptor,
9355        alias: &str,
9356        through_td: Option<&'a TypeDescriptor>,
9357    ) -> Result<IrMultiLinkValues, PyQLError> {
9358        // `a union b` — combine both sides; each keeps its own link_props
9359        // (different targets in one `+=` can carry different property values).
9360        if let Expr::Union(a, b) = expr {
9361            let left = self.compile_multilink_values(a, td, alias, through_td)?;
9362            let right = self.compile_multilink_values(b, td, alias, through_td)?;
9363            return Ok(IrMultiLinkValues {
9364                source: IrMultiLinkValueSource::Union(Box::new(left), Box::new(right)),
9365                link_props: vec![],
9366            });
9367        }
9368
9369        // `emails := (insert …) if exists($email) else {}` — the same choice
9370        // between two sets the select path reads, and the same rewrite: the
9371        // insert carries the condition itself, since Postgres runs a
9372        // data-modifying CTE whether or not anything reads it.
9373        if matches!(expr, Expr::IfElse(_))
9374            && let Some(rewritten) = self.object_if_else_as_union(expr)
9375        {
9376            return self.compile_multilink_values(&rewritten, td, alias, through_td);
9377        }
9378
9379        // `notifications += (select .notifications { @read_at := … } filter …)`
9380        // — the link properties sit *inside* the select. Lifted out, the shape
9381        // branch below assigns them and the bare select goes down the
9382        // relative-path branch, which is what each already knows how to do.
9383        if let Expr::SubQuery(inner) = expr
9384            && let Stmt::Select(sel) = inner.as_ref()
9385            && let Expr::Shape(sh) = &sel.result
9386            && !sh.elements.is_empty()
9387            && sh
9388                .elements
9389                .iter()
9390                .all(|el| matches!(el.path.steps.as_slice(), [ast::PathStep::LinkProp(_)]))
9391            && let Some(base) = sh.expr.clone()
9392        {
9393            let lifted = Expr::Shape(Box::new(ast::ShapeExpr {
9394                expr: Some(Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
9395                    result: base,
9396                    ..sel.clone()
9397                })))),
9398                elements: sh.elements.clone(),
9399                marker_offset: sh.marker_offset,
9400            }));
9401            return self.compile_multilink_values(&lifted, td, alias, through_td);
9402        }
9403
9404        // `expr { @prop := value, ... }` — link-property assignments layered
9405        // onto an inner target-selecting expression.
9406        if let Expr::Shape(shape) = expr {
9407            let inner_expr = shape
9408                .expr
9409                .as_ref()
9410                .ok_or_else(|| self.type_err("multilink value shape must have a base expression"))?;
9411            let mut inner = self.compile_multilink_values(inner_expr, td, alias, through_td)?;
9412            // `(select .notifications { @read_at := … @read_at })` — over a
9413            // walk of a link with properties, `@prop` reads the row's current
9414            // value off the junction that walk crosses.
9415            let walked_junction = match (&inner.source, inner_expr) {
9416                (IrMultiLinkValueSource::PathSelect(ps), Expr::SubQuery(stmt)) => {
9417                    match (stmt.as_ref(), ps.joins.last()) {
9418                        (Stmt::Select(sel), Some(IrPathJoin::Multi { junction_alias, .. })) => match &sel.result {
9419                            Expr::Path(p) if p.partial => match p.steps.as_slice() {
9420                                [ast::PathStep::Name(link)] => Self::resolve_multilink(td, link)
9421                                    .and_then(|m| m.through.clone())
9422                                    .map(|through| (through, junction_alias.clone())),
9423                                _ => None,
9424                            },
9425                            _ => None,
9426                        },
9427                        _ => None,
9428                    }
9429                }
9430                _ => None,
9431            };
9432
9433            let Some(through) = through_td else {
9434                return Err(self.type_err(
9435                    "link properties (`@prop := value`) are only valid on a multi-link \
9436                     declared with `Through[...]`",
9437                ));
9438            };
9439
9440            for el in &shape.elements {
9441                let prop_name = match el.path.steps.as_slice() {
9442                    [ast::PathStep::LinkProp(name)] => name.clone(),
9443                    _ => return Err(self.type_err("only `@prop := value` link-property assignments are valid here")),
9444                };
9445                let prop = Self::resolve_property(through, &prop_name)
9446                    .ok_or_else(|| self.field_err(&prop_name, &format!("{}::{}", through.module, through.name)))?;
9447                if prop.is_readonly {
9448                    return Err(self.type_err(&format!(
9449                        "cannot set link property '{prop_name}': it is declared as read-only"
9450                    )));
9451                }
9452                let value_expr = el
9453                    .compexpr
9454                    .as_ref()
9455                    .ok_or_else(|| self.type_err(&format!("link property '{prop_name}' must be assigned a value")))?;
9456                let scoped = walked_junction.is_some();
9457                if scoped {
9458                    self.link_prop_scope.push(walked_junction.clone());
9459                }
9460                let ir_expr = self.compile_expr(value_expr, td, alias);
9461                if scoped {
9462                    self.link_prop_scope.pop();
9463                }
9464                inner.link_props.push((prop.name.clone(), ir_expr?));
9465            }
9466            return Ok(inner);
9467        }
9468
9469        // `prices := assert_distinct(<union>)` — the assert checks the targets
9470        // before they become junction rows, and passes them through otherwise.
9471        if let Expr::FunctionCall(f) = expr
9472            && (f.module.is_none() || f.module.as_deref() == Some("std"))
9473            && matches!(f.name.as_str(), "assert_exists" | "assert_distinct")
9474            && let [arg] = f.args.as_slice()
9475        {
9476            let arg = arg.clone();
9477            let inner = self.compile_multilink_values(&arg, td, alias, through_td)?;
9478            // The ids travel through the check as a plain array and come back
9479            // unnested, which leaves no row for a per-target link property to
9480            // ride along on.
9481            let mut props = vec![];
9482            crate::sql::collect_link_prop_names(&inner, &mut props);
9483            if !props.is_empty() {
9484                return Err(self.type_err(&format!(
9485                    "'{}' cannot be applied to a link that carries link properties ({}) —                      the check reads the targets alone",
9486                    f.name,
9487                    props.join(", "),
9488                )));
9489            }
9490            let message = self.assert_message(f, Some((td, alias)))?;
9491            return Ok(IrMultiLinkValues {
9492                source: IrMultiLinkValueSource::Asserted {
9493                    fn_name: f.name.clone(),
9494                    inner: Box::new(inner),
9495                    message,
9496                },
9497                link_props: vec![],
9498            });
9499        }
9500
9501        // `translations := (select { teaser, title })` — a select with no
9502        // clauses of its own is the expression it wraps, and the forms below
9503        // already know what to do with that. Left wrapped, a free row source
9504        // (a set literal, a union) reaches the generic subquery arm and is
9505        // refused, while the identical unwrapped `{ teaser, title }` compiles.
9506        if let Expr::SubQuery(inner) = expr
9507            && let Stmt::Select(sel) = inner.as_ref()
9508            && sel.filter.is_none()
9509            && sel.order_by.is_empty()
9510            && sel.offset.is_none()
9511            && sel.limit.is_none()
9512            && matches!(sel.result, Expr::Set(_) | Expr::Union(_, _))
9513        {
9514            let result = sel.result.clone();
9515            return self.compile_multilink_values(&result, td, alias, through_td);
9516        }
9517
9518        // CTE reference: bare name matching a registered CTE
9519        if let Some(name) = self.resolve_cte_name(expr) {
9520            return Ok(IrMultiLinkValues {
9521                source: IrMultiLinkValueSource::CteRef(name.to_string()),
9522                link_props: vec![],
9523            });
9524        }
9525
9526        // `{ a, b }` — a set literal of targets, each contributing its own rows.
9527        if let Expr::Set(elements) = expr
9528            && !elements.is_empty()
9529        {
9530            let mut combined: Option<IrMultiLinkValues> = None;
9531            for element in elements {
9532                let one = self.compile_multilink_values(element, td, alias, through_td)?;
9533                combined = Some(match combined {
9534                    None => one,
9535                    Some(previous) => IrMultiLinkValues {
9536                        source: IrMultiLinkValueSource::Union(Box::new(previous), Box::new(one)),
9537                        link_props: vec![],
9538                    },
9539                });
9540            }
9541            return Ok(combined.expect("elements is non-empty"));
9542        }
9543
9544        // `social_accounts := (for p in … union (insert …))` — the loop's rows
9545        // are the link's targets, read back from the CTE it is hoisted into,
9546        // the same way a bare nested insert is.
9547        if let Expr::SubQuery(inner) = expr
9548            && let Stmt::For(_) = inner.as_ref()
9549        {
9550            let (cte_name, _) = self.hoist_dml_as_cte(inner.as_ref())?;
9551            return Ok(IrMultiLinkValues {
9552                source: IrMultiLinkValueSource::CteRef(cte_name),
9553                link_props: vec![],
9554            });
9555        }
9556
9557        // `emails := (insert Email { … })` — a nested insert whose rows become
9558        // the link's targets, read back from the CTE it is hoisted into.
9559        if let Expr::SubQuery(inner) = expr
9560            && matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
9561        {
9562            let cte_name = self.hoist_nested_dml(inner)?;
9563            return Ok(IrMultiLinkValues {
9564                source: IrMultiLinkValueSource::CteRef(cte_name),
9565                link_props: vec![],
9566            });
9567        }
9568
9569        // `emails := (select .emails filter .primary)` — a relative path here
9570        // is relative to the row being written, so it is rooted at that type
9571        // and correlated back to it; compiled bare it has no object to
9572        // resolve against and reads as a free select.
9573        if let Expr::SubQuery(inner) = expr
9574            && let Stmt::Select(sel) = inner.as_ref()
9575            && let Expr::Path(path) = &sel.result
9576            && path.partial
9577        {
9578            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
9579            steps.extend(path.steps.iter().cloned());
9580            let rooted = ast::Path { steps, partial: false };
9581            let synthetic = ast::SelectStmt {
9582                result: Expr::Path(rooted.clone()),
9583                filter: sel.filter.clone(),
9584                order_by: sel.order_by.clone(),
9585                offset: sel.offset.clone(),
9586                limit: sel.limit.clone(),
9587                lock: None,
9588            };
9589            let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
9590            Self::correlate_path_select(&mut ps, alias);
9591            return Ok(IrMultiLinkValues {
9592                source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9593                link_props: vec![],
9594            });
9595        }
9596
9597        // Parenthesised subquery
9598        if let Expr::SubQuery(inner) = expr {
9599            return match self.compile_stmt(inner)? {
9600                IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
9601                    Ok(IrMultiLinkValues {
9602                        source: IrMultiLinkValueSource::Select(Box::new(s)),
9603                        link_props: vec![],
9604                    })
9605                }
9606                IrStmt::PathSelect(ps) => Ok(IrMultiLinkValues {
9607                    source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9608                    link_props: vec![],
9609                }),
9610                _ => Err(self.type_err("multilink value must resolve to a SELECT or path query")),
9611            };
9612        }
9613
9614        if let Expr::FunctionCall(fc) = expr {
9615            let synthetic = ast::SelectStmt {
9616                result: expr.clone(),
9617                filter: None,
9618                order_by: vec![],
9619                offset: None,
9620                limit: None,
9621                lock: None,
9622            };
9623            if let Some(fs) = self.try_compile_fn_object_select(fc, &[], &synthetic, false)? {
9624                return Ok(IrMultiLinkValues {
9625                    source: IrMultiLinkValueSource::Function(Box::new(fs)),
9626                    link_props: vec![],
9627                });
9628            }
9629        }
9630
9631        // Absolute path expression (type reference or path traversal)
9632        if let Expr::Path(p) = expr
9633            && !p.partial
9634        {
9635            let fake_sel = ast::SelectStmt {
9636                result: expr.clone(),
9637                filter: None,
9638                order_by: vec![],
9639                offset: None,
9640                limit: None,
9641                lock: None,
9642            };
9643            return match self.compile_stmt(&Stmt::Select(fake_sel))? {
9644                IrStmt::PathSelect(ps) => Ok(IrMultiLinkValues {
9645                    source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9646                    link_props: vec![],
9647                }),
9648                IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
9649                    Ok(IrMultiLinkValues {
9650                        source: IrMultiLinkValueSource::Select(Box::new(s)),
9651                        link_props: vec![],
9652                    })
9653                }
9654                _ => Err(self.type_err("expected a path expression for multilink value")),
9655            };
9656        }
9657
9658        Err(self.type_err("multilink value must be a CTE reference, parenthesised subquery, or type path"))
9659    }
9660
9661    // ── DELETE ────────────────────────────────────────────────────────────────────
9662
9663    fn compile_delete(&mut self, del: &ast::DeleteStmt) -> Result<IrDelete, PyQLError> {
9664        // Taken before anything nested is compiled, so a nested statement
9665        // cannot pick up a guard meant for this one.
9666        let pending_guard = self.pending_delete_guard.take();
9667        let type_name = self.expr_as_type_name(&del.subject)?;
9668        // `delete previous` where `previous` is a `with` binding: the binding
9669        // names the rows to delete, so it decides the table *and* narrows the
9670        // delete to its own rows — exactly as `compile_update` does. Without
9671        // the narrowing this would resolve to the type and empty the table.
9672        let bound_rows = self.cte_object_type(&type_name);
9673        let td = self.resolve_path_root(&type_name)?;
9674        let alias = self.fresh_alias();
9675        let target = IrSource {
9676            poly: None,
9677            type_name: format!("{}::{}", td.module, td.name),
9678            table: td.table.clone(),
9679            alias: alias.clone(),
9680        };
9681
9682        let declared_filter = del
9683            .filter
9684            .as_ref()
9685            .map(|f| self.as_condition(|c| c.compile_expr(f, td, &alias)))
9686            .transpose()?;
9687        let filter = match bound_rows {
9688            Some(_) => {
9689                let membership = IrExpr::BinOp(Box::new(IrBinOp {
9690                    left: IrExpr::ColumnRef {
9691                        alias: alias.clone(),
9692                        column: "id".to_string(),
9693                        pg_type: "uuid".to_string(),
9694                    },
9695                    op: ast::BinOpKind::In,
9696                    right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::Select(IrSelect::schema_bound(
9697                        IrSource {
9698                            poly: None,
9699                            type_name: format!("{}::{}", td.module, td.name),
9700                            table: format!("@cte:{type_name}"),
9701                            alias: self.fresh_alias(),
9702                        },
9703                        vec![],
9704                        None,
9705                    )))),
9706                }));
9707                Some(and_conditions(declared_filter, vec![membership]).expect("membership is present"))
9708            }
9709            None => declared_filter,
9710        };
9711        let filter = match pending_guard {
9712            Some(condition) => {
9713                let guard = self.compile_expr(&condition, td, &alias)?;
9714                and_conditions(filter, vec![guard])
9715            }
9716            None => filter,
9717        };
9718
9719        let returning = Self::pk_returning(td);
9720
9721        let (poly_implementors, poly_columns) = if self.is_polymorphic(td) {
9722            (
9723                self.find_poly_implementors(&format!("{}::{}", td.module, td.name)),
9724                Self::poly_dml_columns(td),
9725            )
9726        } else {
9727            (vec![], vec![])
9728        };
9729        let qname = format!("{}::{}", td.module, td.name);
9730        let enqueue_search = collect_search_enqueue(td, &qname, "delete");
9731
9732        Ok(IrDelete {
9733            target,
9734            filter,
9735            returning,
9736            poly_implementors,
9737            poly_columns,
9738            enqueue_search,
9739        })
9740    }
9741
9742    // ── Shape compilation ─────────────────────────────────────────────────────────
9743
9744    fn compile_shape(
9745        &mut self,
9746        elements: &[ShapeElement],
9747        td: &TypeDescriptor,
9748        alias: &str,
9749        module: &str,
9750    ) -> Result<Vec<IrShapePointer>, PyQLError> {
9751        if elements.is_empty() {
9752            // No explicit shape: implicit { id } only.
9753            return Ok(Self::pk_returning(td));
9754        }
9755
9756        let mut pointers = vec![];
9757        for el in elements {
9758            if let Some(splat) = &el.splat {
9759                // Check if this is a type-intersection splat: [is Type].*
9760                if let Some(ast::PathStep::TypeIntersection(type_ref)) = el.path.steps.first() {
9761                    let type_ref = type_ref.clone();
9762                    pointers.extend(self.compile_type_intersection_splat(&type_ref, splat, td, alias)?);
9763                } else {
9764                    pointers.extend(self.compile_splat(splat, td, alias, module)?);
9765                }
9766            } else {
9767                pointers.push(self.compile_shape_element(el, td, alias, module)?);
9768            }
9769        }
9770        self.prepend_implicit_id(&mut pointers, td);
9771        Ok(pointers)
9772    }
9773
9774    /// Put `id` at the front of a shape that did not select one, which every
9775    /// binary-protocol query carries whether or not it asked. Without it,
9776    /// `o.id` on a shape like `options: { value }` reads as unset rather than
9777    /// as the row's id.
9778    ///
9779    /// Skipped in the two places that cannot carry it: inside a mutation's own body and under
9780    /// a cast to `json` — see `implicit_id_in_shapes`. A shape that names `id`
9781    /// itself keeps its own pointer, and its position, untouched.
9782    fn prepend_implicit_id(&self, pointers: &mut Vec<IrShapePointer>, td: &TypeDescriptor) {
9783        if !self.implicit_id_in_shapes {
9784            return;
9785        }
9786        let Some(pk) = td.properties.iter().find(|p| p.is_pk) else {
9787            return;
9788        };
9789        if pointers.iter().any(|p| p.alias() == pk.name) {
9790            return;
9791        }
9792        pointers.insert(
9793            0,
9794            IrShapePointer::Scalar(IrScalarPointer {
9795                implicit_id: true,
9796                marker_offset: None,
9797                alias: pk.name.clone(),
9798                column: pk.name.clone(),
9799                pg_type: pk.pg_type.clone(),
9800                tuple_shape: None,
9801            }),
9802        );
9803    }
9804
9805    /// Run `body` with implicit ids suppressed, restoring the previous setting
9806    /// however it ends.
9807    fn without_implicit_id<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T, PyQLError>) -> Result<T, PyQLError> {
9808        let saved = std::mem::replace(&mut self.implicit_id_in_shapes, false);
9809        let result = body(self);
9810        self.implicit_id_in_shapes = saved;
9811        result
9812    }
9813
9814    /// Expand `*` → all scalars; `**` → all scalars + all single links with implicit `{ id }`.
9815    fn compile_splat(
9816        &mut self,
9817        splat: &ast::Splat,
9818        td: &TypeDescriptor,
9819        alias: &str,
9820        module: &str,
9821    ) -> Result<Vec<IrShapePointer>, PyQLError> {
9822        let mut pointers: Vec<IrShapePointer> = td
9823            .properties
9824            .iter()
9825            .map(|p| {
9826                IrShapePointer::Scalar(IrScalarPointer {
9827                    implicit_id: false,
9828                    marker_offset: None,
9829                    alias: p.name.clone(),
9830                    column: p.name.clone(),
9831                    pg_type: p.pg_type.clone(),
9832                    tuple_shape: self.resolve_property_tuple_shape(p),
9833                })
9834            })
9835            .collect();
9836
9837        for cd in &td.computed.clone() {
9838            // `*` is properties; links arrive only with `**`. A computed
9839            // standing for objects is a link however it is written, so it
9840            // waits for the deep form too.
9841            if matches!(splat, ast::Splat::Shallow) && self.computed_is_object_valued(cd, td) {
9842                continue;
9843            }
9844            pointers.push(self.compile_declared_computed(cd, td, alias, module, None, &[])?);
9845        }
9846
9847        if matches!(splat, ast::Splat::Deep) {
9848            for l in &td.links {
9849                let target_td = self.resolve_type(&l.target)?;
9850                let sub_alias = self.fresh_alias();
9851                // `**` fetches every property (not further nested links) of
9852                // a linked object — a `Shallow` (`*`) expansion of the
9853                // target type, one level deep. Recursing with `Deep` here
9854                // instead would walk the target's own links too, which for
9855                // a two-way or cyclic link graph never terminates.
9856                let target_module = target_td.module.clone();
9857                let sub_shape = self.compile_splat(&ast::Splat::Shallow, target_td, &sub_alias, &target_module)?;
9858                let subquery = IrSelect::schema_bound(
9859                    IrSource {
9860                        poly: self.link_target_fanout(target_td),
9861                        type_name: format!("{}::{}", target_td.module, target_td.name),
9862                        table: target_td.table.clone(),
9863                        alias: sub_alias.clone(),
9864                    },
9865                    sub_shape,
9866                    None,
9867                );
9868                let correlation = if l.is_junction_backed() {
9869                    let join = self.build_multilink_join(td, &l.name, &l.target, &l.through)?;
9870                    IrSingleLinkCorrelation::Junction {
9871                        join,
9872                        target_pk: "id".to_string(),
9873                    }
9874                } else {
9875                    IrSingleLinkCorrelation::Fk {
9876                        fk_column: format!("{}_id", l.name),
9877                        target_pk: "id".to_string(),
9878                    }
9879                };
9880                pointers.push(IrShapePointer::SingleLink(IrSingleLinkPointer {
9881                    marker_offset: None,
9882                    alias: l.name.clone(),
9883                    correlation,
9884                    subquery,
9885                    link_properties: vec![],
9886                }));
9887            }
9888
9889            for ml in &td.multilinks {
9890                let sub_alias = self.fresh_alias();
9891                let target_td = self.resolve_type(&ml.target)?;
9892                // See the single-link loop above — same Shallow-not-Deep
9893                // reasoning applies to multilink targets.
9894                let target_module = target_td.module.clone();
9895                let sub_shape = self.compile_splat(&ast::Splat::Shallow, target_td, &sub_alias, &target_module)?;
9896
9897                let join = if let Some(through_qname) = &ml.through {
9898                    let through_td = self.resolve_type(through_qname)?;
9899                    if through_td.junction {
9900                        // See `junction_info_for`'s doc comment: owner-derived.
9901                        IrMultiLinkJoin::Standard {
9902                            junction_table: self.owner_junction(td, &ml.name, Some(through_td)),
9903                            module: td.module.clone(),
9904                        }
9905                    } else {
9906                        let source_qname = format!("{}::{}", td.module, td.name);
9907                        let source_col = through_td
9908                            .links
9909                            .iter()
9910                            .find(|l| l.target == source_qname)
9911                            .ok_or_else(|| {
9912                                PyQLError::Type(PyQLTypeError {
9913                                    message: format!(
9914                                        "through type {through_qname} has no link to source type {source_qname}"
9915                                    ),
9916                                    position: Position { line: 0, col: 0 },
9917                                })
9918                            })?
9919                            .name
9920                            .clone();
9921                        let target_col = through_td
9922                            .links
9923                            .iter()
9924                            .find(|l| l.target == ml.target && l.name != source_col)
9925                            .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
9926                            .ok_or_else(|| {
9927                                PyQLError::Type(PyQLTypeError {
9928                                    message: format!(
9929                                        "through type {through_qname} has no link to target type {}",
9930                                        ml.target
9931                                    ),
9932                                    position: Position { line: 0, col: 0 },
9933                                })
9934                            })?
9935                            .name
9936                            .clone();
9937                        IrMultiLinkJoin::Through {
9938                            junction_table: through_td.table.clone(),
9939                            module: through_td.module.clone(),
9940                            source_col,
9941                            target_col,
9942                        }
9943                    }
9944                } else {
9945                    // See `junction_info_for`: the junction table is named
9946                    // after the owner and lives in the owner's schema, which a
9947                    // splat reaching in from another module is not.
9948                    IrMultiLinkJoin::Standard {
9949                        junction_table: self.owner_junction(td, &ml.name, None),
9950                        module: td.module.clone(),
9951                    }
9952                };
9953
9954                let subquery = self.link_target_select(
9955                    target_td,
9956                    IrSource {
9957                        poly: None,
9958                        type_name: format!("{}::{}", target_td.module, target_td.name),
9959                        table: target_td.table.clone(),
9960                        alias: sub_alias.clone(),
9961                    },
9962                    sub_shape,
9963                );
9964
9965                let link_properties = self.splat_link_properties(ml)?;
9966                pointers.push(IrShapePointer::MultiLink(IrMultiLinkPointer {
9967                    marker_offset: None,
9968                    alias: ml.name.clone(),
9969                    join,
9970                    subquery,
9971                    link_properties,
9972                    single: false,
9973                }));
9974            }
9975        }
9976
9977        Ok(pointers)
9978    }
9979
9980    /// The link properties a splat over a multi-link's targets carries: every
9981    /// property of its `@pylon.junction` through type.
9982    fn splat_link_properties(&self, ml: &MultiLinkDescriptor) -> Result<Vec<IrLinkProp>, PyQLError> {
9983        let Some(through_qname) = &ml.through else {
9984            return Ok(vec![]);
9985        };
9986        let through_td = self.resolve_type(through_qname)?;
9987        if !through_td.junction {
9988            return Ok(vec![]);
9989        }
9990        Ok(through_td
9991            .properties
9992            .iter()
9993            .filter(|p| p.name != "id")
9994            .map(|p| IrLinkProp { name: p.name.clone() })
9995            .collect())
9996    }
9997
9998    // ── Type-intersection helpers ─────────────────────────────────────────────────
9999
10000    /// Expand `[is ConcreteType].*` → scalar subqueries for each non-inherited property.
10001    fn compile_type_intersection_splat(
10002        &mut self,
10003        type_ref: &ast::ObjectRef,
10004        splat: &ast::Splat,
10005        parent_td: &TypeDescriptor,
10006        parent_alias: &str,
10007    ) -> Result<Vec<IrShapePointer>, PyQLError> {
10008        let type_name = match &type_ref.module {
10009            Some(m) => format!("{}::{}", m, type_ref.name),
10010            None => type_ref.name.clone(),
10011        };
10012        let concrete_td = self.resolve_type(&type_name)?;
10013        let concrete_qname = format!("{}::{}", concrete_td.module, concrete_td.name);
10014        let concrete_table = concrete_td.table.clone();
10015
10016        // Interface properties: those in the parent (interface) td
10017        let interface_props: std::collections::HashSet<String> =
10018            parent_td.properties.iter().map(|p| p.name.clone()).collect();
10019
10020        // Emit scalar subquery for each property not already in the interface
10021        let props: Vec<_> = concrete_td
10022            .properties
10023            .iter()
10024            .filter(|p| !interface_props.contains(&p.name))
10025            .cloned()
10026            .collect();
10027
10028        // Same for computed pointers not already defined on the interface —
10029        // `[is Concrete].*` must include the concrete type's own computed
10030        // properties (e.g. `full_name`), not just its stored properties.
10031        let interface_computed: std::collections::HashSet<String> =
10032            parent_td.computed.iter().map(|c| c.name.clone()).collect();
10033        let computed: Vec<_> = concrete_td
10034            .computed
10035            .iter()
10036            .filter(|c| !interface_computed.contains(&c.name))
10037            .filter(|c| !matches!(splat, ast::Splat::Shallow) || !self.computed_is_object_valued(c, concrete_td))
10038            .cloned()
10039            .collect();
10040
10041        // For deep splat, also include links
10042        let links: Vec<_> = if matches!(splat, ast::Splat::Deep) {
10043            concrete_td.links.to_vec()
10044        } else {
10045            vec![]
10046        };
10047
10048        let mut pointers = vec![];
10049        for prop in props {
10050            let sub_alias = self.fresh_alias();
10051            let filter = IrExpr::BinOp(Box::new(IrBinOp {
10052                left: IrExpr::ColumnRef {
10053                    alias: sub_alias.clone(),
10054                    column: "id".to_string(),
10055                    pg_type: "uuid".to_string(),
10056                },
10057                op: ast::BinOpKind::Eq,
10058                right: IrExpr::ColumnRef {
10059                    alias: parent_alias.to_string(),
10060                    column: "id".to_string(),
10061                    pg_type: "uuid".to_string(),
10062                },
10063            }));
10064            let subquery = IrSelect::schema_bound(
10065                IrSource {
10066                    poly: None,
10067                    type_name: concrete_qname.clone(),
10068                    table: concrete_table.clone(),
10069                    alias: sub_alias,
10070                },
10071                vec![IrShapePointer::Scalar(IrScalarPointer {
10072                    implicit_id: false,
10073                    marker_offset: None,
10074                    alias: prop.name.clone(),
10075                    column: prop.name.clone(),
10076                    pg_type: prop.pg_type.clone(),
10077                    tuple_shape: self.resolve_property_tuple_shape(&prop),
10078                })],
10079                Some(filter),
10080            );
10081            pointers.push(IrShapePointer::Computed(IrComputedPointer {
10082                marker_offset: None,
10083                alias: prop.name.clone(),
10084                expr: IrExpr::Subquery(Box::new(subquery)),
10085            }));
10086        }
10087
10088        for cd in computed {
10089            let sub_alias = self.fresh_alias();
10090            let filter = IrExpr::BinOp(Box::new(IrBinOp {
10091                left: IrExpr::ColumnRef {
10092                    alias: sub_alias.clone(),
10093                    column: "id".to_string(),
10094                    pg_type: "uuid".to_string(),
10095                },
10096                op: ast::BinOpKind::Eq,
10097                right: IrExpr::ColumnRef {
10098                    alias: parent_alias.to_string(),
10099                    column: "id".to_string(),
10100                    pg_type: "uuid".to_string(),
10101                },
10102            }));
10103            let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
10104            let inner_ir = self.compile_expr(&expr_ast, concrete_td, &sub_alias)?;
10105            let subquery = IrSelect::schema_bound(
10106                IrSource {
10107                    poly: None,
10108                    type_name: concrete_qname.clone(),
10109                    table: concrete_table.clone(),
10110                    alias: sub_alias,
10111                },
10112                vec![IrShapePointer::Computed(IrComputedPointer {
10113                    marker_offset: None,
10114                    alias: cd.name.clone(),
10115                    expr: inner_ir,
10116                })],
10117                Some(filter),
10118            );
10119            pointers.push(IrShapePointer::Computed(IrComputedPointer {
10120                marker_offset: None,
10121                alias: cd.name.clone(),
10122                expr: IrExpr::Subquery(Box::new(subquery)),
10123            }));
10124        }
10125
10126        // For deep splat, include single-link pointers as subqueries
10127        for link in links {
10128            let target_td = self.resolve_type(&link.target)?;
10129            let sub_alias = self.fresh_alias();
10130            let filter = if link.is_junction_backed() {
10131                let (jt_table, jt_module, jt_src_col, jt_tgt_col, _) =
10132                    self.junction_info_for(concrete_td, &link.name, &link.target, &link.through)?;
10133                let jt_alias = self.fresh_alias();
10134                let jt_filter = IrExpr::BinOp(Box::new(IrBinOp {
10135                    left: IrExpr::BinOp(Box::new(IrBinOp {
10136                        left: IrExpr::ColumnRef {
10137                            alias: jt_alias.clone(),
10138                            column: jt_src_col,
10139                            pg_type: "uuid".to_string(),
10140                        },
10141                        op: ast::BinOpKind::Eq,
10142                        right: IrExpr::ColumnRef {
10143                            alias: parent_alias.to_string(),
10144                            column: "id".to_string(),
10145                            pg_type: "uuid".to_string(),
10146                        },
10147                    })),
10148                    op: ast::BinOpKind::And,
10149                    right: IrExpr::BinOp(Box::new(IrBinOp {
10150                        left: IrExpr::ColumnRef {
10151                            alias: jt_alias.clone(),
10152                            column: jt_tgt_col,
10153                            pg_type: "uuid".to_string(),
10154                        },
10155                        op: ast::BinOpKind::Eq,
10156                        right: IrExpr::ColumnRef {
10157                            alias: sub_alias.clone(),
10158                            column: "id".to_string(),
10159                            pg_type: "uuid".to_string(),
10160                        },
10161                    })),
10162                }));
10163                let exists_select = IrSelect::schema_bound(
10164                    IrSource {
10165                        poly: None,
10166                        type_name: format!("{}::__jt__", jt_module),
10167                        table: jt_table,
10168                        alias: jt_alias,
10169                    },
10170                    vec![],
10171                    Some(jt_filter),
10172                );
10173                IrExpr::UnaryOp(Box::new(IrUnaryOp {
10174                    op: ast::UnaryOpKind::Exists,
10175                    operand: IrExpr::Subquery(Box::new(exists_select)),
10176                }))
10177            } else {
10178                IrExpr::BinOp(Box::new(IrBinOp {
10179                    left: IrExpr::ColumnRef {
10180                        alias: sub_alias.clone(),
10181                        column: "id".to_string(),
10182                        pg_type: "uuid".to_string(),
10183                    },
10184                    op: ast::BinOpKind::Eq,
10185                    right: IrExpr::ColumnRef {
10186                        alias: parent_alias.to_string(),
10187                        column: format!("{}_id", link.name),
10188                        pg_type: "uuid".to_string(),
10189                    },
10190                }))
10191            };
10192            let sub_shape = Self::pk_returning(target_td);
10193            let subquery = IrSelect::schema_bound(
10194                IrSource {
10195                    poly: self.link_target_fanout(target_td),
10196                    type_name: format!("{}::{}", target_td.module, target_td.name),
10197                    table: target_td.table.clone(),
10198                    alias: sub_alias,
10199                },
10200                sub_shape,
10201                Some(filter),
10202            );
10203            pointers.push(IrShapePointer::Computed(IrComputedPointer {
10204                marker_offset: None,
10205                alias: link.name.clone(),
10206                expr: IrExpr::Subquery(Box::new(subquery)),
10207            }));
10208        }
10209
10210        Ok(pointers)
10211    }
10212
10213    /// Compile `[is ConcreteType].pointer_name` → `IrShapePointer`.
10214    fn compile_type_intersection_pointer(
10215        &mut self,
10216        type_ref: &ast::ObjectRef,
10217        tail_steps: &[ast::PathStep],
10218        parent_alias: &str,
10219        marker_offset: Option<usize>,
10220    ) -> Result<IrShapePointer, PyQLError> {
10221        let expr = self.compile_type_intersection_expr_steps(type_ref, tail_steps, parent_alias)?;
10222        // Alias is the last Name step
10223        let alias = match tail_steps.last() {
10224            Some(ast::PathStep::Name(n)) => n.clone(),
10225            _ => return Err(self.type_err("type intersection must end with a pointer name")),
10226        };
10227        Ok(IrShapePointer::Computed(IrComputedPointer {
10228            alias,
10229            expr,
10230            marker_offset,
10231        }))
10232    }
10233
10234    /// Compile `[is Type].name` as an `IrExpr` (for computed pointer / expression context).
10235    fn compile_type_intersection_expr(
10236        &mut self,
10237        steps: &[ast::PathStep],
10238        td: &TypeDescriptor,
10239        parent_alias: &str,
10240    ) -> Result<IrExpr, PyQLError> {
10241        use ast::PathStep;
10242        let type_ref = match steps.first() {
10243            Some(PathStep::TypeIntersection(tr)) => tr.clone(),
10244            _ => return Err(self.type_err("expected type intersection")),
10245        };
10246        // The fast path reads one stored column off the narrowed type.
10247        // Anything else — a computed, a link, further traversal — falls
10248        // through to the general builder, rooted at the narrowed type
10249        // (whose rows share the interface row's id).
10250        match self.compile_type_intersection_expr_steps(&type_ref, &steps[1..], parent_alias) {
10251            Ok(ir) => Ok(ir),
10252            Err(fast_path_err) => {
10253                let p = ast::Path {
10254                    steps: steps.to_vec(),
10255                    partial: true,
10256                };
10257                self.compile_partial_path_as_subquery(&p, td, parent_alias)
10258                    .map_err(|_| fast_path_err)
10259            }
10260        }
10261    }
10262
10263    /// Shared: build scalar subquery for `[is ConcreteType]` + tail pointer steps.
10264    fn compile_type_intersection_expr_steps(
10265        &mut self,
10266        type_ref: &ast::ObjectRef,
10267        tail_steps: &[ast::PathStep],
10268        parent_alias: &str,
10269    ) -> Result<IrExpr, PyQLError> {
10270        use ast::PathStep;
10271        let type_name = match &type_ref.module {
10272            Some(m) => format!("{}::{}", m, type_ref.name),
10273            None => type_ref.name.clone(),
10274        };
10275        let concrete_td = self.resolve_type(&type_name)?;
10276        let concrete_qname = format!("{}::{}", concrete_td.module, concrete_td.name);
10277        let concrete_table = concrete_td.table.clone();
10278
10279        let pointer_name = match tail_steps.first() {
10280            Some(PathStep::Name(n)) => n.as_str(),
10281            _ => return Err(self.type_err("type intersection must be followed by a pointer name, e.g. [is Type].name")),
10282        };
10283
10284        let prop = concrete_td
10285            .properties
10286            .iter()
10287            .find(|p| p.name == pointer_name)
10288            .ok_or_else(|| self.field_err(pointer_name, &concrete_qname))?;
10289        let prop_name = prop.name.clone();
10290        let prop_type = prop.pg_type.clone();
10291
10292        let sub_alias = self.fresh_alias();
10293        let filter = IrExpr::BinOp(Box::new(IrBinOp {
10294            left: IrExpr::ColumnRef {
10295                alias: sub_alias.clone(),
10296                column: "id".to_string(),
10297                pg_type: "uuid".to_string(),
10298            },
10299            op: ast::BinOpKind::Eq,
10300            right: IrExpr::ColumnRef {
10301                alias: parent_alias.to_string(),
10302                column: "id".to_string(),
10303                pg_type: "uuid".to_string(),
10304            },
10305        }));
10306
10307        let poly = self.poly_fanout_for(&concrete_qname);
10308        Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
10309            IrSource {
10310                poly,
10311                type_name: concrete_qname,
10312                table: concrete_table,
10313                alias: sub_alias,
10314            },
10315            vec![IrShapePointer::Scalar(IrScalarPointer {
10316                implicit_id: false,
10317                marker_offset: None,
10318                alias: prop_name.clone(),
10319                column: prop_name,
10320                pg_type: prop_type,
10321                tuple_shape: self.resolve_property_tuple_shape(prop),
10322            })],
10323            Some(filter),
10324        ))))
10325    }
10326
10327    fn compile_shape_element(
10328        &mut self,
10329        el: &ShapeElement,
10330        td: &TypeDescriptor,
10331        alias: &str,
10332        module: &str,
10333    ) -> Result<IrShapePointer, PyQLError> {
10334        // Type intersection pointer: [is Type].pointer_name (without compexpr)
10335        if let Some(ast::PathStep::TypeIntersection(type_ref)) = el.path.steps.first()
10336            && el.compexpr.is_none()
10337            && el.path.steps.len() >= 2
10338        {
10339            // `[is T].configs: { … }` — a shape says the pointer is read as
10340            // objects, which the scalar route below cannot give: it resolves
10341            // a stored property and nothing else.
10342            if let Some(nested) = el.nested.as_deref()
10343                && !nested.is_empty()
10344                && let Some(ast::PathStep::Name(leaf)) = el.path.steps.last()
10345                && let Some(ptr) = self.try_compile_pointer_expr(
10346                    &leaf.clone(),
10347                    &Expr::Path(el.path.clone()),
10348                    td,
10349                    alias,
10350                    module,
10351                    el.marker_offset,
10352                    nested,
10353                )?
10354            {
10355                return Ok(ptr);
10356            }
10357            let type_ref = type_ref.clone();
10358            return self.compile_type_intersection_pointer(&type_ref, &el.path.steps[1..], alias, el.marker_offset);
10359        }
10360
10361        let pointer_name = path_leaf(&el.path)?;
10362
10363        // __type__ is a virtual property: the fully-qualified type name as a string.
10364        // It's always injected at position 0 for internal use; explicit inclusion adds
10365        // it as a regular computed pointer at a later position so Python can read it.
10366        if pointer_name == "__type__" && el.compexpr.is_none() {
10367            let expr = if self.is_polymorphic(td) {
10368                IrExpr::ColumnRef {
10369                    alias: alias.to_string(),
10370                    column: "__type__".to_string(),
10371                    pg_type: "text".to_string(),
10372                }
10373            } else {
10374                IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
10375            };
10376            return Ok(IrShapePointer::Computed(IrComputedPointer {
10377                marker_offset: el.marker_offset,
10378                alias: "__type__".to_string(),
10379                expr,
10380            }));
10381        }
10382
10383        // `p := assert_exists(.prices { … })` — the assert is a check on the
10384        // pointer's own set, not part of a value expression, so the pointer is
10385        // compiled from the argument and the check reads the rows it
10386        // aggregates. Taken as an expression instead, an object-valued
10387        // argument is "part of a larger expression", which is exactly what an
10388        // object-returning function refuses.
10389        if let Some(compexpr) = &el.compexpr
10390            && let Some((call, value_expr, check_expr)) =
10391                Self::asserted_pointer_expr(compexpr, el.nested.as_deref().unwrap_or(&[]))
10392        {
10393            let element_for = |expr: Expr| ShapeElement {
10394                compexpr: Some(expr),
10395                nested: None,
10396                ..el.clone()
10397            };
10398            // Compiled speculatively: a scalar argument's assert is an ordinary
10399            // function call and belongs on the expression path below, and an
10400            // argument that does not compile at all should report its own error
10401            // from there rather than this one.
10402            if let Ok(ptr) = self.compile_shape_element(&element_for(value_expr), td, alias, module)
10403                && ptr.is_object_pointer()
10404            {
10405                let check = match check_expr {
10406                    Some(expr) => Some(self.compile_shape_element(&element_for(expr), td, alias, module)?),
10407                    None => None,
10408                };
10409                let message = self.assert_message(&call, Some((td, alias)))?;
10410                return Ok(IrShapePointer::Asserted(Box::new(IrAssertedPointer {
10411                    fn_name: call.name,
10412                    inner: ptr,
10413                    check,
10414                    message,
10415                })));
10416            }
10417        }
10418
10419        // `t := (select .teams limit 1) if cond else {}` — one branch names
10420        // objects and the other nothing, so the pointer is that branch with
10421        // the condition folded into its own filter.
10422        if let Some(compexpr) = &el.compexpr
10423            && let Some(guarded) = guarded_object_branch(compexpr)
10424        {
10425            let element = ShapeElement {
10426                compexpr: Some(guarded),
10427                ..el.clone()
10428            };
10429            return self.compile_shape_element(&element, td, alias, module);
10430        }
10431
10432        // Computed override: `pointer := expr`
10433        if let Some(compexpr) = &el.compexpr {
10434            // A link-valued RHS (`.multilink`, `.<backlink[is T] { … }`,
10435            // `(select .link filter … limit 1)`) is a pointer in its own
10436            // right, not an expression — see `try_compile_pointer_expr`.
10437            // `(select .resource { rev := … }) { changed_at := .rev.created_at }`
10438            // — a shape written at the point of use replaces the one the
10439            // subject carries, so whatever that one declared has to stay in
10440            // scope or the replacement cannot read it.
10441            let inner_declared: Vec<ShapeElement> = Self::replaced_subject_shape(compexpr)
10442                .iter()
10443                .filter(|e| e.compexpr.is_some())
10444                .cloned()
10445                .collect();
10446            let restore = (!inner_declared.is_empty()).then(|| {
10447                let mut scope = self.active_declared_pointers.clone();
10448                scope.extend(inner_declared);
10449                std::mem::replace(&mut self.active_declared_pointers, scope)
10450            });
10451            let pointer = self.try_compile_pointer_expr(
10452                pointer_name,
10453                compexpr,
10454                td,
10455                alias,
10456                module,
10457                el.marker_offset,
10458                el.nested.as_deref().unwrap_or(&[]),
10459            );
10460            if let Some(previous) = restore {
10461                self.active_declared_pointers = previous;
10462            }
10463            if let Some(ptr) = pointer? {
10464                return Ok(ptr);
10465            }
10466            // `p := marketplace::retrieve_listing_prices(.id) { amount }` — an
10467            // object-returning call written inline. A declared computed with
10468            // the same body already takes this route; a shape written on it
10469            // belongs to the rows it yields.
10470            if let Some((fc, call_nested)) = Self::object_call_with_shape(compexpr, el.nested.as_deref())
10471                && let Some(fs) = self.compile_fn_object_source(fc, &call_nested)?
10472            {
10473                return Ok(IrShapePointer::Computed(IrComputedPointer {
10474                    marker_offset: el.marker_offset,
10475                    alias: pointer_name.to_string(),
10476                    expr: IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectFunction(Box::new(fs)))),
10477                }));
10478            }
10479            // A `T is U` naming a type other than this scope's answers once
10480            // per row of `T`; a shape keeps all of them, so ask for the set
10481            // form rather than the single value a boolean position takes.
10482            let ir = match compexpr {
10483                Expr::TypeIs { expr, ty } => self.compile_type_is_set(expr, ty, Some((td, alias)))?,
10484                _ => self.in_value_position(|c| c.compile_expr(compexpr, td, alias))?,
10485            };
10486            // Cross-scope TypeIs: promote to set-valued shape pointer.
10487            if let IrExpr::ArrayFromSelect(src) = ir {
10488                if let IrArraySource::RawExpr {
10489                    source,
10490                    poly_implementors,
10491                    poly_columns,
10492                    expr,
10493                } = *src
10494                {
10495                    return Ok(IrShapePointer::ScalarSet(IrScalarSetPointer {
10496                        alias: pointer_name.to_string(),
10497                        source,
10498                        poly_implementors,
10499                        poly_columns,
10500                        bool_expr: expr,
10501                    }));
10502                }
10503                return Ok(IrShapePointer::Computed(IrComputedPointer {
10504                    marker_offset: el.marker_offset,
10505                    alias: pointer_name.to_string(),
10506                    expr: IrExpr::ArrayFromSelect(src),
10507                }));
10508            }
10509            return Ok(IrShapePointer::Computed(IrComputedPointer {
10510                marker_offset: el.marker_offset,
10511                alias: pointer_name.to_string(),
10512                expr: ir,
10513            }));
10514        }
10515
10516        // Scalar property
10517        if let Some(p) = Self::resolve_property(td, pointer_name) {
10518            return Ok(IrShapePointer::Scalar(IrScalarPointer {
10519                implicit_id: false,
10520                marker_offset: el.marker_offset,
10521                alias: pointer_name.to_string(),
10522                column: p.name.clone(),
10523                pg_type: p.pg_type.clone(),
10524                tuple_shape: self.resolve_property_tuple_shape(p),
10525            }));
10526        }
10527
10528        // Single link
10529        if let Some(l) = Self::resolve_link(td, pointer_name) {
10530            let target_td = self.resolve_type(&l.target)?;
10531            let sub_alias = self.fresh_alias();
10532            let nested_elements = el.nested.as_deref().unwrap_or(&[]);
10533
10534            // A junction-backed link can carry `@prop` read references
10535            // (e.g. `spouse: { name, @since }`), the same as a multi-link's
10536            // own nested shape (`compile_multilink_pointer`) — partition
10537            // those out before compiling the rest as a regular shape.
10538            let (regular_els, link_properties): (Vec<ShapeElement>, Vec<IrLinkProp>) = if l.is_junction_backed() {
10539                let mut regular = Vec::new();
10540                let mut props = Vec::new();
10541                for nel in nested_elements {
10542                    if let [ast::PathStep::LinkProp(name)] = nel.path.steps.as_slice() {
10543                        props.push(IrLinkProp { name: name.clone() });
10544                    } else {
10545                        regular.push(nel.clone());
10546                    }
10547                }
10548                (regular, props)
10549            } else {
10550                (nested_elements.to_vec(), vec![])
10551            };
10552
10553            let sub_shape = self.compile_shape(&regular_els, target_td, &sub_alias, &target_td.module.clone())?;
10554            let subquery = self.link_target_select(
10555                target_td,
10556                IrSource {
10557                    poly: None,
10558                    type_name: format!("{}::{}", target_td.module, target_td.name),
10559                    table: target_td.table.clone(),
10560                    alias: sub_alias,
10561                },
10562                sub_shape,
10563            );
10564            let correlation = if l.is_junction_backed() {
10565                let join = self.build_multilink_join(td, &l.name, &l.target, &l.through)?;
10566                IrSingleLinkCorrelation::Junction {
10567                    join,
10568                    target_pk: "id".to_string(),
10569                }
10570            } else {
10571                IrSingleLinkCorrelation::Fk {
10572                    fk_column: format!("{}_id", l.name),
10573                    target_pk: "id".to_string(),
10574                }
10575            };
10576            return Ok(IrShapePointer::SingleLink(IrSingleLinkPointer {
10577                marker_offset: el.marker_offset,
10578                alias: pointer_name.to_string(),
10579                correlation,
10580                subquery,
10581                link_properties,
10582            }));
10583        }
10584
10585        // Multi-link
10586        if Self::resolve_multilink(td, pointer_name).is_some() {
10587            return self.compile_multilink_pointer(pointer_name, pointer_name, td, alias, module, el);
10588        }
10589
10590        // Schema-defined computed pointer
10591        if let Some(cd) = self.resolve_computed(td, pointer_name) {
10592            return self.compile_declared_computed(
10593                &cd,
10594                td,
10595                alias,
10596                module,
10597                el.marker_offset,
10598                el.nested.as_deref().unwrap_or(&[]),
10599            );
10600        }
10601
10602        // Declared by the binding this shape is read off, rather than by the
10603        // type — compiled against the binding's own row, as it was written.
10604        if let Some(declared) = self
10605            .active_declared_pointers
10606            .iter()
10607            .find(|d| path_leaf(&d.path).is_ok_and(|n| n == pointer_name))
10608            .cloned()
10609            && let Some(expr) = declared.compexpr.clone()
10610        {
10611            let nested = if el.nested.as_deref().unwrap_or(&[]).is_empty() {
10612                declared.nested.clone().unwrap_or_default()
10613            } else {
10614                el.nested.clone().unwrap_or_default()
10615            };
10616            return self.compile_computed_expr(pointer_name, &expr, td, alias, module, el.marker_offset, &nested, None);
10617        }
10618
10619        Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
10620    }
10621
10622    /// A computed pointer whose path walks *through* a multi-valued step before
10623    /// landing on objects — `members := .memberships.member`.
10624    ///
10625    /// Rooted at the enclosing type and correlated back to the enclosing row,
10626    /// exactly as `compile_partial_path_as_subquery` does for a value, then
10627    /// aggregated so the rows survive as rows.
10628    #[allow(clippy::too_many_arguments)]
10629    fn compile_chained_link_pointer(
10630        &mut self,
10631        pointer_name: &str,
10632        path: &ast::Path,
10633        td: &TypeDescriptor,
10634        alias: &str,
10635        nested: &[ShapeElement],
10636        modifiers: Option<&ast::SelectStmt>,
10637        multi: bool,
10638        tail: usize,
10639    ) -> Result<IrShapePointer, PyQLError> {
10640        let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
10641        steps.extend(path.steps.iter().cloned());
10642        let full_path = ast::Path { steps, partial: false };
10643        let synthetic = ast::SelectStmt {
10644            result: Expr::Path(full_path.clone()),
10645            filter: modifiers.and_then(|m| m.filter.clone()),
10646            order_by: modifiers.map(|m| m.order_by.clone()).unwrap_or_default(),
10647            offset: modifiers.and_then(|m| m.offset.clone()),
10648            limit: modifiers.and_then(|m| m.limit.clone()),
10649            lock: None,
10650        };
10651        let mut path_select = self.compile_path_select_with_tail(&synthetic, &full_path, nested, false, tail, &[])?;
10652        Self::correlate_path_select(&mut path_select, alias);
10653        // A walk that crosses a multi step stands for a set, so it is
10654        // aggregated; one that cannot is a single object and is read as one,
10655        // rather than an array of length one.
10656        let expr = if multi {
10657            IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(path_select))))
10658        } else {
10659            IrExpr::ObjectPathSubquery(Box::new(path_select))
10660        };
10661        Ok(IrShapePointer::Computed(IrComputedPointer {
10662            marker_offset: None,
10663            alias: pointer_name.to_string(),
10664            expr,
10665        }))
10666    }
10667
10668    fn compile_multilink_pointer(
10669        &mut self,
10670        output_alias: &str,
10671        ml_name: &str,
10672        td: &TypeDescriptor,
10673        _parent_alias: &str,
10674        module: &str,
10675        el: &ShapeElement,
10676    ) -> Result<IrShapePointer, PyQLError> {
10677        let ml = Self::resolve_multilink(td, ml_name)
10678            .expect("caller verified multilink exists")
10679            .clone();
10680        let target_td = self.resolve_type(&ml.target)?;
10681        let sub_alias = self.fresh_alias();
10682        let nested_elements = el.nested.as_deref().unwrap_or(&[]);
10683
10684        // Partition @prop link-property elements from regular shape elements.
10685        let mut link_properties: Vec<IrLinkProp> = Vec::new();
10686        let mut regular_els: Vec<ShapeElement> = Vec::new();
10687        for nel in nested_elements {
10688            if let [ast::PathStep::LinkProp(name)] = nel.path.steps.as_slice() {
10689                link_properties.push(IrLinkProp { name: name.clone() });
10690            } else {
10691                regular_els.push(nel.clone());
10692            }
10693        }
10694        let has_splat = nested_elements.iter().any(|nel| {
10695            nel.splat.is_some() && !matches!(nel.path.steps.first(), Some(ast::PathStep::TypeIntersection(_)))
10696        });
10697        if has_splat {
10698            for prop in self.splat_link_properties(&ml)? {
10699                if !link_properties.iter().any(|existing| existing.name == prop.name) {
10700                    link_properties.push(prop);
10701                }
10702            }
10703        }
10704
10705        let sub_shape = self.compile_shape(&regular_els, target_td, &sub_alias, &target_td.module.clone())?;
10706
10707        let join = if let Some(through_qname) = &ml.through {
10708            let through_td = self.resolve_type(through_qname)?;
10709            if through_td.junction {
10710                // Junction type: columns are always named `source` and
10711                // `target`. See `junction_info_for`'s doc comment: the
10712                // physical table is owner-derived, never `through_td.table`.
10713                IrMultiLinkJoin::Standard {
10714                    junction_table: self.owner_junction(td, ml_name, Some(through_td)),
10715                    module: td.module.clone(),
10716                }
10717            } else {
10718                let source_qname = format!("{}::{}", td.module, td.name);
10719                let source_col = through_td
10720                    .links
10721                    .iter()
10722                    .find(|l| l.target == source_qname)
10723                    .ok_or_else(|| {
10724                        PyQLError::Type(PyQLTypeError {
10725                            message: format!("through type {through_qname} has no link to source type {source_qname}"),
10726                            position: Position { line: 0, col: 0 },
10727                        })
10728                    })?
10729                    .name
10730                    .clone();
10731                let target_col = through_td
10732                    .links
10733                    .iter()
10734                    .find(|l| l.target == ml.target && l.name != source_col)
10735                    .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
10736                    .ok_or_else(|| {
10737                        PyQLError::Type(PyQLTypeError {
10738                            message: format!("through type {through_qname} has no link to target type {}", ml.target),
10739                            position: Position { line: 0, col: 0 },
10740                        })
10741                    })?
10742                    .name
10743                    .clone();
10744                IrMultiLinkJoin::Through {
10745                    junction_table: through_td.table.clone(),
10746                    module: through_td.module.clone(),
10747                    source_col,
10748                    target_col,
10749                }
10750            }
10751        } else {
10752            IrMultiLinkJoin::Standard {
10753                junction_table: self.owner_junction(td, ml_name, None),
10754                module: module.to_string(),
10755            }
10756        };
10757
10758        // `@prop` in this link's own modifiers reads the junction row, so
10759        // the through type has to be in scope while they compile.
10760        self.link_prop_scope
10761            .push(ml.through.clone().map(|t| (t, "jt".to_string())));
10762        let modifiers = (|c: &mut Self| -> Result<SelectModifiers, PyQLError> {
10763            Ok((
10764                el.filter
10765                    .as_ref()
10766                    .map(|f| c.compile_expr(f, target_td, &sub_alias))
10767                    .transpose()?,
10768                el.order_by
10769                    .iter()
10770                    .map(|s| c.compile_sort(s, target_td, &sub_alias))
10771                    .collect::<Result<_, _>>()?,
10772                el.offset
10773                    .as_ref()
10774                    .map(|e| c.compile_expr(e, target_td, &sub_alias))
10775                    .transpose()?,
10776                el.limit
10777                    .as_ref()
10778                    .map(|e| c.compile_expr(e, target_td, &sub_alias))
10779                    .transpose()?,
10780            ))
10781        })(self);
10782        self.link_prop_scope.pop();
10783        let (filter, order_by, offset, limit) = modifiers?;
10784        let subquery = IrSelect {
10785            rows: vec![IrRowSource::Bound {
10786                source: IrSource {
10787                    poly: self.link_target_fanout(target_td),
10788                    type_name: format!("{}::{}", target_td.module, target_td.name),
10789                    table: target_td.table.clone(),
10790                    alias: sub_alias.clone(),
10791                },
10792                shape: sub_shape,
10793            }],
10794            filter,
10795            order_by,
10796            offset,
10797            limit,
10798            distinct: false,
10799            dml_source: None,
10800            polymorphic: false,
10801            poly_implementors: vec![],
10802            poly_columns: vec![],
10803            lock: None,
10804        };
10805
10806        Ok(IrShapePointer::MultiLink(IrMultiLinkPointer {
10807            marker_offset: el.marker_offset,
10808            alias: output_alias.to_string(),
10809            join,
10810            subquery,
10811            link_properties,
10812            single: false,
10813        }))
10814    }
10815
10816    /// Compile `pointer := .<backlink_name[is OwnerType] { shape }` (a
10817    /// backlink used as a computed pointer inside another type's shape —
10818    /// the missing piece that made backlinks unusable for anything beyond
10819    /// `filter exists .<...>`). Mirrors `compile_multilink_pointer`'s
10820    /// shape/subquery construction, but the owner type's rows are
10821    /// correlated in reverse: via their own FK column for a single-link
10822    /// backlink source (`IrMultiLinkJoin::BacklinkFk`), or via the same
10823    /// junction table a forward multi-link would use with the owner/current
10824    /// column roles swapped (`IrMultiLinkJoin::BacklinkJunction`).
10825    fn compile_backlink_pointer(
10826        &mut self,
10827        output_alias: &str,
10828        path: &ast::Path,
10829        current_qname: &str,
10830        nested_elements: &[ShapeElement],
10831        marker_offset: Option<usize>,
10832        modifiers: Option<&ast::SelectStmt>,
10833    ) -> Result<IrShapePointer, PyQLError> {
10834        use ast::PathStep;
10835
10836        let backlink_name = match path.steps.first() {
10837            Some(PathStep::Backlink(n)) => n.clone(),
10838            _ => return Err(self.type_err("internal: expected backlink step")),
10839        };
10840        let type_ref = match path.steps.get(1) {
10841            Some(PathStep::TypeIntersection(tr)) => tr,
10842            _ => {
10843                return Err(PyQLError::Type(PyQLTypeError {
10844                    message: format!(
10845                        "backlink '.< {backlink_name}' requires a type intersection, \
10846                     e.g.: .< {backlink_name}[is SomeType]"
10847                    ),
10848                    position: Position { line: 0, col: 0 },
10849                }));
10850            }
10851        };
10852        if path.steps.len() > 2 {
10853            return Err(self.type_err(
10854                "further path traversal after a backlink shape is not yet supported \
10855                 (e.g. '.<link[is Type].property') — attach a nested shape instead: \
10856                 '.<link[is Type] { property }'",
10857            ));
10858        }
10859
10860        let type_name = match &type_ref.module {
10861            Some(m) => format!("{}::{}", m, type_ref.name),
10862            None => type_ref.name.clone(),
10863        };
10864        let owner_td = self.backlink_owner(self.resolve_type(&type_name)?, &backlink_name, current_qname)?;
10865        let owner_qname = format!("{}::{}", owner_td.module, owner_td.name);
10866
10867        let join = if let Some(l) = owner_td
10868            .links
10869            .iter()
10870            .find(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
10871        {
10872            if l.is_junction_backed() {
10873                let (junction_table, module, owner_col, current_col, _) = self.link_junction_info(owner_td, l)?;
10874                IrMultiLinkJoin::BacklinkJunction {
10875                    junction_table,
10876                    module,
10877                    owner_col,
10878                    current_col,
10879                }
10880            } else {
10881                IrMultiLinkJoin::BacklinkFk {
10882                    fk_col: format!("{}_id", backlink_name),
10883                }
10884            }
10885        } else if let Some(ml) = owner_td
10886            .multilinks
10887            .iter()
10888            .find(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
10889            .cloned()
10890        {
10891            let (junction_table, module, _, _, _) = self.multilink_junction_info(owner_td, &ml)?;
10892            IrMultiLinkJoin::BacklinkJunction {
10893                junction_table,
10894                module,
10895                owner_col: "source".to_string(),
10896                current_col: "target".to_string(),
10897            }
10898        } else {
10899            return Err(PyQLError::Type(PyQLTypeError {
10900                message: format!(
10901                    "type {} has no link or multi-link '{}' pointing to {}",
10902                    owner_qname, backlink_name, current_qname,
10903                ),
10904                position: Position { line: 0, col: 0 },
10905            }));
10906        };
10907
10908        let sub_alias = self.fresh_alias();
10909        let sub_shape = self.compile_shape(nested_elements, owner_td, &sub_alias, &owner_td.module.clone())?;
10910
10911        // The `pointer := expr` grammar (`parse_shape_element`'s `:=`
10912        // branch) never parses trailing FILTER/ORDER BY/OFFSET/LIMIT after
10913        // the RHS expression — those per-link modifiers only exist on the
10914        // separate no-`:=` "bare inclusion with nested shape" parse path
10915        // that `compile_multilink_pointer`'s other call site reads
10916        // `el.filter` etc. from. Writing the RHS as a sub-select
10917        // (`pointer := (select .<link[is T] { … } filter … limit 1)`) is the
10918        // way to get them here, and `modifiers` carries that inner select.
10919        let (filter, order_by, offset, limit) = match modifiers {
10920            Some(sel) => self.compile_path_modifiers(sel, owner_td, &sub_alias)?,
10921            None => (None, vec![], None, None),
10922        };
10923        let subquery = IrSelect {
10924            rows: vec![IrRowSource::Bound {
10925                source: IrSource {
10926                    poly: self.poly_fanout_for(&owner_qname),
10927                    type_name: owner_qname,
10928                    table: owner_td.table.clone(),
10929                    alias: sub_alias.clone(),
10930                },
10931                shape: sub_shape,
10932            }],
10933            filter,
10934            order_by,
10935            offset,
10936            limit,
10937            distinct: false,
10938            dml_source: None,
10939            polymorphic: false,
10940            poly_implementors: vec![],
10941            poly_columns: vec![],
10942            lock: None,
10943        };
10944
10945        Ok(IrShapePointer::MultiLink(IrMultiLinkPointer {
10946            alias: output_alias.to_string(),
10947            join,
10948            subquery,
10949            link_properties: vec![],
10950            marker_offset,
10951            single: self.backlink_is_single(owner_td, &backlink_name, current_qname) || limits_to_one(modifiers),
10952        }))
10953    }
10954
10955    /// Compile a pointer the *schema* declares as computed (as opposed to one
10956    /// written inline in the query). A declared computed whose expression is
10957    /// a sub-select over a link — `(select .emails filter .primary limit 1)`
10958    /// — is an object pointer, exactly as if it had been written inline, so
10959    /// it takes the same route; everything else is a scalar expression.
10960    /// An object-returning function call as a row source, with the shape the
10961    /// pointer asked for (its target's own pk when none was written).
10962    fn compile_fn_object_source(
10963        &mut self,
10964        fc: &ast::FunctionCall,
10965        nested: &[ShapeElement],
10966    ) -> Result<Option<IrFunctionSelect>, PyQLError> {
10967        let Some(fd) = self.schema.functions.iter().find(|f| {
10968            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
10969            module_matches && f.name == fc.name && f.return_is_object
10970        }) else {
10971            return Ok(None);
10972        };
10973        let (fn_module, fn_name, return_type_name) = (fd.module.clone(), fd.name.clone(), fd.return_pg_type.clone());
10974        let return_td = self.resolve_type(&return_type_name)?;
10975        let alias = self.fresh_alias();
10976        let shape = if nested.is_empty() {
10977            Self::pk_returning(return_td)
10978        } else {
10979            self.compile_shape(nested, return_td, &alias, &return_td.module)?
10980        };
10981        let mut args = fc
10982            .args
10983            .iter()
10984            .map(|a| self.compile_free_expr(a))
10985            .collect::<Result<Vec<_>, _>>()?;
10986        // A function reading a session global takes them as its first
10987        // argument — the same forwarding a select over the call gets.
10988        if let Some(globals) = self.globals_arg_for_call(&format!("{fn_module}::{fn_name}"))? {
10989            args.insert(0, globals);
10990        }
10991        Ok(Some(IrFunctionSelect {
10992            fn_module,
10993            fn_name,
10994            fn_args: args,
10995            alias,
10996            type_name: format!("{}::{}", return_td.module, return_td.name),
10997            polymorphic: false,
10998            poly_implementors: vec![],
10999            poly_columns: vec![],
11000            shape,
11001            filter: None,
11002            order_by: vec![],
11003            offset: None,
11004            limit: None,
11005            distinct: false,
11006        }))
11007    }
11008
11009    /// `assert_exists(x)`, `assert_exists(x { … })`, `assert_distinct(x) { … }`
11010    /// — the assert, the set it checks, and the shape to read that set with.
11011    ///
11012    /// Only the one-argument forms, and not `assert_single`: the message
11013    /// overload takes a second argument the wrapper would have to carry, and
11014    /// `assert_single` returns the element rather than the array, so it does
11015    /// not fit the check `IrShapePointer::Asserted` emits.
11016    fn asserted_pointer_expr(expr: &Expr, nested: &[ShapeElement]) -> Option<(ast::FunctionCall, Expr, Option<Expr>)> {
11017        // `(select assert_distinct(X) { … } limit 1)` — the assert is the
11018        // select's subject, so it sees the whole set, while the modifiers
11019        // narrow only what is read back. The two coincide unless a limit or
11020        // offset is written, and then the check needs its own copy without
11021        // them, or it would be asserting over the one row that survives.
11022        // `(select assert_exists(X)) { … }` — the shape after the select is
11023        // the one its subject passes through.
11024        if let Expr::Shape(sh) = expr
11025            && let Some(subquery @ Expr::SubQuery(_)) = sh.expr.as_ref()
11026        {
11027            return Self::asserted_pointer_expr(subquery, &sh.elements);
11028        }
11029        if let Expr::SubQuery(stmt) = expr
11030            && let Stmt::Select(sel) = stmt.as_ref()
11031        {
11032            let (call, inner, _) = Self::asserted_pointer_expr(&sel.result, nested)?;
11033            let rebuild = |offset: Option<Expr>, limit: Option<Expr>| {
11034                Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
11035                    result: inner.clone(),
11036                    filter: sel.filter.clone(),
11037                    order_by: sel.order_by.clone(),
11038                    offset,
11039                    limit,
11040                    lock: sel.lock.clone(),
11041                })))
11042            };
11043            let value = rebuild(sel.offset.clone(), sel.limit.clone());
11044            let check = (sel.offset.is_some() || sel.limit.is_some()).then(|| rebuild(None, None));
11045            return Some((call, value, check));
11046        }
11047        // A shape written after the call (`assert_distinct(f(.id)) { amount }`)
11048        // belongs to the set the assert passes through, so it becomes the
11049        // inner pointer's shape.
11050        let (call, nested) = match expr {
11051            Expr::Shape(sh) => match sh.expr.as_ref() {
11052                Some(Expr::FunctionCall(f)) => (f, sh.elements.clone()),
11053                _ => return None,
11054            },
11055            Expr::FunctionCall(f) => (f, nested.to_vec()),
11056            _ => return None,
11057        };
11058        if call.module.is_some() && call.module.as_deref() != Some("std") {
11059            return None;
11060        }
11061        if !matches!(call.name.as_str(), "assert_exists" | "assert_distinct") {
11062            return None;
11063        }
11064        let [arg] = call.args.as_slice() else {
11065            return None;
11066        };
11067        // A shape written after the call belongs to the set the assert passes
11068        // through, so it is folded onto the argument rather than left as the
11069        // element's nested shape: a union only reads as objects when the shape
11070        // sits directly on it.
11071        let inner = match (&arg, nested.is_empty()) {
11072            (Expr::Shape(_), _) | (_, true) => arg.clone(),
11073            _ => Expr::Shape(Box::new(ast::ShapeExpr {
11074                expr: Some(arg.clone()),
11075                elements: nested.clone(),
11076                marker_offset: None,
11077            })),
11078        };
11079        Some((call.clone(), inner, None))
11080    }
11081
11082    /// A function call written as a pointer's value, with the shape its rows
11083    /// are read through — whether that shape sits after the call
11084    /// (`f(.id) { amount }`) or was parsed as the element's nested shape.
11085    fn object_call_with_shape<'e>(
11086        expr: &'e Expr,
11087        nested: Option<&[ShapeElement]>,
11088    ) -> Option<(&'e ast::FunctionCall, Vec<ShapeElement>)> {
11089        match expr {
11090            Expr::Shape(sh) => match sh.expr.as_ref() {
11091                Some(Expr::FunctionCall(f)) => Some((f, sh.elements.clone())),
11092                _ => None,
11093            },
11094            Expr::FunctionCall(f) => Some((f, nested.unwrap_or(&[]).to_vec())),
11095            _ => None,
11096        }
11097    }
11098
11099    fn compile_declared_computed(
11100        &mut self,
11101        cd: &crate::schema::ComputedDescriptor,
11102        td: &TypeDescriptor,
11103        alias: &str,
11104        module: &str,
11105        marker_offset: Option<usize>,
11106        nested: &[ShapeElement],
11107    ) -> Result<IrShapePointer, PyQLError> {
11108        let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
11109        let declared_on = self.computed_declared_on(td, &cd.name);
11110        self.compile_computed_expr(
11111            &cd.name,
11112            &expr_ast,
11113            td,
11114            alias,
11115            module,
11116            marker_offset,
11117            nested,
11118            declared_on,
11119        )
11120    }
11121
11122    /// The ancestor `td` inherited computed `name` from, nearest first, or
11123    /// `None` when `td` declares it itself. See `SelectAnchor::declared_on`.
11124    fn computed_declared_on(&self, td: &TypeDescriptor, name: &str) -> Option<(String, String)> {
11125        td.bases
11126            .iter()
11127            .chain(td.parents.iter())
11128            .chain(td.interfaces.iter())
11129            .filter_map(|qualified| self.resolve_type(qualified).ok())
11130            .find(|ancestor| ancestor.computed.iter().any(|c| c.name == name))
11131            .map(|ancestor| (ancestor.name.clone(), format!("{}::{}", ancestor.module, ancestor.name)))
11132    }
11133
11134    /// The body of `compile_declared_computed`, for a pointer whose expression
11135    /// is already parsed — a `with` binding's own shape declares one that way.
11136    #[allow(clippy::too_many_arguments)]
11137    fn compile_computed_expr(
11138        &mut self,
11139        name: &str,
11140        expr_ast: &Expr,
11141        td: &TypeDescriptor,
11142        alias: &str,
11143        module: &str,
11144        marker_offset: Option<usize>,
11145        nested: &[ShapeElement],
11146        declared_on: Option<(String, String)>,
11147    ) -> Result<IrShapePointer, PyQLError> {
11148        // The object the pointer is computed on stays in scope for the whole
11149        // expression, including any part of it compiled without a type in
11150        // hand — a `with` binding's right-hand side, say.
11151        self.anchors.push(SelectAnchor {
11152            type_name: td.name.clone(),
11153            qualified: format!("{}::{}", td.module, td.name),
11154            alias: alias.to_string(),
11155            detached: false,
11156            declared_on,
11157        });
11158        let result = (|compiler: &mut Self| -> Result<IrShapePointer, PyQLError> {
11159            if let Some(ptr) =
11160                compiler.try_compile_pointer_expr(name, expr_ast, td, alias, module, marker_offset, nested)?
11161            {
11162                return Ok(ptr);
11163            }
11164            // `manifest := retrieve_connector_manifest(.id)` — a computed whose
11165            // expression is an object-returning call. The rows it yields are
11166            // the pointer's objects; read as a plain expression the call is
11167            // "part of a larger expression", which such a function refuses.
11168            if let Expr::FunctionCall(fc) = expr_ast
11169                && let Some(fs) = compiler.compile_fn_object_source(fc, nested)?
11170            {
11171                return Ok(IrShapePointer::Computed(IrComputedPointer {
11172                    marker_offset,
11173                    alias: name.to_string(),
11174                    expr: IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectFunction(Box::new(fs)))),
11175                }));
11176            }
11177            let ir = compiler.in_value_position(|c| c.compile_expr(expr_ast, td, alias))?;
11178            Ok(IrShapePointer::Computed(IrComputedPointer {
11179                marker_offset,
11180                alias: name.to_string(),
11181                expr: ir,
11182            }))
11183        })(self);
11184        self.anchors.pop();
11185        result
11186    }
11187
11188    /// Split a sub-select's result into the path it traverses and the nested
11189    /// shape attached to it: `select .posts { title }` → `.posts` + `{ title }`,
11190    /// `select .posts` → `.posts` + no shape.
11191    fn split_path_result(result: &Expr) -> Option<(&ast::Path, &[ShapeElement])> {
11192        match result {
11193            Expr::Path(p) => Some((p, &[])),
11194            // `(select detached T filter … limit 1).field` — the marker says
11195            // the set is not correlated with the enclosing one, which is
11196            // already true of a subject named by its own type.
11197            Expr::Detached(inner) => Self::split_path_result(inner),
11198            Expr::Shape(sh) => match &sh.expr {
11199                Some(Expr::Path(p)) => Some((p, sh.elements.as_slice())),
11200                _ => None,
11201            },
11202            _ => None,
11203        }
11204    }
11205
11206    /// Peel a computed pointer's right-hand side down to the function call
11207    /// it names and the sub-select's modifiers, if that is its shape:
11208    /// `latest(.id)` / `(select latest(.id) filter …)`.
11209    fn function_subject(e: &Expr) -> Option<(&ast::FunctionCall, Option<&ast::SelectStmt>)> {
11210        match e {
11211            Expr::FunctionCall(fc) => Some((fc, None)),
11212            Expr::SubQuery(stmt) => match stmt.as_ref() {
11213                Stmt::Select(sel) => match &sel.result {
11214                    Expr::FunctionCall(fc) => Some((fc, Some(sel))),
11215                    _ => None,
11216                },
11217                _ => None,
11218            },
11219            _ => None,
11220        }
11221    }
11222
11223    /// The object-returning user function `fc` names, if any.
11224    fn resolve_object_fn(&self, fc: &ast::FunctionCall) -> Option<&'a FunctionDescriptor> {
11225        self.schema.functions.iter().find(|f| {
11226            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
11227            module_matches && f.name == fc.name && f.return_is_object
11228        })
11229    }
11230
11231    /// The shape a pointer's subject carried, when a shape written at the
11232    /// point of use replaced it: `(select .resource { rev := … }) { … }`.
11233    /// `pointer_subject` drops it, but its declarations are what the
11234    /// replacement reads `.rev` from.
11235    fn replaced_subject_shape(e: &Expr) -> &[ShapeElement] {
11236        let Expr::Shape(sh) = e else { return &[] };
11237        if sh.elements.is_empty() {
11238            return &[];
11239        }
11240        match sh.expr.as_ref() {
11241            Some(inner) => Self::pointer_subject(inner).map(|(_, nested, _)| nested).unwrap_or(&[]),
11242            None => &[],
11243        }
11244    }
11245
11246    /// Peel a computed pointer's right-hand side down to the path it names,
11247    /// the shape attached to it, and the sub-select carrying its modifiers
11248    /// (if any): `.posts` / `.posts { title }` / `(select .posts limit 1)` /
11249    /// `(select .posts { title } limit 1)` / `(select .posts limit 1) { title }`.
11250    fn pointer_subject(e: &Expr) -> Option<(&ast::Path, &[ShapeElement], Option<&ast::SelectStmt>)> {
11251        match e {
11252            Expr::Path(p) => Some((p, &[], None)),
11253            Expr::SubQuery(stmt) => match stmt.as_ref() {
11254                Stmt::Select(sel) => {
11255                    let (p, inner) = Self::split_path_result(&sel.result)?;
11256                    Some((p, inner, Some(sel)))
11257                }
11258                _ => None,
11259            },
11260            Expr::Shape(sh) => {
11261                let (path, inner, modifiers) = Self::pointer_subject(sh.expr.as_ref()?)?;
11262                let nested = if sh.elements.is_empty() {
11263                    inner
11264                } else {
11265                    sh.elements.as_slice()
11266                };
11267                Some((path, nested, modifiers))
11268            }
11269            _ => None,
11270        }
11271    }
11272
11273    /// `((select .parents filter … limit 1)).parent` — a computed whose value
11274    /// is a field access off its own sub-select. Rewritten as the select over
11275    /// the extended path, plus how many steps the field access contributed:
11276    /// the modifiers belong to the step *before* them, which is what a walk's
11277    /// `tail` pins them to.
11278    fn field_access_over_select(expr: &Expr) -> Option<(ast::SelectStmt, usize)> {
11279        let (root, fields) = Self::peel_field_access_chain(expr);
11280        if fields.is_empty() {
11281            return None;
11282        }
11283        let Expr::SubQuery(stmt) = root else { return None };
11284        let Stmt::Select(sel) = stmt.as_ref() else {
11285            return None;
11286        };
11287        let Expr::Path(path) = &sel.result else { return None };
11288        if !path.partial {
11289            return None;
11290        }
11291        let mut steps = path.steps.clone();
11292        let count = fields.len();
11293        steps.extend(fields.into_iter().map(ast::PathStep::Name));
11294        Some((
11295            ast::SelectStmt {
11296                result: Expr::Path(ast::Path { steps, partial: true }),
11297                ..sel.clone()
11298            },
11299            count,
11300        ))
11301    }
11302
11303    /// A computed pointer whose right-hand side names a *link* rather than
11304    /// computing a value — `pointer := .multilink`, `:= .multilink { … }`,
11305    /// `:= .<backlink[is T] { … }`, and any of those wrapped in a sub-select
11306    /// carrying FILTER/ORDER BY/OFFSET/LIMIT.
11307    ///
11308    /// These are pointers in their own right, so they route to the same
11309    /// builders a bare `pointer: { … } filter … limit N` inclusion uses and
11310    /// come back as real object pointers (arrays of hydrated objects) rather
11311    /// than the bare id or EXISTS boolean that compiling them as an
11312    /// expression would produce. A sub-select's modifiers are exactly the
11313    /// per-link modifiers that inclusion form already carries, which is what
11314    /// makes the re-use exact — the `:=` grammar has no trailing-modifier
11315    /// form of its own.
11316    ///
11317    /// Shared by inline `pointer := …` shape elements and schema-declared
11318    /// computeds, so both kinds behave identically.
11319    ///
11320    /// `Ok(None)` means "not a link-valued RHS": the caller compiles it as
11321    /// an ordinary expression, which is where `(select …).field` — a scalar
11322    /// — is handled.
11323    #[allow(clippy::too_many_arguments)]
11324    fn try_compile_pointer_expr(
11325        &mut self,
11326        pointer_name: &str,
11327        compexpr: &Expr,
11328        td: &TypeDescriptor,
11329        alias: &str,
11330        module: &str,
11331        marker_offset: Option<usize>,
11332        nested_override: &[ShapeElement],
11333    ) -> Result<Option<IrShapePointer>, PyQLError> {
11334        // A nested shape can sit inside the parens (`(select .posts
11335        // { title })`) or after them (`(select .posts) { title }`, `.posts
11336        // { title }`) — the parser folds a trailing `{ }` into an
11337        // `Expr::Shape` wrapping whatever precedes it, never into
11338        // `el.nested` (that field is only populated by the separate no-`:=`
11339        // "bare inclusion with nested shape" parse path).
11340        // `((select .history order by … limit 1)).release` — the field access
11341        // continues the walk, and the modifiers stay on the step they were
11342        // written on.
11343        if let Some((sel, tail)) = Self::field_access_over_select(compexpr)
11344            && let Expr::Path(path) = &sel.result
11345        {
11346            let (head_steps, tail_steps) = path.steps.split_at(path.steps.len() - tail);
11347            let (head_multi, head_target) = self.walk_path_types(td, head_steps, MAX_COMPUTED_SPLICES);
11348            let Some(head_target) = head_target else {
11349                return Ok(None);
11350            };
11351            let (tail_multi, target) = self.walk_path_types(head_target, tail_steps, MAX_COMPUTED_SPLICES);
11352            if target.is_none() {
11353                return Ok(None);
11354            }
11355            let multi = tail_multi || (head_multi && !limits_to_one(Some(&sel)));
11356            if !multi && nested_override.is_empty() {
11357                return Ok(None);
11358            }
11359            let path = path.clone();
11360            return self
11361                .compile_chained_link_pointer(pointer_name, &path, td, alias, nested_override, Some(&sel), multi, tail)
11362                .map(Some);
11363        }
11364        let Some((path, declared_nested, modifiers)) = Self::pointer_subject(compexpr) else {
11365            return Ok(None);
11366        };
11367        if !path.partial {
11368            return Ok(None);
11369        }
11370        // A shape written at the point of use (`authors { name }` on a
11371        // declared computed) wins over the one the declaration itself
11372        // carries, which acts as the default.
11373        let nested = if nested_override.is_empty() {
11374            declared_nested
11375        } else {
11376            nested_override
11377        };
11378
11379        // Only a link pointer of the current type becomes an object pointer;
11380        // a property (`x := .name`, `x := (select .name)`) is a scalar and
11381        // belongs on the expression path.
11382        let ml_name = match path.steps.as_slice() {
11383            [ast::PathStep::Name(n)] if Self::resolve_multilink(td, n).is_some() => n.clone(),
11384            // `.<link[is Owner]` — the objects on the other side of the
11385            // link. Traversing *past* the intersection (`.<link[is
11386            // Owner].name`) is a value, not an object pointer, so it goes
11387            // the expression route instead.
11388            [ast::PathStep::Backlink(_)] | [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_)] => {
11389                let current_qname = format!("{}::{}", td.module, td.name);
11390                let path = path.clone();
11391                let nested = nested.to_vec();
11392                return self
11393                    .compile_backlink_pointer(pointer_name, &path, &current_qname, &nested, marker_offset, modifiers)
11394                    .map(Some);
11395            }
11396            // `.memberships.member` — a chain no single-step builder can
11397            // express: the junction it would need belongs to no one link but
11398            // to the whole walk. Compiled as a path select and aggregated, so
11399            // it stays an object pointer instead of collapsing to the bare ids
11400            // an expression-position path gives — which, worse, was a scalar
11401            // subquery that failed outright the moment a second row matched.
11402            // A leading `[is T]` narrows what the walk starts from
11403            // (`brand := [is BrandOrderLineItem].brand { * }`); the walk
11404            // itself is the same one a bare name starts.
11405            steps
11406                if matches!(
11407                    steps.first(),
11408                    Some(ast::PathStep::Name(_) | ast::PathStep::TypeIntersection(_))
11409                ) =>
11410            {
11411                let (multi, target) = self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES);
11412                // A single-valued walk takes this route only when a shape was
11413                // written on it (`account := resource.account { id }`), which
11414                // is what says an object was meant. Without one, a bare
11415                // `x := .link` still stands for the link's value, as it did
11416                // before there was an object route at all.
11417                if target.is_none() || (!multi && nested.is_empty()) {
11418                    return Ok(None);
11419                }
11420                let path = path.clone();
11421                let nested = nested.to_vec();
11422                return self
11423                    .compile_chained_link_pointer(
11424                        pointer_name,
11425                        &path,
11426                        td,
11427                        alias,
11428                        &nested,
11429                        modifiers,
11430                        multi && !limits_to_one(modifiers),
11431                        0,
11432                    )
11433                    .map(Some);
11434            }
11435            _ => return Ok(None),
11436        };
11437
11438        let synthetic = ShapeElement {
11439            path: path.clone(),
11440            splat: None,
11441            nested: Some(nested.to_vec()),
11442            compexpr: None,
11443            op: ast::ShapeOp::Assign,
11444            filter: modifiers.and_then(|s| s.filter.clone()),
11445            order_by: modifiers.map(|s| s.order_by.clone()).unwrap_or_default(),
11446            offset: modifiers.and_then(|s| s.offset.clone()),
11447            limit: modifiers.and_then(|s| s.limit.clone()),
11448            marker_offset,
11449        };
11450        let mut pointer = self.compile_multilink_pointer(pointer_name, &ml_name, td, alias, module, &synthetic)?;
11451        if let IrShapePointer::MultiLink(link) = &mut pointer {
11452            link.single = limits_to_one(modifiers);
11453        }
11454        Ok(Some(pointer))
11455    }
11456
11457    /// A sub-statement used as an expression: `(select .emails filter
11458    /// .primary limit 1).email`, `(select .posts order by .created desc
11459    /// limit 1).title`.
11460    ///
11461    /// Compiles the inner select as a flat path traversal
11462    /// (`compile_path_select` — the same builder a top-level `select
11463    /// Person.company.name` uses, so forward links, multi-links, backlinks,
11464    /// junction-backed links and type intersections all come along) and
11465    /// returns it as one correlated scalar subquery.
11466    ///
11467    /// A *partial* subject path (`.emails`) is relative to the enclosing
11468    /// object, so it is rooted at the enclosing type and correlated back to
11469    /// the enclosing row by primary key; an absolute one (`Person.name`) is
11470    /// independent and needs no correlation.
11471    ///
11472    /// `extra_fields` are the `.field` steps of an enclosing field-access
11473    /// chain. They are spliced onto the subject path rather than applied to
11474    /// its result, so the subquery projects the scalar column itself instead
11475    /// of an opaque object id.
11476    fn compile_subquery_expr(
11477        &mut self,
11478        stmt: &Stmt,
11479        extra_fields: &[String],
11480        ctx: Option<(&TypeDescriptor, &str)>,
11481        outer_shape: &[ShapeElement],
11482    ) -> Result<IrExpr, PyQLError> {
11483        // `(with x := … select …)` — the bindings have nowhere to live in
11484        // expression position, so they're hoisted to the enclosing
11485        // statement's own WITH clause and the inner statement takes over.
11486        if let Stmt::With(w) = stmt {
11487            for alias in &w.aliases {
11488                if self.bind_inline_if_correlated(&alias.name, &alias.expr, ctx)?
11489                    || self.bind_group(&alias.name, &alias.expr)?
11490                {
11491                    continue;
11492                }
11493                // The same computed inlined twice — `select M { * } filter … .role`
11494                // reaches `Membership.role` from both the splat and the filter —
11495                // hoists its `with` bindings once per inlining, and two CTEs of
11496                // one name is "WITH query name specified more than once". The
11497                // bindings are identical, so the first one stands for both; a
11498                // *different* binding under a name already taken is a real
11499                // collision and says so rather than silently shadowing.
11500                match self.hoisted_binding_sources.get(&alias.name) {
11501                    Some(existing) if *existing == alias.expr => continue,
11502                    Some(_) => {
11503                        return Err(self.type_err(&format!(
11504                            "two different `with` bindings named '{}' end up in one statement; \
11505                             rename one of them",
11506                            alias.name
11507                        )));
11508                    }
11509                    None => {}
11510                }
11511                let (ir_stmt, correlated_to) = self.compile_binding_in_scope(&alias.expr)?;
11512                let sql_name = self.claim_cte_sql_name(&alias.name);
11513                if let Some(slot) = correlated_to.as_deref().and_then(|i| i.strip_prefix("_for_")) {
11514                    self.correlated_bindings.insert(sql_name.clone(), slot.to_string());
11515                }
11516                let type_name = self.register_cte(&alias.name, &ir_stmt);
11517                self.hoisted_binding_sources
11518                    .insert(alias.name.clone(), alias.expr.clone());
11519                self.hoisted_ctes.push(IrCteDef {
11520                    name: sql_name,
11521                    stmt: ir_stmt,
11522                    type_name,
11523                    correlated_to,
11524                });
11525            }
11526            let inner = (*w.stmt).clone();
11527            return self.compile_subquery_expr(&inner, extra_fields, ctx, outer_shape);
11528        }
11529
11530        if extra_fields.is_empty()
11531            && let Stmt::Select(sel) = stmt
11532            && let Expr::Shape(sh) = &sel.result
11533            && let Some(Expr::SubQuery(subject)) = &sh.expr
11534            && matches!(subject.as_ref(), Stmt::Group(_))
11535            && let IrStmt::Group(grp) = self.compile_stmt(stmt)?
11536        {
11537            return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::Group(Box::new(grp)))));
11538        }
11539
11540        // `(select (.<a[is T] union .<b[is T]) { id } limit 1)` — the union's
11541        // operands are correlated to the enclosing row, so the whole thing is
11542        // read as one set rather than hoisted branch by branch.
11543        if let Stmt::Select(inner) = stmt
11544            && let Expr::Shape(sh) = &inner.result
11545            && let Some(subject) = sh.expr.as_ref()
11546            && let Some(operands) = Self::union_of_relative_paths(subject)
11547            && let Some((td, alias)) = ctx
11548        {
11549            let elements = sh.elements.clone();
11550            let operands_reach_many = self.relative_paths_reach_many(td, &operands);
11551            let mut branches = Vec::with_capacity(operands.len());
11552            for path in operands {
11553                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
11554                steps.extend(path.steps.iter().cloned());
11555                let rooted = ast::Path { steps, partial: false };
11556                let synthetic = ast::SelectStmt {
11557                    result: Expr::Path(rooted.clone()),
11558                    filter: inner.filter.clone(),
11559                    order_by: vec![],
11560                    offset: None,
11561                    limit: None,
11562                    lock: None,
11563                };
11564                let mut ps = self.compile_path_select(&synthetic, &rooted, &elements, false)?;
11565                Self::correlate_path_select(&mut ps, alias);
11566                branches.push(ps);
11567            }
11568            let limit = inner
11569                .limit
11570                .as_ref()
11571                .map(|l| self.compile_free_expr(l))
11572                .transpose()?
11573                .map(Box::new);
11574            let multi = !matches!(limit.as_deref(), Some(IrExpr::Literal(IrLiteral::Int(1)))) && operands_reach_many;
11575            return Ok(IrExpr::ObjectPathUnion { branches, limit, multi });
11576        }
11577        // `(insert T { … }).id` — a mutation read through a path. The statement
11578        // itself may only run at the top level of a WITH, so it is hoisted into
11579        // one and the path is read back off that binding: exactly what the
11580        // `with x := (insert …) select x.id` spelling already compiles to. With
11581        // no path to read there is nothing for the mutation to stand in for,
11582        // which stays an error.
11583        // Only in free context. Inside a schema-bound shape element
11584        // (`select Person { x := (insert Company { … }).name }`) the mutation
11585        // would run once while every row read its result, which is not what
11586        // writing it there says — that stays rejected.
11587        if matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
11588            && !extra_fields.is_empty()
11589            && ctx.is_none()
11590        {
11591            let type_name = self.dml_subject_type(stmt)?;
11592            let inner = self.compile_stmt(stmt)?;
11593            let cte_name = self.fresh_nested_cte_name();
11594            // `hoisted_ctes`, not `pending_nested_ctes`: this mutation came out
11595            // of an expression in the enclosing statement, not out of another
11596            // statement's link assignment, so it belongs to that statement's
11597            // own WITH. Registering it is what lets the path below resolve
11598            // against it like any other binding.
11599            self.register_cte(&cte_name, &inner);
11600            self.hoisted_ctes.push(IrCteDef {
11601                name: cte_name.clone(),
11602                stmt: inner,
11603                type_name,
11604                correlated_to: None,
11605            });
11606            let mut steps = vec![ast::PathStep::Name(cte_name)];
11607            steps.extend(extra_fields.iter().cloned().map(ast::PathStep::Name));
11608            return self.compile_free_path(&ast::Path { steps, partial: false });
11609        }
11610
11611        let Stmt::Select(sel) = stmt else {
11612            return Err(self.subquery_expr_err(stmt));
11613        };
11614        let has_modifiers =
11615            sel.filter.is_some() || !sel.order_by.is_empty() || sel.offset.is_some() || sel.limit.is_some();
11616
11617        let Some((path, shape_els)) = Self::split_path_result(&sel.result) else {
11618            // `(select account::owner(.id) filter … limit 1).name` — the
11619            // function call is the row source the modifiers apply to.
11620            if let Expr::FunctionCall(fc) = &sel.result {
11621                let fc = fc.clone();
11622                if let Some(ir) = self.try_compile_fn_scalar_subquery(&fc, extra_fields, Some(sel), ctx)? {
11623                    return Ok(ir);
11624                }
11625            }
11626            // `(select <expression>)` with nothing to traverse is just the
11627            // expression itself. With modifiers to honour it is a statement,
11628            // not an expression — `(select count(Visit) filter …)` — so it is
11629            // hoisted into the enclosing WITH and read back by name.
11630            if has_modifiers {
11631                let inner = self.compile_stmt(stmt)?;
11632                let IrStmt::Select(select) = inner else {
11633                    return Err(self.subquery_expr_err(stmt));
11634                };
11635                if !select
11636                    .rows
11637                    .iter()
11638                    .all(|r| matches!(r, IrRowSource::Free(IrFreeExpr::Scalar(_))))
11639                {
11640                    return Err(self.subquery_expr_err(stmt));
11641                }
11642                let mut ir = IrExpr::ScalarSubquery(Box::new(select));
11643                for field in extra_fields {
11644                    ir = Self::project_free_object_field(ir, field);
11645                }
11646                return Ok(ir);
11647            }
11648            let mut ir = self.compile_expr_ctx(&sel.result, ctx)?;
11649            for field in extra_fields {
11650                ir = Self::project_free_object_field(ir, field);
11651            }
11652            return Ok(ir);
11653        };
11654        // A shape whose pointers are relative paths declares local names for
11655        // them, readable by the select's own FILTER/ORDER BY and by whatever
11656        // projects off it — `(select .locators { handle := [is Handle].handle,
11657        // latest := [is Handle].latest } filter .latest limit 1).handle`. Each
11658        // is substituted back into its readers, leaving an ordinary shapeless
11659        // sub-select behind.
11660        let rewritten_sel;
11661        let mut sel = sel;
11662        let mut shape_els = shape_els;
11663        let mut extra_steps: Vec<ast::PathStep> = extra_fields.iter().map(|f| ast::PathStep::Name(f.clone())).collect();
11664        if !shape_els.is_empty()
11665            && let Some(defs) = Self::shape_alias_paths(shape_els)
11666        {
11667            extra_steps = extra_fields
11668                .iter()
11669                .flat_map(|field| match defs.iter().find(|(name, _)| name == field) {
11670                    Some((_, definition)) => definition.steps.clone(),
11671                    None => vec![ast::PathStep::Name(field.clone())],
11672                })
11673                .collect();
11674            rewritten_sel = ast::SelectStmt {
11675                result: sel.result.clone(),
11676                filter: sel.filter.clone().map(|f| Self::substitute_shape_aliases(f, &defs)),
11677                order_by: sel
11678                    .order_by
11679                    .iter()
11680                    .map(|o| ast::SortExpr {
11681                        expr: Self::substitute_shape_aliases(o.expr.clone(), &defs),
11682                        direction: o.direction.clone(),
11683                        nones: o.nones.clone(),
11684                    })
11685                    .collect(),
11686                offset: sel.offset.clone(),
11687                limit: sel.limit.clone(),
11688                lock: sel.lock.clone(),
11689            };
11690            sel = &rewritten_sel;
11691            shape_els = &[];
11692        }
11693
11694        // A shape only matters when the sub-select's own value is the
11695        // result. `(select .emails { address } limit 1).address` projects a
11696        // column straight back out of it, so the shape says nothing the
11697        // projection doesn't — the same reading PyQL gives it.
11698        // `answer := (select brand::AssessmentAnswer { … } filter … limit 1)`
11699        // — a select over a named type, carrying the shape its rows are read
11700        // with. That is an object, not the bare key the branch below gives a
11701        // shapeless one, so the shape has somewhere to go after all.
11702        // `(select names { * })` over an object `with` binding reads the
11703        // binding's rows the same way.
11704        if !shape_els.is_empty()
11705            && extra_fields.is_empty()
11706            && !path.partial
11707            && let [ast::PathStep::Name(type_name)] = path.steps.as_slice()
11708            && let Some(root_td) = match self.cte_object_type(type_name) {
11709                Some(_) => self.resolve_path_root(type_name).ok(),
11710                None => self.resolve_type(type_name).ok(),
11711            }
11712        {
11713            let alias = self.fresh_alias();
11714            let binding = self.cte_object_type(type_name).map(|_| type_name.clone());
11715            let table = match &binding {
11716                Some(name) => self.row_source_table(name, root_td),
11717                None => root_td.table.clone(),
11718            };
11719            let outer_declared = binding.as_ref().map(|name| {
11720                let declared = self.cte_declared_pointers.get(name).cloned().unwrap_or_default();
11721                std::mem::replace(&mut self.active_declared_pointers, declared)
11722            });
11723            let modifiers = self.compile_path_modifiers(sel, root_td, &alias);
11724            let module = root_td.module.clone();
11725            let shape = self.compile_shape(shape_els, root_td, &alias, &module);
11726            if let Some(outer) = outer_declared {
11727                self.active_declared_pointers = outer;
11728            }
11729            let (filter, order_by, offset, limit) = modifiers?;
11730            let shape = shape?;
11731            let source = IrSource {
11732                poly: self.poly_fanout_for(&format!("{}::{}", root_td.module, root_td.name)),
11733                type_name: format!("{}::{}", root_td.module, root_td.name),
11734                table,
11735                alias,
11736            };
11737            let single = selects_at_most_one(sel, root_td);
11738            let mut select = IrSelect::schema_bound(source, shape, filter);
11739            select.order_by = order_by;
11740            select.offset = offset;
11741            select.limit = limit;
11742            // Read as one object, a select yielding several rows aborts the
11743            // query ("more than one row returned by a subquery").
11744            return Ok(if single {
11745                IrExpr::ObjectSubquery(Box::new(select))
11746            } else {
11747                IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(Box::new(select))))
11748            });
11749        }
11750
11751        if !shape_els.is_empty() && extra_fields.is_empty() {
11752            return Err(self.type_err(
11753                "a sub-select with a shape is not valid in expression context — \
11754                 assign it to a computed pointer instead, or project a property \
11755                 off it, e.g. '(select .emails limit 1).address'",
11756            ));
11757        }
11758
11759        // `(with c := … select c)` — the result is the binding itself, which
11760        // is a value already, not something to traverse from.
11761        if !path.partial
11762            && let [ast::PathStep::Name(name)] = path.steps.as_slice()
11763            && self.is_value_binding(name)
11764        {
11765            let mut ir = self.compile_expr_ctx(&sel.result, ctx)?;
11766            for field in extra_fields {
11767                ir = Self::project_free_object_field(ir, field);
11768            }
11769            return Ok(ir);
11770        }
11771
11772        // `(select Company filter .name = 'Acme' limit 1)` — a bare type
11773        // select has nothing to traverse; in expression position it stands
11774        // for the object's primary key, which is what a link's value is.
11775        if !path.partial
11776            && extra_fields.is_empty()
11777            && let [ast::PathStep::Name(type_name)] = path.steps.as_slice()
11778            && let Ok(root_td) = self.resolve_type(type_name)
11779        {
11780            let alias = self.fresh_alias();
11781            let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, root_td, &alias)?;
11782            let source = IrSource {
11783                poly: None,
11784                type_name: format!("{}::{}", root_td.module, root_td.name),
11785                table: root_td.table.clone(),
11786                alias,
11787            };
11788            // Never `pk_returning` here: an empty shape is how the emitter
11789            // spells an EXISTS inner, so a type with no declared pk would
11790            // quietly become `SELECT 1` instead of a key.
11791            let pk = root_td.properties.iter().find(|p| p.is_pk);
11792            let shape = vec![IrShapePointer::Scalar(IrScalarPointer {
11793                implicit_id: false,
11794                marker_offset: None,
11795                alias: "id".to_string(),
11796                column: pk.map(|p| p.name.clone()).unwrap_or_else(|| "id".to_string()),
11797                pg_type: pk.map(|p| p.pg_type.clone()).unwrap_or_else(|| "uuid".to_string()),
11798                tuple_shape: None,
11799            })];
11800            let mut select = IrSelect::schema_bound(source, shape, filter);
11801            select.order_by = order_by;
11802            select.offset = offset;
11803            select.limit = limit;
11804            return Ok(IrExpr::Subquery(Box::new(select)));
11805        }
11806
11807        let mut steps = path.steps.clone();
11808        let correlate = if path.partial {
11809            let Some((td, alias)) = ctx else {
11810                return Err(self.type_err(
11811                    "a relative path in a sub-select needs an enclosing object — \
11812                     write the type name explicitly, e.g. '(select Person.name)'",
11813                ));
11814            };
11815            steps.insert(0, ast::PathStep::Name(format!("{}::{}", td.module, td.name)));
11816            Some(alias.to_string())
11817        } else {
11818            None
11819        };
11820        steps.extend(extra_steps.iter().cloned());
11821        let full_path = ast::Path { steps, partial: false };
11822
11823        let mut ps = self.compile_path_select_with_tail(sel, &full_path, outer_shape, false, extra_steps.len(), &[])?;
11824        if let Some(outer_alias) = correlate {
11825            Self::correlate_path_select(&mut ps, &outer_alias);
11826        }
11827        // A shape written after the projection (`(select … limit 1).account
11828        // { id, name }`) says the object was wanted, not its id, so the walk
11829        // comes back as one rather than being reduced the way a bare path in
11830        // expression position is.
11831        if !outer_shape.is_empty() && matches!(ps.result, IrPathResult::Object { .. }) {
11832            return Ok(IrExpr::ObjectPathSubquery(Box::new(ps)));
11833        }
11834        // `limit 1` is what makes a sub-select over a multi-link single-
11835        // valued — that is the whole point of `(select .emails filter
11836        // .primary limit 1).address`. Any other limit, or none, still stands
11837        // for a set, so it comes back as an array rather than a subquery
11838        // Postgres would reject the moment a second row showed up.
11839        let single = matches!(&sel.limit, Some(Expr::Literal(ast::Literal::Int(1))));
11840        let multi = match &full_path.steps[0] {
11841            ast::PathStep::Name(root) => {
11842                let root_td = self.resolve_path_root(root)?;
11843                // `(select T filter …).field` reads every `T` the filter
11844                // keeps, unless it pins an exclusive pointer.
11845                let every_row_of_a_type = !path.partial
11846                    && self.cte_object_type(root).is_none()
11847                    && self.resolve_type(root).is_ok()
11848                    && !selects_at_most_one(sel, root_td);
11849                every_row_of_a_type || self.path_crosses_multi(root_td, &full_path.steps[1..])
11850            }
11851            _ => false,
11852        };
11853        if multi && !single && matches!(ps.result, IrPathResult::Scalar(..)) {
11854            return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
11855                ps,
11856            )))));
11857        }
11858        Ok(IrExpr::PathSubquery(Box::new(ps)))
11859    }
11860
11861    /// `account::owner(.id).name` and `(select account::owner(.id) filter …
11862    /// limit 1).name` — an object-returning user function used inside a
11863    /// larger expression, projected down to one of its return type's
11864    /// columns.
11865    ///
11866    /// The call itself stays object-valued (there is no way to inline a
11867    /// function that returns a row set), so it becomes the FROM clause of a
11868    /// scalar subquery and `field` becomes what that subquery selects. Only
11869    /// a property of the return type can be projected — reaching further
11870    /// (`fn(x).company.name`) would need joins the function row source has
11871    /// no way to express.
11872    ///
11873    /// `Ok(None)` when `fc` doesn't name an object-returning function, so
11874    /// the caller can fall through to ordinary function-call resolution.
11875    fn try_compile_fn_scalar_subquery(
11876        &mut self,
11877        fc: &ast::FunctionCall,
11878        fields: &[String],
11879        modifiers: Option<&ast::SelectStmt>,
11880        ctx: Option<(&TypeDescriptor, &str)>,
11881    ) -> Result<Option<IrExpr>, PyQLError> {
11882        let fd = self.schema.functions.iter().find(|f| {
11883            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
11884            module_matches && f.name == fc.name && f.return_is_object
11885        });
11886        let Some(fd) = fd else { return Ok(None) };
11887        let (fn_module, fn_name, return_type_name, polymorphic, params) = (
11888            fd.module.clone(),
11889            fd.name.clone(),
11890            fd.return_pg_type.clone(),
11891            fd.return_is_polymorphic,
11892            fd.params.clone(),
11893        );
11894
11895        let qualified = format!("{fn_module}::{fn_name}");
11896        let [field] = fields else {
11897            return Err(self.type_err(&format!(
11898                "function '{qualified}' returns objects, so using it inside an expression needs \
11899                 one of its properties, e.g. '{qualified}(…).name'"
11900            )));
11901        };
11902        if params.len() != fc.args.len() {
11903            return Err(self.type_err(&format!(
11904                "function '{qualified}' expects {} argument(s), got {}",
11905                params.len(),
11906                fc.args.len()
11907            )));
11908        }
11909
11910        let mut fn_args = fc
11911            .args
11912            .iter()
11913            .map(|a| self.compile_expr_ctx(a, ctx))
11914            .collect::<Result<Vec<_>, _>>()?;
11915        if let Some(globals) = self.globals_arg_for_call(&qualified)? {
11916            fn_args.insert(0, globals);
11917        }
11918
11919        let td = self.resolve_type(&return_type_name)?;
11920        let alias = self.fresh_alias();
11921        let Some(prop) = Self::resolve_property(td, field) else {
11922            return Err(self.field_err(field, &return_type_name));
11923        };
11924        let projected = IrShapePointer::Computed(IrComputedPointer {
11925            marker_offset: None,
11926            alias: field.clone(),
11927            expr: IrExpr::ColumnRef {
11928                alias: alias.clone(),
11929                column: prop.name.clone(),
11930                pg_type: prop.pg_type.clone(),
11931            },
11932        });
11933
11934        let (poly_implementors, poly_columns) = if polymorphic {
11935            self.collect_poly_info(&return_type_name)
11936        } else {
11937            (vec![], vec![])
11938        };
11939        let (filter, order_by, offset, limit) = match modifiers {
11940            Some(sel) => {
11941                let td = self.resolve_type(&return_type_name)?;
11942                self.compile_path_modifiers(sel, td, &alias)?
11943            }
11944            None => (None, vec![], None, None),
11945        };
11946
11947        Ok(Some(IrExpr::FnSubquery(Box::new(IrFunctionSelect {
11948            fn_module,
11949            fn_name,
11950            fn_args,
11951            alias,
11952            type_name: return_type_name,
11953            polymorphic,
11954            poly_implementors,
11955            poly_columns,
11956            shape: vec![projected],
11957            filter,
11958            order_by,
11959            offset,
11960            limit,
11961            distinct: false,
11962        }))))
11963    }
11964
11965    /// Whether a declared computed pointer stands for *objects* rather than a
11966    /// value — `members := .memberships.member`, `primary_email := (select
11967    /// .emails filter .primary limit 1)`.
11968    ///
11969    /// `*` expands to properties and `**` adds links, so a computed belongs to
11970    /// whichever side its expression lands on. `ComputedDescriptor` carries no
11971    /// flag saying which, so the expression has to be walked.
11972    fn computed_is_object_valued(&self, cd: &crate::schema::ComputedDescriptor, td: &TypeDescriptor) -> bool {
11973        let Ok(expr) = crate::parse::parse_pointer_expr(&cd.expression) else {
11974            return false;
11975        };
11976        let expr = match Self::field_access_over_select(&expr) {
11977            Some((sel, _)) => Expr::SubQuery(Box::new(Stmt::Select(sel))),
11978            None => expr,
11979        };
11980        let Some((path, _, _)) = Self::pointer_subject(&expr) else {
11981            return false;
11982        };
11983        if !path.partial {
11984            return false;
11985        }
11986        match path.steps.as_slice() {
11987            // A backlink names the objects on the other side of the link.
11988            // Traversing past it (`.<author[is Post].title`) is a value again,
11989            // which the walk below works out for itself.
11990            [ast::PathStep::Backlink(_)] | [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_)] => true,
11991            steps => self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES).1.is_some(),
11992        }
11993    }
11994
11995    /// True when traversing `steps` from `td` crosses a multi-valued step —
11996    /// a multi-link or a backlink. Such a path stands for a *set*, so in
11997    /// expression position it has to come back as an array rather than a
11998    /// scalar subquery (which Postgres would reject at run time the moment a
11999    /// second row showed up).
12000    fn path_crosses_multi(&self, td: &TypeDescriptor, steps: &[ast::PathStep]) -> bool {
12001        self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES).0
12002    }
12003
12004    /// One correlated walk per operand of a coalesce of relative paths, each
12005    /// one filtered to the rows where every operand before it came up empty —
12006    /// which is what makes the arms, read together, mean what `??` means.
12007    fn coalesce_path_branches(
12008        &mut self,
12009        td: &TypeDescriptor,
12010        alias: &str,
12011        operands: &[ast::Path],
12012        elements: &[ast::ShapeElement],
12013    ) -> Result<Vec<IrPathSelect>, PyQLError> {
12014        let mut branches: Vec<IrPathSelect> = Vec::with_capacity(operands.len());
12015        for path in operands {
12016            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
12017            steps.extend(path.steps.iter().cloned());
12018            let rooted = ast::Path { steps, partial: false };
12019            let synthetic = ast::SelectStmt {
12020                result: Expr::Path(rooted.clone()),
12021                filter: None,
12022                order_by: vec![],
12023                offset: None,
12024                limit: None,
12025                lock: None,
12026            };
12027            let mut ps = self.compile_path_select(&synthetic, &rooted, elements, false)?;
12028            Self::correlate_path_select(&mut ps, alias);
12029            let guards: Vec<IrExpr> = branches
12030                .iter()
12031                .map(|earlier| {
12032                    IrExpr::UnaryOp(Box::new(IrUnaryOp {
12033                        op: ast::UnaryOpKind::Not,
12034                        operand: IrExpr::UnaryOp(Box::new(IrUnaryOp {
12035                            op: ast::UnaryOpKind::Exists,
12036                            operand: IrExpr::PathSubquery(Box::new(earlier.clone())),
12037                        })),
12038                    }))
12039                })
12040                .collect();
12041            if !guards.is_empty() {
12042                ps.filter = and_conditions(ps.filter, guards);
12043            }
12044            branches.push(ps);
12045        }
12046        Ok(branches)
12047    }
12048
12049    /// Whether a relative path lands on objects rather than on values — its
12050    /// last named step is a link, a multi-link or a backlink. A coalesce over
12051    /// values is a choice between two sets of scalars, which the ordinary
12052    /// binary-operator path already reads as one.
12053    fn path_lands_on_objects(&self, td: &TypeDescriptor, path: &ast::Path) -> bool {
12054        let Some(last) = path
12055            .steps
12056            .iter()
12057            .rposition(|step| matches!(step, ast::PathStep::Name(_) | ast::PathStep::Backlink(_)))
12058        else {
12059            return false;
12060        };
12061        match &path.steps[last] {
12062            ast::PathStep::Backlink(_) => true,
12063            ast::PathStep::Name(name) => self
12064                .walk_path_types(td, &path.steps[..last], MAX_COMPUTED_SPLICES)
12065                .1
12066                .is_some_and(|owner| {
12067                    Self::resolve_multilink(owner, name).is_some() || Self::resolve_link(owner, name).is_some()
12068                }),
12069            _ => false,
12070        }
12071    }
12072
12073    /// Whether the operands of a union or coalesce of relative paths reach
12074    /// more than one object between them. A leading type intersection roots
12075    /// the walk at the intersected type, as `compile_partial_path_as_subquery`
12076    /// does, so `[is Loop].condition_configs` is read off `Loop`.
12077    fn relative_paths_reach_many(&self, td: &TypeDescriptor, operands: &[ast::Path]) -> bool {
12078        operands.iter().any(|path| {
12079            let (root_td, rest) = match path.steps.first() {
12080                Some(ast::PathStep::TypeIntersection(tr)) => {
12081                    let name = match &tr.module {
12082                        Some(module) => format!("{}::{}", module, tr.name),
12083                        None => tr.name.clone(),
12084                    };
12085                    match self.resolve_type(&name) {
12086                        Ok(resolved) => (resolved, &path.steps[1..]),
12087                        Err(_) => return true,
12088                    }
12089                }
12090                _ => (td, &path.steps[..]),
12091            };
12092            self.path_crosses_multi(root_td, rest)
12093        })
12094    }
12095
12096    /// Walk `steps` from `td` without compiling anything, reporting whether
12097    /// any step is multi-valued and what type the walk ends on. A computed
12098    /// pointer is expanded into the path it stands for — the same splice
12099    /// `compile_path_select` performs — so `.published.title` is recognized
12100    /// as multi-valued when `published` resolves to a multi-link.
12101    ///
12102    /// Gives up (`None` target) rather than guessing on anything it can't
12103    /// resolve; the real compile reports the error.
12104    fn walk_path_types(
12105        &self,
12106        td: &'a TypeDescriptor,
12107        steps: &[ast::PathStep],
12108        depth: usize,
12109    ) -> (bool, Option<&'a TypeDescriptor>) {
12110        let mut current = td;
12111        let mut multi = false;
12112        for (i, step) in steps.iter().enumerate() {
12113            match step {
12114                // `.<link[is Owner]` names the owner type; a bare `.<link`
12115                // spans every type declaring it, so that one lands nowhere in
12116                // particular.
12117                ast::PathStep::Backlink(backlink_name) => {
12118                    let Some(ast::PathStep::TypeIntersection(tr)) = steps.get(i + 1) else {
12119                        return (true, None);
12120                    };
12121                    let owner_name = match &tr.module {
12122                        Some(m) => format!("{}::{}", m, tr.name),
12123                        None => tr.name.clone(),
12124                    };
12125                    let current_qname = format!("{}::{}", current.module, current.name);
12126                    let single = self
12127                        .resolve_type(&owner_name)
12128                        .is_ok_and(|owner| self.backlink_is_single(owner, backlink_name, &current_qname));
12129                    multi |= !single;
12130                    continue;
12131                }
12132                ast::PathStep::TypeIntersection(tr) => {
12133                    let name = match &tr.module {
12134                        Some(m) => format!("{}::{}", m, tr.name),
12135                        None => tr.name.clone(),
12136                    };
12137                    match self.resolve_type(&name) {
12138                        Ok(t) => current = t,
12139                        Err(_) => return (multi, None),
12140                    }
12141                }
12142                ast::PathStep::Name(n) => {
12143                    if let Some(ml) = Self::resolve_multilink(current, n) {
12144                        multi = true;
12145                        match self.resolve_type(&ml.target) {
12146                            Ok(t) => current = t,
12147                            Err(_) => return (multi, None),
12148                        }
12149                        continue;
12150                    }
12151                    if let Some(target) = Self::resolve_link(current, n).map(|l| l.target.clone()) {
12152                        match self.resolve_type(&target) {
12153                            Ok(t) => current = t,
12154                            Err(_) => return (multi, None),
12155                        }
12156                        continue;
12157                    }
12158                    // A computed pointer stands for its own path, or for the
12159                    // object-returning function it calls.
12160                    if depth > 0
12161                        && let Some(cd) = self.resolve_computed(current, n)
12162                        && let Ok(expr) = crate::parse::parse_pointer_expr(&cd.expression)
12163                    {
12164                        if let Some((p, _, modifiers)) = Self::pointer_subject(&expr)
12165                            && p.partial
12166                        {
12167                            let (m, t) = self.walk_path_types(current, &p.steps, depth - 1);
12168                            // `limit 1` of its own caps the computed at one
12169                            // row however many its path crosses.
12170                            let capped = modifiers
12171                                .is_some_and(|m| matches!(&m.limit, Some(Expr::Literal(ast::Literal::Int(1)))));
12172                            multi = multi || (m && !capped);
12173                            match t {
12174                                Some(t) => current = t,
12175                                None => return (multi, None),
12176                            }
12177                            continue;
12178                        }
12179                        if let Some((fc, _)) = Self::function_subject(&expr)
12180                            && let Some(fd) = self.resolve_object_fn(fc)
12181                        {
12182                            multi = multi || fd.return_is_set;
12183                            match self.resolve_type(&fd.return_pg_type) {
12184                                Ok(t) => current = t,
12185                                Err(_) => return (multi, None),
12186                            }
12187                            continue;
12188                        }
12189                    }
12190                    // A property (or something unresolvable): the walk ends.
12191                    return (multi, None);
12192                }
12193                _ => return (multi, None),
12194            }
12195        }
12196        (multi, Some(current))
12197    }
12198
12199    /// Compile a relative path that the step-by-step expression rules can't
12200    /// resolve on their own — deeper than two steps, a computed pointer on a
12201    /// linked type, anything following a backlink — as a single correlated
12202    /// subquery over the whole traversal.
12203    ///
12204    /// `compile_path_select` already implements the general case (forward
12205    /// links, multi-links, backlinks, junction-backed links, type
12206    /// intersections, nested tuple fields, "did you mean" on a typo), so the
12207    /// work here is only to root the path at the enclosing type and
12208    /// correlate it back to the enclosing row by primary key.
12209    ///
12210    /// A leading type intersection is rooted at the *intersected* type
12211    /// instead: `[is Concrete].col` has to read from Concrete's own table,
12212    /// which shares the interface row's id.
12213    fn compile_partial_path_as_subquery(
12214        &mut self,
12215        p: &ast::Path,
12216        td: &TypeDescriptor,
12217        alias: &str,
12218    ) -> Result<IrExpr, PyQLError> {
12219        let (root_name, rest) = match p.steps.first() {
12220            Some(ast::PathStep::TypeIntersection(tr)) => {
12221                let name = match &tr.module {
12222                    Some(m) => format!("{}::{}", m, tr.name),
12223                    None => tr.name.clone(),
12224                };
12225                (name, &p.steps[1..])
12226            }
12227            _ => (format!("{}::{}", td.module, td.name), &p.steps[..]),
12228        };
12229        let root_td = self.resolve_type(&root_name)?;
12230        let multi = self.path_crosses_multi(root_td, rest);
12231
12232        let mut steps = vec![ast::PathStep::Name(root_name)];
12233        steps.extend(rest.iter().cloned());
12234        let full_path = ast::Path { steps, partial: false };
12235        let synthetic = ast::SelectStmt {
12236            result: Expr::Path(full_path.clone()),
12237            filter: None,
12238            order_by: vec![],
12239            offset: None,
12240            limit: None,
12241            lock: None,
12242        };
12243        let mut ps = self.compile_path_select(&synthetic, &full_path, &[], false)?;
12244        Self::correlate_path_select(&mut ps, alias);
12245        if multi && matches!(ps.result, IrPathResult::Scalar(..)) {
12246            Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
12247                ps,
12248            )))))
12249        } else {
12250            Ok(IrExpr::PathSubquery(Box::new(ps)))
12251        }
12252    }
12253
12254    /// A sub-select written relative to the enclosing object (`(select
12255    /// .emails filter .primary)`, with or without a shape), as a path select
12256    /// rooted at that object and correlated back to its row.
12257    ///
12258    /// Several places compile a sub-select with no context and so lose the
12259    /// object a relative path hangs off -- it then resolves in free context
12260    /// and reports the pointer as unknown. `None` when the statement is not
12261    /// of that shape, so the caller can carry on as before.
12262    fn relative_subselect(
12263        &mut self,
12264        stmt: &Stmt,
12265        ctx: Option<(&TypeDescriptor, &str)>,
12266    ) -> Result<Option<IrPathSelect>, PyQLError> {
12267        let (Some((td, alias)), Stmt::Select(sel)) = (ctx, stmt) else {
12268            return Ok(None);
12269        };
12270        let (path, shape): (&ast::Path, &[ShapeElement]) = match &sel.result {
12271            Expr::Path(p) if p.partial => (p, &[]),
12272            Expr::Shape(sh) => match sh.expr.as_ref() {
12273                Some(Expr::Path(p)) if p.partial => (p, sh.elements.as_slice()),
12274                _ => return Ok(None),
12275            },
12276            _ => return Ok(None),
12277        };
12278        let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
12279        steps.extend(path.steps.iter().cloned());
12280        let rooted = ast::Path { steps, partial: false };
12281        let synthetic = ast::SelectStmt {
12282            result: Expr::Path(rooted.clone()),
12283            filter: sel.filter.clone(),
12284            order_by: sel.order_by.clone(),
12285            offset: sel.offset.clone(),
12286            limit: sel.limit.clone(),
12287            lock: None,
12288        };
12289        let mut ps = self.compile_path_select(&synthetic, &rooted, shape, false)?;
12290        Self::correlate_path_select(&mut ps, alias);
12291        Ok(Some(ps))
12292    }
12293
12294    /// Tie a path select's root row to the enclosing row by primary key —
12295    /// what makes a relative path's subquery see only the current object's
12296    /// side of the graph.
12297    fn correlate_path_select(ps: &mut IrPathSelect, outer_alias: &str) {
12298        // A trigger's own row may not be in its table yet (`BEFORE INSERT`):
12299        // the walk starts from the row itself.
12300        if outer_alias == "NEW" || outer_alias == "OLD" {
12301            ps.root.table = format!("@row:{outer_alias}");
12302            ps.root.poly = None;
12303        }
12304        let correlation = IrExpr::BinOp(Box::new(IrBinOp {
12305            left: IrExpr::ColumnRef {
12306                alias: ps.root.alias.clone(),
12307                column: "id".to_string(),
12308                pg_type: "uuid".to_string(),
12309            },
12310            op: ast::BinOpKind::Eq,
12311            right: IrExpr::ColumnRef {
12312                alias: outer_alias.to_string(),
12313                column: "id".to_string(),
12314                pg_type: "uuid".to_string(),
12315            },
12316        }));
12317        ps.filter = Some(match ps.filter.take() {
12318            Some(existing) => IrExpr::BinOp(Box::new(IrBinOp {
12319                left: correlation,
12320                op: ast::BinOpKind::And,
12321                right: existing,
12322            })),
12323            None => correlation,
12324        });
12325    }
12326
12327    /// Names the kind of sub-statement that can't stand in for a value, so
12328    /// the message points at the actual blocker instead of listing every
12329    /// statement keyword.
12330    fn subquery_expr_err(&self, stmt: &Stmt) -> PyQLError {
12331        let what = match stmt {
12332            Stmt::Insert(_) => "an insert",
12333            Stmt::Update(_) => "an update",
12334            Stmt::Delete(_) => "a delete",
12335            Stmt::With(_) => "a `with` block",
12336            Stmt::For(_) => "a `for` loop",
12337            Stmt::Group(_) => "a `group`",
12338            _ => "this sub-statement",
12339        };
12340        self.type_err(&format!(
12341            "{what} cannot stand in for a value — a sub-statement is only valid in expression \
12342             position as a select over a path, e.g. '(select .emails filter .primary limit 1).address'"
12343        ))
12344    }
12345
12346    // ── Expression compilation ────────────────────────────────────────────────────
12347
12348    /// The operands an aggregate reads when its argument is spelled as a set:
12349    /// the elements of a set literal, or the flattened arms of a `union` chain.
12350    /// `min({a, b})` and `min((a union b))` mean the same thing and both read
12351    /// two values, which is the shape `AggOverSet` wants either way. `None` for
12352    /// anything else, leaving the ordinary single-value routes to it.
12353    fn set_operands(expr: &Expr) -> Option<Vec<&Expr>> {
12354        match expr {
12355            // An empty set has no operands to aggregate over, and the row
12356            // source it would emit is not valid SQL -- leave it to the arms
12357            // that give `{}` its own meaning.
12358            Expr::Set(elems) if elems.is_empty() => None,
12359            Expr::Set(elems) => Some(elems.iter().collect()),
12360            Expr::Union(left, right) => {
12361                let mut operands = Self::set_operands(left).unwrap_or_else(|| vec![left.as_ref()]);
12362                operands.extend(Self::set_operands(right).unwrap_or_else(|| vec![right.as_ref()]));
12363                Some(operands)
12364            }
12365            _ => None,
12366        }
12367    }
12368
12369    /// Run `f` with `value_position` set — the expression it compiles stands
12370    /// where a value is expected, so a set-valued `in` keeps its one answer
12371    /// per element instead of collapsing to an EXISTS.
12372    fn in_value_position<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
12373        let previous = std::mem::replace(&mut self.value_position, true);
12374        let result = f(self);
12375        self.value_position = previous;
12376        result
12377    }
12378
12379    /// Run `f` with `value_position` cleared — a FILTER, or any other place
12380    /// that wants a single boolean out of the whole set. A computed pointer's
12381    /// expression may carry a sub-select with a filter of its own, so the flag
12382    /// has to be taken back off on the way down, not just put on at the top.
12383    fn as_condition<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> R {
12384        let previous = std::mem::replace(&mut self.value_position, false);
12385        let result = f(self);
12386        self.value_position = previous;
12387        result
12388    }
12389
12390    /// Single expression compiler for both free and schema-bound contexts.
12391    /// `ctx = Some((td, alias))` when a schema type + SQL alias are in scope
12392    /// (enables `.property`/`.link` resolution via `compile_path`); `ctx =
12393    /// None` for free expressions (set literals, tuples, free objects,
12394    /// scalar function calls not touching a table), via `compile_free_path`
12395    /// for the `Path` variant. Most arms are identical either way and just
12396    /// thread `ctx` through recursive calls; the handful that genuinely
12397    /// diverge (`Path`, `BinOp`, `FunctionCall`, `Set`, `Detached`, `TypeIs`)
12398    /// branch internally on `ctx` — see each arm's own comment.
12399    fn compile_expr_ctx(&mut self, expr: &Expr, ctx: Option<(&TypeDescriptor, &str)>) -> Result<IrExpr, PyQLError> {
12400        match expr {
12401            // The main free/schema divergence — kept genuinely two-branched
12402            // (`compile_path` needs a schema type + alias throughout:
12403            // __type__, absolute-path rewrite, 2-step link/property
12404            // traversal, computed-pointer recursion; `compile_free_path`
12405            // only ever resolves for-vars/CTEs/fn-params/enum members).
12406            // Both share the bare-name lookup via `resolve_name_ref`.
12407            Expr::Path(p) => match ctx {
12408                Some((td, alias)) => self.compile_path(p, td, alias),
12409                None => self.compile_free_path(p),
12410            },
12411
12412            // `(select … limit 1).connector.provider[is Individual].staff` — a
12413            // walk off a sub-select including a step with no expression form.
12414            Expr::PathStepOn { .. } => match self.try_compile_walk_off_subquery(expr, ctx)? {
12415                Some(ir) => Ok(ir),
12416                None => Err(self.type_err(
12417                    "a type intersection, link property or backlink needs a path, a binding \
12418                     or a sub-select to walk off",
12419                )),
12420            },
12421
12422            Expr::Literal(lit) => Ok(IrExpr::Literal(match lit {
12423                Literal::Str(s) => IrLiteral::Str(s.clone()),
12424                Literal::Int(n) => IrLiteral::Int(*n),
12425                Literal::Float(f) => IrLiteral::Float(*f),
12426                Literal::Bool(b) => IrLiteral::Bool(*b),
12427            })),
12428
12429            Expr::Parameter(name) => {
12430                let index = self.param_index(name);
12431                Ok(IrExpr::Param { index })
12432            }
12433
12434            Expr::Global(name) => self.compile_global(name),
12435
12436            Expr::Index { expr: e, index: i } => {
12437                let ir_expr = self.compile_expr_ctx(e, ctx)?;
12438                if self.is_json_expr(e, &ir_expr) {
12439                    return match &**i {
12440                        Expr::Literal(Literal::Str(key)) => Ok(IrExpr::JsonbField {
12441                            expr: Box::new(ir_expr),
12442                            field: key.clone(),
12443                        }),
12444                        Expr::Literal(Literal::Int(n)) if *n >= 0 => Ok(IrExpr::JsonbIndex {
12445                            expr: Box::new(ir_expr),
12446                            index: *n as usize,
12447                        }),
12448                        _ => Err(self.type_err("indexing json needs a literal key or a literal position")),
12449                    };
12450                }
12451                let ir_index = self.compile_expr_ctx(i, ctx)?;
12452                let is_array = is_array_expr(&ir_expr);
12453                Ok(IrExpr::Subscript {
12454                    expr: Box::new(ir_expr),
12455                    index: Box::new(ir_index),
12456                    is_array,
12457                })
12458            }
12459
12460            Expr::Slice {
12461                expr: e,
12462                lower: lo,
12463                upper: hi,
12464            } => {
12465                let ir_expr = self.compile_expr_ctx(e, ctx)?;
12466                let is_array = is_array_expr(&ir_expr);
12467                let ir_lower = lo.as_ref().map(|x| self.compile_expr_ctx(x, ctx)).transpose()?;
12468                let ir_upper = hi.as_ref().map(|x| self.compile_expr_ctx(x, ctx)).transpose()?;
12469                Ok(IrExpr::Slice {
12470                    expr: Box::new(ir_expr),
12471                    lower: ir_lower.map(Box::new),
12472                    upper: ir_upper.map(Box::new),
12473                    is_array,
12474                })
12475            }
12476
12477            Expr::TypeCast(tc) => {
12478                // `<AnyType>{}` — an empty set cast to any type, e.g. clearing
12479                // an optional link (`<Company>{}`) — is always just NULL,
12480                // regardless of what pg_type the cast target would otherwise
12481                // resolve to (a schema object type name isn't a scalar cast
12482                // target at all, so resolve_cast_pg_type couldn't handle it
12483                // below anyway). Generalizes the same bare-`{}`-in-assignment-
12484                // position special case in compile_assignments_inner to any
12485                // expression context.
12486                if matches!(&tc.expr, Expr::Set(elems) if elems.is_empty()) {
12487                    return Ok(IrExpr::Null);
12488                }
12489                if let ast::TypeExpr::Tuple { elements } = &tc.ty
12490                    && let Some(ir) = self.try_compile_tuple_literal_cast_ctx(elements, &tc.expr, ctx)?
12491                {
12492                    let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12493                    let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
12494                    return Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
12495                        expr: ir,
12496                        pg_type,
12497                        tuple_shape,
12498                    })));
12499                }
12500                if let ast::TypeExpr::Array { element } = &tc.ty
12501                    && let Some(ir) = self.try_compile_array_literal_cast_ctx(element, &tc.expr, ctx)?
12502                {
12503                    let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12504                    return Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
12505                        expr: ir,
12506                        pg_type,
12507                        tuple_shape: None,
12508                    })));
12509                }
12510                // `<marketplace::BrandAddon>line.listing.id` — casting a key to
12511                // an object type names the row that key identifies, and in
12512                // expression position (a link's value, a comparison) the row
12513                // *is* its key. There is no scalar pg type to cast to, so
12514                // `resolve_cast_pg_type` below reports the object type as
12515                // unknown.
12516                const STDLIB_MODULES: &[&str] = &["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"];
12517                if let Some((module, name)) = tc.ty.as_named()
12518                    && module.map(|m| !STDLIB_MODULES.contains(&m)).unwrap_or(false)
12519                {
12520                    let qname = match module {
12521                        Some(m) => format!("{m}::{name}"),
12522                        None => name.to_string(),
12523                    };
12524                    if self.resolve_enum(&qname).is_none()
12525                        && self.resolve_scalar(&qname).is_none()
12526                        && self.resolve_named_tuple(&qname).is_none()
12527                        && self.resolve_type(&qname).is_ok()
12528                    {
12529                        return self.compile_expr_ctx(&tc.expr, ctx);
12530                    }
12531                }
12532                // A cast to `json` is an output sink: the text it produces is
12533                // the value, so an `id` nobody asked for would show up in it.
12534                // So the flag is cleared for the whole cast.
12535                let inner = if casts_to_json(&tc.ty) {
12536                    self.without_implicit_id(|this| this.compile_expr_ctx(&tc.expr, ctx))?
12537                } else {
12538                    self.compile_expr_ctx(&tc.expr, ctx)?
12539                };
12540                let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12541
12542                // PostgreSQL has no native jsonb -> {uuid, date/time family,
12543                // interval, array<T>} cast (only jsonb -> {bool, numeric
12544                // family, text} are native as of PG17+) — there's nothing
12545                // generic to defer to the way `to_jsonb(x)` covers every
12546                // scalar in the opposite direction, so extract via `#>>'{}'`
12547                // (the value's raw text form) and cast that, matching what
12548                // `to_json`'s own emitted text already round-trips (ISO 8601
12549                // for datetimes, PG's native interval text, a bare UUID
12550                // string — see `to_jsonb`'s emission for `<json>x`).
12551                if infer_ir_type(&inner) == Some("jsonb") {
12552                    if let ast::TypeExpr::Array { element } = &tc.ty {
12553                        let elem_pg = self.resolve_cast_pg_type(element)?;
12554                        let sql_template =
12555                            format!("ARRAY(SELECT (elem #>> '{{}}')::{elem_pg} FROM jsonb_array_elements($1) AS elem)");
12556                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12557                            return_pg_type: None,
12558                            schema: None,
12559                            name: "jsonb_array_cast".to_string(),
12560                            args: vec![inner],
12561                            sql_template: Some(sql_template),
12562                        }));
12563                    }
12564                    if matches!(
12565                        pg_type.as_str(),
12566                        "uuid" | "timestamptz" | "timestamp" | "date" | "time" | "interval"
12567                    ) {
12568                        let sql_template = format!("(($1 #>> '{{}}'))::{pg_type}");
12569                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12570                            return_pg_type: None,
12571                            schema: None,
12572                            name: "jsonb_scalar_cast".to_string(),
12573                            args: vec![inner],
12574                            sql_template: Some(sql_template),
12575                        }));
12576                    }
12577                }
12578
12579                // The mirror of the `<str>x` routing below: every
12580                // str → date/time cast goes through a function, and those
12581                // are stricter than PostgreSQL's own input parsers, which
12582                // accept `01/16/2026`, a zone-less datetime, or `1 month` as
12583                // a duration. Keyed on the type as written, because
12584                // `duration`, `cal::relative_duration` and
12585                // `cal::date_duration` are all `interval` and each takes a
12586                // different set of units.
12587                if infer_ir_type(&inner) == Some("text")
12588                    && let Some((module, name)) = tc.ty.as_named()
12589                {
12590                    let parser = match (module, name) {
12591                        (None | Some("std"), "datetime") => Some(("std", "to_datetime")),
12592                        (Some("cal"), "local_datetime") => Some(("cal", "to_local_datetime")),
12593                        (Some("cal"), "local_date") => Some(("cal", "to_local_date")),
12594                        (Some("cal"), "local_time") => Some(("cal", "to_local_time")),
12595                        _ => None,
12596                    };
12597                    if let Some((ns, fn_name)) = parser {
12598                        return self.resolve_fn_call(Some(ns), fn_name, vec![inner]);
12599                    }
12600                    // Neither is reachable as a function, only as a cast, so
12601                    // they are internal helpers rather than stdlib
12602                    // overloads (which would also be indistinguishable, both
12603                    // taking one `interval`).
12604                    let helper = match (module, name) {
12605                        (None | Some("std"), "duration") => Some("duration_in"),
12606                        (Some("cal"), "date_duration") => Some("date_duration_in"),
12607                        _ => None,
12608                    };
12609                    if let Some(helper) = helper {
12610                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12611                            return_pg_type: Some("interval".to_string()),
12612                            schema: Some("_pylon".to_string()),
12613                            name: helper.to_string(),
12614                            args: vec![inner],
12615                            sql_template: None,
12616                        }));
12617                    }
12618                }
12619
12620                // `<str>` of a date/time value is `to_str` of it, so that the
12621                // cast and the function cannot disagree. `::text`
12622                // gives a different answer for the same value — a datetime
12623                // renders as `2026-01-16 12:34:56+00` rather than ISO 8601 —
12624                // and the two spellings must not disagree. The rest of the
12625                // family is routed too, where `to_str` *is* `::text`, so the
12626                // rule is about the types rather than about which
12627                // implementations happen to differ today.
12628                if pg_type == "text"
12629                    && matches!(
12630                        infer_ir_type(&inner),
12631                        Some("timestamptz" | "timestamp" | "date" | "time" | "interval")
12632                    )
12633                {
12634                    return self.resolve_fn_call(Some("std"), "to_str", vec![inner]);
12635                }
12636
12637                // PostgreSQL casts a boolean to `int4` and to nothing else, so
12638                // the narrower and wider integers go through it.
12639                let inner = match (pg_type.as_str(), infer_ir_type(&inner)) {
12640                    ("int2" | "int8", Some("boolean")) => IrExpr::TypeCast(Box::new(IrTypeCast {
12641                        expr: inner,
12642                        pg_type: "int4".to_string(),
12643                        tuple_shape: None,
12644                    })),
12645                    _ => inner,
12646                };
12647
12648                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
12649                Ok(self.tuple_cast(inner, pg_type, tuple_shape))
12650            }
12651
12652            // The same coalesce with no shape after it — `(… ?? …)` standing
12653            // for the ids of the set it reaches. SQL's own COALESCE, which an
12654            // ordinary binary operator compiles to, takes one value per side,
12655            // so a walk reaching many rows has to be read as a set instead.
12656            Expr::BinOp(b)
12657                if b.op == ast::BinOpKind::Coalesce
12658                    && ctx.is_some()
12659                    && Self::coalesce_of_relative_paths(expr).is_some_and(|operands| {
12660                        let td = ctx.expect("checked").0;
12661                        self.relative_paths_reach_many(td, &operands)
12662                            && operands.iter().all(|path| self.path_lands_on_objects(td, path))
12663                    }) =>
12664            {
12665                let (td, alias) = ctx.expect("checked by the guard");
12666                let operands = Self::coalesce_of_relative_paths(expr).expect("checked by the guard");
12667                let branches = self.coalesce_path_branches(td, alias, &operands, &[])?;
12668                Ok(IrExpr::ObjectPathUnion {
12669                    branches,
12670                    limit: None,
12671                    multi: true,
12672                })
12673            }
12674
12675            Expr::BinOp(b) => {
12676                // Only a condition can become an EXISTS — `.<pins.updated_at ??
12677                // .<pins.created_at` is a set of values, not a test on each pin.
12678                let yields_values = matches!(
12679                    b.op,
12680                    ast::BinOpKind::Add
12681                        | ast::BinOpKind::Sub
12682                        | ast::BinOpKind::Mul
12683                        | ast::BinOpKind::Div
12684                        | ast::BinOpKind::FloorDiv
12685                        | ast::BinOpKind::Mod
12686                        | ast::BinOpKind::Pow
12687                        | ast::BinOpKind::Coalesce
12688                        | ast::BinOpKind::Concat
12689                );
12690                // `addons := .required_addons in licensed_addons` — `in` is
12691                // asked once per element of the set on its left, so where a
12692                // value is wanted it answers with a set of booleans, not with
12693                // the single "any of them" the EXISTS below would give. That
12694                // one is still what a FILTER means, and `all(…)`/`any(…)` are
12695                // how a query asks for it here.
12696                if let Some(elementwise) = self.try_elementwise_in(b, ctx)? {
12697                    return Ok(elementwise);
12698                }
12699                if let Some((td, alias)) = ctx
12700                    && !yields_values
12701                {
12702                    if let Some(exists) = self.try_backlink_exists(b, td, alias)? {
12703                        return Ok(exists);
12704                    }
12705                    if let Some(exists) = self.try_multilink_exists(b, td, alias)? {
12706                        return Ok(exists);
12707                    }
12708                }
12709                // `x in {a, b, c}` / `x not in {a, b, c}`: a set *literal*
12710                // specifically on the right of in/not-in compiles to a
12711                // Postgres array, not through the generic Set-literal path
12712                // below (which hard-errors on any bare set literal in
12713                // expression position) — In/NotIn's own SQL emission
12714                // (`= ANY(...)`/`<> ALL(...)`, sql/mod.rs) already expects
12715                // an array-typed right operand, so this is the one
12716                // expression position a set literal is actually meaningful
12717                // in, in either schema-bound or free context.
12718                if matches!(b.op, ast::BinOpKind::In | ast::BinOpKind::NotIn)
12719                    && let Expr::Set(elems) = &b.right
12720                {
12721                    let left = self.compile_expr_ctx(&b.left, ctx)?;
12722                    let items = elems
12723                        .iter()
12724                        .map(|e| self.compile_expr_ctx(e, ctx))
12725                        .collect::<Result<Vec<_>, _>>()?;
12726                    let right = IrExpr::Array(items);
12727                    return Ok(IrExpr::BinOp(Box::new(IrBinOp {
12728                        left,
12729                        op: b.op.clone(),
12730                        right,
12731                    })));
12732                }
12733                // `.account = account_of_transaction()` — comparing objects
12734                // compares identity, so an object-returning function on either
12735                // side stands for the id of the row it returns. Left whole it
12736                // is a function call with nowhere to go but the subject of a
12737                // select.
12738                if matches!(b.op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) {
12739                    let id = ["id".to_string()];
12740                    let left_id = match &b.left {
12741                        Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &id, None, ctx)?,
12742                        _ => None,
12743                    };
12744                    let right_id = match &b.right {
12745                        Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &id, None, ctx)?,
12746                        _ => None,
12747                    };
12748                    if left_id.is_some() || right_id.is_some() {
12749                        let left = match left_id {
12750                            Some(e) => e,
12751                            None => self.compile_expr_ctx(&b.left, ctx)?,
12752                        };
12753                        let right = match right_id {
12754                            Some(e) => e,
12755                            None => self.compile_expr_ctx(&b.right, ctx)?,
12756                        };
12757                        return Ok(IrExpr::BinOp(Box::new(IrBinOp {
12758                            left,
12759                            op: b.op.clone(),
12760                            right,
12761                        })));
12762                    }
12763                }
12764                let mut left = self.compile_expr_ctx(&b.left, ctx)?;
12765                let mut right = self.compile_expr_ctx(&b.right, ctx)?;
12766                // An ordering comparison takes one value a side, so a walk
12767                // gathered as an array is read back as a scalar subquery — see
12768                // `set_walk_as_scalar`.
12769                if matches!(
12770                    b.op,
12771                    ast::BinOpKind::Lt | ast::BinOpKind::Le | ast::BinOpKind::Gt | ast::BinOpKind::Ge
12772                ) && yields_array(&left) != yields_array(&right)
12773                {
12774                    left = set_walk_as_scalar(left);
12775                    right = set_walk_as_scalar(right);
12776                }
12777                // `A ?? B` over sets is A unless A is empty. An empty set
12778                // gathered as an array is `{}`, not NULL, so `COALESCE` would
12779                // never fall through to B.
12780                if b.op == ast::BinOpKind::Coalesce && (yields_array(&left) || yields_array(&right)) {
12781                    let as_set = |value: IrExpr| {
12782                        if yields_array(&value) {
12783                            value
12784                        } else {
12785                            IrExpr::FunctionCall(IrFunctionCall {
12786                                return_pg_type: None,
12787                                schema: None,
12788                                name: "array_remove".to_string(),
12789                                args: vec![value],
12790                                sql_template: Some("array_remove(ARRAY[$1], NULL)".to_string()),
12791                            })
12792                        }
12793                    };
12794                    let left = as_set(left);
12795                    let condition = IrExpr::FunctionCall(IrFunctionCall {
12796                        return_pg_type: None,
12797                        schema: None,
12798                        name: "cardinality".to_string(),
12799                        args: vec![left.clone()],
12800                        sql_template: Some("(cardinality($1) > 0)".to_string()),
12801                    });
12802                    return Ok(IrExpr::IfElse(Box::new(IrIfElse {
12803                        condition,
12804                        if_: left,
12805                        else_: as_set(right),
12806                    })));
12807                }
12808                // Comparing a value against a *set* — a path that crosses a
12809                // multi-link or a backlink, which arrives here as an array of
12810                // its elements — holds when any element matches, which is what
12811                // the equality means in PyQL and what `= ANY` says in SQL.
12812                if matches!(b.op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) {
12813                    // A `with` binding of more than one row is the same kind
12814                    // of set, reached through its CTE rather than an array.
12815                    let is_set = |e: &IrExpr| match e {
12816                        IrExpr::ArrayFromSelect(_) => true,
12817                        IrExpr::CteRef { name, .. } => self.multi_row_ctes.contains(name),
12818                        _ => false,
12819                    };
12820                    let flipped = is_set(&left) && !is_set(&right) && !is_array_expr(&right);
12821                    let straight = is_set(&right) && !is_set(&left) && !is_array_expr(&left);
12822                    if flipped || straight {
12823                        let (value, set) = if straight { (left, right) } else { (right, left) };
12824                        let membership = IrExpr::BinOp(Box::new(IrBinOp {
12825                            left: value,
12826                            op: ast::BinOpKind::In,
12827                            right: set,
12828                        }));
12829                        return Ok(if matches!(b.op, ast::BinOpKind::Ne) {
12830                            IrExpr::UnaryOp(Box::new(IrUnaryOp {
12831                                op: ast::UnaryOpKind::Not,
12832                                operand: membership,
12833                            }))
12834                        } else {
12835                            membership
12836                        });
12837                    }
12838                }
12839                if let (Some(lt), Some(rt)) = (infer_ir_type(&left), infer_ir_type(&right))
12840                    && !types_compatible(lt, rt)
12841                    && !datetime_arithmetic_compatible(&b.op, lt, rt)
12842                {
12843                    return Err(PyQLError::Type(PyQLTypeError {
12844                        message: format!(
12845                            "operator '{op}' cannot be applied to operands of type \
12846                                 '{lq}' and '{rq}'",
12847                            op = b.op,
12848                            lq = pg_type_to_pyql(lt),
12849                            rq = pg_type_to_pyql(rt),
12850                        ),
12851                        position: Position { line: 0, col: 0 },
12852                    }));
12853                }
12854                Ok(IrExpr::BinOp(Box::new(IrBinOp {
12855                    left: true_division_operand(&b.op, left, &right),
12856                    op: b.op.clone(),
12857                    right,
12858                })))
12859            }
12860
12861            Expr::FunctionCall(f) => {
12862                // notify(Channel, payload) / notify_raw(name, payload) → pg_notify(...).
12863                // Works in both free and schema-bound context (unlike sequence_next
12864                // below) since a trigger handler's payload needs __new__/__old__,
12865                // which only ever resolves schema-bound.
12866                if (f.module.is_none() || f.module.as_deref() == Some("std")) && f.name == "notify" {
12867                    return self.compile_notify(f, ctx);
12868                }
12869                if (f.module.is_none() || f.module.as_deref() == Some("std")) && f.name == "notify_raw" {
12870                    return self.compile_notify_raw(f, ctx);
12871                }
12872
12873                // sequence_next / sequence_reset: type-ref arg → nextval/setval SQL
12874                // (free-only: schema-bound context never special-cased this).
12875                if ctx.is_none()
12876                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
12877                    && (f.name == "sequence_next" || f.name == "sequence_reset")
12878                {
12879                    return self.compile_sequence_fn(f);
12880                }
12881
12882                // assert_single(subquery) [free-only] / assert_single|assert_exists|
12883                // assert_distinct(subquery) [schema-bound] → _pylon.<fn>(ARRAY(subquery)).
12884                // Preserves the existing asymmetry: free context only ever recognized
12885                // "assert_single" here, not the other two.
12886                let assert_names: &[&str] = if ctx.is_some() {
12887                    &["assert_single", "assert_exists", "assert_distinct"]
12888                } else {
12889                    &["assert_single"]
12890                };
12891                if (f.module.is_none() || f.module.as_deref() == Some("std"))
12892                    && assert_names.contains(&f.name.as_str())
12893                    && !f.args.is_empty()
12894                    && let Expr::SubQuery(inner_stmt) = &f.args[0]
12895                {
12896                    let inner = match self.relative_subselect(inner_stmt, ctx)? {
12897                        Some(ps) => IrArraySource::PathSelect(Box::new(ps)),
12898                        None => self.compile_subquery_to_array_source(inner_stmt)?,
12899                    };
12900                    let fn_pg = match f.name.as_str() {
12901                        "assert_single" => "assert_single",
12902                        "assert_exists" => "assert_exists",
12903                        _ => "assert_distinct",
12904                    };
12905                    let mut args = vec![IrExpr::ArrayFromSelect(Box::new(inner))];
12906                    args.extend(self.assert_message(f, ctx)?);
12907                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
12908                        return_pg_type: None,
12909                        schema: Some("_pylon".to_string()),
12910                        name: fn_pg.to_string(),
12911                        args,
12912                        sql_template: None,
12913                    }));
12914                }
12915
12916                // contains(.multilink.scalar, value) → EXISTS (set-membership
12917                // semantics; schema-bound only, needs td/alias to resolve the
12918                // multilink).
12919                if let Some((td, alias)) = ctx
12920                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
12921                    && f.name == "contains"
12922                    && f.args.len() == 2
12923                    && let Expr::Path(p) = &f.args[0]
12924                    && p.partial
12925                    && p.steps.len() >= 2
12926                    && let ast::PathStep::Name(ln) = &p.steps[0]
12927                    && Self::resolve_multilink(td, ln).is_some()
12928                {
12929                    let synthetic = ast::BinOp {
12930                        left: f.args[0].clone(),
12931                        op: ast::BinOpKind::Eq,
12932                        right: f.args[1].clone(),
12933                    };
12934                    if let Some(exists) = self.try_multilink_exists(&synthetic, td, alias)? {
12935                        return Ok(exists);
12936                    }
12937                }
12938
12939                // Single-arg aggregate over a schema-shaped source →
12940                // AggOverQuery. Schema-bound: count(.multilink) correlates via
12941                // the junction/FK table (`.multilink` isn't an ordinary scalar
12942                // path). Free: count(TypeName) / count((select TypeName ...))
12943                // resolves the arg as a schema type reference or subquery.
12944                if f.args.len() == 1
12945                    && f.kwargs.is_empty()
12946                    && let Some(mut ps) = self.compile_shape_field_select(&f.args[0], ctx)?
12947                {
12948                    let IrPathResult::Scalar(column, _) = ps.result else {
12949                        return Err(self.type_err("a shape's pointer read off a walk is a value"));
12950                    };
12951                    let ns = f.module.as_deref().unwrap_or("std");
12952                    let aggregate =
12953                        crate::stdlib::lookup(ns, &f.name)
12954                            .into_iter()
12955                            .find_map(|d| match &d.impl_strategy {
12956                                crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12957                                    Some(sql_name.to_string())
12958                                }
12959                                _ => None,
12960                            });
12961                    let Some(sql_name) = aggregate else {
12962                        return Err(self.type_err(&format!(
12963                            "'{}' over a shape's pointer read off a walk needs an aggregate",
12964                            f.name
12965                        )));
12966                    };
12967                    // Over no rows SQL's aggregates give NULL where PyQL's
12968                    // give the empty set's own value.
12969                    let over_nothing = aggregate_over_nothing_sql(&f.name);
12970                    // An unnested set cannot sit inside the aggregate call;
12971                    // the walk's rows are aggregated from outside instead.
12972                    let subquery = if matches!(&column, IrExpr::FunctionCall(f) if f.name == "unnest" && f.sql_template.is_none())
12973                    {
12974                        ps.result = IrPathResult::Scalar(column, None);
12975                        IrExpr::FunctionCall(IrFunctionCall {
12976                            return_pg_type: None,
12977                            schema: None,
12978                            name: sql_name.clone(),
12979                            args: vec![IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
12980                                ps,
12981                            ))))],
12982                            sql_template: Some(format!(
12983                                "(SELECT {sql_name}(\"_s\".\"v\") FROM unnest($1) AS \"_s\"(\"v\"))"
12984                            )),
12985                        })
12986                    } else {
12987                        let aggregate = IrExpr::FunctionCall(IrFunctionCall {
12988                            return_pg_type: None,
12989                            schema: None,
12990                            name: sql_name,
12991                            args: vec![column],
12992                            sql_template: None,
12993                        });
12994                        ps.result = IrPathResult::Scalar(aggregate, None);
12995                        IrExpr::PathSubquery(Box::new(ps))
12996                    };
12997                    return Ok(match over_nothing {
12998                        Some(value) => IrExpr::FunctionCall(IrFunctionCall {
12999                            return_pg_type: None,
13000                            schema: None,
13001                            name: "coalesce".to_string(),
13002                            args: vec![subquery, IrExpr::RawSql(value.to_string())],
13003                            sql_template: None,
13004                        }),
13005                        None => subquery,
13006                    });
13007                }
13008                if f.args.len() == 1 {
13009                    let arg = &f.args[0];
13010                    // `count(a intersect b)` counts what the operation yields.
13011                    // Read as an array it would count the array: always one.
13012                    if matches!(arg, Expr::Intersect(_, _) | Expr::Except(_, _)) {
13013                        use crate::stdlib::{ImplStrategy, lookup};
13014                        let ns = f.module.as_deref().unwrap_or("std");
13015                        let best = lookup(ns, &f.name)
13016                            .iter()
13017                            .find(|d| d.params.len() == 1)
13018                            .map(|d| d.impl_strategy.clone());
13019                        if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best
13020                            && let IrExpr::SetOp { op, left, right, .. } = self.compile_expr_ctx(arg, ctx)?
13021                        {
13022                            return Ok(IrExpr::SetOp {
13023                                op,
13024                                left,
13025                                right,
13026                                mode: super::SetOpMode::Aggregate(sql_name.to_string()),
13027                            });
13028                        }
13029                    }
13030                    // `array_agg(array_unpack(.permissions))` — the elements
13031                    // of one row's array. PostgreSQL rejects a set-returning
13032                    // call inside an aggregate, so the unpacking becomes the
13033                    // row source the aggregate reads.
13034                    if let Expr::FunctionCall(unpack) = arg
13035                        && unpack.module.as_deref().unwrap_or("std") == "std"
13036                        && unpack.name == "array_unpack"
13037                        && unpack.kwargs.is_empty()
13038                        && let [array] = unpack.args.as_slice()
13039                        && let Some(sql_name) = crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
13040                            .into_iter()
13041                            .find_map(|d| match &d.impl_strategy {
13042                                crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
13043                                    Some(sql_name.to_string())
13044                                }
13045                                _ => None,
13046                            })
13047                    {
13048                        let values = self.compile_expr_ctx(array, ctx)?;
13049                        // A walk gathered as an array holds one array per row,
13050                        // which `unnest` would only flatten one level; that
13051                        // shape is aggregated where the walk is built instead.
13052                        if !yields_array(&values) {
13053                            let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
13054                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
13055                                return_pg_type: None,
13056                                schema: None,
13057                                name: sql_name.clone(),
13058                                args: vec![values],
13059                                sql_template: Some(format!(
13060                                    "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
13061                                )),
13062                            }));
13063                        }
13064                    }
13065                    // `sum((select T filter …).amount)` — the sub-select's
13066                    // values arrive gathered as an array; the aggregate
13067                    // takes its elements.
13068                    if matches!(arg, Expr::FieldAccess { .. })
13069                        && matches!(Self::peel_field_access_chain(arg).0, Expr::SubQuery(_))
13070                    {
13071                        let ns = f.module.as_deref().unwrap_or("std");
13072                        let aggregate =
13073                            crate::stdlib::lookup(ns, &f.name)
13074                                .into_iter()
13075                                .find_map(|d| match &d.impl_strategy {
13076                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
13077                                        Some(sql_name.to_string())
13078                                    }
13079                                    _ => None,
13080                                });
13081                        if let Some(sql_name) = aggregate {
13082                            let values = self.compile_expr_ctx(arg, ctx)?;
13083                            if matches!(values, IrExpr::ArrayFromSelect(_)) {
13084                                let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
13085                                return Ok(IrExpr::FunctionCall(IrFunctionCall {
13086                                    return_pg_type: None,
13087                                    schema: None,
13088                                    name: sql_name.clone(),
13089                                    args: vec![values],
13090                                    sql_template: Some(format!(
13091                                        "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
13092                                    )),
13093                                }));
13094                            }
13095                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
13096                                return_pg_type: None,
13097                                schema: None,
13098                                name: sql_name,
13099                                args: vec![values],
13100                                sql_template: None,
13101                            }));
13102                        }
13103                    }
13104                    // `max(.<pins.updated_at ?? .<pins.created_at)` — the
13105                    // coalesce picks one whole set, gathered as an array, and
13106                    // the aggregate takes its elements.
13107                    if let Expr::BinOp(b) = arg
13108                        && b.op == ast::BinOpKind::Coalesce
13109                    {
13110                        let values = self.compile_expr_ctx(arg, ctx)?;
13111                        let ns = f.module.as_deref().unwrap_or("std");
13112                        let aggregate =
13113                            crate::stdlib::lookup(ns, &f.name)
13114                                .into_iter()
13115                                .find_map(|d| match &d.impl_strategy {
13116                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
13117                                        Some(sql_name.to_string())
13118                                    }
13119                                    _ => None,
13120                                });
13121                        if let Some(sql_name) = aggregate
13122                            && yields_array(&values)
13123                        {
13124                            let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
13125                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
13126                                return_pg_type: None,
13127                                schema: None,
13128                                name: sql_name.clone(),
13129                                args: vec![values],
13130                                sql_template: Some(format!(
13131                                    "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
13132                                )),
13133                            }));
13134                        }
13135                        return self.resolve_fn_call(f.module.as_deref(), &f.name, vec![values]);
13136                    }
13137                    // `count(memberships)` — a binding names a set, so the
13138                    // aggregate runs over its rows. Compiled as an ordinary
13139                    // expression it becomes a scalar subquery over the CTE,
13140                    // which any binding of more than one row aborts on.
13141                    if let Some(name) = self.resolve_cte_name(arg) {
13142                        use crate::stdlib::{ImplStrategy, lookup};
13143                        let ns = f.module.as_deref().unwrap_or("std");
13144                        let overloads = lookup(ns, &f.name);
13145                        let best = overloads
13146                            .iter()
13147                            .find(|d| d.params.len() == 1)
13148                            .or_else(|| overloads.first());
13149                        if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best.map(|d| &d.impl_strategy)
13150                            && best.is_some_and(|d| d.params.first().is_some_and(|p| p.ty.is_set()))
13151                        {
13152                            let object = self.cte_types.get(name).is_some_and(|bound| bound.contains("::"));
13153                            return Ok(IrExpr::AggOverCte {
13154                                fn_name: sql_name.to_string(),
13155                                cte: name.to_string(),
13156                                column: (!object).then(|| "v".to_string()),
13157                            });
13158                        }
13159                    }
13160                    if let Some((td, alias)) = ctx {
13161                        // `count((select .<promotion filter …))` — compiled as an
13162                        // expression the sub-select is a scalar subquery, and a
13163                        // bare aggregate around it aggregates the enclosing select.
13164                        if let Expr::SubQuery(stmt) = arg
13165                            && f.name != "array_agg"
13166                            && let Some(sql_name) = crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
13167                                .into_iter()
13168                                .find_map(|d| match &d.impl_strategy {
13169                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
13170                                        Some(sql_name.to_string())
13171                                    }
13172                                    _ => None,
13173                                })
13174                            && let Some(mut ps) = self.relative_subselect(stmt, ctx)?
13175                        {
13176                            // An object row is an anonymous record, which `unnest` cannot expand.
13177                            if let IrPathResult::Object { alias, .. } = &ps.result {
13178                                let id = IrExpr::ColumnRef {
13179                                    alias: alias.clone(),
13180                                    column: "id".to_string(),
13181                                    pg_type: "uuid".to_string(),
13182                                };
13183                                ps.result = IrPathResult::Scalar(id, None);
13184                            }
13185                            let aggregate = IrExpr::FunctionCall(IrFunctionCall {
13186                                return_pg_type: None,
13187                                schema: None,
13188                                name: sql_name.clone(),
13189                                args: vec![IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
13190                                    ps,
13191                                ))))],
13192                                sql_template: Some(format!(
13193                                    "(SELECT {sql_name}(\"_s\".\"v\") FROM unnest($1) AS \"_s\"(\"v\"))"
13194                                )),
13195                            });
13196                            return Ok(aggregate_over_nothing(&f.name, aggregate));
13197                        }
13198                        // `max(items.created_at)` inside a FILTER: the
13199                        // aggregate takes the whole set the path names, so it
13200                        // belongs inside a subquery over that set — a bare
13201                        // `max(...)` here is an aggregate in a WHERE clause,
13202                        // which Postgres rejects outright.
13203                        if let Expr::Path(p) = arg
13204                            && !p.partial
13205                            && p.steps.len() > 1
13206                            && let Some(root) = self.find_path_root_in_expr(arg)
13207                        {
13208                            let synthetic = ast::SelectStmt {
13209                                result: Expr::FunctionCall(f.clone()),
13210                                filter: None,
13211                                order_by: vec![],
13212                                offset: None,
13213                                limit: None,
13214                                lock: None,
13215                            };
13216                            let ps = self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
13217                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
13218                        }
13219                        // `sum(.applied_promotions.amount)` — the aggregate
13220                        // takes the set the walk lands on, so the walk becomes
13221                        // the row source of a correlated subquery the aggregate
13222                        // sits inside. Compiled as an expression the walk stands
13223                        // for the array of its elements, leaving the aggregate
13224                        // with an array argument and no overload to match.
13225                        // A backlink is as many-valued: `count(.<revisions)`.
13226                        // So is a computed selecting a set
13227                        // (`Computed[MultiLink[Member], '.staff']`) — one step,
13228                        // but the junction-only count below cannot see through
13229                        // it to a junction table, so it walks the path the
13230                        // computed splices into instead.
13231                        if let Expr::Path(p) = arg
13232                            && p.partial
13233                            && (p.steps.len() > 1 && self.path_crosses_multi(td, &p.steps)
13234                                || matches!(p.steps.first(), Some(ast::PathStep::Backlink(_)))
13235                                || matches!(p.steps.as_slice(), [ast::PathStep::Name(name)]
13236                                    if Self::resolve_multilink(td, name).is_none()
13237                                        && self.path_crosses_multi(td, &p.steps)))
13238                        {
13239                            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
13240                            steps.extend(p.steps.iter().cloned());
13241                            let rooted = ast::Path { steps, partial: false };
13242                            let mut call = f.clone();
13243                            call.args[0] = Expr::Path(rooted);
13244                            let synthetic = ast::SelectStmt {
13245                                result: Expr::FunctionCall(call),
13246                                filter: None,
13247                                order_by: vec![],
13248                                offset: None,
13249                                limit: None,
13250                                lock: None,
13251                            };
13252                            let root = format!("{}::{}", td.module, td.name);
13253                            let mut ps =
13254                                self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
13255                            Self::correlate_path_select(&mut ps, alias);
13256                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
13257                        }
13258                        if let Expr::Path(p) = arg
13259                            && p.partial
13260                            && p.steps.len() == 1
13261                            && let ast::PathStep::Name(ml_name) = &p.steps[0]
13262                            && Self::resolve_multilink(td, ml_name).is_some()
13263                        {
13264                            use crate::stdlib::{ImplStrategy, lookup};
13265                            let ns = f.module.as_deref().unwrap_or("std");
13266                            let overloads = lookup(ns, &f.name);
13267                            let best = overloads
13268                                .iter()
13269                                .find(|d| d.params.len() == 1)
13270                                .or_else(|| overloads.first());
13271                            if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best.map(|d| &d.impl_strategy) {
13272                                let fn_name = sql_name.to_string();
13273                                let inner = self.multilink_correlation_select(ml_name, td, alias)?;
13274                                return Ok(IrExpr::AggOverQuery {
13275                                    fn_name,
13276                                    inner: Box::new(inner),
13277                                });
13278                            }
13279                        }
13280                    } else {
13281                        // `array_agg(a.sessions.id)` with no type in scope: the
13282                        // aggregate takes the whole set the path names, so it
13283                        // belongs in a subquery over that traversal. Compiled as
13284                        // an expression instead, a multi-valued path stands for
13285                        // the array of its elements, and aggregating *that*
13286                        // nests it one level deep — the same reason the
13287                        // schema-bound branch above routes this way.
13288                        if let Expr::Path(p) = arg
13289                            && !p.partial
13290                            && p.steps.len() > 1
13291                            && let Some(root) = self.find_path_root_in_expr(arg)
13292                        {
13293                            let synthetic = ast::SelectStmt {
13294                                result: Expr::FunctionCall(f.clone()),
13295                                filter: None,
13296                                order_by: vec![],
13297                                offset: None,
13298                                limit: None,
13299                                lock: None,
13300                            };
13301                            let ps = self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
13302                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
13303                        }
13304                        // `count((delete AuthLink filter .expired))` — the rows
13305                        // a mutation touched are countable like any other set.
13306                        // The mutation becomes its own data-modifying CTE, which
13307                        // Postgres runs regardless, and the aggregate reads it.
13308                        if let Expr::SubQuery(stmt) = arg
13309                            && matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
13310                        {
13311                            use crate::stdlib::{ImplStrategy, lookup};
13312                            let ns = f.module.as_deref().unwrap_or("std");
13313                            let overloads = lookup(ns, &f.name);
13314                            let best = overloads
13315                                .iter()
13316                                .find(|d| d.params.len() == 1)
13317                                .or_else(|| overloads.first())
13318                                .cloned();
13319                            if let Some(d) = best
13320                                && let ImplStrategy::SqlBuiltin(sql_name) = &d.impl_strategy
13321                            {
13322                                let fn_name = sql_name.to_string();
13323                                let (cte_name, type_name) = self.hoist_dml_as_cte(stmt.as_ref())?;
13324                                let td = self.resolve_type(&type_name)?;
13325                                let source = IrSource {
13326                                    poly: None,
13327                                    type_name: format!("{}::{}", td.module, td.name),
13328                                    table: self.cte_source_table(&cte_name),
13329                                    alias: self.fresh_alias(),
13330                                };
13331                                let inner = IrSelect::schema_bound(source, Self::pk_returning(td), None);
13332                                return Ok(IrExpr::AggOverQuery {
13333                                    fn_name,
13334                                    inner: Box::new(inner),
13335                                });
13336                            }
13337                        }
13338                        let inner_sel: Option<ast::SelectStmt> = match arg {
13339                            Expr::Path(p) if !p.partial => {
13340                                // Resolve as a schema type if it matches a known type (not enum).
13341                                let qname = p
13342                                    .steps
13343                                    .iter()
13344                                    .filter_map(|s| {
13345                                        if let ast::PathStep::Name(n) = s {
13346                                            Some(n.as_str())
13347                                        } else {
13348                                            None
13349                                        }
13350                                    })
13351                                    .collect::<Vec<_>>()
13352                                    .join("::");
13353                                let is_schema_type = self
13354                                    .schema
13355                                    .types
13356                                    .iter()
13357                                    .any(|t| format!("{}::{}", t.module, t.name) == qname || t.name == qname);
13358                                if is_schema_type {
13359                                    Some(ast::SelectStmt {
13360                                        result: arg.clone(),
13361                                        filter: None,
13362                                        order_by: vec![],
13363                                        offset: None,
13364                                        limit: None,
13365                                        lock: None,
13366                                    })
13367                                } else {
13368                                    None
13369                                }
13370                            }
13371                            Expr::SubQuery(stmt) => {
13372                                if let ast::Stmt::Select(inner) = stmt.as_ref() {
13373                                    Some(inner.clone())
13374                                } else {
13375                                    None
13376                                }
13377                            }
13378                            _ => None,
13379                        };
13380                        if let Some(sel) = inner_sel {
13381                            use crate::stdlib::{ImplStrategy, lookup};
13382                            let ns = f.module.as_deref().unwrap_or("std");
13383                            let overloads = lookup(ns, &f.name);
13384                            let best = overloads
13385                                .iter()
13386                                .find(|d| d.params.len() == 1)
13387                                .or_else(|| overloads.first());
13388                            // `array_agg((select Type.prop))` — the sub-select
13389                            // walks to a scalar, which has no type name to be a
13390                            // schema select's subject. It is the same thing as
13391                            // `array_agg(Type.prop)` with the select's own
13392                            // modifiers on the walk, which is what
13393                            // `compile_expr_as_path_select` already builds.
13394                            // A `distinct` inside the sub-select means the
13395                            // same as one on the argument, and that is where the
13396                            // aggregate can act on it.
13397                            let (inner_result, inner_distinct) = match &sel.result {
13398                                Expr::UnaryOp(u) if matches!(u.op, ast::UnaryOpKind::Distinct) => (&u.operand, true),
13399                                other => (other, false),
13400                            };
13401                            if let Expr::Path(inner_path) = inner_result
13402                                && !inner_path.partial
13403                                && inner_path.steps.len() > 1
13404                                && let Some(ast::PathStep::Name(root)) = inner_path.steps.first()
13405                                && let Ok(root_td) = self.resolve_path_root(root)
13406                                && self
13407                                    .walk_path_types(root_td, &inner_path.steps[1..], MAX_COMPUTED_SPLICES)
13408                                    .1
13409                                    .is_none()
13410                            {
13411                                let root = root.clone();
13412                                let mut inner_call = f.clone();
13413                                inner_call.args = vec![if inner_distinct {
13414                                    Expr::UnaryOp(Box::new(ast::UnaryOp {
13415                                        op: ast::UnaryOpKind::Distinct,
13416                                        operand: inner_result.clone(),
13417                                    }))
13418                                } else {
13419                                    inner_result.clone()
13420                                }];
13421                                let call = ast::SelectStmt {
13422                                    result: Expr::FunctionCall(inner_call),
13423                                    ..sel.clone()
13424                                };
13425                                let ps = self.compile_expr_as_path_select(&call, &call.result, &root, false)?;
13426                                return Ok(IrExpr::PathSubquery(Box::new(ps)));
13427                            }
13428                            if let Some(d) = best
13429                                && let ImplStrategy::SqlBuiltin(sql_name) = &d.impl_strategy
13430                            {
13431                                let fn_name = sql_name.to_string();
13432                                let inner_ir = self.compile_select(&sel, &sel.result, false)?;
13433                                // `array_agg((select brands { * } filter …))` —
13434                                // an array of the objects, rows and shape
13435                                // whole; `array_agg(*)` is not an aggregate
13436                                // Postgres has.
13437                                if fn_name == "array_agg"
13438                                    && matches!(inner_ir.rows.as_slice(), [IrRowSource::Bound { .. }])
13439                                {
13440                                    return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(
13441                                        Box::new(inner_ir),
13442                                    ))));
13443                                }
13444                                return Ok(IrExpr::AggOverQuery {
13445                                    fn_name,
13446                                    inner: Box::new(inner_ir),
13447                                });
13448                            }
13449                        }
13450                    }
13451                }
13452
13453                // A set-valued argument → AggOverSet. Schema-bound as much as
13454                // free: an aggregate's argument is a set wherever it is written,
13455                // and each operand is compiled with `ctx` so `.started_at` and
13456                // the like still resolve. The set/shape arm below used to reject
13457                // this outright once a schema was in scope, so `set { x :=
13458                // min({cap, .y}) }` failed where the same call at the top level
13459                // compiled.
13460                if let Some(operands) = f.args.iter().find_map(Self::set_operands) {
13461                    use crate::stdlib::{ImplStrategy, lookup};
13462                    let ns = f.module.as_deref().unwrap_or("std");
13463                    let overloads = lookup(ns, &f.name);
13464                    let best = overloads
13465                        .iter()
13466                        .find(|d| d.params.len() == f.args.len())
13467                        .or_else(|| overloads.first());
13468                    let (schema, fn_name) = match best.map(|d| &d.impl_strategy) {
13469                        Some(ImplStrategy::SqlBuiltin(sql_name)) => (None, sql_name.to_string()),
13470                        Some(_) => {
13471                            return Err(self.type_err(&format!(
13472                                "function '{}::{}' cannot be called with a set literal in this context",
13473                                ns, f.name
13474                            )));
13475                        }
13476                        None => return Err(self.type_err(&format!("function '{}::{}' does not exist", ns, f.name))),
13477                    };
13478                    let elems = operands
13479                        .iter()
13480                        .map(|e| self.compile_expr_ctx(e, ctx))
13481                        .collect::<Result<Vec<_>, _>>()?;
13482                    return Ok(IrExpr::AggOverSet { fn_name, schema, elems });
13483                }
13484
13485                // `any(...)`/`all(...)` say outright that the argument is a
13486                // set, so the comparison inside must not also be warned about.
13487                // The argument is compiled before `resolve_fn_call` ever sees
13488                // which function this is, which is why the guard goes here.
13489                // `any(.grants.account = a and not .grants.deleted)` — every
13490                // `.grants` in it is the same grant, so the condition holds
13491                // per grant; compiled term by term, each walked the link on
13492                // its own.
13493                if let Some((td, _)) = ctx
13494                    && f.module.as_deref().unwrap_or("std") == "std"
13495                    && matches!(f.name.as_str(), "any" | "all")
13496                    && f.kwargs.is_empty()
13497                    && let [condition] = f.args.as_slice()
13498                    && let Some(per_element) = per_element_of_one_multilink(condition, td)
13499                {
13500                    let (link, element_condition) = per_element;
13501                    let condition = if f.name == "all" {
13502                        Expr::UnaryOp(Box::new(ast::UnaryOp {
13503                            op: ast::UnaryOpKind::Not,
13504                            operand: element_condition,
13505                        }))
13506                    } else {
13507                        element_condition
13508                    };
13509                    let exists = Expr::UnaryOp(Box::new(ast::UnaryOp {
13510                        op: ast::UnaryOpKind::Exists,
13511                        operand: Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
13512                            result: Expr::Path(ast::Path {
13513                                steps: link,
13514                                partial: true,
13515                            }),
13516                            filter: Some(condition),
13517                            order_by: vec![],
13518                            offset: None,
13519                            limit: None,
13520                            lock: None,
13521                        }))),
13522                    }));
13523                    let exists = if f.name == "all" {
13524                        Expr::UnaryOp(Box::new(ast::UnaryOp {
13525                            op: ast::UnaryOpKind::Not,
13526                            operand: exists,
13527                        }))
13528                    } else {
13529                        exists
13530                    };
13531                    return self.compile_expr_ctx(&exists, ctx);
13532                }
13533                // `any(.<company.posts is Post)` — one answer per object the
13534                // path reaches, gathered as an array, so aggregate its elements.
13535                if f.module.as_deref().unwrap_or("std") == "std"
13536                    && matches!(f.name.as_str(), "any" | "all")
13537                    && f.kwargs.is_empty()
13538                    && let [Expr::TypeIs { expr: subject, ty }] = f.args.as_slice()
13539                    && let answers @ IrExpr::ArrayFromSelect(_) = self.compile_type_is_set(subject, ty, ctx)?
13540                {
13541                    let (aggregate, over_nothing) = if f.name == "all" {
13542                        ("bool_and", "true")
13543                    } else {
13544                        ("bool_or", "false")
13545                    };
13546                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
13547                        return_pg_type: None,
13548                        schema: None,
13549                        name: aggregate.to_string(),
13550                        args: vec![answers],
13551                        sql_template: Some(format!(
13552                            "(SELECT coalesce({aggregate}(\"_unnested\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_unnested\"(\"v\"))"
13553                        )),
13554                    }));
13555                }
13556                // `all(array_unpack($tags) in .tags.label)` — the comparison is
13557                // made once per element, then aggregated. Inlined as `unnest()`
13558                // the array is a set-returning call, which Postgres refuses in a
13559                // WHERE; over no elements `all` is true and `any` false.
13560                if f.module.as_deref().unwrap_or("std") == "std"
13561                    && matches!(f.name.as_str(), "any" | "all")
13562                    && f.kwargs.is_empty()
13563                    && let [Expr::BinOp(comparison)] = f.args.as_slice()
13564                    && let Expr::FunctionCall(unpack) = &comparison.left
13565                    && unpack.module.as_deref().unwrap_or("std") == "std"
13566                    && unpack.name == "array_unpack"
13567                    && let [array] = unpack.args.as_slice()
13568                {
13569                    let array = self.compile_expr_ctx(array, ctx)?;
13570                    let element = "<unnested element>".to_string();
13571                    self.inline_bindings
13572                        .insert(element.clone(), IrExpr::RawSql("\"_unnested\".\"v\"".to_string()));
13573                    let per_element = self.compile_expr_ctx(
13574                        &Expr::BinOp(Box::new(ast::BinOp {
13575                            left: Expr::Path(ast::Path::absolute(element.clone())),
13576                            op: comparison.op.clone(),
13577                            right: comparison.right.clone(),
13578                        })),
13579                        ctx,
13580                    );
13581                    self.inline_bindings.remove(&element);
13582                    let (aggregate, over_nothing) = if f.name == "all" {
13583                        ("bool_and", "true")
13584                    } else {
13585                        ("bool_or", "false")
13586                    };
13587                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
13588                        return_pg_type: None,
13589                        schema: None,
13590                        name: aggregate.to_string(),
13591                        args: vec![per_element?, array],
13592                        sql_template: Some(format!(
13593                            "(SELECT coalesce({aggregate}($1), {over_nothing}) FROM unnest($2) AS \"_unnested\"(\"v\"))"
13594                        )),
13595                    }));
13596                }
13597                if let Some(args) = self.compile_named_call_args(f, ctx)? {
13598                    return self.resolve_fn_call(f.module.as_deref(), &f.name, args);
13599                }
13600                let is_explicit_set = f.module.as_deref().unwrap_or("std") == "std"
13601                    && matches!(f.name.as_str(), "any" | "all")
13602                    && f.args.len() == 1;
13603                let is_universal = is_explicit_set && f.name == "all";
13604                if is_explicit_set {
13605                    self.explicit_set_depth += 1;
13606                }
13607                if is_universal {
13608                    self.universal_set_depth += 1;
13609                }
13610                let args = f
13611                    .args
13612                    .iter()
13613                    .map(|a| self.compile_expr_ctx(a, ctx))
13614                    .collect::<Result<Vec<_>, _>>();
13615                if is_universal {
13616                    self.universal_set_depth -= 1;
13617                }
13618                if is_explicit_set {
13619                    self.explicit_set_depth -= 1;
13620                }
13621                let args = args?;
13622                // `all(.required_addons in licensed_addons)` — the argument is
13623                // a set of booleans, gathered as an array the way every other
13624                // multi-valued expression is, and the quantifier ranges over
13625                // its elements. Handed the array itself, `bool_and` would
13626                // aggregate the enclosing query's rows instead.
13627                if is_explicit_set
13628                    && let [values] = args.as_slice()
13629                    && yields_array(values)
13630                {
13631                    let (aggregate, over_nothing) = if f.name == "all" {
13632                        ("bool_and", "true")
13633                    } else {
13634                        ("bool_or", "false")
13635                    };
13636                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
13637                        return_pg_type: None,
13638                        schema: None,
13639                        name: aggregate.to_string(),
13640                        args: vec![values.clone()],
13641                        sql_template: Some(format!(
13642                            "(SELECT coalesce({aggregate}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
13643                        )),
13644                    }));
13645                }
13646                self.resolve_fn_call(f.module.as_deref(), &f.name, args)
13647            }
13648
13649            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Exists => self.compile_exists_ctx(&u.operand, ctx),
13650
13651            // `distinct` in expression position: a set-producing operand
13652            // dedupes its own rows, and a single value — an array, a global, a
13653            // column — is already distinct, so it stands for itself. (A
13654            // statement-level `select distinct …` never reaches here; it is
13655            // unwrapped into the select's own `distinct` flag.)
13656            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => {
13657                let operand = self.compile_expr_ctx(&u.operand, ctx)?;
13658                Ok(match operand {
13659                    IrExpr::PathSubquery(mut ps) => {
13660                        ps.distinct = true;
13661                        IrExpr::PathSubquery(ps)
13662                    }
13663                    IrExpr::ArrayFromSelect(src) => IrExpr::ArrayFromSelect(Box::new(match *src {
13664                        IrArraySource::Select(mut sel) => {
13665                            sel.distinct = true;
13666                            IrArraySource::Select(sel)
13667                        }
13668                        IrArraySource::PathSelect(mut ps) => {
13669                            ps.distinct = true;
13670                            IrArraySource::PathSelect(ps)
13671                        }
13672                        other => other,
13673                    })),
13674                    other => other,
13675                })
13676            }
13677
13678            Expr::UnaryOp(u) => {
13679                let operand = self.compile_expr_ctx(&u.operand, ctx)?;
13680                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
13681                    op: u.op.clone(),
13682                    operand,
13683                })))
13684            }
13685
13686            Expr::IfElse(ie) => {
13687                let condition = self.compile_expr_ctx(&ie.condition, ctx)?;
13688                let if_ = self.compile_expr_ctx(&ie.if_expr, ctx)?;
13689                let else_ = self.compile_expr_ctx(&ie.else_expr, ctx)?;
13690                Ok(IrExpr::IfElse(Box::new(IrIfElse { condition, if_, else_ })))
13691            }
13692
13693            Expr::Array(elems) => {
13694                // Each element is one value: `[<uuid>$p, snippet.id]` is two
13695                // uuids, not a uuid beside an array of them — PostgreSQL
13696                // refuses to mix the two in one literal.
13697                let items = elems
13698                    .iter()
13699                    .map(|e| self.compile_expr_ctx(e, ctx).map(set_walk_as_scalar))
13700                    .collect::<Result<Vec<_>, _>>()?;
13701                Ok(IrExpr::Array(items))
13702            }
13703
13704            Expr::NamedTuple(fields) => {
13705                let ir = fields
13706                    .iter()
13707                    .map(|(name, e)| Ok((name.clone(), self.compile_expr_ctx(e, ctx)?)))
13708                    .collect::<Result<Vec<_>, PyQLError>>()?;
13709                Ok(IrExpr::NamedTuple {
13710                    fields: ir,
13711                    is_free_object: false,
13712                })
13713            }
13714
13715            Expr::Tuple(elems) => {
13716                let ir = elems
13717                    .iter()
13718                    .map(|e| self.compile_expr_ctx(e, ctx))
13719                    .collect::<Result<Vec<_>, PyQLError>>()?;
13720                Ok(IrExpr::Tuple(ir))
13721            }
13722
13723            Expr::FieldAccess { expr: inner, field } => {
13724                // `(select …).provider[is Individual].staff` arrives here, not
13725                // at the `PathStepOn` arm: the trailing `.staff` is outermost.
13726                if let Some(ir) = self.try_compile_walk_off_subquery(expr, ctx)? {
13727                    return Ok(ir);
13728                }
13729                if let Expr::NamedTuple(fields) = inner.as_ref() {
13730                    let (_, val) = fields.iter().find(|(k, _)| k == field).ok_or_else(|| {
13731                        self.type_err(&format!("{field} is not a member of {}", named_tuple_type_str(fields)))
13732                    })?;
13733                    return self.compile_expr_ctx(val, ctx);
13734                }
13735                if let Some(ps) = self.compile_shape_field_select(expr, ctx)? {
13736                    return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
13737                        ps,
13738                    )))));
13739                }
13740                // `(select .emails filter .primary limit 1).address` — the
13741                // field chain is spliced onto the sub-select's own path so
13742                // the subquery projects that column, rather than being read
13743                // as jsonb extraction off an object id.
13744                let (base, fields) = Self::peel_field_access_chain(expr);
13745                if let Expr::SubQuery(stmt) = base {
13746                    let stmt = stmt.as_ref().clone();
13747                    return self.compile_subquery_expr(&stmt, &fields, ctx, &[]);
13748                }
13749                // `account::owner(.id).name` — an object-returning function
13750                // projected to one of its columns.
13751                if let Expr::FunctionCall(fc) = base {
13752                    let fc = fc.clone();
13753                    if let Some(ir) = self.try_compile_fn_scalar_subquery(&fc, &fields, None, ctx)? {
13754                        return Ok(ir);
13755                    }
13756                }
13757                let ir = self.compile_expr_ctx(inner, ctx)?;
13758                Ok(Self::project_free_object_field(ir, field))
13759            }
13760
13761            Expr::TupleIndex { expr: inner, index } => {
13762                match inner.as_ref() {
13763                    Expr::Tuple(elems) => {
13764                        let elem = elems.get(*index).ok_or_else(|| {
13765                            self.type_err(&format!(
13766                                "{index} is not a member of {}",
13767                                positional_tuple_type_str(elems)
13768                            ))
13769                        })?;
13770                        self.compile_expr_ctx(elem, ctx)
13771                    }
13772                    Expr::NamedTuple(fields) => {
13773                        let (_, val) = fields.get(*index).ok_or_else(|| {
13774                            self.type_err(&format!("{index} is not a member of {}", named_tuple_type_str(fields)))
13775                        })?;
13776                        self.compile_expr_ctx(val, ctx)
13777                    }
13778                    // Not a literal to constant-fold — emit a generic runtime
13779                    // jsonb positional access (`$param.1`, `(<tuple<...>>expr).1`, …).
13780                    // When the source is a cast to a statically-known tuple type,
13781                    // bounds-check the index against its arity at compile time
13782                    // (e.g. `2 is not a member of tuple<std::int64, std::str>`).
13783                    _ => {
13784                        if let Expr::TypeCast(tc) = inner.as_ref()
13785                            && let Some(shape) = self.resolve_tuple_cast_shape(&tc.ty)
13786                            && *index >= shape.members.len()
13787                        {
13788                            return Err(self.type_err(&format!(
13789                                "{index} is not a member of {}",
13790                                self.type_expr_to_display_str(&tc.ty)
13791                            )));
13792                        }
13793                        let ir = self.compile_expr_ctx(inner, ctx)?;
13794                        Ok(IrExpr::JsonbIndex {
13795                            expr: Box::new(ir),
13796                            index: *index,
13797                        })
13798                    }
13799                }
13800            }
13801
13802            // `detached` bypasses the implicit root-matches-td correlation
13803            // rewrite. Schema-bound: if `inner` contains a type-rooted path,
13804            // compile it as an independent PathSubquery; otherwise (or when
13805            // already free) fall back to compiling `inner` with NO schema
13806            // binding — note this fallback is deliberately `None`, not
13807            // `ctx`, since `detached` means "evaluate independently of the
13808            // enclosing scope" and free context has no rooted path to find
13809            // in the first place (`find_path_root_in_expr` only ever matches
13810            // against a resolvable schema type name).
13811            Expr::Detached(inner) => {
13812                if ctx.is_some()
13813                    && let Some(root) = self.find_path_root_in_expr(inner)
13814                {
13815                    let synthetic = ast::SelectStmt {
13816                        result: (**inner).clone(),
13817                        filter: None,
13818                        order_by: vec![],
13819                        offset: None,
13820                        limit: None,
13821                        lock: None,
13822                    };
13823                    let ps = self.compile_expr_as_path_select(&synthetic, inner, &root, false)?;
13824                    return Ok(IrExpr::PathSubquery(Box::new(ps)));
13825                }
13826                self.compile_expr_ctx(inner, None)
13827            }
13828
13829            // Free context tolerates an empty set (-> Null) or a singleton
13830            // set (-> its one element) as a convenience; a schema-bound
13831            // expression position hard-errors on ANY set literal instead
13832            // (see the generic Shape|Set arm below) — a plausibly
13833            // intentional semantic difference, preserved exactly as-is.
13834            Expr::Set(elems) if ctx.is_none() && elems.is_empty() => Ok(IrExpr::Null),
13835
13836            Expr::Set(elems) if ctx.is_none() => {
13837                let compiled: Result<Vec<_>, _> = elems.iter().map(|e| self.compile_expr_ctx(e, ctx)).collect();
13838                let mut compiled = compiled?;
13839                if compiled.len() == 1 {
13840                    Ok(compiled.remove(0))
13841                } else {
13842                    Err(self.type_err("multi-element set literal is not supported in free SELECT context"))
13843                }
13844            }
13845
13846            // A free object literal (`{ foo := 'bar' }`, no subject type) is
13847            // valid anywhere an expression is, not just as a whole SELECT's
13848            // result — e.g. nested inside a computed shape element. Compiled
13849            // the same way `compile_free_select` treats it at the top level
13850            // (each field compiled independently, ctx propagated so a
13851            // schema-bound nested free object can still reference `.name`
13852            // etc.), just wrapped as `IrExpr::NamedTuple` (jsonb) instead of
13853            // a whole result row, since here it's a value, not a row source.
13854            // is_free_object: true — this came from curly-brace shape syntax,
13855            // not a paren tuple literal, so the value-shape-tag tree
13856            // (pylon/query.py's shape_value_tags) can tell the frontend to
13857            // render it as an expandable "Object {...}", not a `(...)`
13858            // tuple literal (see ShapeNode::NamedTuple's own doc comment).
13859            Expr::Shape(s) if s.expr.is_none() => {
13860                let fields = s
13861                    .elements
13862                    .iter()
13863                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
13864                        let name = path_leaf(&el.path)?.to_string();
13865                        let expr = el.compexpr.as_ref().ok_or_else(|| {
13866                            self.type_err("free object field must have a value expression (':= expr')")
13867                        })?;
13868                        let compiled = match expr {
13869                            Expr::SubQuery(stmt)
13870                                if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) =>
13871                            {
13872                                self.dml_as_value(stmt.as_ref())?
13873                            }
13874                            other => match self.free_object_link_field(other)? {
13875                                Some(object) => object,
13876                                None => self.compile_expr_ctx(other, ctx)?,
13877                            },
13878                        };
13879                        Ok((name, compiled))
13880                    })
13881                    .collect::<Result<Vec<_>, _>>()?;
13882                Ok(IrExpr::NamedTuple {
13883                    fields,
13884                    is_free_object: true,
13885                })
13886            }
13887
13888            // A shape applied to a WITH-bound free object (`test := test {
13889            // test2 }` where `test` isn't a schema object, just a free
13890            // binding) projects the named fields out of it — each element
13891            // either reads the underlying field (by name, via the same
13892            // per-field CTE column `.field` access uses) or, with a `:=`
13893            // override, compiles a brand new value in the current context,
13894            // exactly like a fresh free-object-literal field would.
13895            Expr::Shape(s) if matches!(&s.expr, Some(inner) if self.is_free_cte_ref(inner)) => {
13896                let Some(Expr::Path(root_path)) = &s.expr else {
13897                    unreachable!()
13898                };
13899                let ast::PathStep::Name(root) = &root_path.steps[0] else {
13900                    unreachable!()
13901                };
13902                let fields = s
13903                    .elements
13904                    .iter()
13905                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
13906                        let name = path_leaf(&el.path)?.to_string();
13907                        let expr = match &el.compexpr {
13908                            Some(over) => self.compile_expr_ctx(over, ctx)?,
13909                            None => match self.resolve_cte_field_chain(root, &[name.as_str()]) {
13910                                Some(result) => result?,
13911                                None => {
13912                                    return Err(self.type_err(&format!("free object '{root}' has no field '{name}'")));
13913                                }
13914                            },
13915                        };
13916                        Ok((name, expr))
13917                    })
13918                    .collect::<Result<Vec<_>, _>>()?;
13919                Ok(IrExpr::NamedTuple {
13920                    fields,
13921                    is_free_object: true,
13922                })
13923            }
13924
13925            // `(.<prices[is Listing] union .<sale_prices[is Listing]) { id }`
13926            // — each operand hangs off the enclosing row, so none can be
13927            // hoisted into a CTE the way a standalone union's operands are.
13928            Expr::Shape(sh)
13929                if ctx.is_some()
13930                    && matches!(sh.expr.as_ref(), Some(Expr::Union(_, _)))
13931                    && Self::union_of_relative_paths(sh.expr.as_ref().expect("checked")).is_some() =>
13932            {
13933                let (td, alias) = ctx.expect("checked by the guard");
13934                let operands =
13935                    Self::union_of_relative_paths(sh.expr.as_ref().expect("checked")).expect("checked by the guard");
13936                let elements = sh.elements.clone();
13937                let multi = self.relative_paths_reach_many(td, &operands);
13938                let mut branches = Vec::with_capacity(operands.len());
13939                for path in operands {
13940                    let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
13941                    steps.extend(path.steps.iter().cloned());
13942                    let rooted = ast::Path { steps, partial: false };
13943                    let synthetic = ast::SelectStmt {
13944                        result: Expr::Path(rooted.clone()),
13945                        filter: None,
13946                        order_by: vec![],
13947                        offset: None,
13948                        limit: None,
13949                        lock: None,
13950                    };
13951                    let mut ps = self.compile_path_select(&synthetic, &rooted, &elements, false)?;
13952                    Self::correlate_path_select(&mut ps, alias);
13953                    branches.push(ps);
13954                }
13955                Ok(IrExpr::ObjectPathUnion {
13956                    branches,
13957                    limit: None,
13958                    multi,
13959                })
13960            }
13961
13962            // `([is Conditional].configs ?? [is Loop].configs) { … }` — the
13963            // same correlated walks a union of them gives, except each operand
13964            // after the first only stands in when the ones before it are empty.
13965            Expr::Shape(sh)
13966                if ctx.is_some() && sh.expr.as_ref().and_then(Self::coalesce_of_relative_paths).is_some() =>
13967            {
13968                let (td, alias) = ctx.expect("checked by the guard");
13969                let operands =
13970                    Self::coalesce_of_relative_paths(sh.expr.as_ref().expect("checked")).expect("checked by the guard");
13971                let multi = self.relative_paths_reach_many(td, &operands);
13972                let branches = self.coalesce_path_branches(td, alias, &operands, &sh.elements)?;
13973                Ok(IrExpr::ObjectPathUnion {
13974                    branches,
13975                    limit: None,
13976                    multi,
13977                })
13978            }
13979
13980            Expr::Shape(sh) if matches!(sh.expr.as_ref(), Some(Expr::SubQuery(_))) => {
13981                let sh = sh.clone();
13982                match self.shape_over_subquery(&sh)? {
13983                    Some(ir) => Ok(ir),
13984                    None => Err(PyQLError::Type(PyQLTypeError {
13985                        message: "shapes and set literals are not valid in expression context".into(),
13986                        position: Position { line: 0, col: 0 },
13987                    })),
13988                }
13989            }
13990
13991            Expr::Shape(sh) if Self::shape_over_subquery_projection(sh).is_some() => {
13992                let (stmt, fields) = Self::shape_over_subquery_projection(sh).expect("checked by the guard");
13993                let stmt = stmt.clone();
13994                let elements = sh.elements.clone();
13995                self.compile_subquery_expr(&stmt, &fields, ctx, &elements)
13996            }
13997
13998            // `account := account if cond else {}` — the empty set is a legal
13999            // expression and means "no value", which is what the
14000            // assignment paths already spell `IrExpr::Null`. Only a *non-empty*
14001            // set literal has no expression-position meaning.
14002            Expr::Set(items) if items.is_empty() => Ok(IrExpr::Null),
14003
14004            Expr::Shape(sh)
14005                if matches!(sh.expr.as_ref(), Some(Expr::Path(p))
14006                    if !p.partial
14007                        && matches!(p.steps.first(), Some(ast::PathStep::Name(n))
14008                            if self.cte_object_type(n).is_some() || self.for_var_types.contains_key(n))) =>
14009            {
14010                let expr = expr.clone();
14011                match self.shape_over_binding(&expr)? {
14012                    Some(ir) => Ok(ir),
14013                    None => Err(PyQLError::Type(PyQLTypeError {
14014                        message: "shapes and set literals are not valid in expression context".into(),
14015                        position: Position { line: 0, col: 0 },
14016                    })),
14017                }
14018            }
14019
14020            // A bare shape or set literal is never valid in expression
14021            // position, in either context — preserved exactly as the
14022            // schema-bound side always enforced (the free side's more
14023            // permissive empty/singleton-set handling above is the one
14024            // deliberate exception, handled before this arm).
14025            Expr::Shape(_) | Expr::Set(_) => Err(PyQLError::Type(PyQLTypeError {
14026                message: "shapes and set literals are not valid in expression context".into(),
14027                position: Position { line: 0, col: 0 },
14028            })),
14029
14030            // A `select` over a path compiles to a correlated subquery; DML
14031            // and everything else still has no expression-position meaning.
14032            // Union/Except previously fell through free's generic "not valid
14033            // in free SELECT context" catch-all — these explicit,
14034            // purpose-written messages (already used schema-bound) apply
14035            // equally well with no schema in scope, so they're unconditional
14036            // here rather than ctx-gated.
14037            Expr::SubQuery(stmt) => {
14038                let stmt = stmt.as_ref().clone();
14039                self.compile_subquery_expr(&stmt, &[], ctx, &[])
14040            }
14041
14042            Expr::Union(_, _) => Err(PyQLError::Type(PyQLTypeError {
14043                message: "union is not valid in expression context".into(),
14044                position: Position { line: 0, col: 0 },
14045            })),
14046
14047            // `array_unpack(a) intersect array_unpack(b)` — a set operation
14048            // between two set-valued expressions is itself a set, which in
14049            // expression position is the array of what it yields.
14050            Expr::Except(left, right) | Expr::Intersect(left, right) => {
14051                let op = if matches!(expr, Expr::Intersect(_, _)) {
14052                    super::SetOpKind::Intersect
14053                } else {
14054                    super::SetOpKind::Except
14055                };
14056                let left = self.compile_expr_ctx(left, ctx)?;
14057                let right = self.compile_expr_ctx(right, ctx)?;
14058                Ok(IrExpr::SetOp {
14059                    op,
14060                    left: Box::new(left),
14061                    right: Box::new(right),
14062                    mode: super::SetOpMode::Array,
14063                })
14064            }
14065
14066            // TypeIs (`expr is Type`) is schema-exclusive — compile_type_is
14067            // deeply needs td/alias throughout (interface checks, __type__
14068            // column, alias-scoped bool expr). No free-context equivalent
14069            // existed before the merge (fell to the generic catch-all); this
14070            // is a new, clearer explicit error for that case.
14071            Expr::TypeIs { expr, ty } => match ctx {
14072                Some((td, alias)) => self.compile_type_is(expr, ty, td, alias),
14073                None => match expr.as_ref() {
14074                    Expr::Path(p) => self.compile_path_type_is(p, ty, None),
14075                    _ => Err(self.type_err("'is' type check is not valid in free SELECT context")),
14076                },
14077            },
14078        }
14079    }
14080
14081    fn compile_expr(&mut self, expr: &Expr, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
14082        self.compile_expr_ctx(expr, Some((td, alias)))
14083    }
14084
14085    fn compile_free_expr(&mut self, expr: &Expr) -> Result<IrExpr, PyQLError> {
14086        self.compile_expr_ctx(expr, None)
14087    }
14088
14089    /// `path is T`: whether the objects `path` reaches are of `T` or one of
14090    /// its subtypes, read off their own `__type__`.
14091    fn compile_path_type_is(
14092        &mut self,
14093        path: &ast::Path,
14094        ty: &ast::TypeExpr,
14095        ctx: Option<(&TypeDescriptor, &str)>,
14096    ) -> Result<IrExpr, PyQLError> {
14097        let (ty_module, ty_name) = ty
14098            .as_named()
14099            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
14100        let check_qname = match (ty_module, ctx) {
14101            (Some(module), _) => format!("{module}::{ty_name}"),
14102            (None, Some((td, _))) => format!("{}::{}", td.module, ty_name),
14103            (None, None) => ty_name.to_string(),
14104        };
14105        let check_td = self.resolve_type(&check_qname)?;
14106        let check_qname = format!("{}::{}", check_td.module, check_td.name);
14107        let mut type_path = path.clone();
14108        type_path.steps.push(ast::PathStep::Name("__type__".to_string()));
14109        let own_type = self.compile_expr_ctx(&Expr::Path(type_path), ctx)?;
14110        let implementors: Vec<String> = self
14111            .find_poly_implementors(&check_qname)
14112            .into_iter()
14113            .map(|implementor| implementor.type_name)
14114            .collect();
14115        let is_one_of = |own_type: IrExpr| {
14116            implementors
14117                .iter()
14118                .map(|implementor| {
14119                    IrExpr::BinOp(Box::new(IrBinOp {
14120                        left: own_type.clone(),
14121                        op: ast::BinOpKind::Eq,
14122                        right: IrExpr::Literal(IrLiteral::Str(implementor.clone())),
14123                    }))
14124                })
14125                .reduce(|left, right| {
14126                    IrExpr::BinOp(Box::new(IrBinOp {
14127                        left,
14128                        op: ast::BinOpKind::Or,
14129                        right,
14130                    }))
14131                })
14132                .unwrap_or(IrExpr::Literal(IrLiteral::Bool(false)))
14133        };
14134        // A path that reaches many objects yields one answer per object.
14135        Ok(match own_type {
14136            IrExpr::ArrayFromSelect(source) => match *source {
14137                IrArraySource::PathSelect(mut path_select) => {
14138                    let IrPathResult::Scalar(element, cast) = path_select.result else {
14139                        return Err(self.type_err("'__type__' is read as a value"));
14140                    };
14141                    path_select.result = IrPathResult::Scalar(is_one_of(element), cast);
14142                    IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(path_select)))
14143                }
14144                other => is_one_of(IrExpr::ArrayFromSelect(Box::new(other))),
14145            },
14146            single => is_one_of(single),
14147        })
14148    }
14149
14150    fn compile_type_is(
14151        &mut self,
14152        expr: &Expr,
14153        ty: &ast::TypeExpr,
14154        td: &TypeDescriptor,
14155        alias: &str,
14156    ) -> Result<IrExpr, PyQLError> {
14157        self.compile_type_is_inner(expr, ty, td, alias, false)
14158    }
14159
14160    /// `expr is T` read as every answer it gives rather than as one value:
14161    /// naming a type other than the scope's asks the question once per row
14162    /// of that type. A shape pointer keeps that whole set and `any()`/`all()`
14163    /// fold it, where a boolean position — a FILTER, an `if` condition — can
14164    /// only take a single value and goes through `compile_type_is`.
14165    fn compile_type_is_set(
14166        &mut self,
14167        expr: &Expr,
14168        ty: &ast::TypeExpr,
14169        ctx: Option<(&TypeDescriptor, &str)>,
14170    ) -> Result<IrExpr, PyQLError> {
14171        match ctx {
14172            Some((td, alias)) => self.compile_type_is_inner(expr, ty, td, alias, true),
14173            None => match expr {
14174                Expr::Path(p) => self.compile_path_type_is(p, ty, None),
14175                _ => Err(self.type_err("'is' type check is not valid in free SELECT context")),
14176            },
14177        }
14178    }
14179
14180    fn compile_type_is_inner(
14181        &mut self,
14182        expr: &Expr,
14183        ty: &ast::TypeExpr,
14184        td: &TypeDescriptor,
14185        alias: &str,
14186        set_valued: bool,
14187    ) -> Result<IrExpr, PyQLError> {
14188        let self_qname = format!("{}::{}", td.module, td.name);
14189
14190        // `.listing is T`, `x is T`: the objects the path reaches are tested,
14191        // not the current row — through their own `__type__`.
14192        let reaches_other_objects = match expr {
14193            Expr::Path(p) if p.partial || p.steps.len() > 1 => {
14194                !matches!(p.steps.last(), Some(ast::PathStep::Name(n)) if n == "__type__")
14195            }
14196            Expr::Path(p) => matches!(
14197                p.steps.as_slice(),
14198                [ast::PathStep::Name(n)] if n != &td.name && n != &self_qname && self.resolve_type(n).is_err()
14199                    && self.resolve_name_ref(n, true).is_some()
14200            ),
14201            _ => false,
14202        };
14203        if reaches_other_objects && let Expr::Path(p) = expr {
14204            return self.compile_path_type_is(p, ty, Some((td, alias)));
14205        }
14206
14207        // Determine whether `expr` refers to the current scope or a different type.
14208        // A 1-step absolute path matching the current td → same scope.
14209        let (source_qname, cross_scope) = match expr {
14210            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
14211                if let ast::PathStep::Name(n) = &p.steps[0] {
14212                    if n == &td.name || n == &self_qname || self.scope_answers_to(n, &self_qname) {
14213                        (self_qname.clone(), false)
14214                    } else {
14215                        // Attempt to resolve as another type.
14216                        match self.resolve_type(n) {
14217                            Ok(other) => (format!("{}::{}", other.module, other.name), true),
14218                            Err(_) => (self_qname.clone(), false),
14219                        }
14220                    }
14221                } else {
14222                    (self_qname.clone(), false)
14223                }
14224            }
14225            _ => (self_qname.clone(), false),
14226        };
14227
14228        let (ty_module, ty_name) = ty
14229            .as_named()
14230            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
14231        let check_module = ty_module.unwrap_or(td.module.as_str());
14232        let check_qname = format!("{}::{}", check_module, ty_name);
14233        self.resolve_type(&check_qname)?;
14234
14235        if !cross_scope {
14236            // Scalar bool using the current alias.
14237            return Ok(self.type_check_bool_expr(&source_qname, &check_qname, td, alias));
14238        }
14239
14240        // Cross-scope: ARRAY(SELECT bool_expr FROM source_table).
14241        // Collect everything needed before calling fresh_alias (which needs &mut self).
14242        let src_alias = self.fresh_alias();
14243        let src_td = self.resolve_type(&source_qname)?;
14244        let source_table = src_td.table.clone();
14245        let (poly_implementors, poly_columns) = if self.is_polymorphic(src_td) {
14246            (
14247                self.find_poly_implementors(&source_qname),
14248                Self::poly_dml_columns(src_td),
14249            )
14250        } else {
14251            (vec![], vec![])
14252        };
14253        let bool_expr = self.type_check_bool_expr(&source_qname, &check_qname, src_td, &src_alias);
14254
14255        // Whenever the answer is the same for every row of the source,
14256        // `type_check_bool_expr` settles it with a literal. There is nothing
14257        // to iterate then, and a constant is what a boolean position can
14258        // consume: `ARRAY(SELECT FALSE FROM "Brand")` is a `boolean[]`, which
14259        // Postgres refuses outright as a CASE/WHEN condition or a WHERE
14260        // clause.
14261        if !set_valued && matches!(bool_expr, IrExpr::Literal(IrLiteral::Bool(_))) {
14262            return Ok(bool_expr);
14263        }
14264
14265        let source = IrSource {
14266            poly: None,
14267            type_name: source_qname.clone(),
14268            table: source_table,
14269            alias: src_alias,
14270        };
14271
14272        let answers = IrExpr::ArrayFromSelect(Box::new(IrArraySource::RawExpr {
14273            source,
14274            poly_implementors,
14275            poly_columns,
14276            expr: bool_expr,
14277        }));
14278        if set_valued {
14279            return Ok(answers);
14280        }
14281        // What is left really does differ per row of the source, so it still
14282        // has to collapse to one value here — empty answers nothing, more
14283        // than one row aborts. The message says which check asked and over
14284        // what, since `assert_single` appears nowhere in the query that
14285        // triggers it and its own wording sends the reader looking for a call
14286        // they never wrote.
14287        Ok(IrExpr::FunctionCall(IrFunctionCall {
14288            return_pg_type: None,
14289            schema: Some("_pylon".to_string()),
14290            name: "assert_single".to_string(),
14291            args: vec![
14292                answers,
14293                IrExpr::Literal(IrLiteral::Str(format!(
14294                    "'{source_qname} is {check_qname}' is asked once for every {source_qname} object, \
14295                     so it is not the single value a filter or an 'if' condition needs — narrow it to \
14296                     one object, or name the type being selected"
14297                ))),
14298            ],
14299            sql_template: None,
14300        }))
14301    }
14302
14303    /// Build the boolean `IrExpr` for `source_qname is check_qname` in the current row's scope.
14304    fn type_check_bool_expr(&self, source_qname: &str, check_qname: &str, td: &TypeDescriptor, alias: &str) -> IrExpr {
14305        if check_qname == source_qname || Self::is_or_implements(td, check_qname) {
14306            return IrExpr::Literal(IrLiteral::Bool(true));
14307        }
14308        if !self.is_polymorphic(td) {
14309            return IrExpr::Literal(IrLiteral::Bool(false));
14310        }
14311        // A row is of the checked type when its own type is that type or one
14312        // of its subtypes — but only the ones this source can actually hold
14313        // are worth asking about, since its rows carry one of *its* own
14314        // implementors in `__type__` and nothing else. Across two disjoint
14315        // hierarchies (`brand::Brand is brand::CustomBrief`) nothing is left
14316        // and the answer is false for every row; where nothing is ruled out
14317        // it is true for every row. Both are decided here rather than per
14318        // row, which is what keeps a check over another type out of the
14319        // per-row machinery in `compile_type_is_inner` — asking it once per
14320        // row of a three-row table is what made a filter fail the
14321        // single-value check it has to pass.
14322        let reachable: Vec<String> = self
14323            .find_poly_implementors(source_qname)
14324            .into_iter()
14325            .map(|implementor| implementor.type_name)
14326            .collect();
14327        let matching: Vec<String> = self
14328            .find_poly_implementors(check_qname)
14329            .into_iter()
14330            .map(|implementor| implementor.type_name)
14331            .filter(|type_name| reachable.contains(type_name))
14332            .collect();
14333        if matching.is_empty() {
14334            return IrExpr::Literal(IrLiteral::Bool(false));
14335        }
14336        if matching.len() == reachable.len() {
14337            return IrExpr::Literal(IrLiteral::Bool(true));
14338        }
14339        matching
14340            .into_iter()
14341            .map(|type_name| {
14342                IrExpr::BinOp(Box::new(IrBinOp {
14343                    left: IrExpr::ColumnRef {
14344                        alias: alias.to_string(),
14345                        column: "__type__".into(),
14346                        pg_type: "text".into(),
14347                    },
14348                    op: crate::parse::ast::BinOpKind::Eq,
14349                    right: IrExpr::Literal(IrLiteral::Str(type_name)),
14350                }))
14351            })
14352            .reduce(|left, right| {
14353                IrExpr::BinOp(Box::new(IrBinOp {
14354                    left,
14355                    op: crate::parse::ast::BinOpKind::Or,
14356                    right,
14357                }))
14358            })
14359            .unwrap_or(IrExpr::Literal(IrLiteral::Bool(false)))
14360    }
14361
14362    /// Shared lookup for a bare 1-step name: for-loop variable, CTE binding,
14363    /// or function parameter. Used by both `compile_path`'s schema-bound
14364    /// prefix and `compile_free_path`.
14365    ///
14366    /// `allow_fn_param` used to be `false` from `compile_path`, so a parameter
14367    /// resolved only in free context. An object-returning body like
14368    /// `select Item filter .rank = v` compiles its filter against a schema
14369    /// anchor, so it never consulted `fn_params` and rejected the bare `v` as
14370    /// "absolute paths are not valid in expression context" — while the same
14371    /// parameter in a scalar-returning body worked, which is why the gap went
14372    /// unnoticed. A bare one-step name can only be a variable, CTE binding or
14373    /// parameter in either context (a property is always written `.name`), so
14374    /// there is nothing here for a parameter to shadow.
14375    fn resolve_name_ref(&mut self, name: &str, allow_fn_param: bool) -> Option<IrExpr> {
14376        if self.for_vars.contains_key(name) {
14377            return Some(self.for_var_ref(name));
14378        }
14379        // A free-object-bound CTE (`with x := { a := 1 } select ... x ...`),
14380        // referenced bare with no shape to project through, has nothing to
14381        // expose — a free *object* needs an explicit shape to know what to
14382        // return (unlike a tuple/named tuple, it has no "default"
14383        // projection), so this collapses to an empty free object, the same
14384        // as `Expr::Shape`'s `is_free_cte_ref` case for `x { ... }`.
14385        // It also sidesteps a real problem: this CTE has no "v" column
14386        // (only `IrFreeExpr::Scalar` CTEs get one), so falling through to
14387        // the generic `IrExpr::CteRef` below would reference a column that
14388        // doesn't exist.
14389        if let Some(IrFreeExpr::FreeObject(_)) = self.cte_free_items.get(name) {
14390            return Some(IrExpr::NamedTuple {
14391                fields: vec![],
14392                is_free_object: true,
14393            });
14394        }
14395        if let Some(ir) = self.inline_bindings.get(name) {
14396            return Some(ir.clone());
14397        }
14398        if let Some(t) = self.cte_types.get(name) {
14399            // A scalar binding records its pg type here (see `cte_stmt_type`);
14400            // an object binding records a `module::Type` name, which is not a
14401            // pg type and must not be handed to type inference.
14402            let scalar = !t.contains("::");
14403            return Some(IrExpr::CteRef {
14404                name: self.cte_sql_name(name),
14405                scalar,
14406                pg_type: (scalar && !t.is_empty()).then(|| literal_sentinel_to_pg(t).to_string()),
14407            });
14408        }
14409        if allow_fn_param && let Some(pg_type) = self.fn_params.get(name) {
14410            return Some(IrExpr::FnParam {
14411                name: name.to_string(),
14412                pg_type: pg_type.clone(),
14413            });
14414        }
14415        None
14416    }
14417
14418    /// The `(qualified type, alias)` an absolute `root.prop` should resolve
14419    /// against when the innermost select is `detached`.
14420    ///
14421    /// `None` in the ordinary case — the innermost select is not detached, or
14422    /// nothing outside it binds that type, in which case naming the type still
14423    /// means the current row.
14424    fn enclosing_anchor(&self, root: &str) -> Option<(String, String)> {
14425        let innermost = self.anchors.last()?;
14426        if !innermost.detached || !innermost.answers_to(root) {
14427            return None;
14428        }
14429        self.anchors
14430            .iter()
14431            .rev()
14432            .skip(1)
14433            .find(|a| a.answers_to(root))
14434            .map(|a| (a.qualified.clone(), a.alias.clone()))
14435    }
14436
14437    /// Whether `root` names the scope `self_qname` stands for, beyond its own
14438    /// two spellings — an inherited computed names its declaring type, which
14439    /// is the subject there too. See `SelectAnchor::declared_on`.
14440    fn scope_answers_to(&self, root: &str, self_qname: &str) -> bool {
14441        self.anchors
14442            .last()
14443            .is_some_and(|anchor| anchor.qualified == self_qname && anchor.answers_to(root))
14444    }
14445
14446    /// The innermost enclosing select binding `root` as its subject.
14447    fn outer_anchor(&self, root: &str) -> Option<(String, String)> {
14448        self.anchors
14449            .iter()
14450            .rev()
14451            .find(|a| a.answers_to(root))
14452            .map(|a| (a.qualified.clone(), a.alias.clone()))
14453    }
14454
14455    fn compile_path(&mut self, p: &ast::Path, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
14456        if !p.partial {
14457            if p.steps.len() == 1
14458                && let ast::PathStep::Name(n) = &p.steps[0]
14459            {
14460                if let Some(ir) = self.resolve_name_ref(n, true) {
14461                    return Ok(ir);
14462                }
14463                // __type__ without a leading dot still means the current object's type.
14464                if n == "__type__" {
14465                    return Ok(if self.is_polymorphic(td) {
14466                        IrExpr::ColumnRef {
14467                            alias: alias.to_string(),
14468                            column: "__type__".to_string(),
14469                            pg_type: "text".to_string(),
14470                        }
14471                    } else {
14472                        IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
14473                    });
14474                }
14475            }
14476            // Enum member access: `default::Gender.Female`
14477            if p.steps.len() == 2
14478                && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
14479                && self.resolve_enum(type_ref).is_some()
14480            {
14481                return self.compile_enum_access(type_ref, variant);
14482            }
14483            // `root.field1.field2...` where `root` is a WITH-bound free object.
14484            if let Some(resolved) = self.resolve_cte_path(p) {
14485                return resolved;
14486            }
14487            // `root.field1.field2...` where `root` is a WITH-bound *schema
14488            // object* (`with person := (select detached Person filter ...)
14489            // select ... person.company.name ...`). `compile_path_select`
14490            // already treats a CTE-bound root exactly like a real type name
14491            // (it checks `cte_types` before falling back to `resolve_type`,
14492            // via the `@cte:` source-table sentinel — see its own doc
14493            // comment and `compile_expr_as_path_select`'s sibling case for
14494            // `Detached`), so it already implements the *entire* general
14495            // path-traversal feature set here — forward links, multilinks,
14496            // backlinks, junction-backed links, nested tuple field access,
14497            // "did you mean" on a typo'd name — not just a one-property
14498            // special case. Wrapping its result as `IrExpr::PathSubquery`
14499            // (the same vehicle `Detached` uses for a type-rooted path in
14500            // expression position) turns the whole traversal into one
14501            // correlated scalar/id expression.
14502            if p.steps.len() > 1
14503                && let ast::PathStep::Name(root) = &p.steps[0]
14504                && self
14505                    .cte_types
14506                    .get(root.as_str())
14507                    .map(|t| t.contains("::"))
14508                    .unwrap_or(false)
14509            {
14510                let full_path = ast::Path {
14511                    steps: p.steps.clone(),
14512                    partial: false,
14513                };
14514                let synthetic = ast::SelectStmt {
14515                    result: Expr::Path(full_path.clone()),
14516                    filter: None,
14517                    order_by: vec![],
14518                    offset: None,
14519                    limit: None,
14520                    lock: None,
14521                };
14522                let ps = self.compile_path_select(&synthetic, &full_path, &[], false)?;
14523                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14524            }
14525            // `__new__.prop` / `__old__.prop` — the inserted/updated/deleted
14526            // row a trigger handler is compiled against (see
14527            // `compile_trigger_handler`, which populates `special_anchors`
14528            // per the trigger's declared `on` events). Same rewrite-and-
14529            // recurse trick as the `TypeName.prop` case below, just handing
14530            // `compile_path` the anchor's own alias ("NEW"/"OLD") instead
14531            // of `td`'s. Outside trigger-handler compilation (or for the
14532            // anchor the current trigger's events don't legally bind —
14533            // e.g. `__old__` in an Insert-only trigger) `special_anchors`
14534            // is empty/missing that entry, so this falls through to the
14535            // explicit error below rather than the generic "absolute
14536            // paths" message: `__old__`/`__new__` cannot be used in
14537            // this expression.
14538            if p.steps.len() > 1
14539                && let ast::PathStep::Name(root) = &p.steps[0]
14540                && (root == "__new__" || root == "__old__")
14541            {
14542                if let Some((anchor_td, anchor_alias)) = self.special_anchors.get(root).cloned() {
14543                    let relative = ast::Path {
14544                        steps: p.steps[1..].to_vec(),
14545                        partial: true,
14546                    };
14547                    return self.compile_path(&relative, anchor_td, &anchor_alias);
14548                }
14549                return Err(PyQLError::Resolution(PyQLResolutionError::UnknownField(
14550                    PyQLUnknownFieldError {
14551                        message: format!("{root} cannot be used in this expression"),
14552                        position: Position { line: 0, col: 0 },
14553                    },
14554                )));
14555            }
14556            // `__subject__` is the row a constraint is checked against, which
14557            // is the same row a relative path reads.
14558            if p.steps.len() > 1 && matches!(&p.steps[0], ast::PathStep::Name(root) if root == "__subject__") {
14559                let relative = ast::Path {
14560                    steps: p.steps[1..].to_vec(),
14561                    partial: true,
14562                };
14563                return self.compile_path(&relative, td, alias);
14564            }
14565            // Absolute path rooted at the current td: `TypeName.prop` inside a schema-bound
14566            // expression (e.g. the value side of a BinOp in compile_expr_as_path_select).
14567            // Rewrite to a relative path and compile normally.
14568            if p.steps.len() > 1
14569                && let ast::PathStep::Name(root) = &p.steps[0]
14570            {
14571                let qualified = format!("{}::{}", td.module, td.name);
14572                if *root == td.name || *root == qualified {
14573                    let relative = ast::Path {
14574                        steps: p.steps[1..].to_vec(),
14575                        partial: true,
14576                    };
14577                    // Inside a `detached` select, naming its own type means the
14578                    // enclosing select's row — that is what makes an anti-join
14579                    // compare two different rows.
14580                    if let Some((outer_qualified, outer_alias)) = self.enclosing_anchor(root) {
14581                        let outer_td = self.resolve_type(&outer_qualified)?.clone();
14582                        return self.compile_path(&relative, &outer_td, &outer_alias);
14583                    }
14584                    return self.compile_path(&relative, td, alias);
14585                }
14586                // A prefix naming a type an *enclosing* select already binds —
14587                // `select Font { styles: { font := Font.id } }`. Such a prefix
14588                // factors out to the scope that binds it, so it reads that row
14589                // rather than every row of the type.
14590                if let Some((outer_qualified, outer_alias)) = self.outer_anchor(root) {
14591                    let relative = ast::Path {
14592                        steps: p.steps[1..].to_vec(),
14593                        partial: true,
14594                    };
14595                    let outer_td = self.resolve_type(&outer_qualified)?.clone();
14596                    return self.compile_path(&relative, &outer_td, &outer_alias);
14597                }
14598            }
14599            // `membership.account.id` — a walk off a for-loop variable in
14600            // expression position. `compile_path_select` already knows to
14601            // start such a walk from the row the variable holds, so this is
14602            // that walk read as one subquery.
14603            if let Some(ast::PathStep::Name(var)) = p.steps.first()
14604                && p.steps.len() > 1
14605                && self.for_var_types.contains_key(var)
14606                && !matches!(p.steps[1], ast::PathStep::TypeIntersection(_))
14607            {
14608                let synthetic = ast::SelectStmt {
14609                    result: Expr::Path(p.clone()),
14610                    filter: None,
14611                    order_by: vec![],
14612                    offset: None,
14613                    limit: None,
14614                    lock: None,
14615                };
14616                let ps = self.compile_path_select(&synthetic, p, &[], false)?;
14617                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14618            }
14619            // `o[is Organization]` on a for-loop variable — the narrowing is
14620            // a filter, not a traversal: the row is read from the narrowed
14621            // type's own table by the key the variable holds, so a variable
14622            // bound to something else yields nothing, as it should.
14623            // `line[is BrandOrderLineItem].brand` — the narrowing picks the row
14624            // out of the narrowed type's own table, and the walk continues from
14625            // there. Without the tail this is the row itself, handled below.
14626            if let [
14627                ast::PathStep::Name(var),
14628                ast::PathStep::TypeIntersection(type_ref),
14629                rest @ ..,
14630            ] = p.steps.as_slice()
14631                && !rest.is_empty()
14632                && self.for_var_types.contains_key(var)
14633            {
14634                let type_name = match &type_ref.module {
14635                    Some(m) => format!("{}::{}", m, type_ref.name),
14636                    None => type_ref.name.clone(),
14637                };
14638                let narrowed = self.resolve_type(&type_name)?;
14639                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", narrowed.module, narrowed.name))];
14640                steps.extend(rest.iter().cloned());
14641                let rooted = ast::Path { steps, partial: false };
14642                let synthetic = ast::SelectStmt {
14643                    result: Expr::Path(rooted.clone()),
14644                    filter: None,
14645                    order_by: vec![],
14646                    offset: None,
14647                    limit: None,
14648                    lock: None,
14649                };
14650                let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
14651                let correlation = IrExpr::BinOp(Box::new(IrBinOp {
14652                    left: IrExpr::ColumnRef {
14653                        alias: ps.root.alias.clone(),
14654                        column: "id".to_string(),
14655                        pg_type: "uuid".to_string(),
14656                    },
14657                    op: ast::BinOpKind::Eq,
14658                    right: self.for_var_ref(var),
14659                }));
14660                ps.filter = and_conditions(ps.filter, vec![correlation]);
14661                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14662            }
14663            if let [ast::PathStep::Name(var), ast::PathStep::TypeIntersection(type_ref)] = p.steps.as_slice()
14664                && self.for_var_types.contains_key(var)
14665            {
14666                let type_name = match &type_ref.module {
14667                    Some(m) => format!("{}::{}", m, type_ref.name),
14668                    None => type_ref.name.clone(),
14669                };
14670                let narrowed = self.resolve_type(&type_name)?;
14671                let narrowed_alias = self.fresh_alias();
14672                let source = IrSource {
14673                    poly: self.poly_fanout_for(&format!("{}::{}", narrowed.module, narrowed.name)),
14674                    type_name: format!("{}::{}", narrowed.module, narrowed.name),
14675                    table: narrowed.table.clone(),
14676                    alias: narrowed_alias.clone(),
14677                };
14678                let filter = IrExpr::BinOp(Box::new(IrBinOp {
14679                    left: IrExpr::ColumnRef {
14680                        alias: narrowed_alias,
14681                        column: "id".to_string(),
14682                        pg_type: "uuid".to_string(),
14683                    },
14684                    op: ast::BinOpKind::Eq,
14685                    right: self.for_var_ref(var),
14686                }));
14687                return Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
14688                    source,
14689                    Self::pk_returning(narrowed),
14690                    Some(filter),
14691                ))));
14692            }
14693            return Err(PyQLError::Type(PyQLTypeError {
14694                message: "absolute paths are not valid in expression context; use .name".into(),
14695                position: Position { line: 0, col: 0 },
14696            }));
14697        }
14698
14699        // A bare `@prop` — the junction row of the multi-link whose own
14700        // modifiers are being compiled. Read from the junction alias the
14701        // emitter puts in scope there ("jt"), the same one a `@prop` in the
14702        // nested shape reads.
14703        if p.partial
14704            && let [ast::PathStep::LinkProp(prop_name)] = p.steps.as_slice()
14705        {
14706            return self.compile_link_prop_ref(prop_name);
14707        }
14708
14709        // Type intersection in expression: [is Type].name — scalar subquery
14710        if p.partial && matches!(p.steps.first(), Some(ast::PathStep::TypeIntersection(_))) {
14711            return self.compile_type_intersection_expr(&p.steps, td, alias);
14712        }
14713
14714        // `.<assessment[is brand::AssessmentRespondent]` — a backlink read as a
14715        // value, with or without a narrowing. No column holds it, so it is the
14716        // traversal as a correlated subquery; the builders below only know
14717        // forward links and reject the intersection outright.
14718        if p.partial && matches!(p.steps.first(), Some(ast::PathStep::Backlink(_))) {
14719            return self.compile_partial_path_as_subquery(p, td, alias);
14720        }
14721        // `.ca.name` where `ca := a if cond else b` — the condition does not
14722        // depend on which branch is taken, so the field access distributes
14723        // over both and the walk happens inside each. Left whole, the walk
14724        // builder has no column to traverse through.
14725        if p.partial
14726            && p.steps.len() > 1
14727            && let Some(ast::PathStep::Name(first)) = p.steps.first()
14728            && let Some(Expr::IfElse(ie)) = self
14729                .active_declared_pointers
14730                .iter()
14731                .find(|d| path_leaf(&d.path).is_ok_and(|name| name == first))
14732                .and_then(|d| d.compexpr.clone())
14733        {
14734            let extend = |branch: &Expr| match branch {
14735                Expr::Path(bp) => {
14736                    let mut steps = bp.steps.clone();
14737                    steps.extend(p.steps[1..].iter().cloned());
14738                    Some(Expr::Path(ast::Path {
14739                        steps,
14740                        partial: bp.partial,
14741                    }))
14742                }
14743                // `(select .<brand[is T] … limit 1).priority` — the walk's
14744                // remaining steps projected off the sub-select.
14745                Expr::SubQuery(_) => p.steps[1..].iter().try_fold(branch.clone(), |expr, step| match step {
14746                    ast::PathStep::Name(field) => Some(Expr::FieldAccess {
14747                        expr: Box::new(expr),
14748                        field: field.clone(),
14749                    }),
14750                    _ => None,
14751                }),
14752                // Nothing to walk from, so nothing is reached.
14753                Expr::Set(items) if items.is_empty() => Some(Expr::Set(vec![])),
14754                Expr::TypeCast(cast) if matches!(&cast.expr, Expr::Set(items) if items.is_empty()) => {
14755                    Some(Expr::Set(vec![]))
14756                }
14757                _ => None,
14758            };
14759            if let (Some(if_expr), Some(else_expr)) = (extend(&ie.if_expr), extend(&ie.else_expr)) {
14760                let distributed = Expr::IfElse(Box::new(ast::IfElse {
14761                    condition: ie.condition.clone(),
14762                    if_expr,
14763                    else_expr,
14764                }));
14765                return self.compile_expr(&distributed, td, alias);
14766            }
14767        }
14768
14769        // A walk that ends in a type intersection (`exists .provider[is
14770        // account::Individual]`) narrows to objects rather than reading a
14771        // pointer, so there is no trailing column for the two-step form to
14772        // read and it goes the way a longer traversal already does.
14773        let narrows_last = matches!(p.steps.last(), Some(ast::PathStep::TypeIntersection(_)));
14774
14775        if p.steps.len() == 2 && !narrows_last {
14776            return self.compile_path_2step(p, td, alias);
14777        }
14778
14779        // Three or more steps: no single column to read, so the whole
14780        // traversal becomes one correlated subquery.
14781        if p.steps.len() != 1 {
14782            return self.compile_partial_path_as_subquery(p, td, alias);
14783        }
14784
14785        let pointer_name = match &p.steps[0] {
14786            ast::PathStep::Name(n) => n.as_str(),
14787            _ => {
14788                return Err(PyQLError::Type(PyQLTypeError {
14789                    message: "type intersections are not valid in expression context".into(),
14790                    position: Position { line: 0, col: 0 },
14791                }));
14792            }
14793        };
14794
14795        // __type__ as an expression: for polymorphic (interface) types, read from the
14796        // inline union column; for concrete types, emit the static qualified name.
14797        if pointer_name == "__type__" {
14798            return Ok(if self.is_polymorphic(td) {
14799                IrExpr::ColumnRef {
14800                    alias: alias.to_string(),
14801                    column: "__type__".to_string(),
14802                    pg_type: "text".to_string(),
14803                }
14804            } else {
14805                IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
14806            });
14807        }
14808
14809        if let Some(prop) = Self::resolve_property(td, pointer_name) {
14810            return Ok(IrExpr::ColumnRef {
14811                alias: alias.to_string(),
14812                column: prop.name.clone(),
14813                pg_type: prop.pg_type.clone(),
14814            });
14815        }
14816
14817        if let Some(link) = Self::resolve_link(td, pointer_name) {
14818            if link.is_junction_backed() {
14819                return self.junction_target_id_expr(td, link, alias);
14820            }
14821            // FK column reference (uuid) — e.g. `.company` → `t0."company_id"`
14822            return Ok(IrExpr::ColumnRef {
14823                alias: alias.to_string(),
14824                column: format!("{}_id", link.name),
14825                pg_type: "uuid".to_string(),
14826            });
14827        }
14828
14829        // Schema-defined computed pointer: inline the expression in place.
14830        if let Some(cd) = self.resolve_computed(td, pointer_name) {
14831            let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
14832            return self.compile_expr(&expr_ast, td, alias);
14833        }
14834
14835        // `o in .organizations` — a multi-link read as a value is the set of
14836        // rows on the other side, which no column holds; the same traversal a
14837        // longer walk already becomes. Only property, link and computed were
14838        // resolved here, so a bare multi-link reported itself as unknown —
14839        // while the suggester, which does know them, offered the name back.
14840        if Self::resolve_multilink(td, pointer_name).is_some() {
14841            return self.compile_partial_path_as_subquery(p, td, alias);
14842        }
14843
14844        // `select (select T { a := … }) filter .a = …` — a pointer the shape
14845        // in scope declared is on no type, so it is read as the expression it
14846        // was written as. The shape route already resolves these; a filter or
14847        // order-by naming one arrives here instead.
14848        if let Some(expr) = self
14849            .active_declared_pointers
14850            .iter()
14851            .find(|d| path_leaf(&d.path).is_ok_and(|n| n == pointer_name))
14852            .and_then(|d| d.compexpr.clone())
14853        {
14854            return self.compile_expr(&expr, td, alias);
14855        }
14856
14857        Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
14858    }
14859
14860    /// Free-context counterpart of `compile_path` — no schema type/alias in
14861    /// scope, so only for-loop variables, CTE bindings, function parameters,
14862    /// and enum member access (`default::Gender.Female`) are resolvable;
14863    /// anything property/link-shaped is a hard error.
14864    fn compile_free_path(&mut self, p: &ast::Path) -> Result<IrExpr, PyQLError> {
14865        if p.partial {
14866            // A WITH binding inside a computed pointer or a nested SELECT is
14867            // compiled without a type in scope, but `.name` there still means
14868            // the innermost enclosing set — the one the anchor stack holds.
14869            if let Some((qualified, alias)) = self.anchors.last().map(|a| (a.qualified.clone(), a.alias.clone())) {
14870                let td = self.resolve_type(&qualified)?;
14871                return self.compile_path(p, td, &alias);
14872            }
14873            return Err(self.type_err(
14874                "property reference (.name) is not valid in free SELECT; \
14875                 use a schema-bound SELECT instead",
14876            ));
14877        }
14878        if p.steps.len() == 2
14879            && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
14880            && self.resolve_enum(type_ref).is_some()
14881        {
14882            return self.compile_enum_access(type_ref, variant);
14883        }
14884        // `root.field1.field2...` where `root` is a WITH-bound free object
14885        // (any length >= 2, including chains through nested free objects).
14886        if let Some(resolved) = self.resolve_cte_path(p) {
14887            return resolved;
14888        }
14889        if p.steps.len() == 1
14890            && let ast::PathStep::Name(n) = &p.steps[0]
14891            && let Some(ir) = self.resolve_name_ref(n, true)
14892        {
14893            return Ok(ir);
14894        }
14895        // `assessment.current_question` in a free shape's field or filter — a
14896        // walk off an object binding. It is the same traversal a bound select
14897        // makes, read back as one subquery; there is no enclosing row to
14898        // resolve it against, and nothing else here knows a binding can root a
14899        // path.
14900        if !p.partial
14901            && p.steps.len() > 1
14902            && let Some(ast::PathStep::Name(root)) = p.steps.first()
14903            && self.cte_object_type(root).is_some()
14904        {
14905            let synthetic = ast::SelectStmt {
14906                result: Expr::Path(p.clone()),
14907                filter: None,
14908                order_by: vec![],
14909                offset: None,
14910                limit: None,
14911                lock: None,
14912            };
14913            let ps = self.compile_path_select(&synthetic, p, &[], false)?;
14914            // A walk made only of forward single links does not multiply rows,
14915            // so its cardinality is the root binding's. Treating every join as
14916            // widening made `(select T filter .id = $x).link.prop` a
14917            // one-element set rather than the value itself.
14918            let widens = ps.joins.iter().any(|join| !matches!(join, IrPathJoin::Single { .. }));
14919            let root_is_multi = self.multi_row_ctes.contains(root.as_str());
14920            let multi = matches!(ps.result, IrPathResult::Scalar(..)) && (widens || root_is_multi);
14921            return Ok(if multi {
14922                IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(ps))))
14923            } else {
14924                IrExpr::PathSubquery(Box::new(ps))
14925            });
14926        }
14927        Err(self.type_err("expression is not valid in free SELECT context"))
14928    }
14929
14930    fn compile_path_2step(&mut self, p: &ast::Path, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
14931        let link_name = match &p.steps[0] {
14932            ast::PathStep::Name(n) => n.as_str(),
14933            _ => {
14934                return Err(PyQLError::Type(PyQLTypeError {
14935                    message: "type intersections are not valid in expression context".into(),
14936                    position: Position { line: 0, col: 0 },
14937                }));
14938            }
14939        };
14940        let pointer_name = match &p.steps[1] {
14941            ast::PathStep::Name(n) => n.as_str(),
14942            _ => {
14943                return Err(PyQLError::Type(PyQLTypeError {
14944                    message: "type intersections are not valid in expression context".into(),
14945                    position: Position { line: 0, col: 0 },
14946                }));
14947            }
14948        };
14949
14950        if let Some(link) = Self::resolve_link(td, link_name) {
14951            if pointer_name == "id" {
14952                if link.is_junction_backed() {
14953                    return self.junction_target_id_expr(td, link, alias);
14954                }
14955                return Ok(IrExpr::ColumnRef {
14956                    alias: alias.to_string(),
14957                    column: format!("{}_id", link_name),
14958                    pg_type: "uuid".to_string(),
14959                });
14960            }
14961            let target_td = self.resolve_type(&link.target)?;
14962            if let Some(prop) = Self::resolve_property(target_td, pointer_name) {
14963                let ft_alias = self.fresh_alias();
14964                let target_id_expr = if link.is_junction_backed() {
14965                    self.junction_target_id_expr(td, link, alias)?
14966                } else {
14967                    IrExpr::ColumnRef {
14968                        alias: alias.to_string(),
14969                        column: format!("{}_id", link_name),
14970                        pg_type: "uuid".to_string(),
14971                    }
14972                };
14973                return Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
14974                    IrSource {
14975                        poly: None,
14976                        type_name: format!("{}::{}", target_td.module, target_td.name),
14977                        table: target_td.table.clone(),
14978                        alias: ft_alias.clone(),
14979                    },
14980                    vec![IrShapePointer::Scalar(IrScalarPointer {
14981                        implicit_id: false,
14982                        marker_offset: None,
14983                        alias: prop.name.clone(),
14984                        column: prop.name.clone(),
14985                        pg_type: prop.pg_type.clone(),
14986                        tuple_shape: self.resolve_property_tuple_shape(prop),
14987                    })],
14988                    Some(IrExpr::BinOp(Box::new(IrBinOp {
14989                        left: IrExpr::ColumnRef {
14990                            alias: ft_alias.clone(),
14991                            column: "id".to_string(),
14992                            pg_type: "uuid".to_string(),
14993                        },
14994                        op: ast::BinOpKind::Eq,
14995                        right: target_id_expr,
14996                    }))),
14997                ))));
14998            }
14999            // Not a stored column on the target — a computed pointer, or a
15000            // further link. The general traversal builder resolves both.
15001            return self.compile_partial_path_as_subquery(p, td, alias);
15002        }
15003
15004        // A multi-link path stands for a set of values. Inside a comparison
15005        // it has already been rewritten to an EXISTS (`try_multilink_exists`,
15006        // which runs first); everywhere else it is an array.
15007        if Self::resolve_multilink(td, link_name).is_some() {
15008            return self.compile_partial_path_as_subquery(p, td, alias);
15009        }
15010
15011        // Not a stored pointer at all — a computed one, most likely, which
15012        // the general builder can traverse through by splicing in the path
15013        // it stands for. It raises the same "no link or property" error this
15014        // used to when the name really is unknown (which is what made a
15015        // computed head read as `has no link or property 'x'. Did you mean
15016        // 'x'?` — the suggester could see it, the resolver couldn't).
15017        self.compile_partial_path_as_subquery(p, td, alias)
15018    }
15019
15020    // ── Backlink compilation ─────────────────────────────────────────────────────
15021
15022    /// Detect a backlink path on either side of a BinOp and compile as EXISTS.
15023    fn try_backlink_exists(
15024        &mut self,
15025        b: &ast::BinOp,
15026        td: &TypeDescriptor,
15027        alias: &str,
15028    ) -> Result<Option<IrExpr>, PyQLError> {
15029        use ast::PathStep;
15030        fn is_backlink(p: &ast::Path) -> bool {
15031            p.partial && matches!(p.steps.first(), Some(PathStep::Backlink(_)))
15032        }
15033        let (path_steps, value_ast, flip) = if let Expr::Path(p) = &b.left {
15034            if is_backlink(p) {
15035                (p.steps.as_slice(), &b.right, false)
15036            } else {
15037                return Ok(None);
15038            }
15039        } else if let Expr::Path(p) = &b.right {
15040            if is_backlink(p) {
15041                (p.steps.as_slice(), &b.left, true)
15042            } else {
15043                return Ok(None);
15044            }
15045        } else {
15046            return Ok(None);
15047        };
15048        let value_expr = self.compile_comparison_value(value_ast, &b.op, td, alias)?;
15049        let current_qname = format!("{}::{}", td.module, td.name);
15050        let exists = self.compile_backlink_as_exists(
15051            path_steps,
15052            Some((b.op.clone(), value_expr, flip)),
15053            &current_qname,
15054            alias,
15055        )?;
15056        Ok(Some(exists))
15057    }
15058
15059    /// Compile a path `[Backlink(name), TypeIntersect(type), ...rest]` into EXISTS.
15060    /// `comparison` is `Some((op, value_expr, flip))` when used in a comparison filter.
15061    /// `current_qname` is the fully-qualified name of the object type being filtered.
15062    fn compile_backlink_as_exists(
15063        &mut self,
15064        steps: &[ast::PathStep],
15065        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
15066        current_qname: &str,
15067        alias: &str,
15068    ) -> Result<IrExpr, PyQLError> {
15069        use ast::PathStep;
15070
15071        let backlink_name = match steps.first() {
15072            Some(PathStep::Backlink(n)) => n.clone(),
15073            _ => return Err(self.type_err("internal: expected backlink step")),
15074        };
15075        // Without a type intersection the backlink spans every type that
15076        // declares the link at this target, so the row qualifies if any one of
15077        // them points at it.
15078        let Some(PathStep::TypeIntersection(type_ref)) = steps.get(1) else {
15079            // Without a type intersection the backlink spans every type that
15080            // declares the link at this target.
15081            return self.backlink_exists_over_owners(
15082                None,
15083                &backlink_name,
15084                &steps[1..],
15085                comparison,
15086                current_qname,
15087                alias,
15088            );
15089        };
15090
15091        let type_name = match &type_ref.module {
15092            Some(m) => format!("{}::{}", m, type_ref.name),
15093            None => type_ref.name.clone(),
15094        };
15095        let target_td = self.resolve_type(&type_name)?;
15096        if self.declares_backlink(target_td, &backlink_name, current_qname) {
15097            return self.backlink_exists_for_owner(
15098                target_td,
15099                &backlink_name,
15100                &steps[2..],
15101                comparison,
15102                current_qname,
15103                alias,
15104            );
15105        }
15106        // The intersection narrows to an interface or mixin that does not
15107        // declare the link itself — its implementors do, and a row of one of
15108        // them satisfies `[is ThatType]` all the same. Qualified, because that
15109        // is how an implementor names what it implements.
15110        let narrow_to = format!("{}::{}", target_td.module, target_td.name);
15111        self.backlink_exists_over_owners(
15112            Some(&narrow_to),
15113            &backlink_name,
15114            &steps[2..],
15115            comparison,
15116            current_qname,
15117            alias,
15118        )
15119    }
15120
15121    /// The type that declares the link a backlink narrowed to `owner_td`
15122    /// reads through.
15123    fn backlink_owner(
15124        &self,
15125        owner_td: &'a TypeDescriptor,
15126        backlink_name: &str,
15127        current_qname: &str,
15128    ) -> Result<&'a TypeDescriptor, PyQLError> {
15129        // `.<passkeys[is account::Account]` — the intersection narrows what
15130        // comes back, and the link itself may be declared further down: here
15131        // `passkeys` is Individual's, and an Individual is an Account. So the
15132        // owner is the concrete type that actually declares it, the same way
15133        // `backlink_exists_over_owners` resolves one.
15134        Ok(if self.declares_backlink(owner_td, backlink_name, current_qname) {
15135            owner_td
15136        } else {
15137            // Qualified, because that is how a type records the interfaces it
15138            // implements; the query may well have written the bare name.
15139            let narrowed = format!("{}::{}", owner_td.module, owner_td.name);
15140            let declaring: Vec<&'a TypeDescriptor> = self
15141                .schema
15142                .types
15143                .iter()
15144                .filter(|t| !t.abstract_ && Self::is_or_implements(t, &narrowed))
15145                .filter(|t| self.declares_backlink(t, backlink_name, current_qname))
15146                .collect();
15147            let declaring = Self::without_inherited_owners(declaring);
15148            match declaring.as_slice() {
15149                [only] => only,
15150                [] => owner_td,
15151                several => {
15152                    return Err(self.type_err(&format!(
15153                        "'{backlink_name}' pointing to {current_qname} is declared by {} types under \
15154                         {narrowed} ({}), so a backlink narrowed to it has no single source to read \
15155                         — narrow to one of them instead",
15156                        several.len(),
15157                        several
15158                            .iter()
15159                            .map(|t| format!("{}::{}", t.module, t.name))
15160                            .collect::<Vec<_>>()
15161                            .join(", "),
15162                    )));
15163                }
15164            }
15165        })
15166    }
15167
15168    /// Whether a backlink narrowed to `owner_td` reaches at most one object,
15169    /// which it does through an exclusive link.
15170    fn backlink_is_single(&self, owner_td: &'a TypeDescriptor, backlink_name: &str, current_qname: &str) -> bool {
15171        let Ok(owner) = self.backlink_owner(owner_td, backlink_name, current_qname) else {
15172            return false;
15173        };
15174        owner
15175            .links
15176            .iter()
15177            .any(|l| l.name == backlink_name && l.is_exclusive && self.link_target_reaches(&l.target, current_qname))
15178            || owner.multilinks.iter().any(|ml| {
15179                ml.name == backlink_name && ml.is_exclusive && self.link_target_reaches(&ml.target, current_qname)
15180            })
15181    }
15182
15183    /// Does `td` declare the link a backlink names, pointing at the type the
15184    /// traversal is standing on?
15185    fn declares_backlink(&self, td: &TypeDescriptor, backlink_name: &str, current_qname: &str) -> bool {
15186        td.links
15187            .iter()
15188            .any(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
15189            || td
15190                .multilinks
15191                .iter()
15192                .any(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
15193    }
15194
15195    /// EXISTS over every type that declares the backlink's link, OR-ed
15196    /// together: a row qualifies if any one of them points at it. `narrow_to`
15197    /// keeps only the types that satisfy an `[is …]` the path asked for.
15198    #[allow(clippy::too_many_arguments)]
15199    /// Drops the owners that only inherit the link from another owner in the
15200    /// list: reading the base already reads its subtypes' rows.
15201    fn without_inherited_owners(owners: Vec<&'a TypeDescriptor>) -> Vec<&'a TypeDescriptor> {
15202        let qnames: Vec<String> = owners.iter().map(|t| format!("{}::{}", t.module, t.name)).collect();
15203        owners
15204            .into_iter()
15205            .filter(|t| !t.bases.iter().any(|base| qnames.contains(base)))
15206            .collect()
15207    }
15208
15209    fn backlink_exists_over_owners(
15210        &mut self,
15211        narrow_to: Option<&str>,
15212        backlink_name: &str,
15213        rest: &[ast::PathStep],
15214        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
15215        current_qname: &str,
15216        alias: &str,
15217    ) -> Result<IrExpr, PyQLError> {
15218        let schema = self.schema;
15219        let owners: Vec<&'a TypeDescriptor> = schema
15220            .types
15221            .iter()
15222            .filter(|t| !t.abstract_)
15223            .filter(|t| narrow_to.is_none_or(|q| Self::is_or_implements(t, q)))
15224            .filter(|t| self.declares_backlink(t, backlink_name, current_qname))
15225            .collect();
15226        let owners = Self::without_inherited_owners(owners);
15227        if owners.is_empty() {
15228            return Err(self.type_err(&match narrow_to {
15229                Some(q) => format!("type {q} has no link or multi-link '{backlink_name}' pointing to {current_qname}"),
15230                None => format!("no type has a link or multi-link '{backlink_name}' pointing to {current_qname}"),
15231            }));
15232        }
15233        let mut combined: Option<IrExpr> = None;
15234        for owner_td in owners {
15235            let one = self.backlink_exists_for_owner(
15236                owner_td,
15237                backlink_name,
15238                rest,
15239                comparison.clone(),
15240                current_qname,
15241                alias,
15242            )?;
15243            combined = Some(match combined {
15244                None => one,
15245                Some(previous) => IrExpr::BinOp(Box::new(IrBinOp {
15246                    left: previous,
15247                    op: ast::BinOpKind::Or,
15248                    right: one,
15249                })),
15250            });
15251        }
15252        Ok(combined.expect("owners is non-empty"))
15253    }
15254
15255    /// Is `td` the type `qname` names, or one that implements/extends it?
15256    fn is_or_implements(td: &TypeDescriptor, qname: &str) -> bool {
15257        format!("{}::{}", td.module, td.name) == qname
15258            || td.interfaces.iter().any(|i| i == qname)
15259            || td.parents.iter().any(|p| p == qname)
15260            || td.bases.iter().any(|b| b == qname)
15261    }
15262
15263    /// A concrete type another concrete type extends (`BrandAddon`, which
15264    /// `BrandAddonBundle` extends). Its own table holds only its own rows, so
15265    /// reading the type means reading the subtypes' tables beside it.
15266    fn has_subtypes(&self, td: &TypeDescriptor) -> bool {
15267        let qname = format!("{}::{}", td.module, td.name);
15268        !td.abstract_ && self.schema.types.iter().any(|t| t.bases.contains(&qname))
15269    }
15270
15271    /// A plain `@pylon.abstract` mixin, materialised as nothing at all.
15272    /// Unlike an interface it backs no view, so naming its table reaches a
15273    /// relation that does not exist.
15274    fn backs_no_relation(td: &TypeDescriptor) -> bool {
15275        td.abstract_ && !td.materialized
15276    }
15277
15278    /// See `IrOutput::subtype_fanouts`.
15279    fn subtype_fanouts(&self) -> HashMap<(String, String), IrPolyFanout> {
15280        self.schema
15281            .types
15282            .iter()
15283            .filter(|t| self.has_subtypes(t) || Self::backs_no_relation(t))
15284            .filter_map(|t| {
15285                let fanout = self.poly_fanout_for(&format!("{}::{}", t.module, t.name))?;
15286                Some(((t.module.clone(), t.table.clone()), fanout))
15287            })
15288            .collect()
15289    }
15290
15291    /// Whether the type's rows come from more than one table — an interface,
15292    /// an abstract type (which has no table of its own), or a concrete type
15293    /// with subtypes — and so are read through the union that carries each
15294    /// row's own `__type__`.
15295    fn is_polymorphic(&self, td: &TypeDescriptor) -> bool {
15296        td.abstract_ || self.has_subtypes(td)
15297    }
15298
15299    /// One owner type's half of `compile_backlink_as_exists`: EXISTS over the
15300    /// rows of `target_td` that link back to `alias`, plus whatever the
15301    /// remaining path steps and comparison require of them.
15302    #[allow(clippy::too_many_arguments)]
15303    fn backlink_exists_for_owner(
15304        &mut self,
15305        target_td: &'a TypeDescriptor,
15306        backlink_name: &str,
15307        rest: &[ast::PathStep],
15308        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
15309        current_qname: &str,
15310        alias: &str,
15311    ) -> Result<IrExpr, PyQLError> {
15312        let backlink_name = backlink_name.to_string();
15313        let target_qname = format!("{}::{}", target_td.module, target_td.name);
15314        let target_table = target_td.table.clone();
15315        let t_alias = self.fresh_alias();
15316
15317        // Backlink source is either a single (FK) link or a multi-link
15318        // (junction table) on the target type — mirrors the equivalent
15319        // link-vs-multilink resolution the general shape-position backlink
15320        // code already does (see the `PathStep::Backlink` handling above in
15321        // `compile_path_expr`/similar), which this filter/exists-specific
15322        // path previously didn't: it only ever checked `target_td.links`,
15323        // so a self-referential-multilink backlink like `Person.friends`
15324        // failed to compile here even though it worked in a shape position.
15325        let join_cond = if let Some(l) = target_td
15326            .links
15327            .iter()
15328            .find(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
15329        {
15330            if l.is_junction_backed() {
15331                // Same junction-table EXISTS shape the multi-link branch
15332                // below uses — no direct FK column, since this link is
15333                // itself junction-backed.
15334                let (jt_table, jt_module, jt_owner_col, jt_current_col, _) = self.link_junction_info(target_td, l)?;
15335                let jt_alias = self.fresh_alias();
15336                IrExpr::UnaryOp(Box::new(IrUnaryOp {
15337                    op: ast::UnaryOpKind::Exists,
15338                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15339                        IrSource {
15340                            poly: None,
15341                            type_name: format!("{}::__jt__", jt_module),
15342                            table: jt_table,
15343                            alias: jt_alias.clone(),
15344                        },
15345                        vec![],
15346                        Some(IrExpr::BinOp(Box::new(IrBinOp {
15347                            left: IrExpr::BinOp(Box::new(IrBinOp {
15348                                left: IrExpr::ColumnRef {
15349                                    alias: jt_alias.clone(),
15350                                    column: jt_owner_col,
15351                                    pg_type: "uuid".to_string(),
15352                                },
15353                                op: ast::BinOpKind::Eq,
15354                                right: IrExpr::ColumnRef {
15355                                    alias: t_alias.clone(),
15356                                    column: "id".to_string(),
15357                                    pg_type: "uuid".to_string(),
15358                                },
15359                            })),
15360                            op: ast::BinOpKind::And,
15361                            right: IrExpr::BinOp(Box::new(IrBinOp {
15362                                left: IrExpr::ColumnRef {
15363                                    alias: jt_alias,
15364                                    column: jt_current_col,
15365                                    pg_type: "uuid".to_string(),
15366                                },
15367                                op: ast::BinOpKind::Eq,
15368                                right: IrExpr::ColumnRef {
15369                                    alias: alias.to_string(),
15370                                    column: "id".to_string(),
15371                                    pg_type: "uuid".to_string(),
15372                                },
15373                            })),
15374                        }))),
15375                    ))),
15376                }))
15377            } else {
15378                let fk_col = format!("{}_id", backlink_name);
15379                // Join condition: target.fk_col = current.id
15380                IrExpr::BinOp(Box::new(IrBinOp {
15381                    left: IrExpr::ColumnRef {
15382                        alias: t_alias.clone(),
15383                        column: fk_col,
15384                        pg_type: "uuid".to_string(),
15385                    },
15386                    op: ast::BinOpKind::Eq,
15387                    right: IrExpr::ColumnRef {
15388                        alias: alias.to_string(),
15389                        column: "id".to_string(),
15390                        pg_type: "uuid".to_string(),
15391                    },
15392                }))
15393            }
15394        } else if let Some(ml) = target_td
15395            .multilinks
15396            .iter()
15397            .find(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
15398            .cloned()
15399        {
15400            // Junction row connects t_alias (as the multi-link's owner/
15401            // "source") to the current row (as its "target") — no direct FK
15402            // column on either table, so this is a nested EXISTS over the
15403            // junction table rather than a simple column comparison.
15404            let (jt_table, jt_module, _, _, _) = self.multilink_junction_info(target_td, &ml)?;
15405            let jt_alias = self.fresh_alias();
15406            IrExpr::UnaryOp(Box::new(IrUnaryOp {
15407                op: ast::UnaryOpKind::Exists,
15408                operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15409                    IrSource {
15410                        poly: None,
15411                        type_name: format!("{}::__jt__", jt_module),
15412                        table: jt_table,
15413                        alias: jt_alias.clone(),
15414                    },
15415                    vec![],
15416                    Some(IrExpr::BinOp(Box::new(IrBinOp {
15417                        left: IrExpr::BinOp(Box::new(IrBinOp {
15418                            left: IrExpr::ColumnRef {
15419                                alias: jt_alias.clone(),
15420                                column: "source".to_string(),
15421                                pg_type: "uuid".to_string(),
15422                            },
15423                            op: ast::BinOpKind::Eq,
15424                            right: IrExpr::ColumnRef {
15425                                alias: t_alias.clone(),
15426                                column: "id".to_string(),
15427                                pg_type: "uuid".to_string(),
15428                            },
15429                        })),
15430                        op: ast::BinOpKind::And,
15431                        right: IrExpr::BinOp(Box::new(IrBinOp {
15432                            left: IrExpr::ColumnRef {
15433                                alias: jt_alias,
15434                                column: "target".to_string(),
15435                                pg_type: "uuid".to_string(),
15436                            },
15437                            op: ast::BinOpKind::Eq,
15438                            right: IrExpr::ColumnRef {
15439                                alias: alias.to_string(),
15440                                column: "id".to_string(),
15441                                pg_type: "uuid".to_string(),
15442                            },
15443                        })),
15444                    }))),
15445                ))),
15446            }))
15447        } else {
15448            return Err(PyQLError::Type(PyQLTypeError {
15449                message: format!(
15450                    "type {} has no link or multi-link '{}' pointing to {}",
15451                    target_qname, backlink_name, current_qname,
15452                ),
15453                position: Position { line: 0, col: 0 },
15454            }));
15455        };
15456
15457        let tail_cond = self.compile_backlink_tail(rest, comparison, &target_qname, &t_alias)?;
15458
15459        let filter = match tail_cond {
15460            Some(tc) => IrExpr::BinOp(Box::new(IrBinOp {
15461                left: join_cond,
15462                op: ast::BinOpKind::And,
15463                right: tc,
15464            })),
15465            None => join_cond,
15466        };
15467
15468        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15469            op: ast::UnaryOpKind::Exists,
15470            operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15471                IrSource {
15472                    poly: None,
15473                    type_name: target_qname,
15474                    table: target_table,
15475                    alias: t_alias,
15476                },
15477                vec![],
15478                Some(filter),
15479            ))),
15480        })))
15481    }
15482
15483    /// Compile the tail steps after `[Backlink, TypeIntersect]`.
15484    fn compile_backlink_tail(
15485        &mut self,
15486        steps: &[ast::PathStep],
15487        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
15488        target_qname: &str,
15489        t_alias: &str,
15490    ) -> Result<Option<IrExpr>, PyQLError> {
15491        use ast::PathStep;
15492
15493        // `.<contacts[is Account] = account` — nothing after the backlink, so
15494        // the objects it reaches are what is compared, by identity. Returning
15495        // nothing here dropped the comparison, leaving "any account links to
15496        // this row" in its place.
15497        if steps.is_empty() {
15498            return Ok(comparison.map(|(op, value, flip)| {
15499                let key = IrExpr::ColumnRef {
15500                    alias: t_alias.to_string(),
15501                    column: "id".to_string(),
15502                    pg_type: "uuid".to_string(),
15503                };
15504                let (left, right) = if flip { (value, key) } else { (key, value) };
15505                IrExpr::BinOp(Box::new(IrBinOp { left, op, right }))
15506            }));
15507        }
15508
15509        // Chained backlink: recurse (current_qname is now target_qname of the outer backlink)
15510        if matches!(steps.first(), Some(PathStep::Backlink(_))) {
15511            let inner = self.compile_backlink_as_exists(steps, comparison, target_qname, t_alias)?;
15512            return Ok(Some(inner));
15513        }
15514
15515        let target_td = self.resolve_type(target_qname)?;
15516
15517        // Single property or link FK
15518        if let [PathStep::Name(pointer_name)] = steps {
15519            if let Some(prop) = Self::resolve_property(target_td, pointer_name) {
15520                let col = IrExpr::ColumnRef {
15521                    alias: t_alias.to_string(),
15522                    column: prop.name.clone(),
15523                    pg_type: prop.pg_type.clone(),
15524                };
15525                return Ok(Some(self.apply_comparison(col, comparison)));
15526            }
15527            if let Some(link) = Self::resolve_link(target_td, pointer_name) {
15528                let col = if link.is_junction_backed() {
15529                    self.junction_target_id_expr(target_td, link, t_alias)?
15530                } else {
15531                    IrExpr::ColumnRef {
15532                        alias: t_alias.to_string(),
15533                        column: format!("{}_id", link.name),
15534                        pg_type: "uuid".to_string(),
15535                    }
15536                };
15537                return Ok(Some(self.apply_comparison(col, comparison)));
15538            }
15539            // Not a property or a link: a multi-link has no single column to
15540            // read, so it falls through to the general tail walk below, which
15541            // reports the same error for a name that is none of the three.
15542        }
15543
15544        // Two forward steps: link then property (single FK join)
15545        if let [PathStep::Name(link_name), PathStep::Name(prop_name)] = steps
15546            && let Some(link) = Self::resolve_link(target_td, link_name)
15547        {
15548            let link_target = link.target.clone();
15549            let target_id_expr = if link.is_junction_backed() {
15550                self.junction_target_id_expr(target_td, link, t_alias)?
15551            } else {
15552                IrExpr::ColumnRef {
15553                    alias: t_alias.to_string(),
15554                    column: format!("{}_id", link_name),
15555                    pg_type: "uuid".to_string(),
15556                }
15557            };
15558            let link_target_td = self.resolve_type(&link_target)?;
15559            let link_target_qname = format!("{}::{}", link_target_td.module, link_target_td.name);
15560            let link_target_table = link_target_td.table.clone();
15561            if let Some(prop) = Self::resolve_property(link_target_td, prop_name) {
15562                let l_alias = self.fresh_alias();
15563                let id_cond = IrExpr::BinOp(Box::new(IrBinOp {
15564                    left: IrExpr::ColumnRef {
15565                        alias: l_alias.clone(),
15566                        column: "id".to_string(),
15567                        pg_type: "uuid".to_string(),
15568                    },
15569                    op: ast::BinOpKind::Eq,
15570                    right: target_id_expr,
15571                }));
15572                let col = IrExpr::ColumnRef {
15573                    alias: l_alias.clone(),
15574                    column: prop.name.clone(),
15575                    pg_type: prop.pg_type.clone(),
15576                };
15577                let prop_cond = self.apply_comparison(col, comparison);
15578                let full = IrExpr::BinOp(Box::new(IrBinOp {
15579                    left: id_cond,
15580                    op: ast::BinOpKind::And,
15581                    right: prop_cond,
15582                }));
15583                return Ok(Some(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15584                    op: ast::UnaryOpKind::Exists,
15585                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15586                        IrSource {
15587                            poly: None,
15588                            type_name: link_target_qname,
15589                            table: link_target_table,
15590                            alias: l_alias,
15591                        },
15592                        vec![],
15593                        Some(full),
15594                    ))),
15595                }))));
15596            }
15597        }
15598
15599        // Anything else is an ordinary path from the backlink's target type, so
15600        // it is compiled as one rather than enumerated arity by arity. The two
15601        // cases above stay because each reads a single column; a step that
15602        // crosses a multi-link has none to read.
15603        let tail = ast::Path {
15604            steps: steps.to_vec(),
15605            partial: true,
15606        };
15607        let walked = self.compile_path(&tail, target_td, t_alias)?;
15608        Ok(Some(self.apply_comparison(walked, comparison)))
15609    }
15610
15611    /// Whether `e` stands for a *set* rather than one value: a walk gathered
15612    /// as an array, or a `with` binding of more than one row, which arrives as
15613    /// a reference to its CTE. Reading only the first of those left `filter
15614    /// .posts = ps` comparing against `(SELECT id FROM ps)` — one value, and
15615    /// an error from Postgres as soon as the binding held two.
15616    fn is_set_expr(&self, e: &IrExpr) -> bool {
15617        match e {
15618            IrExpr::ArrayFromSelect(_) => true,
15619            IrExpr::CteRef { name, .. } => self.multi_row_ctes.contains(name),
15620            _ => false,
15621        }
15622    }
15623
15624    /// Apply an optional comparison to a column ref, defaulting to IS NOT NULL.
15625    fn apply_comparison(&self, col: IrExpr, comparison: Option<(ast::BinOpKind, IrExpr, bool)>) -> IrExpr {
15626        let is_set = |e: &IrExpr| self.is_set_expr(e);
15627        match comparison {
15628            Some((op, val, flip)) => {
15629                let (l, r) = if flip { (val, col) } else { (col, val) };
15630                // A walk that crosses a multi-link stands for a *set*, which
15631                // an equality holds against when any element matches — the
15632                // same reading `compile_expr_ctx` gives one anywhere else.
15633                if matches!(op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) && is_set(&l) != is_set(&r) {
15634                    let (value, set) = if is_set(&r) { (l, r) } else { (r, l) };
15635                    let membership = IrExpr::BinOp(Box::new(IrBinOp {
15636                        left: value,
15637                        op: ast::BinOpKind::In,
15638                        right: set,
15639                    }));
15640                    return if matches!(op, ast::BinOpKind::Ne) {
15641                        IrExpr::UnaryOp(Box::new(IrUnaryOp {
15642                            op: ast::UnaryOpKind::Not,
15643                            operand: membership,
15644                        }))
15645                    } else {
15646                        membership
15647                    };
15648                }
15649                IrExpr::BinOp(Box::new(IrBinOp { left: l, op, right: r }))
15650            }
15651            None => ir_is_not_null(col),
15652        }
15653    }
15654
15655    /// The other side of a comparison whose path crosses a multi-link or a
15656    /// backlink, ready to sit inside the EXISTS that walk becomes.
15657    ///
15658    /// A set literal is meaningless as an expression anywhere else, so the
15659    /// general compiler hard-errors on one — but on the right of `in` it is
15660    /// exactly the array `= ANY(…)` wants, the same reading the `In`/`NotIn`
15661    /// arm of `compile_expr_ctx` gives it. Without this, `filter .posts.title
15662    /// in {'a', 'b'}` failed to compile at all: the walk reaches this builder
15663    /// before that arm is ever tried.
15664    fn compile_comparison_value(
15665        &mut self,
15666        value: &Expr,
15667        op: &ast::BinOpKind,
15668        td: &TypeDescriptor,
15669        alias: &str,
15670    ) -> Result<IrExpr, PyQLError> {
15671        match value {
15672            Expr::Set(elems) if matches!(op, ast::BinOpKind::In | ast::BinOpKind::NotIn) => Ok(IrExpr::Array(
15673                elems
15674                    .iter()
15675                    .map(|e| self.compile_expr(e, td, alias))
15676                    .collect::<Result<Vec<_>, _>>()?,
15677            )),
15678            other => self.compile_expr(other, td, alias),
15679        }
15680    }
15681
15682    /// `X in Y` where `X` is a set and a *value* is what the position wants:
15683    /// one boolean per element of `X`, gathered as the array every other
15684    /// multi-valued expression comes back as. `Y` is the set membership is
15685    /// tested against, so it stays whole.
15686    ///
15687    /// Only the left operand's walk is rebuilt — it already compiles to
15688    /// `ARRAY(SELECT <element> FROM <walk>)`, and what this does is move the
15689    /// comparison inside that select, where it is made once per row rather
15690    /// than once for the array. `Ok(None)` for anything else, including every
15691    /// condition position, which still collapses to the EXISTS below.
15692    fn try_elementwise_in(
15693        &mut self,
15694        b: &ast::BinOp,
15695        ctx: Option<(&TypeDescriptor, &str)>,
15696    ) -> Result<Option<IrExpr>, PyQLError> {
15697        // `=`/`!=` against a *set* is the same membership question spelled
15698        // differently — the reading `apply_comparison` already gives it — so
15699        // `all(.posts = ps)` has to answer per element too. Whether the other
15700        // side really is a set is only known once compiled, which the walk arm
15701        // below checks; the bare-binding arm takes `in` alone.
15702        let membership = matches!(b.op, ast::BinOpKind::In | ast::BinOpKind::NotIn);
15703        if !membership && !matches!(b.op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) {
15704            return Ok(None);
15705        }
15706        // `individuals in accounts` — the left operand is the whole set a
15707        // binding or a type names, so what answers per element is a select
15708        // over that set. Available with or without a schema type in scope,
15709        // since neither operand needs one.
15710        //
15711        // A quantifier's argument is a set position by definition, so this
15712        // holds inside one whether or not the surrounding position asked for
15713        // a value: `any(individuals in accounts)` is how those answers are
15714        // reduced back to one, and it has the same set to reduce either way.
15715        // A walk (below) has an EXISTS to fold into instead, which says the
15716        // same thing more directly, so only this arm needs the quantifier.
15717        if membership
15718            && (self.value_position || self.explicit_set_depth > 0)
15719            && let Some(root) = self.object_set_name(&b.left)
15720        {
15721            let rewritten = Expr::BinOp(Box::new(ast::BinOp {
15722                left: Expr::Path(ast::Path {
15723                    steps: vec![ast::PathStep::Name(root.clone()), ast::PathStep::Name("id".into())],
15724                    partial: false,
15725                }),
15726                op: b.op.clone(),
15727                right: b.right.clone(),
15728            }));
15729            let over_the_set = ast::SelectStmt {
15730                result: rewritten.clone(),
15731                filter: None,
15732                order_by: vec![],
15733                offset: None,
15734                limit: None,
15735                lock: None,
15736            };
15737            let ps = self.compile_expr_as_path_select(&over_the_set, &rewritten, &root, false)?;
15738            return Ok(Some(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(
15739                Box::new(ps),
15740            )))));
15741        }
15742        // A walk answers `any` with an EXISTS over its junction, which says
15743        // the same thing more directly, so outside a value position it only
15744        // takes the per-element route for `all` — where an EXISTS is the
15745        // wrong question and the answers have to be reduced one at a time.
15746        if !self.value_position && self.universal_set_depth == 0 {
15747            return Ok(None);
15748        }
15749        let Some((td, _)) = ctx else { return Ok(None) };
15750        // Checked before compiling anything: a walk that turns out to be
15751        // single-valued has to reach the ordinary path below, and compiling it
15752        // twice would burn aliases and re-hoist whatever it bound.
15753        let Expr::Path(p) = &b.left else { return Ok(None) };
15754        if !p.partial || !self.path_crosses_multi(td, &p.steps) {
15755            return Ok(None);
15756        }
15757        // The walk is compiled speculatively — whether it can be rebuilt this
15758        // way is only visible once it has been — so anything it bound on the
15759        // way is rolled back when it turns out it cannot.
15760        let hoisted = self.hoisted_ctes.len();
15761        let left = self.compile_expr_ctx(&b.left, ctx)?;
15762        // A walk onto objects arrives as the bare correlated subquery its rows
15763        // are; one onto values arrives already gathered into an array. Either
15764        // way what comes back from here is a set of booleans, so both leave as
15765        // an array.
15766        let mut ps = match left {
15767            IrExpr::PathSubquery(ps) | IrExpr::ObjectPathSubquery(ps) => ps,
15768            IrExpr::ArrayFromSelect(source) => match *source {
15769                IrArraySource::PathSelect(ps) => ps,
15770                _ => {
15771                    self.hoisted_ctes.truncate(hoisted);
15772                    return Ok(None);
15773                }
15774            },
15775            _ => {
15776                self.hoisted_ctes.truncate(hoisted);
15777                return Ok(None);
15778            }
15779        };
15780        // A set literal on the right is the one expression position one is
15781        // meaningful in — see the `In`/`NotIn` arm of `compile_expr_ctx`,
15782        // which reads it as the array `= ANY(…)` wants.
15783        let right = match &b.right {
15784            Expr::Set(elems) => IrExpr::Array(
15785                elems
15786                    .iter()
15787                    .map(|e| self.compile_expr_ctx(e, ctx))
15788                    .collect::<Result<Vec<_>, _>>()?,
15789            ),
15790            other => self.compile_expr_ctx(other, ctx)?,
15791        };
15792        // An equality earns this route only by really being a membership
15793        // test; against a single value it is an ordinary comparison, which
15794        // the EXISTS fold below already reads correctly.
15795        if !membership && !self.is_set_expr(&right) {
15796            self.hoisted_ctes.truncate(hoisted);
15797            return Ok(None);
15798        }
15799        // Objects are compared by identity, which is the id the walk's last
15800        // step lands on — the same reading `in` gives them anywhere else.
15801        let element = match &ps.result {
15802            IrPathResult::Scalar(expr, _) => expr.clone(),
15803            IrPathResult::Object { alias, .. } => IrExpr::ColumnRef {
15804                alias: alias.clone(),
15805                column: "id".to_string(),
15806                pg_type: "uuid".to_string(),
15807            },
15808        };
15809        // `apply_comparison` turns the equality into the membership it stands
15810        // for; `in` it leaves alone.
15811        let per_element = self.apply_comparison(element, Some((b.op.clone(), right, false)));
15812        ps.result = IrPathResult::Scalar(per_element, None);
15813        Ok(Some(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(ps)))))
15814    }
15815
15816    /// If `b` has a multi-link path (any depth) on either side, compile as EXISTS over the junction.
15817    fn try_multilink_exists(
15818        &mut self,
15819        b: &ast::BinOp,
15820        td: &TypeDescriptor,
15821        alias: &str,
15822    ) -> Result<Option<IrExpr>, PyQLError> {
15823        // A bare `.multilink` counts: comparing the link itself to an object
15824        // (`filter any(.emails = email)`) is the same set-membership question
15825        // as comparing something reached through it, just with no tail.
15826        fn ml_first_name(steps: &[ast::PathStep]) -> Option<&str> {
15827            match steps.first()? {
15828                ast::PathStep::Name(n) => Some(n.as_str()),
15829                _ => None,
15830            }
15831        }
15832
15833        let (path_steps, value_ast, flip) = if let Expr::Path(p) = &b.left {
15834            if p.partial {
15835                if let Some(ln) = ml_first_name(&p.steps) {
15836                    if Self::resolve_multilink(td, ln).is_some() {
15837                        (p.steps.as_slice(), &b.right, false)
15838                    } else {
15839                        return Ok(None);
15840                    }
15841                } else {
15842                    return Ok(None);
15843                }
15844            } else {
15845                return Ok(None);
15846            }
15847        } else if let Expr::Path(p) = &b.right {
15848            if p.partial {
15849                if let Some(ln) = ml_first_name(&p.steps) {
15850                    if Self::resolve_multilink(td, ln).is_some() {
15851                        (p.steps.as_slice(), &b.left, true)
15852                    } else {
15853                        return Ok(None);
15854                    }
15855                } else {
15856                    return Ok(None);
15857                }
15858            } else {
15859                return Ok(None);
15860            }
15861        } else {
15862            return Ok(None);
15863        };
15864
15865        let value_expr = self.compile_comparison_value(value_ast, &b.op, td, alias)?;
15866        let ml_name = match &path_steps[0] {
15867            ast::PathStep::Name(n) => n.clone(),
15868            _ => return Ok(None),
15869        };
15870        let ml = Self::resolve_multilink(td, &ml_name).unwrap();
15871
15872        // Warn: multi-link traversal in a comparison returns a set, not a single boolean.
15873        // The query works (compiled as EXISTS), but `any()` makes the intent explicit.
15874        if self.explicit_set_depth == 0 {
15875            // Rendered back the way it was written — a path the reader cannot
15876            // find in their own query is worse than no path at all.
15877            let mut pointer_path = String::new();
15878            for step in path_steps {
15879                match step {
15880                    ast::PathStep::Name(n) => {
15881                        pointer_path.push('.');
15882                        pointer_path.push_str(n);
15883                    }
15884                    ast::PathStep::Backlink(n) => {
15885                        pointer_path.push_str(".<");
15886                        pointer_path.push_str(n);
15887                    }
15888                    ast::PathStep::LinkProp(n) => {
15889                        pointer_path.push('@');
15890                        pointer_path.push_str(n);
15891                    }
15892                    ast::PathStep::TypeIntersection(t) => {
15893                        pointer_path.push_str("[is ");
15894                        pointer_path.push_str(&t.qualified_name());
15895                        pointer_path.push(']');
15896                    }
15897                }
15898            }
15899            self.warnings.push(format!(
15900                "possibly more than one element returned by an expression in a FILTER clause \
15901                 (multi-link '{pointer_path}'); wrap with any() to make intent explicit",
15902            ));
15903        }
15904
15905        // Clone what we need to avoid borrow conflicts with self below.
15906        let ml_target = ml.target.clone();
15907        let ml_through = ml.through.clone();
15908        let td_module = td.module.clone();
15909        let td_name = td.name.clone();
15910        let td_table = td.table.clone();
15911
15912        // tail steps are everything after the multi-link name (path_steps[1..])
15913        let tail_steps: Vec<ast::PathStep> = path_steps[1..].to_vec();
15914
15915        let jt_alias = self.fresh_alias();
15916
15917        // Resolve junction table columns.
15918        let (jt_table, jt_module, jt_src_col, jt_tgt_col) = if let Some(through_qname) = &ml_through {
15919            let through_td = self.resolve_type(through_qname)?;
15920            if through_td.junction {
15921                // See `junction_info_for`'s doc comment: owner-derived,
15922                // never `through_td.table` itself.
15923                (
15924                    format!("{}.{}", td_table, ml_name),
15925                    td_module.clone(),
15926                    "source".to_string(),
15927                    "target".to_string(),
15928                )
15929            } else {
15930                let source_qname = format!("{}::{}", td_module, td_name);
15931                let src_col = through_td
15932                    .links
15933                    .iter()
15934                    .find(|l| l.target == source_qname)
15935                    .ok_or_else(|| {
15936                        PyQLError::Type(PyQLTypeError {
15937                            message: format!("through type {through_qname} has no link to {source_qname}"),
15938                            position: Position { line: 0, col: 0 },
15939                        })
15940                    })?
15941                    .name
15942                    .clone();
15943                let tgt_col = through_td
15944                    .links
15945                    .iter()
15946                    .find(|l| l.target == ml_target && l.name != src_col)
15947                    .or_else(|| through_td.links.iter().find(|l| l.target == ml_target))
15948                    .ok_or_else(|| {
15949                        PyQLError::Type(PyQLTypeError {
15950                            message: format!("through type {through_qname} has no link to {ml_target}"),
15951                            position: Position { line: 0, col: 0 },
15952                        })
15953                    })?
15954                    .name
15955                    .clone();
15956                (
15957                    through_td.table.clone(),
15958                    through_td.module.clone(),
15959                    format!("{}_id", src_col),
15960                    format!("{}_id", tgt_col),
15961                )
15962            }
15963        } else {
15964            (
15965                format!("{}.{}", td_table, ml_name),
15966                td_module.clone(),
15967                "source".to_string(),
15968                "target".to_string(),
15969            )
15970        };
15971
15972        // source filter: jt.src_col = parent.id
15973        let src_filter = IrExpr::BinOp(Box::new(IrBinOp {
15974            left: IrExpr::ColumnRef {
15975                alias: jt_alias.clone(),
15976                column: jt_src_col,
15977                pg_type: "uuid".to_string(),
15978            },
15979            op: ast::BinOpKind::Eq,
15980            right: IrExpr::ColumnRef {
15981                alias: alias.to_string(),
15982                column: "id".to_string(),
15983                pg_type: "uuid".to_string(),
15984            },
15985        }));
15986
15987        // Build tail filter: what to compare inside the junction/target EXISTS
15988        let tail_filter = self.compile_path_tail_filter(
15989            &tail_steps,
15990            b.op.clone(),
15991            value_expr,
15992            flip,
15993            &ml_target,
15994            &jt_alias,
15995            &jt_tgt_col,
15996        )?;
15997
15998        let full_filter = IrExpr::BinOp(Box::new(IrBinOp {
15999            left: src_filter,
16000            op: ast::BinOpKind::And,
16001            right: tail_filter,
16002        }));
16003
16004        // EXISTS(SELECT 1 FROM junction jt WHERE ...)
16005        let jt_source = IrSource {
16006            poly: None,
16007            type_name: format!("{}::__jt__", jt_module),
16008            table: jt_table,
16009            alias: jt_alias,
16010        };
16011        let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(jt_source, vec![], Some(full_filter))));
16012
16013        Ok(Some(IrExpr::UnaryOp(Box::new(IrUnaryOp {
16014            op: ast::UnaryOpKind::Exists,
16015            operand: inner,
16016        }))))
16017    }
16018
16019    /// Build a filter expression for the tail steps after the multi-link in an EXISTS context.
16020    ///
16021    /// `steps` = path steps after the multi-link name (e.g. ["company", "id"] for .friends.company.id)
16022    /// `jt_alias` = alias of the junction table row
16023    /// `jt_tgt_col` = column in the junction table holding the target id (e.g. "target" or "friend_id")
16024    /// `target_type` = qualified name of the multi-link's target type (e.g. "default::Person")
16025    #[allow(clippy::too_many_arguments)]
16026    fn compile_path_tail_filter(
16027        &mut self,
16028        steps: &[ast::PathStep],
16029        op: ast::BinOpKind,
16030        value_expr: IrExpr,
16031        flip: bool,
16032        target_type: &str,
16033        jt_alias: &str,
16034        jt_tgt_col: &str,
16035    ) -> Result<IrExpr, PyQLError> {
16036        // No tail at all — the multi-link itself is what is being compared,
16037        // so the junction row's target id is the whole answer.
16038        if steps.is_empty() {
16039            let col_ref = IrExpr::ColumnRef {
16040                alias: jt_alias.to_string(),
16041                column: jt_tgt_col.to_string(),
16042                pg_type: "uuid".to_string(),
16043            };
16044            // Through `apply_comparison` rather than built here, so an
16045            // equality against a *set* — a binding of more than one row —
16046            // reads as membership instead of comparing against the one value
16047            // a scalar subquery can return.
16048            return Ok(self.apply_comparison(col_ref, Some((op, value_expr, flip))));
16049        }
16050
16051        // `.emails[is account::Email].email` — an intersection narrows what the
16052        // walk continues from. The remaining steps read against the narrowed
16053        // type, and because its own table is what they join to, a row of any
16054        // other type drops out on its own: the narrowing needs no separate
16055        // check.
16056        if let Some(ast::PathStep::TypeIntersection(type_ref)) = steps.first() {
16057            let narrowed = self.resolve_type(&type_ref.qualified_name())?;
16058            let narrowed_qname = format!("{}::{}", narrowed.module, narrowed.name);
16059            return self.compile_path_tail_filter(
16060                &steps[1..],
16061                op,
16062                value_expr,
16063                flip,
16064                &narrowed_qname,
16065                jt_alias,
16066                jt_tgt_col,
16067            );
16068        }
16069
16070        let first_name = match steps.first() {
16071            Some(ast::PathStep::Name(n)) => n.clone(),
16072            _ => return Err(self.type_err("expected a property or link name in path")),
16073        };
16074
16075        let target_td = self.resolve_type(target_type)?;
16076        let target_table = target_td.table.clone();
16077
16078        if steps.len() == 1 {
16079            // Terminal step: must be a scalar property or "id"
16080            if first_name == "id" {
16081                // FK optimisation: compare jt.target directly
16082                let col_ref = IrExpr::ColumnRef {
16083                    alias: jt_alias.to_string(),
16084                    column: jt_tgt_col.to_string(),
16085                    pg_type: "uuid".to_string(),
16086                };
16087                let (l, r) = if flip {
16088                    (value_expr, col_ref)
16089                } else {
16090                    (col_ref, value_expr)
16091                };
16092                return Ok(IrExpr::BinOp(Box::new(IrBinOp { left: l, op, right: r })));
16093            }
16094            // Check if it's a link (object) rather than a scalar
16095            // A single link is compared by the foreign key it stores, the
16096            // same way `.link = obj` is one step higher up
16097            // (`any(.access_grants.account = account)`). A multi-link has no
16098            // column to compare and would need a junction of its own.
16099            let single_link = target_td
16100                .links
16101                .iter()
16102                .find(|l| l.name == first_name && !l.is_junction_backed());
16103            if single_link.is_none() && target_td.multilinks.iter().any(|l| l.name == first_name) {
16104                let target_display = target_type.replace("::", ".");
16105                return Err(PyQLError::Type(PyQLTypeError {
16106                    message: format!(
16107                        "operator '{op}' cannot be applied to operands of type '{target_display}' and the value type",
16108                        op = op,
16109                    ),
16110                    position: Position { line: 0, col: 0 },
16111                }));
16112            }
16113            let (prop_name, prop_pg) = match single_link {
16114                Some(link) => (format!("{}_id", link.name), "uuid".to_string()),
16115                None => {
16116                    let prop = target_td
16117                        .properties
16118                        .iter()
16119                        .find(|p| p.name == first_name)
16120                        .ok_or_else(|| self.field_err(&first_name, target_type))?;
16121                    (prop.name.clone(), prop.pg_type.clone())
16122                }
16123            };
16124            let tgt_alias = self.fresh_alias();
16125            // Build EXISTS(SELECT 1 FROM target WHERE target.id = jt.target AND target.prop op value)
16126            let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
16127                left: IrExpr::ColumnRef {
16128                    alias: tgt_alias.clone(),
16129                    column: "id".to_string(),
16130                    pg_type: "uuid".to_string(),
16131                },
16132                op: ast::BinOpKind::Eq,
16133                right: IrExpr::ColumnRef {
16134                    alias: jt_alias.to_string(),
16135                    column: jt_tgt_col.to_string(),
16136                    pg_type: "uuid".to_string(),
16137                },
16138            }));
16139            let prop_col = IrExpr::ColumnRef {
16140                alias: tgt_alias.clone(),
16141                column: prop_name,
16142                pg_type: prop_pg,
16143            };
16144            let (pl, pr) = if flip {
16145                (value_expr, prop_col)
16146            } else {
16147                (prop_col, value_expr)
16148            };
16149            let prop_filter = IrExpr::BinOp(Box::new(IrBinOp {
16150                left: pl,
16151                op,
16152                right: pr,
16153            }));
16154            let full = IrExpr::BinOp(Box::new(IrBinOp {
16155                left: id_filter,
16156                op: ast::BinOpKind::And,
16157                right: prop_filter,
16158            }));
16159            let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
16160                IrSource {
16161                    poly: None,
16162                    type_name: target_type.to_string(),
16163                    table: target_table,
16164                    alias: tgt_alias,
16165                },
16166                vec![],
16167                Some(full),
16168            )));
16169            return Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
16170                op: ast::UnaryOpKind::Exists,
16171                operand: inner,
16172            })));
16173        }
16174
16175        // steps.len() >= 2: first_name must be a single link (not multi)
16176        if target_td.multilinks.iter().any(|l| l.name == first_name) {
16177            return Err(self.type_err("nested multi-link traversal in comparison is not yet supported"));
16178        }
16179        let link = target_td
16180            .links
16181            .iter()
16182            .find(|l| l.name == first_name)
16183            .ok_or_else(|| self.field_err(&first_name, target_type))?;
16184        if link.is_junction_backed() {
16185            // A junction-backed link's correlation is a subquery, not a
16186            // plain FK column, and this recursive path-tail resolver is
16187            // built entirely around passing a (alias, column) pair down to
16188            // the next level — supporting it here would need a broader
16189            // signature change. Fail loudly rather than silently reference
16190            // a `{name}_id` column that doesn't exist for this link.
16191            return Err(self.type_err(&format!(
16192                "filtering through a junction-backed single link ('{first_name}') nested inside \
16193                 a multi-link path comparison is not yet supported — filter on '.{first_name}' \
16194                 directly instead"
16195            )));
16196        }
16197        let next_target = link.target.clone();
16198        let fk_col = format!("{}_id", first_name);
16199        let tgt_alias = self.fresh_alias();
16200
16201        // FK optimisation for [single_link, "id"]:
16202        if steps.len() == 2
16203            && let Some(ast::PathStep::Name(n)) = steps.get(1)
16204            && n == "id"
16205        {
16206            // Compare tgt.{fk_col} (the FK in current target) directly
16207            // We need an EXISTS over the target to access fk_col
16208            // Actually: EXISTS(target WHERE target.id = jt.target AND target.{fk_col} op value)
16209            let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
16210                left: IrExpr::ColumnRef {
16211                    alias: tgt_alias.clone(),
16212                    column: "id".to_string(),
16213                    pg_type: "uuid".to_string(),
16214                },
16215                op: ast::BinOpKind::Eq,
16216                right: IrExpr::ColumnRef {
16217                    alias: jt_alias.to_string(),
16218                    column: jt_tgt_col.to_string(),
16219                    pg_type: "uuid".to_string(),
16220                },
16221            }));
16222            let fk_ref = IrExpr::ColumnRef {
16223                alias: tgt_alias.clone(),
16224                column: fk_col,
16225                pg_type: "uuid".to_string(),
16226            };
16227            let (fl, fr) = if flip {
16228                (value_expr, fk_ref)
16229            } else {
16230                (fk_ref, value_expr)
16231            };
16232            let fk_filter = IrExpr::BinOp(Box::new(IrBinOp {
16233                left: fl,
16234                op,
16235                right: fr,
16236            }));
16237            let full = IrExpr::BinOp(Box::new(IrBinOp {
16238                left: id_filter,
16239                op: ast::BinOpKind::And,
16240                right: fk_filter,
16241            }));
16242            let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
16243                IrSource {
16244                    poly: None,
16245                    type_name: target_type.to_string(),
16246                    table: target_table,
16247                    alias: tgt_alias,
16248                },
16249                vec![],
16250                Some(full),
16251            )));
16252            return Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
16253                op: ast::UnaryOpKind::Exists,
16254                operand: inner,
16255            })));
16256        }
16257
16258        // General case: EXISTS(target WHERE target.id = jt.jt_tgt_col AND <tail_filter for steps[1..]>)
16259        // Recursive call uses tgt_alias.fk_col as the "pointer to the next type's id"
16260        let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
16261            left: IrExpr::ColumnRef {
16262                alias: tgt_alias.clone(),
16263                column: "id".to_string(),
16264                pg_type: "uuid".to_string(),
16265            },
16266            op: ast::BinOpKind::Eq,
16267            right: IrExpr::ColumnRef {
16268                alias: jt_alias.to_string(),
16269                column: jt_tgt_col.to_string(),
16270                pg_type: "uuid".to_string(),
16271            },
16272        }));
16273        let nested_filter =
16274            self.compile_path_tail_filter(&steps[1..], op, value_expr, flip, &next_target, &tgt_alias, &fk_col)?;
16275        let full = IrExpr::BinOp(Box::new(IrBinOp {
16276            left: id_filter,
16277            op: ast::BinOpKind::And,
16278            right: nested_filter,
16279        }));
16280        let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
16281            IrSource {
16282                poly: None,
16283                type_name: target_type.to_string(),
16284                table: target_table,
16285                alias: tgt_alias,
16286            },
16287            vec![],
16288            Some(full),
16289        )));
16290        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
16291            op: ast::UnaryOpKind::Exists,
16292            operand: inner,
16293        })))
16294    }
16295}
16296
16297/// Where a walk reads a multi-link this statement also wrote: the CTE holding
16298/// the junction rows, and — when the targets were inserted here too — the CTE
16299/// holding those.
16300#[derive(Clone)]
16301struct JunctionReadOverride {
16302    junction: String,
16303    targets: Option<String>,
16304}
16305
16306/// What an `UNLESS CONFLICT … ELSE (UPDATE …)` clause contributes: the
16307/// `DO UPDATE SET` assignments, and the predicate deciding whether the
16308/// conflicting row is touched at all when the ELSE UPDATE carried a filter.
16309struct ConflictElse {
16310    sets: Vec<(String, IrExpr)>,
16311    predicate: Option<IrExpr>,
16312}
16313
16314impl<'a> Compiler<'a> {
16315    /// Compile `UNLESS CONFLICT [ON expr] [ELSE (UPDATE …)]` into `IrConflict`.
16316    fn compile_conflict(
16317        &mut self,
16318        uc: &ast::UnlessConflict,
16319        td: &TypeDescriptor,
16320    ) -> Result<(IrConflict, Vec<IrMultiLinkMutation>), PyQLError> {
16321        // ON clause: compile with empty alias → bare column name (`"col"` not `"t0"."col"`)
16322        // so the emitter produces `ON CONFLICT ("name")` not `ON CONFLICT ("t0"."name")`.
16323        let on = uc.on.as_ref().map(|e| self.compile_expr(e, td, "")).transpose()?;
16324        let mut appends = vec![];
16325        let mut do_update_where = None;
16326        let do_update = match uc.else_.as_ref() {
16327            Some(e) => {
16328                let resolved = self.compile_conflict_else(e, &mut appends)?;
16329                do_update_where = resolved.predicate;
16330                Some(resolved.sets)
16331            }
16332            None => None,
16333        };
16334        Ok((
16335            IrConflict {
16336                on,
16337                do_update,
16338                do_update_where,
16339            },
16340            appends,
16341        ))
16342    }
16343
16344    /// Compile the ELSE clause of UNLESS CONFLICT, which must be `(UPDATE Type SET { … })`.
16345    ///
16346    /// Assignments are compiled with the target table's own bare name (not
16347    /// schema-qualified, and not the usual `t0`-style fresh alias) as the
16348    /// qualifying "alias" so a self-referencing RHS (`.stock` in `stock :=
16349    /// .stock + 1`) resolves unambiguously to the *existing* conflicting
16350    /// row. A genuinely bare, unqualified column reference here is
16351    /// ambiguous in Postgres between the existing row and the `excluded`
16352    /// pseudo-row (confirmed live: "column reference ... is ambiguous")
16353    /// even though only one of the two is ever actually reachable this way
16354    /// (nothing here ever compiles a reference to `excluded`) — Postgres's
16355    /// own docs describe exactly this qualification: "the existing row
16356    /// using the table's name (or an alias)," no explicit `AS` needed on
16357    /// the INSERT target for that self-reference to work.
16358    fn compile_conflict_else(
16359        &mut self,
16360        expr: &Expr,
16361        appends: &mut Vec<IrMultiLinkMutation>,
16362    ) -> Result<ConflictElse, PyQLError> {
16363        let Expr::SubQuery(stmt) = expr else {
16364            return Err(self.type_err("UNLESS CONFLICT ELSE must be an UPDATE expression, e.g. ELSE (UPDATE …)"));
16365        };
16366        // `else (select usage::Allowance)` — yield the conflicting row rather
16367        // than change it. `DO NOTHING` returns nothing at all, so the row is
16368        // read back by assigning its own key to itself, which is what makes
16369        // `RETURNING` see it.
16370        if let Stmt::Select(sel) = stmt.as_ref()
16371            && sel.filter.is_none()
16372            && sel.limit.is_none()
16373            && sel.offset.is_none()
16374            && let Ok(type_name) = self.expr_as_type_name(&sel.result)
16375            && let Ok(sel_td) = self.resolve_type(&type_name)
16376        {
16377            let table = sel_td.table.clone();
16378            let pk = sel_td
16379                .properties
16380                .iter()
16381                .find(|p| p.is_pk)
16382                .ok_or_else(|| self.type_err(&format!("type '{type_name}' has no primary key to read back")))?;
16383            return Ok(ConflictElse {
16384                sets: vec![(
16385                    pk.name.clone(),
16386                    IrExpr::ColumnRef {
16387                        alias: table,
16388                        column: pk.name.clone(),
16389                        pg_type: pk.pg_type.clone(),
16390                    },
16391                )],
16392                predicate: None,
16393            });
16394        }
16395        let Stmt::Update(upd) = stmt.as_ref() else {
16396            return Err(self.type_err(
16397                "UNLESS CONFLICT ELSE must be an UPDATE that changes the conflicting row, or a \
16398                 SELECT of its type to read it back unchanged",
16399            ));
16400        };
16401        let type_name = self.expr_as_type_name(&upd.subject)?;
16402        let upd_td = self.resolve_type(&type_name)?;
16403        let table = upd_td.table.clone();
16404        // The ON CONFLICT target says *which* row conflicts; the ELSE UPDATE's
16405        // own filter says whether to touch it at all, and it is honoured.
16406        // Dropping it silently turned `unless conflict on .key else (update T
16407        // filter .expires_at < now() set { … })` into an unconditional steal of
16408        // a live advisory lock. Compiled against the table name, which is what
16409        // refers to the *existing* row inside `DO UPDATE` (`excluded` is the
16410        // proposed one).
16411        let do_update_where = upd
16412            .filter
16413            .as_ref()
16414            .map(|f| self.as_condition(|c| c.compile_expr(f, upd_td, &table)))
16415            .transpose()?;
16416        // A multi-link cannot be written by `DO UPDATE SET` — junction rows
16417        // are separate DML. Appending them to the enclosing insert instead
16418        // applies them to whichever row comes back, inserted or conflicting,
16419        // and the junction insert is `ON CONFLICT DO NOTHING`, so the
16420        // inserted branch (which already wrote the same rows from its own
16421        // shape) is unaffected.
16422        let mut scalar_shape = vec![];
16423        for el in &upd.shape {
16424            let pointer_name = path_leaf(&el.path)?;
16425            let Some(ml) = Self::resolve_multilink(upd_td, pointer_name) else {
16426                scalar_shape.push(el.clone());
16427                continue;
16428            };
16429            if el.op == ShapeOp::Remove {
16430                return Err(self.type_err(&format!(
16431                    "cannot use `-=` for multi-link '{pointer_name}' inside an UNLESS CONFLICT \
16432                     ELSE clause; the rows to remove are not known until the conflict resolves"
16433                )));
16434            }
16435            let Some(value) = &el.compexpr else { continue };
16436            let (jt, module, src_col, tgt_col, through_td) = self.own_multilink_junction_info(upd_td, ml)?;
16437            let values = self.compile_multilink_values(value, upd_td, &table, through_td)?;
16438            appends.push(IrMultiLinkMutation {
16439                junction_table: jt,
16440                module,
16441                source_col: src_col,
16442                target_col: tgt_col,
16443                values,
16444                single: false,
16445            });
16446        }
16447        let sets = self.compile_assignments_for_update(&scalar_shape, upd_td, &table)?;
16448        Ok(ConflictElse {
16449            sets,
16450            predicate: do_update_where,
16451        })
16452    }
16453
16454    /// Compile a nested INSERT/UPDATE/DELETE into its own CTE and return that
16455    /// CTE's name. Postgres cannot run DML inside another statement's value
16456    /// list, so it is hoisted into the enclosing statement's `WITH` and read
16457    /// back from there — see `pending_nested_ctes`.
16458    /// Compile a DML statement into a CTE of the query's own `WITH`.
16459    ///
16460    /// Unlike `hoist_nested_dml`, whose CTE belongs to the enclosing
16461    /// INSERT/UPDATE it was nested inside, this one has no enclosing statement
16462    /// to attach to — the select that names it *is* the top level.
16463    fn hoist_dml_as_cte(&mut self, stmt: &Stmt) -> Result<(String, String), PyQLError> {
16464        let type_name = self.dml_subject_type(stmt)?;
16465        let before = std::mem::take(&mut self.for_vars_read);
16466        let inner = self.compile_stmt(stmt);
16467        let read = std::mem::replace(&mut self.for_vars_read, before);
16468        let correlated_to = self
16469            .for_scope
16470            .iter()
16471            .rev()
16472            .find(|slot| read.contains(*slot))
16473            .map(|slot| format!("_for_{slot}"));
16474        self.for_vars_read.extend(read);
16475        let cte_name = self.fresh_nested_cte_name();
16476        if let Some(slot) = correlated_to.as_deref().and_then(|i| i.strip_prefix("_for_")) {
16477            self.correlated_bindings.insert(cte_name.clone(), slot.to_string());
16478        }
16479        self.hoisted_ctes.push(IrCteDef {
16480            name: cte_name.clone(),
16481            stmt: inner?,
16482            type_name: type_name.clone(),
16483            correlated_to,
16484        });
16485        Ok((cte_name, type_name))
16486    }
16487
16488    /// True when a `for`'s body ultimately mutates, through however many
16489    /// nested loops. A loop over loops over a `select` mutates nothing.
16490    fn for_body_mutates(stmt: &Stmt) -> bool {
16491        match stmt {
16492            Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_) => true,
16493            Stmt::For(inner) => Self::for_body_mutates(&inner.body),
16494            _ => false,
16495        }
16496    }
16497
16498    /// `select (for x in S union (insert T { … })) { id }` — a loop whose body
16499    /// mutates cannot be a select's source as written: the source is emitted as
16500    /// a `CROSS JOIN LATERAL`, and Postgres allows a data-modifying statement
16501    /// only at the top level of a `WITH`. So the loop is bound to a CTE of its
16502    /// own and the select re-rooted at that name, which is the `with c := (for
16503    /// … union (insert …)) select c { id }` spelling the binding path already
16504    /// builds. Compiling the loop up front also puts its variable in scope
16505    /// before the body's subject is resolved, which an `update x` body needs.
16506    /// Returns the re-rooted result expression, or `None` when the select's
16507    /// source is not such a loop.
16508    fn bind_mutating_loop_source(&mut self, result_expr: &Expr) -> Result<Option<Expr>, PyQLError> {
16509        let (subject, shape) = match result_expr {
16510            Expr::SubQuery(stmt) => (stmt.as_ref(), None),
16511            Expr::Shape(sh) => match sh.expr.as_ref() {
16512                Some(Expr::SubQuery(stmt)) => (stmt.as_ref(), Some(sh.as_ref())),
16513                _ => return Ok(None),
16514            },
16515            _ => return Ok(None),
16516        };
16517        if !matches!(subject, Stmt::For(f) if Self::for_body_mutates(&f.body)) {
16518            return Ok(None);
16519        }
16520        // An enclosing loop's variable read from inside this one makes the CTE
16521        // correlated to that iteration, the same bookkeeping `hoist_dml_as_cte`
16522        // does for a mutation hoisted out of a loop.
16523        let before = std::mem::take(&mut self.for_vars_read);
16524        let inner = self.compile_stmt(subject);
16525        let read = std::mem::replace(&mut self.for_vars_read, before);
16526        let correlated_to = self
16527            .for_scope
16528            .iter()
16529            .rev()
16530            .find(|slot| read.contains(*slot))
16531            .map(|slot| format!("_for_{slot}"));
16532        self.for_vars_read.extend(read);
16533        let inner = inner?;
16534        let cte_name = self.fresh_nested_cte_name();
16535        if let Some(slot) = correlated_to.as_deref().and_then(|i| i.strip_prefix("_for_")) {
16536            self.correlated_bindings.insert(cte_name.clone(), slot.to_string());
16537        }
16538        let type_name = self.register_cte(&cte_name, &inner);
16539        self.hoisted_ctes.push(IrCteDef {
16540            name: cte_name.clone(),
16541            stmt: inner,
16542            type_name,
16543            correlated_to,
16544        });
16545        let bound = Expr::Path(ast::Path {
16546            steps: vec![ast::PathStep::Name(cte_name)],
16547            partial: false,
16548        });
16549        Ok(Some(match shape {
16550            Some(sh) => Expr::Shape(Box::new(ast::ShapeExpr {
16551                expr: Some(bound),
16552                elements: sh.elements.clone(),
16553                marker_offset: sh.marker_offset,
16554            })),
16555            None => bound,
16556        }))
16557    }
16558
16559    fn hoist_nested_dml(&mut self, stmt: &Stmt) -> Result<String, PyQLError> {
16560        let type_name = self.dml_subject_type(stmt)?;
16561        let inner = self.compile_stmt(stmt)?;
16562        let cte_name = self.fresh_nested_cte_name();
16563        self.pending_nested_ctes.push(IrCteDef {
16564            name: cte_name.clone(),
16565            stmt: inner,
16566            type_name,
16567            correlated_to: None,
16568        });
16569        Ok(cte_name)
16570    }
16571
16572    /// Compile `(SELECT TargetType FILTER …)` as a scalar subquery for use in a
16573    /// link assignment (`company := (SELECT Company FILTER .name = $co)`).
16574    /// Returns `IrExpr::Subquery` whose shape is the target pk — the SQL emitter
16575    /// renders this as `(SELECT "alias"."id" FROM … WHERE …)`.
16576    fn compile_link_subquery(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
16577        // `preferences := (insert Preferences { … })` — the nested DML used
16578        // directly as the value, which is the same hoist the wrapped
16579        // `select (insert …) { id }` form below goes through.
16580        if matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) {
16581            let cte_name = self.hoist_nested_dml(stmt)?;
16582            return Ok(IrExpr::ColumnRef {
16583                alias: cte_name,
16584                column: "id".to_string(),
16585                pg_type: "uuid".to_string(),
16586            });
16587        }
16588        let Stmt::Select(sel) = stmt else {
16589            return Err(self.type_err(
16590                "only SELECT is valid as a link assignment value; \
16591                 use SELECT (INSERT …) { id } to assign from a DML result",
16592            ));
16593        };
16594
16595        // `SELECT (INSERT …) { id }` / `SELECT (UPDATE …) { id }` /
16596        // `SELECT (DELETE …) { id }` — the exact workaround this function's
16597        // own error message above recommends. Postgres has no way to run a
16598        // nested INSERT/UPDATE/DELETE inside another statement's value list
16599        // without hoisting it into a `WITH` CTE first, so that's what this
16600        // does: compile the inner DML as its own statement, stash it in
16601        // `self.pending_nested_ctes` under a fresh CTE name (drained by
16602        // whichever `compile_insert`/`compile_update` is compiling the
16603        // assignment this value belongs to — see those functions' own doc
16604        // comments — and prepended as a `WITH` CTE by the emitter, which
16605        // also switches the outer statement's own row source from
16606        // `VALUES (...)` / a bare `SET` to something that can actually
16607        // reference it), and return a plain reference to that CTE's `id`
16608        // column in place of the subquery.
16609        //
16610        // A prior attempt to handle this by delegating to `compile_select`
16611        // (which does know how to chain a `dml_source`, but only for a
16612        // *top-level* `SELECT (INSERT …) { ... }` statement) compiled
16613        // without error but silently emitted a subquery that dropped the
16614        // nested INSERT and selected an unrelated, arbitrary pre-existing
16615        // row instead — do not repeat that approach.
16616        let nested_dml = match &sel.result {
16617            Expr::SubQuery(inner) if matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
16618                Some(inner.as_ref())
16619            }
16620            Expr::Shape(s) => match s.expr.as_ref() {
16621                Some(Expr::SubQuery(inner))
16622                    if matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) =>
16623                {
16624                    Some(inner.as_ref())
16625                }
16626                _ => None,
16627            },
16628            _ => None,
16629        };
16630        if let Some(inner_stmt) = nested_dml {
16631            let cte_name = self.hoist_nested_dml(inner_stmt)?;
16632            return Ok(IrExpr::ColumnRef {
16633                alias: cte_name,
16634                column: "id".to_string(),
16635                pg_type: "uuid".to_string(),
16636            });
16637        }
16638
16639        let type_name = self.expr_as_type_name(&sel.result)?;
16640        let td = self.resolve_type(&type_name)?;
16641        let alias = self.fresh_alias();
16642
16643        // `(select T filter … order by … limit 1)` — every clause decides
16644        // which row is linked, not just the filter.
16645        let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, td, &alias)?;
16646        let mut select = IrSelect::schema_bound(
16647            IrSource {
16648                poly: None,
16649                type_name: format!("{}::{}", td.module, td.name),
16650                table: td.table.clone(),
16651                alias,
16652            },
16653            Self::pk_returning(td),
16654            filter,
16655        );
16656        select.order_by = order_by;
16657        select.offset = offset;
16658        select.limit = limit;
16659        Ok(IrExpr::Subquery(Box::new(select)))
16660    }
16661
16662    /// Shared `IrSort` builder for both schema-bound (`compile_sort`) and
16663    /// free (`compile_free_select`'s order-by) contexts — direction/nulls
16664    /// translation is identical either way, only the expr compiler ctx differs.
16665    fn compile_sort_ctx(
16666        &mut self,
16667        s: &ast::SortExpr,
16668        ctx: Option<(&TypeDescriptor, &str)>,
16669    ) -> Result<IrSort, PyQLError> {
16670        Ok(IrSort {
16671            expr: self.compile_expr_ctx(&s.expr, ctx)?,
16672            direction: match s.direction {
16673                SortDirection::Asc => IrSortDir::Asc,
16674                SortDirection::Desc => IrSortDir::Desc,
16675            },
16676            nulls: match s.nones {
16677                NonesOrder::First => IrNulls::First,
16678                NonesOrder::Last => IrNulls::Last,
16679            },
16680        })
16681    }
16682
16683    fn compile_sort(&mut self, s: &ast::SortExpr, td: &TypeDescriptor, alias: &str) -> Result<IrSort, PyQLError> {
16684        self.compile_sort_ctx(s, Some((td, alias)))
16685    }
16686
16687    // ── Stdlib function resolution ────────────────────────────────────────────────
16688
16689    /// Look up `name` in the stdlib (namespace = `module` or `"std"`) and produce
16690    /// the correct `IrExpr::FunctionCall` based on the matching `ImplStrategy`.
16691    /// Falls through to a plain call if no overload is found (unknown / PG built-in).
16692    /// `message := …` of an assert handled outside the generic call route,
16693    /// which would otherwise drop it and raise the assert's own text.
16694    fn assert_message(
16695        &mut self,
16696        f: &ast::FunctionCall,
16697        ctx: Option<(&TypeDescriptor, &str)>,
16698    ) -> Result<Option<IrExpr>, PyQLError> {
16699        let mut message = None;
16700        for (name, value) in &f.kwargs {
16701            if name != "message" {
16702                return Err(self.type_err(&format!("function 'std::{}' has no parameter '{name}'", f.name)));
16703            }
16704            message = Some(self.compile_expr_ctx(value, ctx)?);
16705        }
16706        Ok(message)
16707    }
16708
16709    /// A stdlib call against its named-only parameters — `message :=` of the
16710    /// asserts, every parameter of `cal::to_relative_duration`. The arguments
16711    /// come back in parameter order, each named-only one left out standing
16712    /// at its default. `None` for a call that names nothing and needs no
16713    /// default, which the positional route handles as it always has.
16714    fn compile_named_call_args(
16715        &mut self,
16716        f: &ast::FunctionCall,
16717        ctx: Option<(&TypeDescriptor, &str)>,
16718    ) -> Result<Option<Vec<IrExpr>>, PyQLError> {
16719        use crate::stdlib::NamedDefault;
16720
16721        let ns = f.module.as_deref().unwrap_or("std");
16722        let overloads = crate::stdlib::lookup(ns, &f.name);
16723        let positional = |d: &crate::stdlib::FnDescriptor| d.params.iter().filter(|p| p.named_only.is_none()).count();
16724        let named = |d: &crate::stdlib::FnDescriptor| d.params.iter().filter(|p| p.named_only.is_some()).count();
16725        // A variadic parameter absorbs any number of the positional arguments,
16726        // so it fixes only a floor on how many a call may pass.
16727        let takes_positionally = |d: &crate::stdlib::FnDescriptor| {
16728            if d.is_variadic() {
16729                f.args.len() + 1 >= positional(d)
16730            } else {
16731                positional(d) == f.args.len()
16732            }
16733        };
16734        if f.kwargs.is_empty() {
16735            if overloads.iter().any(|d| takes_positionally(d) && named(d) == 0) {
16736                return Ok(None);
16737            }
16738            // Only when the call passed *more* positionally than an overload's
16739            // positions hold, and no more than its whole parameter list: that
16740            // is a call writing the named-only ones by position, as opposed to
16741            // one whose argument count is simply wrong.
16742            let wrote_a_named_one_positionally =
16743                |d: &crate::stdlib::FnDescriptor| positional(d) < f.args.len() && f.args.len() <= d.params.len();
16744            if !overloads.iter().any(|d| takes_positionally(d))
16745                && let Some(param) = overloads
16746                    .iter()
16747                    .filter(|d| wrote_a_named_one_positionally(d))
16748                    .flat_map(|d| d.params.iter())
16749                    .find(|p| p.named_only.is_some())
16750                    .map(|p| p.keyword())
16751            {
16752                return Err(self.type_err(&format!(
16753                    "function '{ns}::{}' takes '{param}' as a named argument only — e.g. '{param} := …'",
16754                    f.name
16755                )));
16756            }
16757        }
16758        let fits = |d: &&&crate::stdlib::FnDescriptor| {
16759            takes_positionally(d)
16760                && named(d) > 0
16761                && f.kwargs
16762                    .iter()
16763                    .all(|(name, _)| d.params.iter().any(|p| p.keyword() == name && p.named_only.is_some()))
16764        };
16765        let Some(desc) = overloads.iter().find(fits) else {
16766            if let Some((name, _)) = f.kwargs.first() {
16767                let message = if overloads.iter().all(|d| named(d) == 0) {
16768                    format!(
16769                        "function '{ns}::{}' does not take named arguments, got '{name}'",
16770                        f.name
16771                    )
16772                } else {
16773                    format!("function '{ns}::{}' has no parameter '{name}'", f.name)
16774                };
16775                return Err(self.type_err(&message));
16776            }
16777            return Ok(None);
16778        };
16779        let mut args = f
16780            .args
16781            .iter()
16782            .map(|a| self.compile_expr_ctx(a, ctx))
16783            .collect::<Result<Vec<_>, _>>()?;
16784        for param in desc.params.iter().filter(|p| p.named_only.is_some()) {
16785            let arg = match (
16786                f.kwargs.iter().find(|(name, _)| name == param.keyword()),
16787                param.named_only,
16788            ) {
16789                (Some((_, value)), _) => self.compile_expr_ctx(value, ctx)?,
16790                (None, Some(NamedDefault::Required)) => {
16791                    let keyword = param.keyword();
16792                    return Err(self.type_err(&format!(
16793                        "function '{ns}::{}' requires the named argument '{keyword}' — e.g. '{keyword} := …'",
16794                        f.name
16795                    )));
16796                }
16797                (None, Some(NamedDefault::Int(n))) => IrExpr::Literal(IrLiteral::Int(n)),
16798                (None, Some(NamedDefault::Bool(b))) => IrExpr::Literal(IrLiteral::Bool(b)),
16799                (None, Some(NamedDefault::Str(s))) => IrExpr::Literal(IrLiteral::Str(s.to_string())),
16800                (None, _) => IrExpr::Null,
16801            };
16802            args.push(arg);
16803        }
16804        Ok(Some(args))
16805    }
16806
16807    fn resolve_fn_call(&mut self, module: Option<&str>, name: &str, args: Vec<IrExpr>) -> Result<IrExpr, PyQLError> {
16808        use crate::stdlib::{ImplStrategy, lookup};
16809
16810        let ns = module.unwrap_or("std");
16811        // `any`/`all` aggregate a *set* of booleans. Everything that reaches
16812        // here is a single value — a multi-link comparison has already become
16813        // its own EXISTS — and a single boolean is its own any()/all();
16814        // `bool_or` over it would be an aggregate where SQL allows none.
16815        if ns == "std" && matches!(name, "any" | "all") && args.len() == 1 && !is_array_expr(&args[0]) {
16816            return Ok(args.into_iter().next().expect("checked by the guard"));
16817        }
16818        let overloads = lookup(ns, name);
16819
16820        // Arity is decided first and on its own, so a call no overload could
16821        // accept is never type-matched against one anyway. Named-only
16822        // parameters are already materialized into `args` by
16823        // `compile_named_call_args`, so the comparison is against the full
16824        // parameter list; a trailing variadic absorbs zero or more, matching
16825        // `pylon.stdlib._arity_matches`, the same rule the Python surface
16826        // has always applied to `std.foo(...)`.
16827        let by_arity: Vec<&crate::stdlib::FnDescriptor> = overloads
16828            .iter()
16829            .copied()
16830            .filter(|d| {
16831                if d.is_variadic() {
16832                    args.len() + 1 >= d.params.len()
16833                } else {
16834                    d.params.len() == args.len()
16835                }
16836            })
16837            .collect();
16838
16839        // Pick the overload whose parameter types best match the argument
16840        // types. An overload whose declared types are exactly what is known
16841        // of the arguments beats one that merely accepts them:
16842        // `to_str(<bytes>)` is the bytes overload, not the first one whose
16843        // parameter type no check rejects.
16844        let exact = by_arity.iter().copied().find(|d| {
16845            params_for_args(d, args.len())
16846                .zip(&args)
16847                .any(|(p, a)| p.ty.scalar_pg_type().is_some() && infer_ir_type(a).is_some())
16848                && params_for_args(d, args.len()).zip(&args).all(|(p, a)| {
16849                    match (p.ty.scalar_pg_type(), infer_ir_type(a)) {
16850                        (Some(declared), Some(known)) => declared == known,
16851                        _ => pylon_type_matches(a, &p.ty),
16852                    }
16853                })
16854        });
16855        let best = exact.or_else(|| {
16856            by_arity.iter().copied().find(|d| {
16857                params_for_args(d, args.len())
16858                    .zip(&args)
16859                    .all(|(p, a)| pylon_type_matches(a, &p.ty))
16860            })
16861        });
16862
16863        // A parameter that takes one value takes the walk's value, not the
16864        // array its rows were gathered into: `contains(ids, .<menus.id)`
16865        // compares uuids. A `set of …`/`array<…>` parameter asked for the
16866        // whole set and keeps it.
16867        let args_len = args.len();
16868        let args: Vec<IrExpr> = match best {
16869            Some(descriptor) => args
16870                .into_iter()
16871                .enumerate()
16872                .map(
16873                    |(i, arg)| match params_for_args(descriptor, args_len).nth(i).map(|p| &p.ty) {
16874                        Some(crate::stdlib::PylonType::Set(_)) | Some(crate::stdlib::PylonType::Array(_)) | None => arg,
16875                        Some(_) => set_walk_as_scalar(arg),
16876                    },
16877                )
16878                .collect(),
16879            None => args,
16880        };
16881        let args = match best {
16882            Some(descriptor) => pack_variadic_args(descriptor, args),
16883            None => args,
16884        };
16885
16886        // An `optional<…>` return still travels as its own scalar — a call
16887        // that may yield nothing (`json_get`) is typed by what it yields when
16888        // it does, or every cast and overload over it reads as untyped.
16889        let stdlib_return = best
16890            .map(|d| match &d.return_type {
16891                crate::stdlib::PylonType::Optional(inner) => inner.as_ref(),
16892                other => other,
16893            })
16894            .and_then(|ty| ty.scalar_pg_type())
16895            .map(str::to_string);
16896        let (schema, resolved_name, sql_template) = if let Some(desc) = best {
16897            match &desc.impl_strategy {
16898                ImplStrategy::SqlBuiltin(sql_name) => (None, sql_name.to_string(), None),
16899                ImplStrategy::SqlExpression(tmpl) => (None, name.to_string(), Some(tmpl.to_string())),
16900                ImplStrategy::PylonFunction(def) => (Some("_pylon".to_string()), def.name.to_string(), None),
16901                // Binary infix operator: emit as a template so sql/mod.rs's
16902                // generic FunctionCall path (which only ever calls
16903                // `schema.name(args)`) doesn't swallow the operator symbol —
16904                // previously unreachable in practice (only `std::overlaps`
16905                // used this strategy, untested, and would have silently
16906                // resolved to a nonexistent `"std".overlaps(...)` call).
16907                ImplStrategy::SqlOperator(op) if args.len() == 2 => {
16908                    (None, name.to_string(), Some(format!("($1 {op} $2)")))
16909                }
16910                ImplStrategy::TranspilerIntrinsic(intrinsic) => {
16911                    return self.compile_range_intrinsic(intrinsic, name, args);
16912                }
16913                // TranspilerIntrinsic: pass through; handled elsewhere
16914                _ => (module.map(str::to_string), name.to_string(), None),
16915            }
16916        } else {
16917            // Fall back to user-defined scalar functions. Overload
16918            // resolution is by (module, name, argument count) only — true
16919            // Postgres-style resolution by argument *type* isn't
16920            // implemented (a call whose args happen to have the right
16921            // count for the wrong-typed overload still picks that one,
16922            // relying on the forced TypeCast below rather than erroring).
16923            // Candidates are searched for one whose param count actually
16924            // matches the call — taking just the first (module, name)
16925            // match regardless of arg count used to silently pick the
16926            // wrong overload's signature (and then error on arg count)
16927            // whenever an earlier-declared overload happened to have a
16928            // different arity than the one actually being called
16929            // (confirmed live: a 2-arg call to an overload set whose
16930            // first-declared member takes 1 arg reported "expects 1
16931            // argument(s), got 2" even though a 2-arg overload existed).
16932            let candidates: Vec<&FunctionDescriptor> = self
16933                .schema
16934                .functions
16935                .iter()
16936                .filter(|f| {
16937                    let module_matches = module.map(|m| m == f.module.as_str()).unwrap_or(true);
16938                    module_matches && f.name == name && !f.return_is_object
16939                })
16940                .collect();
16941            let user_fn = candidates
16942                .iter()
16943                .find(|f| f.params.len() == args.len())
16944                .copied()
16945                .or_else(|| candidates.first().copied());
16946            if let Some(fd) = user_fn {
16947                if fd.params.len() != args.len() {
16948                    return Err(self.type_err(&format!(
16949                        "function '{}::{}' expects {} argument(s), got {}",
16950                        fd.module,
16951                        fd.name,
16952                        fd.params.len(),
16953                        args.len()
16954                    )));
16955                }
16956                let cast_args = fd
16957                    .params
16958                    .iter()
16959                    .zip(args)
16960                    .map(|(p, a)| {
16961                        IrExpr::TypeCast(Box::new(super::IrTypeCast {
16962                            expr: a,
16963                            pg_type: p.pg_type.clone(),
16964                            tuple_shape: None,
16965                        }))
16966                    })
16967                    .collect();
16968                let qualified = format!("{}::{}", fd.module, fd.name);
16969                let fn_module = fd.module.clone();
16970                let fn_name = fd.name.clone();
16971                let mut call_args: Vec<IrExpr> = cast_args;
16972                if let Some(globals) = self.globals_arg_for_call(&qualified)? {
16973                    call_args.insert(0, globals);
16974                }
16975                // A set-returning function has no single value to type, and
16976                // the declared type of an object-returning one names a type,
16977                // not a column — neither is a scalar `infer_ir_type` may
16978                // report.
16979                let return_pg_type = (!fd.return_is_set).then(|| fd.return_pg_type.clone());
16980                return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16981                    return_pg_type,
16982                    schema: Some(fn_module),
16983                    name: fn_name,
16984                    args: call_args,
16985                    sql_template: None,
16986                }));
16987            }
16988            // An object-returning function is deliberately not a candidate
16989            // above — it compiles through `try_compile_fn_object_select`, which
16990            // only runs when the call is a select's subject. Saying "does not
16991            // exist" for one that plainly does (and naming `default::` for a
16992            // call the user wrote unqualified) sent people looking for a typo
16993            // instead of at the restriction.
16994            if let Some(fd) = self.schema.functions.iter().find(|f| {
16995                let module_matches = module.map(|m| m == f.module.as_str()).unwrap_or(true);
16996                module_matches && f.name == name && f.return_is_object
16997            }) {
16998                return Err(self.type_err(&format!(
16999                    "function '{}::{}' returns objects, so it can only be the subject of a \
17000                     select (`select {}::{}(…) {{ … }}`), not part of a larger expression",
17001                    fd.module, fd.name, fd.module, fd.name
17002                )));
17003            }
17004            let qualified = match module {
17005                Some(m) => format!("{m}::{name}"),
17006                None => name.to_string(),
17007            };
17008            // The name is real and only the call is wrong — say which,
17009            // rather than resolving to whichever overload happened to be
17010            // registered first and letting PostgreSQL reject the emitted
17011            // SQL at query time with a signature the author never wrote.
17012            if !overloads.is_empty() {
17013                return Err(self.type_err(&if by_arity.is_empty() {
17014                    format!(
17015                        "function '{qualified}' takes {}, got {}",
17016                        describe_arities(&overloads),
17017                        args.len()
17018                    )
17019                } else {
17020                    format!(
17021                        "function '{qualified}' has no overload accepting ({}) — it accepts {}",
17022                        describe_args(&args),
17023                        describe_signatures(&by_arity),
17024                    )
17025                }));
17026            }
17027            return Err(self.type_err(&format!(
17028                "function '{qualified}' does not exist{}",
17029                self.suggest_function_name(ns, name)
17030            )));
17031        };
17032
17033        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17034            return_pg_type: stdlib_return,
17035            schema,
17036            name: resolved_name,
17037            args,
17038            sql_template,
17039        }))
17040    }
17041
17042    /// Resolve `std::range(...)`/`std::multirange(...)` — `TranspilerIntrinsic`
17043    /// entries with no real backing function anywhere (no `_pylon` function,
17044    /// no bare PostgreSQL builtin of that literal name). PostgreSQL has no
17045    /// single polymorphic range constructor — the concrete constructor
17046    /// (`int8range`, `numrange`, `tsrange`, `tstzrange`, `daterange`, and
17047    /// their multirange counterparts) is chosen here from the resolved
17048    /// element type of the arguments, since there's nothing generic to defer
17049    /// to at the SQL level the way `to_jsonb(x)` covers "cast to json".
17050    fn compile_range_intrinsic(&self, intrinsic: &str, name: &str, args: Vec<IrExpr>) -> Result<IrExpr, PyQLError> {
17051        match intrinsic {
17052            "range" => {
17053                // Either endpoint names the element type, since a range open
17054                // on one side carries it only on the other.
17055                let bounds = args.len().saturating_sub(3);
17056                let point_ty = args[..bounds].iter().find_map(infer_ir_type).ok_or_else(|| {
17057                    self.type_err(
17058                        "range(): cannot infer the element type from either bound — \
17059                     use an explicit cast, e.g. range(<int64>$lower, <int64>$upper)",
17060                    )
17061                })?;
17062                let ctor = range_ctor_for_pg_type(point_ty).ok_or_else(|| {
17063                    self.type_err(&format!(
17064                        "range(): unsupported element type '{point_ty}' — PostgreSQL only has native \
17065                     ranges over int64, decimal, datetime, cal::local_datetime, and cal::local_date"
17066                    ))
17067                })?;
17068                // `inc_lower`/`inc_upper`/`empty` are named-only and so always
17069                // present, defaults included: four arguments is the form that
17070                // gave only a lower bound, five the one that gave both.
17071                let upper = match args.len() {
17072                    4 => "NULL",
17073                    5 => "$2",
17074                    n => return Err(self.type_err(&format!("range(): unexpected argument count {n}"))),
17075                };
17076                let flags: Vec<Option<bool>> = args[args.len() - 3..].iter().map(bool_literal).collect();
17077                // Written out or defaulted, the three are constants at nearly
17078                // every call site, and folding them keeps a plain `range(a, b)`
17079                // emitting a plain constructor instead of a CASE over literals.
17080                let sql_template = match flags[..] {
17081                    [Some(_), Some(_), Some(true)] => format!("'empty'::{ctor}"),
17082                    [Some(true), Some(false), Some(false)] => format!("{ctor}($1, {upper})"),
17083                    [Some(inc_lower), Some(inc_upper), Some(false)] => format!(
17084                        "{ctor}($1, {upper}, '{}{}')",
17085                        if inc_lower { '[' } else { '(' },
17086                        if inc_upper { ']' } else { ')' },
17087                    ),
17088                    _ => {
17089                        let (lower_inc, upper_inc, empty) = match args.len() {
17090                            4 => ("$2", "$3", "$4"),
17091                            _ => ("$3", "$4", "$5"),
17092                        };
17093                        format!(
17094                            "(CASE WHEN {empty} THEN 'empty'::{ctor} ELSE {ctor}($1, {upper}, \
17095                             (CASE WHEN {lower_inc} THEN '[' ELSE '(' END) \
17096                             || (CASE WHEN {upper_inc} THEN ']' ELSE ')' END)) END)"
17097                        )
17098                    }
17099                };
17100                // `.name` carries the *resolved* constructor (not the
17101                // original `range`) so a wrapping `multirange([range(...)])`
17102                // call can identify the element family — emission always
17103                // goes through `sql_template` above, so this doesn't change
17104                // the SQL text.
17105                // A PostgreSQL range constructor shares its name with the
17106                // type it builds (`int8range(…)` is an `int8range`), so the
17107                // ctor doubles as the return type — which is what lets a
17108                // range reach a `range<anypoint>` parameter as itself rather
17109                // than as an untyped expression.
17110                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17111                    return_pg_type: Some(ctor.to_string()),
17112                    schema: None,
17113                    name: ctor.to_string(),
17114                    args,
17115                    sql_template: Some(sql_template),
17116                }))
17117            }
17118            "multirange" => {
17119                let Some(IrExpr::Array(elems)) = args.first() else {
17120                    return Err(self.type_err("multirange(): argument must be an array literal of ranges"));
17121                };
17122                let first_ctor = elems
17123                    .first()
17124                    .and_then(|e| match e {
17125                        IrExpr::FunctionCall(fc) => Some(fc.name.as_str()),
17126                        _ => None,
17127                    })
17128                    .ok_or_else(|| {
17129                        self.type_err(
17130                            "multirange(): cannot infer the element range type from an empty or non-range \
17131                     array — pass at least one range(...) call, e.g. multirange([range(1, 3)])",
17132                        )
17133                    })?;
17134                let ctor = multirange_ctor_for_range_ctor(first_ctor).ok_or_else(|| {
17135                    self.type_err(&format!("multirange(): unrecognized range constructor '{first_ctor}'"))
17136                })?;
17137                // PostgreSQL's multirange constructors (int8multirange, etc.)
17138                // are VARIADIC — they don't accept a plain array argument
17139                // without the VARIADIC keyword.
17140                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17141                    return_pg_type: Some(ctor.to_string()),
17142                    schema: None,
17143                    name: name.to_string(),
17144                    args,
17145                    sql_template: Some(format!("{ctor}(VARIADIC $1)")),
17146                }))
17147            }
17148            other => Err(self.type_err(&format!("internal error: unhandled TranspilerIntrinsic '{other}'"))),
17149        }
17150    }
17151
17152    // ── Sequence function helpers ──────────────────────────────────────────────────
17153
17154    /// Compile `sequence_next(SeqType)` → `nextval('"module"."Name_seq"')`
17155    /// and `sequence_reset(SeqType[, val])` → `setval(...)`.
17156    fn compile_sequence_fn(&mut self, fc: &ast::FunctionCall) -> Result<IrExpr, PyQLError> {
17157        let (module, scalar_name) = self.resolve_sequence_scalar_arg(fc)?;
17158
17159        if fc.name == "sequence_next" {
17160            if fc.args.len() != 1 {
17161                return Err(self.type_err("sequence_next takes exactly 1 argument"));
17162            }
17163            let sql = format!("nextval('\"{}\".\"{}_seq\"')", module, scalar_name);
17164            return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17165                return_pg_type: None,
17166                schema: None,
17167                name: "nextval".into(),
17168                args: vec![],
17169                sql_template: Some(sql),
17170            }));
17171        }
17172
17173        // sequence_reset
17174        match fc.args.len() {
17175            1 => {
17176                let sql = format!("setval('\"{}\".\"{}_seq\"', 1, false)", module, scalar_name);
17177                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17178                    return_pg_type: None,
17179                    schema: None,
17180                    name: "setval".into(),
17181                    args: vec![],
17182                    sql_template: Some(sql),
17183                }))
17184            }
17185            2 => {
17186                let val = self.compile_free_expr(&fc.args[1])?;
17187                let sql = format!("setval('\"{}\".\"{}_seq\"', $1, true)", module, scalar_name);
17188                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17189                    return_pg_type: None,
17190                    schema: None,
17191                    name: "setval".into(),
17192                    args: vec![val],
17193                    sql_template: Some(sql),
17194                }))
17195            }
17196            _ => Err(self.type_err("sequence_reset takes 1 or 2 arguments")),
17197        }
17198    }
17199
17200    /// Resolve the first argument of a sequence function to `(module, scalar_name)`.
17201    /// The argument must be an unqualified path that names a sequence scalar in the schema.
17202    fn resolve_sequence_scalar_arg(&self, fc: &ast::FunctionCall) -> Result<(String, String), PyQLError> {
17203        use crate::parse::ast::{Expr, Path, PathStep};
17204
17205        let arg = fc.args.first().ok_or_else(|| {
17206            self.type_err(&format!(
17207                "{}() requires a sequence scalar type as its first argument",
17208                fc.name
17209            ))
17210        })?;
17211
17212        // The parser encodes `module::Name` as a single PathStep::Name("module::Name"),
17213        // so we split on "::" here to recover the module part.
17214        let (arg_module, arg_name): (Option<&str>, &str) = match arg {
17215            Expr::Path(Path { steps, partial: false }) => match steps.as_slice() {
17216                [PathStep::Name(s)] => {
17217                    if let Some((m, n)) = s.split_once("::") {
17218                        (Some(m), n)
17219                    } else {
17220                        (None, s.as_str())
17221                    }
17222                }
17223                _ => return Err(self.type_err(&format!(
17224                    "{}(): first argument must be a sequence scalar type name (e.g. OrderNumber or default::OrderNumber)",
17225                    fc.name
17226                ))),
17227            },
17228            _ => return Err(self.type_err(&format!(
17229                "{}(): first argument must be a sequence scalar type name (e.g. OrderNumber or default::OrderNumber)",
17230                fc.name
17231            ))),
17232        };
17233
17234        let scalar = self.schema.scalars.iter().find(|s| {
17235            s.is_sequence && s.name == arg_name && arg_module.map(|m| m == s.module.as_str()).unwrap_or(true)
17236        });
17237
17238        match scalar {
17239            Some(s) => Ok((s.module.clone(), s.name.clone())),
17240            None => Err(self.type_err(&format!(
17241                "{}(): '{}' is not a known sequence scalar type",
17242                fc.name, arg_name
17243            ))),
17244        }
17245    }
17246
17247    // ── Channel notify() helpers ────────────────────────────────────────────────
17248
17249    /// PostgreSQL's hard per-NOTIFY-payload limit (`NOTIFY_PAYLOAD_MAX_LENGTH`
17250    /// in the Postgres source) — a payload at or over this is rejected by the
17251    /// server at runtime, always, for every session. Checked here only when
17252    /// the payload is a literal string (the one case actually decidable at
17253    /// compile time — see `static_min_payload_bytes` for how much of an
17254    /// arbitrary payload expression can be sized without running it.
17255    const NOTIFY_PAYLOAD_MAX_BYTES: usize = 8000;
17256
17257    /// The rejection every non-object payload for a Type channel shares.
17258    fn notify_type_payload_err(&self, channel: &str, qname: &str) -> PyQLError {
17259        self.type_err(&format!(
17260            "notify(): payload for Channel '{channel}' (a '{qname}' object channel) must name an object of that \
17261             type — either a with-block binding, or __new__/__old__ inside a trigger handler"
17262        ))
17263    }
17264
17265    /// Compile `notify(Channel, payload)` → `pg_notify('<wire_name>', (<payload>)::text)`.
17266    /// The payload's required shape depends on the Channel's declared kind:
17267    /// - `Type(qname)`: payload must be the bare `__new__`/`__old__` trigger
17268    ///   anchor for that exact type — sends its `.id`, not the whole row (see
17269    ///   this function's own restriction: a general object-typed expression
17270    ///   isn't supported yet, only the trigger-anchor case).
17271    /// - `Scalar(pg_type)`: payload is any expression, best-effort type-checked.
17272    /// - `Object(fields)`: payload must be a free object literal (`{ a := .., b := .. }`)
17273    ///   whose field names exactly match the declared shape.
17274    fn compile_notify(
17275        &mut self,
17276        fc: &ast::FunctionCall,
17277        ctx: Option<(&TypeDescriptor, &str)>,
17278    ) -> Result<IrExpr, PyQLError> {
17279        use crate::parse::ast::{Expr, Path, PathStep};
17280
17281        if fc.args.len() != 2 {
17282            return Err(self.type_err("notify() takes exactly 2 arguments: (Channel, payload)"));
17283        }
17284
17285        let (channel_module, channel_name): (Option<&str>, &str) = match &fc.args[0] {
17286            Expr::Path(Path { steps, partial: false }) => match steps.as_slice() {
17287                [PathStep::Name(s)] => {
17288                    if let Some((m, n)) = s.split_once("::") {
17289                        (Some(m), n)
17290                    } else {
17291                        (None, s.as_str())
17292                    }
17293                }
17294                _ => {
17295                    return Err(self.type_err(
17296                        "notify(): first argument must be a Channel name (e.g. OrderEvents or orders::OrderEvents)",
17297                    ));
17298                }
17299            },
17300            _ => {
17301                return Err(self.type_err(
17302                    "notify(): first argument must be a Channel name (e.g. OrderEvents or orders::OrderEvents)",
17303                ));
17304            }
17305        };
17306        let full_channel_name = match channel_module {
17307            Some(m) => format!("{m}::{channel_name}"),
17308            None => channel_name.to_string(),
17309        };
17310        let channel = self
17311            .resolve_channel(&full_channel_name)
17312            .ok_or_else(|| self.type_err(&format!("notify(): '{channel_name}' is not a known Channel")))?;
17313        let wire_name = channel.wire_name.clone();
17314        let payload_arg = &fc.args[1];
17315
17316        // A bare `select notify(...)` trigger handler has no type at its
17317        // root, so the top-level statement compiles as a *free* select
17318        // (ctx=None) even though `special_anchors` is populated — `__new__`/
17319        // `__old__` only resolve through `compile_path`, which is only ever
17320        // reached when ctx is `Some(..)` (see `Expr::Path`'s arm above).
17321        // Fall back to any bound anchor's (td, alias) as a stand-in ctx so a
17322        // Scalar/Object payload like `__new__.name` or `{ a := __new__.x }`
17323        // still resolves — `compile_path`'s own `__new__`/`__old__` branch
17324        // ignores whatever td/alias ctx carries for those two names anyway,
17325        // it only matters for a real property access on some other root.
17326        // Cloned out of `special_anchors` (rather than borrowed) so it
17327        // doesn't hold an immutable borrow of `self` across the `&mut self`
17328        // `compile_expr_ctx` calls below.
17329        let anchor_fallback: Option<(&'a TypeDescriptor, String)> = self.special_anchors.values().next().cloned();
17330        let ctx = ctx.or_else(|| anchor_fallback.as_ref().map(|(td, alias)| (*td, alias.as_str())));
17331
17332        let payload_ir = match &channel.payload {
17333            crate::schema::ChannelPayload::Type(qname) => {
17334                // The payload for an object channel is the object's `id`, in
17335                // either of the two places one can be named: the `__new__`/
17336                // `__old__` anchor a trigger handler runs against, or a
17337                // with-block binding in an ordinary query — which is what
17338                // lets a notify compose with the mutation that caused it:
17339                //
17340                //   with updated := (update User filter ... set { ... }),
17341                //   select (notify(UserUpdates, updated), updated)
17342                let Expr::Path(Path { steps, partial: false }) = payload_arg else {
17343                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
17344                };
17345                let [PathStep::Name(name)] = steps.as_slice() else {
17346                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
17347                };
17348
17349                if name == "__new__" || name == "__old__" {
17350                    let (anchor_td, alias) = self.special_anchors.get(name.as_str()).cloned().ok_or_else(|| {
17351                        self.type_err(&format!(
17352                            "notify(): '{name}' cannot be used here — it's only bound inside a trigger handler"
17353                        ))
17354                    })?;
17355                    let anchor_qname = format!("{}::{}", anchor_td.module, anchor_td.name);
17356                    if &anchor_qname != qname {
17357                        return Err(self.type_err(&format!(
17358                            "notify(): Channel '{full_channel_name}' expects a payload of type '{qname}', got '{anchor_qname}'"
17359                        )));
17360                    }
17361                    IrExpr::ColumnRef {
17362                        alias,
17363                        column: "id".to_string(),
17364                        pg_type: "uuid".to_string(),
17365                    }
17366                } else if let Some(cte_type) = self.cte_types.get(name.as_str()).cloned() {
17367                    if &cte_type != qname {
17368                        return Err(self.type_err(&format!(
17369                            "notify(): Channel '{full_channel_name}' expects a payload of type '{qname}', got '{cte_type}'"
17370                        )));
17371                    }
17372                    // `CteRef { scalar: false }` emits `(SELECT "id" FROM
17373                    // "<cte>")` — the same id the trigger path sends.
17374                    IrExpr::CteRef {
17375                        name: name.clone(),
17376                        scalar: false,
17377                        pg_type: None,
17378                    }
17379                } else {
17380                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
17381                }
17382            }
17383            crate::schema::ChannelPayload::Scalar(pg_type) => {
17384                let ir = self.compile_expr_ctx(payload_arg, ctx)?;
17385                if let Some(actual) = infer_ir_type(&ir)
17386                    && !types_compatible(actual, pg_type)
17387                {
17388                    return Err(self.type_err(&format!(
17389                            "notify(): Channel '{full_channel_name}' expects a payload of type '{expected}', got '{actual_pyql}'",
17390                            expected = pg_type_to_pyql(pg_type),
17391                            actual_pyql = pg_type_to_pyql(actual),
17392                        )));
17393                }
17394                ir
17395            }
17396            crate::schema::ChannelPayload::Object(declared_fields) => {
17397                let Expr::Shape(sh) = payload_arg else {
17398                    return Err(self.type_err(&format!(
17399                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free \
17400                         object literal, e.g. {{ {} }}",
17401                        declared_fields
17402                            .iter()
17403                            .map(|(n, _)| format!("{n} := .."))
17404                            .collect::<Vec<_>>()
17405                            .join(", ")
17406                    )));
17407                };
17408                if sh.expr.is_some() {
17409                    return Err(self.type_err(&format!(
17410                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free \
17411                         object literal, not a shape over a type"
17412                    )));
17413                }
17414                let ir = self.compile_expr_ctx(payload_arg, ctx)?;
17415                let IrExpr::NamedTuple {
17416                    fields,
17417                    is_free_object: true,
17418                } = &ir
17419                else {
17420                    return Err(self.type_err(&format!(
17421                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free object literal"
17422                    )));
17423                };
17424                let declared_names: std::collections::HashSet<&str> =
17425                    declared_fields.iter().map(|(n, _)| n.as_str()).collect();
17426                let actual_names: std::collections::HashSet<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
17427                if declared_names != actual_names {
17428                    let mut expected: Vec<&str> = declared_names.iter().copied().collect();
17429                    expected.sort();
17430                    let mut actual: Vec<&str> = actual_names.iter().copied().collect();
17431                    actual.sort();
17432                    return Err(self.type_err(&format!(
17433                        "notify(): payload fields for Channel '{full_channel_name}' don't match — expected {{{}}}, got {{{}}}",
17434                        expected.join(", "), actual.join(", ")
17435                    )));
17436                }
17437                for (name, expr) in fields {
17438                    let Some((_, declared_pg_type)) = declared_fields.iter().find(|(n, _)| n == name) else {
17439                        continue;
17440                    };
17441                    if let Some(actual) = infer_ir_type(expr)
17442                        && !types_compatible(actual, declared_pg_type)
17443                    {
17444                        return Err(self.type_err(&format!(
17445                                "notify(): field '{name}' of Channel '{full_channel_name}' expects type '{expected}', got '{actual_pyql}'",
17446                                expected = pg_type_to_pyql(declared_pg_type),
17447                                actual_pyql = pg_type_to_pyql(actual),
17448                            )));
17449                    }
17450                }
17451                ir
17452            }
17453        };
17454
17455        let floor = static_min_payload_bytes(payload_arg);
17456        if floor >= Self::NOTIFY_PAYLOAD_MAX_BYTES {
17457            return Err(self.type_err(&format!(
17458                "notify(): this payload is at least {floor} bytes, which is at or over PostgreSQL's {}-byte NOTIFY \
17459                 payload limit — the notification would fail at runtime, aborting the transaction that sent it",
17460                Self::NOTIFY_PAYLOAD_MAX_BYTES
17461            )));
17462        }
17463
17464        let sql = format!("pg_notify('{}', ($1)::text)", wire_name.replace('\'', "''"));
17465        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17466            return_pg_type: None,
17467            schema: None,
17468            name: "pg_notify".to_string(),
17469            args: vec![payload_ir],
17470            sql_template: Some(sql),
17471        }))
17472    }
17473
17474    /// Compile `notify_raw(channel_name, payload)` → `pg_notify($1, $2)` — the
17475    /// escape hatch that bypasses Channel resolution and payload-shape
17476    /// checking entirely: both arguments are arbitrary text expressions.
17477    fn compile_notify_raw(
17478        &mut self,
17479        fc: &ast::FunctionCall,
17480        ctx: Option<(&TypeDescriptor, &str)>,
17481    ) -> Result<IrExpr, PyQLError> {
17482        if fc.args.len() != 2 {
17483            return Err(self.type_err("notify_raw() takes exactly 2 arguments: (channel_name, payload)"));
17484        }
17485        let channel_ir = self.compile_expr_ctx(&fc.args[0], ctx)?;
17486        let payload_ir = self.compile_expr_ctx(&fc.args[1], ctx)?;
17487
17488        let floor = static_min_payload_bytes(&fc.args[1]);
17489        if floor >= Self::NOTIFY_PAYLOAD_MAX_BYTES {
17490            return Err(self.type_err(&format!(
17491                "notify_raw(): this payload is at least {floor} bytes, which is at or over PostgreSQL's {}-byte \
17492                 NOTIFY payload limit",
17493                Self::NOTIFY_PAYLOAD_MAX_BYTES
17494            )));
17495        }
17496
17497        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
17498            return_pg_type: None,
17499            schema: None,
17500            name: "pg_notify".to_string(),
17501            args: vec![channel_ir, payload_ir],
17502            sql_template: None,
17503        }))
17504    }
17505
17506    /// The `GLOBALS_ARG` value to pass when calling `qualified`, or `None` if
17507    /// that function does not take one.
17508    ///
17509    /// Inside a function body the caller's own argument is forwarded verbatim;
17510    /// at the top level every session global is packed, rather than just the
17511    /// ones the callee happens to read. Packing the whole set costs one small
17512    /// jsonb per call and keeps the caller from having to know anything about
17513    /// the callee's body.
17514    fn globals_arg_for_call(&mut self, qualified: &str) -> Result<Option<IrExpr>, PyQLError> {
17515        if self.fns_needing_globals.is_none() {
17516            self.fns_needing_globals = Some(functions_needing_globals(self.schema).clone());
17517        }
17518        if !self.fns_needing_globals.as_ref().is_some_and(|s| s.contains(qualified)) {
17519            return Ok(None);
17520        }
17521        if self.in_fn_body {
17522            self.used_globals_arg = true;
17523            return Ok(Some(IrExpr::RawSql(GLOBALS_ARG.to_string())));
17524        }
17525        let session_globals: Vec<String> = self
17526            .schema
17527            .globals
17528            .iter()
17529            .filter(|g| g.computed_expr.is_none())
17530            .map(|g| format!("{}::{}", g.module, g.name))
17531            .collect();
17532        let mut args = Vec::with_capacity(session_globals.len() * 2);
17533        for name in session_globals {
17534            let value = self.compile_global(&name)?;
17535            args.push(IrExpr::Literal(IrLiteral::Str(name)));
17536            args.push(value);
17537        }
17538        Ok(Some(IrExpr::FunctionCall(super::IrFunctionCall {
17539            return_pg_type: None,
17540            schema: None,
17541            name: "jsonb_build_object".to_string(),
17542            args,
17543            sql_template: None,
17544        })))
17545    }
17546
17547    // ── User-defined function helpers ─────────────────────────────────────────────
17548
17549    /// Try to compile `fn(args) { shape }` as a `FunctionSelect` for an object-returning
17550    /// user function.  Returns `None` if no matching user function exists (so the caller
17551    /// can fall through to other dispatch paths).
17552    fn try_compile_fn_object_select(
17553        &mut self,
17554        fc: &ast::FunctionCall,
17555        elements: &[ast::ShapeElement],
17556        s: &ast::SelectStmt,
17557        distinct: bool,
17558    ) -> Result<Option<IrFunctionSelect>, PyQLError> {
17559        use crate::schema::FunctionDescriptor;
17560
17561        let fd: Option<&FunctionDescriptor> = self.schema.functions.iter().find(|f| {
17562            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
17563            module_matches && f.name == fc.name && f.return_is_object
17564        });
17565        let fd = match fd {
17566            Some(f) => f,
17567            None => return Ok(None),
17568        };
17569        if fd.params.len() != fc.args.len() {
17570            return Err(self.type_err(&format!(
17571                "function '{}::{}' expects {} argument(s), got {}",
17572                fd.module,
17573                fd.name,
17574                fd.params.len(),
17575                fc.args.len()
17576            )));
17577        }
17578
17579        let fn_module = fd.module.clone();
17580        let fn_name = fd.name.clone();
17581        let return_type_name = fd.return_pg_type.clone(); // qualified type name for object returns
17582        let polymorphic = fd.return_is_polymorphic;
17583
17584        let mut fn_args = fc
17585            .args
17586            .iter()
17587            .map(|a| self.compile_free_expr(a))
17588            .collect::<Result<Vec<_>, _>>()?;
17589        let qualified = format!("{}::{}", fn_module, fn_name);
17590        if let Some(globals) = self.globals_arg_for_call(&qualified)? {
17591            fn_args.insert(0, globals);
17592        }
17593
17594        let alias = self.fresh_alias();
17595
17596        // Resolve the return type to build the shape.
17597        let td = self.resolve_type(&return_type_name)?;
17598        let td = td.clone();
17599
17600        let (poly_implementors, poly_columns) = if polymorphic {
17601            self.collect_poly_info(&return_type_name)
17602        } else {
17603            (vec![], vec![])
17604        };
17605
17606        let td_module = td.module.clone();
17607        // Build shape against the return type (treat alias as the source alias).
17608        let shape = self.compile_shape(elements, &td, &alias, &td_module)?;
17609        // The shape's own computeds are in scope for the select's clauses,
17610        // the same as over a type -- added only now, so one pointer still
17611        // cannot read another.
17612        let outer_declared = self.active_declared_pointers.clone();
17613        self.active_declared_pointers
17614            .extend(elements.iter().filter(|el| el.compexpr.is_some()).cloned());
17615        let modifiers = self.compile_path_modifiers(s, &td, &alias);
17616        self.active_declared_pointers = outer_declared;
17617        let (filter, order_by, offset, limit) = modifiers?;
17618
17619        Ok(Some(IrFunctionSelect {
17620            fn_module,
17621            fn_name,
17622            fn_args,
17623            alias,
17624            type_name: return_type_name,
17625            polymorphic,
17626            poly_implementors,
17627            poly_columns,
17628            shape,
17629            filter,
17630            order_by,
17631            offset,
17632            limit,
17633            distinct,
17634        }))
17635    }
17636
17637    /// The interface's own physical columns (properties + `{link}_id`) — the
17638    /// subset every concrete implementor table is guaranteed to share, safe
17639    /// to RETURNING/SELECT uniformly across a poly_implementors fan-out.
17640    /// Shared by compile_select (read path) and the IrUpdate/IrDelete
17641    /// builders (write path) so a DML-as-CTE fan-out (sql/mod.rs) can expose
17642    /// the same columns a polymorphic select would.
17643    /// Point a shape's single-link sub-selects at the nested-DML CTE that
17644    /// supplied their foreign key.
17645    ///
17646    /// `insert Account { credentials := (insert Credentials { … }) } { ** }`
17647    /// runs the nested insert as a data-modifying CTE, and Postgres does not
17648    /// show one statement's CTE writes to the rest of that statement: reading
17649    /// `access."Credentials"` back finds nothing, so the link hydrated as
17650    /// `None` even though the row was there on the next statement (confirmed
17651    /// live). The CTE itself is in scope, and `RETURNING *` gives it every
17652    /// column the shape asks for, so the sub-select reads that instead.
17653    fn read_nested_links_from_their_ctes(dml: &IrStmt, dml_cte: Option<&str>, shape: &mut [IrShapePointer]) {
17654        let (assignments, nested_ctes, appends) = match dml {
17655            IrStmt::Insert(ins) => (&ins.assignments, &ins.nested_ctes, &ins.multi_link_appends),
17656            IrStmt::Update(upd) => (&upd.assignments, &upd.nested_ctes, &upd.multi_link_appends),
17657            _ => return,
17658        };
17659        // A multi-link's junction rows are written by this statement too, in
17660        // a CTE the emitter names after the DML's own — see
17661        // `emit_insert_multilink_ctes`. The targets themselves come from the
17662        // append's value source, which for a nested insert is one of
17663        // `nested_ctes`.
17664        if let Some(dml_cte) = dml_cte {
17665            for pointer in shape.iter_mut() {
17666                let IrShapePointer::MultiLink(link) = pointer else {
17667                    continue;
17668                };
17669                let IrMultiLinkJoin::Standard { junction_table, .. } = &mut link.join else {
17670                    continue;
17671                };
17672                let Some(index) = appends.iter().position(|a| a.junction_table == *junction_table) else {
17673                    continue;
17674                };
17675                *junction_table = format!("@cte:{dml_cte}__ml_add_{index}");
17676                if let IrMultiLinkValueSource::CteRef(target_cte) = &appends[index].values.source {
17677                    for row in &mut link.subquery.rows {
17678                        if let IrRowSource::Bound { source, .. } = row {
17679                            source.table = format!("@cte:{target_cte}");
17680                            source.poly = None;
17681                        }
17682                    }
17683                }
17684            }
17685        }
17686        if nested_ctes.is_empty() {
17687            return;
17688        }
17689        let from_cte: HashMap<&str, &str> = assignments
17690            .iter()
17691            .filter_map(|(column, expr)| match expr {
17692                IrExpr::ColumnRef { alias, column: c, .. }
17693                    if c == "id" && nested_ctes.iter().any(|cte| cte.name == *alias) =>
17694                {
17695                    Some((column.as_str(), alias.as_str()))
17696                }
17697                _ => None,
17698            })
17699            .collect();
17700        if from_cte.is_empty() {
17701            return;
17702        }
17703        for pointer in shape {
17704            let IrShapePointer::SingleLink(link) = pointer else {
17705                continue;
17706            };
17707            let IrSingleLinkCorrelation::Fk { fk_column, .. } = &link.correlation else {
17708                continue;
17709            };
17710            let Some(cte_name) = from_cte.get(fk_column.as_str()) else {
17711                continue;
17712            };
17713            for row in &mut link.subquery.rows {
17714                if let IrRowSource::Bound { source, .. } = row {
17715                    source.table = format!("@cte:{cte_name}");
17716                    source.poly = None;
17717                }
17718            }
17719        }
17720    }
17721
17722    fn poly_dml_columns(td: &TypeDescriptor) -> Vec<String> {
17723        td.properties
17724            .iter()
17725            .map(|p| p.name.clone())
17726            .chain(
17727                td.links
17728                    .iter()
17729                    .filter(|l| !l.is_junction_backed())
17730                    .map(|l| format!("{}_id", l.name)),
17731            )
17732            .collect()
17733    }
17734
17735    /// A nested SELECT over a link's target, expanded inline over the
17736    /// interface's implementors when the target is one.
17737    ///
17738    /// An interface is materialised as a view that carries only the
17739    /// interface's own columns -- no discriminator -- so reading a link
17740    /// through it tags every row as the interface itself and hydrates the
17741    /// interface class rather than the concrete one. Expanding the
17742    /// implementors inline is what the root of a query already does, and each
17743    /// branch supplies its own `__type__`.
17744    fn link_target_select(
17745        &self,
17746        target_td: &TypeDescriptor,
17747        mut source: IrSource,
17748        shape: Vec<IrShapePointer>,
17749    ) -> IrSelect {
17750        source.poly = self.link_target_fanout(target_td);
17751        IrSelect::schema_bound(source, shape, None)
17752    }
17753
17754    /// The fan-out a link's target needs, for the builders that assemble their
17755    /// own source rather than going through `link_target_select`.
17756    fn link_target_fanout(&self, target_td: &TypeDescriptor) -> Option<IrPolyFanout> {
17757        self.poly_fanout_for(&format!("{}::{}", target_td.module, target_td.name))
17758    }
17759
17760    /// Give every interface-typed join target its fan-out, so a traversal that
17761    /// lands on one reads the implementors and carries the real type rather
17762    /// than the interface's view, which has no discriminator to carry.
17763    ///
17764    /// The root is left alone: a path select fans its own root out through
17765    /// `poly_implementors` already, and doing it twice would nest the union.
17766    fn resolve_join_fanouts(&self, path_select: &mut IrPathSelect) {
17767        for join in &mut path_select.joins {
17768            let target = match join {
17769                IrPathJoin::Single { target, .. }
17770                | IrPathJoin::Multi { target, .. }
17771                | IrPathJoin::BacklinkSingle { target, .. }
17772                | IrPathJoin::BacklinkMulti { target, .. }
17773                | IrPathJoin::Function { target, .. }
17774                | IrPathJoin::Lateral { target, .. } => target,
17775            };
17776            if target.poly.is_none() && !target.table.starts_with("@cte:") {
17777                target.poly = self.poly_fanout_for(&target.type_name.clone());
17778            }
17779        }
17780    }
17781
17782    /// The fan-out a source of `type_name` needs, or `None` when it is not an
17783    /// interface and so reads from a table of its own. See `IrSource::poly`.
17784    fn poly_fanout_for(&self, type_name: &str) -> Option<IrPolyFanout> {
17785        let td = self
17786            .schema
17787            .types
17788            .iter()
17789            .find(|t| format!("{}::{}", t.module, t.name) == type_name)?;
17790        // A materialised interface has a view to read; a plain abstract has no
17791        // relation at all, so reading its table name finds nothing. Both are
17792        // answered by the union of the types that carry the columns.
17793        if !td.abstract_ && !self.has_subtypes(td) {
17794            return None;
17795        }
17796        let (implementors, columns) = self.collect_poly_info(type_name);
17797        Some(IrPolyFanout { implementors, columns })
17798    }
17799
17800    /// Collect poly_implementors and poly_columns for a polymorphic return type.
17801    fn collect_poly_info(&self, type_name: &str) -> (Vec<IrPolyImplementor>, Vec<String>) {
17802        let implementors = self.find_poly_implementors(type_name);
17803        let columns = if let Some(td) = self
17804            .schema
17805            .types
17806            .iter()
17807            .find(|t| format!("{}::{}", t.module, t.name) == type_name)
17808        {
17809            // The same set the DML path fans out (`poly_dml_columns`): the
17810            // interface's single-link FKs belong in it too, or reading
17811            // `.profile` off an interface-typed source finds no
17812            // `profile_id` column in the union it was fanned out into.
17813            Self::poly_dml_columns(td)
17814        } else {
17815            vec![]
17816        };
17817        (implementors, columns)
17818    }
17819
17820    // ── vector::search ────────────────────────────────────────────────────────────
17821
17822    /// Try to compile `vector::search` from a function-call AST node.
17823    ///
17824    /// Two overloads are supported:
17825    /// - `vector::search(TypeName, $vec [, index_name := '…'])` — pre-computed vector
17826    /// - `vector::search(TypeName, query := $text [, index_name := '…'])` — text overload;
17827    ///   the Python layer embeds the text and injects `__deferred_vec__` before execution.
17828    ///
17829    /// Returns `None` if the call is not `vector::search`.
17830    fn try_compile_vector_search(
17831        &mut self,
17832        fc: &ast::FunctionCall,
17833        elements: &[ast::ShapeElement],
17834        s: &ast::SelectStmt,
17835    ) -> Result<Option<IrVectorSearch>, PyQLError> {
17836        if fc.module.as_deref() != Some("vector") || fc.name != "search" {
17837            return Ok(None);
17838        }
17839
17840        // Detect text overload: `query :=` kwarg present (no positional second arg needed).
17841        let text_query_arg = fc.kwargs.iter().find(|(k, _)| k == "query").map(|(_, v)| v);
17842        let is_text_overload = text_query_arg.is_some();
17843
17844        if !is_text_overload && fc.args.len() < 2 {
17845            return Err(
17846                self.type_err("vector::search requires either a positional vector argument or `query := $text`")
17847            );
17848        }
17849
17850        // First argument: a type name or a filtered subquery narrowing the candidate set.
17851        //   - Bare name:     `Product` or `default::Product`
17852        //   - Subquery:      `(select Product filter .price < 100)`
17853        //
17854        // For the subquery form we store the raw inner filter AST and compile it below,
17855        // after the source alias is generated, so property column refs use the right alias.
17856        let (type_qname, inner_filter_ast): (String, Option<ast::Expr>) = match &fc.args[0] {
17857            ast::Expr::Path(p) if !p.partial => {
17858                let name = p.steps.iter()
17859                    .filter_map(|s| if let ast::PathStep::Name(n) = s { Some(n.as_str()) } else { None })
17860                    .collect::<Vec<_>>().join("::");
17861                let td = self.resolve_type(&name)
17862                    .map_err(|_| self.type_err(&format!("vector::search: '{}' is not a known type", name)))?;
17863                (format!("{}::{}", td.module, td.name), None)
17864            }
17865            ast::Expr::SubQuery(stmt) => {
17866                if let ast::Stmt::Select(inner_sel) = stmt.as_ref() {
17867                    let inner_type_name = match &inner_sel.result {
17868                        ast::Expr::Path(p) if !p.partial => {
17869                            p.steps.iter()
17870                                .filter_map(|s| if let ast::PathStep::Name(n) = s { Some(n.as_str()) } else { None })
17871                                .collect::<Vec<_>>().join("::")
17872                        }
17873                        _ => return Err(self.type_err(
17874                            "vector::search: subquery first argument must select a single type (e.g. select Product filter …)"
17875                        )),
17876                    };
17877                    let td = self.resolve_type(&inner_type_name)
17878                        .map_err(|_| self.type_err(&format!("vector::search: '{}' is not a known type", inner_type_name)))?;
17879                    let qname = format!("{}::{}", td.module, td.name);
17880                    (qname, inner_sel.filter.clone())
17881                } else {
17882                    return Err(self.type_err("vector::search: subquery first argument must be a SELECT"));
17883                }
17884            }
17885            _ => return Err(self.type_err(
17886                "vector::search: first argument must be a type name or a filtered subquery (e.g. select Product filter …)"
17887            )),
17888        };
17889
17890        // Optional named argument: index_name := '…'
17891        let index_name: Option<String> = fc.kwargs.iter().find(|(k, _)| k == "index_name").and_then(|(_, v)| {
17892            if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
17893                Some(s.clone())
17894            } else {
17895                None
17896            }
17897        });
17898
17899        // Resolve the type and find the VectorIndex.
17900        let td = self.resolve_type(&type_qname)?.clone();
17901        let vi = td
17902            .vector_indexes
17903            .iter()
17904            .find(|vi| vi.index_name.as_deref() == index_name.as_deref())
17905            .ok_or_else(|| {
17906                let key = index_name.as_deref().unwrap_or("<default>");
17907                self.type_err(&format!("type '{}' has no vector index '{}'", type_qname, key))
17908            })?;
17909
17910        let vector_col = vi.column_name();
17911        let distance_op = match vi.metric.as_str() {
17912            "euclidean" => "<->",
17913            "inner_product" => "<#>",
17914            _ => "<=>", // cosine (default)
17915        };
17916
17917        // Build query expression and inference fields.
17918        let (
17919            query_expr,
17920            inference_query_param_name,
17921            inference_query_literal,
17922            inference_model,
17923            inference_type_name,
17924            inference_index_name,
17925        );
17926
17927        if is_text_overload {
17928            // Text overload: register __deferred_vec__ as the SQL param; Python injects
17929            // the embedding result into it before executing the query.
17930            let vec_idx = self.param_index("__deferred_vec__");
17931            let vec_param = IrExpr::Param { index: vec_idx };
17932            // Cast float8[] → vector so a Python list[float] encodes natively.
17933            let inner_cast = IrExpr::TypeCast(Box::new(IrTypeCast {
17934                expr: vec_param,
17935                pg_type: "float8[]".to_string(),
17936                tuple_shape: None,
17937            }));
17938            query_expr = IrExpr::TypeCast(Box::new(IrTypeCast {
17939                expr: inner_cast,
17940                pg_type: "vector".to_string(),
17941                tuple_shape: None,
17942            }));
17943            let query_arg = text_query_arg.unwrap();
17944            inference_query_param_name = Some(match query_arg {
17945                ast::Expr::Parameter(name) => name.clone(),
17946                _ => String::new(),
17947            });
17948            inference_query_literal = match query_arg {
17949                ast::Expr::Literal(ast::Literal::Str(s)) => Some(s.clone()),
17950                _ => None,
17951            };
17952            inference_model = Some(vi.model.clone());
17953            inference_type_name = Some(type_qname.clone());
17954            inference_index_name = Some(index_name.clone());
17955        } else {
17956            // Vector overload: second positional arg is the pre-computed vector.
17957            let raw_query_expr = self.compile_free_expr(&fc.args[1])?;
17958            query_expr = IrExpr::TypeCast(Box::new(IrTypeCast {
17959                expr: raw_query_expr,
17960                pg_type: "vector".to_string(),
17961                tuple_shape: None,
17962            }));
17963            inference_query_param_name = None;
17964            inference_query_literal = None;
17965            inference_model = None;
17966            inference_type_name = None;
17967            inference_index_name = None;
17968        }
17969
17970        let alias = self.fresh_alias();
17971        let source = IrSource {
17972            poly: None,
17973            type_name: type_qname.clone(),
17974            table: td.table.clone(),
17975            alias: alias.clone(),
17976        };
17977
17978        // Compile inner_filter_ast (from subquery first arg) now that we have the alias,
17979        // so property column refs (e.g. `.price`) use the correct table alias.
17980        let pre_filter: Option<IrExpr> = match inner_filter_ast {
17981            Some(ref f) => Some(self.as_condition(|c| c.compile_expr(f, &td, &alias))?),
17982            None => None,
17983        };
17984
17985        // Compile the object shape from `object { … }` inside the shape elements.
17986        // `elements` is the outer shape (`{ object { … }, distance }`).
17987        // We find the `object` element and take its sub-shape; everything else is ignored
17988        // at compile time (distance is always emitted; unknown pointers are an error).
17989        let mut object_shape: Vec<IrShapePointer> = vec![];
17990        for el in elements {
17991            if el.splat.is_some() {
17992                continue;
17993            } // ignore splat in outer shape
17994            let pointer_name = match el.path.steps.first() {
17995                Some(ast::PathStep::Name(n)) => n.as_str(),
17996                _ => continue,
17997            };
17998            match pointer_name {
17999                "distance" => { /* always emitted; no sub-shape */ }
18000                "object" => {
18001                    let sub_els = el.nested.as_deref().unwrap_or(&[]);
18002                    object_shape = self.compile_shape(sub_els, &td, &alias, &td.module)?;
18003                }
18004                other => {
18005                    return Err(self.type_err(&format!(
18006                        "vector::search result has no pointer '{}'; valid pointers are 'object' and 'distance'",
18007                        other
18008                    )));
18009                }
18010            }
18011        }
18012
18013        let (outer_filter, order_by_distance, offset, limit) = self.compile_vs_modifiers(s)?;
18014
18015        // Merge pre_filter (from subquery first arg) with any outer filter via AND.
18016        let filter = match (pre_filter, outer_filter) {
18017            (Some(a), Some(b)) => Some(IrExpr::BinOp(Box::new(IrBinOp {
18018                left: a,
18019                op: ast::BinOpKind::And,
18020                right: b,
18021            }))),
18022            (Some(f), None) | (None, Some(f)) => Some(f),
18023            (None, None) => None,
18024        };
18025
18026        Ok(Some(IrVectorSearch {
18027            source,
18028            vector_col,
18029            distance_op,
18030            query_expr,
18031            object_shape,
18032            filter,
18033            order_by_distance,
18034            offset,
18035            limit,
18036            inference_query_param_name,
18037            inference_query_literal,
18038            inference_model,
18039            inference_type_name,
18040            inference_index_name,
18041        }))
18042    }
18043
18044    /// Compile `order by`, `filter`, `offset`, `limit` for a VectorSearch source.
18045    /// Recognises `.distance` (relative path) as the distance expression.
18046    fn compile_vs_modifiers(&mut self, s: &ast::SelectStmt) -> Result<SearchModifiers, PyQLError> {
18047        let mut order_by_distance: Option<IrSortDir> = None;
18048        for sort in &s.order_by {
18049            let is_distance = matches!(&sort.expr,
18050                ast::Expr::Path(p) if p.partial && p.steps.len() == 1
18051                    && matches!(&p.steps[0], ast::PathStep::Name(n) if n == "distance")
18052            );
18053            if is_distance {
18054                let dir = match sort.direction {
18055                    ast::SortDirection::Desc => IrSortDir::Desc,
18056                    ast::SortDirection::Asc => IrSortDir::Asc,
18057                };
18058                order_by_distance = Some(dir);
18059            } else {
18060                return Err(self.type_err("vector::search: only 'order by .distance' is supported as a sort key"));
18061            }
18062        }
18063
18064        let filter = match &s.filter {
18065            Some(f) => Some(self.compile_free_expr(f)?),
18066            None => None,
18067        };
18068        let offset = match &s.offset {
18069            Some(o) => Some(self.compile_free_expr(o)?),
18070            None => None,
18071        };
18072        let limit = match &s.limit {
18073            Some(l) => Some(self.compile_free_expr(l)?),
18074            None => None,
18075        };
18076
18077        Ok((filter, order_by_distance, offset, limit))
18078    }
18079
18080    // ── fts::search ──────────────────────────────────────────────────────────────
18081
18082    /// Try to compile `fts::search(TypeName, $query [, index_name := '…'] [, mode := '…'])`.
18083    /// Returns `None` if the call is not `fts::search`.
18084    fn try_compile_fts_search(
18085        &mut self,
18086        fc: &ast::FunctionCall,
18087        elements: &[ast::ShapeElement],
18088        s: &ast::SelectStmt,
18089    ) -> Result<Option<IrFtsSearch>, PyQLError> {
18090        if fc.module.as_deref() != Some("fts") || fc.name != "search" {
18091            return Ok(None);
18092        }
18093        if fc.args.len() < 2 {
18094            return Err(self.type_err("fts::search requires at least 2 arguments: (TypeName, $query)"));
18095        }
18096
18097        // First argument: a bare type name reference.
18098        let type_qname = match &fc.args[0] {
18099            ast::Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
18100                if let ast::PathStep::Name(n) = &p.steps[0] {
18101                    let td = self
18102                        .resolve_type(n)
18103                        .map_err(|_| self.type_err(&format!("fts::search: '{}' is not a known type", n)))?;
18104                    format!("{}::{}", td.module, td.name)
18105                } else {
18106                    return Err(self.type_err("fts::search: first argument must be a type name"));
18107                }
18108            }
18109            _ => return Err(self.type_err("fts::search: first argument must be a bare type name")),
18110        };
18111
18112        // Optional named arguments.
18113        let index_name: Option<String> = fc.kwargs.iter().find(|(k, _)| k == "index_name").and_then(|(_, v)| {
18114            if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
18115                Some(s.clone())
18116            } else {
18117                None
18118            }
18119        });
18120
18121        let mode_str = fc
18122            .kwargs
18123            .iter()
18124            .find(|(k, _)| k == "mode")
18125            .and_then(|(_, v)| {
18126                if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
18127                    Some(s.as_str())
18128                } else {
18129                    None
18130                }
18131            })
18132            .unwrap_or("BestFields");
18133
18134        let tsquery_fn: &'static str = match mode_str {
18135            "Phrase" => "phraseto_tsquery",
18136            _ => "websearch_to_tsquery", // BestFields and PhrasePrefix both use websearch
18137        };
18138
18139        // Resolve the type and find the SearchIndex.
18140        let td = self.resolve_type(&type_qname)?.clone();
18141        let si = td
18142            .search_indexes
18143            .iter()
18144            .find(|si| si.index_name.as_deref() == index_name.as_deref())
18145            .ok_or_else(|| {
18146                let key = index_name.as_deref().unwrap_or("<default>");
18147                self.type_err(&format!("type '{}' has no search index '{}'", type_qname, key))
18148            })?;
18149
18150        let backend = si.backend.clone();
18151        let search_col = si.column_name();
18152        let is_deferred = backend != SearchBackend::Postgres;
18153        let deferred_index_name = if is_deferred {
18154            Some(si.deferred_index_name(&td.module, &td.name))
18155        } else {
18156            None
18157        };
18158
18159        // Second argument: the query text expression.
18160        // For deferred backends, IDs/scores are the only SQL params ($1/$2). The query
18161        // text is consumed by the Python layer before SQL execution, so we extract its
18162        // name/literal directly from the AST without registering a SQL param for it.
18163        let query_expr;
18164        let deferred_query_param_name;
18165        let deferred_query_literal;
18166        let deferred_ids_param;
18167        let deferred_scores_param;
18168
18169        if is_deferred {
18170            let ids_idx = self.param_index("__deferred_ids__");
18171            let scores_idx = self.param_index("__deferred_scores__");
18172            deferred_ids_param = Some(ids_idx);
18173            deferred_scores_param = Some(scores_idx);
18174            deferred_query_param_name = match &fc.args[1] {
18175                ast::Expr::Parameter(name) => Some(name.clone()),
18176                _ => None,
18177            };
18178            deferred_query_literal = match &fc.args[1] {
18179                ast::Expr::Literal(ast::Literal::Str(s)) => Some(s.clone()),
18180                _ => None,
18181            };
18182            // Placeholder — not used in SQL for the deferred backend.
18183            query_expr = IrExpr::Literal(crate::ir::IrLiteral::Str(String::new()));
18184        } else {
18185            query_expr = self.compile_free_expr(&fc.args[1])?;
18186            deferred_query_param_name = None;
18187            deferred_query_literal = None;
18188            deferred_ids_param = None;
18189            deferred_scores_param = None;
18190        }
18191
18192        let alias = self.fresh_alias();
18193        let source = IrSource {
18194            poly: None,
18195            type_name: type_qname.clone(),
18196            table: td.table.clone(),
18197            alias: alias.clone(),
18198        };
18199
18200        // Compile the object shape from `object { … }` in the outer shape.
18201        let mut object_shape: Vec<IrShapePointer> = vec![];
18202        for el in elements {
18203            if el.splat.is_some() {
18204                continue;
18205            }
18206            let pointer_name = match el.path.steps.first() {
18207                Some(ast::PathStep::Name(n)) => n.as_str(),
18208                _ => continue,
18209            };
18210            match pointer_name {
18211                "score" => { /* always emitted */ }
18212                "object" => {
18213                    let sub_els = el.nested.as_deref().unwrap_or(&[]);
18214                    object_shape = self.compile_shape(sub_els, &td, &alias, &td.module)?;
18215                }
18216                other => {
18217                    return Err(self.type_err(&format!(
18218                        "fts::search result has no pointer '{}'; valid pointers are 'object' and 'score'",
18219                        other
18220                    )));
18221                }
18222            }
18223        }
18224
18225        let (filter, order_by_rank, offset, limit) = self.compile_fts_modifiers(s)?;
18226
18227        Ok(Some(IrFtsSearch {
18228            source,
18229            backend,
18230            search_col,
18231            tsquery_fn,
18232            query_expr,
18233            object_shape,
18234            filter,
18235            order_by_rank,
18236            offset,
18237            limit,
18238            deferred_index_name,
18239            deferred_query_param_name,
18240            deferred_query_literal,
18241            deferred_ids_param,
18242            deferred_scores_param,
18243        }))
18244    }
18245
18246    /// Compile `order by`, `filter`, `offset`, `limit` for a FtsSearch source.
18247    /// Recognises `.score` (relative path) as the score expression.
18248    fn compile_fts_modifiers(&mut self, s: &ast::SelectStmt) -> Result<SearchModifiers, PyQLError> {
18249        let mut order_by_rank: Option<IrSortDir> = None;
18250        for sort in &s.order_by {
18251            let is_rank = matches!(&sort.expr,
18252                ast::Expr::Path(p) if p.partial && p.steps.len() == 1
18253                    && matches!(&p.steps[0], ast::PathStep::Name(n) if n == "score")
18254            );
18255            if is_rank {
18256                let dir = match sort.direction {
18257                    ast::SortDirection::Desc => IrSortDir::Desc,
18258                    ast::SortDirection::Asc => IrSortDir::Asc,
18259                };
18260                order_by_rank = Some(dir);
18261            } else {
18262                return Err(self.type_err("fts::search: only 'order by .score' is supported as a sort key"));
18263            }
18264        }
18265
18266        let filter = match &s.filter {
18267            Some(f) => Some(self.compile_free_expr(f)?),
18268            None => None,
18269        };
18270        let offset = match &s.offset {
18271            Some(o) => Some(self.compile_free_expr(o)?),
18272            None => None,
18273        };
18274        let limit = match &s.limit {
18275            Some(l) => Some(self.compile_free_expr(l)?),
18276            None => None,
18277        };
18278
18279        Ok((filter, order_by_rank, offset, limit))
18280    }
18281
18282    // ── Error helpers ─────────────────────────────────────────────────────────────
18283
18284    fn type_err(&self, msg: &str) -> PyQLError {
18285        PyQLError::Type(PyQLTypeError {
18286            message: msg.to_string(),
18287            position: Position { line: 0, col: 0 },
18288        })
18289    }
18290
18291    fn field_err(&self, field: &str, type_name: &str) -> PyQLError {
18292        if std::env::var("PYLON_DBG_FIELD_ERR").is_ok() {
18293            eprintln!(
18294                "DBG field_err {field} on {type_name}
18295{}",
18296                std::backtrace::Backtrace::force_capture()
18297            );
18298        }
18299        let suggestion = self
18300            .schema
18301            .types
18302            .iter()
18303            .find(|t| format!("{}::{}", t.module, t.name) == type_name)
18304            .and_then(|td| Self::suggest_pointer_name(td, field));
18305        let message = match suggestion {
18306            Some(s) => format!("object type '{type_name}' has no link or property '{field}'. Did you mean '{s}'?"),
18307            None => format!("object type '{type_name}' has no link or property '{field}'"),
18308        };
18309        PyQLError::Resolution(PyQLResolutionError::UnknownField(PyQLUnknownFieldError {
18310            message,
18311            position: Position { line: 0, col: 0 },
18312        }))
18313    }
18314
18315    /// A ` — did you mean …?` fragment for an unresolvable function name, or
18316    /// an empty string.
18317    ///
18318    /// Mirrors `pylon.stdlib.unknown_function_message`, which has always
18319    /// offered this for the `std.foo(...)` attribute form; a name written as
18320    /// PyQL text got nothing, which is how a schema converted from a system
18321    /// that spelled it `uuid_generate_v7j` stayed broken instead of being
18322    /// pointed one character back at `uuid_generate_v7`.
18323    ///
18324    /// The wrong-namespace check comes first and is worth its own wording:
18325    /// the namespaces overlap unevenly — `sqrt`/`abs`/`ceil` are in both
18326    /// `std` and `math`, while `pi`/`ln`/`exp` are only in `math` — so
18327    /// guessing the wrong one is routine, and naming where the function
18328    /// actually lives recovers from it in a way a fuzzy match over the
18329    /// namespace that lacks it cannot.
18330    fn suggest_function_name(&self, ns: &str, name: &str) -> String {
18331        const MIN_SIMILARITY: f64 = 0.7;
18332        if let Some(other) = ["std", "math", "cal", "sys"]
18333            .into_iter()
18334            .find(|o| *o != ns && !crate::stdlib::lookup(o, name).is_empty())
18335        {
18336            return format!(" — it lives in {other}, use {other}::{name}()");
18337        }
18338        crate::stdlib::registry()
18339            .iter()
18340            .filter(|d| d.namespace == ns)
18341            .map(|d| (format!("{ns}::{}", d.name), d.name))
18342            .chain(
18343                self.schema
18344                    .functions
18345                    .iter()
18346                    .map(|f| (format!("{}::{}", f.module, f.name), f.name.as_str())),
18347            )
18348            .map(|(qualified, candidate)| (qualified, strsim::jaro_winkler(name, candidate)))
18349            .filter(|(_, score)| *score >= MIN_SIMILARITY)
18350            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
18351            .map(|(qualified, _)| format!(" — did you mean {qualified}()?"))
18352            .unwrap_or_default()
18353    }
18354
18355    /// Fuzzy-matches `name` against every pointer (property/link/multilink/
18356    /// computed — Pylon's term for a type's own attributes; "field" is a
18357    /// Postgres-level term that doesn't apply here) on `td`, returning the
18358    /// closest candidate when it's plausibly a typo — powers a
18359    /// "Did you mean X?" suggestion for an unknown property/link.
18360    /// Jaro-Winkler (favors a shared prefix, which is where most real typos
18361    /// preserve the most characters, e.g. `nam` -> `name`) with a
18362    /// conservative similarity floor, so an unrelated pointer never gets
18363    /// suggested just because it happens to be the "closest" among an
18364    /// otherwise-dissimilar set of candidates.
18365    fn suggest_pointer_name(td: &TypeDescriptor, name: &str) -> Option<String> {
18366        const MIN_SIMILARITY: f64 = 0.7;
18367        td.properties
18368            .iter()
18369            .map(|p| p.name.as_str())
18370            .chain(td.links.iter().map(|l| l.name.as_str()))
18371            .chain(td.multilinks.iter().map(|m| m.name.as_str()))
18372            .chain(td.computed.iter().map(|c| c.name.as_str()))
18373            .map(|candidate| (candidate, strsim::jaro_winkler(name, candidate)))
18374            .filter(|(_, score)| *score >= MIN_SIMILARITY)
18375            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
18376            .map(|(name, _)| name.to_string())
18377    }
18378
18379    // ── Default returning (pk only) ────────────────────────────────────────────
18380
18381    /// For bare DML (not wrapped in SELECT) return only primary-key properties:
18382    /// `INSERT … ` returns `{ id }`, same for UPDATE/DELETE.
18383    fn pk_returning(td: &TypeDescriptor) -> Vec<IrShapePointer> {
18384        td.properties
18385            .iter()
18386            .filter(|p| p.is_pk)
18387            .map(|p| {
18388                IrShapePointer::Scalar(IrScalarPointer {
18389                    implicit_id: false,
18390                    marker_offset: None,
18391                    alias: p.name.clone(),
18392                    column: p.name.clone(),
18393                    pg_type: p.pg_type.clone(),
18394                    tuple_shape: None,
18395                })
18396            })
18397            .collect()
18398    }
18399}
18400
18401// ── Helpers ─────────────────────────────────────────────────────────────────────
18402
18403/// True for `<json>` / `<std::json>` — the one cast target that turns its
18404/// operand into output text rather than another value.
18405fn casts_to_json(ty: &ast::TypeExpr) -> bool {
18406    matches!(ty.as_named(), Some((module, "json")) if module.is_none_or(|m| m == "std"))
18407}
18408
18409/// True if any node in a multilink value tree (including both sides of any
18410/// nested `union`) carries a `@prop := value` link-property assignment.
18411fn has_any_link_props(vals: &IrMultiLinkValues) -> bool {
18412    if !vals.link_props.is_empty() {
18413        return true;
18414    }
18415    match &vals.source {
18416        IrMultiLinkValueSource::Union(a, b) => has_any_link_props(a) || has_any_link_props(b),
18417        _ => false,
18418    }
18419}
18420
18421/// True for a bare `{}` or a `<AnyType>{}` cast of one — the empty-set
18422/// literal a caller uses to clear an optional pointer, in either form. A
18423/// frontend generating PyQL typically emits the cast form (e.g.
18424/// `<Company>{}`, matching `compile_expr`'s own `Expr::TypeCast` handling of
18425/// this exact case), while hand-written PyQL more often uses the bare form.
18426fn is_empty_set_expr(expr: &Expr) -> bool {
18427    match expr {
18428        Expr::Set(elems) => elems.is_empty(),
18429        Expr::TypeCast(tc) => matches!(&tc.expr, Expr::Set(elems) if elems.is_empty()),
18430        _ => false,
18431    }
18432}
18433
18434/// Extract the single pointer name from a relative path used in a shape element.
18435fn path_leaf(p: &ast::Path) -> Result<&str, PyQLError> {
18436    match p.steps.as_slice() {
18437        [ast::PathStep::Name(n)] => Ok(n.as_str()),
18438        _ => Err(PyQLError::Type(PyQLTypeError {
18439            message: "expected a simple pointer name in shape element".into(),
18440            position: Position { line: 0, col: 0 },
18441        })),
18442    }
18443}
18444
18445/// Map a PyQL type expression to a PostgreSQL type string.
18446fn type_expr_to_pg(ty: &ast::TypeExpr) -> Result<String, PyQLError> {
18447    let Some((module, bare_name)) = ty.as_named() else {
18448        // Callers resolve a structural tuple/array directly before ever
18449        // reaching this function (see `resolve_cast_pg_type`) — reaching here
18450        // with one would be an internal bug, not a user-facing scenario.
18451        return Err(PyQLError::Type(PyQLTypeError {
18452            message: "internal error: structural tuple/array type reached type_expr_to_pg".into(),
18453            position: Position { line: 0, col: 0 },
18454        }));
18455    };
18456
18457    // pgvector:: types map directly to PostgreSQL types.
18458    if module == Some("pgvector") {
18459        return match bare_name {
18460            "vector" => Ok("vector".to_string()),
18461            other => Err(PyQLError::Type(PyQLTypeError {
18462                message: format!("unknown pgvector type '{other}'; valid types are: vector"),
18463                position: Position { line: 0, col: 0 },
18464            })),
18465        };
18466    }
18467
18468    // postgis:: types map directly to PostgreSQL types.
18469    if module == Some("postgis") {
18470        return match bare_name {
18471            "geometry" => Ok("geometry".to_string()),
18472            "geography" => Ok("geography".to_string()),
18473            "box2d" => Ok("box2d".to_string()),
18474            "box3d" => Ok("box3d".to_string()),
18475            other => Err(PyQLError::Type(PyQLTypeError {
18476                message: format!("unknown postgis type '{other}'; valid types are: geometry, geography, box2d, box3d"),
18477                position: Position { line: 0, col: 0 },
18478            })),
18479        };
18480    }
18481
18482    // cal:: types map directly to PostgreSQL types.
18483    if module == Some("cal") {
18484        let pg = match bare_name {
18485            "local_datetime" => "timestamp",
18486            "local_date" => "date",
18487            "local_time" => "time",
18488            "relative_duration" | "date_duration" => "interval",
18489            other => {
18490                return Err(PyQLError::Type(PyQLTypeError {
18491                    message: format!(
18492                        "unknown cal type '{other}'; \
18493                     valid types are: local_datetime, local_date, local_time, \
18494                     relative_duration, date_duration"
18495                    ),
18496                    position: Position { line: 0, col: 0 },
18497                }));
18498            }
18499        };
18500        return Ok(pg.to_string());
18501    }
18502
18503    let name = match module {
18504        Some("std") | None => bare_name,
18505        Some(m) => {
18506            return Err(PyQLError::Type(PyQLTypeError {
18507                message: format!("unknown type '{}::{}'", m, bare_name),
18508                position: Position { line: 0, col: 0 },
18509            }));
18510        }
18511    };
18512    Ok(match name {
18513        "str" => "text",
18514        "int16" => "int2",
18515        "int32" => "int4",
18516        "int64" => "int8",
18517        "float32" => "float4",
18518        "float64" => "float8",
18519        "bool" => "boolean",
18520        "uuid" => "uuid",
18521        "bytes" => "bytea",
18522        "json" => "jsonb",
18523        "decimal" => "numeric",
18524        // bigint and decimal are both PostgreSQL `numeric` — the distinction
18525        // is a Pylon-level scale/precision convention, not a separate PG type.
18526        "bigint" => "numeric",
18527        "datetime" => "timestamptz",
18528        "date" => "date",
18529        "time" => "time",
18530        "duration" => "interval",
18531        other => {
18532            return Err(PyQLError::Type(PyQLTypeError {
18533                message: format!("unknown type '{other}'"),
18534                position: Position { line: 0, col: 0 },
18535            }));
18536        }
18537    }
18538    .to_string())
18539}
18540
18541/// Check whether a compiled expression is compatible with a PylonType parameter.
18542/// Used for overload selection when multiple overloads share the same name.
18543/// Emit `($1 IS NOT NULL)` for a given IR expression.
18544fn ir_is_not_null(expr: IrExpr) -> IrExpr {
18545    IrExpr::FunctionCall(IrFunctionCall {
18546        return_pg_type: None,
18547        schema: None,
18548        name: String::new(),
18549        args: vec![expr],
18550        sql_template: Some("($1 IS NOT NULL)".to_string()),
18551    })
18552}
18553
18554/// "1 argument(s)" / "2 or 3 argument(s)" / "at least 1 argument(s)" — the
18555/// arities an overload set accepts, worded as `pylon.stdlib._arity_error`
18556/// words the same rejection on the Python side.
18557fn describe_arities(overloads: &[&crate::stdlib::FnDescriptor]) -> String {
18558    // Positions only: a named-only parameter is not one of the arguments the
18559    // count is about, so counting it reported `range(…)` as taking four.
18560    let mut arities: Vec<usize> = overloads
18561        .iter()
18562        .map(|d| d.params.iter().filter(|p| p.named_only.is_none()).count())
18563        .collect();
18564    arities.sort_unstable();
18565    arities.dedup();
18566    if overloads.iter().any(|d| d.is_variadic()) {
18567        return format!("at least {} argument(s)", arities[0].saturating_sub(1));
18568    }
18569    let list = arities.iter().map(usize::to_string).collect::<Vec<_>>().join(" or ");
18570    format!("{list} argument(s)")
18571}
18572
18573/// The argument types as far as they could be inferred, for an error that
18574/// has to show the caller what it actually passed.
18575fn describe_args(args: &[IrExpr]) -> String {
18576    args.iter()
18577        .map(|a| infer_ir_type(a).map_or("?", literal_sentinel_to_pg))
18578        .collect::<Vec<_>>()
18579        .join(", ")
18580}
18581
18582/// The parameter lists of the overloads that did match on arity, in PyQL
18583/// spelling — what the caller should have passed.
18584fn describe_signatures(overloads: &[&crate::stdlib::FnDescriptor]) -> String {
18585    overloads
18586        .iter()
18587        .map(|d| {
18588            let params = d
18589                .params
18590                .iter()
18591                .map(|p| match p.named_only {
18592                    // Spelled the way the call has to pass it, so a rejected
18593                    // call isn't told to write a named parameter by position.
18594                    Some(_) => format!("{} := {}", p.keyword(), p.ty.pyql_name()),
18595                    None => p.ty.pyql_name(),
18596                })
18597                .collect::<Vec<_>>()
18598                .join(", ");
18599            format!("({params})")
18600        })
18601        .collect::<Vec<_>>()
18602        .join(" or ")
18603}
18604
18605/// The value of a boolean literal argument, for the call shapes that can be
18606/// resolved at compile time rather than left to a runtime `CASE`.
18607fn bool_literal(expr: &IrExpr) -> Option<bool> {
18608    match expr {
18609        IrExpr::Literal(IrLiteral::Bool(b)) => Some(*b),
18610        _ => None,
18611    }
18612}
18613
18614/// Gather the arguments a variadic parameter absorbed into one array, for the
18615/// overloads that declare named-only parameters after it.
18616///
18617/// PostgreSQL requires `VARIADIC` to be the last parameter, so an overload
18618/// with anything after it declares a plain array instead (see
18619/// `ddl::pg_params`) and is handed the elements already gathered. An overload
18620/// whose variadic really is last keeps passing them one by one, which is what
18621/// `VARIADIC path text[]` expects.
18622fn pack_variadic_args(d: &crate::stdlib::FnDescriptor, args: Vec<IrExpr>) -> Vec<IrExpr> {
18623    let named = d.named_count();
18624    let Some(variadic_at) = d.variadic_index().filter(|v| v + 1 < d.params.len()) else {
18625        return args;
18626    };
18627    let Some(absorbed) = args.len().checked_sub(variadic_at + named) else {
18628        return args;
18629    };
18630    let mut packed: Vec<IrExpr> = Vec::with_capacity(variadic_at + 1 + named);
18631    let mut rest = args.into_iter();
18632    packed.extend(rest.by_ref().take(variadic_at));
18633    packed.push(IrExpr::Array(rest.by_ref().take(absorbed).collect()));
18634    packed.extend(rest);
18635    packed
18636}
18637
18638/// The parameter each of `argc` arguments is checked against.
18639///
18640/// One per declared parameter, except that a variadic one stands in for every
18641/// argument past the fixed ones — `json_get(j, 'a', 'b')` checks both path
18642/// elements against the single variadic `str` parameter rather than leaving
18643/// the third argument unchecked, which a plain `params.iter()` zipped against
18644/// the arguments would do.
18645///
18646/// The named-only parameters keep their own positions at the tail:
18647/// `compile_named_call_args` has already appended one argument per named-only
18648/// parameter, in declaration order, so the arguments a variadic absorbs are
18649/// only the ones between the fixed parameters and that tail.
18650fn params_for_args(d: &crate::stdlib::FnDescriptor, argc: usize) -> impl Iterator<Item = &crate::stdlib::Param> {
18651    let named = d.named_count();
18652    let variadic_at = d.is_variadic().then(|| d.variadic_index()).flatten();
18653    let absorbed = variadic_at.map_or(0, |v| argc.saturating_sub(v + named));
18654    (0..argc).filter_map(move |i| match variadic_at {
18655        Some(v) if i >= v && i < v + absorbed => d.params.get(v),
18656        Some(v) if i >= v => d.params.get(i + 1 - absorbed),
18657        _ => d.params.get(i),
18658    })
18659}
18660
18661fn pylon_type_matches(expr: &IrExpr, ty: &crate::stdlib::PylonType) -> bool {
18662    use crate::stdlib::PylonType as PT;
18663    match ty {
18664        // Wildcard params always match.
18665        PT::Any | PT::AnyOrderable | PT::AnyPoint => true,
18666        PT::Array(_) => is_array_expr(expr),
18667        // Matched on the concrete PostgreSQL type name, which for a
18668        // multirange is `int8multirange`/`tstzmultirange`/… — it carries the
18669        // element family as a prefix, so "is a multirange" is a `contains`,
18670        // not a `starts_with`, and "is a plain range" has to exclude it.
18671        PT::Range(_) => matches!(infer_ir_type(expr), Some(t) if t.ends_with("range") && !t.contains("multirange")),
18672        PT::Multirange(_) => matches!(infer_ir_type(expr), Some(t) if t.contains("multirange")),
18673        // Every parameter type that names a concrete PostgreSQL scalar is
18674        // judged by `types_compatible` — the one implicit-cast graph the
18675        // rest of the compiler already resolves operators against — rather
18676        // than by an exact type-name match. Exactness rejected an integer
18677        // literal reaching a `float64` parameter, which is how
18678        // `cal::to_relative_duration(seconds := 3)` and every other
18679        // int-for-float argument reads.
18680        //
18681        // An argument whose type cannot be inferred does *not* match a
18682        // parameter that names one: an overload is chosen only on evidence,
18683        // and `resolve_fn_call` reports the call rather than guessing.
18684        // For a parameter type with no scalar spelling (`optional<…>` and
18685        // friends) there is nothing to judge, so allow.
18686        _ => match (ty.scalar_pg_type(), infer_ir_type(expr)) {
18687            (Some(declared), Some(known)) => types_compatible(known, declared),
18688            (Some(_), None) => false,
18689            (None, _) => true,
18690        },
18691    }
18692}
18693
18694/// True for a call like `array_unpack` that yields a set rather than one value.
18695fn expr_returns_set(expr: &Expr) -> bool {
18696    let Expr::FunctionCall(call) = expr else {
18697        return false;
18698    };
18699    crate::stdlib::lookup(call.module.as_deref().unwrap_or("std"), &call.name)
18700        .iter()
18701        .any(|d| d.returns_set())
18702}
18703
18704fn literal_sentinel_to_pg(t: &str) -> &str {
18705    match t {
18706        "__int_literal" => "int8",
18707        "__float_literal" => "float8",
18708        other => other,
18709    }
18710}
18711
18712fn is_array_expr(expr: &IrExpr) -> bool {
18713    match through_coalesce(expr) {
18714        IrExpr::Array(_) | IrExpr::ArrayFromSelect(_) => true,
18715        // `str_split(…)[-1]` — a stdlib call declared to return an array,
18716        // which `infer_ir_type` cannot spell because it only names scalars.
18717        IrExpr::FunctionCall(f) if f.schema.is_none() => {
18718            let mut overloads = crate::stdlib::registry().iter().filter(|d| d.name == f.name).peekable();
18719            overloads.peek().is_some() && overloads.all(|d| matches!(d.return_type, crate::stdlib::PylonType::Array(_)))
18720        }
18721        // Everything else an array can arrive as — a column, a cast, a
18722        // global, a `with` binding, a concatenation of any of those — is
18723        // known by the type it carries.
18724        other => matches!(infer_ir_type(other), Some(t) if t.ends_with("[]")),
18725    }
18726}
18727
18728/// A lower bound, computable without running the query, on how many bytes a
18729/// `notify()` payload will serialize to.
18730///
18731/// Only the parts that are already known contribute: string literals count
18732/// their own length, a concatenation sums its sides, a free object counts the
18733/// JSON envelope it will always emit (`{}`, the quoted field names, and the
18734/// `:`/`,` separators) plus whatever its field expressions themselves floor
18735/// at. Anything runtime-valued — a column, a parameter, a function call —
18736/// contributes 0, so this never over-estimates and never rejects a payload
18737/// that could actually have fit.
18738///
18739/// Exists because Postgres's 8000-byte NOTIFY cap is enforced at *runtime*,
18740/// and blowing it aborts the transaction that sent the notification — which,
18741/// for the composed `with update ... select (notify(...), updated)` shape, is
18742/// the write itself.
18743fn static_min_payload_bytes(expr: &ast::Expr) -> usize {
18744    use crate::parse::ast::{BinOpKind, Expr, Literal};
18745    match expr {
18746        Expr::Literal(Literal::Str(s)) => s.len(),
18747        Expr::BinOp(op) if matches!(op.op, BinOpKind::Concat) => {
18748            static_min_payload_bytes(&op.left) + static_min_payload_bytes(&op.right)
18749        }
18750        Expr::Shape(sh) if sh.expr.is_none() => {
18751            // `{"a":,"b":}` — braces, one quoted name and colon per field,
18752            // and a comma between them. Every one of those bytes is emitted
18753            // regardless of what the values turn out to be.
18754            let mut total = 2;
18755            for (i, el) in sh.elements.iter().enumerate() {
18756                if i > 0 {
18757                    total += 1;
18758                }
18759                let name_len = match el.path.steps.last() {
18760                    Some(crate::parse::ast::PathStep::Name(n)) => n.len(),
18761                    _ => 0,
18762                };
18763                total += name_len + 3;
18764                if let Some(value) = &el.compexpr {
18765                    total += static_min_payload_bytes(value);
18766                }
18767            }
18768            total
18769        }
18770        _ => 0,
18771    }
18772}
18773
18774fn expr_to_std_type(expr: &ast::Expr) -> &'static str {
18775    match expr {
18776        ast::Expr::Literal(ast::Literal::Str(_)) => "std::str",
18777        ast::Expr::Literal(ast::Literal::Int(_)) => "std::int64",
18778        ast::Expr::Literal(ast::Literal::Float(_)) => "std::float64",
18779        ast::Expr::Literal(ast::Literal::Bool(_)) => "std::bool",
18780        _ => "anytype",
18781    }
18782}
18783
18784fn named_tuple_type_str(fields: &[(String, ast::Expr)]) -> String {
18785    let inner = fields
18786        .iter()
18787        .map(|(k, v)| format!("{}: {}", k, expr_to_std_type(v)))
18788        .collect::<Vec<_>>()
18789        .join(", ");
18790    format!("tuple<{}>", inner)
18791}
18792
18793fn positional_tuple_type_str(elems: &[ast::Expr]) -> String {
18794    let inner = elems.iter().map(expr_to_std_type).collect::<Vec<_>>().join(", ");
18795    format!("tuple<{}>", inner)
18796}
18797
18798pub(crate) fn infer_ir_type(expr: &IrExpr) -> Option<&str> {
18799    match expr {
18800        IrExpr::ColumnRef { pg_type, .. } => Some(pg_type.as_str()),
18801        IrExpr::TypeCast(tc) => Some(tc.pg_type.as_str()),
18802        IrExpr::FnParam { pg_type, .. } => Some(pg_type.as_str()),
18803        IrExpr::Literal(lit) => Some(match lit {
18804            IrLiteral::Str(_) => "text",
18805            IrLiteral::Int(_) => "__int_literal",
18806            IrLiteral::Float(_) => "__float_literal",
18807            IrLiteral::Bool(_) => "boolean",
18808        }),
18809        IrExpr::EnumLiteral { pg_type, .. } => Some(pg_type.as_str()),
18810        IrExpr::NamedTuple { .. } => Some("jsonb"),
18811        IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } => Some("jsonb"),
18812        IrExpr::GlobalParam { pg_type, .. } => Some(pg_type.as_str()),
18813        // A `with`-bound scalar is typed by what it binds, so a call over one
18814        // resolves to the same overload the bare value would.
18815        IrExpr::CteRef { pg_type, .. } => pg_type.as_deref(),
18816        // A correlated path subquery is typed by whatever it projects — the
18817        // scalar column at the end of the path. An object-valued one yields
18818        // an id, which no operator should silently compare against.
18819        IrExpr::PathSubquery(ps) => match &ps.result {
18820            IrPathResult::Scalar(e, _) => infer_ir_type(e),
18821            IrPathResult::Object { .. } => None,
18822        },
18823        // `a ++ b` and `distinct a` both yield whatever they were given, so
18824        // an array stays recognisable as one through either.
18825        IrExpr::BinOp(b) if b.op == crate::parse::ast::BinOpKind::Concat => {
18826            infer_ir_type(&b.left).or_else(|| infer_ir_type(&b.right))
18827        }
18828        IrExpr::UnaryOp(u) if u.op == crate::parse::ast::UnaryOpKind::Distinct => infer_ir_type(&u.operand),
18829        // `-x` is a number of x's own type; `not x` and `exists x` are always
18830        // boolean.
18831        IrExpr::UnaryOp(u) if u.op == crate::parse::ast::UnaryOpKind::Minus => infer_ir_type(&u.operand),
18832        IrExpr::UnaryOp(u)
18833            if matches!(
18834                u.op,
18835                crate::parse::ast::UnaryOpKind::Not | crate::parse::ast::UnaryOpKind::Exists
18836            ) =>
18837        {
18838            Some("boolean")
18839        }
18840        // `a ?? b` and `a if c else b` yield a value of their branches' own
18841        // type, so a call over either resolves the overload the bare value
18842        // would. Whichever side carries a type decides: the other is routinely
18843        // the untypeable one — an empty set, or a path known only to be
18844        // optional.
18845        IrExpr::BinOp(b) if b.op == crate::parse::ast::BinOpKind::Coalesce => {
18846            infer_ir_type(&b.left).or_else(|| infer_ir_type(&b.right))
18847        }
18848        IrExpr::IfElse(ie) => infer_ir_type(&ie.if_).or_else(|| infer_ir_type(&ie.else_)),
18849        // A slice is the same type as the thing sliced — `substr` of text is
18850        // text, of bytea is bytea, and an array slice is still that array.
18851        // Without this a `to_bytes(x)[12:16]` reaching a `bytes` parameter
18852        // reads as untyped, which is the difference between resolving the
18853        // bytes overload and resolving nothing at all.
18854        IrExpr::Slice { expr, .. } => infer_ir_type(expr),
18855        // `xs[0]` is one element of what `xs` holds — the array type with its
18856        // `[]` taken off. A string or bytes subscript stays the type it
18857        // indexes, the way the `substr` it emits does.
18858        IrExpr::CteFieldRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => pg_type.as_deref(),
18859        IrExpr::Subscript { expr, is_array, .. } => {
18860            // `str_split(s, '::')[0]`: the array a stdlib call returns is not a
18861            // type `infer_ir_type` can name (it only names scalars), so the
18862            // element type comes from the overload's own declared return type.
18863            let Some(base) = infer_ir_type(expr) else {
18864                return stdlib_array_element_type(expr);
18865            };
18866            if *is_array { base.strip_suffix("[]") } else { Some(base) }
18867        }
18868        IrExpr::BinOp(b) => arithmetic_result_type(&b.op, infer_ir_type(&b.left)?, infer_ir_type(&b.right)?),
18869        // The `coalesce` the compiler itself builds — around an aggregate that
18870        // ran over nothing, or a `??` whose sides name rows — carries no
18871        // recorded return type, so it is typed from its arguments like the
18872        // operator it stands in for.
18873        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "coalesce" => f.args.iter().find_map(infer_ir_type),
18874        IrExpr::FunctionCall(f) if f.schema.is_none() && matches!(f.name.as_str(), "max" | "min" | "sum") => {
18875            aggregate_result_type(&f.name, infer_ir_type(f.args.first()?)?)
18876        }
18877        IrExpr::AggOverSet {
18878            fn_name, schema: None, ..
18879        } if fn_name == "count" => Some("int8"),
18880        IrExpr::AggOverSet {
18881            fn_name,
18882            schema: None,
18883            elems,
18884        } => aggregate_result_type(fn_name, infer_ir_type(elems.first()?)?),
18885        // Any call whose resolution recorded a scalar return type — a stdlib
18886        // overload (`enc::base64_decode(…)` is bytea) or a user-defined
18887        // function, which is how a computed pointer's declared type gets
18888        // checked against the function it calls at all.
18889        IrExpr::FunctionCall(f) => f.return_pg_type.as_deref(),
18890        _ => None,
18891    }
18892}
18893
18894/// The element type of an array a stdlib call returns, for an expression whose
18895/// own type `infer_ir_type` cannot name. `None` unless every overload of the
18896/// name returns an array of the same scalar — the call would be ambiguous
18897/// otherwise, and guessing is what overload resolution exists to avoid.
18898fn stdlib_array_element_type(expr: &IrExpr) -> Option<&'static str> {
18899    let IrExpr::FunctionCall(f) = expr else {
18900        return None;
18901    };
18902    if f.schema.is_some() {
18903        return None;
18904    }
18905    let mut element: Option<&'static str> = None;
18906    for descriptor in crate::stdlib::registry().iter().filter(|d| d.name == f.name) {
18907        let crate::stdlib::PylonType::Array(inner) = &descriptor.return_type else {
18908            return None;
18909        };
18910        let scalar = inner.scalar_pg_type()?;
18911        if element.is_some_and(|seen| seen != scalar) {
18912            return None;
18913        }
18914        element = Some(scalar);
18915    }
18916    element
18917}
18918
18919/// What `+` and `-` yield over instants and durations — a datetime minus a
18920/// datetime is a duration, a datetime shifted by one is a datetime.
18921/// `duration_to_seconds(datetime_of_transaction() - .started_at)` is the shape
18922/// this exists for, and it resolves no overload while the subtraction reads as
18923/// untyped.
18924fn temporal_result_type(op: &ast::BinOpKind, left: &str, right: &str) -> Option<&'static str> {
18925    use ast::BinOpKind::*;
18926    let instant = |t: &str| matches!(t, "timestamptz" | "timestamp" | "date" | "time");
18927    match (op, left, right) {
18928        (Sub, l, r) if instant(l) && instant(r) => Some("interval"),
18929        (Sub | Add, l, "interval") if instant(l) => Some(match l {
18930            "timestamptz" => "timestamptz",
18931            "timestamp" => "timestamp",
18932            "date" => "date",
18933            _ => "time",
18934        }),
18935        (Add, "interval", r) if instant(r) => Some(match r {
18936            "timestamptz" => "timestamptz",
18937            "timestamp" => "timestamp",
18938            "date" => "date",
18939            _ => "time",
18940        }),
18941        (Add | Sub, "interval", "interval") => Some("interval"),
18942        _ => None,
18943    }
18944}
18945
18946/// The type an arithmetic operator's result carries, from its operands'.
18947/// `None` for anything but a pair of numbers.
18948fn arithmetic_result_type(op: &ast::BinOpKind, left: &str, right: &str) -> Option<&'static str> {
18949    use ast::BinOpKind::*;
18950    if !matches!(op, Add | Sub | Mul | Div | FloorDiv | Mod | Pow) {
18951        return None;
18952    }
18953    let (left, right) = (literal_sentinel_to_pg(left), literal_sentinel_to_pg(right));
18954    if let Some(temporal) = temporal_result_type(op, left, right) {
18955        return Some(temporal);
18956    }
18957    let int = |t: &str| INT_TYPES.contains(&t);
18958    let float = |t: &str| FLOAT_TYPES.contains(&t);
18959    let numeric = |t: &str| NUMERIC_TYPES.contains(&t);
18960    if int(left) && int(right) {
18961        return Some(if matches!(op, Div | Pow) {
18962            "float8"
18963        } else if left == "int8" || right == "int8" {
18964            "int8"
18965        } else if left == "int4" || right == "int4" {
18966            "int4"
18967        } else {
18968            "int2"
18969        });
18970    }
18971    if (float(left) || int(left)) && (float(right) || int(right)) {
18972        return Some(if left == "float4" && right == "float4" {
18973            "float4"
18974        } else {
18975            "float8"
18976        });
18977    }
18978    if (numeric(left) || int(left)) && (numeric(right) || int(right)) {
18979        return Some("numeric");
18980    }
18981    None
18982}
18983
18984/// What `max`, `min` and `sum` over values of `element` yield.
18985fn aggregate_result_type<'a>(name: &str, element: &'a str) -> Option<&'a str> {
18986    let element = literal_sentinel_to_pg(element);
18987    match name {
18988        "max" | "min" => Some(element),
18989        "sum" if INT_TYPES.contains(&element) => Some("int8"),
18990        "sum" if FLOAT_TYPES.contains(&element) || NUMERIC_TYPES.contains(&element) => Some(element),
18991        _ => None,
18992    }
18993}
18994
18995/// The left operand of `left op right`, cast to `float8` when `op` divides
18996/// two integers: PyQL's `/` yields a float64 there, Postgres's truncates.
18997fn true_division_operand(op: &ast::BinOpKind, left: IrExpr, right: &IrExpr) -> IrExpr {
18998    let integer = |expr: &IrExpr| infer_ir_type(expr).is_some_and(|t| INT_TYPES.contains(&literal_sentinel_to_pg(t)));
18999    if *op != ast::BinOpKind::Div || !integer(&left) || !integer(right) {
19000        return left;
19001    }
19002    IrExpr::TypeCast(Box::new(IrTypeCast {
19003        expr: left,
19004        pg_type: "float8".to_string(),
19005        tuple_shape: None,
19006    }))
19007}
19008
19009/// Maps a resolved element `pg_type` (as `infer_ir_type` reports it) to the
19010/// PostgreSQL native range constructor over that type. PG has no float4/
19011/// float8/int2/int4-native range type — only int8range, numrange, tsrange,
19012/// tstzrange, and daterange exist — so untyped int/float literals default
19013/// to the widest native family (int8/numeric) rather than erroring, matching
19014/// how those literals already default elsewhere in Pylon.
19015fn range_ctor_for_pg_type(pg_type: &str) -> Option<&'static str> {
19016    match pg_type {
19017        "int2" | "int4" | "int8" | "__int_literal" => Some("int8range"),
19018        "numeric" | "__float_literal" => Some("numrange"),
19019        "timestamp" => Some("tsrange"),
19020        "timestamptz" => Some("tstzrange"),
19021        "date" => Some("daterange"),
19022        _ => None,
19023    }
19024}
19025
19026/// The multirange counterpart of a range constructor name resolved by
19027/// `range_ctor_for_pg_type`.
19028fn multirange_ctor_for_range_ctor(range_ctor: &str) -> Option<&'static str> {
19029    match range_ctor {
19030        "int8range" => Some("int8multirange"),
19031        "numrange" => Some("nummultirange"),
19032        "tsrange" => Some("tsmultirange"),
19033        "tstzrange" => Some("tstzmultirange"),
19034        "daterange" => Some("datemultirange"),
19035        _ => None,
19036    }
19037}
19038
19039const INT_TYPES: &[&str] = &["int2", "int4", "int8", "__int_literal"];
19040const FLOAT_TYPES: &[&str] = &["float4", "float8", "__float_literal"];
19041/// Postgres `numeric` backs both Pylon's `bigint` and `decimal` (see
19042/// `pg_type_for_scalar_name`) — Pylon doesn't distinguish them at the
19043/// pg_type level, so this bucket covers both.
19044const NUMERIC_TYPES: &[&str] = &["numeric"];
19045
19046/// Pylon's implicit-cast graph for numeric operands:
19047/// `int16 → int32 → int64 → float32 → float64` on one branch and
19048/// `int64 → bigint → decimal` on another — every int width casts to every
19049/// float width and to numeric/decimal, but float and numeric/decimal don't
19050/// cast to each other (they're separate branches past `int64`). Postgres's
19051/// own operator resolution handles the actual mixed-type arithmetic once
19052/// the compile-time gate lets it through (e.g. `int2 + float4` is a native
19053/// Postgres operator) — this only needs to match which operand-type
19054/// combinations are meant to be allowed.
19055pub(crate) fn types_compatible(a: &str, b: &str) -> bool {
19056    if a == b {
19057        return true;
19058    }
19059    let a_int = INT_TYPES.contains(&a);
19060    let b_int = INT_TYPES.contains(&b);
19061    if a_int && b_int {
19062        return true;
19063    }
19064    let a_float = FLOAT_TYPES.contains(&a);
19065    let b_float = FLOAT_TYPES.contains(&b);
19066    if a_float && b_float {
19067        return true;
19068    }
19069    let a_numeric = NUMERIC_TYPES.contains(&a);
19070    let b_numeric = NUMERIC_TYPES.contains(&b);
19071    if a_numeric && b_numeric {
19072        return true;
19073    }
19074    (a_int && b_float) || (a_float && b_int) || (a_int && b_numeric) || (a_numeric && b_int)
19075}
19076
19077/// Datetime/duration `+`/`-` pairs PostgreSQL supports natively (e.g.
19078/// `timestamptz + interval`) that `types_compatible`'s bucket-matching
19079/// (same type, or both-int, or both-float) doesn't cover. Deliberately a
19080/// separate, narrower check from `types_compatible` (also used for UNION-branch
19081/// compatibility, where "a datetime and a duration are interchangeable"
19082/// would be nonsensical) rather than folded into it, so this can't leak
19083/// into a context where "arithmetic-compatible" isn't the same relation as
19084/// "interchangeable." Postgres's own operator resolution is the final
19085/// authority on any (op, operand-order) combination that doesn't actually
19086/// exist (e.g. `interval - timestamptz`) — this only needs to widen the
19087/// compile-time gate far enough to let the legitimate combinations through.
19088fn datetime_arithmetic_compatible(op: &ast::BinOpKind, a: &str, b: &str) -> bool {
19089    if !matches!(op, ast::BinOpKind::Add | ast::BinOpKind::Sub) {
19090        return false;
19091    }
19092    matches!(
19093        (a, b),
19094        ("timestamptz", "interval")
19095            | ("interval", "timestamptz")
19096            | ("timestamp", "interval")
19097            | ("interval", "timestamp")
19098            | ("date", "interval")
19099            | ("interval", "date")
19100            | ("time", "interval")
19101            | ("interval", "time")
19102    )
19103}
19104
19105/// Collect `SearchEnqueueInfo` for every OpenSearch- or Meilisearch-backed
19106/// search index on a type — `Postgres`-backed indexes are excluded since
19107/// they're maintained synchronously by a trigger-updated tsvector column,
19108/// not an async outbox worker.
19109fn collect_search_enqueue(td: &TypeDescriptor, type_name: &str, operation: &'static str) -> Vec<SearchEnqueueInfo> {
19110    td.search_indexes
19111        .iter()
19112        .filter(|si| si.backend == SearchBackend::OpenSearch || si.backend == SearchBackend::Meilisearch)
19113        .map(|si| SearchEnqueueInfo {
19114            type_name: type_name.to_string(),
19115            index_name: si.index_name.clone(),
19116            operation,
19117            backend: si.backend.clone(),
19118        })
19119        .collect()
19120}
19121
19122/// Reverse of `type_expr_to_pg`'s plain-scalar branch — renders a base
19123/// Postgres type back to its canonical PyQL display name. `pub` (not just
19124/// crate-local): reused by `pylon-server`'s `/api/schema` port
19125/// (`_scalar_type_name`'s equivalent) so that display-name logic isn't
19126/// duplicated across crates.
19127///
19128/// `"interval"`/`"date"`/`"time"`/`"timestamp"` are inherently ambiguous
19129/// from the bare pg_type alone: `interval` backs both `std::duration` and
19130/// `cal::relative_duration` (defaults to the former — the more common
19131/// case), while `date`/`time`/`timestamp` (no tz) are unambiguous (only
19132/// `cal::local_date`/`cal::local_time`/`cal::local_datetime` use them, as
19133/// opposed to `timestamptz` for `std::datetime`).
19134pub fn pg_type_to_pyql(pg: &str) -> &str {
19135    match pg {
19136        "text" | "varchar" => "std::str",
19137        "uuid" => "std::uuid",
19138        "int2" => "std::int16",
19139        "int4" => "std::int32",
19140        "int8" => "std::int64",
19141        "float4" => "std::float32",
19142        "float8" => "std::float64",
19143        "boolean" => "std::bool",
19144        "numeric" => "std::decimal",
19145        "timestamptz" => "std::datetime",
19146        "timestamp" => "cal::local_datetime",
19147        "date" => "cal::local_date",
19148        "time" => "cal::local_time",
19149        "interval" => "std::duration",
19150        "bytea" => "std::bytes",
19151        "jsonb" => "std::json",
19152        "__int_literal" => "std::int64",
19153        "__float_literal" => "std::float64",
19154        other => other,
19155    }
19156}