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                ctes: vec![],
92                global_ctes: c.global_ctes,
93                warnings: c.warnings,
94                uses_globals_arg: c.used_globals_arg,
95            });
96        }
97        // Special case: `with search := fts::search(…); select search { … } …`
98        if let Some(ir) = try_compile_fts_with_pattern(&mut c, w)? {
99            return Ok(IrOutput {
100                subtype_fanouts: c.subtype_fanouts(),
101                stmt: ir,
102                params: c.params,
103                ctes: vec![],
104                global_ctes: c.global_ctes,
105                warnings: c.warnings,
106                uses_globals_arg: c.used_globals_arg,
107            });
108        }
109
110        let mut cte_defs = vec![];
111        for alias in &w.aliases {
112            if let Some(declared) = declared_pointers_of(&alias.expr) {
113                c.cte_declared_pointers.insert(alias.name.clone(), declared);
114            }
115            if c.bind_group(&alias.name, &alias.expr)? {
116                continue;
117            }
118            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
119            let sql_name = c.claim_cte_sql_name(&alias.name);
120            let type_name = c.register_cte(&alias.name, &ir_stmt);
121            // What the binding hoisted (`p := existing ?? (insert …)`) goes
122            // right before it: a CTE only sees the ones ahead of it.
123            cte_defs.append(&mut c.hoisted_ctes);
124            cte_defs.push(IrCteDef {
125                name: sql_name,
126                stmt: ir_stmt,
127                type_name,
128                correlated_to: None,
129            });
130        }
131        let main = c.compile_stmt(&w.stmt)?;
132        (cte_defs, main)
133    } else {
134        (vec![], c.compile_stmt(stmt)?)
135    };
136
137    let mut ctes = ctes;
138    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
139    Ok(IrOutput {
140        subtype_fanouts: c.subtype_fanouts(),
141        stmt: ir,
142        params: c.params,
143        ctes,
144        global_ctes: c.global_ctes,
145        warnings: c.warnings,
146        uses_globals_arg: c.used_globals_arg,
147    })
148}
149
150/// Detect `with <var> := vector::search(Type, $vec); select <var> { object { … }, distance }`.
151/// When matched, compile the whole thing to a single `IrVectorSearch`.
152fn try_compile_vs_with_pattern(c: &mut Compiler<'_>, w: &ast::WithStmt) -> Result<Option<IrStmt>, PyQLError> {
153    // Only handle exactly one alias that is a bare function call (not a subquery).
154    if w.aliases.len() != 1 {
155        return Ok(None);
156    }
157    let alias_def = &w.aliases[0];
158    let fc = match &alias_def.expr {
159        Expr::FunctionCall(fc) => fc,
160        _ => return Ok(None),
161    };
162    if fc.module.as_deref() != Some("vector") || fc.name != "search" {
163        return Ok(None);
164    }
165
166    // Main statement must be `select <alias_name> { … }`.
167    let select_stmt = match w.stmt.as_ref() {
168        Stmt::Select(s) => s,
169        _ => return Ok(None),
170    };
171    // Unwrap optional Shape wrapper around the result expression.
172    let (elements, result_inner): (&[ast::ShapeElement], &Expr) = match &select_stmt.result {
173        Expr::Shape(sh) => {
174            let inner = sh.expr.as_ref().unwrap_or(&select_stmt.result);
175            (sh.elements.as_slice(), inner)
176        }
177        other => (&[], other),
178    };
179    // The inner expression should be a path referencing the WITH alias.
180    match result_inner {
181        Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
182            if let ast::PathStep::Name(n) = &p.steps[0] {
183                if n != &alias_def.name {
184                    return Ok(None);
185                }
186            } else {
187                return Ok(None);
188            }
189        }
190        _ => return Ok(None),
191    }
192
193    if let Some(ir) = c.try_compile_vector_search(fc, elements, select_stmt)? {
194        Ok(Some(IrStmt::VectorSearch(ir)))
195    } else {
196        Ok(None)
197    }
198}
199
200fn try_compile_fts_with_pattern(c: &mut Compiler<'_>, w: &ast::WithStmt) -> Result<Option<IrStmt>, PyQLError> {
201    if w.aliases.len() != 1 {
202        return Ok(None);
203    }
204    let alias_def = &w.aliases[0];
205    let fc = match &alias_def.expr {
206        Expr::FunctionCall(fc) => fc,
207        _ => return Ok(None),
208    };
209    if fc.module.as_deref() != Some("fts") || fc.name != "search" {
210        return Ok(None);
211    }
212
213    let select_stmt = match w.stmt.as_ref() {
214        Stmt::Select(s) => s,
215        _ => return Ok(None),
216    };
217    let (elements, result_inner): (&[ast::ShapeElement], &Expr) = match &select_stmt.result {
218        Expr::Shape(sh) => {
219            let inner = sh.expr.as_ref().unwrap_or(&select_stmt.result);
220            (sh.elements.as_slice(), inner)
221        }
222        other => (&[], other),
223    };
224    match result_inner {
225        Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
226            if let ast::PathStep::Name(n) = &p.steps[0] {
227                if n != &alias_def.name {
228                    return Ok(None);
229                }
230            } else {
231                return Ok(None);
232            }
233        }
234        _ => return Ok(None),
235    }
236
237    if let Some(ir) = c.try_compile_fts_search(fc, elements, select_stmt)? {
238        Ok(Some(IrStmt::FtsSearch(ir)))
239    } else {
240        Ok(None)
241    }
242}
243
244/// How many times a path may expand a computed pointer into its own path
245/// before we call it a cycle. Chains are normal (a computed over a computed);
246/// a chain this long is a schema that refers to itself.
247/// AND a path traversal's own FILTER together with whatever conditions the
248/// computed pointers spliced into it contributed. Every join in a path
249/// select is inner, so a spliced computed's filter means the same thing as a
250/// WHERE condition on the whole traversal.
251/// `__subject__.p ?? (insert T { … })` — a rewrite that fills `p` with a new
252/// object when the statement gives it none. The insert has no place in an
253/// expression, so on insert it becomes `p := (insert …)` instead; `None`
254/// for any other rewrite.
255fn subject_default_insert(handler: &str, pointer: &str) -> Option<Expr> {
256    fn holds_insert(expr: &Expr) -> bool {
257        match expr {
258            Expr::SubQuery(stmt) => match stmt.as_ref() {
259                Stmt::Insert(_) => true,
260                Stmt::Select(sel) => holds_insert(&sel.result),
261                _ => false,
262            },
263            _ => false,
264        }
265    }
266    let Ok(Expr::BinOp(b)) = crate::parse::parse_pointer_expr(handler) else {
267        return None;
268    };
269    let reads_itself = matches!(&b.left, Expr::Path(p) if matches!(p.steps.as_slice(),
270        [ast::PathStep::Name(subject), ast::PathStep::Name(name)] if subject == "__subject__" && name == pointer));
271    (matches!(b.op, ast::BinOpKind::Coalesce) && reads_itself && holds_insert(&b.right)).then_some(b.right)
272}
273
274/// A sub-select's own `limit 1`: what makes it one object rather than a set.
275fn limits_to_one(sel: Option<&ast::SelectStmt>) -> bool {
276    sel.is_some_and(|sel| matches!(sel.limit, Some(Expr::Literal(ast::Literal::Int(1)))))
277}
278
279/// True when a select can yield at most one row: `limit 1`, or a filter that
280/// pins an exclusive property (`.id = …`) in one of its `and`-ed conditions.
281fn selects_at_most_one(sel: &ast::SelectStmt, td: &TypeDescriptor) -> bool {
282    fn pins_exclusive(expr: &Expr, td: &TypeDescriptor) -> bool {
283        let Expr::BinOp(b) = expr else { return false };
284        match b.op {
285            ast::BinOpKind::And => pins_exclusive(&b.left, td) || pins_exclusive(&b.right, td),
286            ast::BinOpKind::Eq => [&b.left, &b.right].into_iter().any(|side| {
287                matches!(side, Expr::Path(p) if p.partial
288                    && matches!(p.steps.as_slice(), [ast::PathStep::Name(name)]
289                        if td.properties.iter().any(|d| &d.name == name && (d.is_exclusive || d.is_pk))))
290            }),
291            _ => false,
292        }
293    }
294    matches!(sel.limit, Some(Expr::Literal(ast::Literal::Int(1))))
295        || sel.filter.as_ref().is_some_and(|f| pins_exclusive(f, td))
296}
297
298/// `(select .teams … limit 1) if cond else {}` as `(select .teams filter cond …
299/// limit 1)`, carrying along a shape written on the branch or on the whole
300/// if/else. `None` unless exactly one branch is empty and the other is a
301/// relative walk or a select over one, and `cond` reads no relative path --
302/// moved into the branch's filter, one would read the branch's rows instead.
303fn guarded_object_branch(expr: &Expr) -> Option<Expr> {
304    fn reads_relative(expr: &Expr) -> bool {
305        match expr {
306            Expr::Path(p) => p.partial,
307            Expr::Literal(_) | Expr::Parameter(_) | Expr::Global(_) => false,
308            Expr::BinOp(b) => reads_relative(&b.left) || reads_relative(&b.right),
309            Expr::UnaryOp(u) => reads_relative(&u.operand),
310            Expr::TypeCast(c) => reads_relative(&c.expr),
311            Expr::FunctionCall(f) => f.args.iter().chain(f.kwargs.iter().map(|(_, v)| v)).any(reads_relative),
312            Expr::IfElse(ie) => {
313                reads_relative(&ie.if_expr) || reads_relative(&ie.condition) || reads_relative(&ie.else_expr)
314            }
315            _ => true,
316        }
317    }
318    fn empty(expr: &Expr) -> bool {
319        match expr {
320            Expr::Set(items) => items.is_empty(),
321            Expr::TypeCast(c) => matches!(&c.expr, Expr::Set(items) if items.is_empty()),
322            _ => false,
323        }
324    }
325    let (ie, outer_shape) = match expr {
326        Expr::IfElse(ie) => (ie.as_ref(), None),
327        Expr::Shape(sh) => match sh.expr.as_ref()? {
328            Expr::IfElse(ie) => (ie.as_ref(), Some(sh.elements.clone())),
329            _ => return None,
330        },
331        _ => return None,
332    };
333    if reads_relative(&ie.condition) {
334        return None;
335    }
336    let (branch, condition) = match (empty(&ie.if_expr), empty(&ie.else_expr)) {
337        (false, true) => (&ie.if_expr, ie.condition.clone()),
338        (true, false) => (
339            &ie.else_expr,
340            Expr::UnaryOp(Box::new(ast::UnaryOp {
341                op: ast::UnaryOpKind::Not,
342                operand: ie.condition.clone(),
343            })),
344        ),
345        _ => return None,
346    };
347    let (branch, branch_shape) = match branch {
348        Expr::Shape(sh) => (sh.expr.as_ref()?, Some(sh.elements.clone())),
349        other => (other, None),
350    };
351    let mut select = match branch {
352        Expr::Path(p) if p.partial => ast::SelectStmt {
353            result: branch.clone(),
354            filter: None,
355            order_by: vec![],
356            offset: None,
357            limit: None,
358            lock: None,
359        },
360        Expr::SubQuery(stmt) => match stmt.as_ref() {
361            Stmt::Select(sel) if matches!(&sel.result, Expr::Path(p) if p.partial) => sel.clone(),
362            _ => return None,
363        },
364        _ => return None,
365    };
366    select.filter = Some(match select.filter.take() {
367        Some(filter) => Expr::BinOp(Box::new(ast::BinOp {
368            left: filter,
369            op: ast::BinOpKind::And,
370            right: condition,
371        })),
372        None => condition,
373    });
374    let guarded = Expr::SubQuery(Box::new(Stmt::Select(select)));
375    Some(match outer_shape.or(branch_shape) {
376        Some(elements) => Expr::Shape(Box::new(ast::ShapeExpr {
377            expr: Some(guarded),
378            elements,
379            marker_offset: None,
380        })),
381        None => guarded,
382    })
383}
384
385/// `.grants.account = a and not .grants.deleted` as the multi-link `grants`
386/// and the condition on one of its elements (`.account = a and not
387/// .deleted`, a bare `.grants` becoming the element's `.id`). `None` unless
388/// every relative path in the condition walks that same multi-link — one
389/// reading the outer row would rebind to the element inside the sub-select —
390/// and the condition holds no sub-statement.
391fn per_element_of_one_multilink(condition: &Expr, td: &TypeDescriptor) -> Option<(Vec<ast::PathStep>, Expr)> {
392    // The steps that reach the elements: a link name, or a backlink with the
393    // type intersection that narrows it (`.<brand[is Assignment]`).
394    fn prefix_len(steps: &[ast::PathStep]) -> Option<usize> {
395        match steps {
396            [ast::PathStep::Name(_), ..] => Some(1),
397            [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_), ..] => Some(2),
398            _ => None,
399        }
400    }
401    fn rewrite(expr: &Expr, link: &mut Option<Vec<ast::PathStep>>) -> Option<Expr> {
402        Some(match expr {
403            Expr::Path(p) if p.partial => {
404                let split = prefix_len(&p.steps)?;
405                let prefix = p.steps[..split].to_vec();
406                if *link.get_or_insert_with(|| prefix.clone()) != prefix {
407                    return None;
408                }
409                // The link itself (`.posts = p`) already compares per element.
410                let rest = p.steps[split..].to_vec();
411                if rest.is_empty() {
412                    return None;
413                }
414                Expr::Path(ast::Path {
415                    steps: rest,
416                    partial: true,
417                })
418            }
419            Expr::Path(_) | Expr::Literal(_) | Expr::Parameter(_) | Expr::Global(_) => expr.clone(),
420            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
421                left: rewrite(&b.left, link)?,
422                op: b.op.clone(),
423                right: rewrite(&b.right, link)?,
424            })),
425            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
426                op: u.op.clone(),
427                operand: rewrite(&u.operand, link)?,
428            })),
429            Expr::TypeCast(c) => Expr::TypeCast(Box::new(ast::TypeCast {
430                expr: rewrite(&c.expr, link)?,
431                ty: c.ty.clone(),
432            })),
433            Expr::TypeIs { expr, ty } => Expr::TypeIs {
434                expr: Box::new(rewrite(expr, link)?),
435                ty: ty.clone(),
436            },
437            // A set-returning call (`array_unpack`) is a second set the
438            // quantifier ranges over, so turning the condition into one asked
439            // per link element would change what it asks.
440            Expr::FunctionCall(f)
441                if crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
442                    .iter()
443                    .any(|d| d.returns_set()) =>
444            {
445                return None;
446            }
447            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
448                module: f.module.clone(),
449                name: f.name.clone(),
450                args: f.args.iter().map(|a| rewrite(a, link)).collect::<Option<_>>()?,
451                kwargs: f
452                    .kwargs
453                    .iter()
454                    .map(|(k, v)| Some((k.clone(), rewrite(v, link)?)))
455                    .collect::<Option<_>>()?,
456            }),
457            _ => return None,
458        })
459    }
460    let mut link = None;
461    let element_condition = rewrite(condition, &mut link)?;
462    let link = link?;
463    let many = match link.first()? {
464        ast::PathStep::Name(name) => td.multilinks.iter().any(|m| &m.name == name),
465        ast::PathStep::Backlink(_) => true,
466        _ => false,
467    };
468    many.then_some((link, element_condition))
469}
470
471/// A shape's subject written in a longer form than it needs, rewritten to the
472/// form the select routes already take; `None` when there is nothing to
473/// rewrite.
474///
475/// - `(select E) { … }` with no clauses of its own is `E { … }`, for the
476///   subjects only a select over them spells: an if/else, a union, a shape.
477/// - `X { a := … } { a: { … } }` is one shape. The outer one says what comes
478///   back; a name it lists that the inner one computed keeps that computation
479///   and takes the outer one's nested shape and modifiers.
480fn flatten_shape_subject(expr: &Expr) -> Option<Expr> {
481    let Expr::Shape(outer) = expr else { return None };
482    match outer.expr.as_ref()? {
483        Expr::SubQuery(stmt) => {
484            let Stmt::Select(sel) = stmt.as_ref() else { return None };
485            let bare = sel.filter.is_none()
486                && sel.order_by.is_empty()
487                && sel.offset.is_none()
488                && sel.limit.is_none()
489                && sel.lock.is_none();
490            if !bare || !matches!(sel.result, Expr::IfElse(_) | Expr::Union(_, _) | Expr::Shape(_)) {
491                return None;
492            }
493            let mut flat = outer.as_ref().clone();
494            flat.expr = Some(sel.result.clone());
495            Some(Expr::Shape(Box::new(flat)))
496        }
497        Expr::Shape(inner) => {
498            let elements = outer
499                .elements
500                .iter()
501                .map(|el| {
502                    let declared = match (&el.compexpr, el.path.steps.as_slice()) {
503                        (None, [ast::PathStep::Name(name)]) => inner.elements.iter().find(|d| {
504                            d.compexpr.is_some()
505                                && matches!(d.path.steps.as_slice(), [ast::PathStep::Name(n)] if n == name)
506                        }),
507                        _ => None,
508                    };
509                    match declared {
510                        Some(d) => ShapeElement {
511                            nested: el.nested.clone().or_else(|| d.nested.clone()),
512                            filter: el.filter.clone().or_else(|| d.filter.clone()),
513                            order_by: if el.order_by.is_empty() {
514                                d.order_by.clone()
515                            } else {
516                                el.order_by.clone()
517                            },
518                            offset: el.offset.clone().or_else(|| d.offset.clone()),
519                            limit: el.limit.clone().or_else(|| d.limit.clone()),
520                            ..d.clone()
521                        },
522                        None => el.clone(),
523                    }
524                })
525                .collect();
526            Some(Expr::Shape(Box::new(ast::ShapeExpr {
527                expr: inner.expr.clone(),
528                elements,
529                marker_offset: inner.marker_offset,
530            })))
531        }
532        _ => None,
533    }
534}
535
536/// The name `.key.<name>` of a group is read through while its shape
537/// compiles — not one a query can spell, so it shadows nothing.
538fn group_key_binding(name: &str) -> String {
539    format!("<group key {name}>")
540}
541
542fn and_conditions(filter: Option<IrExpr>, extra: Vec<IrExpr>) -> Option<IrExpr> {
543    extra.into_iter().fold(filter, |acc, cond| match acc {
544        Some(existing) => Some(IrExpr::BinOp(Box::new(IrBinOp {
545            left: existing,
546            op: ast::BinOpKind::And,
547            right: cond,
548        }))),
549        None => Some(cond),
550    })
551}
552
553const MAX_COMPUTED_SPLICES: usize = 32;
554
555/// `sum`, `any`, `all` and `array_agg` over no rows: SQL gives NULL, PyQL the
556/// empty set's own value. `'{}'` rather than `ARRAY[]`, whose element type
557/// PostgreSQL cannot infer on its own — inside a `coalesce` it takes the one
558/// the aggregate already carries.
559fn aggregate_over_nothing_sql(name: &str) -> Option<&'static str> {
560    match name {
561        "sum" => Some("0"),
562        "any" => Some("false"),
563        "all" => Some("true"),
564        "array_agg" => Some("'{}'"),
565        _ => None,
566    }
567}
568
569fn aggregate_over_nothing(name: &str, aggregate: IrExpr) -> IrExpr {
570    let Some(value) = aggregate_over_nothing_sql(name) else {
571        return aggregate;
572    };
573    IrExpr::FunctionCall(IrFunctionCall {
574        return_pg_type: None,
575        schema: None,
576        name: "coalesce".to_string(),
577        args: vec![aggregate, IrExpr::RawSql(value.to_string())],
578        sql_template: None,
579    })
580}
581
582/// An expression whose value is a whole set, gathered as an array.
583/// A set-valued walk gathered as an array, read back as a scalar subquery
584/// instead — what an ordering comparison against a single value needs.
585///
586/// `(.installation.<installation[is RateLimitState].backoff_until < now) ?? true`
587/// is conduit's backoff gate, and it reads element-wise: empty set →
588/// `{}` → the `??` supplies `true`. Rendered as an array the comparison is
589/// `timestamptz[] < timestamptz`, which PostgreSQL has no operator for, so the
590/// statement failed outright. As a scalar subquery the empty case is NULL, the
591/// comparison is NULL, and `COALESCE` supplies the default — right for
592/// the nought-or-one sets these walks actually produce (an `exclusive`
593/// constraint guarantees it here). A genuinely many-valued walk now raises
594/// PostgreSQL's own "more than one row returned by a subquery" rather than
595/// comparing element-wise; that is a narrower gap than emitting SQL that
596/// cannot run at all.
597fn set_walk_as_scalar(expr: IrExpr) -> IrExpr {
598    match expr {
599        IrExpr::ArrayFromSelect(source) => match *source {
600            IrArraySource::PathSelect(ps) => IrExpr::PathSubquery(ps),
601            other => IrExpr::ArrayFromSelect(Box::new(other)),
602        },
603        other => other,
604    }
605}
606
607fn yields_array(expr: &IrExpr) -> bool {
608    match expr {
609        IrExpr::ArrayFromSelect(_) => true,
610        IrExpr::IfElse(ie) => {
611            (yields_array(&ie.if_) || matches!(ie.if_, IrExpr::Null))
612                && (yields_array(&ie.else_) || matches!(ie.else_, IrExpr::Null))
613                && !(matches!(ie.if_, IrExpr::Null) && matches!(ie.else_, IrExpr::Null))
614        }
615        _ => false,
616    }
617}
618
619/// Look through the `coalesce` an aggregate over no rows is wrapped in — see
620/// `aggregate_over_nothing`. The default only stands in for the aggregate, so
621/// the type is the aggregate's.
622fn through_coalesce(expr: &IrExpr) -> &IrExpr {
623    match expr {
624        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "coalesce" => {
625            f.args.first().map(through_coalesce).unwrap_or(expr)
626        }
627        other => other,
628    }
629}
630
631fn ir_value_type_name(expr: &IrExpr) -> String {
632    ir_value_type(expr).unwrap_or_default()
633}
634
635/// The pg type a bound value carries, including the array types
636/// `infer_ir_type` has no spelling for — a `with` binding that loses its
637/// array-ness resolves no overload at all, so `contains(ids, .id)` reports a
638/// call nobody wrote.
639fn ir_value_type(expr: &IrExpr) -> Option<String> {
640    let expr = through_coalesce(expr);
641    match expr {
642        IrExpr::Array(elements) => {
643            let element = elements.first().and_then(infer_ir_type).unwrap_or("text");
644            Some(format!("{}[]", literal_sentinel_to_pg(element)))
645        }
646        // `ids := array_agg(r.id)` binds an array of what it aggregates, which
647        // is what lets `contains(ids, .id)` pick the array overload over `str`'s.
648        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "array_agg" => {
649            let element = f.args.first().and_then(infer_ir_type)?;
650            Some(format!("{}[]", literal_sentinel_to_pg(element)))
651        }
652        // `array_agg((select …))` aggregates inside a correlated select, so
653        // the array is what that select projects rather than the call itself.
654        IrExpr::PathSubquery(ps) => match &ps.result {
655            IrPathResult::Scalar(inner, _) => ir_value_type(inner),
656            IrPathResult::Object { .. } => None,
657        },
658        IrExpr::ArrayFromSelect(source) => array_source_element_type(source).map(|element| format!("{element}[]")),
659        // `xs if cond else ys` is typed by whichever branch can say — the
660        // other is routinely the empty set.
661        IrExpr::IfElse(ie) => ir_value_type(&ie.if_).or_else(|| ir_value_type(&ie.else_)),
662        other => infer_ir_type(other).map(|t| t.to_string()),
663    }
664}
665
666/// The pg type of one row of an `ARRAY(SELECT …)`. `None` for a source that
667/// aggregates whole objects: those rows are composites with no scalar name.
668fn array_source_element_type(source: &IrArraySource) -> Option<String> {
669    match source {
670        IrArraySource::Select(sel) => match sel.rows.as_slice() {
671            [IrRowSource::Bound { shape, .. }] => match shape.first() {
672                // Mirrors the emitter: the first scalar pointer is the
673                // element, and a shape with none of them projects the id.
674                Some(IrShapePointer::Scalar(scalar)) => Some(scalar.pg_type.clone()),
675                _ => Some("uuid".to_string()),
676            },
677            _ => None,
678        },
679        IrArraySource::PathSelect(ps) => match &ps.result {
680            IrPathResult::Scalar(expr, _) => infer_ir_type(expr).map(|t| literal_sentinel_to_pg(t).to_string()),
681            IrPathResult::Object { .. } => None,
682        },
683        _ => None,
684    }
685}
686
687/// The computed pointers a `with` binding's own shape declares, if any.
688fn declared_pointers_of(expr: &Expr) -> Option<Vec<ShapeElement>> {
689    // `(select T { x := … }) if cond else {}` declares what its select does.
690    if let Expr::IfElse(ie) = expr {
691        return declared_pointers_of(&ie.if_expr).or_else(|| declared_pointers_of(&ie.else_expr));
692    }
693    let Expr::SubQuery(stmt) = expr else {
694        return None;
695    };
696    let shape = match innermost_select(stmt)? {
697        ast::SelectStmt {
698            result: Expr::Shape(sh),
699            ..
700        } => sh,
701        _ => return None,
702    };
703    let declared: Vec<ShapeElement> = shape
704        .elements
705        .iter()
706        .filter(|el| el.compexpr.is_some())
707        .cloned()
708        .collect();
709    (!declared.is_empty()).then_some(declared)
710}
711
712/// The select a statement ultimately is, past any `with` blocks.
713fn innermost_select(stmt: &Stmt) -> Option<&ast::SelectStmt> {
714    match stmt {
715        Stmt::Select(sel) => Some(sel),
716        Stmt::With(w) => innermost_select(&w.stmt),
717        _ => None,
718    }
719}
720
721fn cte_stmt_type(stmt: &IrStmt) -> String {
722    match stmt {
723        IrStmt::Insert(ins) => ins.target.type_name.clone(),
724        IrStmt::Update(upd) => upd.target.type_name.clone(),
725        IrStmt::Delete(del) => del.target.type_name.clone(),
726        IrStmt::Select(sel) => match sel.rows.first() {
727            Some(IrRowSource::Bound { source, .. }) => source.type_name.clone(),
728            // Infer the scalar pg_type from the first free item so the type
729            // is available for UNION mismatch error messages. Returns empty
730            // string if unknown.
731            Some(IrRowSource::Free(IrFreeExpr::Scalar(expr))) => ir_value_type_name(expr),
732            _ => String::new(),
733        },
734        // What a path select *yields*, not what it starts from: `with xs :=
735        // (select Person.name)` binds text, not `default::Person`, and the
736        // difference decides whether a reference to it reads the CTE's
737        // `result` column or its `id` (see `resolve_name_ref`).
738        IrStmt::PathSelect(ps) => match &ps.result {
739            IrPathResult::Scalar(expr, _) => ir_value_type_name(expr),
740            IrPathResult::Object { type_name, .. } => type_name.clone(),
741        },
742        IrStmt::For(f) => cte_stmt_type(&f.body),
743        IrStmt::ScalarUnion(branches) => branches.first().map(cte_stmt_type).unwrap_or_default(),
744        IrStmt::Group(g) => g.source.type_name.clone(),
745        IrStmt::FunctionSelect(fs) => fs.type_name.clone(),
746        IrStmt::VectorSearch(vs) => format!("__vs__{}", vs.source.type_name),
747        IrStmt::FtsSearch(fs) => format!("__fts__{}", fs.source.type_name),
748    }
749}
750
751/// Compile a WITH binding value: a subquery becomes its statement; any other
752/// expression is wrapped in a synthetic `select expr` so it can be used as a CTE.
753fn compile_cte_binding(c: &mut Compiler<'_>, expr: &Expr) -> Result<IrStmt, PyQLError> {
754    let stmt = compile_cte_binding_stmt(c, expr)?;
755    Ok(hoist_binding_dml(c, stmt))
756}
757
758/// `g := (insert T { … }) if cond else {}` — the binding's value compiles to a
759/// select *over* a mutation. A data-modifying statement may only sit at the top
760/// level of a `WITH`, never inside another CTE's body, so the mutation becomes a
761/// CTE of its own ahead of this binding — where `g := existing ?? (insert T { …
762/// })` already puts one — and the binding reads its rows back from there.
763/// Emitted as-is, the mutation was dropped and the binding read the whole table.
764fn hoist_binding_dml(c: &mut Compiler<'_>, stmt: IrStmt) -> IrStmt {
765    let IrStmt::Select(mut select) = stmt else {
766        return stmt;
767    };
768    let Some(dml) = select
769        .dml_source
770        .take_if(|dml| matches!(dml.as_ref(), IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::Delete(_)))
771    else {
772        return IrStmt::Select(select);
773    };
774    let cte_name = c.fresh_nested_cte_name();
775    for row in &mut select.rows {
776        if let IrRowSource::Bound { source, .. } = row {
777            source.table = format!("@cte:{cte_name}");
778            // The CTE's rows are already the concrete ones the mutation
779            // touched, each carrying its own `__type__`.
780            source.poly = None;
781        }
782    }
783    c.hoisted_ctes.push(IrCteDef {
784        name: cte_name,
785        type_name: cte_stmt_type(&dml),
786        stmt: *dml,
787        correlated_to: None,
788    });
789    IrStmt::Select(select)
790}
791
792fn compile_cte_binding_stmt(c: &mut Compiler<'_>, expr: &Expr) -> Result<IrStmt, PyQLError> {
793    if let Expr::SubQuery(s) = expr {
794        let stmt = c.compile_stmt(s)?;
795        if let IrStmt::Group(grp) = &stmt
796            && !matches!(grp.output, IrGroupOutput::Elements)
797        {
798            return Err(c.type_err("a `group` cannot be bound in a `with` except to iterate it with `for`"));
799        }
800        return Ok(stmt);
801    }
802    // `existing ?? (insert Tag { … })` — on sets `A ?? B` is `A if exists A
803    // else B`, the upsert idiom the if/else route already guards the
804    // mutation for.
805    let expr = match expr {
806        Expr::BinOp(b)
807            if b.op == ast::BinOpKind::Coalesce
808                && matches!(&b.right, Expr::SubQuery(s) if matches!(s.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))) =>
809        {
810            &Expr::IfElse(Box::new(ast::IfElse {
811                if_expr: b.left.clone(),
812                condition: Expr::UnaryOp(Box::new(ast::UnaryOp {
813                    op: ast::UnaryOpKind::Exists,
814                    operand: b.left.clone(),
815                })),
816                else_expr: b.right.clone(),
817            }))
818        }
819        other => other,
820    };
821    // Non-statement expression (e.g. `<default::Company><uuid>'...'`):
822    // treat as `select expr`.
823    let fake_sel = ast::SelectStmt {
824        result: expr.clone(),
825        filter: None,
826        order_by: vec![],
827        offset: None,
828        limit: None,
829        lock: None,
830    };
831    c.compile_stmt(&Stmt::Select(fake_sel))
832}
833
834/// Qualified names of the functions that take `GLOBALS_ARG`.
835///
836/// A function needs it when its body reads a session global, *or* when it calls
837/// a function that needs it — so this is a fixpoint, not a single pass: the
838/// first pass finds the direct readers, later passes find their callers. A body
839/// that fails to compile is skipped; that failure is reported by validation,
840/// and guessing at its globals here would only produce a second, worse error.
841pub fn functions_needing_globals(schema: &SchemaDescriptor) -> &std::collections::HashSet<String> {
842    schema
843        .functions_needing_globals
844        .get_or_init(|| find_functions_needing_globals(schema))
845}
846
847fn find_functions_needing_globals(schema: &SchemaDescriptor) -> std::collections::HashSet<String> {
848    let mut needs: std::collections::HashSet<String> = std::collections::HashSet::new();
849    loop {
850        let mut changed = false;
851        for fd in &schema.functions {
852            let qualified = format!("{}::{}", fd.module, fd.name);
853            if needs.contains(&qualified) {
854                continue;
855            }
856            if let Ok(out) = compile_fn_body_with(fd, schema, &needs)
857                && out.uses_globals_arg
858            {
859                needs.insert(qualified);
860                changed = true;
861            }
862        }
863        if !changed {
864            return needs;
865        }
866    }
867}
868
869/// Compile the PyQL body of a user-defined function for DDL emission.
870///
871/// Sets `fn_params` on the compiler so that parameter names resolve as `FnParam`
872/// nodes rather than raising "expression is not valid in free SELECT context".
873pub fn compile_fn_body(
874    fn_desc: &crate::schema::FunctionDescriptor,
875    schema: &SchemaDescriptor,
876) -> Result<super::IrOutput, crate::error::PyQLError> {
877    compile_fn_body_with(fn_desc, schema, functions_needing_globals(schema))
878}
879
880/// `compile_fn_body` with the globals-argument set supplied, so the fixpoint in
881/// `functions_needing_globals` can call this without recursing into itself.
882pub fn compile_fn_body_with(
883    fn_desc: &crate::schema::FunctionDescriptor,
884    schema: &SchemaDescriptor,
885    fns_needing_globals: &std::collections::HashSet<String>,
886) -> Result<super::IrOutput, crate::error::PyQLError> {
887    use crate::parse;
888    use crate::parse::ast::Stmt;
889
890    let body = fn_desc.body.trim().to_string();
891    // A body that is already a statement stands on its own; only a bare
892    // expression needs the `select` that turns it into one. Wrapping a
893    // statement instead would bury it as a sub-select and lose its clauses.
894    let starts_a_stmt = ["select", "with", "for", "group", "insert", "update", "delete"]
895        .iter()
896        .any(|keyword| {
897            body.len() > keyword.len()
898                && body[..keyword.len()].eq_ignore_ascii_case(keyword)
899                && !body.as_bytes()[keyword.len()].is_ascii_alphanumeric()
900                && body.as_bytes()[keyword.len()] != b'_'
901        });
902    let body = if starts_a_stmt {
903        body
904    } else {
905        format!("select {}", body)
906    };
907
908    let ast = parse::parse(&body)?;
909    let mut c = Compiler::new(schema);
910    c.in_fn_body = true;
911    c.fns_needing_globals = Some(fns_needing_globals.clone());
912    for p in &fn_desc.params {
913        c.fn_params.insert(p.name.clone(), p.pg_type.clone());
914    }
915
916    let (ctes, ir) = if let Stmt::With(w) = &ast {
917        let mut cte_defs = vec![];
918        for alias in &w.aliases {
919            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
920            let type_name = cte_stmt_type(&ir_stmt);
921            c.cte_types.insert(alias.name.clone(), type_name.clone());
922            c.note_cte_cardinality(&alias.name, &ir_stmt);
923            cte_defs.push(super::IrCteDef {
924                name: alias.name.clone(),
925                stmt: ir_stmt,
926                type_name,
927                correlated_to: None,
928            });
929        }
930        let main = c.compile_stmt(&w.stmt)?;
931        (cte_defs, main)
932    } else {
933        (vec![], c.compile_stmt(&ast)?)
934    };
935
936    let mut ctes = ctes;
937    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
938    Ok(super::IrOutput {
939        subtype_fanouts: c.subtype_fanouts(),
940        stmt: ir,
941        params: c.params,
942        ctes,
943        global_ctes: c.global_ctes,
944        warnings: c.warnings,
945        uses_globals_arg: c.used_globals_arg,
946    })
947}
948
949/// Compile a schema `Trigger`'s `handler` PyQL statement for DDL emission —
950/// same shape as `compile_fn_body`, but instead of `fn_params` this binds
951/// `__new__`/`__old__` as the inserted/updated/deleted row (see
952/// `Compiler::special_anchors`'s own doc comment), gated by `on_mask`
953/// (Pylon's `On` bitmask: 1=Insert, 2=Update, 4=Delete): `__old__` is bound whenever
954/// `on_mask` does *not* include Insert (so Update-only, Delete-only, and
955/// Update+Delete all get it — but never a mask that includes Insert, even
956/// combined with Update, since a shared trigger function has no old row on
957/// the Insert branch of that combination); `__new__` is bound whenever
958/// `on_mask` does *not* include Delete, by the mirror-image argument. A
959/// mask combining Insert and Delete (with no Update) legally binds
960/// neither. Referencing an anchor that isn't bound is a compile error, not
961/// a runtime NULL.
962/// One compiled rewrite: the column it writes and the value it writes there.
963pub struct RewriteAssignment {
964    pub pointer: String,
965    pub column: String,
966    pub ir: IrExpr,
967    pub sql: String,
968    /// True when the expression reads one of the owner's multi-links. Those
969    /// rows live in a junction table the statement writes *after* the row
970    /// itself, so a `BEFORE` trigger reading one sums an empty set — see
971    /// `export::rewrite_trigger_infos`.
972    pub reads_a_multi_link: bool,
973}
974
975/// A type's rewrites for one event (`on_mask`: 1 = insert, 2 = update), each
976/// compiled against the row being written (`NEW`) as `(column, SQL)`, for
977/// the `BEFORE` trigger that applies them. Evaluated on the row as the
978/// statement left it, every rewrite sees the others' inputs, not their
979/// results.
980pub fn compile_rewrite_assignments(
981    type_name: &str,
982    on_mask: u8,
983    schema: &SchemaDescriptor,
984) -> Result<Vec<RewriteAssignment>, crate::error::PyQLError> {
985    let mut c = Compiler::new(schema);
986    c.in_fn_body = true;
987    let owner_td = c.resolve_type(type_name)?;
988    c.special_anchors
989        .insert("__new__".to_string(), (owner_td, "NEW".to_string()));
990    if on_mask & 1 == 0 {
991        c.special_anchors
992            .insert("__old__".to_string(), (owner_td, "OLD".to_string()));
993    }
994    let pointers = owner_td
995        .properties
996        .iter()
997        .map(|p| (p.name.clone(), p.name.clone(), &p.rewrites))
998        .chain(
999            owner_td
1000                .links
1001                .iter()
1002                .filter(|l| !l.is_junction_backed())
1003                .map(|l| (l.name.clone(), format!("{}_id", l.name), &l.rewrites)),
1004        );
1005    let fanouts = c.subtype_fanouts();
1006    let mut out = Vec::new();
1007    for (name, column, rewrites) in pointers {
1008        for rw in rewrites.iter().filter(|rw| rw.on & on_mask != 0) {
1009            // Carried out by `compile_insert` as an assignment.
1010            if on_mask == 1 && subject_default_insert(&rw.handler, &name).is_some() {
1011                continue;
1012            }
1013            let expr = crate::parse::parse_pointer_expr(&rw.handler)?;
1014            let ir = c.compile_expr(&expr, owner_td, "NEW")?;
1015            if !c.params.is_empty() || !c.hoisted_ctes.is_empty() {
1016                return Err(PyQLError::Type(PyQLTypeError {
1017                    message: format!(
1018                        "the rewrite of '{type_name}.{name}' has nowhere to bind a parameter or a binding"
1019                    ),
1020                    position: Position { line: 0, col: 0 },
1021                }));
1022            }
1023            let sql = crate::sql::emit_expr_with_fanouts(&ir, &fanouts);
1024            // Read off the emitted SQL rather than the expression: a walk
1025            // reaches a junction through computeds, subtypes and `through`
1026            // types, and what it actually joins is the one answer all of
1027            // those agree on.
1028            let junctions = c.junction_tables_of(owner_td)?;
1029            let reads_a_multi_link = junctions.iter().any(|table| sql.contains(table.as_str()));
1030            out.push(RewriteAssignment {
1031                pointer: name.clone(),
1032                column: column.clone(),
1033                ir,
1034                sql,
1035                reads_a_multi_link,
1036            });
1037        }
1038    }
1039    Ok(out)
1040}
1041
1042pub fn compile_trigger_handler(
1043    handler: &str,
1044    type_name: &str,
1045    on_mask: u8,
1046    schema: &SchemaDescriptor,
1047) -> Result<super::IrOutput, crate::error::PyQLError> {
1048    use crate::parse;
1049    use crate::parse::ast::Stmt;
1050
1051    let ast = parse::parse(handler.trim())?;
1052    let mut c = Compiler::new(schema);
1053    // A trigger has no parameters to bind a session global to; it reads them
1054    // from `GLOBALS_ARG`, which its function declares from `pylon.globals`.
1055    c.in_fn_body = true;
1056    // The trigger's own type — `__new__`/`__old__` always resolve against
1057    // this, regardless of what type happens to be the ambient `td` where
1058    // the anchor is textually used (e.g. inside `insert Note { note :=
1059    // __new__.name }`, the ambient td at that point is `Note`, not this).
1060    let owner_td = c.resolve_type(type_name)?;
1061    if on_mask & 4 == 0 {
1062        c.special_anchors
1063            .insert("__new__".to_string(), (owner_td, "NEW".to_string()));
1064    }
1065    if on_mask & 1 == 0 {
1066        c.special_anchors
1067            .insert("__old__".to_string(), (owner_td, "OLD".to_string()));
1068    }
1069
1070    let (ctes, ir) = if let Stmt::With(w) = &ast {
1071        let mut cte_defs = vec![];
1072        for alias in &w.aliases {
1073            // `resolved := (__new__.status = …)` — a value read off the row
1074            // the trigger fired for, which a CTE has no way to see; it
1075            // stands in for its value wherever the name is used.
1076            if !matches!(alias.expr, ast::Expr::SubQuery(_)) {
1077                let anchor = if on_mask & 4 == 0 { "NEW" } else { "OLD" };
1078                let value = c.compile_expr(&alias.expr, owner_td, anchor)?;
1079                c.inline_bindings.insert(alias.name.clone(), value);
1080                continue;
1081            }
1082            let ir_stmt = compile_cte_binding(&mut c, &alias.expr)?;
1083            let sql_name = c.claim_cte_sql_name(&alias.name);
1084            let cte_type_name = cte_stmt_type(&ir_stmt);
1085            c.cte_types.insert(alias.name.clone(), cte_type_name.clone());
1086            c.note_cte_cardinality(&alias.name, &ir_stmt);
1087            cte_defs.push(super::IrCteDef {
1088                name: sql_name,
1089                stmt: ir_stmt,
1090                type_name: cte_type_name,
1091                correlated_to: None,
1092            });
1093        }
1094        let main = c.compile_stmt(&w.stmt)?;
1095        (cte_defs, main)
1096    } else {
1097        (vec![], c.compile_stmt(&ast)?)
1098    };
1099
1100    let recursive_kind = recursive_dml_event(&ir, type_name)
1101        .or_else(|| ctes.iter().find_map(|cte| recursive_dml_event(&cte.stmt, type_name)));
1102    if let Some(kind) = recursive_kind
1103        && kind & on_mask != 0
1104    {
1105        return Err(crate::error::PyQLError::Fragment(crate::error::PyQLFragmentError {
1106            message: format!(
1107                "trigger on {type_name} is recursive: its handler {}s its own type, \
1108                     which this trigger also fires on",
1109                dml_event_word(kind),
1110            ),
1111            context: type_name.to_string(),
1112            position: crate::error::Position { line: 0, col: 0 },
1113        }));
1114    }
1115
1116    let mut ctes = ctes;
1117    ctes.extend(std::mem::take(&mut c.hoisted_ctes));
1118    Ok(super::IrOutput {
1119        subtype_fanouts: c.subtype_fanouts(),
1120        stmt: ir,
1121        params: c.params,
1122        ctes,
1123        global_ctes: c.global_ctes,
1124        warnings: c.warnings,
1125        uses_globals_arg: c.used_globals_arg,
1126    })
1127}
1128
1129fn dml_event_word(kind: u8) -> &'static str {
1130    match kind {
1131        1 => "insert",
1132        2 => "update",
1133        4 => "delete",
1134        _ => "mutate",
1135    }
1136}
1137
1138/// Walks a compiled trigger handler's IR looking for a nested `INSERT`/
1139/// `UPDATE`/`DELETE` targeting `owner_type` — the same type the trigger
1140/// itself is declared on. Returns that DML's own event bit (1/2/4) the
1141/// first time one is found, so the caller can check it against the
1142/// trigger's own `on_mask`: a handler that inserts into its own type only
1143/// matters if this trigger *also* fires on Insert — the mask determines
1144/// what would actually refire, not just "does it touch itself at all".
1145///
1146/// Deliberately not exhaustive: covers the direct statement, a `SELECT
1147/// (INSERT/UPDATE/DELETE …) { … }` wrapper, and `for x in … union (…)`
1148/// loop bodies — the shapes every trigger handler in this codebase's own
1149/// test suite actually uses. A DML nested inside a free tuple/set literal
1150/// (`select { (insert A {...}), (insert B {...}) }`) isn't walked; a
1151/// handler written that way relies on Postgres's own runtime recursion-
1152/// depth guard instead, same fallback as before this check existed.
1153fn recursive_dml_event(stmt: &IrStmt, owner_type: &str) -> Option<u8> {
1154    match stmt {
1155        IrStmt::Insert(ins) if ins.target.type_name == owner_type => Some(1),
1156        IrStmt::Update(upd) if upd.target.type_name == owner_type => Some(2),
1157        IrStmt::Delete(del) if del.target.type_name == owner_type => Some(4),
1158        IrStmt::Select(sel) => sel
1159            .dml_source
1160            .as_deref()
1161            .and_then(|inner| recursive_dml_event(inner, owner_type)),
1162        IrStmt::For(for_stmt) => recursive_dml_event(&for_stmt.body, owner_type),
1163        _ => None,
1164    }
1165}
1166
1167/// Compile a single PyQL expression in the context of a named type.
1168/// Used for schema fragments: computed pointers, rewrite handlers, constraint exprs.
1169pub fn compile_expr_in_type(
1170    expr: &Expr,
1171    type_name: &str,
1172    schema: &SchemaDescriptor,
1173) -> Result<(IrExpr, Vec<String>), PyQLError> {
1174    let mut c = Compiler::new(schema);
1175    let td = c.resolve_type(type_name)?;
1176    let alias = c.fresh_alias();
1177    let ir = c.compile_expr(expr, td, &alias)?;
1178    Ok((ir, c.params))
1179}
1180
1181/// Compile a schema-declared computed pointer the same way a shape that
1182/// included it would, for validation.
1183///
1184/// Returns the scalar expression when the computed is scalar-valued, and
1185/// `None` when it compiles to an object (link) pointer — `(select .emails
1186/// filter .primary limit 1)` and friends, which have no scalar type for a
1187/// declared return type to be checked against.
1188pub fn compile_computed_in_type(
1189    cd: &crate::schema::ComputedDescriptor,
1190    type_name: &str,
1191    schema: &SchemaDescriptor,
1192) -> Result<Option<IrExpr>, PyQLError> {
1193    let mut c = Compiler::new(schema);
1194    let td = c.resolve_type(type_name)?;
1195    let module = td.module.clone();
1196    let alias = c.fresh_alias();
1197    match c.compile_declared_computed(cd, td, &alias, &module, None, &[])? {
1198        IrShapePointer::Computed(p) => Ok(Some(p.expr)),
1199        _ => Ok(None),
1200    }
1201}
1202
1203/// Like `compile_expr_in_type` but uses an empty table alias, so that column
1204/// references emit as bare column names (`"col"` rather than `"a1"."col"`).
1205/// Used for fill expressions in migration UPDATE SET clauses.
1206pub fn compile_expr_unaliased(
1207    expr: &Expr,
1208    type_name: &str,
1209    schema: &SchemaDescriptor,
1210) -> Result<(IrExpr, Vec<String>), PyQLError> {
1211    let mut c = Compiler::new(schema);
1212    let td = c.resolve_type(type_name)?;
1213    let ir = c.compile_expr(expr, td, "")?;
1214    Ok((ir, c.params))
1215}
1216
1217/// Compile a PyQL scalar expression to a SQL string for use as a column DEFAULT.
1218///
1219/// Wraps the expression in `SELECT <expr>`, compiles it as a free scalar, and
1220/// returns the emitted SQL expression (without the SELECT wrapper).
1221pub fn compile_scalar_default(pyql: &str, schema: &SchemaDescriptor) -> Result<String, String> {
1222    compile_scalar_default_typed(pyql, schema).map(|(sql, _ir)| sql)
1223}
1224
1225/// Like `compile_scalar_default`, but also returns the compiled `IrExpr` —
1226/// used by schema-type-consistency validation (`crate::validate`) to infer
1227/// the default's actual produced type via `infer_ir_type`.
1228/// Compile a PyQL boolean expression in a type's context into the SQL a CHECK
1229/// constraint needs.
1230///
1231/// Columns emit unqualified (via `compile_expr_unaliased`), which is what a
1232/// table-level CHECK wants — it has no alias to qualify against.
1233pub fn compile_constraint_expr(
1234    pyql: &str,
1235    type_name: &str,
1236    schema: &SchemaDescriptor,
1237) -> Result<String, crate::error::PyQLError> {
1238    use crate::parse::ast::Stmt;
1239    let ast = crate::parse::parse(&format!("SELECT {pyql}"))?;
1240    let Stmt::Select(sel) = &ast else {
1241        return Err(PyQLError::Type(PyQLTypeError {
1242            message: "constraint expression must be an expression".into(),
1243            position: Position { line: 0, col: 0 },
1244        }));
1245    };
1246    let (ir, _params) = compile_expr_unaliased(&sel.result, type_name, schema)?;
1247    Ok(crate::sql::emit_expr(&ir))
1248}
1249
1250pub fn compile_scalar_default_typed(pyql: &str, schema: &SchemaDescriptor) -> Result<(String, IrExpr), String> {
1251    use crate::parse::ast::Stmt;
1252    let full = format!("SELECT {}", pyql);
1253    let ast = crate::parse::parse(&full).map_err(|e| e.message)?;
1254    let Stmt::Select(sel) = &ast else {
1255        return Err("default expression must be a select statement".into());
1256    };
1257    let mut c = Compiler::new(schema);
1258    let ir = c.compile_free_expr(&sel.result).map_err(|e| e.to_string())?;
1259    let sql = crate::sql::emit_expr(&ir);
1260    Ok((sql, ir))
1261}
1262
1263/// What in `expr` disqualifies it from being a column DEFAULT, if anything.
1264///
1265/// PostgreSQL evaluates a column default with no row and no query in scope,
1266/// so it rejects a sub-select outright ("cannot use subquery in DEFAULT
1267/// expression") and has nothing to resolve another column's name against.
1268pub fn default_blocker(expr: &IrExpr) -> Option<&'static str> {
1269    use IrExpr as E;
1270    match expr {
1271        E::Subquery(_)
1272        | E::ObjectSubquery(_)
1273        | E::ObjectPathSubquery(_)
1274        | E::ObjectPathUnion { .. }
1275        | E::PathSubquery(_)
1276        | E::FnSubquery(_)
1277        | E::ArrayFromSelect(_)
1278        | E::ScalarSubquery(_)
1279        | E::AggOverQuery { .. }
1280        | E::AggOverCte { .. }
1281        | E::ExistsOverCte { .. }
1282        | E::SetOp { .. }
1283        | E::AggOverSet { .. }
1284        | E::CteRef { .. }
1285        | E::CteFieldRef { .. }
1286        | E::GlobalRef { .. } => Some("a sub-select"),
1287        E::ColumnRef { .. } => Some("a reference to another pointer"),
1288        E::Param { .. } | E::GlobalParam { .. } => Some("a query parameter"),
1289        E::ForVar { .. } => Some("a for-loop variable"),
1290        E::FnParam { .. } => Some("a function parameter"),
1291        E::BinOp(b) => default_blocker(&b.left).or_else(|| default_blocker(&b.right)),
1292        E::UnaryOp(u) => default_blocker(&u.operand),
1293        E::TypeCast(c) => default_blocker(&c.expr),
1294        E::IfElse(i) => default_blocker(&i.condition)
1295            .or_else(|| default_blocker(&i.if_))
1296            .or_else(|| default_blocker(&i.else_)),
1297        E::FunctionCall(f) => f.args.iter().find_map(default_blocker),
1298        E::Array(items) | E::Tuple(items) => items.iter().find_map(default_blocker),
1299        E::NamedTuple { fields, .. } => fields.iter().find_map(|(_, e)| default_blocker(e)),
1300        E::Subscript { expr, index, .. } => default_blocker(expr).or_else(|| default_blocker(index)),
1301        E::JsonbField { expr, .. } | E::JsonbIndex { expr, .. } => default_blocker(expr),
1302        E::Slice { expr, lower, upper, .. } => default_blocker(expr)
1303            .or_else(|| lower.as_deref().and_then(default_blocker))
1304            .or_else(|| upper.as_deref().and_then(default_blocker)),
1305        E::Literal(_) | E::Null | E::EnumLiteral { .. } | E::RawSql(_) => None,
1306    }
1307}
1308
1309/// The SQL for a pointer's PyQL default as a column DEFAULT clause, or `None`
1310/// when a column DEFAULT cannot hold it — it reads a session global, selects
1311/// an object, or otherwise needs the query around it. Those are applied by
1312/// `compile_insert` instead; see `inlined_pointer_defaults`.
1313pub fn column_default_sql(pyql: &str, schema: &SchemaDescriptor) -> Option<String> {
1314    let (sql, ir) = compile_scalar_default_typed(pyql, schema).ok()?;
1315    default_blocker(&ir).is_none().then_some(sql)
1316}
1317
1318/// The pointers whose PyQL default an insert has to expand into its own shape,
1319/// paired with that default, because `column_default_sql` cannot render it.
1320///
1321/// Expanding a default means adding one shape element per unspecified pointer,
1322/// which is what makes an object-returning default like
1323/// `created_by := account_of_transaction()` work at all. Here the defaults a
1324/// column DEFAULT does hold keep using it, so only the remainder needs the
1325/// query.
1326pub fn inlined_pointer_defaults(td: &TypeDescriptor, schema: &SchemaDescriptor) -> Vec<(String, String)> {
1327    let properties = td
1328        .properties
1329        .iter()
1330        .filter(|p| !p.is_pk)
1331        .filter_map(|p| p.default_pyql.as_ref().map(|d| (p.name.clone(), d.clone())));
1332    let links = td
1333        .links
1334        .iter()
1335        .filter(|l| !l.is_junction_backed())
1336        .filter_map(|l| l.default_pyql.as_ref().map(|d| (l.name.clone(), d.clone())));
1337    properties
1338        .chain(links)
1339        .filter(|(_, pyql)| column_default_sql(pyql, schema).is_none())
1340        .collect()
1341}
1342
1343/// One inlined default as the insert shape carries it: `pointer := <default>`.
1344fn default_shape_element(pointer: &str, value: Expr) -> ShapeElement {
1345    ShapeElement {
1346        path: ast::Path::relative(pointer),
1347        splat: None,
1348        nested: None,
1349        compexpr: Some(value),
1350        op: ShapeOp::Assign,
1351        filter: None,
1352        order_by: vec![],
1353        offset: None,
1354        limit: None,
1355        marker_offset: None,
1356    }
1357}
1358
1359/// One inlined default, compiled the way `compile_insert` expands it into an
1360/// insert's shape — so a default that cannot compile there is caught by schema
1361/// validation rather than by the first insert that omits the pointer.
1362///
1363/// One pointer at a time, because the caller reporting the failure has to be
1364/// able to name which declaration caused it.
1365pub fn compile_inlined_default(
1366    type_name: &str,
1367    pointer: &str,
1368    pyql: &str,
1369    schema: &SchemaDescriptor,
1370) -> Result<(String, IrExpr), PyQLError> {
1371    let mut c = Compiler::new(schema);
1372    let td = c.resolve_type(type_name)?;
1373    let alias = c.fresh_alias();
1374    let element = default_shape_element(pointer, crate::parse::parse_pointer_expr(pyql)?);
1375    let mut assignments = c.compile_assignments(&[element], td, &alias)?;
1376    Ok(assignments.remove(0))
1377}
1378
1379// ── Compiler context ────────────────────────────────────────────────────────────
1380
1381struct Compiler<'a> {
1382    schema: &'a SchemaDescriptor,
1383    /// Ordered parameter names — index + 1 is the $N position in SQL.
1384    params: Vec<String>,
1385    alias_counter: usize,
1386    /// CTE names registered in the enclosing WITH block → qualified type name.
1387    cte_types: HashMap<String, String>,
1388    /// `with g := (group …)` bindings. A group's rows are no schema object, so
1389    /// nothing can read them as a CTE; a `for` over one compiles the group
1390    /// again in its own terms instead.
1391    group_bindings: HashMap<String, IrGroup>,
1392    /// CTE names bound to a single free row (free object/scalar/tuple, not
1393    /// a schema object) → that row's `IrFreeExpr` — lets `root.field`
1394    /// resolve to `IrExpr::CteFieldRef` when `root` is such a binding,
1395    /// instead of failing as an unresolvable schema-path root.
1396    cte_free_items: HashMap<String, IrFreeExpr>,
1397    /// CTE names whose binding can hold more than one row. Comparing a value
1398    /// against one has to become set membership: `IrExpr::CteRef` reads the
1399    /// binding as a scalar subquery, which Postgres aborts on the second row
1400    /// ("more than one row returned by a subquery used as an expression").
1401    multi_row_ctes: HashSet<String>,
1402    /// FOR loop variables in scope: variable name → pg_type of the scalar iterator.
1403    for_vars: HashMap<String, String>,
1404    /// For-loop variables that iterate objects, by qualified type name. The
1405    /// variable itself holds the row's key (see `compile_for`), so a path
1406    /// rooted at one reads its table back by that key.
1407    for_var_types: HashMap<String, String>,
1408    /// For a loop over a `with` binding, the binding each variable's rows come
1409    /// from. The rows a sibling CTE has just written are not in the base table
1410    /// yet -- Postgres runs every CTE against the snapshot the statement
1411    /// started with -- so a walk off the variable has to read the binding.
1412    for_var_ctes: HashMap<String, String>,
1413    /// The name each loop variable's iterator CTE is emitted under. Two
1414    /// sibling loops may use the same variable name, and a WITH clause cannot
1415    /// hold the same CTE name twice.
1416    for_var_slots: HashMap<String, String>,
1417    /// The condition a `(insert …) if cond else {}` puts on the insert about
1418    /// to be compiled — see `IrInsert::guard`.
1419    pending_insert_guard: Option<Expr>,
1420    /// The condition a guarded `update` has to carry itself, for the same
1421    /// reason an insert does: a data-modifying CTE runs whether or not
1422    /// anything reads it, so filtering what is read back changes nothing.
1423    pending_update_guard: Option<Expr>,
1424    /// See `pending_update_guard` — the same, for a guarded delete.
1425    pending_delete_guard: Option<Expr>,
1426    /// Pointers a `with` binding declared in its own shape (`offering := (
1427    /// select Offering { publisher := … })`). They exist nowhere on the type,
1428    /// so a later `offering { publisher }` has to find them here.
1429    cte_declared_pointers: HashMap<String, Vec<ShapeElement>>,
1430    /// Those of the binding whose shape is being compiled right now.
1431    active_declared_pointers: Vec<ShapeElement>,
1432    /// Computed pointers a path is reading as a value right now, as
1433    /// `module::Type.name` — one that names itself would otherwise recurse
1434    /// without end.
1435    expanding_computeds: Vec<String>,
1436    /// The schema-bound selects currently being compiled, innermost last.
1437    ///
1438    /// Only consulted for `detached`: an absolute `TypeName.prop` inside a
1439    /// detached select means the *enclosing* select's row, so the innermost
1440    /// entry is skipped and the next matching one used.
1441    anchors: Vec<SelectAnchor>,
1442    /// CTE definitions a nested `with` contributed from somewhere the
1443    /// emitter has no WITH clause of its own — an expression, say. They are
1444    /// appended to the statement's own CTEs at the top-level boundary.
1445    hoisted_ctes: Vec<IrCteDef>,
1446    /// What each hoisted binding was defined as, so the same one arriving
1447    /// twice (one computed inlined from two places) is recognised as the same
1448    /// rather than emitted twice under one CTE name.
1449    hoisted_binding_sources: HashMap<String, Expr>,
1450    /// The WITH name a binding is actually emitted under. Two `for` bodies may
1451    /// each declare `line`; one WITH clause cannot hold that name twice, so the
1452    /// second is emitted under a suffixed one and every reference to it reads
1453    /// from here.
1454    cte_sql_names: HashMap<String, String>,
1455    /// Every WITH name this statement will carry, whoever asked for it — a
1456    /// user binding, a loop's iterator, a hoisted mutation. One set so no two
1457    /// can claim the same name and no guard has to know about the others.
1458    cte_namespace: std::collections::HashSet<String>,
1459    /// Loop slots whose variable has been read since the last time this was
1460    /// cleared — see `for_var_ref`.
1461    for_vars_read: std::collections::HashSet<String>,
1462    /// The loops whose bodies are being compiled, innermost last.
1463    for_scope: Vec<String>,
1464    /// `(through type, junction alias)` of the multi-link whose own
1465    /// modifiers are being compiled — what a bare `@prop` in `filter
1466    /// (@primary = true)` resolves against. `None` on the stack means a link
1467    /// with no through type, which has no link properties at all.
1468    link_prop_scope: Vec<Option<(String, String)>>,
1469    /// Set when `compile_stmt` strips a select-level `detached`, and taken by
1470    /// the `compile_path_modifiers` that compiles that select's own clauses.
1471    pending_detached: bool,
1472    /// Set while a path continues past a sub-select's own subject, e.g. the
1473    /// `.plan.tier` of `(select .licences filter not exists .ended_at limit
1474    /// 1).plan.tier`: the subject's landing row is what FILTER/ORDER BY/LIMIT
1475    /// scope to, not the type the whole path ends on.
1476    modifier_anchor: Option<(String, String)>,
1477    /// The `order by` the *outer* select wrote around such a path, which
1478    /// speaks about what the path projects rather than about the anchored
1479    /// subject — `(select Cart filter .id = $c).applied_promotions order by
1480    /// .code` orders applications, having filtered carts.
1481    tail_sorts: Vec<ast::SortExpr>,
1482    /// WITH bindings that name a path off the enclosing object. They read that
1483    /// object's alias, which a CTE emitted ahead of the FROM clause cannot
1484    /// see, so they stand in for their value wherever the name is used.
1485    inline_bindings: std::collections::HashMap<String, IrExpr>,
1486    /// True while compiling a `@pylon.function` body. A session global cannot
1487    /// be a query parameter there — a `CREATE FUNCTION` body has nothing to
1488    /// bind one to — so it is read out of the `__pylon_json_globals__`
1489    /// argument instead. See `GLOBALS_ARG`.
1490    in_fn_body: bool,
1491    /// Qualified names of functions that take the globals argument, so a call
1492    /// to one can be given it.
1493    ///
1494    /// Computed on first use rather than up front: working it out means
1495    /// compiling every function body in the schema, and the overwhelming
1496    /// majority of queries never call a user function at all. Function-body
1497    /// compilation seeds it explicitly (`compile_fn_body_with`), which is also
1498    /// what keeps the fixpoint from recursing into itself.
1499    fns_needing_globals: Option<std::collections::HashSet<String>>,
1500    /// Set when this body read a global or forwarded the argument to a callee
1501    /// — i.e. when the function being compiled needs the argument itself.
1502    used_globals_arg: bool,
1503    /// User-defined function parameters in scope (only set during body compilation).
1504    fn_params: HashMap<String, String>,
1505    /// `__new__`/`__old__` row-context bindings, only set during trigger-handler
1506    /// compilation (`compile_trigger_handler`) — maps the anchor name to
1507    /// *the trigger's own type* and the table alias its properties/links
1508    /// should resolve against (`"NEW"`/`"OLD"`). Stored explicitly (not just
1509    /// the alias string) because `__new__.x`/`__old__.x` can appear nested
1510    /// inside a sub-statement targeting a *different* type (e.g. `insert Note
1511    /// { note := __new__.name }` — the ambient `td` at that point is `Note`,
1512    /// not the trigger's own type), so resolution can't rely on whatever
1513    /// `td` happens to be in scope where the anchor is used. An anchor not
1514    /// legal for the trigger's `on` mask (e.g. `__old__` in an Insert-only
1515    /// trigger) is simply absent from this map, and fails resolution the
1516    /// same way any other unknown identifier does.
1517    special_anchors: HashMap<String, (&'a TypeDescriptor, String)>,
1518    /// Global CTEs collected during compilation (session and computed), in dependency order.
1519    global_ctes: Vec<IrGlobalCte>,
1520    /// Nested DML hoisted out of a link-assignment value currently being
1521    /// compiled (`author := (select (insert Person {...}) { id })`) — see
1522    /// `compile_link_subquery`'s doc comment. `compile_insert`/
1523    /// `compile_update` save-and-clear this on entry and drain it back into
1524    /// their own `IrInsert`/`IrUpdate::nested_ctes` on exit, so nesting
1525    /// (a nested insert whose own link value nests another insert) attaches
1526    /// each level's discoveries to the right statement.
1527    pending_nested_ctes: Vec<IrCteDef>,
1528    /// Junction tables this statement also *writes*, mapped to the CTE that
1529    /// wrote them — installed only while a walk off a hoisted mutation is
1530    /// being compiled. Postgres does not show a sibling CTE's inserts in the
1531    /// base table, so `(update T set { multi += … }).multi` has to read the
1532    /// rows back out of the append CTE or it sees none of them.
1533    junction_read_overrides: HashMap<String, JunctionReadOverride>,
1534    nested_cte_counter: usize,
1535    /// Non-fatal warnings collected during compilation.
1536    warnings: Vec<String>,
1537    /// Depth of `any()`/`all()` arguments currently being compiled. Wrapping a
1538    /// set-valued comparison in one of those *is* the explicit intent the
1539    /// multi-link FILTER warning asks for, so the warning stays quiet inside.
1540    explicit_set_depth: usize,
1541    /// User-configurable session options — see `SessionConfig`. Always
1542    /// `default()` for every entry point except `compile_with_config`.
1543    config: crate::ir::SessionConfig,
1544    /// Whether a shape compiled right now gets an `id` it did not ask for —
1545    /// see `prepend_implicit_id`. True at the top level and cleared for the
1546    /// two places that cannot carry it: a mutation's own body, where a link
1547    /// value compiles to a one-column correlated subquery that a second column
1548    /// would break, and under a cast to `json`, which is an output sink whose
1549    /// text an added key would change.
1550    implicit_id_in_shapes: bool,
1551}
1552
1553/// One schema-bound select in the enclosing chain — see `Compiler::anchors`.
1554struct SelectAnchor {
1555    type_name: String,
1556    qualified: String,
1557    alias: String,
1558    detached: bool,
1559    /// The ancestor an inherited computed is declared on, set only for the
1560    /// anchor that computed is compiled under.
1561    ///
1562    /// A computed is compiled once, against the type that declares it, so
1563    /// `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)` reads its own
1564    /// row there and keeps doing so through every subtype that inherits it.
1565    /// Pylon materialises an inherited computed onto each subtype and compiles
1566    /// it again there, where `BrandAddon` no longer names the subject -- and a
1567    /// bare type name that anchors nothing reads the whole table. Naming a
1568    /// supertype does *not* otherwise mean the subject
1569    /// (`select BrandAddonBundle { n := count(BrandAddon) }` is the full count,
1570    /// while the same shape naming `BrandAddonBundle` is 1), so this is scoped
1571    /// to the one anchor rather than folded into the general name match.
1572    declared_on: Option<(String, String)>,
1573}
1574
1575impl SelectAnchor {
1576    /// Whether this anchor answers to `root` as a path root.
1577    fn answers_to(&self, root: &str) -> bool {
1578        self.type_name == root
1579            || self.qualified == root
1580            || self
1581                .declared_on
1582                .as_ref()
1583                .is_some_and(|(name, qualified)| name == root || qualified == root)
1584    }
1585}
1586
1587/// The synthetic argument carrying session globals into a function body.
1588///
1589/// A session global is normally a query parameter, which a `CREATE FUNCTION`
1590/// body cannot have — it would emit a bare `$1` nothing binds. Instead the
1591/// caller packs the globals into one jsonb value and passes it as a leading
1592/// argument. One opaque argument rather than one
1593/// per global keeps the function's signature independent of which globals its
1594/// body happens to mention, so editing a body does not churn its signature
1595/// (and, with PostgreSQL overloading, leave a stale one behind).
1596pub const GLOBALS_ARG: &str = "__pylon_json_globals__";
1597
1598/// SQL reading one global out of `GLOBALS_ARG`.
1599fn globals_arg_read(qualified: &str, pg_type: &str) -> String {
1600    let key = qualified.replace('\'', "''");
1601    if let Some(element) = pg_type.strip_suffix("[]") {
1602        // `->>` would hand back the JSON array's text, not an array, so the
1603        // elements are unpacked instead. The `jsonb_typeof` guard matters: an
1604        // unset global arrives as JSON null, and unpacking that raises "cannot
1605        // extract elements from a scalar". The `coalesce` keeps an empty array
1606        // an empty array rather than letting `array_agg` turn it into NULL.
1607        format!(
1608            "(case when jsonb_typeof({GLOBALS_ARG} -> '{key}') = 'array' \
1609             then coalesce((select array_agg(value::{element}) \
1610             from jsonb_array_elements_text({GLOBALS_ARG} -> '{key}') as value), '{{}}'::{pg_type}) \
1611             else null end)"
1612        )
1613    } else {
1614        format!("(({GLOBALS_ARG} ->> '{key}')::{pg_type})")
1615    }
1616}
1617
1618impl<'a> Compiler<'a> {
1619    fn new(schema: &'a SchemaDescriptor) -> Self {
1620        Self::with_config(schema, crate::ir::SessionConfig::default())
1621    }
1622
1623    fn with_config(schema: &'a SchemaDescriptor, config: crate::ir::SessionConfig) -> Self {
1624        Compiler {
1625            schema,
1626            params: vec![],
1627            alias_counter: 0,
1628            cte_types: HashMap::new(),
1629            group_bindings: HashMap::new(),
1630            cte_free_items: HashMap::new(),
1631            multi_row_ctes: HashSet::new(),
1632            for_vars: HashMap::new(),
1633            for_var_types: HashMap::new(),
1634            for_var_ctes: HashMap::new(),
1635            for_var_slots: HashMap::new(),
1636            pending_insert_guard: None,
1637            pending_update_guard: None,
1638            pending_delete_guard: None,
1639            cte_declared_pointers: HashMap::new(),
1640            active_declared_pointers: vec![],
1641            expanding_computeds: vec![],
1642            fn_params: HashMap::new(),
1643            special_anchors: HashMap::new(),
1644            global_ctes: vec![],
1645            pending_nested_ctes: vec![],
1646            junction_read_overrides: HashMap::new(),
1647            nested_cte_counter: 0,
1648            warnings: vec![],
1649            explicit_set_depth: 0,
1650            config,
1651            implicit_id_in_shapes: true,
1652            anchors: Vec::new(),
1653            link_prop_scope: Vec::new(),
1654            hoisted_ctes: Vec::new(),
1655            hoisted_binding_sources: HashMap::new(),
1656            cte_sql_names: HashMap::new(),
1657            cte_namespace: std::collections::HashSet::new(),
1658            for_vars_read: std::collections::HashSet::new(),
1659            for_scope: Vec::new(),
1660            pending_detached: false,
1661            modifier_anchor: None,
1662            tail_sorts: vec![],
1663            inline_bindings: std::collections::HashMap::new(),
1664            in_fn_body: false,
1665            fns_needing_globals: None,
1666            used_globals_arg: false,
1667        }
1668    }
1669
1670    /// Register a compiled WITH binding under `name`: records its type (or
1671    /// empty string for a free binding) in `cte_types`, and — when it's a
1672    /// single free row — its `IrFreeExpr` in `cte_free_items` so a later
1673    /// `name.field` reference can resolve to `IrExpr::CteFieldRef`.
1674    /// True when `name` is bound to a value rather than to an object set —
1675    /// a scalar WITH binding, or one inlined by `bind_inline_if_correlated`.
1676    fn is_value_binding(&self, name: &str) -> bool {
1677        self.inline_bindings.contains_key(name) || self.cte_types.get(name).is_some_and(|t| !t.contains("::"))
1678    }
1679
1680    /// Bind `name` to the value of a relative path off the enclosing object —
1681    /// `with handle_id := .id`. Returns false when the binding is anything
1682    /// else, which the caller hoists into a CTE as usual.
1683    /// `ctx` is the row the enclosing sub-select is correlated to, when it
1684    /// has one: a relative path in its bindings reads that row, not whichever
1685    /// select happens to enclose it.
1686    fn bind_inline_if_correlated(
1687        &mut self,
1688        name: &str,
1689        expr: &Expr,
1690        ctx: Option<(&TypeDescriptor, &str)>,
1691    ) -> Result<bool, PyQLError> {
1692        if (self.anchors.is_empty() && ctx.is_none()) || !matches!(expr, Expr::Path(p) if p.partial) {
1693            return Ok(false);
1694        }
1695        let ir = self.compile_expr_ctx(expr, ctx)?;
1696        self.inline_bindings.insert(name.to_string(), ir);
1697        Ok(true)
1698    }
1699
1700    /// The WITH name `name` is emitted under — itself, unless it collided.
1701    fn cte_sql_name(&self, name: &str) -> String {
1702        self.cte_sql_names
1703            .get(name)
1704            .cloned()
1705            .unwrap_or_else(|| name.to_string())
1706    }
1707
1708    /// Compile a `with` binding, reporting the loop slot it reads if it reads
1709    /// one. A binding that names the loop variable holds a different value
1710    /// each iteration, which is not something a statement-level CTE can be.
1711    fn compile_binding_in_scope(&mut self, expr: &Expr) -> Result<(IrStmt, Option<String>), PyQLError> {
1712        let before = std::mem::take(&mut self.for_vars_read);
1713        let ir_stmt = compile_cte_binding(self, expr);
1714        let read = std::mem::replace(&mut self.for_vars_read, before);
1715        // The innermost loop read is the one it belongs to; an outer loop's
1716        // variable is reachable from there anyway.
1717        let correlated_to = self
1718            .for_scope
1719            .iter()
1720            .rev()
1721            .find(|slot| read.contains(*slot))
1722            .map(|slot| format!("_for_{slot}"));
1723        self.for_vars_read.extend(read);
1724        Ok((ir_stmt?, correlated_to))
1725    }
1726
1727    /// Claim a WITH name, suffixing `base` until it is free. Names the SQL
1728    /// emitter derives from a claimed one — `x__ids`, `x__rows`,
1729    /// `x__ml_add_0` — are claimed with it, which is why anything under a
1730    /// taken name's `__` prefix counts as taken too.
1731    fn claim_in_cte_namespace(&mut self, base: &str) -> String {
1732        // A suffixed candidate is itself an extension of `base`, so `base` is
1733        // the one claimed name that never rules its own suffixes out.
1734        let taken = |namespace: &std::collections::HashSet<String>, candidate: &String| {
1735            namespace.contains(candidate)
1736                || namespace
1737                    .iter()
1738                    .any(|claimed| claimed != base && candidate.starts_with(&format!("{claimed}__")))
1739        };
1740        let mut candidate = base.to_string();
1741        let mut suffix = 1;
1742        while taken(&self.cte_namespace, &candidate) {
1743            candidate = format!("{base}__{suffix}");
1744            suffix += 1;
1745        }
1746        self.cte_namespace.insert(candidate.clone());
1747        candidate
1748    }
1749
1750    /// Claim a WITH name the compiler spelled itself — a loop's iterator, a
1751    /// hoisted mutation. These already live in the underscore-prefixed space
1752    /// that user names are kept out of, so they are claimed as written.
1753    fn claim_generated_cte_name(&mut self, preferred: &str) -> String {
1754        self.claim_in_cte_namespace(preferred)
1755    }
1756
1757    /// Claim a WITH name for a `with` binding, recording the name to read it
1758    /// back by when the one it asked for was already spoken for.
1759    fn claim_cte_sql_name(&mut self, name: &str) -> String {
1760        // Every generated WITH name starts with an underscore. Keeping user
1761        // names out of that space is what makes the two sets disjoint, so a
1762        // binding named `_dml` cannot collide with the wrapper of that name.
1763        let base = match name.starts_with('_') {
1764            true => format!("w{name}"),
1765            false => name.to_string(),
1766        };
1767        let claimed = self.claim_in_cte_namespace(&base);
1768        if claimed == name {
1769            self.cte_sql_names.remove(name);
1770        } else {
1771            self.cte_sql_names.insert(name.to_string(), claimed.clone());
1772        }
1773        claimed
1774    }
1775
1776    fn register_cte(&mut self, name: &str, ir_stmt: &IrStmt) -> String {
1777        let type_name = cte_stmt_type(ir_stmt);
1778        self.cte_types.insert(name.to_string(), type_name.clone());
1779        if let IrStmt::Select(sel) = ir_stmt
1780            && let [IrRowSource::Free(item)] = sel.rows.as_slice()
1781        {
1782            self.cte_free_items.insert(name.to_string(), item.clone());
1783        }
1784        self.note_cte_cardinality(name, ir_stmt);
1785        type_name
1786    }
1787
1788    /// True when `sel`'s filter pins an exclusive property to one value, which
1789    /// makes it single-valued however many rows the table holds. It is why
1790    /// `(select Installation filter .id = <uuid>$x).connector.provider.staff`
1791    /// is a value rather than a one-element set.
1792    fn pins_an_exclusive_property(&self, sel: &IrSelect) -> bool {
1793        let [IrRowSource::Bound { source, .. }] = sel.rows.as_slice() else {
1794            return false;
1795        };
1796        let Some(filter) = &sel.filter else { return false };
1797        let Ok(td) = self.resolve_type(&source.type_name) else {
1798            return false;
1799        };
1800        Self::pins_exclusive(td, filter)
1801    }
1802
1803    /// Walks the conjuncts of `filter` for an equality against an exclusive or
1804    /// primary-key column. Only `and` is descended: under `or` either side
1805    /// could match a different row, so neither pins anything.
1806    fn pins_exclusive(td: &TypeDescriptor, filter: &IrExpr) -> bool {
1807        let IrExpr::BinOp(binop) = filter else { return false };
1808        match binop.op {
1809            ast::BinOpKind::And => Self::pins_exclusive(td, &binop.left) || Self::pins_exclusive(td, &binop.right),
1810            ast::BinOpKind::Eq => {
1811                let column = match (&binop.left, &binop.right) {
1812                    (IrExpr::ColumnRef { column, .. }, _) => column,
1813                    (_, IrExpr::ColumnRef { column, .. }) => column,
1814                    _ => return false,
1815                };
1816                td.properties
1817                    .iter()
1818                    .any(|p| &p.name == column && (p.is_exclusive || p.is_pk))
1819            }
1820            _ => false,
1821        }
1822    }
1823
1824    /// Record `name` when its binding can hold more than one row, so a later
1825    /// comparison against it compiles to membership rather than a scalar
1826    /// subquery read. Anything whose row count isn't statically one counts as
1827    /// multi: a needless membership test still answers correctly, a needless
1828    /// scalar read aborts the query.
1829    fn note_cte_cardinality(&mut self, name: &str, ir_stmt: &IrStmt) {
1830        let single = match ir_stmt {
1831            IrStmt::Insert(_) => true,
1832            IrStmt::Select(sel) => {
1833                matches!(sel.limit, Some(IrExpr::Literal(IrLiteral::Int(1))))
1834                    || matches!(
1835                        sel.rows.as_slice(),
1836                        [IrRowSource::Free(
1837                            IrFreeExpr::Scalar(_)
1838                                | IrFreeExpr::FreeObject(_)
1839                                | IrFreeExpr::NamedTupleRow(_)
1840                                | IrFreeExpr::Tuple(_)
1841                        )]
1842                    )
1843                    || self.pins_an_exclusive_property(sel)
1844            }
1845            _ => false,
1846        };
1847        if !single {
1848            self.multi_row_ctes.insert(name.to_string());
1849        }
1850    }
1851
1852    /// Resolve `root.field1.field2. ... fieldN` where `root` is a WITH-bound
1853    /// free object (e.g. `with x := { a := { b := 1 } } select x.a.b`) —
1854    /// `None` when `root` isn't such a binding, so callers fall back to
1855    /// their normal path resolution. The first step reads the CTE's own
1856    /// per-field column (`IrExpr::CteFieldRef`, materialized once); any
1857    /// further steps index into that value as jsonb (`IrExpr::JsonbField`),
1858    /// since a nested free-object *field* is jsonb the moment it's not the
1859    /// top-level row itself — validated statically wherever the nesting is
1860    /// itself a free-object literal (so a typo like `x.a.typo` still gets a
1861    /// compile error instead of silently returning SQL NULL).
1862    fn resolve_cte_field_chain(&self, root: &str, steps: &[&str]) -> Option<Result<IrExpr, PyQLError>> {
1863        let (first, rest) = steps.split_first()?;
1864        let fields = match self.cte_free_items.get(root)? {
1865            IrFreeExpr::FreeObject(fields) => fields,
1866            _ => return None,
1867        };
1868        let mut current: &IrExpr = match fields.iter().find(|(n, _)| n == first) {
1869            Some((_, e)) => e,
1870            None => {
1871                return Some(Err(
1872                    self.type_err(&format!("free object '{root}' has no field '{first}'"))
1873                ));
1874            }
1875        };
1876        let mut expr = IrExpr::CteFieldRef {
1877            name: root.to_string(),
1878            field: first.to_string(),
1879            pg_type: infer_ir_type(current).map(str::to_string),
1880        };
1881        for step in rest {
1882            if let IrExpr::NamedTuple {
1883                fields: nested,
1884                is_free_object: true,
1885            } = current
1886            {
1887                match nested.iter().find(|(n, _)| n == step) {
1888                    Some((_, next)) => current = next,
1889                    None => {
1890                        return Some(Err(
1891                            self.type_err(&format!("{step} is not a member of the nested free object"))
1892                        ));
1893                    }
1894                }
1895            }
1896            expr = IrExpr::JsonbField {
1897                expr: Box::new(expr),
1898                field: step.to_string(),
1899            };
1900        }
1901        Some(Ok(expr))
1902    }
1903
1904    /// `resolve_cte_field_chain`, but taking the whole `root.f1.f2...` path
1905    /// directly — `None` when the path isn't an absolute multi-step name
1906    /// chain (so, in particular, whenever a step is anything other than a
1907    /// plain name, e.g. a type intersection or backlink).
1908    fn resolve_cte_path(&self, p: &ast::Path) -> Option<Result<IrExpr, PyQLError>> {
1909        if p.partial || p.steps.len() < 2 {
1910            return None;
1911        }
1912        let ast::PathStep::Name(root) = &p.steps[0] else {
1913            return None;
1914        };
1915        let mut steps = Vec::with_capacity(p.steps.len() - 1);
1916        for step in &p.steps[1..] {
1917            match step {
1918                ast::PathStep::Name(n) => steps.push(n.as_str()),
1919                _ => return None,
1920            }
1921        }
1922        self.resolve_cte_field_chain(root, &steps)
1923    }
1924
1925    /// Return the CTE name if `expr` is a bare identifier that matches a registered CTE.
1926    fn resolve_cte_name<'e>(&self, expr: &'e Expr) -> Option<&'e str> {
1927        if let Expr::Path(p) = expr
1928            && !p.partial
1929            && p.steps.len() == 1
1930            && let ast::PathStep::Name(n) = &p.steps[0]
1931            && self.cte_types.contains_key(n.as_str())
1932        {
1933            return Some(n.as_str());
1934        }
1935        None
1936    }
1937
1938    /// A reference to the loop variable `name`, under the CTE name its loop
1939    /// was emitted with. Recorded as read, which is what tells a `with`
1940    /// binding compiled around it that it belongs to one iteration rather
1941    /// than to the statement.
1942    fn for_var_ref(&mut self, name: &str) -> IrExpr {
1943        let slot = self
1944            .for_var_slots
1945            .get(name)
1946            .cloned()
1947            .unwrap_or_else(|| name.to_string());
1948        self.for_vars_read.insert(slot.clone());
1949        let pg_type = self.for_vars.get(name).cloned();
1950        IrExpr::ForVar { name: slot, pg_type }
1951    }
1952
1953    fn fresh_alias(&mut self) -> String {
1954        let a = format!("t{}", self.alias_counter);
1955        self.alias_counter += 1;
1956        a
1957    }
1958
1959    /// A distinct naming scheme from `fresh_alias`'s `t0`, `t1`, ... (table
1960    /// aliases) so a hoisted nested-DML CTE name can never collide with one
1961    /// — see `pending_nested_ctes`.
1962    fn fresh_nested_cte_name(&mut self) -> String {
1963        let n = self.nested_cte_counter;
1964        self.nested_cte_counter += 1;
1965        self.claim_generated_cte_name(&format!("_nested_dml_{n}"))
1966    }
1967
1968    /// Register a named parameter and return its 0-based index.
1969    fn param_index(&mut self, name: &str) -> usize {
1970        if let Some(i) = self.params.iter().position(|n| n == name) {
1971            return i;
1972        }
1973        let i = self.params.len();
1974        self.params.push(name.to_string());
1975        i
1976    }
1977
1978    // ── Global variable resolution ────────────────────────────────────────────────
1979
1980    /// `scalar_type` is a PyQL-style type-name string built by the Python
1981    /// walker's `_pyql_type_name` (e.g. `"std::str"`, `"std::uuid"`,
1982    /// `"cal::local_date"`, `"default::Gender"`, `"array<std::str>"`) — never
1983    /// a bare Python class name. Mirrors every shape `_pyql_type_name` can
1984    /// produce for a `Global[T]` annotation.
1985    fn resolve_global_pg_type(&self, scalar_type: &str) -> String {
1986        // `datetime` and `std::datetime` name the same type.
1987        let builtin = match scalar_type.strip_prefix("std::").unwrap_or(scalar_type) {
1988            "str" => Some("text"),
1989            "int16" => Some("int2"),
1990            "int32" => Some("int4"),
1991            "int64" => Some("int8"),
1992            "float32" => Some("float4"),
1993            "float64" => Some("float8"),
1994            "decimal" => Some("numeric"),
1995            "bool" => Some("boolean"),
1996            "datetime" => Some("timestamptz"),
1997            "cal::local_datetime" => Some("timestamp"),
1998            "cal::local_date" => Some("date"),
1999            "cal::local_time" => Some("time"),
2000            "uuid" => Some("uuid"),
2001            "bytes" => Some("bytea"),
2002            "json" => Some("jsonb"),
2003            "duration" => Some("interval"),
2004            _ => None,
2005        };
2006        if let Some(t) = builtin {
2007            return t.to_string();
2008        }
2009        if let Some(inner) = scalar_type.strip_prefix("array<").and_then(|s| s.strip_suffix('>')) {
2010            return format!("{}[]", self.resolve_global_pg_type(inner));
2011        }
2012        if scalar_type.starts_with("tuple<") {
2013            return "jsonb".to_string();
2014        }
2015        if let Some(ed) = self.resolve_enum(scalar_type) {
2016            return format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name);
2017        }
2018        if self.resolve_named_tuple(scalar_type).is_some() {
2019            return "jsonb".to_string();
2020        }
2021        // Fall back to a registered custom scalar lookup.
2022        self.schema
2023            .scalars
2024            .iter()
2025            .find(|s| s.name == scalar_type || format!("{}::{}", s.module, s.name) == scalar_type)
2026            .map(|s| s.pg_type.clone())
2027            .unwrap_or_else(|| "text".to_string())
2028    }
2029
2030    /// When `global name` (or `global name { shape }`) appears as the top-level
2031    /// SELECT subject, inline the computed expression rather than going through a
2032    /// CTE — this returns a full object, not just its id.
2033    fn try_compile_global_select(
2034        &mut self,
2035        outer: &ast::SelectStmt,
2036        result: &Expr,
2037        _distinct: bool,
2038    ) -> Result<Option<IrStmt>, PyQLError> {
2039        let (global_name, shape_elements): (&str, &[ast::ShapeElement]) = match result {
2040            Expr::Global(name) => (name.as_str(), &[]),
2041            Expr::Shape(sh) => match sh.expr.as_ref() {
2042                Some(Expr::Global(name)) => (name.as_str(), sh.elements.as_slice()),
2043                _ => return Ok(None),
2044            },
2045            _ => return Ok(None),
2046        };
2047
2048        let global = self
2049            .schema
2050            .globals
2051            .iter()
2052            .find(|g| g.name == global_name || format!("{}::{}", g.module, g.name) == global_name);
2053        let global = match global {
2054            Some(g) => g.clone(),
2055            None => return Ok(None),
2056        };
2057        let computed_expr = match global.computed_expr {
2058            Some(e) => e,
2059            None => return Ok(None), // scalar session global — fall through
2060        };
2061
2062        let inner_ast = crate::parse::parse(&computed_expr)?;
2063        let inner_sel = match inner_ast {
2064            Stmt::Select(sel) => sel,
2065            _ => return Ok(None),
2066        };
2067
2068        let merged_result = if shape_elements.is_empty() {
2069            inner_sel.result.clone()
2070        } else {
2071            Expr::Shape(Box::new(ast::ShapeExpr {
2072                expr: Some(inner_sel.result.clone()),
2073                elements: shape_elements.to_vec(),
2074                marker_offset: None,
2075            }))
2076        };
2077
2078        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2079            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2080                left: a.clone(),
2081                op: ast::BinOpKind::And,
2082                right: b.clone(),
2083            }))),
2084            (Some(a), None) => Some(a.clone()),
2085            (None, b) => b.clone(),
2086        };
2087
2088        let merged = ast::SelectStmt {
2089            result: merged_result,
2090            filter: merged_filter,
2091            order_by: if outer.order_by.is_empty() {
2092                inner_sel.order_by.clone()
2093            } else {
2094                outer.order_by.clone()
2095            },
2096            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2097            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2098            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2099        };
2100
2101        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2102        Ok(Some(ir))
2103    }
2104
2105    /// Reading one field off a free object. A free shape is a real object
2106    /// type, so `{ device := d { id }, … }.device` is an ordinary
2107    /// path step through a pointer and yields whatever that pointer holds —
2108    /// an object stays an object. Extracting it out of the jsonb the free
2109    /// object would otherwise build flattens it back to raw JSON. Any field
2110    /// left unread still runs: a mutation among them is a data-modifying CTE,
2111    /// which Postgres executes whether or not the outer query reads it.
2112    fn project_free_object_field(expr: IrExpr, field: &str) -> IrExpr {
2113        if let IrExpr::NamedTuple {
2114            fields,
2115            is_free_object: true,
2116        } = &expr
2117            && let Some((_, value)) = fields.iter().find(|(name, _)| name == field)
2118        {
2119            return value.clone();
2120        }
2121        IrExpr::JsonbField {
2122            expr: Box::new(expr),
2123            field: field.to_string(),
2124        }
2125    }
2126
2127    /// The operands of a union written entirely of relative paths, which are
2128    /// correlated to the enclosing row and so cannot be hoisted.
2129    fn union_of_relative_paths(expr: &Expr) -> Option<Vec<ast::Path>> {
2130        fn walk(expr: &Expr, out: &mut Vec<ast::Path>) -> bool {
2131            match expr {
2132                Expr::Union(a, b) => walk(a, out) && walk(b, out),
2133                Expr::Path(p) if p.partial => {
2134                    out.push(p.clone());
2135                    true
2136                }
2137                _ => false,
2138            }
2139        }
2140        if !matches!(expr, Expr::Union(_, _)) {
2141            return None;
2142        }
2143        let mut out = vec![];
2144        walk(expr, &mut out).then_some(out)
2145    }
2146
2147    /// `([is Conditional].configs ?? [is Loop].configs)` — the operands of a
2148    /// coalesce, when every one is a path off the enclosing row. Unlike a
2149    /// union each operand but the first only contributes when the ones before
2150    /// it are empty, which is what the caller guards them with.
2151    fn coalesce_of_relative_paths(expr: &Expr) -> Option<Vec<ast::Path>> {
2152        fn walk(expr: &Expr, out: &mut Vec<ast::Path>) -> bool {
2153            match expr {
2154                Expr::BinOp(b) if b.op == ast::BinOpKind::Coalesce => walk(&b.left, out) && walk(&b.right, out),
2155                Expr::Path(p) if p.partial => {
2156                    out.push(p.clone());
2157                    true
2158                }
2159                _ => false,
2160            }
2161        }
2162        if !matches!(expr, Expr::BinOp(b) if b.op == ast::BinOpKind::Coalesce) {
2163            return None;
2164        }
2165        let mut out = vec![];
2166        walk(expr, &mut out).then_some(out)
2167    }
2168
2169    /// `(select Licence filter …) { id }` — a shape written after a
2170    /// parenthesised sub-select rather than inside it.
2171    ///
2172    /// The two mean the same thing, so the shape is pushed onto the inner
2173    /// statement's own result and the whole thing compiled as the select it
2174    /// already was. A `with` block is carried through to its inner statement.
2175    fn shape_over_subquery(&mut self, sh: &ast::ShapeExpr) -> Result<Option<IrExpr>, PyQLError> {
2176        fn push_shape(stmt: &Stmt, elements: &[ShapeElement]) -> Option<Stmt> {
2177            match stmt {
2178                Stmt::Select(sel) => {
2179                    if matches!(sel.result, Expr::Shape(_)) {
2180                        return None;
2181                    }
2182                    let mut shaped = sel.clone();
2183                    shaped.result = Expr::Shape(Box::new(ast::ShapeExpr {
2184                        expr: Some(sel.result.clone()),
2185                        elements: elements.to_vec(),
2186                        marker_offset: None,
2187                    }));
2188                    Some(Stmt::Select(shaped))
2189                }
2190                Stmt::With(w) => {
2191                    let inner = push_shape(&w.stmt, elements)?;
2192                    let mut carried = w.clone();
2193                    carried.stmt = Box::new(inner);
2194                    Some(Stmt::With(carried))
2195                }
2196                _ => None,
2197            }
2198        }
2199        let Some(Expr::SubQuery(stmt)) = sh.expr.as_ref() else {
2200            return Ok(None);
2201        };
2202        let Some(shaped) = push_shape(stmt.as_ref(), &sh.elements) else {
2203            return Ok(None);
2204        };
2205        let single = matches!(
2206            innermost_select(&shaped).and_then(|s| s.limit.as_ref()),
2207            Some(Expr::Literal(ast::Literal::Int(1)))
2208        );
2209        let IrStmt::Select(select) = self.compile_stmt(&shaped)? else {
2210            return Ok(None);
2211        };
2212        if !matches!(select.rows.as_slice(), [IrRowSource::Bound { .. }]) {
2213            return Ok(None);
2214        }
2215        Ok(Some(if single {
2216            IrExpr::ObjectSubquery(Box::new(select))
2217        } else {
2218            IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(Box::new(select))))
2219        }))
2220    }
2221
2222    /// `(select … limit 1).account { id, name }` — a shape written on what a
2223    /// projection off a sub-select lands on. Returns the sub-statement and
2224    /// the field chain, so the shape can ride along with the splice instead
2225    /// of being rejected as a shape in expression position.
2226    fn shape_over_subquery_projection(sh: &ast::ShapeExpr) -> Option<(&Stmt, Vec<String>)> {
2227        let inner = sh.expr.as_ref()?;
2228        if !matches!(inner, Expr::FieldAccess { .. }) {
2229            return None;
2230        }
2231        let (base, fields) = Self::peel_field_access_chain(inner);
2232        match base {
2233            Expr::SubQuery(stmt) => Some((stmt.as_ref(), fields)),
2234            _ => None,
2235        }
2236    }
2237
2238    /// Peel nested `Expr::FieldAccess` layers (`X.a.b` parses as
2239    /// `FieldAccess{FieldAccess{X, "a"}, "b"}`) into the innermost root
2240    /// expression plus the ordered chain of field names.
2241    /// The junction tables `dml` writes, mapped to the CTE the emitter names
2242    /// for each append (`{cte}__ml_add_{i}`, see `emit_ml_append_cte`). A walk
2243    /// off that mutation reads them from there, because Postgres shows a
2244    /// sibling CTE's inserts nowhere else. Only appends carrying no link
2245    /// properties qualify: the append CTE returns just the two id columns, so
2246    /// a walk reading `@prop` off one would name a column it does not have.
2247    fn junction_overrides_for(dml: &IrStmt, cte_name: &str) -> HashMap<String, JunctionReadOverride> {
2248        let appends = match dml {
2249            IrStmt::Insert(ins) => &ins.multi_link_appends,
2250            IrStmt::Update(upd) => &upd.multi_link_appends,
2251            _ => return HashMap::new(),
2252        };
2253        appends
2254            .iter()
2255            .enumerate()
2256            .filter(|(_, append)| append.values.link_props.is_empty())
2257            .map(|(i, append)| {
2258                // The targets themselves may also have been inserted by this
2259                // statement, in which case they are only in their own CTE too.
2260                let targets = match &append.values.source {
2261                    IrMultiLinkValueSource::CteRef(target_cte) => Some(format!("@cte:{target_cte}")),
2262                    _ => None,
2263                };
2264                (
2265                    append.junction_table.clone(),
2266                    JunctionReadOverride {
2267                        junction: format!("@cte:{cte_name}__ml_add_{i}"),
2268                        targets,
2269                    },
2270                )
2271            })
2272            .collect()
2273    }
2274
2275    /// `(select …).provider[is Individual].staff` — a walk off a sub-select
2276    /// that includes a step with no expression form of its own. The select is
2277    /// hoisted into the statement's own WITH and the walk re-rooted at that
2278    /// binding, which is the `with i := (select …) select i.provider[is …].staff`
2279    /// spelling that already compiles.
2280    ///
2281    /// `None` when this is not that shape — in particular a walk made only of
2282    /// `.field` steps, which the `FieldAccess` arm already handles and must go
2283    /// on handling: re-rooting one of those reads the trailing field as jsonb
2284    /// off an object id instead of as a column.
2285    fn try_compile_walk_off_subquery(
2286        &mut self,
2287        expr: &Expr,
2288        ctx: Option<(&TypeDescriptor, &str)>,
2289    ) -> Result<Option<IrExpr>, PyQLError> {
2290        if !matches!(expr, Expr::PathStepOn { .. } | Expr::FieldAccess { .. }) {
2291            return Ok(None);
2292        }
2293        let (base, steps) = Self::peel_path_step_chain(expr);
2294        if !steps.iter().any(|step| !matches!(step, ast::PathStep::Name(_))) {
2295            return Ok(None);
2296        }
2297        let Expr::SubQuery(inner_stmt) = base else {
2298            return Ok(None);
2299        };
2300        // Bound exactly as a `with` alias would be, not via `compile_stmt`.
2301        let inner = compile_cte_binding(self, &Expr::SubQuery(inner_stmt.clone()))?;
2302        let cte_name = self.fresh_nested_cte_name();
2303        let overrides = Self::junction_overrides_for(&inner, &cte_name);
2304        let type_name = self.register_cte(&cte_name, &inner);
2305        self.hoisted_ctes.push(IrCteDef {
2306            name: cte_name.clone(),
2307            stmt: inner,
2308            type_name,
2309            correlated_to: None,
2310        });
2311        let mut path_steps = vec![ast::PathStep::Name(cte_name)];
2312        path_steps.extend(steps);
2313        let path = ast::Path {
2314            steps: path_steps,
2315            partial: false,
2316        };
2317        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
2318        let ir = match ctx {
2319            Some((td, alias)) => self.compile_path(&path, td, alias),
2320            None => self.compile_free_path(&path),
2321        };
2322        self.junction_read_overrides = previous;
2323        Ok(Some(ir?))
2324    }
2325
2326    /// Peel a mixed `.field` / `[is T]` / `@prop` / `.<backlink` walk off a
2327    /// base that is not itself a path, outermost step last. The counterpart of
2328    /// `peel_field_access_chain` for a chain that contains a step with no
2329    /// expression form of its own (see `ast::Expr::PathStepOn`).
2330    fn peel_path_step_chain(expr: &Expr) -> (&Expr, Vec<ast::PathStep>) {
2331        let mut steps = Vec::new();
2332        let mut current = expr;
2333        loop {
2334            match current {
2335                Expr::FieldAccess { expr: inner, field } => {
2336                    steps.push(ast::PathStep::Name(field.clone()));
2337                    current = inner;
2338                }
2339                Expr::PathStepOn { expr: inner, step } => {
2340                    steps.push((**step).clone());
2341                    current = inner;
2342                }
2343                _ => break,
2344            }
2345        }
2346        steps.reverse();
2347        (current, steps)
2348    }
2349
2350    fn peel_field_access_chain(expr: &Expr) -> (&Expr, Vec<String>) {
2351        let mut fields = Vec::new();
2352        let mut current = expr;
2353        while let Expr::FieldAccess { expr: inner, field } = current {
2354            fields.push(field.clone());
2355            current = inner;
2356        }
2357        fields.reverse();
2358        (current, fields)
2359    }
2360
2361    /// Resolve `expr` to the inner `select Type filter ...` statement it
2362    /// stands for, if any — either a literal subquery (`(select Type filter
2363    /// ...)`) or a computed global whose defining expression is such a
2364    /// select. Only bare object-type results are recognized (`select Type
2365    /// ...`, not `select Type { shape }` or a free expression) — that's the
2366    /// only shape `.field` access after it can be spliced onto as an
2367    /// additional path step.
2368    fn resolve_field_owner_select(&self, expr: &Expr) -> Option<ast::SelectStmt> {
2369        let sel = match expr {
2370            Expr::SubQuery(stmt) => match stmt.as_ref() {
2371                Stmt::Select(sel) => sel.clone(),
2372                _ => return None,
2373            },
2374            Expr::Global(name) => {
2375                let global = self
2376                    .schema
2377                    .globals
2378                    .iter()
2379                    .find(|g| g.name == *name || format!("{}::{}", g.module, g.name) == *name)?;
2380                let computed_expr = global.computed_expr.as_ref()?;
2381                match crate::parse::parse(computed_expr).ok()? {
2382                    Stmt::Select(sel) => sel,
2383                    _ => return None,
2384                }
2385            }
2386            _ => return None,
2387        };
2388        // `(select detached T filter … limit 1).field` — at the top level the
2389        // marker says nothing a field access changes, and the path underneath
2390        // is the subject to splice the chain onto.
2391        let mut sel = sel;
2392        if let Expr::Detached(inner) = &sel.result {
2393            sel.result = inner.as_ref().clone();
2394        }
2395        match &sel.result {
2396            Expr::Path(p) if !p.partial => Some(sel),
2397            _ => None,
2398        }
2399    }
2400
2401    /// `global name.field` or `(select Type filter ...).field` (and deeper
2402    /// chains like `.link.field`) used as the top-level SELECT subject:
2403    /// rather than treating `.field` as jsonb extraction on an opaque value
2404    /// (which only makes sense for tuple-typed properties — see
2405    /// `resolve_property_tuple_shape`), splice the field chain onto the
2406    /// inner select as additional path steps and recompile as an ordinary
2407    /// path-select. Mirrors `try_compile_global_select`'s filter/modifier
2408    /// merge, generalized to a field-access result instead of a bare/shape
2409    /// global reference.
2410    /// `(select T filter … limit 1).a.b { … }` compiled as though it were
2411    /// written `with head := (select T filter … limit 1) select head.a.b { … }`:
2412    /// the head becomes a binding of its own, so its own row count stays on it
2413    /// and the walk off it keeps every row it reaches.
2414    fn compile_walk_off_bound_head(
2415        &mut self,
2416        outer: &ast::SelectStmt,
2417        inner_sel: &ast::SelectStmt,
2418        fields: &[String],
2419        trailing_shape: &[ShapeElement],
2420    ) -> Result<Option<IrStmt>, PyQLError> {
2421        let head = Expr::SubQuery(Box::new(Stmt::Select(inner_sel.clone())));
2422        let inner = compile_cte_binding(self, &head)?;
2423        let cte_name = self.fresh_nested_cte_name();
2424        let overrides = Self::junction_overrides_for(&inner, &cte_name);
2425        let type_name = self.register_cte(&cte_name, &inner);
2426        self.hoisted_ctes.push(IrCteDef {
2427            name: cte_name.clone(),
2428            stmt: inner,
2429            type_name,
2430            correlated_to: None,
2431        });
2432        let mut steps = vec![ast::PathStep::Name(cte_name)];
2433        steps.extend(fields.iter().cloned().map(ast::PathStep::Name));
2434        let walk = Expr::Path(ast::Path { steps, partial: false });
2435        let result = if trailing_shape.is_empty() {
2436            walk
2437        } else {
2438            Expr::Shape(Box::new(ast::ShapeExpr {
2439                expr: Some(walk),
2440                elements: trailing_shape.to_vec(),
2441                marker_offset: None,
2442            }))
2443        };
2444        let rerooted = ast::SelectStmt {
2445            result,
2446            filter: outer.filter.clone(),
2447            order_by: outer.order_by.clone(),
2448            offset: outer.offset.clone(),
2449            limit: outer.limit.clone(),
2450            lock: outer.lock.clone(),
2451        };
2452        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
2453        let ir = self.compile_stmt(&Stmt::Select(rerooted));
2454        self.junction_read_overrides = previous;
2455        Ok(Some(ir?))
2456    }
2457
2458    fn try_compile_field_access_select(
2459        &mut self,
2460        outer: &ast::SelectStmt,
2461        result: &Expr,
2462    ) -> Result<Option<IrStmt>, PyQLError> {
2463        // `(select T).chapter { … }` — the parser folds a trailing shape around
2464        // the whole field access, so the chain has to be peeled out of it or
2465        // the select reads as one with no type for a subject.
2466        let (result, trailing_shape): (&Expr, &[ShapeElement]) = match result {
2467            Expr::Shape(sh) => match sh.expr.as_ref() {
2468                Some(inner @ Expr::FieldAccess { .. }) => (inner, sh.elements.as_slice()),
2469                _ => (result, &[]),
2470            },
2471            other => (other, &[]),
2472        };
2473        let (root, fields) = Self::peel_field_access_chain(result);
2474        if fields.is_empty() {
2475            return Ok(None);
2476        }
2477        let inner_sel = match self.resolve_field_owner_select(root) {
2478            Some(sel) => sel,
2479            None => return Ok(None),
2480        };
2481        let Expr::Path(type_path) = &inner_sel.result else {
2482            return Ok(None);
2483        };
2484        // A sub-select's own `limit`/`offset` counts its rows, not the walk's:
2485        // `(select Cart filter .id = $c limit 1).applied_promotions` is every
2486        // promotion of that one cart. Spliced onto the walk the count lands on
2487        // the result instead and takes one promotion, so the head is bound on
2488        // its own and walked from there — the `with` spelling, which scopes it.
2489        if inner_sel.limit.is_some() || inner_sel.offset.is_some() {
2490            return self.compile_walk_off_bound_head(outer, &inner_sel, &fields, trailing_shape);
2491        }
2492        let mut steps = type_path.steps.clone();
2493        let field_count = fields.len();
2494        steps.extend(fields.into_iter().map(ast::PathStep::Name));
2495        let merged_result = Expr::Path(ast::Path { steps, partial: false });
2496
2497        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2498            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2499                left: a.clone(),
2500                op: ast::BinOpKind::And,
2501                right: b.clone(),
2502            }))),
2503            (Some(a), None) => Some(a.clone()),
2504            (None, b) => b.clone(),
2505        };
2506
2507        let merged = ast::SelectStmt {
2508            result: merged_result,
2509            filter: merged_filter,
2510            order_by: if outer.order_by.is_empty() {
2511                inner_sel.order_by.clone()
2512            } else {
2513                outer.order_by.clone()
2514            },
2515            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2516            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2517            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2518        };
2519
2520        // The inner select's own filter and ordering speak about its subject,
2521        // not about what the field chain projects off it: `(select Individual
2522        // filter .id = $a).credentials.password` filters Individuals, not
2523        // Credentials. `tail` is what pins them there. Only safe when the
2524        // outer select contributed no filter of its own — the merge folds
2525        // both into one clause, and an outer filter does belong at the end.
2526        // An outer `order by` belongs at the end too, so it rides along
2527        // separately rather than costing the inner filter its subject.
2528        if outer.filter.is_none() {
2529            let merged = ast::SelectStmt {
2530                order_by: inner_sel.order_by.clone(),
2531                ..merged
2532            };
2533            let Expr::Path(merged_path) = &merged.result else {
2534                unreachable!("built as a path just above")
2535            };
2536            let ps = self.compile_path_select_with_tail(
2537                &merged,
2538                merged_path,
2539                trailing_shape,
2540                false,
2541                field_count,
2542                &outer.order_by,
2543            )?;
2544            return Ok(Some(IrStmt::PathSelect(ps)));
2545        }
2546
2547        let merged = if trailing_shape.is_empty() {
2548            merged
2549        } else {
2550            ast::SelectStmt {
2551                result: Expr::Shape(Box::new(ast::ShapeExpr {
2552                    expr: Some(merged.result.clone()),
2553                    elements: trailing_shape.to_vec(),
2554                    marker_offset: None,
2555                })),
2556                ..merged
2557            }
2558        };
2559        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2560        Ok(Some(ir))
2561    }
2562
2563    fn try_compile_alias_select(
2564        &mut self,
2565        outer: &ast::SelectStmt,
2566        result: &Expr,
2567        distinct: bool,
2568    ) -> Result<Option<IrStmt>, PyQLError> {
2569        // Extract the bare path name and any outer shape elements.
2570        let (path_name, shape_elements): (&str, &[ast::ShapeElement]) = match result {
2571            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
2572                if let ast::PathStep::Name(n) = &p.steps[0] {
2573                    (n.as_str(), &[])
2574                } else {
2575                    return Ok(None);
2576                }
2577            }
2578            Expr::Shape(sh) => match sh.expr.as_ref() {
2579                Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => {
2580                    if let ast::PathStep::Name(n) = &p.steps[0] {
2581                        (n.as_str(), sh.elements.as_slice())
2582                    } else {
2583                        return Ok(None);
2584                    }
2585                }
2586                _ => return Ok(None),
2587            },
2588            _ => return Ok(None),
2589        };
2590
2591        // Match against schema aliases (bare name or module::name).
2592        let alias = self
2593            .schema
2594            .aliases
2595            .iter()
2596            .find(|a| a.name == path_name || format!("{}::{}", a.module, a.name) == path_name);
2597        let alias = match alias {
2598            Some(a) => a.clone(),
2599            None => return Ok(None),
2600        };
2601
2602        let inner_ast = crate::parse::parse(&alias.expr)?;
2603        let inner_sel = match inner_ast {
2604            Stmt::Select(sel) => sel,
2605            _ => return Err(self.type_err(&format!("alias '{}' expression must be a select statement", alias.name))),
2606        };
2607
2608        // Merge outer shape / filter / modifiers over the alias's select.
2609        // `inner_sel.result` is itself an `Expr::Shape` whenever the
2610        // alias's own body declares a shape (e.g. `select Type { field }
2611        // order by ... limit ...`, a legitimate, documented pattern — an
2612        // alias's own body may combine a shape with modifiers) — wrapping
2613        // it wholesale as the outer shape's own `expr` would nest a Shape
2614        // inside a Shape, which the compiler's SELECT-subject resolution
2615        // rejects ("expected a type name as SELECT subject", confirmed
2616        // live). The outer shape must bind to the same underlying *type*
2617        // reference the alias's own shape does, not to the alias's shape
2618        // node itself — so unwrap through it first.
2619        let inner_base = match &inner_sel.result {
2620            Expr::Shape(sh) => sh.expr.clone(),
2621            other => Some(other.clone()),
2622        };
2623        let merged_result = if shape_elements.is_empty() {
2624            inner_sel.result.clone()
2625        } else {
2626            Expr::Shape(Box::new(ast::ShapeExpr {
2627                expr: inner_base,
2628                elements: shape_elements.to_vec(),
2629                marker_offset: None,
2630            }))
2631        };
2632
2633        let merged_filter = match (&inner_sel.filter, &outer.filter) {
2634            (Some(a), Some(b)) => Some(Expr::BinOp(Box::new(ast::BinOp {
2635                left: a.clone(),
2636                op: ast::BinOpKind::And,
2637                right: b.clone(),
2638            }))),
2639            (Some(a), None) => Some(a.clone()),
2640            (None, b) => b.clone(),
2641        };
2642
2643        let merged = ast::SelectStmt {
2644            result: merged_result,
2645            filter: merged_filter,
2646            order_by: if outer.order_by.is_empty() {
2647                inner_sel.order_by.clone()
2648            } else {
2649                outer.order_by.clone()
2650            },
2651            offset: outer.offset.clone().or(inner_sel.offset.clone()),
2652            limit: outer.limit.clone().or(inner_sel.limit.clone()),
2653            lock: outer.lock.clone().or(inner_sel.lock.clone()),
2654        };
2655
2656        let _ = distinct; // alias selects honour the outer distinct if applied
2657        let ir = self.compile_stmt(&Stmt::Select(merged))?;
2658        Ok(Some(ir))
2659    }
2660
2661    fn compile_global(&mut self, raw_name: &str) -> Result<IrExpr, PyQLError> {
2662        let global = self
2663            .schema
2664            .globals
2665            .iter()
2666            .find(|g| g.name == raw_name || format!("{}::{}", g.module, g.name) == raw_name);
2667        let global = global
2668            .ok_or_else(|| {
2669                PyQLError::Resolution(PyQLResolutionError::UnknownField(PyQLUnknownFieldError {
2670                    message: format!("unknown global: {:?}", raw_name),
2671                    position: Position { line: 0, col: 0 },
2672                }))
2673            })?
2674            .clone();
2675
2676        let qualified = format!("{}::{}", global.module, global.name);
2677        let cte_name = format!("__global__{}", qualified);
2678
2679        if let Some(computed_expr) = global.computed_expr {
2680            // Computed global — check for duplicate before compiling
2681            if self.global_ctes.iter().any(|g| g.cte_name() == cte_name) {
2682                return Ok(IrExpr::GlobalRef { cte_name });
2683            }
2684            // Recursively compile the PyQL expression (shares params and global_ctes)
2685            let inner_ast = crate::parse::parse(&computed_expr)?;
2686            let inner_stmt = self.compile_stmt(&inner_ast)?;
2687            self.global_ctes
2688                .push(IrGlobalCte::Computed(Box::new(IrComputedGlobalCte {
2689                    cte_name: cte_name.clone(),
2690                    qualified_name: qualified,
2691                    stmt: inner_stmt,
2692                })));
2693            Ok(IrExpr::GlobalRef { cte_name })
2694        } else if self.in_fn_body {
2695            // Inside a function body there is no parameter to bind, so the
2696            // value is read out of the `__pylon_json_globals__` argument the
2697            // caller packs.
2698            let pg_type = self.resolve_global_pg_type(&global.scalar_type);
2699            self.used_globals_arg = true;
2700            // Wrapped in a cast rather than returned bare: `RawSql` carries no
2701            // type, so overload resolution fell back to text and picked the
2702            // `str` `find` for an `array<str>` global (`strpos(text[], text)
2703            // does not exist`).
2704            Ok(IrExpr::TypeCast(Box::new(super::IrTypeCast {
2705                expr: IrExpr::RawSql(globals_arg_read(&qualified, &pg_type)),
2706                pg_type,
2707                tuple_shape: None,
2708            })))
2709        } else {
2710            // Session global — allocate parameter slot
2711            let pg_type = self.resolve_global_pg_type(&global.scalar_type);
2712            let param_name = format!("__global__{}", qualified);
2713            let index = self.param_index(&param_name);
2714            // Register CTE only once
2715            if !self.global_ctes.iter().any(|g| g.cte_name() == cte_name) {
2716                self.global_ctes.push(IrGlobalCte::Session(IrSessionGlobalCte {
2717                    cte_name,
2718                    qualified_name: qualified,
2719                    param_index: index,
2720                    pg_type: pg_type.clone(),
2721                }));
2722            }
2723            Ok(IrExpr::GlobalParam { index, pg_type })
2724        }
2725    }
2726
2727    // ── Schema lookups ────────────────────────────────────────────────────────────
2728
2729    /// The object type a WITH binding names, if it binds one — object type
2730    /// strings are qualified, free/scalar bindings' are not.
2731    fn cte_object_type(&self, name: &str) -> Option<String> {
2732        self.cte_types.get(name).filter(|t| t.contains("::")).cloned()
2733    }
2734
2735    fn resolve_type(&self, name: &str) -> Result<&'a TypeDescriptor, PyQLError> {
2736        // Accept both "TypeName" and "module::TypeName"
2737        self.schema
2738            .types
2739            .iter()
2740            .find(|t| t.name == name || format!("{}::{}", t.module, t.name) == name)
2741            .ok_or_else(|| {
2742                PyQLError::Resolution(PyQLResolutionError::UnknownType(PyQLUnknownTypeError {
2743                    message: format!("unknown type '{name}'"),
2744                    position: Position { line: 0, col: 0 },
2745                }))
2746            })
2747    }
2748
2749    /// A schema enum, or failing that one the stdlib defines (`std::Endian`).
2750    /// The stdlib's are `&'static`, which coerces to `&'a` for any shorter
2751    /// `'a`, so both live behind one lookup and every site that recognizes
2752    /// enum member access picks them up without its own special case.
2753    fn resolve_enum(&self, name: &str) -> Option<&'a crate::schema::EnumDescriptor> {
2754        self.schema
2755            .enums
2756            .iter()
2757            .find(|e| e.name == name || format!("{}::{}", e.module, e.name) == name)
2758            .or_else(|| crate::stdlib::lookup_enum(name))
2759    }
2760
2761    /// Resolve a `Channel` by name (bare or `module::Name`) — used by
2762    /// `notify()` to find the declared payload shape its second argument
2763    /// must match.
2764    fn resolve_channel(&self, name: &str) -> Option<&'a crate::schema::ChannelDescriptor> {
2765        self.schema.find_channel(name)
2766    }
2767
2768    /// Resolve a registered custom scalar (`pylon.scalar(..., name=...)` or
2769    /// the `@pylon.scalar` decorator form) by name — used to recognize a
2770    /// cast target as that scalar's own PostgreSQL DOMAIN (see
2771    /// `resolve_cast_pg_type`); an anonymous (unregistered) scalar has no
2772    /// name reachable here at all, so it's never a valid cast target.
2773    fn resolve_scalar(&self, name: &str) -> Option<&'a crate::schema::ScalarDescriptor> {
2774        self.schema
2775            .scalars
2776            .iter()
2777            .find(|s| s.name == name || format!("{}::{}", s.module, s.name) == name)
2778    }
2779
2780    /// Resolve a registered (nominal) `@pylon.named_tuple` type by name — used only
2781    /// to recognize a cast target as a named tuple (member structure isn't
2782    /// validated here; the value is trusted the same way a plain `<json>` cast is).
2783    fn resolve_named_tuple(&self, name: &str) -> Option<&'a crate::schema::NamedTupleDescriptor> {
2784        self.schema
2785            .named_tuples
2786            .iter()
2787            .find(|nt| nt.name == name || format!("{}::{}", nt.module, nt.name) == name)
2788    }
2789
2790    /// Convert a schema-level `TupleMemberDescriptor` into a decode-time
2791    /// `JsonMember` — recurses for a nested tuple member, resolving a nested
2792    /// *nominal* member's own registered members too.
2793    fn tuple_member_to_json_member(&self, m: &crate::schema::TupleMemberDescriptor) -> crate::query::JsonMember {
2794        use crate::schema::TupleMemberKind;
2795        let kind = match &m.kind {
2796            TupleMemberKind::Scalar { .. } => crate::query::JsonMemberKind::Scalar,
2797            TupleMemberKind::Enum { module, name } => crate::query::JsonMemberKind::Enum {
2798                enum_type: format!("{}::{}", module, name),
2799            },
2800            TupleMemberKind::NamedTuple { module, name } => {
2801                let qname = format!("{}::{}", module, name);
2802                let nested_members = self
2803                    .resolve_named_tuple(&qname)
2804                    .map(|nt| {
2805                        nt.members
2806                            .iter()
2807                            .map(|mm| self.tuple_member_to_json_member(mm))
2808                            .collect()
2809                    })
2810                    .unwrap_or_default();
2811                crate::query::JsonMemberKind::Tuple {
2812                    type_name: Some(qname),
2813                    members: nested_members,
2814                }
2815            }
2816            TupleMemberKind::Tuple { members } => crate::query::JsonMemberKind::Tuple {
2817                type_name: None,
2818                members: members.iter().map(|mm| self.tuple_member_to_json_member(mm)).collect(),
2819            },
2820        };
2821        crate::query::JsonMember {
2822            key: m.name.clone(),
2823            kind,
2824        }
2825    }
2826
2827    /// Convert a parsed cast-target `ast::TupleTypeElement` into a decode-time
2828    /// `JsonMember` — the cast-syntax counterpart of `tuple_member_to_json_member`.
2829    fn ast_tuple_element_to_json_member(&self, elem: &ast::TupleTypeElement) -> crate::query::JsonMember {
2830        crate::query::JsonMember {
2831            key: elem.name.clone(),
2832            kind: self.ast_type_expr_to_json_member_kind(&elem.ty),
2833        }
2834    }
2835
2836    fn ast_type_expr_to_json_member_kind(&self, ty: &ast::TypeExpr) -> crate::query::JsonMemberKind {
2837        if let ast::TypeExpr::Tuple { elements } = ty {
2838            return crate::query::JsonMemberKind::Tuple {
2839                type_name: None,
2840                members: elements
2841                    .iter()
2842                    .map(|e| self.ast_tuple_element_to_json_member(e))
2843                    .collect(),
2844            };
2845        }
2846        let Some((module, name)) = ty.as_named() else {
2847            return crate::query::JsonMemberKind::Scalar;
2848        };
2849        let qname = match module {
2850            Some(m) => format!("{}::{}", m, name),
2851            None => name.to_string(),
2852        };
2853        if let Some(ed) = self.resolve_enum(&qname) {
2854            return crate::query::JsonMemberKind::Enum {
2855                enum_type: format!("{}::{}", ed.module, ed.name),
2856            };
2857        }
2858        if let Some(nt) = self.resolve_named_tuple(&qname) {
2859            return crate::query::JsonMemberKind::Tuple {
2860                type_name: Some(format!("{}::{}", nt.module, nt.name)),
2861                members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
2862            };
2863        }
2864        crate::query::JsonMemberKind::Scalar
2865    }
2866
2867    /// Resolve a cast's own target-type shape for decode-time `ShapeNode`
2868    /// building — a structural `tuple<...>` cast resolves its elements
2869    /// directly; a nominal `<module::Name>` cast resolves via the registered
2870    /// `NamedTupleDescriptor`'s members. `None` for a plain scalar/enum
2871    /// cast target.
2872    fn resolve_tuple_cast_shape(&self, ty: &ast::TypeExpr) -> Option<TupleCastShape> {
2873        match ty {
2874            ast::TypeExpr::Tuple { elements } => Some(TupleCastShape {
2875                type_name: None,
2876                members: elements
2877                    .iter()
2878                    .map(|e| self.ast_tuple_element_to_json_member(e))
2879                    .collect(),
2880            }),
2881            _ => {
2882                let (module, name) = ty.as_named()?;
2883                let qname = match module {
2884                    Some(m) => format!("{}::{}", m, name),
2885                    None => name.to_string(),
2886                };
2887                let nt = self.resolve_named_tuple(&qname)?;
2888                Some(TupleCastShape {
2889                    type_name: Some(format!("{}::{}", nt.module, nt.name)),
2890                    members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
2891                })
2892            }
2893        }
2894    }
2895
2896    /// Render a cast target `TypeExpr` for error messages, e.g.
2897    /// `tuple<std::int64, std::str>` or `default::Point` — used by the
2898    /// compile-time tuple-index bounds check.
2899    fn type_expr_to_display_str(&self, ty: &ast::TypeExpr) -> String {
2900        match ty {
2901            ast::TypeExpr::Tuple { elements } => {
2902                let inner = elements
2903                    .iter()
2904                    .map(|e| match &e.name {
2905                        Some(n) => format!("{}: {}", n, self.type_expr_to_display_str(&e.ty)),
2906                        None => self.type_expr_to_display_str(&e.ty),
2907                    })
2908                    .collect::<Vec<_>>()
2909                    .join(", ");
2910                format!("tuple<{}>", inner)
2911            }
2912            ast::TypeExpr::Array { element } => format!("array<{}>", self.type_expr_to_display_str(element)),
2913            ast::TypeExpr::Named { module, name } => match module {
2914                Some(m) => format!("{}::{}", m, name),
2915                None => type_expr_to_pg(ty)
2916                    .map(|pg| pg_type_to_pyql(&pg).to_string())
2917                    .unwrap_or_else(|_| name.clone()),
2918            },
2919        }
2920    }
2921
2922    /// Resolve a property's tuple-type shape for decode-time `ShapeNode`
2923    /// building — a nominal `__nt__:module::Name` `pg_type` marker resolves
2924    /// via the registered `NamedTupleDescriptor`'s own members; a structural
2925    /// `pylon.Tuple[...]` property carries its own `tuple_members` directly.
2926    fn resolve_property_tuple_shape(&self, prop: &PropertyDescriptor) -> Option<TupleCastShape> {
2927        if let Some(qname) = prop.pg_type.strip_prefix("__nt__:") {
2928            let nt = self.resolve_named_tuple(qname)?;
2929            return Some(TupleCastShape {
2930                type_name: Some(qname.to_string()),
2931                members: nt.members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
2932            });
2933        }
2934        prop.tuple_members.as_ref().map(|members| TupleCastShape {
2935            type_name: None,
2936            members: members.iter().map(|m| self.tuple_member_to_json_member(m)).collect(),
2937        })
2938    }
2939
2940    /// Emit a bare `<pg_type>expr` scalar cast as a free-select statement — shared
2941    /// by the enum/named-tuple/structural-tuple cast cases in `compile_stmt`'s
2942    /// top-level `Expr::TypeCast` handling. For a structural tuple cast whose
2943    /// source is itself a tuple/named-tuple literal, applies each element's
2944    /// own cast by position (see `try_compile_tuple_literal_cast_ctx`)
2945    /// instead of jsonb-wrapping the raw uncast literal values.
2946    fn scalar_cast_free_select(
2947        &mut self,
2948        tc: &ast::TypeCast,
2949        pg_type: String,
2950        distinct: bool,
2951    ) -> Result<IrStmt, PyQLError> {
2952        let cast_expr = match &tc.ty {
2953            ast::TypeExpr::Tuple { elements } => {
2954                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
2955                match self.try_compile_tuple_literal_cast_ctx(elements, &tc.expr, None)? {
2956                    // The literal-decompose path already applies each element's own
2957                    // cast — still wrap in TypeCast so `tuple_shape` reaches SQL
2958                    // emission for decode-time ShapeNode building (jsonb_build_*
2959                    // already produces jsonb, so the outer `::jsonb` is a no-op).
2960                    Some(ir) => IrExpr::TypeCast(Box::new(IrTypeCast {
2961                        expr: ir,
2962                        pg_type,
2963                        tuple_shape,
2964                    })),
2965                    None => {
2966                        let inner = self.compile_expr_ctx(&tc.expr, None)?;
2967                        IrExpr::TypeCast(Box::new(IrTypeCast {
2968                            expr: inner,
2969                            pg_type,
2970                            tuple_shape,
2971                        }))
2972                    }
2973                }
2974            }
2975            _ => {
2976                let inner = self.compile_expr_ctx(&tc.expr, None)?;
2977                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
2978                IrExpr::TypeCast(Box::new(IrTypeCast {
2979                    expr: inner,
2980                    pg_type,
2981                    tuple_shape,
2982                }))
2983            }
2984        };
2985        Ok(IrStmt::Select(IrSelect {
2986            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(cast_expr))],
2987            filter: None,
2988            order_by: vec![],
2989            offset: None,
2990            limit: None,
2991            distinct,
2992            dml_source: None,
2993            polymorphic: false,
2994            poly_implementors: vec![],
2995            poly_columns: vec![],
2996            lock: None,
2997        }))
2998    }
2999
3000    /// Resolve a cast's target `pg_type` string — shared by both `Expr::TypeCast`
3001    /// compile sites (`compile_free_expr`/`compile_expr`). A structural tuple
3002    /// always resolves to jsonb; an array resolves to its element's own pg_type
3003    /// with a `[]` suffix — a real Postgres array, not jsonb, so it decodes
3004    /// natively (the driver already returns a Python list) with no per-member
3005    /// shape-tracking needed the way tuples require; a named type checks enum,
3006    /// then registered named tuple, then falls back to the built-in
3007    /// scalar/pgvector/cal type list.
3008    fn resolve_cast_pg_type(&self, ty: &ast::TypeExpr) -> Result<String, PyQLError> {
3009        if matches!(ty, ast::TypeExpr::Tuple { .. }) {
3010            return Ok("jsonb".to_string());
3011        }
3012        if let ast::TypeExpr::Array { element } = ty {
3013            let element_pg = self.resolve_cast_pg_type(element)?;
3014            return Ok(format!("{}[]", element_pg));
3015        }
3016        let (module, name) = ty.as_named().expect("checked above: not Tuple/Array");
3017        let qname = match module {
3018            Some(m) => format!("{}::{}", m, name),
3019            None => name.to_string(),
3020        };
3021        if let Some(ed) = self.resolve_enum(&qname) {
3022            return Ok(format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name));
3023        }
3024        if self.resolve_named_tuple(&qname).is_some() {
3025            return Ok("jsonb".to_string());
3026        }
3027        // A registered custom scalar casts directly to its own DOMAIN (not
3028        // just its base type) — Postgres enforces the domain's CHECK right
3029        // at cast time, the same way it would on column assignment (see
3030        // `PropertyDescriptor.column_type`'s own doc comment for why this
3031        // is safe to do everywhere else too).
3032        if let Some(sd) = self.resolve_scalar(&qname) {
3033            return Ok(format!("{}.\"{}\"", crate::sql::pg_schema_str(&sd.module), sd.name));
3034        }
3035        type_expr_to_pg(ty)
3036    }
3037
3038    /// Cast one tuple-type element's source value to `target_ty` — recurses
3039    /// via `try_compile_tuple_literal_cast_ctx` when both the element's own
3040    /// type and its source value are themselves a nested tuple/named-tuple
3041    /// literal, so nesting applies per-element casts all the way down;
3042    /// otherwise a plain scalar/enum/nominal-named-tuple cast.
3043    fn compile_tuple_element_cast_ctx(
3044        &mut self,
3045        target_ty: &ast::TypeExpr,
3046        value: &Expr,
3047        ctx: Option<(&TypeDescriptor, &str)>,
3048    ) -> Result<IrExpr, PyQLError> {
3049        if let ast::TypeExpr::Tuple { elements } = target_ty
3050            && let Some(ir) = self.try_compile_tuple_literal_cast_ctx(elements, value, ctx)?
3051        {
3052            return Ok(ir);
3053        }
3054        let inner = self.compile_expr_ctx(value, ctx)?;
3055        let pg_type = self.resolve_cast_pg_type(target_ty)?;
3056        Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
3057            expr: inner,
3058            pg_type,
3059            tuple_shape: None,
3060        })))
3061    }
3062
3063    /// When casting a tuple/named-tuple *literal* to a structural tuple type,
3064    /// apply each target element's own cast to its corresponding source value
3065    /// by position — e.g. `<tuple<int64, str>>('1', 3)` must coerce '1' to
3066    /// int64 and 3 to str, not just jsonb-wrap the raw literal values
3067    /// unchanged. Returns `None` when the source isn't a literal tuple/named-
3068    /// tuple of matching arity (e.g. a `$param`) — the whole value already
3069    /// arrives pre-shaped in that case, so the caller's generic jsonb-cast
3070    /// path handles it instead.
3071    fn try_compile_tuple_literal_cast_ctx(
3072        &mut self,
3073        target_elements: &[ast::TupleTypeElement],
3074        source: &Expr,
3075        ctx: Option<(&TypeDescriptor, &str)>,
3076    ) -> Result<Option<IrExpr>, PyQLError> {
3077        let source_values: Vec<&Expr> = match source {
3078            Expr::Tuple(vals) if vals.len() == target_elements.len() => vals.iter().collect(),
3079            Expr::NamedTuple(fields) if fields.len() == target_elements.len() => {
3080                fields.iter().map(|(_, v)| v).collect()
3081            }
3082            _ => return Ok(None),
3083        };
3084        let named = target_elements.iter().all(|e| e.name.is_some());
3085        let mut casted = Vec::with_capacity(target_elements.len());
3086        for (elem, value) in target_elements.iter().zip(source_values) {
3087            casted.push(self.compile_tuple_element_cast_ctx(&elem.ty, value, ctx)?);
3088        }
3089        if named {
3090            let fields = target_elements
3091                .iter()
3092                .zip(casted)
3093                .map(|(e, v)| (e.name.clone().unwrap(), v))
3094                .collect();
3095            Ok(Some(IrExpr::NamedTuple {
3096                fields,
3097                is_free_object: false,
3098            }))
3099        } else {
3100            Ok(Some(IrExpr::Tuple(casted)))
3101        }
3102    }
3103
3104    /// When casting an array *literal* to `array<T>`, apply the element
3105    /// type's own cast to each element by position — e.g.
3106    /// `<array<int64>>['1', '3']` must coerce each string element to int64,
3107    /// not just emit a raw untyped `ARRAY[...]`. Reuses
3108    /// `compile_tuple_element_cast_ctx` for the per-element cast since
3109    /// casting "this value to this target type" is exactly the same
3110    /// operation regardless of whether the target is a tuple element or an
3111    /// array element (including decomposing a nested tuple-literal element).
3112    /// Returns `None` when the source isn't a literal array (e.g. a
3113    /// `$param` or a sub-select) — the caller's generic cast path handles
3114    /// those instead.
3115    fn try_compile_array_literal_cast_ctx(
3116        &mut self,
3117        element_ty: &ast::TypeExpr,
3118        source: &Expr,
3119        ctx: Option<(&TypeDescriptor, &str)>,
3120    ) -> Result<Option<IrExpr>, PyQLError> {
3121        let Expr::Array(elems) = source else { return Ok(None) };
3122        let casted = elems
3123            .iter()
3124            .map(|e| self.compile_tuple_element_cast_ctx(element_ty, e, ctx))
3125            .collect::<Result<Vec<_>, _>>()?;
3126        Ok(Some(IrExpr::Array(casted)))
3127    }
3128
3129    fn compile_enum_access(&self, type_ref: &str, variant: &str) -> Result<IrExpr, PyQLError> {
3130        let ed = self
3131            .resolve_enum(type_ref)
3132            .ok_or_else(|| self.type_err(&format!("unknown type '{}'", type_ref)))?;
3133        if !ed.members.iter().any(|m| m == variant) {
3134            return Err(self.type_err(&format!(
3135                "enum '{}::{}' has no member '{}'",
3136                ed.module, ed.name, variant
3137            )));
3138        }
3139        // A stdlib enum has no PostgreSQL enum type to cast to — the function
3140        // taking it switches on the label as text.
3141        let pg_type = if crate::stdlib::lookup_enum(&format!("{}::{}", ed.module, ed.name)).is_some() {
3142            "text".to_string()
3143        } else {
3144            format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name)
3145        };
3146        Ok(IrExpr::EnumLiteral {
3147            pg_type,
3148            variant: variant.to_string(),
3149        })
3150    }
3151
3152    /// The row source a name reads from: the binding it is bound to, if any,
3153    /// else the type's own table.
3154    fn row_source_table(&self, name: &str, td: &TypeDescriptor) -> String {
3155        if let Some(cte) = self.for_var_ctes.get(name) {
3156            return format!("@cte:{cte}");
3157        }
3158        if self.cte_object_type(name).is_some() {
3159            return format!("@cte:{}", self.cte_sql_name(name));
3160        }
3161        td.table.clone()
3162    }
3163
3164    /// The type a path's root names — a real type, or the object type a
3165    /// `with` binding stands for.
3166    ///
3167    /// A binding is a row source just like a type name, so anything that
3168    /// re-resolves a root after `compile_path_select` has already accepted it
3169    /// has to look it up the same way, or a bound alias reads as an unknown
3170    /// type.
3171    fn resolve_path_root(&self, root_name: &str) -> Result<&'a TypeDescriptor, PyQLError> {
3172        if let Some(qualified) = self.for_var_types.get(root_name) {
3173            return self.resolve_type(qualified);
3174        }
3175        match self.cte_types.get(root_name).filter(|t| t.contains("::")).cloned() {
3176            Some(bound) => self.resolve_type(&bound),
3177            None => self.resolve_type(root_name),
3178        }
3179    }
3180
3181    fn find_poly_implementors(&self, iface_qname: &str) -> Vec<IrPolyImplementor> {
3182        self.schema
3183            .types
3184            .iter()
3185            .filter(|t| !t.abstract_ && Self::is_or_implements(t, iface_qname))
3186            .map(|t| IrPolyImplementor {
3187                type_name: format!("{}::{}", t.module, t.name),
3188                table: t.table.clone(),
3189                module: t.module.clone(),
3190            })
3191            .collect()
3192    }
3193
3194    fn resolve_property<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t PropertyDescriptor> {
3195        td.properties.iter().find(|p| p.name == name)
3196    }
3197
3198    fn resolve_link<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t LinkDescriptor> {
3199        td.links.iter().find(|l| l.name == name)
3200    }
3201
3202    fn resolve_multilink<'t>(td: &'t TypeDescriptor, name: &str) -> Option<&'t MultiLinkDescriptor> {
3203        td.multilinks.iter().find(|m| m.name == name)
3204    }
3205
3206    /// `@prop` read off the junction row of the multi-link currently in
3207    /// scope (`link_prop_scope`). Only its own modifiers put one there —
3208    /// anywhere else there is no junction row to read.
3209    fn compile_link_prop_ref(&mut self, prop_name: &str) -> Result<IrExpr, PyQLError> {
3210        let Some(scope) = self.link_prop_scope.last().cloned() else {
3211            return Err(self.type_err(&format!(
3212                "'@{prop_name}' is a link property, so it is only valid in the modifiers or shape of the \
3213                 link it belongs to, e.g. 'locators: {{ … }} filter @{prop_name}'"
3214            )));
3215        };
3216        let Some((through_qname, junction_alias)) = scope else {
3217            return Err(self.type_err(&format!(
3218                "this link has no link properties, so '@{prop_name}' cannot be read — \
3219                 declare the link with a `Through[...]` type to give it some"
3220            )));
3221        };
3222        let through_td = self.resolve_type(&through_qname)?;
3223        let Some(prop) = Self::resolve_property(through_td, prop_name) else {
3224            return Err(self.field_err(prop_name, &through_qname));
3225        };
3226        Ok(IrExpr::ColumnRef {
3227            alias: junction_alias,
3228            column: prop.name.clone(),
3229            pg_type: prop.pg_type.clone(),
3230        })
3231    }
3232
3233    /// Whether a link declared with `target` reaches the type named
3234    /// `current_qname` — directly, or because that type implements the
3235    /// interface the link points at. A link to an interface accepts every
3236    /// implementor, so a backlink from one is exactly as valid as a backlink
3237    /// from the interface itself.
3238    fn link_target_reaches(&self, target: &str, current_qname: &str) -> bool {
3239        if target == current_qname {
3240            return true;
3241        }
3242        self.resolve_type(current_qname)
3243            .is_ok_and(|td| td.interfaces.iter().any(|i| i == target))
3244    }
3245
3246    /// A computed pointer visible on `td` — its own, or one declared on an
3247    /// interface it implements. An interface's computeds are not copied into
3248    /// its implementors the way its stored pointers are, so without the
3249    /// second lookup `.account.tier` reported that `tier` was missing while
3250    /// suggesting `tier` (the suggester already searched interfaces).
3251    fn resolve_computed(&self, td: &TypeDescriptor, name: &str) -> Option<crate::schema::ComputedDescriptor> {
3252        if let Some(cd) = td.computed.iter().find(|c| c.name == name) {
3253            return Some(cd.clone());
3254        }
3255        td.interfaces
3256            .iter()
3257            .filter_map(|iface| self.resolve_type(iface).ok())
3258            .find_map(|itd| itd.computed.iter().find(|c| c.name == name))
3259            .cloned()
3260    }
3261
3262    // ── Statement dispatch ────────────────────────────────────────────────────────
3263
3264    fn compile_stmt(&mut self, stmt: &Stmt) -> Result<IrStmt, PyQLError> {
3265        match stmt {
3266            Stmt::Select(s) => {
3267                let (distinct, result) = match &s.result {
3268                    Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => (true, &u.operand),
3269                    // `detached` marks this select as independent of the one
3270                    // enclosing it. At the top level that is already true, but
3271                    // for a select nested in a filter it is the whole point:
3272                    // it is what lets `select X filter not exists (select
3273                    // detached X filter .k = X.k)` mean "no *other* X", rather
3274                    // than comparing the inner row with itself. Stripping the
3275                    // wrapper outright made that anti-join a tautology
3276                    // (`"t1"."label" = "t1"."label"`) with no error.
3277                    Expr::Detached(inner) => {
3278                        self.pending_detached = true;
3279                        (false, inner.as_ref())
3280                    }
3281                    other => (false, other),
3282                };
3283
3284                // `select assert_distinct(f(…)) { … }` — the function's rows,
3285                // with the assert run once over all of them: the select's own
3286                // filter narrows what comes back, not what is checked.
3287                if let Expr::Shape(sh) = result
3288                    && let Some(Expr::FunctionCall(assert)) = &sh.expr
3289                    && assert.module.as_deref().is_none_or(|m| m == "std")
3290                    && matches!(assert.name.as_str(), "assert_exists" | "assert_distinct")
3291                    && let [Expr::FunctionCall(inner)] = assert.args.as_slice()
3292                    && let Some(mut fs) = self.try_compile_fn_object_select(inner, &sh.elements, s, distinct)?
3293                {
3294                    let message = self.assert_message(assert, None)?;
3295                    let unchecked = ast::SelectStmt {
3296                        result: Expr::FunctionCall(inner.clone()),
3297                        filter: None,
3298                        order_by: vec![],
3299                        offset: None,
3300                        limit: None,
3301                        lock: None,
3302                    };
3303                    let every_row = self
3304                        .try_compile_fn_object_select(inner, &[], &unchecked, false)?
3305                        .ok_or_else(|| self.type_err("assert subject is not an object-returning function"))?;
3306                    let checked = IrExpr::FunctionCall(IrFunctionCall {
3307                        return_pg_type: None,
3308                        schema: Some("_pylon".to_string()),
3309                        name: assert.name.clone(),
3310                        args: std::iter::once(IrExpr::ArrayFromSelect(Box::new(IrArraySource::StmtColumn {
3311                            stmt: Box::new(IrStmt::FunctionSelect(every_row)),
3312                            column: "id".to_string(),
3313                        })))
3314                        .chain(message)
3315                        .collect(),
3316                        sql_template: None,
3317                    });
3318                    let check = IrExpr::BinOp(Box::new(IrBinOp {
3319                        left: IrExpr::FunctionCall(IrFunctionCall {
3320                            return_pg_type: None,
3321                            schema: None,
3322                            name: "cardinality".to_string(),
3323                            args: vec![checked],
3324                            sql_template: None,
3325                        }),
3326                        op: ast::BinOpKind::Ge,
3327                        right: IrExpr::Literal(IrLiteral::Int(0)),
3328                    }));
3329                    fs.filter = and_conditions(fs.filter, vec![check]);
3330                    return Ok(IrStmt::FunctionSelect(fs));
3331                }
3332                if let Some(flattened) = flatten_shape_subject(result) {
3333                    let mut flat = s.clone();
3334                    flat.result = match distinct {
3335                        true => Expr::UnaryOp(Box::new(ast::UnaryOp {
3336                            op: ast::UnaryOpKind::Distinct,
3337                            operand: flattened,
3338                        })),
3339                        false => flattened,
3340                    };
3341                    return self.compile_stmt(&Stmt::Select(flat));
3342                }
3343                if let Expr::Shape(sh) = result
3344                    && let Some(Expr::SubQuery(inner)) = &sh.expr
3345                    && let Stmt::Group(g) = inner.as_ref()
3346                {
3347                    return self.compile_group_projection(s, g, &sh.elements).map(IrStmt::Group);
3348                }
3349
3350                // `select alias_name [{ shape }]` — inline the alias expression.
3351                if let Some(ir) = self.try_compile_alias_select(s, result, distinct)? {
3352                    return Ok(ir);
3353                }
3354
3355                // `select global name [{ shape }]` — inline the computed expression.
3356                if let Some(ir) = self.try_compile_global_select(s, result, distinct)? {
3357                    return Ok(ir);
3358                }
3359
3360                // `global name.field` / `(select Type filter ...).field` — splice the
3361                // field access onto the inner type-select as an additional path step.
3362                if let Some(ir) = self.try_compile_field_access_select(s, result)? {
3363                    return Ok(ir);
3364                }
3365
3366                // select fn() { shape } — user-defined object-returning function with shape.
3367                if let Expr::Shape(sh) = result
3368                    && let Some(Expr::FunctionCall(fc)) = sh.expr.as_ref()
3369                    && let Some(ir) = self.try_compile_fn_object_select(fc, &sh.elements, s, distinct)?
3370                {
3371                    return Ok(IrStmt::FunctionSelect(ir));
3372                }
3373
3374                // select fn() — bare user-defined object-returning function (no shape).
3375                if let Expr::FunctionCall(fc) = result
3376                    && let Some(ir) = self.try_compile_fn_object_select(fc, &[], s, distinct)?
3377                {
3378                    return Ok(IrStmt::FunctionSelect(ir));
3379                }
3380
3381                // select vector::search(Type, $vec) { object { … }, distance }
3382                if let Expr::Shape(sh) = result
3383                    && let Some(Expr::FunctionCall(fc)) = sh.expr.as_ref()
3384                {
3385                    if let Some(ir) = self.try_compile_vector_search(fc, &sh.elements, s)? {
3386                        return Ok(IrStmt::VectorSearch(ir));
3387                    }
3388                    if let Some(ir) = self.try_compile_fts_search(fc, &sh.elements, s)? {
3389                        return Ok(IrStmt::FtsSearch(ir));
3390                    }
3391                }
3392                // bare vector::search / fts::search without shape
3393                if let Expr::FunctionCall(fc) = result {
3394                    if let Some(ir) = self.try_compile_vector_search(fc, &[], s)? {
3395                        return Ok(IrStmt::VectorSearch(ir));
3396                    }
3397                    if let Some(ir) = self.try_compile_fts_search(fc, &[], s)? {
3398                        return Ok(IrStmt::FtsSearch(ir));
3399                    }
3400                }
3401
3402                // (<Module::Type>expr) { shape } — parenthesised id-lookup with shape.
3403                // The parens are stripped by the parser, leaving Shape { expr: TypeCast }.
3404                // Only meaningful for a named schema type — a structural tuple cast
3405                // never denotes an object-type lookup.
3406                if let Expr::Shape(sh) = result
3407                    && let Some(Expr::TypeCast(tc)) = sh.expr.as_ref()
3408                    && let Some((module, name)) = tc.ty.as_named()
3409                    && module
3410                        .map(|m| !["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"].contains(&m))
3411                        .unwrap_or(false)
3412                {
3413                    let id_filter = Expr::BinOp(Box::new(ast::BinOp {
3414                        left: Expr::Path(ast::Path::relative("id")),
3415                        op: ast::BinOpKind::Eq,
3416                        right: tc.expr.clone(),
3417                    }));
3418                    let merged_filter = match &s.filter {
3419                        None => Some(id_filter),
3420                        Some(existing) => Some(Expr::BinOp(Box::new(ast::BinOp {
3421                            left: id_filter,
3422                            op: ast::BinOpKind::And,
3423                            right: existing.clone(),
3424                        }))),
3425                    };
3426                    // Keep the module qualification so an unknown
3427                    // target reports its full name, not a bare one.
3428                    let qname = match module {
3429                        Some(m) => format!("{}::{}", m, name),
3430                        None => name.to_string(),
3431                    };
3432                    let synthetic = ast::SelectStmt {
3433                        result: Expr::Shape(Box::new(ast::ShapeExpr {
3434                            expr: Some(Expr::Path(ast::Path::absolute(&qname))),
3435                            elements: sh.elements.clone(),
3436                            marker_offset: None,
3437                        })),
3438                        filter: merged_filter,
3439                        order_by: s.order_by.clone(),
3440                        offset: s.offset.clone(),
3441                        limit: s.limit.clone(),
3442                        lock: s.lock.clone(),
3443                    };
3444                    return self
3445                        .compile_select(&synthetic, &synthetic.result, distinct)
3446                        .map(IrStmt::Select);
3447                }
3448                // <Module::Type>expr — schema object lookup by id, or a scalar cast
3449                // (enum, registered named tuple, or a bare structural `tuple<...>`).
3450                // Stdlib modules are handled by compile_free_expr; only user schema
3451                // modules (or a structural tuple, which has no module at all) route here.
3452                const STDLIB_MODULES: &[&str] = &["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"];
3453                if let Expr::TypeCast(tc) = result {
3454                    // A structural tuple cast is always a plain scalar (jsonb) cast —
3455                    // never an object-type lookup — so it's handled directly, before
3456                    // any of the qname-based (named-type-only) checks below.
3457                    if matches!(&tc.ty, ast::TypeExpr::Tuple { .. }) {
3458                        return self.scalar_cast_free_select(tc, "jsonb".to_string(), distinct);
3459                    }
3460                    if let Some((module, name)) = tc.ty.as_named() {
3461                        let qname = match module {
3462                            Some(m) => format!("{}::{}", m, name),
3463                            None => name.to_string(),
3464                        };
3465                        if let Some(ed) = self.resolve_enum(&qname) {
3466                            let pg_type = format!("{}.\"{}\"", crate::sql::pg_schema_str(&ed.module), ed.name);
3467                            return self.scalar_cast_free_select(tc, pg_type, distinct);
3468                        }
3469                        if self.resolve_named_tuple(&qname).is_some() {
3470                            return self.scalar_cast_free_select(tc, "jsonb".to_string(), distinct);
3471                        }
3472                        if let Some(sd) = self.resolve_scalar(&qname) {
3473                            let pg_type = format!("{}.\"{}\"", crate::sql::pg_schema_str(&sd.module), sd.name);
3474                            return self.scalar_cast_free_select(tc, pg_type, distinct);
3475                        }
3476                        if module.map(|m| !STDLIB_MODULES.contains(&m)).unwrap_or(false) {
3477                            return self.compile_schema_cast_select(s, tc).map(IrStmt::Select);
3478                        }
3479                    }
3480                }
3481                // Path traversal: `select TypeName.link.prop` or `select TypeName.link { shape }`.
3482                if let Expr::Path(p) = result {
3483                    if !p.partial
3484                        && p.steps.len() == 2
3485                        && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
3486                        && self.resolve_enum(type_ref).is_some()
3487                    {
3488                        let expr = self.compile_enum_access(type_ref, variant)?;
3489                        return Ok(IrStmt::Select(IrSelect {
3490                            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3491                            filter: None,
3492                            order_by: vec![],
3493                            offset: None,
3494                            limit: None,
3495                            distinct,
3496                            dml_source: None,
3497                            polymorphic: false,
3498                            poly_implementors: vec![],
3499                            poly_columns: vec![],
3500                            lock: None,
3501                        }));
3502                    }
3503                    // `root.field1.field2...` where `root` is a WITH-bound
3504                    // free object (not a real/CTE-bound schema type) —
3505                    // resolve to a field reference (possibly chained through
3506                    // nested free objects) instead of falling into
3507                    // compile_path_select, which only knows schema paths.
3508                    if let Some(resolved) = self.resolve_cte_path(p) {
3509                        let expr = resolved?;
3510                        return Ok(IrStmt::Select(IrSelect {
3511                            rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3512                            filter: None,
3513                            order_by: vec![],
3514                            offset: None,
3515                            limit: None,
3516                            distinct,
3517                            dml_source: None,
3518                            polymorphic: false,
3519                            poly_implementors: vec![],
3520                            poly_columns: vec![],
3521                            lock: None,
3522                        }));
3523                    }
3524                    if !p.partial && p.steps.len() > 1 {
3525                        return self.compile_path_select(s, p, &[], distinct).map(IrStmt::PathSelect);
3526                    }
3527                }
3528                if let Expr::Shape(sh) = result
3529                    && let Some(Expr::Path(p)) = sh.expr.as_ref()
3530                    && !p.partial
3531                    && p.steps.len() > 1
3532                {
3533                    return self
3534                        .compile_path_select(s, p, &sh.elements, distinct)
3535                        .map(IrStmt::PathSelect);
3536                }
3537                // assert_exists/assert_distinct with SubQuery arg → set-returning assert
3538                if let Expr::FunctionCall(f) = result
3539                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
3540                    && matches!(f.name.as_str(), "assert_exists" | "assert_distinct")
3541                    && !f.args.is_empty()
3542                    && let Expr::SubQuery(inner_stmt) = &f.args[0]
3543                {
3544                    let offset = s.offset.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
3545                    let limit = s.limit.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
3546                    let message = self.assert_message(f, None)?;
3547                    // A set of objects has to come back as rows, not as the
3548                    // bare ids the array form carries — so the assert vets the
3549                    // ids and the type's own rows are selected by them.
3550                    if let Ok(type_name) = self.dml_subject_type(inner_stmt)
3551                        && let Ok(td) = self.resolve_type(&type_name)
3552                    {
3553                        let td_module = td.module.clone();
3554                        let td_name = td.name.clone();
3555                        let td_table = td.table.clone();
3556                        let pk = td
3557                            .properties
3558                            .iter()
3559                            .find(|p| p.is_pk)
3560                            .map(|p| (p.name.clone(), p.pg_type.clone()))
3561                            .unwrap_or_else(|| ("id".to_string(), "uuid".to_string()));
3562                        let inner_ir = self.compile_stmt(inner_stmt)?;
3563                        let alias = self.fresh_alias();
3564                        let vetted = IrExpr::FunctionCall(IrFunctionCall {
3565                            return_pg_type: None,
3566                            schema: Some("_pylon".to_string()),
3567                            name: f.name.clone(),
3568                            args: std::iter::once(IrExpr::ArrayFromSelect(Box::new(IrArraySource::StmtColumn {
3569                                stmt: Box::new(inner_ir),
3570                                column: pk.0.clone(),
3571                            })))
3572                            .chain(message)
3573                            .collect(),
3574                            sql_template: None,
3575                        });
3576                        let filter = IrExpr::BinOp(Box::new(IrBinOp {
3577                            left: IrExpr::ColumnRef {
3578                                alias: alias.clone(),
3579                                column: pk.0.clone(),
3580                                pg_type: pk.1.clone(),
3581                            },
3582                            op: ast::BinOpKind::In,
3583                            right: vetted,
3584                        }));
3585                        let shape = vec![IrShapePointer::Scalar(IrScalarPointer {
3586                            implicit_id: false,
3587                            marker_offset: None,
3588                            alias: "id".to_string(),
3589                            column: pk.0,
3590                            pg_type: pk.1,
3591                            tuple_shape: None,
3592                        })];
3593                        let source = IrSource {
3594                            poly: None,
3595                            type_name: format!("{td_module}::{td_name}"),
3596                            table: td_table,
3597                            alias,
3598                        };
3599                        let mut select = IrSelect::schema_bound(source, shape, Some(filter));
3600                        select.offset = offset;
3601                        select.limit = limit;
3602                        select.distinct = distinct;
3603                        return Ok(IrStmt::Select(select));
3604                    }
3605                    let inner = self.compile_subquery_to_array_source(inner_stmt)?;
3606                    return Ok(IrStmt::Select(IrSelect {
3607                        rows: vec![IrRowSource::Free(IrFreeExpr::AssertSet {
3608                            fn_name: f.name.clone(),
3609                            inner: Box::new(inner),
3610                            message,
3611                        })],
3612                        filter: None,
3613                        order_by: vec![],
3614                        offset,
3615                        limit,
3616                        distinct,
3617                        dml_source: None,
3618                        polymorphic: false,
3619                        poly_implementors: vec![],
3620                        poly_columns: vec![],
3621                        lock: None,
3622                    }));
3623                }
3624                // `array_agg(array_unpack(teams.permissions))` — the unpacking
3625                // has to happen in a row source, so the aggregate is left with
3626                // no walk of its own and the select stands free.
3627                if let Some(expr) = self.aggregate_over_unpacked(s, result, distinct)? {
3628                    return Ok(IrStmt::Select(IrSelect {
3629                        rows: vec![IrRowSource::Free(IrFreeExpr::Scalar(expr))],
3630                        filter: None,
3631                        order_by: vec![],
3632                        offset: None,
3633                        limit: None,
3634                        distinct: false,
3635                        dml_source: None,
3636                        polymorphic: false,
3637                        poly_implementors: vec![],
3638                        poly_columns: vec![],
3639                        lock: None,
3640                    }));
3641                }
3642                // Expression containing a type-rooted path: `select fn(TypeName.link.prop, ...)`.
3643                if let Some(root) = self.find_path_root_in_expr(result) {
3644                    return self
3645                        .compile_expr_as_path_select(s, result, &root, distinct)
3646                        .map(IrStmt::PathSelect);
3647                }
3648                // `select TypeName is CheckType` — iterate source type, return bool per row.
3649                if let Expr::TypeIs { expr, ty } = result
3650                    && let Expr::Path(p) = expr.as_ref()
3651                    && !p.partial
3652                    && p.steps.len() == 1
3653                    && let ast::PathStep::Name(src_name) = &p.steps[0]
3654                    && self.resolve_name_ref(src_name, true).is_none()
3655                {
3656                    let src_td = self.resolve_type(src_name)?;
3657                    {
3658                        let src_qname = format!("{}::{}", src_td.module, src_td.name);
3659                        let (ty_module, ty_name) = ty
3660                            .as_named()
3661                            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
3662                        let check_module = ty_module.unwrap_or(&src_td.module);
3663                        let check_name = format!("{}::{}", check_module, ty_name);
3664                        self.resolve_type(&check_name)?;
3665                        let check_qname = check_name;
3666                        let src_table = src_td.table.clone();
3667                        let src_alias = self.fresh_alias();
3668                        let src_td = self.resolve_type(&src_qname)?;
3669                        let poly_implementors = if self.is_polymorphic(src_td) {
3670                            self.find_poly_implementors(&src_qname)
3671                        } else {
3672                            vec![]
3673                        };
3674                        let bool_expr = self.type_check_bool_expr(&src_qname, &check_qname, src_td, &src_alias);
3675                        let ps = IrPathSelect {
3676                            root: IrSource {
3677                                poly: None,
3678                                type_name: src_qname,
3679                                table: src_table,
3680                                alias: src_alias,
3681                            },
3682                            joins: vec![],
3683                            result: IrPathResult::Scalar(bool_expr, None),
3684                            filter: s
3685                                .filter
3686                                .as_ref()
3687                                .map(|_| Err(self.type_err("FILTER is not supported on type-is SELECT")))
3688                                .transpose()?,
3689                            order_by: vec![],
3690                            offset: None,
3691                            limit: None,
3692                            distinct,
3693                            poly_implementors,
3694                        };
3695                        return Ok(IrStmt::PathSelect(ps));
3696                    }
3697                }
3698                if let Expr::Union(a, b) = result
3699                    && !distinct
3700                    && s.filter.is_none()
3701                    && s.order_by.is_empty()
3702                    && s.offset.is_none()
3703                    && s.limit.is_none()
3704                    && (self.is_scalar_walk(a) || self.is_scalar_walk(b))
3705                {
3706                    return self.compile_scalar_union(result);
3707                }
3708                // `select (l.addon { c := … }).c` — the values, one row each.
3709                if !distinct
3710                    && s.filter.is_none()
3711                    && s.order_by.is_empty()
3712                    && s.offset.is_none()
3713                    && s.limit.is_none()
3714                    && let Some(ps) = self.compile_shape_field_select(result, None)?
3715                {
3716                    return Ok(IrStmt::PathSelect(ps));
3717                }
3718                // `select (update T … ).link { … }` — the subject walks off a
3719                // mutation. Hoist the mutation into the statement's own WITH
3720                // and re-root the walk at that binding, which is the
3721                // `with a := (update …) select a.link { … }` spelling that
3722                // already compiles. A walk off a plain sub-select needs none
3723                // of this.
3724                if let Expr::Shape(sh) = result
3725                    && let Some(subject) = sh.expr.as_ref()
3726                    && !matches!(subject, Expr::Path(_))
3727                {
3728                    let (base, fields) = Self::peel_field_access_chain(subject);
3729                    if let Expr::SubQuery(inner_stmt) = base
3730                        && !fields.is_empty()
3731                        && matches!(inner_stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
3732                    {
3733                        let inner = self.compile_stmt(inner_stmt)?;
3734                        let cte_name = self.fresh_nested_cte_name();
3735                        // The walk may read a junction this very mutation
3736                        // appended to; those rows live only in the append CTE.
3737                        let overrides = Self::junction_overrides_for(&inner, &cte_name);
3738                        let type_name = self.register_cte(&cte_name, &inner);
3739                        self.hoisted_ctes.push(IrCteDef {
3740                            name: cte_name.clone(),
3741                            stmt: inner,
3742                            type_name,
3743                            correlated_to: None,
3744                        });
3745                        let mut steps = vec![ast::PathStep::Name(cte_name)];
3746                        steps.extend(fields.into_iter().map(ast::PathStep::Name));
3747                        let rerooted = ast::SelectStmt {
3748                            result: Expr::Shape(Box::new(ast::ShapeExpr {
3749                                expr: Some(Expr::Path(ast::Path { steps, partial: false })),
3750                                elements: sh.elements.clone(),
3751                                marker_offset: None,
3752                            })),
3753                            filter: s.filter.clone(),
3754                            order_by: s.order_by.clone(),
3755                            offset: s.offset.clone(),
3756                            limit: s.limit.clone(),
3757                            lock: s.lock.clone(),
3758                        };
3759                        let previous = std::mem::replace(&mut self.junction_read_overrides, overrides);
3760                        let compiled = self.compile_stmt(&Stmt::Select(rerooted));
3761                        self.junction_read_overrides = previous;
3762                        return compiled;
3763                    }
3764                }
3765                // `select (select (A union B) { … } limit 1) { … }` — a nested
3766                // select whose own subject is a union has no single type name
3767                // for the outer select to take. Hoisting it into a binding and
3768                // reading that back is the `with l := (select …) select l { … }`
3769                // spelling, which already compiles.
3770                if let Expr::Shape(sh) = result
3771                    && let Some(Expr::SubQuery(inner_stmt)) = sh.expr.as_ref()
3772                    && matches!(inner_stmt.as_ref(), Stmt::Select(_))
3773                    && self.dml_subject_type(inner_stmt).is_err()
3774                {
3775                    let inner = self.compile_stmt(inner_stmt)?;
3776                    let cte_name = self.fresh_nested_cte_name();
3777                    let type_name = self.register_cte(&cte_name, &inner);
3778                    self.hoisted_ctes.push(IrCteDef {
3779                        name: cte_name.clone(),
3780                        stmt: inner,
3781                        type_name,
3782                        correlated_to: None,
3783                    });
3784                    let rerooted = ast::SelectStmt {
3785                        result: Expr::Shape(Box::new(ast::ShapeExpr {
3786                            expr: Some(Expr::Path(ast::Path {
3787                                steps: vec![ast::PathStep::Name(cte_name)],
3788                                partial: false,
3789                            })),
3790                            elements: sh.elements.clone(),
3791                            marker_offset: None,
3792                        })),
3793                        filter: s.filter.clone(),
3794                        order_by: s.order_by.clone(),
3795                        offset: s.offset.clone(),
3796                        limit: s.limit.clone(),
3797                        lock: s.lock.clone(),
3798                    };
3799                    return self.compile_stmt(&Stmt::Select(rerooted));
3800                }
3801                // `select (for … union …)` — the select adds nothing the loop has
3802                // not already produced, so it *is* the loop. Written with
3803                // modifiers of its own it would need the loop bound in a CTE
3804                // first, which nothing supports yet, so that still errors.
3805                if let Expr::SubQuery(inner) = result
3806                    && matches!(inner.as_ref(), Stmt::For(_))
3807                    && !distinct
3808                    && s.filter.is_none()
3809                    && s.order_by.is_empty()
3810                    && s.offset.is_none()
3811                    && s.limit.is_none()
3812                {
3813                    return self.compile_stmt(inner);
3814                }
3815                // Catch mixed object/scalar UNION before dispatching further.
3816                self.check_union_type_compat(result)?;
3817                if self.is_free_result(result) {
3818                    self.compile_free_select(s, result, distinct).map(IrStmt::Select)
3819                } else {
3820                    self.compile_select(s, result, distinct).map(IrStmt::Select)
3821                }
3822            }
3823            Stmt::Insert(s) => self.compile_insert(s).map(IrStmt::Insert),
3824            Stmt::Update(s) => self.compile_update(s).map(IrStmt::Update),
3825            Stmt::Delete(s) => self.compile_delete(s).map(IrStmt::Delete),
3826            Stmt::Group(s) => self.compile_group(s).map(IrStmt::Group),
3827            // Nested WITH blocks (e.g. inside a subquery): inline the CTE types
3828            // into the current compiler scope so references resolve correctly.
3829            // The actual CTE SQL is handled at the top-level compile() boundary.
3830            Stmt::With(w) => {
3831                for alias in &w.aliases {
3832                    if self.bind_inline_if_correlated(&alias.name, &alias.expr, None)?
3833                        || self.bind_group(&alias.name, &alias.expr)?
3834                    {
3835                        continue;
3836                    }
3837                    let (ir_inner, correlated_to) = self.compile_binding_in_scope(&alias.expr)?;
3838                    let sql_name = self.claim_cte_sql_name(&alias.name);
3839                    let type_name = self.register_cte(&alias.name, &ir_inner);
3840                    self.hoisted_binding_sources
3841                        .insert(sql_name.clone(), alias.expr.clone());
3842                    self.hoisted_ctes.push(IrCteDef {
3843                        name: sql_name,
3844                        stmt: ir_inner,
3845                        type_name,
3846                        correlated_to,
3847                    });
3848                }
3849                self.compile_stmt(&w.stmt)
3850            }
3851            Stmt::For(f) => match self.compile_group_elements(f)? {
3852                Some(grp) => Ok(IrStmt::Group(grp)),
3853                None => self.compile_for(f).map(IrStmt::For),
3854            },
3855            // `analyze` only changes how the query is *executed* (see
3856            // `analyze.rs`) — the inner statement's IR is identical either
3857            // way, so this layer just unwraps and compiles it normally.
3858            Stmt::Analyze(inner) => self.compile_stmt(inner),
3859        }
3860    }
3861
3862    /// `<Module::Type>expr` in SELECT position is a schema object lookup:
3863    /// select the object whose `id` equals `expr`.  Semantically identical to
3864    /// `SELECT Type FILTER .id = expr` plus any modifiers on the outer SELECT.
3865    fn compile_schema_cast_select(&mut self, sel: &ast::SelectStmt, tc: &ast::TypeCast) -> Result<IrSelect, PyQLError> {
3866        let id_filter = Expr::BinOp(Box::new(ast::BinOp {
3867            left: Expr::Path(ast::Path::relative("id")),
3868            op: ast::BinOpKind::Eq,
3869            right: tc.expr.clone(),
3870        }));
3871        let merged_filter = match &sel.filter {
3872            None => Some(id_filter),
3873            Some(existing) => Some(Expr::BinOp(Box::new(ast::BinOp {
3874                left: id_filter,
3875                op: ast::BinOpKind::And,
3876                right: existing.clone(),
3877            }))),
3878        };
3879        // Callers only reach this function after confirming `tc.ty` is `Named`
3880        // (a structural tuple is never an object-type lookup).
3881        let (module, name) = tc
3882            .ty
3883            .as_named()
3884            .ok_or_else(|| self.type_err("cannot use a tuple or array type as a schema object cast"))?;
3885        // Keep the module qualification in the synthetic path so a genuinely
3886        // unknown target (neither an object type, enum, scalar, nor named
3887        // tuple) reports its full name via `resolve_type`'s own error,
3888        // instead of silently dropping the module and reporting a bare name.
3889        let qname = match module {
3890            Some(m) => format!("{}::{}", m, name),
3891            None => name.to_string(),
3892        };
3893        let synthetic = ast::SelectStmt {
3894            result: Expr::Path(ast::Path::absolute(&qname)),
3895            filter: merged_filter,
3896            order_by: sel.order_by.clone(),
3897            offset: sel.offset.clone(),
3898            limit: sel.limit.clone(),
3899            lock: sel.lock.clone(),
3900        };
3901        self.compile_select(&synthetic, &synthetic.result, false)
3902    }
3903
3904    // ── PATH SELECT ───────────────────────────────────────────────────────────────
3905
3906    /// `select TypeName.link.prop` / `select TypeName.link { shape }`.
3907    /// Walks the path, building JOIN steps, then projects the final pointer or object.
3908    /// `compile_shape` with the type the walk landed on anchored, so a
3909    /// relative path written inside the shape — a `with` binding's value, a
3910    /// nested sub-select's filter — has an enclosing object to resolve
3911    /// against. A plain `select` pushes the same anchor for its own shape.
3912    fn compile_shape_anchored(
3913        &mut self,
3914        elements: &[ShapeElement],
3915        td: &'a TypeDescriptor,
3916        alias: &str,
3917        module: &str,
3918    ) -> Result<Vec<IrShapePointer>, PyQLError> {
3919        self.anchors.push(SelectAnchor {
3920            type_name: td.name.clone(),
3921            qualified: format!("{}::{}", td.module, td.name),
3922            alias: alias.to_string(),
3923            detached: false,
3924            declared_on: None,
3925        });
3926        let result = self.compile_shape(elements, td, alias, module);
3927        self.anchors.pop();
3928        result
3929    }
3930
3931    fn compile_path_select(
3932        &mut self,
3933        sel: &ast::SelectStmt,
3934        path: &ast::Path,
3935        shape_elements: &[ShapeElement],
3936        distinct: bool,
3937    ) -> Result<IrPathSelect, PyQLError> {
3938        self.compile_path_select_with_tail(sel, path, shape_elements, distinct, 0, &[])
3939    }
3940
3941    /// `compile_path_select` where the last `tail` steps were appended by a
3942    /// projection off a sub-select — `(select … limit 1).plan.tier` — so
3943    /// `sel`'s own modifiers belong to the step before them, not to the end
3944    /// of the path.
3945    fn compile_path_select_with_tail(
3946        &mut self,
3947        sel: &ast::SelectStmt,
3948        path: &ast::Path,
3949        shape_elements: &[ShapeElement],
3950        distinct: bool,
3951        tail: usize,
3952        tail_sorts: &[ast::SortExpr],
3953    ) -> Result<IrPathSelect, PyQLError> {
3954        let outer_anchor = self.modifier_anchor.take();
3955        // Swapped rather than assigned, so a path select compiled inside this
3956        // one gets its own (empty) list and hands these back untouched.
3957        let outer_sorts = std::mem::replace(&mut self.tail_sorts, tail_sorts.to_vec());
3958        let mut result = self.compile_path_select_inner(sel, path, shape_elements, distinct, tail);
3959        self.tail_sorts = outer_sorts;
3960        self.modifier_anchor = outer_anchor;
3961        if let Ok(path_select) = &mut result {
3962            self.resolve_join_fanouts(path_select);
3963        }
3964        result
3965    }
3966
3967    fn compile_path_select_inner(
3968        &mut self,
3969        sel: &ast::SelectStmt,
3970        path: &ast::Path,
3971        shape_elements: &[ShapeElement],
3972        distinct: bool,
3973        mut tail: usize,
3974    ) -> Result<IrPathSelect, PyQLError> {
3975        use ast::PathStep;
3976
3977        let root_name = match &path.steps[0] {
3978            PathStep::Name(n) => n.as_str(),
3979            _ => return Err(self.type_err("path traversal must start with a type name")),
3980        };
3981        // A WITH-block CTE bound to an object type (e.g. `with user :=
3982        // (select global current_user) select user.gender`) can be
3983        // traversed just like a real type name — resolve its underlying
3984        // type and source from the `@cte:` sentinel table (the same
3985        // mechanism `compile_select`'s bare-CTE-object-select case uses)
3986        // instead of failing with "unknown type '{root_name}'".
3987        let root_td = self.resolve_path_root(root_name)?;
3988        let root_alias = self.fresh_alias();
3989        let root = IrSource {
3990            poly: None,
3991            type_name: format!("{}::{}", root_td.module, root_td.name),
3992            table: self.row_source_table(root_name, root_td),
3993            alias: root_alias.clone(),
3994        };
3995        // `for c in (select Person) union (select c.name)` — the loop variable
3996        // holds the row's key, so the walk starts from that row rather than
3997        // from the whole table.
3998        let for_var_root = self.for_var_types.contains_key(root_name).then(|| {
3999            IrExpr::BinOp(Box::new(IrBinOp {
4000                left: IrExpr::ColumnRef {
4001                    alias: root_alias.clone(),
4002                    column: "id".to_string(),
4003                    pg_type: "uuid".to_string(),
4004                },
4005                op: ast::BinOpKind::Eq,
4006                right: self.for_var_ref(root_name),
4007            }))
4008        });
4009
4010        let mut joins: Vec<IrPathJoin> = vec![];
4011        let mut current_td = root_td;
4012        let mut current_alias = root_alias;
4013
4014        // Owned rather than borrowed: traversing *through* a computed
4015        // pointer splices that computed's own path in place of the step
4016        // naming it (see the computed branch at the bottom of the loop).
4017        let mut steps: Vec<PathStep> = path.steps[1..].to_vec();
4018        // `(step index, filter)` a spliced computed contributed, to compile
4019        // against the type that step lands on once the loop reaches it.
4020        let mut pending_filters: Vec<(usize, Expr)> = vec![];
4021        let mut extra_conditions: Vec<IrExpr> = for_var_root.into_iter().collect();
4022        // The junction the traversal last crossed, for a bare `@prop` in the
4023        // select's own modifiers.
4024        let mut junction_scope: Option<(String, String)> = None;
4025        let mut splices = 0usize;
4026        let mut idx = 0;
4027        if tail > 0 && tail >= steps.len() {
4028            self.modifier_anchor = Some((
4029                format!("{}::{}", current_td.module, current_td.name),
4030                current_alias.clone(),
4031            ));
4032        }
4033        while idx < steps.len() {
4034            let owned_step = steps[idx].clone();
4035            let step = &owned_step;
4036            let n_steps = steps.len();
4037            let is_last = |extra: usize| idx + extra == n_steps - 1;
4038            if tail > 0 && idx == n_steps - tail {
4039                self.modifier_anchor = Some((
4040                    format!("{}::{}", current_td.module, current_td.name),
4041                    current_alias.clone(),
4042                ));
4043            }
4044
4045            // A spliced computed's own filter belongs to the step it landed
4046            // on, which is the one just processed.
4047            while let Some(pos) = pending_filters.iter().position(|(i, _)| *i + 1 == idx) {
4048                let (_, f) = pending_filters.remove(pos);
4049                let cond = self.compile_expr(&f, current_td, &current_alias)?;
4050                extra_conditions.push(cond);
4051            }
4052
4053            // Type intersection standalone (not after backlink): narrows current_td.
4054            if let PathStep::TypeIntersection(type_ref) = step {
4055                let type_name = match &type_ref.module {
4056                    Some(m) => format!("{}::{}", m, type_ref.name),
4057                    None => type_ref.name.clone(),
4058                };
4059                let narrowed = self.resolve_type(&type_name)?;
4060                // Narrowing to a type with a relation of its own moves to that
4061                // relation, joined on the shared id: an interface's view has
4062                // none of its implementors' own columns, and two unrelated
4063                // types share no ids at all, so the join finds nothing — which
4064                // is exactly what an impossible intersection yields. A mixin
4065                // has no relation; its columns are already on the current row.
4066                let narrowed_has_relation = !Self::backs_no_relation(narrowed);
4067                if narrowed_has_relation && narrowed.table != current_td.table {
4068                    let target_alias = self.fresh_alias();
4069                    let target = IrSource {
4070                        poly: None,
4071                        type_name: format!("{}::{}", narrowed.module, narrowed.name),
4072                        table: narrowed.table.clone(),
4073                        alias: target_alias.clone(),
4074                    };
4075                    joins.push(IrPathJoin::Single {
4076                        source_alias: current_alias.clone(),
4077                        fk_col: "id".to_string(),
4078                        target,
4079                    });
4080                    current_alias = target_alias;
4081                }
4082                current_td = narrowed;
4083                idx += 1;
4084                continue;
4085            }
4086
4087            // Backlink: .<link_name — optionally followed by [is OwnerType].
4088            if let PathStep::Backlink(link_name) = step {
4089                // Peek ahead: if the next step is [is Type], use it to identify the owner.
4090                let owner_hint = steps.get(idx + 1).and_then(|s| {
4091                    if let PathStep::TypeIntersection(tr) = s {
4092                        Some(tr.clone())
4093                    } else {
4094                        None
4095                    }
4096                });
4097                let consumed_extra = if owner_hint.is_some() { 1 } else { 0 };
4098
4099                let owner_td: &TypeDescriptor = if let Some(ref tr) = owner_hint {
4100                    let type_name = match &tr.module {
4101                        Some(m) => format!("{}::{}", m, tr.name),
4102                        None => tr.name.clone(),
4103                    };
4104                    let td = self.resolve_type(&type_name)?;
4105                    let current_qname = format!("{}::{}", current_td.module, current_td.name);
4106                    let link_targets_current =
4107                        td.links
4108                            .iter()
4109                            .any(|l| l.name == *link_name && self.link_target_reaches(&l.target, &current_qname))
4110                            || td.multilinks.iter().any(|ml| {
4111                                ml.name == *link_name && self.link_target_reaches(&ml.target, &current_qname)
4112                            });
4113                    if link_targets_current {
4114                        td
4115                    } else {
4116                        // `.<memberships[is account::Account]` — declared further
4117                        // down, by the one type under the narrowing that has it.
4118                        let narrowed = format!("{}::{}", td.module, td.name);
4119                        let declaring: Vec<&'a TypeDescriptor> = self
4120                            .schema
4121                            .types
4122                            .iter()
4123                            .filter(|t| !t.abstract_ && Self::is_or_implements(t, &narrowed))
4124                            .filter(|t| self.declares_backlink(t, link_name, &current_qname))
4125                            .collect();
4126                        match Self::without_inherited_owners(declaring).as_slice() {
4127                            [only] => only,
4128                            _ => {
4129                                return Err(self.type_err(&format!(
4130                                    "link '{}::{}' does not target '{}'; backlink is not valid here",
4131                                    type_name, link_name, current_qname,
4132                                )));
4133                            }
4134                        }
4135                    }
4136                } else {
4137                    // No hint — search for any type whose link/multilink targets current_td.
4138                    let current_qname = format!("{}::{}", current_td.module, current_td.name);
4139                    self.schema
4140                        .types
4141                        .iter()
4142                        // The base, not a subtype inheriting the link from it.
4143                        .filter(|t| {
4144                            t.bases.is_empty()
4145                                || !t.bases.iter().any(|base| {
4146                                    self.resolve_type(base).is_ok_and(|b| {
4147                                        b.links.iter().any(|l| l.name == *link_name)
4148                                            || b.multilinks.iter().any(|ml| ml.name == *link_name)
4149                                    })
4150                                })
4151                        })
4152                        .find(|t| {
4153                            t.links
4154                                .iter()
4155                                .any(|l| l.name == *link_name && self.link_target_reaches(&l.target, &current_qname))
4156                                || t.multilinks.iter().any(|ml| {
4157                                    ml.name == *link_name && self.link_target_reaches(&ml.target, &current_qname)
4158                                })
4159                        })
4160                        .ok_or_else(|| {
4161                            self.type_err(&format!(
4162                                "no type has a link '{}' targeting '{}'",
4163                                link_name, current_qname,
4164                            ))
4165                        })?
4166                };
4167
4168                let target_alias = self.fresh_alias();
4169                let target = IrSource {
4170                    poly: None,
4171                    type_name: format!("{}::{}", owner_td.module, owner_td.name),
4172                    table: owner_td.table.clone(),
4173                    alias: target_alias.clone(),
4174                };
4175
4176                // Determine if the link is single (FK), junction-backed
4177                // single (same shape as a backlinked multi-link), or multi
4178                // (junction).
4179                if let Some(l) = owner_td.links.iter().find(|l| l.name == *link_name) {
4180                    if l.is_junction_backed() {
4181                        let junction_alias = self.fresh_alias();
4182                        let (junction_table, module, owner_col, current_col, _) =
4183                            self.link_junction_info(owner_td, l)?;
4184                        joins.push(IrPathJoin::BacklinkMulti {
4185                            source_alias: current_alias.clone(),
4186                            junction_alias,
4187                            junction_table,
4188                            module,
4189                            owner_col,
4190                            current_col,
4191                            target,
4192                        });
4193                    } else {
4194                        joins.push(IrPathJoin::BacklinkSingle {
4195                            source_alias: current_alias.clone(),
4196                            fk_col: format!("{}_id", link_name),
4197                            target,
4198                        });
4199                    }
4200                } else {
4201                    let ml = owner_td
4202                        .multilinks
4203                        .iter()
4204                        .find(|ml| ml.name == *link_name)
4205                        .unwrap()
4206                        .clone();
4207                    let junction_alias = self.fresh_alias();
4208                    let (junction_table, module, _, _, _) = self.multilink_junction_info(owner_td, &ml)?;
4209                    joins.push(IrPathJoin::BacklinkMulti {
4210                        source_alias: current_alias.clone(),
4211                        junction_alias,
4212                        junction_table,
4213                        module,
4214                        owner_col: "source".to_string(),
4215                        current_col: "target".to_string(),
4216                        target,
4217                    });
4218                }
4219
4220                if is_last(consumed_extra) {
4221                    let shape =
4222                        self.compile_shape_anchored(shape_elements, owner_td, &target_alias, &owner_td.module.clone())?;
4223                    let result = IrPathResult::Object {
4224                        alias: target_alias.clone(),
4225                        type_name: format!("{}::{}", owner_td.module, owner_td.name),
4226                        shape,
4227                    };
4228                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4229                        sel,
4230                        owner_td,
4231                        &target_alias,
4232                        junction_scope.clone(),
4233                        shape_elements,
4234                    )?;
4235                    return Ok(IrPathSelect {
4236                        root,
4237                        joins,
4238                        result,
4239                        filter: and_conditions(filter, extra_conditions),
4240                        order_by,
4241                        offset,
4242                        limit,
4243                        distinct,
4244                        poly_implementors: vec![],
4245                    });
4246                }
4247                current_td = owner_td;
4248                current_alias = target_alias;
4249                idx += 1 + consumed_extra;
4250                continue;
4251            }
4252
4253            let step_name = match step {
4254                PathStep::Name(n) => n.as_str(),
4255                _ => return Err(self.type_err("only name steps are supported in path traversal")),
4256            };
4257
4258            // `__type__` is a virtual scalar property holding the
4259            // fully-qualified type name: a real discriminator column on a
4260            // polymorphic (interface) source, a constant on a concrete one.
4261            if step_name == "__type__" {
4262                if !is_last(0) {
4263                    return Err(
4264                        self.type_err("'__type__' is the type's name, not an object — it cannot be traversed further")
4265                    );
4266                }
4267                let polymorphic = self.is_polymorphic(current_td);
4268                let expr = if polymorphic {
4269                    IrExpr::ColumnRef {
4270                        alias: current_alias.clone(),
4271                        column: "__type__".to_string(),
4272                        pg_type: "text".to_string(),
4273                    }
4274                } else {
4275                    IrExpr::Literal(IrLiteral::Str(format!("{}::{}", current_td.module, current_td.name)))
4276                };
4277                // A CTE-backed root already carries the discriminator column
4278                // from the binding's own polymorphic expansion; re-expanding it
4279                // here would read the base type again and drop the binding's
4280                // filter.
4281                let poly_implementors = if polymorphic && joins.is_empty() && !root.table.starts_with("@cte:") {
4282                    self.find_poly_implementors(&root.type_name.clone())
4283                } else {
4284                    vec![]
4285                };
4286                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4287                    sel,
4288                    current_td,
4289                    &current_alias,
4290                    junction_scope.clone(),
4291                    shape_elements,
4292                )?;
4293                return Ok(IrPathSelect {
4294                    root,
4295                    joins,
4296                    result: IrPathResult::Scalar(expr, None),
4297                    filter: and_conditions(filter, extra_conditions),
4298                    order_by,
4299                    offset,
4300                    limit,
4301                    distinct,
4302                    poly_implementors,
4303                });
4304            }
4305
4306            // Check scalar property first.
4307            if let Some(p) = current_td.properties.iter().find(|p| p.name == step_name) {
4308                if !is_last(0) {
4309                    // Named tuple properties (nominal `__nt__:` marker) and structural
4310                    // tuple properties (`tuple_members`) both allow further field
4311                    // access via jsonb operators.
4312                    if p.pg_type.starts_with("__nt__:") || p.tuple_members.is_some() {
4313                        let base = IrExpr::ColumnRef {
4314                            alias: current_alias.clone(),
4315                            column: p.name.clone(),
4316                            pg_type: p.pg_type.clone(),
4317                        };
4318                        let remaining = &steps[idx + 1..];
4319                        let mut ir: IrExpr = base;
4320                        for step in remaining {
4321                            let field = match step {
4322                                ast::PathStep::Name(n) => n.clone(),
4323                                _ => return Err(self.type_err("only field name steps are valid inside a named tuple")),
4324                            };
4325                            ir = IrExpr::JsonbField {
4326                                expr: Box::new(ir),
4327                                field,
4328                            };
4329                        }
4330                        let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4331                            sel,
4332                            current_td,
4333                            &current_alias,
4334                            junction_scope.clone(),
4335                            shape_elements,
4336                        )?;
4337                        return Ok(IrPathSelect {
4338                            root,
4339                            joins,
4340                            result: IrPathResult::Scalar(ir, None),
4341                            filter: and_conditions(filter, extra_conditions),
4342                            order_by,
4343                            offset,
4344                            limit,
4345                            distinct,
4346                            poly_implementors: vec![],
4347                        });
4348                    }
4349                    return Err(self.type_err(&format!(
4350                        "'{step_name}' is a scalar property, not a link — cannot traverse further"
4351                    )));
4352                }
4353                let tuple_shape = self.resolve_property_tuple_shape(p);
4354                let result = IrPathResult::Scalar(
4355                    IrExpr::ColumnRef {
4356                        alias: current_alias.clone(),
4357                        column: p.name.clone(),
4358                        pg_type: p.pg_type.clone(),
4359                    },
4360                    tuple_shape,
4361                );
4362                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4363                    sel,
4364                    current_td,
4365                    &current_alias,
4366                    junction_scope.clone(),
4367                    shape_elements,
4368                )?;
4369                return Ok(IrPathSelect {
4370                    root,
4371                    joins,
4372                    result,
4373                    filter: and_conditions(filter, extra_conditions),
4374                    order_by,
4375                    offset,
4376                    limit,
4377                    distinct,
4378                    poly_implementors: vec![],
4379                });
4380            }
4381
4382            // Single link.
4383            if let Some(l) = Self::resolve_link(current_td, step_name) {
4384                let target_td = self.resolve_type(&l.target)?;
4385                let target_alias = self.fresh_alias();
4386                let target = IrSource {
4387                    poly: None,
4388                    type_name: format!("{}::{}", target_td.module, target_td.name),
4389                    table: target_td.table.clone(),
4390                    alias: target_alias.clone(),
4391                };
4392                if l.is_junction_backed() {
4393                    // Same join shape a multi-link's own path step uses
4394                    // (D1) — `PRIMARY KEY (source)` on the junction table
4395                    // already guarantees at most one matching row, so no
4396                    // extra cardinality handling is needed here.
4397                    let join = self.build_multilink_join(current_td, &l.name, &l.target, &l.through)?;
4398                    let junction_alias = self.fresh_alias();
4399                    junction_scope = l.through.clone().map(|t| (t, junction_alias.clone()));
4400                    joins.push(IrPathJoin::Multi {
4401                        source_alias: current_alias.clone(),
4402                        junction_alias,
4403                        join,
4404                        target,
4405                    });
4406                } else {
4407                    joins.push(IrPathJoin::Single {
4408                        source_alias: current_alias.clone(),
4409                        fk_col: format!("{}_id", l.name),
4410                        target,
4411                    });
4412                }
4413                if is_last(0) {
4414                    let shape = self.compile_shape_anchored(
4415                        shape_elements,
4416                        target_td,
4417                        &target_alias,
4418                        &target_td.module.clone(),
4419                    )?;
4420                    let result = IrPathResult::Object {
4421                        alias: target_alias.clone(),
4422                        type_name: format!("{}::{}", target_td.module, target_td.name),
4423                        shape,
4424                    };
4425                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4426                        sel,
4427                        target_td,
4428                        &target_alias,
4429                        junction_scope.clone(),
4430                        shape_elements,
4431                    )?;
4432                    return Ok(IrPathSelect {
4433                        root,
4434                        joins,
4435                        result,
4436                        filter: and_conditions(filter, extra_conditions),
4437                        order_by,
4438                        offset,
4439                        limit,
4440                        distinct,
4441                        poly_implementors: vec![],
4442                    });
4443                }
4444                current_td = target_td;
4445                current_alias = target_alias;
4446                idx += 1;
4447                continue;
4448            }
4449
4450            // Multi-link.
4451            if let Some(ml) = Self::resolve_multilink(current_td, step_name) {
4452                let target_td = self.resolve_type(&ml.target)?;
4453                let target_alias = self.fresh_alias();
4454                let junction_alias = self.fresh_alias();
4455                // Set only while walking off a mutation this statement made:
4456                // both the junction rows and, when they were inserted here
4457                // too, the targets live in CTEs rather than their tables.
4458                let override_for_step = self
4459                    .junction_read_overrides
4460                    .get(&self.owner_junction(current_td, &ml.name, None))
4461                    .cloned();
4462                let target = IrSource {
4463                    poly: None,
4464                    type_name: format!("{}::{}", target_td.module, target_td.name),
4465                    table: match override_for_step.as_ref().and_then(|o| o.targets.clone()) {
4466                        Some(cte) => cte,
4467                        None => target_td.table.clone(),
4468                    },
4469                    alias: target_alias.clone(),
4470                };
4471                let join_info = if let Some(through_qname) = &ml.through {
4472                    let through_td = self.resolve_type(through_qname)?;
4473                    if through_td.junction {
4474                        // See `junction_info_for`'s doc comment: owner-derived,
4475                        // never `through_td.table` itself.
4476                        IrMultiLinkJoin::Standard {
4477                            junction_table: self.owner_junction(current_td, &ml.name, Some(through_td)),
4478                            module: current_td.module.clone(),
4479                        }
4480                    } else {
4481                        let source_qname = format!("{}::{}", current_td.module, current_td.name);
4482                        let source_col = through_td
4483                            .links
4484                            .iter()
4485                            .find(|l| l.target == source_qname)
4486                            .ok_or_else(|| {
4487                                PyQLError::Type(PyQLTypeError {
4488                                    message: format!("through type {through_qname} has no link to {source_qname}"),
4489                                    position: Position { line: 0, col: 0 },
4490                                })
4491                            })?
4492                            .name
4493                            .clone();
4494                        let target_col = through_td
4495                            .links
4496                            .iter()
4497                            .find(|l| l.target == ml.target && l.name != source_col)
4498                            .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
4499                            .ok_or_else(|| {
4500                                PyQLError::Type(PyQLTypeError {
4501                                    message: format!(
4502                                        "through type {through_qname} has no link to target {}",
4503                                        ml.target
4504                                    ),
4505                                    position: Position { line: 0, col: 0 },
4506                                })
4507                            })?
4508                            .name
4509                            .clone();
4510                        IrMultiLinkJoin::Through {
4511                            junction_table: through_td.table.clone(),
4512                            module: through_td.module.clone(),
4513                            source_col,
4514                            target_col,
4515                        }
4516                    }
4517                } else {
4518                    let junction_table = self.owner_junction(current_td, &ml.name, None);
4519                    IrMultiLinkJoin::Standard {
4520                        junction_table: match &override_for_step {
4521                            Some(o) => o.junction.clone(),
4522                            None => junction_table,
4523                        },
4524                        module: current_td.module.clone(),
4525                    }
4526                };
4527                junction_scope = ml.through.clone().map(|t| (t, junction_alias.clone()));
4528                joins.push(IrPathJoin::Multi {
4529                    source_alias: current_alias.clone(),
4530                    junction_alias,
4531                    join: join_info,
4532                    target,
4533                });
4534                if is_last(0) {
4535                    let shape = self.compile_shape_anchored(
4536                        shape_elements,
4537                        target_td,
4538                        &target_alias,
4539                        &target_td.module.clone(),
4540                    )?;
4541                    let result = IrPathResult::Object {
4542                        alias: target_alias.clone(),
4543                        type_name: format!("{}::{}", target_td.module, target_td.name),
4544                        shape,
4545                    };
4546                    let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4547                        sel,
4548                        target_td,
4549                        &target_alias,
4550                        junction_scope.clone(),
4551                        shape_elements,
4552                    )?;
4553                    return Ok(IrPathSelect {
4554                        root,
4555                        joins,
4556                        result,
4557                        filter: and_conditions(filter, extra_conditions),
4558                        order_by,
4559                        offset,
4560                        limit,
4561                        distinct,
4562                        poly_implementors: vec![],
4563                    });
4564                }
4565                current_td = target_td;
4566                current_alias = target_alias;
4567                idx += 1;
4568                continue;
4569            }
4570
4571            // A computed pointer declared on the type reached so far (or on
4572            // an interface it implements) — or, at the root only, one the
4573            // shape in scope declared, which is on no type at all:
4574            // `r { revision := … }` read back as `.revision.created_at`.
4575            let declared = match self.resolve_computed(current_td, step_name) {
4576                Some(cd) => Some(crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?),
4577                None if idx == 0 => self
4578                    .active_declared_pointers
4579                    .iter()
4580                    .find(|d| path_leaf(&d.path).is_ok_and(|n| n == step_name))
4581                    .and_then(|d| d.compexpr.clone()),
4582                None => None,
4583            };
4584            if let Some(parsed) = declared {
4585                // `(select .parents … limit 1).parent` reads as the select over
4586                // the walk those two make together.
4587                let rewritten = Self::field_access_over_select(&parsed);
4588                let expr_ast = match &rewritten {
4589                    Some((sel, _)) => Expr::SubQuery(Box::new(Stmt::Select(sel.clone()))),
4590                    None => parsed,
4591                };
4592                let field_steps = rewritten.as_ref().map(|(_, n)| *n).unwrap_or(0);
4593
4594                // Traversing *through* it: a computed has no stored column
4595                // for a join to hang off, but if it just names a link — with
4596                // or without a filter — its own path can take the step's
4597                // place, and its filter rides along on the spliced segment.
4598                // Chaining computeds this way is routine, so the splice
4599                // re-enters the loop and expands again if it lands on
4600                // another one.
4601                // As the last step too, when what it names lands on an object:
4602                // the shape and the select's own modifiers belong to that
4603                // object, and reading it as an expression instead binds them to
4604                // the type that declares the computed.
4605                if let Some((p, _, modifiers)) = Self::pointer_subject(&expr_ast)
4606                    && p.partial
4607                    && !p.steps.is_empty()
4608                    && (!is_last(0)
4609                        || self
4610                            .walk_path_types(current_td, &p.steps, MAX_COMPUTED_SPLICES)
4611                            .1
4612                            .is_some())
4613                {
4614                    // Order/offset/limit of its own pick one row *per source
4615                    // row*, which no plain join expresses — so the computed's
4616                    // own traversal becomes a correlated LATERAL and the path
4617                    // continues from its result.
4618                    if let Some(m) = modifiers
4619                        && (m.limit.is_some() || m.offset.is_some() || !m.order_by.is_empty())
4620                    {
4621                        let mut inner_steps =
4622                            vec![PathStep::Name(format!("{}::{}", current_td.module, current_td.name))];
4623                        inner_steps.extend(p.steps.iter().cloned());
4624                        let inner_path = ast::Path {
4625                            steps: inner_steps,
4626                            partial: false,
4627                        };
4628                        let inner_sel = ast::SelectStmt {
4629                            result: Expr::Path(inner_path.clone()),
4630                            filter: m.filter.clone(),
4631                            order_by: m.order_by.clone(),
4632                            offset: m.offset.clone(),
4633                            limit: m.limit.clone(),
4634                            lock: None,
4635                        };
4636                        let mut inner =
4637                            self.compile_path_select_with_tail(&inner_sel, &inner_path, &[], false, field_steps, &[])?;
4638                        Self::correlate_path_select(&mut inner, &current_alias);
4639                        let IrPathResult::Object { type_name, .. } = &inner.result else {
4640                            return Err(self.type_err(&format!(
4641                                "computed pointer '{step_name}' is a scalar — a path cannot continue through it"
4642                            )));
4643                        };
4644                        let target_td = self.resolve_type(&type_name.clone())?;
4645                        let target_alias = self.fresh_alias();
4646                        let target = IrSource {
4647                            poly: None,
4648                            type_name: format!("{}::{}", target_td.module, target_td.name),
4649                            table: target_td.table.clone(),
4650                            alias: target_alias.clone(),
4651                        };
4652                        joins.push(IrPathJoin::Lateral {
4653                            inner: Box::new(inner),
4654                            target,
4655                        });
4656                        if is_last(0) {
4657                            let module = target_td.module.clone();
4658                            let shape =
4659                                self.compile_shape_anchored(shape_elements, target_td, &target_alias, &module)?;
4660                            let result = IrPathResult::Object {
4661                                alias: target_alias.clone(),
4662                                type_name: format!("{}::{}", target_td.module, target_td.name),
4663                                shape,
4664                            };
4665                            let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4666                                sel,
4667                                target_td,
4668                                &target_alias,
4669                                junction_scope,
4670                                shape_elements,
4671                            )?;
4672                            return Ok(IrPathSelect {
4673                                root,
4674                                joins,
4675                                result,
4676                                filter: and_conditions(filter, extra_conditions),
4677                                order_by,
4678                                offset,
4679                                limit,
4680                                distinct,
4681                                poly_implementors: vec![],
4682                            });
4683                        }
4684                        current_td = target_td;
4685                        current_alias = target_alias;
4686                        idx += 1;
4687                        continue;
4688                    }
4689                    splices += 1;
4690                    if splices > MAX_COMPUTED_SPLICES {
4691                        return Err(self.type_err(&format!(
4692                            "computed pointer '{step_name}' expands into itself — \
4693                             a path cannot be resolved through a cycle of computed pointers"
4694                        )));
4695                    }
4696                    let filter = modifiers.and_then(|m| m.filter.clone());
4697                    let spliced = p.steps.clone();
4698                    if tail > 0 && idx >= steps.len() - tail {
4699                        tail += spliced.len() - 1;
4700                    }
4701                    let landing = idx + spliced.len() - 1;
4702                    steps.splice(idx..idx + 1, spliced);
4703                    // Every filter already queued for a later step shifts
4704                    // along with it.
4705                    let shift = landing - idx;
4706                    for (i, _) in pending_filters.iter_mut() {
4707                        if *i > idx {
4708                            *i += shift;
4709                        }
4710                    }
4711                    if let Some(f) = filter {
4712                        pending_filters.push((landing, f));
4713                    }
4714                    continue;
4715                }
4716
4717                // A computed backed by an object-returning function —
4718                // `translation := latest(.id)`. There is no path to splice
4719                // in, so the call itself becomes the next row source: a
4720                // LATERAL join, since its arguments read the alias the
4721                // traversal has reached.
4722                if let Some((fc, modifiers)) = Self::function_subject(&expr_ast)
4723                    && let Some(fd) = self.resolve_object_fn(fc)
4724                {
4725                    if fd.params.len() != fc.args.len() {
4726                        return Err(self.type_err(&format!(
4727                            "function '{}::{}' expects {} argument(s), got {}",
4728                            fd.module,
4729                            fd.name,
4730                            fd.params.len(),
4731                            fc.args.len()
4732                        )));
4733                    }
4734                    let (fn_module, fn_name, return_type_name) =
4735                        (fd.module.clone(), fd.name.clone(), fd.return_pg_type.clone());
4736                    let mut args = fc
4737                        .args
4738                        .iter()
4739                        .map(|a| self.compile_expr(a, current_td, &current_alias))
4740                        .collect::<Result<Vec<_>, _>>()?;
4741                    let qualified = format!("{fn_module}::{fn_name}");
4742                    if let Some(globals) = self.globals_arg_for_call(&qualified)? {
4743                        args.insert(0, globals);
4744                    }
4745                    let target_td = self.resolve_type(&return_type_name)?;
4746                    let target_alias = self.fresh_alias();
4747                    let target = IrSource {
4748                        poly: None,
4749                        type_name: format!("{}::{}", target_td.module, target_td.name),
4750                        table: target_td.table.clone(),
4751                        alias: target_alias.clone(),
4752                    };
4753                    joins.push(IrPathJoin::Function {
4754                        fn_module,
4755                        fn_name,
4756                        args,
4757                        target,
4758                    });
4759                    if let Some(f) = modifiers.and_then(|m| m.filter.clone()) {
4760                        let cond = self.compile_expr(&f, target_td, &target_alias)?;
4761                        extra_conditions.push(cond);
4762                    }
4763                    if is_last(0) {
4764                        let shape = self.compile_shape_anchored(
4765                            shape_elements,
4766                            target_td,
4767                            &target_alias,
4768                            &target_td.module.clone(),
4769                        )?;
4770                        let result = IrPathResult::Object {
4771                            alias: target_alias.clone(),
4772                            type_name: format!("{}::{}", target_td.module, target_td.name),
4773                            shape,
4774                        };
4775                        let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4776                            sel,
4777                            target_td,
4778                            &target_alias,
4779                            junction_scope.clone(),
4780                            shape_elements,
4781                        )?;
4782                        return Ok(IrPathSelect {
4783                            root,
4784                            joins,
4785                            result,
4786                            filter: and_conditions(filter, extra_conditions),
4787                            order_by,
4788                            offset,
4789                            limit,
4790                            distinct,
4791                            poly_implementors: vec![],
4792                        });
4793                    }
4794                    current_td = target_td;
4795                    current_alias = target_alias;
4796                    idx += 1;
4797                    continue;
4798                }
4799
4800                if !is_last(0) {
4801                    return Err(self.type_err(&format!(
4802                        "'{step_name}' is a computed pointer — it has no stored column to traverse further through"
4803                    )));
4804                }
4805                let expanding = format!("{}::{}.{step_name}", current_td.module, current_td.name);
4806                if self.expanding_computeds.contains(&expanding) {
4807                    return Err(self.type_err(&format!(
4808                        "computed pointer '{step_name}' expands into itself — \
4809                         a path cannot be resolved through a cycle of computed pointers"
4810                    )));
4811                }
4812                self.expanding_computeds.push(expanding);
4813                let expr = self.compile_expr(&expr_ast, current_td, &current_alias);
4814                self.expanding_computeds.pop();
4815                let expr = expr?;
4816                let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4817                    sel,
4818                    current_td,
4819                    &current_alias,
4820                    junction_scope.clone(),
4821                    shape_elements,
4822                )?;
4823                return Ok(IrPathSelect {
4824                    root,
4825                    joins,
4826                    result: IrPathResult::Scalar(expr, None),
4827                    filter: and_conditions(filter, extra_conditions),
4828                    order_by,
4829                    offset,
4830                    limit,
4831                    distinct,
4832                    poly_implementors: vec![],
4833                });
4834            }
4835
4836            return Err(self.field_err(step_name, &format!("{}::{}", current_td.module, current_td.name)));
4837        }
4838
4839        // Every step consumed with nothing returned means the path *ended* on a
4840        // narrowing (`a.respondents[is GuestRespondent]`, or a bare `[is T]`).
4841        // The loop has already joined to the narrowed relation, so what it
4842        // landed on is the answer — read with the shape, like any other object
4843        // step.
4844        if matches!(steps.last(), Some(PathStep::TypeIntersection(_))) {
4845            let module = current_td.module.clone();
4846            let type_name = format!("{}::{}", current_td.module, current_td.name);
4847            let shape = self.compile_shape_anchored(shape_elements, current_td, &current_alias, &module)?;
4848            let (filter, order_by, offset, limit) = self.compile_path_modifiers_scoped(
4849                sel,
4850                current_td,
4851                &current_alias,
4852                junction_scope.clone(),
4853                shape_elements,
4854            )?;
4855            return Ok(IrPathSelect {
4856                root,
4857                joins,
4858                result: IrPathResult::Object {
4859                    alias: current_alias,
4860                    type_name,
4861                    shape,
4862                },
4863                filter: and_conditions(filter, extra_conditions),
4864                order_by,
4865                offset,
4866                limit,
4867                distinct,
4868                poly_implementors: vec![],
4869            });
4870        }
4871
4872        // Should be unreachable: steps is non-empty (we checked len > 1 before dispatch).
4873        Err(self.type_err("empty path traversal"))
4874    }
4875
4876    /// `compile_path_modifiers` with the junction the traversal last crossed
4877    /// in scope, so a bare `@prop` in the select's own FILTER/ORDER BY can
4878    /// read it. Pushes nothing when there is no junction, leaving `@prop` to
4879    /// report that it has no link to belong to.
4880    fn compile_path_modifiers_scoped(
4881        &mut self,
4882        sel: &ast::SelectStmt,
4883        td: &TypeDescriptor,
4884        alias: &str,
4885        junction: Option<(String, String)>,
4886        shape: &[ShapeElement],
4887    ) -> Result<SelectModifiers, PyQLError> {
4888        let pushed = junction.is_some();
4889        if pushed {
4890            self.link_prop_scope.push(junction);
4891        }
4892        // `select .applied_promotions { name := .promotion.name } order by .name`
4893        // — the shape's own computeds are in scope for the clauses beside it,
4894        // the same as for a schema select. Added only now, after the shape has
4895        // been compiled, so one shape pointer still cannot read another.
4896        let outer_declared = self.active_declared_pointers.clone();
4897        self.active_declared_pointers
4898            .extend(shape.iter().filter(|el| el.compexpr.is_some()).cloned());
4899        let anchored = self.modifier_anchor.take();
4900        let tail_sorts = std::mem::take(&mut self.tail_sorts);
4901        let result = match &anchored {
4902            Some((qualified, anchor_alias)) => {
4903                let anchor_td = self.resolve_type(qualified)?;
4904                let anchor_alias = anchor_alias.clone();
4905                self.compile_path_modifiers(sel, anchor_td, &anchor_alias)
4906            }
4907            None => self.compile_path_modifiers(sel, td, alias),
4908        };
4909        let result = result.and_then(|(filter, mut order_by, offset, limit)| {
4910            if !tail_sorts.is_empty() {
4911                let sorting = ast::SelectStmt {
4912                    result: sel.result.clone(),
4913                    filter: None,
4914                    order_by: tail_sorts,
4915                    offset: None,
4916                    limit: None,
4917                    lock: None,
4918                };
4919                let (_, sorts, _, _) = self.compile_path_modifiers(&sorting, td, alias)?;
4920                order_by = sorts;
4921            }
4922            Ok((filter, order_by, offset, limit))
4923        });
4924        if pushed {
4925            self.link_prop_scope.pop();
4926        }
4927        self.active_declared_pointers = outer_declared;
4928        result
4929    }
4930
4931    fn compile_path_modifiers(
4932        &mut self,
4933        sel: &ast::SelectStmt,
4934        td: &TypeDescriptor,
4935        alias: &str,
4936    ) -> Result<SelectModifiers, PyQLError> {
4937        self.anchors.push(SelectAnchor {
4938            type_name: td.name.clone(),
4939            qualified: format!("{}::{}", td.module, td.name),
4940            alias: alias.to_string(),
4941            detached: std::mem::take(&mut self.pending_detached),
4942            declared_on: None,
4943        });
4944        let result = self.compile_path_modifiers_inner(sel, td, alias);
4945        self.anchors.pop();
4946        result
4947    }
4948
4949    fn compile_path_modifiers_inner(
4950        &mut self,
4951        sel: &ast::SelectStmt,
4952        td: &TypeDescriptor,
4953        alias: &str,
4954    ) -> Result<SelectModifiers, PyQLError> {
4955        let filter = sel
4956            .filter
4957            .as_ref()
4958            .map(|f| self.compile_expr(f, td, alias))
4959            .transpose()?;
4960        let order_by = sel
4961            .order_by
4962            .iter()
4963            .map(|s| self.compile_sort(s, td, alias))
4964            .collect::<Result<Vec<_>, _>>()?;
4965        let offset = sel
4966            .offset
4967            .as_ref()
4968            .map(|e| self.compile_expr(e, td, alias))
4969            .transpose()?;
4970        let limit = sel
4971            .limit
4972            .as_ref()
4973            .map(|e| self.compile_expr(e, td, alias))
4974            .transpose()?;
4975        Ok((filter, order_by, offset, limit))
4976    }
4977
4978    // ── Expression-over-type dispatch ─────────────────────────────────────────────
4979
4980    /// Recursively find the first absolute path (non-partial, multi-step) in an expression
4981    /// and return its root type name if it resolves to a known schema type.
4982    fn find_path_root_in_expr(&self, expr: &Expr) -> Option<String> {
4983        match expr {
4984            Expr::Path(p) if !p.partial && p.steps.len() > 1 => {
4985                if let ast::PathStep::Name(root) = &p.steps[0]
4986                    && (self.resolve_type(root).is_ok() || self.cte_object_type(root).is_some())
4987                {
4988                    return Some(root.clone());
4989                }
4990                None
4991            }
4992            Expr::FunctionCall(f) => f.args.iter().find_map(|a| self.find_path_root_in_expr(a)),
4993            Expr::BinOp(b) => self
4994                .find_path_root_in_expr(&b.left)
4995                .or_else(|| self.find_path_root_in_expr(&b.right)),
4996            Expr::UnaryOp(u) => self.find_path_root_in_expr(&u.operand),
4997            _ => None,
4998        }
4999    }
5000
5001    /// Rewrite absolute paths rooted at `root_name` to relative (partial) paths.
5002    fn rewrite_abs_to_partial(expr: Expr, root_name: &str) -> Expr {
5003        match expr {
5004            Expr::Path(ref p) if !p.partial => {
5005                if let ast::PathStep::Name(first) = &p.steps[0]
5006                    && first == root_name
5007                    && p.steps.len() > 1
5008                {
5009                    return Expr::Path(ast::Path {
5010                        steps: p.steps[1..].to_vec(),
5011                        partial: true,
5012                    });
5013                }
5014                expr
5015            }
5016            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
5017                module: f.module,
5018                name: f.name,
5019                args: f
5020                    .args
5021                    .into_iter()
5022                    .map(|a| Self::rewrite_abs_to_partial(a, root_name))
5023                    .collect(),
5024                kwargs: f.kwargs,
5025            }),
5026            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
5027                left: Self::rewrite_abs_to_partial(b.left, root_name),
5028                op: b.op,
5029                right: Self::rewrite_abs_to_partial(b.right, root_name),
5030            })),
5031            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
5032                op: u.op,
5033                operand: Self::rewrite_abs_to_partial(u.operand, root_name),
5034            })),
5035            other => other,
5036        }
5037    }
5038
5039    /// The pointers a sub-select's shape declares, as the relative paths they
5040    /// stand for: `{ handle := [is Handle].handle }` → `handle` → `[is
5041    /// Handle].handle`. `None` when any element is something other than a
5042    /// named pointer defined by a relative path, which this substitution
5043    /// cannot stand in for.
5044    fn shape_alias_paths(elements: &[ShapeElement]) -> Option<Vec<(String, ast::Path)>> {
5045        let mut defs = Vec::with_capacity(elements.len());
5046        for element in elements {
5047            let [ast::PathStep::Name(name)] = element.path.steps.as_slice() else {
5048                return None;
5049            };
5050            match &element.compexpr {
5051                Some(Expr::Path(p)) if p.partial => defs.push((name.clone(), p.clone())),
5052                _ => return None,
5053            }
5054        }
5055        Some(defs)
5056    }
5057
5058    /// Replace each `.alias` a sub-select's shape declares with the path it
5059    /// stands for, so the select's own FILTER/ORDER BY read the same thing the
5060    /// shape projects.
5061    fn substitute_shape_aliases(expr: Expr, defs: &[(String, ast::Path)]) -> Expr {
5062        match expr {
5063            Expr::Path(ref p) if p.partial => {
5064                let Some(ast::PathStep::Name(first)) = p.steps.first() else {
5065                    return expr;
5066                };
5067                let Some((_, definition)) = defs.iter().find(|(name, _)| name == first) else {
5068                    return expr;
5069                };
5070                let mut steps = definition.steps.clone();
5071                steps.extend(p.steps[1..].iter().cloned());
5072                Expr::Path(ast::Path { steps, partial: true })
5073            }
5074            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
5075                module: f.module,
5076                name: f.name,
5077                args: f
5078                    .args
5079                    .into_iter()
5080                    .map(|a| Self::substitute_shape_aliases(a, defs))
5081                    .collect(),
5082                kwargs: f.kwargs,
5083            }),
5084            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
5085                left: Self::substitute_shape_aliases(b.left, defs),
5086                op: b.op,
5087                right: Self::substitute_shape_aliases(b.right, defs),
5088            })),
5089            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
5090                op: u.op,
5091                operand: Self::substitute_shape_aliases(u.operand, defs),
5092            })),
5093            other => other,
5094        }
5095    }
5096
5097    /// `array_agg(array_unpack(X))` — the aggregate runs over the elements the
5098    /// array unpacks to. PostgreSQL rejects a set-returning call inside an
5099    /// aggregate ("aggregate function calls cannot contain set-returning
5100    /// function calls"), so the unpacking moves to a row source of its own and
5101    /// the aggregate reads that from outside. `None` when the result is not an
5102    /// aggregate over an unpacked array, leaving the ordinary routes to it.
5103    fn aggregate_over_unpacked(
5104        &mut self,
5105        sel: &ast::SelectStmt,
5106        result: &Expr,
5107        distinct: bool,
5108    ) -> Result<Option<IrExpr>, PyQLError> {
5109        let Expr::FunctionCall(f) = result else {
5110            return Ok(None);
5111        };
5112        let [Expr::FunctionCall(unpack)] = f.args.as_slice() else {
5113            return Ok(None);
5114        };
5115        if !f.kwargs.is_empty()
5116            || !unpack.kwargs.is_empty()
5117            || unpack.module.as_deref().unwrap_or("std") != "std"
5118            || unpack.name != "array_unpack"
5119        {
5120            return Ok(None);
5121        }
5122        let [array] = unpack.args.as_slice() else {
5123            return Ok(None);
5124        };
5125        let namespace = f.module.as_deref().unwrap_or("std");
5126        let Some(sql_name) =
5127            crate::stdlib::lookup(namespace, &f.name)
5128                .into_iter()
5129                .find_map(|d| match &d.impl_strategy {
5130                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => Some(sql_name.to_string()),
5131                    _ => None,
5132                })
5133        else {
5134            return Ok(None);
5135        };
5136
5137        // A walk is unpacked one row at a time, so the unnest belongs in that
5138        // walk's own projection; anything else is a single array standing on
5139        // its own and unnests directly.
5140        let elements = match array {
5141            Expr::Path(path) if !path.partial && path.steps.len() > 1 => {
5142                let walk = ast::Path {
5143                    partial: false,
5144                    steps: path.steps.clone(),
5145                };
5146                let mut path_select = self.compile_path_select(sel, &walk, &[], distinct)?;
5147                let IrPathResult::Scalar(column, _) = path_select.result else {
5148                    return Ok(None);
5149                };
5150                path_select.result = IrPathResult::Scalar(
5151                    IrExpr::FunctionCall(IrFunctionCall {
5152                        return_pg_type: None,
5153                        schema: None,
5154                        name: "unnest".to_string(),
5155                        args: vec![column],
5156                        sql_template: None,
5157                    }),
5158                    None,
5159                );
5160                IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(path_select))))
5161            }
5162            _ if sel.filter.is_none()
5163                && sel.order_by.is_empty()
5164                && sel.offset.is_none()
5165                && sel.limit.is_none()
5166                && !distinct =>
5167            {
5168                self.compile_expr_ctx(array, None)?
5169            }
5170            _ => return Ok(None),
5171        };
5172
5173        // Over no rows SQL's aggregates give NULL where PyQL's give the empty
5174        // set's own value.
5175        let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
5176        Ok(Some(IrExpr::FunctionCall(IrFunctionCall {
5177            return_pg_type: None,
5178            schema: None,
5179            name: sql_name.clone(),
5180            args: vec![elements],
5181            sql_template: Some(format!(
5182                "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
5183            )),
5184        })))
5185    }
5186
5187    /// Compile an expression that contains a type-rooted absolute path as a flat
5188    /// path select, iterating over the root type and projecting the expression as
5189    /// a scalar computed pointer.
5190    fn compile_expr_as_path_select(
5191        &mut self,
5192        sel: &ast::SelectStmt,
5193        result: &Expr,
5194        root_type_name: &str,
5195        distinct: bool,
5196    ) -> Result<IrPathSelect, PyQLError> {
5197        // `array_agg(a.sessions.id)` — a single-argument aggregate over a
5198        // path. The aggregate applies to the set the path traverses *to*, so
5199        // the traversal becomes this select's own row source and the aggregate
5200        // wraps the column it lands on. Compiling the path as an expression
5201        // instead yields the array it stands for, and aggregating that nests it
5202        // one level deep.
5203        // `array_agg(distinct X.p)` — the DISTINCT belongs *inside* the
5204        // aggregate. The unary `distinct` emits its operand unchanged (it is
5205        // normally applied where the set is built, which an aggregate argument
5206        // is not), so left here it silently keeps the duplicates.
5207        let (agg_arg, agg_distinct) = match result {
5208            Expr::FunctionCall(f) if f.args.len() == 1 => match &f.args[0] {
5209                Expr::UnaryOp(u) if matches!(u.op, ast::UnaryOpKind::Distinct) => (Some(&u.operand), true),
5210                other => (Some(other), false),
5211            },
5212            _ => (None, false),
5213        };
5214        if let Expr::FunctionCall(f) = result
5215            && f.args.len() == 1
5216            && let Some(Expr::Path(p)) = agg_arg
5217            && !p.partial
5218            && p.steps.len() > 1
5219        {
5220            let arg_path = ast::Path {
5221                partial: false,
5222                steps: p.steps.clone(),
5223            };
5224            let mut ps = self.compile_path_select(sel, &arg_path, &[], distinct)?;
5225            // A walk onto objects (`count(.<brand[is Licence])`) is counted by
5226            // its rows, which is its key. Falling through instead put the
5227            // aggregate back through the expression route that sent it here.
5228            let aggregated = match ps.result.clone() {
5229                IrPathResult::Scalar(column, _) => Some(column),
5230                IrPathResult::Object { alias, .. } => Some(IrExpr::ColumnRef {
5231                    alias,
5232                    column: "id".to_string(),
5233                    pg_type: "uuid".to_string(),
5234                }),
5235            };
5236            if let Some(column) = aggregated {
5237                let mut args = vec![column];
5238                if !f.kwargs.is_empty() {
5239                    if !matches!(f.name.as_str(), "assert_single" | "assert_exists" | "assert_distinct") {
5240                        return Err(self.type_err(&format!(
5241                            "function '{}' does not take named arguments, got '{}'",
5242                            f.name, f.kwargs[0].0
5243                        )));
5244                    }
5245                    args.extend(self.assert_message(f, None)?);
5246                }
5247                let mut call = self.resolve_fn_call(f.module.as_deref(), &f.name, args)?;
5248                if agg_distinct {
5249                    let IrExpr::FunctionCall(fc) = &mut call else {
5250                        return Err(self.type_err(&format!(
5251                            "'{}' does not take a distinct argument — it does not resolve to an aggregate",
5252                            f.name
5253                        )));
5254                    };
5255                    if fc.schema.is_some() || fc.sql_template.is_some() {
5256                        return Err(self.type_err(&format!(
5257                            "'distinct' inside '{}' is not supported — only a plain SQL aggregate can take it",
5258                            f.name
5259                        )));
5260                    }
5261                    fc.sql_template = Some(format!("{}(DISTINCT $1)", fc.name));
5262                }
5263                ps.result = IrPathResult::Scalar(aggregate_over_nothing(&f.name, call), None);
5264                return Ok(ps);
5265            }
5266        }
5267
5268        // BinOp where one side is the absolute type-rooted path:
5269        // Build the join chain for the path side, then apply the comparison
5270        // element-wise so we get one boolean per traversal element (not one EXISTS per root).
5271        if let Expr::BinOp(b) = result {
5272            let (path_expr, value_expr, flip) = match (&b.left, &b.right) {
5273                (Expr::Path(p), v) if !p.partial => (p, v, false),
5274                (v, Expr::Path(p)) if !p.partial => (p, v, true),
5275                _ => return self.compile_expr_as_path_select_fallback(sel, result, root_type_name, distinct),
5276            };
5277            // Compile value side first so we can report its type in errors
5278            let root_td = match self.cte_object_type(root_type_name) {
5279                Some(t) => self.resolve_type(&t)?,
5280                None => self.resolve_type(root_type_name)?,
5281            };
5282            // Build PathSelect for the path (builds root + joins + scalar/object result)
5283            let ast_path = ast::Path {
5284                partial: false,
5285                steps: path_expr.steps.clone(),
5286            };
5287            let mut ps = self.compile_path_select(sel, &ast_path, &[], distinct)?;
5288            let val_ir = self.compile_expr(value_expr, root_td, &ps.root.alias)?;
5289            // Extract the scalar result from the path
5290            let scalar_col = match ps.result {
5291                IrPathResult::Scalar(e, _) => e,
5292                IrPathResult::Object { type_name, .. } => {
5293                    let val_type = infer_ir_type(&val_ir).map(pg_type_to_pyql).unwrap_or("unknown");
5294                    return Err(PyQLError::Type(PyQLTypeError {
5295                        message: format!(
5296                            "operator '{}' cannot be applied to operands of type '{}' and '{}'",
5297                            b.op, type_name, val_type,
5298                        ),
5299                        position: Position { line: 0, col: 0 },
5300                    }));
5301                }
5302            };
5303            let (l, r) = if flip {
5304                (val_ir, scalar_col)
5305            } else {
5306                (scalar_col, val_ir)
5307            };
5308            ps.result = IrPathResult::Scalar(
5309                IrExpr::BinOp(Box::new(IrBinOp {
5310                    left: true_division_operand(&b.op, l, &r),
5311                    op: b.op.clone(),
5312                    right: r,
5313                })),
5314                None,
5315            );
5316            return Ok(ps);
5317        }
5318        self.compile_expr_as_path_select_fallback(sel, result, root_type_name, distinct)
5319    }
5320
5321    fn compile_expr_as_path_select_fallback(
5322        &mut self,
5323        sel: &ast::SelectStmt,
5324        result: &Expr,
5325        root_type_name: &str,
5326        distinct: bool,
5327    ) -> Result<IrPathSelect, PyQLError> {
5328        let cte_object_type = self.cte_object_type(root_type_name);
5329        let td = match &cte_object_type {
5330            Some(t) => self.resolve_type(t)?,
5331            None => self.resolve_type(root_type_name)?,
5332        };
5333        let alias = self.fresh_alias();
5334        let root = IrSource {
5335            poly: None,
5336            type_name: format!("{}::{}", td.module, td.name),
5337            table: match &cte_object_type {
5338                Some(_) => format!("@cte:{}", root_type_name),
5339                None => td.table.clone(),
5340            },
5341            alias: alias.clone(),
5342        };
5343        let rewritten = Self::rewrite_abs_to_partial(result.clone(), root_type_name);
5344        let expr = self.compile_expr(&rewritten, td, &alias)?;
5345        let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, td, &alias)?;
5346        Ok(IrPathSelect {
5347            root,
5348            joins: vec![],
5349            result: IrPathResult::Scalar(expr, None),
5350            filter,
5351            order_by,
5352            offset,
5353            limit,
5354            distinct,
5355            poly_implementors: vec![],
5356        })
5357    }
5358
5359    /// Compile a SubQuery stmt to an `IrArraySource` for use in assert functions.
5360    /// The result is `ARRAY(SELECT scalar FROM compiled_inner)`.
5361    fn compile_subquery_to_array_source(&mut self, stmt: &Stmt) -> Result<IrArraySource, PyQLError> {
5362        match self.compile_stmt(stmt)? {
5363            IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
5364                Ok(IrArraySource::Select(s))
5365            }
5366            IrStmt::PathSelect(ps) => Ok(IrArraySource::PathSelect(Box::new(ps))),
5367            _ => Err(self.type_err("assert functions require a schema-bound SELECT as argument")),
5368        }
5369    }
5370
5371    /// `exists` in either schema-bound (FILTER, computed pointer, etc. —
5372    /// `ctx = Some((td, alias))`) or free (`ctx = None`) expression context.
5373    /// The `Path`-based arms (backlink / prop / link / multilink existence)
5374    /// only apply schema-bound — guarded on `ctx.is_some()` — since none of
5375    /// those concepts exist without a schema type in scope; everything else
5376    /// (`Parameter`, `TypeCast`, `SubQuery`, and the scalar-expression
5377    /// fallback) is identical either way and just threads `ctx` through.
5378    fn compile_exists_ctx(
5379        &mut self,
5380        operand: &Expr,
5381        ctx: Option<(&TypeDescriptor, &str)>,
5382    ) -> Result<IrExpr, PyQLError> {
5383        match operand {
5384            // exists $param  /  exists <type>$param → $N IS NOT NULL
5385            Expr::Parameter(name) => {
5386                let idx = self.param_index(name);
5387                Ok(ir_is_not_null(IrExpr::Param { index: idx }))
5388            }
5389            // exists <type>expr → expr IS NOT NULL (cast result is always a scalar)
5390            Expr::TypeCast(_) => {
5391                let inner = self.compile_expr_ctx(operand, ctx)?;
5392                Ok(ir_is_not_null(inner))
5393            }
5394
5395            // exists .<link[is Type] → EXISTS(SELECT 1 FROM type WHERE type.link_id = alias.id)
5396            Expr::Path(p)
5397                if ctx.is_some() && p.partial && matches!(p.steps.first(), Some(ast::PathStep::Backlink(_))) =>
5398            {
5399                let (td, alias) = ctx.unwrap();
5400                let current_qname = format!("{}::{}", td.module, td.name);
5401                let exists = self.compile_backlink_as_exists(&p.steps, None, &current_qname, alias)?;
5402                Ok(exists)
5403            }
5404
5405            // exists .prop → alias.col IS NOT NULL
5406            // exists .link → alias.link_id IS NOT NULL
5407            // exists .multilink → EXISTS(SELECT 1 FROM junction WHERE src = alias.id)
5408            Expr::Path(p) if ctx.is_some() && p.partial && p.steps.len() == 1 => {
5409                let (td, alias) = ctx.unwrap();
5410                // `exists @dismissed` — a link property is a column on the
5411                // junction row the multi-link's own modifiers are being
5412                // compiled against, so whether it is set is an ordinary null
5413                // check on it.
5414                if let ast::PathStep::LinkProp(prop_name) = &p.steps[0] {
5415                    let prop = self.compile_link_prop_ref(prop_name)?;
5416                    return Ok(ir_is_not_null(prop));
5417                }
5418                // `filter exists .n` where the shape in scope declared `n` —
5419                // the pointer is on no type, so it is read as the expression it
5420                // was written as.
5421                if let ast::PathStep::Name(n) = &p.steps[0]
5422                    && Self::resolve_property(td, n).is_none()
5423                    && Self::resolve_link(td, n).is_none()
5424                    && Self::resolve_multilink(td, n).is_none()
5425                    && self.resolve_computed(td, n).is_none()
5426                    && let Some(expr) = self
5427                        .active_declared_pointers
5428                        .iter()
5429                        .find(|d| path_leaf(&d.path).is_ok_and(|name| name == n))
5430                        .and_then(|d| d.compexpr.clone())
5431                {
5432                    let inner = self.compile_expr(&expr, td, alias)?;
5433                    return Ok(ir_is_not_null(inner));
5434                }
5435                // `exists [is contact::IndividualContact]` — a bare narrowing
5436                // is the current row seen as that type, so whether it exists is
5437                // whether the row *is* one.
5438                if let ast::PathStep::TypeIntersection(type_ref) = &p.steps[0] {
5439                    let narrowed = self.resolve_type(&type_ref.qualified_name())?;
5440                    let check = format!("{}::{}", narrowed.module, narrowed.name);
5441                    let source = format!("{}::{}", td.module, td.name);
5442                    return Ok(self.type_check_bool_expr(&source, &check, td, alias));
5443                }
5444                let pointer_name = match &p.steps[0] {
5445                    ast::PathStep::Name(n) => n.as_str(),
5446                    _ => return Err(self.type_err("exists: invalid path step")),
5447                };
5448                if let Some(prop) = Self::resolve_property(td, pointer_name) {
5449                    return Ok(ir_is_not_null(IrExpr::ColumnRef {
5450                        alias: alias.to_string(),
5451                        column: prop.name.clone(),
5452                        pg_type: prop.pg_type.clone(),
5453                    }));
5454                }
5455                if let Some(link) = Self::resolve_link(td, pointer_name) {
5456                    if link.is_junction_backed() {
5457                        return self.compile_junction_link_exists_check(link, td, alias);
5458                    }
5459                    return Ok(ir_is_not_null(IrExpr::ColumnRef {
5460                        alias: alias.to_string(),
5461                        column: format!("{}_id", link.name),
5462                        pg_type: "uuid".to_string(),
5463                    }));
5464                }
5465                if Self::resolve_multilink(td, pointer_name).is_some() {
5466                    return self.compile_multilink_exists_check(pointer_name, td, alias);
5467                }
5468                // `exists .primary_email` — a computed pointer stands for the
5469                // path it names, and whether *that* is empty is the question.
5470                // Checked last, so a stored pointer of the same name still
5471                // wins; without it the suggester could see the name the
5472                // resolver had just reported as unknown.
5473                if let Some(cd) = self.resolve_computed(td, pointer_name) {
5474                    let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
5475                    return self.compile_exists_ctx(&expr_ast, ctx);
5476                }
5477                Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
5478            }
5479
5480            // `exists ((select .prices filter …))` — a sub-select over a
5481            // relative path is relative to the enclosing object, so it needs
5482            // the same rooting and correlation `count((select .prices))`
5483            // already gets. Compiled without them it resolves in free context
5484            // and reports the pointer as unknown.
5485            Expr::SubQuery(stmt)
5486                if ctx.is_some()
5487                    && matches!(
5488                        stmt.as_ref(),
5489                        Stmt::Select(inner) if matches!(&inner.result, Expr::Path(p) if p.partial)
5490                    ) =>
5491            {
5492                let Stmt::Select(inner) = stmt.as_ref() else {
5493                    unreachable!("checked by the guard")
5494                };
5495                let Expr::Path(path) = &inner.result else {
5496                    unreachable!("checked by the guard")
5497                };
5498                let (td, alias) = ctx.expect("checked by the guard");
5499                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
5500                steps.extend(path.steps.iter().cloned());
5501                let full_path = ast::Path { steps, partial: false };
5502                let rooted = ast::SelectStmt {
5503                    result: Expr::Path(full_path.clone()),
5504                    filter: inner.filter.clone(),
5505                    order_by: inner.order_by.clone(),
5506                    offset: inner.offset.clone(),
5507                    limit: inner.limit.clone(),
5508                    lock: None,
5509                };
5510                let mut ps = self.compile_path_select(&rooted, &full_path, &[], false)?;
5511                Self::correlate_path_select(&mut ps, alias);
5512                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5513                    op: ast::UnaryOpKind::Exists,
5514                    operand: IrExpr::PathSubquery(Box::new(ps)),
5515                })))
5516            }
5517
5518            // exists (select ...) → EXISTS(SELECT 1 FROM ...)
5519            Expr::SubQuery(stmt) => self.compile_subquery_exists(stmt),
5520
5521            // `exists licenses` — a binding names a set, so it exists when it
5522            // has a row; the fallback's scalar subquery aborts on the second.
5523            operand if let Some(name) = self.resolve_cte_name(operand) => {
5524                let object = self.cte_types.get(name).is_some_and(|bound| bound.contains("::"));
5525                Ok(IrExpr::ExistsOverCte {
5526                    cte: name.to_string(),
5527                    column: (!object).then(|| "v".to_string()),
5528                })
5529            }
5530
5531            // Fallback: any scalar expression → expr IS NOT NULL. When `ctx`
5532            // is `None` and `operand` is a partial `Path` not caught above
5533            // (guards failed since ctx.is_some() was false), this recurses
5534            // into the free-context Path handling, which itself produces the
5535            // "not valid in free SELECT" error — same as before the merge.
5536            other => {
5537                let inner = self.compile_expr_ctx(other, ctx)?;
5538                // A set operation already knows how to ask whether it yields
5539                // anything; going through the array would build it first.
5540                if let IrExpr::SetOp { op, left, right, .. } = inner {
5541                    return Ok(IrExpr::SetOp {
5542                        op,
5543                        left,
5544                        right,
5545                        mode: super::SetOpMode::Exists,
5546                    });
5547                }
5548                Ok(ir_is_not_null(inner))
5549            }
5550        }
5551    }
5552
5553    /// Compile `exists (select ...)` → `EXISTS(SELECT 1 FROM ... WHERE ...)`.
5554    fn compile_subquery_exists(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
5555        match self.compile_stmt(stmt)? {
5556            IrStmt::Select(s) => {
5557                let mut rows = s.rows;
5558                if rows.len() != 1 {
5559                    return Err(self.type_err("exists requires a schema-bound SELECT expression"));
5560                }
5561                let IrRowSource::Bound { source, .. } = rows.remove(0) else {
5562                    return Err(self.type_err("exists requires a schema-bound SELECT expression"));
5563                };
5564                let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(source, vec![], s.filter)));
5565                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5566                    op: ast::UnaryOpKind::Exists,
5567                    operand: inner,
5568                })))
5569            }
5570            IrStmt::PathSelect(ps) => {
5571                // EXISTS(SELECT 1 FROM root [JOINs] WHERE filter)
5572                // Reuse the path select but signal "exists" via a dedicated IR node
5573                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5574                    op: ast::UnaryOpKind::Exists,
5575                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(ps.root, vec![], ps.filter))),
5576                })))
5577            }
5578            _ => Err(self.type_err("exists requires a SELECT expression")),
5579        }
5580    }
5581
5582    /// `EXISTS(SELECT 1 FROM junction WHERE junction.source = alias.id)` for a multi-link.
5583    /// Build the `IrSelect` over the junction/FK-target rows for a multilink,
5584    /// correlated to the current row (`alias.id`) — shared by `exists
5585    /// .multilink` and `count(.multilink)`.
5586    /// `EXISTS(SELECT 1 FROM junction WHERE junction.<source_col> = alias.id)`,
5587    /// correlated to the current row — shared by a multi-link's own `exists
5588    /// .multilink`/`count(.multilink)` and a junction-backed single link's
5589    /// `exists .link` (D2: same junction-info resolution either way).
5590    fn junction_correlation_select(
5591        &mut self,
5592        td: &TypeDescriptor,
5593        alias: &str,
5594        name: &str,
5595        target: &str,
5596        through: &Option<String>,
5597    ) -> Result<IrSelect, PyQLError> {
5598        let jt_alias = self.fresh_alias();
5599        let (jt_table, jt_module, jt_src_col, _, _) = self.junction_info_for(td, name, target, through)?;
5600
5601        let filter = IrExpr::BinOp(Box::new(IrBinOp {
5602            left: IrExpr::ColumnRef {
5603                alias: jt_alias.clone(),
5604                column: jt_src_col,
5605                pg_type: "uuid".to_string(),
5606            },
5607            op: ast::BinOpKind::Eq,
5608            right: IrExpr::ColumnRef {
5609                alias: alias.to_string(),
5610                column: "id".to_string(),
5611                pg_type: "uuid".to_string(),
5612            },
5613        }));
5614        Ok(IrSelect::schema_bound(
5615            IrSource {
5616                poly: None,
5617                type_name: format!("{}::__jt__", jt_module),
5618                table: jt_table,
5619                alias: jt_alias,
5620            },
5621            vec![],
5622            Some(filter),
5623        ))
5624    }
5625
5626    fn multilink_correlation_select(
5627        &mut self,
5628        ml_name: &str,
5629        td: &TypeDescriptor,
5630        alias: &str,
5631    ) -> Result<IrSelect, PyQLError> {
5632        let ml = Self::resolve_multilink(td, ml_name).unwrap();
5633        let (name, target, through) = (ml.name.clone(), ml.target.clone(), ml.through.clone());
5634        self.junction_correlation_select(td, alias, &name, &target, &through)
5635    }
5636
5637    fn compile_multilink_exists_check(
5638        &mut self,
5639        ml_name: &str,
5640        td: &TypeDescriptor,
5641        alias: &str,
5642    ) -> Result<IrExpr, PyQLError> {
5643        let inner = self.multilink_correlation_select(ml_name, td, alias)?;
5644        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5645            op: ast::UnaryOpKind::Exists,
5646            operand: IrExpr::Subquery(Box::new(inner)),
5647        })))
5648    }
5649
5650    /// Same as `compile_multilink_exists_check`, for `exists .link` where
5651    /// `link` is a junction-backed single link.
5652    fn compile_junction_link_exists_check(
5653        &mut self,
5654        l: &LinkDescriptor,
5655        td: &TypeDescriptor,
5656        alias: &str,
5657    ) -> Result<IrExpr, PyQLError> {
5658        let (name, target, through) = (l.name.clone(), l.target.clone(), l.through.clone());
5659        let inner = self.junction_correlation_select(td, alias, &name, &target, &through)?;
5660        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
5661            op: ast::UnaryOpKind::Exists,
5662            operand: IrExpr::Subquery(Box::new(inner)),
5663        })))
5664    }
5665
5666    /// A junction-backed single link's target id, as a scalar correlated
5667    /// subquery — `(SELECT jt.<target_col> FROM junction AS jt WHERE
5668    /// jt.<source_col> = alias.id)`. Stands in wherever a plain single
5669    /// link's `{name}_id` FK column would otherwise be referenced directly
5670    /// as a scalar uuid expression (bare `.link` in a filter/order-by,
5671    /// `.link.id`, or correlating `.link.<other prop>`).
5672    fn junction_target_id_expr(
5673        &mut self,
5674        td: &TypeDescriptor,
5675        l: &LinkDescriptor,
5676        alias: &str,
5677    ) -> Result<IrExpr, PyQLError> {
5678        let (name, target, through) = (l.name.clone(), l.target.clone(), l.through.clone());
5679        let (jt_table, jt_module, jt_src_col, jt_tgt_col, _) = self.junction_info_for(td, &name, &target, &through)?;
5680        let jt_alias = self.fresh_alias();
5681        let filter = IrExpr::BinOp(Box::new(IrBinOp {
5682            left: IrExpr::ColumnRef {
5683                alias: jt_alias.clone(),
5684                column: jt_src_col,
5685                pg_type: "uuid".to_string(),
5686            },
5687            op: ast::BinOpKind::Eq,
5688            right: IrExpr::ColumnRef {
5689                alias: alias.to_string(),
5690                column: "id".to_string(),
5691                pg_type: "uuid".to_string(),
5692            },
5693        }));
5694        let select = IrSelect::schema_bound(
5695            IrSource {
5696                poly: None,
5697                type_name: format!("{}::__jt__", jt_module),
5698                table: jt_table,
5699                alias: jt_alias,
5700            },
5701            vec![IrShapePointer::Scalar(IrScalarPointer {
5702                implicit_id: false,
5703                marker_offset: None,
5704                alias: "target".to_string(),
5705                column: jt_tgt_col,
5706                pg_type: "uuid".to_string(),
5707                tuple_shape: None,
5708            })],
5709            Some(filter),
5710        );
5711        Ok(IrExpr::Subquery(Box::new(select)))
5712    }
5713
5714    /// Returns true when the SELECT result expression is not a schema type reference.
5715    /// Check that a UNION expression doesn't mix object types with scalars.
5716    /// Called before dispatch so we can give a clear error instead of "expected a type name".
5717    fn check_union_type_compat(&self, expr: &Expr) -> Result<(), PyQLError> {
5718        let Expr::Union(a, b) = expr else { return Ok(()) };
5719        let a_free = self.is_free_result(a);
5720        let b_free = self.is_free_result(b);
5721        if a_free != b_free {
5722            let left = self.union_operand_type_display(a);
5723            let right = self.union_operand_type_display(b);
5724            return Err(PyQLError::Type(PyQLTypeError {
5725                message: format!(
5726                    "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
5727                    left, right,
5728                ),
5729                position: Position { line: 0, col: 0 },
5730            }));
5731        }
5732        Ok(())
5733    }
5734
5735    fn union_operand_type_display(&self, expr: &Expr) -> String {
5736        match expr {
5737            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
5738                if let ast::PathStep::Name(n) = &p.steps[0]
5739                    && let Some(t) = self.cte_types.get(n.as_str())
5740                {
5741                    if t.contains("::") {
5742                        return t.clone(); // object CTE: "default::Person"
5743                    }
5744                    if !t.is_empty() {
5745                        return pg_type_to_pyql(t).to_string(); // scalar CTE: "std::int64"
5746                    }
5747                }
5748                // Bare type name reference
5749                if let Ok(td) = self.resolve_type(
5750                    p.steps
5751                        .first()
5752                        .and_then(|s| {
5753                            if let ast::PathStep::Name(n) = s {
5754                                Some(n.as_str())
5755                            } else {
5756                                None
5757                            }
5758                        })
5759                        .unwrap_or(""),
5760                ) {
5761                    return format!("{}::{}", td.module, td.name);
5762                }
5763            }
5764            Expr::Literal(Literal::Int(_)) => return "std::int64".to_string(),
5765            Expr::Literal(Literal::Str(_)) => return "std::str".to_string(),
5766            Expr::Literal(Literal::Float(_)) => return "std::float64".to_string(),
5767            Expr::Literal(Literal::Bool(_)) => return "std::bool".to_string(),
5768            _ => {}
5769        }
5770        "unknown".to_string()
5771    }
5772
5773    fn is_free_result(&self, expr: &Expr) -> bool {
5774        let expr = match expr {
5775            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => &u.operand,
5776            Expr::Detached(inner) => inner.as_ref(),
5777            other => other,
5778        };
5779        match expr {
5780            Expr::Path(p) if !p.partial => {
5781                if p.steps.len() == 1
5782                    && let ast::PathStep::Name(n) = &p.steps[0]
5783                {
5784                    // A for-loop variable over values is a scalar; one over
5785                    // objects stands for a row, and `select c` is a schema-
5786                    // bound select of its type.
5787                    if self.for_vars.contains_key(n.as_str()) {
5788                        return !self.for_var_types.contains_key(n.as_str());
5789                    }
5790                    // Scalar CTE: type string has no "::" (object types always do).
5791                    if self.is_value_binding(n.as_str()) {
5792                        return true;
5793                    }
5794                    // Function parameter — only populated while compiling a
5795                    // function body (see `compile_fn_body`), so a bare `select a`
5796                    // where `a` is a param must resolve as the param, not be
5797                    // misread as a schema-type-name select.
5798                    if self.fn_params.contains_key(n.as_str()) {
5799                        return true;
5800                    }
5801                }
5802                false
5803            }
5804            Expr::Shape(s) if s.expr.is_some() => false,
5805            Expr::SubQuery(_) => false,
5806            Expr::Union(a, b) => self.is_free_result(a) && self.is_free_result(b),
5807            // `A if cond else B` picks between two *sets*; when those are
5808            // object sets the result is an object set too, not the scalar an
5809            // if/else over values gives. An empty branch says nothing about
5810            // which it is, so it does not get a vote.
5811            Expr::IfElse(ie) => {
5812                let empty = |e: &Expr| matches!(e, Expr::Set(items) if items.is_empty());
5813                match (empty(&ie.if_expr), empty(&ie.else_expr)) {
5814                    (true, true) => true,
5815                    (true, false) => self.is_free_result(&ie.else_expr),
5816                    (false, true) => self.is_free_result(&ie.if_expr),
5817                    (false, false) => self.is_free_result(&ie.if_expr) || self.is_free_result(&ie.else_expr),
5818                }
5819            }
5820            _ => true,
5821        }
5822    }
5823
5824    /// True when `expr` is a bare 1-step name bound to a free (non-object)
5825    /// WITH binding — the same "scalar CTE" check `is_free_result` uses for
5826    /// its `Expr::Path` arm, factored out so `Expr::Shape`'s handling of
5827    /// "shape applied to a non-object" can reuse it too.
5828    fn is_free_cte_ref(&self, expr: &Expr) -> bool {
5829        let Expr::Path(p) = expr else { return false };
5830        if p.partial || p.steps.len() != 1 {
5831            return false;
5832        }
5833        let ast::PathStep::Name(n) = &p.steps[0] else {
5834            return false;
5835        };
5836        self.is_value_binding(n.as_str())
5837    }
5838
5839    // ── FREE SELECT ───────────────────────────────────────────────────────────────
5840
5841    /// A mutation standing where a value is expected. Postgres cannot run DML
5842    /// in a value list, so it becomes a data-modifying CTE — which it runs to
5843    /// completion whether or not the outer query reads it — and what stands
5844    /// here reads the ids back, so the value still names the rows the
5845    /// mutation touched.
5846    fn dml_as_value(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
5847        let (cte_name, type_name) = self.hoist_dml_as_cte(stmt)?;
5848        // Read back through a subquery rather than naming the CTE's column
5849        // directly: a free select has no FROM of its own for the reference to
5850        // resolve against.
5851        let source = IrSource {
5852            poly: None,
5853            type_name,
5854            table: format!("@cte:{cte_name}"),
5855            alias: self.fresh_alias(),
5856        };
5857        Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
5858            source,
5859            // An empty shape would emit the `SELECT 1` an EXISTS wants, which
5860            // says nothing about which rows the value stands for.
5861            vec![IrShapePointer::Scalar(IrScalarPointer {
5862                implicit_id: false,
5863                marker_offset: None,
5864                alias: "id".to_string(),
5865                column: "id".to_string(),
5866                pg_type: "uuid".to_string(),
5867                tuple_shape: None,
5868            })],
5869            None,
5870        ))))
5871    }
5872
5873    fn collect_union_items(&mut self, expr: &Expr, items: &mut Vec<IrFreeExpr>) -> Result<(), PyQLError> {
5874        match expr {
5875            Expr::Union(a, b) => {
5876                self.collect_union_items(a, items)?;
5877                self.collect_union_items(b, items)?;
5878            }
5879            Expr::Set(exprs) => {
5880                for e in exprs {
5881                    self.collect_union_items(e, items)?;
5882                }
5883            }
5884            // `select { (update A set …), (update B set …) }` — several
5885            // mutations in one statement. Postgres cannot run DML in a value
5886            // list, so each becomes a data-modifying CTE, which it runs to
5887            // completion whether or not the outer query reads it; the item
5888            // itself reads back the id, so the set still stands for the rows
5889            // the mutations touched.
5890            Expr::SubQuery(stmt) if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
5891                items.push(IrFreeExpr::Scalar(self.dml_as_value(stmt.as_ref())?));
5892            }
5893            other => {
5894                items.push(IrFreeExpr::Scalar(self.compile_free_expr(other)?));
5895            }
5896        }
5897        Ok(())
5898    }
5899
5900    /// `metadata := metadata { author }` — a shape over a `with` binding or a
5901    /// for-loop variable. Both name a row source, so the shape reads their
5902    /// rows; a bare type name is deliberately not accepted here, because in an
5903    /// enclosing scope that prefix is factored (see `outer_anchor`) and would
5904    /// mean the outer row rather than the whole table.
5905    fn shape_over_binding(&mut self, expr: &Expr) -> Result<Option<IrExpr>, PyQLError> {
5906        let Expr::Shape(sh) = expr else {
5907            return Ok(None);
5908        };
5909        let Some(Expr::Path(p)) = sh.expr.as_ref() else {
5910            return Ok(None);
5911        };
5912        let Some(ast::PathStep::Name(root)) = p.steps.first() else {
5913            return Ok(None);
5914        };
5915        if p.partial || (self.cte_object_type(root).is_none() && !self.for_var_types.contains_key(root)) {
5916            return Ok(None);
5917        }
5918        self.free_object_link_field(expr)
5919    }
5920
5921    /// A free object's field holding an object with a shape (`{ device := d
5922    /// { id } }`). A free shape is a real object type whose fields are real
5923    /// pointers, so an object field stays an object; without
5924    /// this it would compile to the object's bare id, which is not what was
5925    /// asked for.
5926    fn free_object_link_field(&mut self, expr: &Expr) -> Result<Option<IrExpr>, PyQLError> {
5927        let Expr::Shape(sh) = expr else {
5928            return Ok(None);
5929        };
5930        let Some(Expr::Path(p)) = sh.expr.as_ref() else {
5931            return Ok(None);
5932        };
5933        if p.partial {
5934            return Ok(None);
5935        }
5936        let rest_steps = p.steps.as_slice();
5937        let Some(ast::PathStep::Name(name)) = rest_steps.first() else {
5938            return Ok(None);
5939        };
5940        let cte_name = self.cte_object_type(name).map(|_| name.clone());
5941        let Ok(td) = self.resolve_path_root(name) else {
5942            return Ok(None);
5943        };
5944        let table = self.row_source_table(name, td);
5945        // `account := resource.account { id }` — the field holds what a walk
5946        // off the binding lands on, so it is that walk with the shape on it
5947        // rather than a plain read of the binding.
5948        if rest_steps.len() > 1 {
5949            let synthetic = ast::SelectStmt {
5950                result: Expr::Path(p.clone()),
5951                filter: None,
5952                order_by: vec![],
5953                offset: None,
5954                limit: None,
5955                lock: None,
5956            };
5957            let path_select = self.compile_path_select(&synthetic, p, &sh.elements, false)?;
5958            return Ok(Some(IrExpr::ObjectPathSubquery(Box::new(path_select))));
5959        }
5960        let alias = self.fresh_alias();
5961        let source = IrSource {
5962            poly: self.poly_fanout_for(&format!("{}::{}", td.module, td.name)),
5963            type_name: format!("{}::{}", td.module, td.name),
5964            table,
5965            alias: alias.clone(),
5966        };
5967        // Same as reading the binding by name: its own shape may have
5968        // declared pointers that exist on no type.
5969        let outer_declared = std::mem::replace(
5970            &mut self.active_declared_pointers,
5971            cte_name
5972                .as_deref()
5973                .and_then(|n| self.cte_declared_pointers.get(n).cloned())
5974                .unwrap_or_default(),
5975        );
5976        let shape = self.compile_shape(&sh.elements, td, &alias, &td.module);
5977        self.active_declared_pointers = outer_declared;
5978        // A for-loop variable holds one row's key, so the select has to be
5979        // narrowed to it -- unfiltered it would read the whole table.
5980        let filter = self.for_var_types.contains_key(name).then(|| {
5981            IrExpr::BinOp(Box::new(IrBinOp {
5982                left: IrExpr::ColumnRef {
5983                    alias: alias.clone(),
5984                    column: "id".to_string(),
5985                    pg_type: "uuid".to_string(),
5986                },
5987                op: ast::BinOpKind::Eq,
5988                right: self.for_var_ref(name),
5989            }))
5990        });
5991        Ok(Some(IrExpr::ObjectSubquery(Box::new(IrSelect::schema_bound(
5992            source, shape?, filter,
5993        )))))
5994    }
5995
5996    /// One field of a free object written as a whole SELECT's result. A
5997    /// mutation is allowed here for the same reason it is allowed as an item
5998    /// of a free set — see `dml_as_value`.
5999    fn free_object_field(&mut self, expr: &Expr) -> Result<IrExpr, PyQLError> {
6000        if let Some(object) = self.free_object_link_field(expr)? {
6001            return Ok(object);
6002        }
6003        match expr {
6004            Expr::SubQuery(stmt) if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
6005                self.dml_as_value(stmt.as_ref())
6006            }
6007            other => self.compile_free_expr(other),
6008        }
6009    }
6010
6011    fn compile_free_select(
6012        &mut self,
6013        sel: &ast::SelectStmt,
6014        result_expr: &Expr,
6015        distinct: bool,
6016    ) -> Result<IrSelect, PyQLError> {
6017        // `{ a := 1 } if cond else {}` — read as a scalar `CASE` it comes back
6018        // as a bare record instead of an object, so the object is the row and
6019        // the condition decides whether there is one.
6020        if let Expr::IfElse(ie) = result_expr {
6021            let is_empty_set = |expr: &Expr| matches!(expr, Expr::Set(items) if items.is_empty());
6022            let is_free_object = |expr: &Expr| matches!(expr, Expr::Shape(sh) if sh.expr.is_none());
6023            let guarded = if is_free_object(&ie.if_expr) && is_empty_set(&ie.else_expr) {
6024                Some((&ie.if_expr, false))
6025            } else if is_empty_set(&ie.if_expr) && is_free_object(&ie.else_expr) {
6026                Some((&ie.else_expr, true))
6027            } else {
6028                None
6029            };
6030            if let Some((object, negated)) = guarded {
6031                let condition = self.compile_free_expr(&ie.condition)?;
6032                let condition = if negated {
6033                    IrExpr::UnaryOp(Box::new(IrUnaryOp {
6034                        op: ast::UnaryOpKind::Not,
6035                        operand: condition,
6036                    }))
6037                } else {
6038                    condition
6039                };
6040                let mut select = self.compile_free_select(sel, object, distinct)?;
6041                select.filter = and_conditions(select.filter, vec![condition]);
6042                return Ok(select);
6043            }
6044        }
6045        let items: Vec<IrFreeExpr> = match result_expr {
6046            Expr::Union(_, _) | Expr::Set(_) => {
6047                let mut union_items = vec![];
6048                self.collect_union_items(result_expr, &mut union_items)?;
6049                // Type-check UNION operands: all scalar branches must be in the same type family.
6050                let mut first: Option<(String, String)> = None; // (pg_type, pyql_name)
6051                for item in &union_items {
6052                    if let IrFreeExpr::Scalar(expr) = item
6053                        && let Some(t) = infer_ir_type(expr)
6054                    {
6055                        let t = t.to_string();
6056                        if let Some((ft, fq)) = &first {
6057                            if !types_compatible(ft, &t) {
6058                                return Err(PyQLError::Type(PyQLTypeError {
6059                                    message: format!(
6060                                        "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
6061                                        fq,
6062                                        pg_type_to_pyql(&t),
6063                                    ),
6064                                    position: Position { line: 0, col: 0 },
6065                                }));
6066                            }
6067                        } else {
6068                            first = Some((t.clone(), pg_type_to_pyql(&t).to_string()));
6069                        }
6070                    }
6071                }
6072                union_items
6073            }
6074            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
6075                if let ast::PathStep::Name(n) = &p.steps[0] {
6076                    if self
6077                        .cte_types
6078                        .get(n.as_str())
6079                        .map(|t| !t.contains("::"))
6080                        .unwrap_or(false)
6081                    {
6082                        vec![IrFreeExpr::CtePassthrough(n.clone())]
6083                    } else {
6084                        vec![IrFreeExpr::Scalar(self.compile_free_expr(result_expr)?)]
6085                    }
6086                } else {
6087                    vec![IrFreeExpr::Scalar(self.compile_free_expr(result_expr)?)]
6088                }
6089            }
6090            Expr::Shape(s) if s.expr.is_none() => {
6091                let fields = s
6092                    .elements
6093                    .iter()
6094                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
6095                        let name = path_leaf(&el.path)?.to_string();
6096                        let expr = el.compexpr.as_ref().ok_or_else(|| {
6097                            self.type_err("free object field must have a value expression (':= expr')")
6098                        })?;
6099                        Ok((name, self.free_object_field(expr)?))
6100                    })
6101                    .collect::<Result<Vec<_>, _>>()?;
6102                vec![IrFreeExpr::FreeObject(fields)]
6103            }
6104            Expr::Tuple(exprs) => {
6105                // An element may hold an object with a shape
6106                // (`select (offering { id }, revision { id })`), which is the
6107                // same question a free object's field asks.
6108                let ir = exprs
6109                    .iter()
6110                    .map(|e| self.free_object_field(e))
6111                    .collect::<Result<_, _>>()?;
6112                vec![IrFreeExpr::Tuple(ir)]
6113            }
6114            Expr::NamedTuple(fields) => {
6115                let ir = fields
6116                    .iter()
6117                    .map(|(name, e)| Ok((name.clone(), self.free_object_field(e)?)))
6118                    .collect::<Result<Vec<_>, PyQLError>>()?;
6119                // jsonb has no member kind for an object, so a tuple holding
6120                // one is emitted as a composite row instead; the rest stay on
6121                // the jsonb encoding they have always had.
6122                // An array of objects (`brands := array_agg((select b { * }))`)
6123                // flattens in jsonb the same way.
6124                let holds_an_object = ir.iter().any(|(_, e)| match e {
6125                    IrExpr::ObjectSubquery(_) | IrExpr::ObjectPathSubquery(_) => true,
6126                    IrExpr::ArrayFromSelect(source) => match source.as_ref() {
6127                        IrArraySource::ObjectSelect(_) | IrArraySource::ObjectFunction(_) | IrArraySource::Group(_) => {
6128                            true
6129                        }
6130                        IrArraySource::PathSelect(ps) => matches!(ps.result, IrPathResult::Object { .. }),
6131                        _ => false,
6132                    },
6133                    _ => false,
6134                });
6135                if holds_an_object {
6136                    vec![IrFreeExpr::NamedTupleRow(ir)]
6137                } else {
6138                    vec![IrFreeExpr::Scalar(IrExpr::NamedTuple {
6139                        fields: ir,
6140                        is_free_object: false,
6141                    })]
6142                }
6143            }
6144            other => vec![IrFreeExpr::Scalar(self.compile_free_expr(other)?)],
6145        };
6146
6147        let order_by = sel
6148            .order_by
6149            .iter()
6150            .map(|s| self.compile_sort_ctx(s, None))
6151            .collect::<Result<Vec<_>, _>>()?;
6152
6153        let offset = sel.offset.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
6154        let limit = sel.limit.as_ref().map(|e| self.compile_free_expr(e)).transpose()?;
6155        let filter = sel.filter.as_ref().map(|f| self.compile_free_filter(f)).transpose()?;
6156
6157        Ok(IrSelect {
6158            rows: items.into_iter().map(IrRowSource::Free).collect(),
6159            filter,
6160            order_by,
6161            offset,
6162            limit,
6163            distinct,
6164            dml_source: None,
6165            polymorphic: false,
6166            poly_implementors: vec![],
6167            poly_columns: vec![],
6168            lock: None,
6169        })
6170    }
6171
6172    /// The FILTER of a free SELECT, which has no row to apply itself to: it
6173    /// gates the result the select already produced.
6174    ///
6175    /// A condition over a type-rooted path is a *set* of booleans, one per row
6176    /// of that type, so the result survives when any of them holds — the same
6177    /// reading PyQL gives it, and the reason for the warning: a filter is
6178    /// meant to be one boolean, and `any()` says so outright.
6179    fn compile_free_filter(&mut self, filter: &Expr) -> Result<IrExpr, PyQLError> {
6180        let Some(root) = self.find_path_root_in_expr(filter) else {
6181            return self.compile_free_expr(filter);
6182        };
6183        // A binding roots a path here as readily as a type name does; resolved
6184        // as a type it is simply unknown.
6185        let td = self.resolve_path_root(&root)?;
6186        let table = self.row_source_table(&root, td);
6187        let alias = self.fresh_alias();
6188        let source = IrSource {
6189            poly: None,
6190            type_name: format!("{}::{}", td.module, td.name),
6191            table,
6192            alias: alias.clone(),
6193        };
6194        let condition = self.compile_expr(&Self::rewrite_abs_to_partial(filter.clone(), &root), td, &alias)?;
6195        self.warnings.push(format!(
6196            "possibly more than one element returned by an expression in a FILTER clause \
6197             (every '{root}'); wrap with any() to make intent explicit",
6198        ));
6199        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
6200            op: ast::UnaryOpKind::Exists,
6201            operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(source, vec![], Some(condition)))),
6202        })))
6203    }
6204
6205    // ── SELECT ────────────────────────────────────────────────────────────────────
6206
6207    /// The `(qualified type, source table)` of each branch of an object-set
6208    /// union, or `None` if any branch is something other than a direct
6209    /// reference to an object set (a WITH binding or a bare type name).
6210    /// The type a union of object sets carries: the one branch type every
6211    /// other branch is or implements. `A union (insert B)` where B implements
6212    /// A is an A-set, which is how PyQL reads it; requiring the branches
6213    /// to name the same type refuses a get-or-create over an interface.
6214    fn common_union_type(&self, branches: &[(String, String)]) -> Option<String> {
6215        let covers_all = |candidate: &String| {
6216            branches.iter().all(|(t, _)| {
6217                t == candidate
6218                    || self
6219                        .resolve_type(t)
6220                        .map(|td| Self::is_or_implements(td, candidate))
6221                        .unwrap_or(false)
6222            })
6223        };
6224        if let Some(branch) = branches.iter().map(|(t, _)| t.clone()).find(&covers_all) {
6225            return Some(branch);
6226        }
6227        // None of the branches is the others' supertype, but they may still
6228        // share one — `IncomingTransformer union OutgoingTransformer` are both
6229        // `Transformer`. The first branch's ancestors are the only candidates,
6230        // since a common type has to be among them.
6231        let first = self.resolve_type(&branches.first()?.0).ok()?;
6232        first
6233            .interfaces
6234            .iter()
6235            .chain(first.parents.iter())
6236            .find(|ancestor| covers_all(ancestor))
6237            .cloned()
6238    }
6239
6240    fn object_union_branches(&self, expr: &Expr) -> Option<Vec<(String, String)>> {
6241        fn flatten<'e>(expr: &'e Expr, out: &mut Vec<&'e Expr>) {
6242            match expr {
6243                Expr::Union(a, b) => {
6244                    flatten(a, out);
6245                    flatten(b, out);
6246                }
6247                other => out.push(other),
6248            }
6249        }
6250        if !matches!(expr, Expr::Union(_, _)) {
6251            return None;
6252        }
6253        let mut operands: Vec<&Expr> = vec![];
6254        flatten(expr, &mut operands);
6255
6256        let mut branches: Vec<(String, String)> = vec![];
6257        for operand in operands {
6258            let Expr::Path(path) = operand else { return None };
6259            if path.partial || path.steps.len() != 1 {
6260                return None;
6261            }
6262            let ast::PathStep::Name(name) = &path.steps[0] else {
6263                return None;
6264            };
6265            match self.cte_object_type(name) {
6266                Some(qualified) => branches.push((qualified, format!("@cte:{}", self.cte_sql_name(name)))),
6267                None => match self.resolve_type(name) {
6268                    Ok(td) => branches.push((format!("{}::{}", td.module, td.name), td.table.clone())),
6269                    Err(_) => return None,
6270                },
6271            }
6272        }
6273        Some(branches)
6274    }
6275
6276    /// `select (a union b) { shape }` where every branch is a set of the same
6277    /// object type — a WITH binding or a bare type name. Each branch becomes
6278    /// its own bound row; the SQL layer unions them in the FROM clause, so the
6279    /// shape and modifiers are compiled once, against the shared type.
6280    ///
6281    /// `Ok(None)` when the result isn't an object union, leaving the ordinary
6282    /// single-source path (and its own errors) in charge.
6283    /// Give every operand of an object union a name to be read by.
6284    ///
6285    /// `object_union_branches` recognises operands that already name a
6286    /// relation -- a type, or a `with` binding -- because a union is emitted
6287    /// as one relation per branch. An operand written inline (a sub-select, or
6288    /// an insert in a get-or-create) names nothing yet, so it is hoisted into
6289    /// a CTE of its own and replaced by that name, leaving an ordinary union
6290    /// of names behind.
6291    fn name_union_operands(&mut self, expr: &Expr) -> Result<Option<Expr>, PyQLError> {
6292        let Expr::Union(left, right) = expr else {
6293            return Ok(None);
6294        };
6295        let mut rewritten = false;
6296        let mut name_one = |compiler: &mut Self, operand: &Expr| -> Result<Expr, PyQLError> {
6297            match operand {
6298                Expr::Union(_, _) => match compiler.name_union_operands(operand)? {
6299                    Some(inner) => {
6300                        rewritten = true;
6301                        Ok(inner)
6302                    }
6303                    None => Ok(operand.clone()),
6304                },
6305                Expr::SubQuery(stmt) => {
6306                    let (cte_name, type_name) = compiler.hoist_dml_as_cte(stmt.as_ref())?;
6307                    // An insert names its subject unqualified (`Credentials`),
6308                    // a select yields an already-qualified name; a branch that
6309                    // resolves to no object type is not one of these at all.
6310                    let Ok(td) = compiler.resolve_type(&type_name) else {
6311                        return Ok(operand.clone());
6312                    };
6313                    compiler
6314                        .cte_types
6315                        .insert(cte_name.clone(), format!("{}::{}", td.module, td.name));
6316                    rewritten = true;
6317                    Ok(Expr::Path(ast::Path {
6318                        steps: vec![ast::PathStep::Name(cte_name)],
6319                        partial: false,
6320                    }))
6321                }
6322                // `a.answers.question union a.current_question` — a walk names
6323                // no relation either, so it is hoisted the same way an inline
6324                // select is.
6325                Expr::Path(path) if !path.partial && path.steps.len() > 1 => {
6326                    let synthetic = Stmt::Select(ast::SelectStmt {
6327                        result: operand.clone(),
6328                        filter: None,
6329                        order_by: vec![],
6330                        offset: None,
6331                        limit: None,
6332                        lock: None,
6333                    });
6334                    let Ok((cte_name, type_name)) = compiler.hoist_dml_as_cte(&synthetic) else {
6335                        return Ok(operand.clone());
6336                    };
6337                    let Ok(td) = compiler.resolve_type(&type_name) else {
6338                        return Ok(operand.clone());
6339                    };
6340                    compiler
6341                        .cte_types
6342                        .insert(cte_name.clone(), format!("{}::{}", td.module, td.name));
6343                    rewritten = true;
6344                    Ok(Expr::Path(ast::Path {
6345                        steps: vec![ast::PathStep::Name(cte_name)],
6346                        partial: false,
6347                    }))
6348                }
6349                other => Ok(other.clone()),
6350            }
6351        };
6352        let left = name_one(self, left)?;
6353        let right = name_one(self, right)?;
6354        if !rewritten {
6355            return Ok(None);
6356        }
6357        Ok(Some(Expr::Union(Box::new(left), Box::new(right))))
6358    }
6359
6360    /// `A if cond else B` over object sets, as the union it is.
6361    ///
6362    /// Picking between two *sets* is not the scalar `CASE` an if/else over
6363    /// values compiles to: it stands for A's rows when the condition holds and
6364    /// B's when it does not, which is `(A filter cond) union (B filter not
6365    /// cond)`. Rewritten here rather than given its own IR, so it reaches the
6366    /// union machinery that already knows how to read one relation per branch.
6367    /// Hand a guarded mutation its condition: an insert folds it into its own
6368    /// `WHERE`, an update or a delete ANDs it onto the rows it narrows to.
6369    fn set_mutation_guard(&mut self, branch: &Expr, condition: Expr) {
6370        let stmt = match branch {
6371            Expr::SubQuery(stmt) => Some(stmt.as_ref()),
6372            Expr::Shape(sh) => match sh.expr.as_ref() {
6373                Some(Expr::SubQuery(stmt)) => Some(stmt.as_ref()),
6374                _ => None,
6375            },
6376            _ => None,
6377        };
6378        match stmt {
6379            Some(Stmt::Update(_)) => self.pending_update_guard = Some(condition),
6380            Some(Stmt::Delete(_)) => self.pending_delete_guard = Some(condition),
6381            _ => self.pending_insert_guard = Some(condition),
6382        }
6383    }
6384
6385    fn object_if_else_as_union(&mut self, expr: &Expr) -> Option<Expr> {
6386        let Expr::IfElse(ie) = expr else {
6387            return None;
6388        };
6389        fn is_empty_set(expr: &Expr) -> bool {
6390            matches!(expr, Expr::Set(items) if items.is_empty())
6391        }
6392        let yields_objects = |branch: &Expr| match branch {
6393            Expr::Path(p) if !p.partial && p.steps.len() == 1 => match &p.steps[0] {
6394                ast::PathStep::Name(n) => self.cte_object_type(n).is_some() || self.resolve_type(n).is_ok(),
6395                _ => false,
6396            },
6397            Expr::SubQuery(stmt) => {
6398                // A select over a walk (`select resource.revisions`) yields
6399                // objects too, and names no type for `dml_subject_type` to
6400                // report; what it lands on is what the branch carries.
6401                if let Some(ast::SelectStmt {
6402                    result: Expr::Path(path),
6403                    ..
6404                }) = innermost_select(stmt.as_ref())
6405                    && !path.partial
6406                    && path.steps.len() > 1
6407                    && let Some(ast::PathStep::Name(root)) = path.steps.first()
6408                    && let Ok(root_td) = self.resolve_path_root(root)
6409                {
6410                    return matches!(
6411                        self.walk_path_types(root_td, &path.steps[1..], MAX_COMPUTED_SPLICES),
6412                        (_, Some(_))
6413                    );
6414                }
6415                matches!(
6416                    self.dml_subject_type(stmt.as_ref()),
6417                    Ok(name) if self.resolve_type(&name).is_ok()
6418                )
6419            }
6420            // A branch may carry the shape the result is read with.
6421            Expr::Shape(sh) => match sh.expr.as_ref() {
6422                Some(Expr::SubQuery(stmt)) => {
6423                    matches!(self.dml_subject_type(stmt.as_ref()), Ok(name) if self.resolve_type(&name).is_ok())
6424                }
6425                Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => match &p.steps[0] {
6426                    ast::PathStep::Name(n) => self.cte_object_type(n).is_some() || self.resolve_type(n).is_ok(),
6427                    _ => false,
6428                },
6429                _ => false,
6430            },
6431            _ => false,
6432        };
6433        // `{}` contributes no rows, so the union degenerates to the other
6434        // branch under its own guard -- which is what the full rewrite
6435        // (`SELECT A WHERE Cond UNION ALL SELECT B WHERE NOT Cond`) reduces
6436        // to when one side is empty.
6437        // A mutation under a condition is *not* handled here. Guarding the
6438        // branch only filters what is read back: the mutation is its own CTE
6439        // and Postgres runs it regardless, so `(insert …) if cond else {}`
6440        // would insert even when the condition is false -- silently, which is
6441        // far worse than the compile error it replaced. It needs the
6442        // condition folded into the mutation itself.
6443        fn mutating_stmt(branch: &Expr) -> Option<&Stmt> {
6444            match branch {
6445                Expr::SubQuery(stmt) => Some(stmt.as_ref()),
6446                Expr::Shape(sh) => match sh.expr.as_ref() {
6447                    Some(Expr::SubQuery(stmt)) => Some(stmt.as_ref()),
6448                    _ => None,
6449                },
6450                _ => None,
6451            }
6452            .filter(|stmt| matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)))
6453        }
6454        // Guarding a branch only filters what is read back, while the
6455        // mutation is its own CTE that Postgres runs regardless -- so the
6456        // condition has to go into the mutation itself, which is what
6457        // `set_mutation_guard` arranges for all three.
6458        let (if_yields_objects, else_yields_objects) = (yields_objects(&ie.if_expr), yields_objects(&ie.else_expr));
6459        let guard = |branch: &Expr, condition: Expr| {
6460            Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
6461                result: branch.clone(),
6462                filter: Some(condition),
6463                order_by: vec![],
6464                offset: None,
6465                limit: None,
6466                lock: None,
6467            })))
6468        };
6469        let negated = Expr::UnaryOp(Box::new(ast::UnaryOp {
6470            op: ast::UnaryOpKind::Not,
6471            operand: ie.condition.clone(),
6472        }));
6473        let guarded_insert = |branch: &Expr, other: &Expr| {
6474            mutating_stmt(branch).is_some() && matches!(other, Expr::Set(items) if items.is_empty())
6475        };
6476        if guarded_insert(&ie.if_expr, &ie.else_expr) {
6477            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6478            return Some(ie.if_expr.clone());
6479        }
6480        if guarded_insert(&ie.else_expr, &ie.if_expr) {
6481            let negated = Expr::UnaryOp(Box::new(ast::UnaryOp {
6482                op: ast::UnaryOpKind::Not,
6483                operand: ie.condition.clone(),
6484            }));
6485            self.set_mutation_guard(&ie.else_expr, negated);
6486            return Some(ie.else_expr.clone());
6487        }
6488        // `(insert …) if not exists existing else (update existing set …)` —
6489        // the upsert idiom. Both branches mutate, so both carry their own
6490        // condition; each statement kind has its own pending slot, so two of
6491        // different kinds can be guarded at once. Two of the *same* kind would
6492        // have one guard overwrite the other, and fall through to the refusal
6493        // below rather than compile to a mutation that runs regardless.
6494        if let (Some(if_stmt), Some(else_stmt)) = (mutating_stmt(&ie.if_expr), mutating_stmt(&ie.else_expr))
6495            && std::mem::discriminant(if_stmt) != std::mem::discriminant(else_stmt)
6496        {
6497            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6498            self.set_mutation_guard(&ie.else_expr, negated);
6499            return Some(Expr::Union(
6500                Box::new(ie.if_expr.clone()),
6501                Box::new(ie.else_expr.clone()),
6502            ));
6503        }
6504        // `existing if exists existing else (insert …)` — the insert still has
6505        // to carry the condition itself, but the other branch is a set of its
6506        // own, so the result is the full rewrite: both branches under
6507        // opposite guards, unioned.
6508        let reads_objects = |branch: &Expr, yields: bool| mutating_stmt(branch).is_none() && yields;
6509        if mutating_stmt(&ie.if_expr).is_some() && reads_objects(&ie.else_expr, else_yields_objects) {
6510            self.set_mutation_guard(&ie.if_expr, ie.condition.clone());
6511            return Some(Expr::Union(
6512                Box::new(ie.if_expr.clone()),
6513                Box::new(guard(&ie.else_expr, negated)),
6514            ));
6515        }
6516        if mutating_stmt(&ie.else_expr).is_some() && reads_objects(&ie.if_expr, if_yields_objects) {
6517            self.set_mutation_guard(&ie.else_expr, negated);
6518            return Some(Expr::Union(
6519                Box::new(guard(&ie.if_expr, ie.condition.clone())),
6520                Box::new(ie.else_expr.clone()),
6521            ));
6522        }
6523        if mutating_stmt(&ie.if_expr).is_some() || mutating_stmt(&ie.else_expr).is_some() {
6524            return None;
6525        }
6526        let (if_empty, else_empty) = (is_empty_set(&ie.if_expr), is_empty_set(&ie.else_expr));
6527        if if_empty && else_empty {
6528            return None;
6529        }
6530        if !(if_empty || if_yields_objects) || !(else_empty || else_yields_objects) {
6531            return None;
6532        }
6533        if else_empty {
6534            return Some(guard(&ie.if_expr, ie.condition.clone()));
6535        }
6536        if if_empty {
6537            return Some(guard(&ie.else_expr, negated));
6538        }
6539        Some(Expr::Union(
6540            Box::new(guard(&ie.if_expr, ie.condition.clone())),
6541            Box::new(guard(&ie.else_expr, negated)),
6542        ))
6543    }
6544
6545    /// `granted ?? existing` over object sets is the choice `A if exists A
6546    /// else B` spells, so it reaches the same union machinery. A scalar
6547    /// coalesce rewritten this way is simply not an object union, and the
6548    /// caller falls back to compiling what was written.
6549    fn object_coalesce_as_if_else(expr: &Expr) -> Option<Expr> {
6550        fn as_if_else(b: &ast::BinOp) -> Option<Expr> {
6551            (b.op == ast::BinOpKind::Coalesce).then(|| {
6552                Expr::IfElse(Box::new(ast::IfElse {
6553                    condition: Expr::UnaryOp(Box::new(ast::UnaryOp {
6554                        op: ast::UnaryOpKind::Exists,
6555                        operand: b.left.clone(),
6556                    })),
6557                    if_expr: b.left.clone(),
6558                    else_expr: b.right.clone(),
6559                }))
6560            })
6561        }
6562        match expr {
6563            Expr::BinOp(b) => as_if_else(b),
6564            Expr::Shape(sh) => match sh.expr.as_ref() {
6565                Some(Expr::BinOp(b)) => Some(Expr::Shape(Box::new(ast::ShapeExpr {
6566                    expr: Some(as_if_else(b)?),
6567                    elements: sh.elements.clone(),
6568                    marker_offset: sh.marker_offset,
6569                }))),
6570                _ => None,
6571            },
6572            _ => None,
6573        }
6574    }
6575
6576    fn try_compile_object_union_select(
6577        &mut self,
6578        sel: &ast::SelectStmt,
6579        result_expr: &Expr,
6580        distinct: bool,
6581    ) -> Result<Option<IrSelect>, PyQLError> {
6582        let coalesced;
6583        let result_expr = match Self::object_coalesce_as_if_else(result_expr) {
6584            Some(rewritten) => {
6585                coalesced = rewritten;
6586                &coalesced
6587            }
6588            None => result_expr,
6589        };
6590        let as_union;
6591        let (union_expr, shape_elements): (&Expr, &[ShapeElement]) = match result_expr {
6592            Expr::Union(_, _) => (result_expr, &[]),
6593            Expr::IfElse(_) => match self.object_if_else_as_union(result_expr) {
6594                // One branch empty leaves a single guarded set, not a union;
6595                // that is an ordinary select over what the branch names.
6596                Some(rewritten @ Expr::SubQuery(_)) => {
6597                    return self.compile_select(sel, &rewritten, distinct).map(Some);
6598                }
6599                Some(rewritten) if !matches!(rewritten, Expr::Union(_, _)) => {
6600                    return self.compile_select(sel, &rewritten, distinct).map(Some);
6601                }
6602                Some(rewritten) => {
6603                    as_union = rewritten;
6604                    (&as_union, &[] as &[ShapeElement])
6605                }
6606                None => return Ok(None),
6607            },
6608            Expr::Shape(shape) => match &shape.expr {
6609                Some(inner @ Expr::Union(_, _)) => (inner, shape.elements.as_slice()),
6610                Some(inner @ Expr::IfElse(_)) => match self.object_if_else_as_union(inner) {
6611                    Some(Expr::SubQuery(stmt)) => {
6612                        let shaped = Expr::Shape(Box::new(ast::ShapeExpr {
6613                            expr: Some(Expr::SubQuery(stmt)),
6614                            elements: shape.elements.clone(),
6615                            marker_offset: None,
6616                        }));
6617                        return self.compile_select(sel, &shaped, distinct).map(Some);
6618                    }
6619                    Some(rewritten) if !matches!(rewritten, Expr::Union(_, _)) => {
6620                        let shaped = match rewritten {
6621                            Expr::Shape(_) => rewritten,
6622                            other => Expr::Shape(Box::new(ast::ShapeExpr {
6623                                expr: Some(other),
6624                                elements: shape.elements.clone(),
6625                                marker_offset: None,
6626                            })),
6627                        };
6628                        return self.compile_select(sel, &shaped, distinct).map(Some);
6629                    }
6630                    Some(rewritten) => {
6631                        as_union = rewritten;
6632                        (&as_union, shape.elements.as_slice())
6633                    }
6634                    None => return Ok(None),
6635                },
6636                _ => return Ok(None),
6637            },
6638            _ => return Ok(None),
6639        };
6640
6641        let named;
6642        let union_expr = match self.name_union_operands(union_expr)? {
6643            Some(rewritten) => {
6644                named = rewritten;
6645                &named
6646            }
6647            None => union_expr,
6648        };
6649        let Some(branches) = self.object_union_branches(union_expr) else {
6650            return Ok(None);
6651        };
6652
6653        let Some(first_type) = self.common_union_type(&branches) else {
6654            let (first_type, _) = &branches[0];
6655            let (other, _) = branches
6656                .iter()
6657                .find(|(t, _)| t != first_type)
6658                .expect("no common type means at least two differ");
6659            return Err(self.type_err(&format!(
6660                "operator 'UNION' cannot be applied to operands of type '{first_type}' and '{other}'"
6661            )));
6662        };
6663        let first_type = &first_type;
6664        if sel.lock.is_some() {
6665            return Err(self.type_err(
6666                "FOR UPDATE/SHARE cannot be used on a UNION — its rows come from more than one \
6667                 source, which a single locking clause can't target",
6668            ));
6669        }
6670        let td = self.resolve_type(first_type)?;
6671        let alias = self.fresh_alias();
6672
6673        self.anchors.push(SelectAnchor {
6674            type_name: td.name.clone(),
6675            qualified: format!("{}::{}", td.module, td.name),
6676            alias: alias.clone(),
6677            detached: std::mem::take(&mut self.pending_detached),
6678            declared_on: None,
6679        });
6680        let clauses = (|compiler: &mut Self| -> Result<_, PyQLError> {
6681            let shape = compiler.compile_shape(shape_elements, td, &alias, &td.module)?;
6682            let filter = sel
6683                .filter
6684                .as_ref()
6685                .map(|f| compiler.compile_expr(f, td, &alias))
6686                .transpose()?;
6687            let order_by = sel
6688                .order_by
6689                .iter()
6690                .map(|o| compiler.compile_sort(o, td, &alias))
6691                .collect::<Result<Vec<_>, _>>()?;
6692            let offset = sel
6693                .offset
6694                .as_ref()
6695                .map(|e| compiler.compile_expr(e, td, &alias))
6696                .transpose()?;
6697            let limit = sel
6698                .limit
6699                .as_ref()
6700                .map(|e| compiler.compile_expr(e, td, &alias))
6701                .transpose()?;
6702            Ok((shape, filter, order_by, offset, limit))
6703        })(self);
6704        self.anchors.pop();
6705        let (shape, mut filter, order_by, offset, limit) = clauses?;
6706        // A select whose subject is a for-loop variable reads the one row that
6707        // variable holds, not the whole table.
6708        if let Some(var) = Self::subject_name(result_expr).filter(|n| self.for_var_types.contains_key(n)) {
6709            let narrowed = IrExpr::BinOp(Box::new(IrBinOp {
6710                left: IrExpr::ColumnRef {
6711                    alias: alias.clone(),
6712                    column: "id".to_string(),
6713                    pg_type: "uuid".to_string(),
6714                },
6715                op: ast::BinOpKind::Eq,
6716                right: self.for_var_ref(&var),
6717            }));
6718            filter = and_conditions(filter, vec![narrowed]);
6719        }
6720
6721        // `brands union offerings` — types that only share an ancestor carry
6722        // different columns, so each branch is read as that ancestor: its
6723        // columns, and the concrete type each row has. An abstract branch's
6724        // rows already say which type they are.
6725        let heterogeneous = branches.iter().any(|(t, _)| t != first_type);
6726        let common_columns = if heterogeneous {
6727            self.collect_poly_info(first_type).1
6728        } else {
6729            vec![]
6730        };
6731        let rows = branches
6732            .into_iter()
6733            .map(|(type_name, table)| IrRowSource::Bound {
6734                source: IrSource {
6735                    poly: if heterogeneous {
6736                        self.poly_fanout_for(&type_name)
6737                    } else {
6738                        None
6739                    },
6740                    type_name,
6741                    table,
6742                    alias: alias.clone(),
6743                },
6744                shape: shape.clone(),
6745            })
6746            .collect();
6747
6748        Ok(Some(IrSelect {
6749            rows,
6750            filter,
6751            order_by,
6752            offset,
6753            limit,
6754            distinct,
6755            dml_source: None,
6756            polymorphic: false,
6757            poly_implementors: vec![],
6758            poly_columns: common_columns,
6759            lock: None,
6760        }))
6761    }
6762
6763    /// The bare name a select's result is written as, whether it carries a
6764    /// shape or not.
6765    fn subject_name(result_expr: &Expr) -> Option<String> {
6766        let path = match result_expr {
6767            Expr::Path(p) => p,
6768            Expr::Shape(sh) => match sh.expr.as_ref()? {
6769                Expr::Path(p) => p,
6770                _ => return None,
6771            },
6772            _ => return None,
6773        };
6774        if path.partial {
6775            return None;
6776        }
6777        match path.steps.as_slice() {
6778            [ast::PathStep::Name(n)] => Some(n.clone()),
6779            _ => None,
6780        }
6781    }
6782
6783    fn compile_select(
6784        &mut self,
6785        sel: &ast::SelectStmt,
6786        result_expr: &Expr,
6787        distinct: bool,
6788    ) -> Result<IrSelect, PyQLError> {
6789        if let Some(union_select) = self.try_compile_object_union_select(sel, result_expr, distinct)? {
6790            return Ok(union_select);
6791        }
6792        let (type_name, shape_elements, inner_stmt, cte_name) = self.extract_type_and_shape(result_expr)?;
6793        let td = self.resolve_type(&type_name)?;
6794        let alias = self.fresh_alias();
6795        let table = match cte_name {
6796            Some(ref cte) => format!("@cte:{}", cte),
6797            None => Self::subject_name(result_expr)
6798                .map(|name| self.row_source_table(&name, td))
6799                .unwrap_or_else(|| td.table.clone()),
6800        };
6801        let source = IrSource {
6802            poly: self.poly_fanout_for(&format!("{}::{}", td.module, td.name)),
6803            type_name: format!("{}::{}", td.module, td.name),
6804            table,
6805            alias: alias.clone(),
6806        };
6807
6808        // On the stack for as long as this select's own clauses are being
6809        // compiled, so a `detached` select nested in one of them can find the
6810        // row it is being compared against. See `enclosing_anchor`.
6811        self.anchors.push(SelectAnchor {
6812            type_name: td.name.clone(),
6813            qualified: format!("{}::{}", td.module, td.name),
6814            alias: alias.clone(),
6815            detached: std::mem::take(&mut self.pending_detached),
6816            declared_on: None,
6817        });
6818        // A binding read back by name brings whatever its own shape declared
6819        // (`offering { publisher }`), which is on no type and has to be found
6820        // through the binding it was written on.
6821        let declared = match cte_name.as_deref() {
6822            Some(name) => self.cte_declared_pointers.get(name).cloned().unwrap_or_default(),
6823            // `select (select T { x := … }) filter .x…` — a nested select's
6824            // shape declares pointers on no type, just as a binding's does, and
6825            // the outer select's clauses read them the same way.
6826            None => inner_stmt
6827                .and_then(innermost_select)
6828                .and_then(|inner| match &inner.result {
6829                    Expr::Shape(sh) => Some(sh.elements.clone()),
6830                    _ => None,
6831                })
6832                .unwrap_or_default(),
6833        };
6834        // `*` on a binding takes in what the binding's shape computed, too.
6835        let splat_over_declared: Vec<ShapeElement>;
6836        let shape_elements = if shape_elements
6837            .iter()
6838            .any(|el| el.splat.is_some() && !matches!(el.path.steps.first(), Some(ast::PathStep::TypeIntersection(_))))
6839        {
6840            let named = |name: &str| {
6841                shape_elements
6842                    .iter()
6843                    .any(|el| el.splat.is_none() && path_leaf(&el.path).is_ok_and(|n| n == name))
6844            };
6845            let extra: Vec<ShapeElement> = declared
6846                .iter()
6847                .filter(|d| d.compexpr.is_some())
6848                .filter_map(|d| {
6849                    let name = path_leaf(&d.path).ok()?;
6850                    (!named(name)).then(|| ShapeElement {
6851                        path: ast::Path::relative(name),
6852                        splat: None,
6853                        nested: None,
6854                        compexpr: None,
6855                        op: ast::ShapeOp::Assign,
6856                        filter: None,
6857                        order_by: vec![],
6858                        offset: None,
6859                        limit: None,
6860                        marker_offset: None,
6861                    })
6862                })
6863                .collect();
6864            splat_over_declared = shape_elements.iter().cloned().chain(extra).collect();
6865            &splat_over_declared[..]
6866        } else {
6867            shape_elements
6868        };
6869        let outer_declared = std::mem::replace(&mut self.active_declared_pointers, declared);
6870        let clauses = (|compiler: &mut Self| -> Result<_, PyQLError> {
6871            let shape = compiler.compile_shape(shape_elements, td, &alias, &td.module)?;
6872            // This select's own shape declares pointers the same way, and its
6873            // clauses read them the same way: `select T { x := … } order by .x`
6874            // is legal, while one shape pointer reading another is not. Added
6875            // only now, after the shape is compiled, so the scope stops at the
6876            // clauses.
6877            compiler
6878                .active_declared_pointers
6879                .extend(shape_elements.iter().filter(|el| el.compexpr.is_some()).cloned());
6880            let filter = sel
6881                .filter
6882                .as_ref()
6883                .map(|f| compiler.compile_expr(f, td, &alias))
6884                .transpose()?;
6885            let order_by = sel
6886                .order_by
6887                .iter()
6888                .map(|s| compiler.compile_sort(s, td, &alias))
6889                .collect::<Result<Vec<_>, _>>()?;
6890            let offset = sel
6891                .offset
6892                .as_ref()
6893                .map(|e| compiler.compile_expr(e, td, &alias))
6894                .transpose()?;
6895            let limit = sel
6896                .limit
6897                .as_ref()
6898                .map(|e| compiler.compile_expr(e, td, &alias))
6899                .transpose()?;
6900            Ok((shape, filter, order_by, offset, limit))
6901        })(self);
6902        self.anchors.pop();
6903        self.active_declared_pointers = outer_declared;
6904        let (shape, mut filter, order_by, offset, limit) = clauses?;
6905
6906        // A select whose subject is a for-loop variable reads the one row that
6907        // variable holds; unfiltered it would read the whole table.
6908        if let Some(var) = Self::subject_name(result_expr).filter(|n| self.for_var_types.contains_key(n)) {
6909            let narrowed = IrExpr::BinOp(Box::new(IrBinOp {
6910                left: IrExpr::ColumnRef {
6911                    alias: alias.clone(),
6912                    column: "id".to_string(),
6913                    pg_type: "uuid".to_string(),
6914                },
6915                op: ast::BinOpKind::Eq,
6916                right: self.for_var_ref(&var),
6917            }));
6918            filter = and_conditions(filter, vec![narrowed]);
6919        }
6920
6921        // Compile the inner DML if this is a SELECT-over-DML / SELECT-over-SELECT.
6922        let dml_source = inner_stmt.map(|s| self.compile_stmt(s).map(Box::new)).transpose()?;
6923        let mut shape = shape;
6924        if let Some(dml) = &dml_source {
6925            // `SELECT (INSERT …)` has no user-written CTE name: the emitter
6926            // wraps the DML in one of its own (`sql::DML_CTE`), and the
6927            // junction CTEs are named after it.
6928            let dml_cte = cte_name.as_deref().unwrap_or(crate::sql::DML_CTE);
6929            Self::read_nested_links_from_their_ctes(dml, Some(dml_cte), &mut shape);
6930        }
6931
6932        let polymorphic = self.is_polymorphic(td);
6933        let (poly_implementors, poly_columns) = if polymorphic {
6934            let iface_qname = format!("{}::{}", td.module, td.name);
6935            (self.find_poly_implementors(&iface_qname), Self::poly_dml_columns(td))
6936        } else {
6937            (vec![], vec![])
6938        };
6939
6940        // `FOR UPDATE`/`FOR SHARE`/... — only valid when every output row
6941        // maps 1:1 to a single physical table row, the same restriction
6942        // Postgres itself enforces (it rejects the same combinations with
6943        // its own "FOR UPDATE is not allowed with ..." errors). `DISTINCT`
6944        // and an interface (polymorphic) target both break that mapping;
6945        // `SELECT (INSERT/UPDATE/DELETE …) { ... }` has nothing left to
6946        // lock, since the DML already ran by the time this SELECT reads it.
6947        let lock = match &sel.lock {
6948            None => None,
6949            Some(lc) => {
6950                if distinct {
6951                    return Err(self.type_err(
6952                        "FOR UPDATE/SHARE cannot be combined with DISTINCT — Postgres can't \
6953                         guarantee the result rows map 1:1 to physical table rows",
6954                    ));
6955                }
6956                if polymorphic {
6957                    return Err(self.type_err(
6958                        "FOR UPDATE/SHARE cannot be used on an interface type — its rows span \
6959                         multiple underlying tables, which a single locking clause can't target",
6960                    ));
6961                }
6962                if dml_source.is_some() {
6963                    return Err(self.type_err(
6964                        "FOR UPDATE/SHARE cannot be used on SELECT (INSERT/UPDATE/DELETE …) — \
6965                         there's nothing left to lock once the DML has already run",
6966                    ));
6967                }
6968                Some(IrLockClause {
6969                    strength: match lc.strength {
6970                        ast::LockStrength::Update => IrLockStrength::Update,
6971                        ast::LockStrength::NoKeyUpdate => IrLockStrength::NoKeyUpdate,
6972                        ast::LockStrength::Share => IrLockStrength::Share,
6973                        ast::LockStrength::KeyShare => IrLockStrength::KeyShare,
6974                    },
6975                    wait: match lc.wait {
6976                        ast::LockWait::Block => IrLockWait::Block,
6977                        ast::LockWait::NoWait => IrLockWait::NoWait,
6978                        ast::LockWait::SkipLocked => IrLockWait::SkipLocked,
6979                    },
6980                })
6981            }
6982        };
6983
6984        Ok(IrSelect {
6985            rows: vec![IrRowSource::Bound { source, shape }],
6986            filter,
6987            order_by,
6988            offset,
6989            limit,
6990            distinct,
6991            dml_source,
6992            polymorphic,
6993            poly_implementors,
6994            poly_columns,
6995            lock,
6996        })
6997    }
6998
6999    /// Unwrap `Shape(expr, elements)` or bare `Path` from a SELECT result.
7000    /// Returns (type_name, shape_elements, optional_inner_stmt, optional_cte_name).
7001    /// The inner stmt is Some when the subject is `(INSERT …)` / `(SELECT …)` etc.
7002    /// The cte_name is Some when the subject is a WITH-block CTE reference.
7003    fn extract_type_and_shape<'e>(&self, expr: &'e Expr) -> Result<TypeAndShape<'e>, PyQLError> {
7004        match expr {
7005            Expr::Shape(s) => {
7006                // s.expr is Option<Expr> (not Box), so use as_ref() not as_deref()
7007                let (type_name, cte_name, inner) = match s.expr.as_ref() {
7008                    Some(Expr::SubQuery(stmt)) => (self.dml_subject_type(stmt)?, None, Some(stmt.as_ref())),
7009                    Some(Expr::Path(p)) if !p.partial && p.steps.len() == 1 => {
7010                        if let ast::PathStep::Name(n) = &p.steps[0] {
7011                            if let Some(t) = self.for_var_types.get(n.as_str()) {
7012                                (t.clone(), None, None)
7013                            } else if let Some(t) = self.cte_types.get(n.as_str()) {
7014                                if t.contains("::") {
7015                                    (t.clone(), Some(n.clone()), None)
7016                                } else {
7017                                    (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7018                                }
7019                            } else {
7020                                (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7021                            }
7022                        } else {
7023                            (self.expr_as_type_name(s.expr.as_ref().unwrap())?, None, None)
7024                        }
7025                    }
7026                    Some(inner) => (self.expr_as_type_name(inner)?, None, None),
7027                    None => {
7028                        return Err(PyQLError::Type(PyQLTypeError {
7029                            message: "shape without subject expression".into(),
7030                            position: Position { line: 0, col: 0 },
7031                        }));
7032                    }
7033                };
7034                Ok((type_name, &s.elements, inner, cte_name))
7035            }
7036            // Bare `SELECT (DML)` without an outer shape
7037            Expr::SubQuery(stmt) => Ok((self.dml_subject_type(stmt)?, &[], Some(stmt.as_ref()), None)),
7038            // Bare CTE object reference: `select cte_name`
7039            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
7040                if let ast::PathStep::Name(n) = &p.steps[0] {
7041                    if let Some(t) = self.for_var_types.get(n.as_str()) {
7042                        return Ok((t.clone(), &[], None, None));
7043                    }
7044                    if let Some(t) = self.cte_types.get(n.as_str())
7045                        && t.contains("::")
7046                    {
7047                        return Ok((t.clone(), &[], None, Some(n.clone())));
7048                    }
7049                }
7050                Ok((self.expr_as_type_name(expr)?, &[], None, None))
7051            }
7052            _ => Ok((self.expr_as_type_name(expr)?, &[], None, None)),
7053        }
7054    }
7055
7056    /// True when `expr` is known to hold json, so `expr[…]` reaches into it
7057    /// with `->` rather than indexing a string or an array. A loop variable
7058    /// carries no type in the IR, so its `for` registration answers for it.
7059    fn is_json_expr(&self, ast: &Expr, ir: &IrExpr) -> bool {
7060        if matches!(infer_ir_type(ir), Some("jsonb")) {
7061            return true;
7062        }
7063        let Expr::Path(path) = ast else {
7064            return false;
7065        };
7066        if path.partial || path.steps.len() != 1 {
7067            return false;
7068        }
7069        let ast::PathStep::Name(name) = &path.steps[0] else {
7070            return false;
7071        };
7072        self.for_vars.get(name.as_str()).is_some_and(|t| t == "jsonb")
7073    }
7074
7075    /// The pg type one row of a set-returning call yields. An overload that
7076    /// names its element type (`json_array_unpack` yields json) answers for
7077    /// itself; one declared `set of any` (`array_unpack`) takes the type from
7078    /// the array it unpacks. `None` when the call is not set-returning, or
7079    /// when neither the overload nor the argument names a scalar type.
7080    fn set_returning_element_type(&mut self, expr: &Expr) -> Result<Option<String>, PyQLError> {
7081        if !expr_returns_set(expr) {
7082            return Ok(None);
7083        }
7084        let Expr::FunctionCall(call) = expr else {
7085            return Ok(None);
7086        };
7087        // Overloads that disagree on the element type say nothing between
7088        // them, so only one answer shared by all of them counts.
7089        let mut declared = crate::stdlib::lookup(call.module.as_deref().unwrap_or("std"), &call.name)
7090            .into_iter()
7091            .map(|descriptor| match &descriptor.return_type {
7092                crate::stdlib::PylonType::Set(element) => element.scalar_pg_type(),
7093                _ => None,
7094            });
7095        if let Some(element) = declared.next().flatten()
7096            && declared.all(|other| other == Some(element))
7097        {
7098            return Ok(Some(element.to_string()));
7099        }
7100        let [argument] = call.args.as_slice() else {
7101            return Ok(None);
7102        };
7103        let compiled = self.compile_free_expr(argument)?;
7104        let Some(array_type) = infer_ir_type(&compiled) else {
7105            return Ok(None);
7106        };
7107        Ok(array_type
7108            .strip_suffix("[]")
7109            .map(|element| literal_sentinel_to_pg(element).to_string()))
7110    }
7111
7112    fn compile_for(&mut self, f: &ast::ForStmt) -> Result<IrFor, PyQLError> {
7113        // A loop straight over a binding: the rows are that binding's, so a
7114        // walk off the variable reads it rather than the type's own table.
7115        let iterated_binding = self.resolve_cte_name(&f.iterator).map(|name| self.cte_sql_name(name));
7116        // Compile the iterator to determine what one loop variable binds to.
7117        let (iterator, pg_type, yielded_object_type) = match &f.iterator {
7118            Expr::Set(elems) => {
7119                let compiled: Result<Vec<_>, _> = elems.iter().map(|e| self.compile_free_expr(e)).collect();
7120                let exprs = compiled?;
7121                let raw = exprs.first().and_then(|e| infer_ir_type(e)).unwrap_or("text");
7122                let pg_type = literal_sentinel_to_pg(raw).to_string();
7123                (
7124                    IrForIterator::Values {
7125                        exprs,
7126                        pg_type: pg_type.clone(),
7127                    },
7128                    pg_type,
7129                    None,
7130                )
7131            }
7132            // A derived set — every row of it, not the single value a scalar
7133            // subquery in a one-row `VALUES` would collapse it to.
7134            Expr::SubQuery(stmt) => {
7135                let inner = self.compile_stmt(stmt)?;
7136                if !matches!(inner, IrStmt::Select(_) | IrStmt::PathSelect(_)) {
7137                    return Err(self.type_err(
7138                        "for-loop iterator: only a select can be iterated over — \
7139                         bind the statement in a `with` first",
7140                    ));
7141                }
7142                let yielded = cte_stmt_type(&inner);
7143                let scalar = !yielded.contains("::");
7144                let pg_type = if scalar {
7145                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7146                    literal_sentinel_to_pg(raw).to_string()
7147                } else {
7148                    "uuid".to_string()
7149                };
7150                (
7151                    IrForIterator::Query {
7152                        stmt: Box::new(inner),
7153                        scalar,
7154                    },
7155                    pg_type,
7156                    (!scalar).then_some(yielded),
7157                )
7158            }
7159            // A WITH binding names a set, so the loop runs once per row of
7160            // it — not once over the single value a scalar subquery would
7161            // collapse it to.
7162            Expr::Path(p)
7163                if !p.partial
7164                    && p.steps.len() == 1
7165                    && matches!(&p.steps[0], ast::PathStep::Name(n) if self.cte_types.contains_key(n.as_str())) =>
7166            {
7167                let ast::PathStep::Name(name) = &p.steps[0] else {
7168                    unreachable!("checked by the guard")
7169                };
7170                let yielded = self.cte_types.get(name.as_str()).cloned().unwrap_or_default();
7171                let scalar = !yielded.contains("::");
7172                let pg_type = if scalar {
7173                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7174                    literal_sentinel_to_pg(raw).to_string()
7175                } else {
7176                    "uuid".to_string()
7177                };
7178                let source = IrSource {
7179                    poly: None,
7180                    type_name: yielded.clone(),
7181                    table: format!("@cte:{}", self.cte_sql_name(name)),
7182                    alias: self.fresh_alias(),
7183                };
7184                (
7185                    IrForIterator::Query {
7186                        stmt: Box::new(IrStmt::Select(IrSelect::schema_bound(source, vec![], None))),
7187                        scalar,
7188                    },
7189                    pg_type,
7190                    (!scalar).then_some(yielded),
7191                )
7192            }
7193            // `for o in invitations.organizations union (…)` — a walk off a
7194            // binding names a set to iterate just as a sub-select does; it is
7195            // only written without the parentheses.
7196            Expr::Path(p) if !p.partial && p.steps.len() > 1 => {
7197                let synthetic = ast::SelectStmt {
7198                    result: Expr::Path(p.clone()),
7199                    filter: None,
7200                    order_by: vec![],
7201                    offset: None,
7202                    limit: None,
7203                    lock: None,
7204                };
7205                let inner = self.compile_stmt(&Stmt::Select(synthetic))?;
7206                let yielded = cte_stmt_type(&inner);
7207                let scalar = !yielded.contains("::");
7208                let pg_type = if scalar {
7209                    let raw = if yielded.is_empty() { "text" } else { yielded.as_str() };
7210                    literal_sentinel_to_pg(raw).to_string()
7211                } else {
7212                    "uuid".to_string()
7213                };
7214                (
7215                    IrForIterator::Query {
7216                        stmt: Box::new(inner),
7217                        scalar,
7218                    },
7219                    pg_type,
7220                    (!scalar).then_some(yielded),
7221                )
7222            }
7223            other => {
7224                let e = self.compile_free_expr(other)?;
7225                // A set-returning call is registered as `set of any`, so its
7226                // own type says nothing — the rows are the argument array's
7227                // elements, which is where the type comes from.
7228                let raw = match self.set_returning_element_type(other)? {
7229                    Some(element) => element,
7230                    None => literal_sentinel_to_pg(infer_ir_type(&e).unwrap_or("text")).to_string(),
7231                };
7232                let pg_type = raw;
7233                let iterator = if expr_returns_set(other) {
7234                    IrForIterator::SetReturning {
7235                        expr: e,
7236                        pg_type: pg_type.clone(),
7237                    }
7238                } else {
7239                    IrForIterator::Values {
7240                        exprs: vec![e],
7241                        pg_type: pg_type.clone(),
7242                    }
7243                };
7244                (iterator, pg_type, None)
7245            }
7246        };
7247
7248        // The CTE name this loop's iterator is emitted under, claimed from the
7249        // statement's one namespace so no sibling loop and no binding can end
7250        // up under it too. The emitter spells it `_for_<slot>`.
7251        let slot = self
7252            .claim_generated_cte_name(&format!("_for_{}", f.var))
7253            .strip_prefix("_for_")
7254            .unwrap_or(&f.var)
7255            .to_string();
7256        let prev_slot = self.for_var_slots.insert(f.var.clone(), slot.clone());
7257        // Register the for variable so the body can reference it.
7258        let prev = self.for_vars.insert(f.var.clone(), pg_type.clone());
7259        let prev_cte = match iterated_binding {
7260            Some(name) => self.for_var_ctes.insert(f.var.clone(), name),
7261            None => self.for_var_ctes.remove(&f.var),
7262        };
7263        let prev_type = match yielded_object_type {
7264            Some(qualified) => self.for_var_types.insert(f.var.clone(), qualified),
7265            None => self.for_var_types.remove(&f.var),
7266        };
7267        let hoisted_before = self.hoisted_ctes.len();
7268        self.for_scope.push(slot.clone());
7269        let body = self.compile_stmt(&f.body);
7270        self.for_scope.pop();
7271        let body = body?;
7272        let body_ctes: Vec<IrCteDef> = self.hoisted_ctes.split_off(hoisted_before);
7273        // Restore previous for-var (or remove if none existed).
7274        match prev {
7275            Some(old) => {
7276                self.for_vars.insert(f.var.clone(), old);
7277            }
7278            None => {
7279                self.for_vars.remove(&f.var);
7280            }
7281        }
7282        match prev_type {
7283            Some(old) => {
7284                self.for_var_types.insert(f.var.clone(), old);
7285            }
7286            None => {
7287                self.for_var_types.remove(&f.var);
7288            }
7289        }
7290        match prev_cte {
7291            Some(old) => {
7292                self.for_var_ctes.insert(f.var.clone(), old);
7293            }
7294            None => {
7295                self.for_var_ctes.remove(&f.var);
7296            }
7297        }
7298        match prev_slot {
7299            Some(old) => {
7300                self.for_var_slots.insert(f.var.clone(), old);
7301            }
7302            None => {
7303                self.for_var_slots.remove(&f.var);
7304            }
7305        }
7306
7307        // Checked here rather than left to the SQL emitter: `emit_for_stmt`
7308        // only implements these three body kinds, and reaching it with any
7309        // other one used to abort the process instead of reporting a PyQL
7310        // error the caller could act on.
7311        let body_kind = match &body {
7312            IrStmt::Insert(_) | IrStmt::Select(_) | IrStmt::PathSelect(_) => None,
7313            // An update is driven from the iteration itself (`UPDATE … FROM
7314            // <iterated set>`) rather than from a LATERAL, which cannot hold
7315            // DML. A multi-link mutation inside one would need its junction
7316            // rows driven from the iteration too, which it is not yet.
7317            // An append whose value is the loop variable itself can be driven
7318            // from the iteration (`emit_for_ml_append_cte`); anything more
7319            // would need its junction rows correlated in a way they are not.
7320            IrStmt::Update(upd)
7321                if upd.assignments.is_empty()
7322                    && upd.rewrites.is_empty()
7323                    && !upd.multi_link_appends.is_empty()
7324                    && upd
7325                        .multi_link_appends
7326                        .iter()
7327                        .all(|a| crate::sql::append_value_is_the_loop_variable(&a.values, &f.var))
7328                    && upd.multi_link_clears.is_empty()
7329                    && upd.multi_link_replaces.is_empty()
7330                    && upd.multi_link_removals.is_empty()
7331                    && upd.poly_implementors.is_empty() =>
7332            {
7333                None
7334            }
7335            IrStmt::Update(upd)
7336                if upd.multi_link_appends.is_empty()
7337                    && upd.multi_link_clears.is_empty()
7338                    && upd.multi_link_replaces.is_empty()
7339                    && upd.multi_link_removals.is_empty()
7340                    && upd.poly_implementors.is_empty() =>
7341            {
7342                None
7343            }
7344            IrStmt::Update(_) if std::env::var("PYLON_DBG_FORUPD").is_ok() => None,
7345            // `update x set { ml += (insert T { … }) }` — the insert runs once
7346            // per iteration, driven from the same rows the update is, and the
7347            // junction pairs each with the id that iteration generated. Only
7348            // appends: a clear/remove/replace has no such pairing.
7349            IrStmt::Update(upd)
7350                if upd.assignments.is_empty()
7351                    && upd.rewrites.is_empty()
7352                    && !upd.multi_link_appends.is_empty()
7353                    && upd.multi_link_clears.is_empty()
7354                    && upd.multi_link_replaces.is_empty()
7355                    && upd.multi_link_removals.is_empty()
7356                    && upd.poly_implementors.is_empty()
7357                    && upd
7358                        .multi_link_appends
7359                        .iter()
7360                        .all(|a| crate::sql::per_iteration_insert(a, &upd.nested_ctes).is_some()) =>
7361            {
7362                None
7363            }
7364            IrStmt::Update(_) => Some("update"),
7365            IrStmt::Delete(_) => Some("delete"),
7366            // `for line in … union (for component in … union (insert …))` —
7367            // one iterator CTE per loop, joined on the key the inner carries.
7368            // Only an insert innermost: anything else would need its own rows
7369            // driven from the pair, which they are not.
7370            IrStmt::For(inner) if matches!(inner.body.as_ref(), IrStmt::Insert(_)) => None,
7371            IrStmt::For(_) => Some("nested for"),
7372            IrStmt::Group(_) => Some("group"),
7373            _ => Some("this statement"),
7374        };
7375        if let Some(kind) = body_kind {
7376            return Err(self.type_err(&format!(
7377                "for-loop body: {kind} is not supported as a `for` body — use insert or select"
7378            )));
7379        }
7380
7381        Ok(IrFor {
7382            var_name: slot,
7383            iterator,
7384            body: Box::new(body),
7385            body_ctes,
7386        })
7387    }
7388
7389    fn compile_group(&mut self, g: &ast::GroupStmt) -> Result<IrGroup, PyQLError> {
7390        // Resolve the subject type — may be a schema type, a CTE alias, or a
7391        // walk. A walk (`group a.names by …`) names rows rather than a type:
7392        // the group runs over what it lands on, narrowed below to the rows it
7393        // yields, the same way `update a.preferences` narrows its own target.
7394        // The steps used to be joined with `::` and looked up as a type name,
7395        // which reported `a.names` as an unknown type `a::names`.
7396        let mut walked: Option<&ast::Path> = None;
7397        let (type_name, cte_name) = match &g.subject {
7398            Expr::Path(p) if !p.partial => match p.steps.as_slice() {
7399                [ast::PathStep::Name(n)] => {
7400                    if let Some(t) = self.cte_types.get(n.as_str()) {
7401                        (t.clone(), Some(n.clone()))
7402                    } else {
7403                        (n.clone(), None)
7404                    }
7405                }
7406                [ast::PathStep::Name(root), rest @ ..]
7407                    if !rest.is_empty()
7408                        && let Ok(root_td) = self.resolve_path_root(root)
7409                        && let (_, Some(target)) = self.walk_path_types(root_td, rest, MAX_COMPUTED_SPLICES) =>
7410                {
7411                    walked = Some(p);
7412                    (format!("{}::{}", target.module, target.name), None)
7413                }
7414                _ => {
7415                    return Err(PyQLError::Type(PyQLTypeError {
7416                        message: format!("unsupported group subject: {:?}", g.subject),
7417                        position: Position { line: 0, col: 0 },
7418                    }));
7419                }
7420            },
7421            _ => {
7422                return Err(PyQLError::Type(PyQLTypeError {
7423                    message: "group subject must be a type name".to_string(),
7424                    position: Position { line: 0, col: 0 },
7425                }));
7426            }
7427        };
7428
7429        let td = self.resolve_type(&type_name)?;
7430        let alias = self.fresh_alias();
7431        let fq_type_name = format!("{}::{}", td.module, td.name);
7432        let table = match cte_name {
7433            Some(ref cte) => format!("@cte:{}", cte),
7434            None => td.table.clone(),
7435        };
7436        let source = IrSource {
7437            poly: self.poly_fanout_for(&fq_type_name),
7438            type_name: fq_type_name.clone(),
7439            table,
7440            alias: alias.clone(),
7441        };
7442        let module = td.module.clone();
7443
7444        // Compile the element shape. No explicit shape → implicit { id }.
7445        let shape = self.compile_shape(g.shape.as_deref().unwrap_or(&[]), td, &alias, &module)?;
7446
7447        // Build a map from using-alias → compiled expression.
7448        let td = self.resolve_type(&type_name)?;
7449        let mut using_map: HashMap<String, IrExpr> = HashMap::new();
7450        for (alias_name, expr) in &g.using {
7451            let ir = self.compile_expr(expr, td, &alias)?;
7452            using_map.insert(alias_name.clone(), ir);
7453        }
7454
7455        // Compile BY keys: each is either an Ident (using-alias ref) or a partial Path (.prop).
7456        let mut keys: Vec<(String, IrExpr)> = vec![];
7457        let td = self.resolve_type(&type_name)?;
7458        for by_expr in &g.by {
7459            match by_expr {
7460                Expr::Path(p) if p.partial && p.steps.len() == 1 => {
7461                    if let ast::PathStep::Name(prop) = &p.steps[0] {
7462                        // `.prop` shorthand: infer alias = prop name, expr = column ref.
7463                        let ir = self.compile_expr(by_expr, td, &alias)?;
7464                        keys.push((prop.clone(), ir));
7465                    } else {
7466                        return Err(PyQLError::Type(PyQLTypeError {
7467                            message: "group by path must be a simple property".to_string(),
7468                            position: Position { line: 0, col: 0 },
7469                        }));
7470                    }
7471                }
7472                Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
7473                    if let ast::PathStep::Name(name) = &p.steps[0] {
7474                        // Bare ident: must be a using alias.
7475                        let ir = using_map.get(name).ok_or_else(|| {
7476                            PyQLError::Type(PyQLTypeError {
7477                                message: format!("group by references unknown alias '{}'", name),
7478                                position: Position { line: 0, col: 0 },
7479                            })
7480                        })?;
7481                        keys.push((name.clone(), ir.clone()));
7482                    } else {
7483                        return Err(PyQLError::Type(PyQLTypeError {
7484                            message: "group by identifier must be a simple name".to_string(),
7485                            position: Position { line: 0, col: 0 },
7486                        }));
7487                    }
7488                }
7489                _ => {
7490                    return Err(PyQLError::Type(PyQLTypeError {
7491                        message: format!("unsupported group by expression: {:?}", by_expr),
7492                        position: Position { line: 0, col: 0 },
7493                    }));
7494                }
7495            }
7496        }
7497
7498        // `filter` restricts the grouped rows; `order by`/`offset`/`limit`
7499        // apply within each group, so they're compiled against the same
7500        // element scope the shape is.
7501        let td = self.resolve_type(&type_name)?;
7502        let synthetic = ast::SelectStmt {
7503            result: g.subject.clone(),
7504            filter: g.filter.clone(),
7505            order_by: g.order_by.clone(),
7506            offset: g.offset.clone(),
7507            limit: g.limit.clone(),
7508            lock: None,
7509        };
7510        let (filter, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7511        // A walked subject groups only the rows the walk reaches, not every
7512        // row of the type it happens to land on.
7513        let filter = match walked {
7514            Some(subject) => {
7515                let subject = subject.clone();
7516                let rows = self.compile_subject_path_rows(&subject, &alias)?;
7517                and_conditions(filter, vec![rows])
7518            }
7519            None => filter,
7520        };
7521
7522        Ok(IrGroup {
7523            source,
7524            shape,
7525            keys,
7526            filter,
7527            order_by,
7528            offset,
7529            limit,
7530            output: IrGroupOutput::Groups,
7531        })
7532    }
7533
7534    fn bind_group(&mut self, name: &str, expr: &Expr) -> Result<bool, PyQLError> {
7535        let Expr::SubQuery(stmt) = expr else {
7536            return Ok(false);
7537        };
7538        let Stmt::Group(g) = stmt.as_ref() else {
7539            return Ok(false);
7540        };
7541        let grp = self.compile_group(g)?;
7542        self.group_bindings.insert(name.to_string(), grp);
7543        Ok(true)
7544    }
7545
7546    /// `select (group S using k := E by k) { n := count(.elements) }`. Each
7547    /// pointer is compiled over the grouped rows of `S`: `.key.k` stands for
7548    /// `E` itself, which is constant within a group, and an aggregate over
7549    /// `.elements.p` for the same aggregate over `.p`, which the `GROUP BY`
7550    /// then takes per group.
7551    fn compile_group_projection(
7552        &mut self,
7553        sel: &ast::SelectStmt,
7554        g: &ast::GroupStmt,
7555        elements: &[ShapeElement],
7556    ) -> Result<IrGroup, PyQLError> {
7557        if sel.filter.is_some() {
7558            return Err(self.type_err("a shape over a `group` does not support filter"));
7559        }
7560        if !g.order_by.is_empty() || g.offset.is_some() || g.limit.is_some() {
7561            return Err(self.type_err(
7562                "a `group` read through a shape does not support order by, offset or limit on its elements",
7563            ));
7564        }
7565        let mut grp = self.compile_group(g)?;
7566        // `.key.k` reads the key as the `GROUP BY` already compiled it:
7567        // compiling it a second time would give a walk fresh aliases, and
7568        // Postgres only accepts a grouped expression it can match verbatim.
7569        let keys: Vec<String> = grp.keys.iter().map(|(name, _)| name.clone()).collect();
7570        for (name, key) in &grp.keys {
7571            self.inline_bindings.insert(group_key_binding(name), key.clone());
7572        }
7573        let compiled = self.compile_group_projection_parts(sel, &grp, &keys, elements);
7574        for name in &keys {
7575            self.inline_bindings.remove(&group_key_binding(name));
7576        }
7577        let (pointers, order_by, offset, limit) = compiled?;
7578        grp.output = IrGroupOutput::Projection(Box::new(IrGroupProjection {
7579            pointers,
7580            order_by,
7581            offset,
7582            limit,
7583        }));
7584        Ok(grp)
7585    }
7586
7587    #[allow(clippy::type_complexity)]
7588    fn compile_group_projection_parts(
7589        &mut self,
7590        sel: &ast::SelectStmt,
7591        grp: &IrGroup,
7592        keys: &[String],
7593        elements: &[ShapeElement],
7594    ) -> Result<(Vec<IrShapePointer>, Vec<IrSort>, Option<IrExpr>, Option<IrExpr>), PyQLError> {
7595        let td = self.resolve_type(&grp.source.type_name)?;
7596        let mut rewritten = Vec::with_capacity(elements.len());
7597        for element in elements {
7598            let (Some(compexpr), [ast::PathStep::Name(_)]) = (&element.compexpr, element.path.steps.as_slice()) else {
7599                return Err(self.type_err(
7600                    "only computed pointers (`name := …`) can be read off a `group` — e.g. `k := .key.name`",
7601                ));
7602            };
7603            let mut element = element.clone();
7604            element.compexpr = Some(self.rewrite_group_refs(compexpr, keys, td)?);
7605            rewritten.push(element);
7606        }
7607        let alias = grp.source.alias.clone();
7608        let module = td.module.clone();
7609        // `td` is here so `.key`/`.elements` resolve, but the row this
7610        // projects is the group's, not one of `td`'s: an `id` added to it
7611        // reads a column no GROUP BY covers.
7612        let pointers = self.without_implicit_id(|this| this.compile_shape(&rewritten, td, &alias, &module))?;
7613        let order_by = sel
7614            .order_by
7615            .iter()
7616            .map(|sort| {
7617                Ok(ast::SortExpr {
7618                    expr: self.rewrite_group_refs(&sort.expr, keys, td)?,
7619                    direction: sort.direction.clone(),
7620                    nones: sort.nones.clone(),
7621                })
7622            })
7623            .collect::<Result<Vec<_>, PyQLError>>()?;
7624        let synthetic = ast::SelectStmt {
7625            result: Expr::Path(ast::Path::absolute(grp.source.type_name.clone())),
7626            filter: None,
7627            order_by,
7628            offset: sel.offset.clone(),
7629            limit: sel.limit.clone(),
7630            lock: None,
7631        };
7632        let (_, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7633        Ok((pointers, order_by, offset, limit))
7634    }
7635
7636    fn rewrite_group_refs(&self, expr: &Expr, keys: &[String], td: &TypeDescriptor) -> Result<Expr, PyQLError> {
7637        const AGGREGATES: &[&str] = &["count", "sum", "min", "max", "avg", "array_agg", "all", "any"];
7638        let rewrite = |e: &Expr| self.rewrite_group_refs(e, keys, td);
7639        Ok(match expr {
7640            Expr::Path(p) if p.partial => {
7641                let name = match p.steps.first() {
7642                    Some(ast::PathStep::Name(name)) => name.as_str(),
7643                    _ => "",
7644                };
7645                match (name, p.steps.get(1..).unwrap_or_default()) {
7646                    ("key", [ast::PathStep::Name(key)]) if keys.contains(key) => {
7647                        Expr::Path(ast::Path::absolute(group_key_binding(key)))
7648                    }
7649                    ("key", [ast::PathStep::Name(key)]) => {
7650                        return Err(self.type_err(&format!("'{key}' is not a key of this group")));
7651                    }
7652                    ("elements", _) => {
7653                        return Err(self.type_err(
7654                            "`.elements` of a group can only be read through an aggregate over one \
7655                             of their properties — e.g. `count(.elements)`, `sum(.elements.amount)`",
7656                        ));
7657                    }
7658                    _ => {
7659                        return Err(self.type_err(&format!(
7660                            "a group has no pointer '{name}' — read `.key.<name>` or aggregate over `.elements`"
7661                        )));
7662                    }
7663                }
7664            }
7665            Expr::FunctionCall(f)
7666                if f.args.len() == 1
7667                    && f.kwargs.is_empty()
7668                    && f.module.as_deref().is_none_or(|m| m == "std")
7669                    && AGGREGATES.contains(&f.name.as_str())
7670                    && let Expr::Path(p) = &f.args[0]
7671                    && p.partial
7672                    && matches!(p.steps.first(), Some(ast::PathStep::Name(n)) if n == "elements") =>
7673            {
7674                let property = match p.steps.get(1..).unwrap_or_default() {
7675                    [] => "id",
7676                    [ast::PathStep::Name(prop)] if td.properties.iter().any(|d| &d.name == prop) => prop.as_str(),
7677                    _ => {
7678                        return Err(self.type_err(&format!(
7679                            "'{}' over a group's elements only reads one of their own properties",
7680                            f.name
7681                        )));
7682                    }
7683                };
7684                Expr::FunctionCall(ast::FunctionCall {
7685                    module: f.module.clone(),
7686                    name: f.name.clone(),
7687                    args: vec![Expr::Path(ast::Path::relative(property))],
7688                    kwargs: vec![],
7689                })
7690            }
7691            Expr::FunctionCall(f) => Expr::FunctionCall(ast::FunctionCall {
7692                module: f.module.clone(),
7693                name: f.name.clone(),
7694                args: f.args.iter().map(rewrite).collect::<Result<_, _>>()?,
7695                kwargs: f
7696                    .kwargs
7697                    .iter()
7698                    .map(|(k, v)| Ok((k.clone(), rewrite(v)?)))
7699                    .collect::<Result<_, PyQLError>>()?,
7700            }),
7701            Expr::BinOp(b) => Expr::BinOp(Box::new(ast::BinOp {
7702                left: rewrite(&b.left)?,
7703                op: b.op.clone(),
7704                right: rewrite(&b.right)?,
7705            })),
7706            Expr::UnaryOp(u) => Expr::UnaryOp(Box::new(ast::UnaryOp {
7707                op: u.op.clone(),
7708                operand: rewrite(&u.operand)?,
7709            })),
7710            Expr::TypeCast(c) => Expr::TypeCast(Box::new(ast::TypeCast {
7711                expr: rewrite(&c.expr)?,
7712                ty: c.ty.clone(),
7713            })),
7714            Expr::IfElse(ie) => Expr::IfElse(Box::new(ast::IfElse {
7715                if_expr: rewrite(&ie.if_expr)?,
7716                condition: rewrite(&ie.condition)?,
7717                else_expr: rewrite(&ie.else_expr)?,
7718            })),
7719            other => other.clone(),
7720        })
7721    }
7722
7723    /// `for g in (group S by k) union (select g.elements order by … limit n)`
7724    /// — the first `n` rows of `S` per key. `None` for any other `for`.
7725    fn compile_group_elements(&mut self, f: &ast::ForStmt) -> Result<Option<IrGroup>, PyQLError> {
7726        let Stmt::Select(body) = f.body.as_ref() else {
7727            return Ok(None);
7728        };
7729        let unmodified =
7730            body.filter.is_none() && body.order_by.is_empty() && body.offset.is_none() && body.limit.is_none();
7731        let (inner, shape) = match &body.result {
7732            Expr::Shape(sh) if unmodified => match &sh.expr {
7733                Some(Expr::SubQuery(stmt)) => match stmt.as_ref() {
7734                    Stmt::Select(inner) => (inner, Some(sh.elements.as_slice())),
7735                    _ => return Ok(None),
7736                },
7737                _ => return Ok(None),
7738            },
7739            _ => (body, None),
7740        };
7741        let reads_elements = matches!(&inner.result, Expr::Path(p) if !p.partial
7742            && matches!(p.steps.as_slice(), [ast::PathStep::Name(var), ast::PathStep::Name(elements)]
7743                if var == &f.var && elements == "elements"));
7744        if !reads_elements {
7745            return Ok(None);
7746        }
7747        let mut grp = match &f.iterator {
7748            Expr::SubQuery(stmt) => match stmt.as_ref() {
7749                Stmt::Group(g) => self.compile_group(g)?,
7750                _ => return Ok(None),
7751            },
7752            Expr::Path(p) if !p.partial => match p.steps.as_slice() {
7753                [ast::PathStep::Name(name)] => match self.group_bindings.get(name) {
7754                    Some(grp) => grp.clone(),
7755                    None => return Ok(None),
7756                },
7757                _ => return Ok(None),
7758            },
7759            _ => return Ok(None),
7760        };
7761        let ordered = !inner.order_by.is_empty() || inner.offset.is_some() || inner.limit.is_some();
7762        if ordered && (!grp.order_by.is_empty() || grp.offset.is_some() || grp.limit.is_some()) {
7763            return Err(self.type_err(
7764                "a group that already orders or limits its elements cannot be ordered or limited again by a `for` over it",
7765            ));
7766        }
7767        let td = self.resolve_type(&grp.source.type_name)?;
7768        let alias = grp.source.alias.clone();
7769        let synthetic = ast::SelectStmt {
7770            result: Expr::Path(ast::Path::absolute(grp.source.type_name.clone())),
7771            ..inner.clone()
7772        };
7773        let (filter, order_by, offset, limit) = self.compile_path_modifiers(&synthetic, td, &alias)?;
7774        grp.filter = and_conditions(grp.filter, filter.into_iter().collect());
7775        if ordered {
7776            grp.order_by = order_by;
7777            grp.offset = offset;
7778            grp.limit = limit;
7779        }
7780        if let Some(shape) = shape {
7781            let module = td.module.clone();
7782            grp.shape = self.compile_shape(shape, td, &alias, &module)?;
7783        }
7784        grp.output = IrGroupOutput::Elements;
7785        Ok(Some(grp))
7786    }
7787
7788    /// The key a single-link assignment stores, for the values that name a
7789    /// row rather than compute one. `None` for anything the generic
7790    /// expression route already handles.
7791    fn compile_link_key(&mut self, expr: &Expr, td: &TypeDescriptor, alias: &str) -> Result<Option<IrExpr>, PyQLError> {
7792        // A one-element set is that element — `credentials := {
7793        // (insert Credentials { … }) }` says the same thing as assigning the
7794        // insert directly.
7795        let value = match expr {
7796            Expr::Set(elements) if elements.len() == 1 => &elements[0],
7797            other => other,
7798        };
7799        match value {
7800            // `link := (select { <T>a.id, <T>b.id } limit 1)` — a set literal
7801            // of keys, read for the one value its clauses leave.
7802            Expr::SubQuery(inner_stmt)
7803                if matches!(inner_stmt.as_ref(), Stmt::Select(sel) if matches!(sel.result, Expr::Set(_))) =>
7804            {
7805                let IrStmt::Select(select) = self.compile_stmt(inner_stmt)? else {
7806                    return Ok(None);
7807                };
7808                if !select.rows.iter().all(|r| matches!(r, IrRowSource::Free(IrFreeExpr::Scalar(_)))) {
7809                    return Ok(None);
7810                }
7811                Ok(Some(IrExpr::ScalarSubquery(Box::new(select))))
7812            }
7813            // Link assignment via subquery: `company := (SELECT Company FILTER ...)`
7814            // Compile as a scalar subquery returning the target pk (the FK uuid).
7815            Expr::SubQuery(inner_stmt) => self.compile_link_subquery(inner_stmt).map(Some),
7816            // `created_by := account_of_transaction()` — an object-returning
7817            // function names the row to link to, so what is stored is its key,
7818            // the same as for a select. The schema itself writes this as a
7819            // link default.
7820            Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &["id".to_string()], None, None),
7821            // `credentials := (update c set { … }) if exists(c) else (insert
7822            // Credentials { … })` — each mutation carries its own condition,
7823            // so exactly one branch yields the row to link.
7824            Expr::IfElse(ie)
7825                if [&ie.if_expr, &ie.else_expr].iter().any(|b| {
7826                    matches!(b, Expr::SubQuery(s) if matches!(s.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)))
7827                }) =>
7828            {
7829                let Some(rewritten) = self.object_if_else_as_union(value) else {
7830                    return Ok(None);
7831                };
7832                self.compile_link_key(&rewritten, td, alias)
7833            }
7834            Expr::Union(a, b) => {
7835                let (Some(left), Some(right)) = (self.compile_link_key(a, td, alias)?, self.compile_link_key(b, td, alias)?)
7836                else {
7837                    return Ok(None);
7838                };
7839                // A hoisted mutation's key is read by subquery: joined into
7840                // the statement instead, the branch that wrote nothing would
7841                // leave no row to update.
7842                let hoisted = |key: IrExpr| match key {
7843                    IrExpr::ColumnRef { alias, column, .. } if column == "id" => IrExpr::CteRef {
7844                        name: alias,
7845                        scalar: false,
7846                        pg_type: None,
7847                    },
7848                    other => other,
7849                };
7850                Ok(Some(IrExpr::FunctionCall(IrFunctionCall {
7851                    return_pg_type: None,
7852                    schema: None,
7853                    name: "coalesce".to_string(),
7854                    args: vec![hoisted(left), hoisted(right)],
7855                    sql_template: None,
7856                })))
7857            }
7858            // `by := account_of_transaction() if cond else {}` — each branch
7859            // names a row (or none) the same way.
7860            Expr::IfElse(ie) => {
7861                if ![&ie.if_expr, &ie.else_expr].iter().any(|b| matches!(b, Expr::FunctionCall(_))) {
7862                    return Ok(None);
7863                }
7864                Ok(Some(IrExpr::IfElse(Box::new(IrIfElse {
7865                    condition: self.compile_expr(&ie.condition, td, alias)?,
7866                    if_: self.compile_link_branch(&ie.if_expr, td, alias)?,
7867                    else_: self.compile_link_branch(&ie.else_expr, td, alias)?,
7868                }))))
7869            }
7870            _ => Ok(None),
7871        }
7872    }
7873
7874    fn compile_link_branch(&mut self, branch: &Expr, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
7875        if matches!(branch, Expr::Set(elements) if elements.is_empty()) {
7876            return Ok(IrExpr::Null);
7877        }
7878        match self.compile_link_key(branch, td, alias)? {
7879            Some(key) => Ok(key),
7880            None => self.compile_expr(branch, td, alias),
7881        }
7882    }
7883
7884    /// `.teams { owning := … }.owning` — a walk, and a pointer its shape
7885    /// declares read back off each row it lands on. `None` for anything
7886    /// else, including a field the shape does not compute.
7887    fn compile_shape_field_select(
7888        &mut self,
7889        expr: &Expr,
7890        ctx: Option<(&TypeDescriptor, &str)>,
7891    ) -> Result<Option<IrPathSelect>, PyQLError> {
7892        let Expr::FieldAccess { expr: inner, field } = expr else {
7893            return Ok(None);
7894        };
7895        let Expr::Shape(sh) = inner.as_ref() else {
7896            return Ok(None);
7897        };
7898        let Some(Expr::Path(base)) = sh.expr.as_ref() else {
7899            return Ok(None);
7900        };
7901        let Some(value) = sh
7902            .elements
7903            .iter()
7904            .find(|el| matches!(el.path.steps.as_slice(), [ast::PathStep::Name(n)] if n == field))
7905            .and_then(|el| el.compexpr.clone())
7906        else {
7907            return Ok(None);
7908        };
7909        let (rooted, correlate) = if base.partial {
7910            let Some((td, alias)) = ctx else {
7911                return Ok(None);
7912            };
7913            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
7914            steps.extend(base.steps.iter().cloned());
7915            (ast::Path { steps, partial: false }, Some(alias.to_string()))
7916        } else if base.steps.len() > 1 {
7917            (base.clone(), None)
7918        } else if let [ast::PathStep::Name(root)] = base.steps.as_slice()
7919            && let Ok(root_td) = self.resolve_path_root(root)
7920        {
7921            // A walk needs a step: `T[is T]` lands on the same objects.
7922            let narrowing = ast::PathStep::TypeIntersection(ast::ObjectRef {
7923                module: Some(root_td.module.clone()),
7924                name: root_td.name.clone(),
7925            });
7926            (
7927                ast::Path {
7928                    steps: vec![base.steps[0].clone(), narrowing],
7929                    partial: false,
7930                },
7931                None,
7932            )
7933        } else {
7934            return Ok(None);
7935        };
7936        let synthetic = ast::SelectStmt {
7937            result: Expr::Path(rooted.clone()),
7938            filter: None,
7939            order_by: vec![],
7940            offset: None,
7941            limit: None,
7942            lock: None,
7943        };
7944        let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
7945        if let Some(alias) = correlate {
7946            Self::correlate_path_select(&mut ps, &alias);
7947        }
7948        let IrPathResult::Object { alias, type_name, .. } = &ps.result else {
7949            return Err(self.type_err(&format!(
7950                "'{field}' is read off a walk that lands on a value, which has no shape"
7951            )));
7952        };
7953        let (alias, type_name) = (alias.clone(), type_name.clone());
7954        let td = self.resolve_type(&type_name)?;
7955        let value = self.compile_expr(&value, td, &alias)?;
7956        // A computed standing for a set is an array per row; the field reads
7957        // its elements, so the rows of all of them make up one set.
7958        let value = if yields_array(&value) {
7959            IrExpr::FunctionCall(IrFunctionCall {
7960                return_pg_type: None,
7961                schema: None,
7962                name: "unnest".to_string(),
7963                args: vec![value],
7964                sql_template: None,
7965            })
7966        } else {
7967            value
7968        };
7969        ps.result = IrPathResult::Scalar(value, None);
7970        Ok(Some(ps))
7971    }
7972
7973    /// `l.addon.id` — an absolute walk that lands on a property, so it yields
7974    /// values rather than objects.
7975    fn is_scalar_walk(&self, expr: &Expr) -> bool {
7976        let Expr::Path(p) = expr else { return false };
7977        let (Some(ast::PathStep::Name(root)), false) = (p.steps.first(), p.partial) else {
7978            return false;
7979        };
7980        let (Some(ast::PathStep::Name(leaf)), [_, middle @ .., _]) = (p.steps.last(), p.steps.as_slice()) else {
7981            return false;
7982        };
7983        let Ok(root_td) = self.resolve_path_root(root) else {
7984            return false;
7985        };
7986        let owner = if middle.is_empty() {
7987            Some(root_td)
7988        } else {
7989            self.walk_path_types(root_td, middle, MAX_COMPUTED_SPLICES).1
7990        };
7991        owner.is_some_and(|td| Self::resolve_property(td, leaf).is_some())
7992    }
7993
7994    /// `a.id union b.id` — each operand compiled as the select it stands
7995    /// for, the rows of all of them concatenated.
7996    fn compile_scalar_union(&mut self, expr: &Expr) -> Result<IrStmt, PyQLError> {
7997        fn operands<'e>(expr: &'e Expr, out: &mut Vec<&'e Expr>) {
7998            match expr {
7999                Expr::Union(a, b) => {
8000                    operands(a, out);
8001                    operands(b, out);
8002                }
8003                other => out.push(other),
8004            }
8005        }
8006        let mut exprs = vec![];
8007        operands(expr, &mut exprs);
8008        let mut branches = Vec::with_capacity(exprs.len());
8009        for operand in exprs {
8010            let stmt = match operand {
8011                Expr::SubQuery(stmt) => stmt.as_ref().clone(),
8012                other => Stmt::Select(ast::SelectStmt {
8013                    result: other.clone(),
8014                    filter: None,
8015                    order_by: vec![],
8016                    offset: None,
8017                    limit: None,
8018                    lock: None,
8019                }),
8020            };
8021            branches.push(self.compile_stmt(&stmt)?);
8022        }
8023        let types: Vec<String> = branches.iter().map(cte_stmt_type).collect();
8024        let first = types.iter().find(|t| !t.is_empty()).cloned().unwrap_or_default();
8025        if let Some(other) = types
8026            .iter()
8027            .find(|t| t.contains("::") || (!t.is_empty() && **t != first))
8028        {
8029            let display = |t: &str| {
8030                if t.contains("::") {
8031                    t.to_string()
8032                } else {
8033                    pg_type_to_pyql(t).to_string()
8034                }
8035            };
8036            return Err(self.type_err(&format!(
8037                "operator 'UNION' cannot be applied to operands of type '{}' and '{}'",
8038                display(&first),
8039                display(other)
8040            )));
8041        }
8042        Ok(IrStmt::ScalarUnion(branches))
8043    }
8044
8045    /// Extract the target type name from a DML or inner SELECT statement.
8046    fn dml_subject_type(&self, stmt: &Stmt) -> Result<String, PyQLError> {
8047        match stmt {
8048            Stmt::Insert(ins) => Ok(ins.subject.qualified_name()),
8049            Stmt::Update(upd) => self.subject_type_name(&upd.subject),
8050            Stmt::Delete(del) => self.subject_type_name(&del.subject),
8051            Stmt::With(w) => self.dml_subject_type(&w.stmt),
8052            Stmt::For(f) => self.dml_subject_type(&f.body),
8053            Stmt::Analyze(inner) => self.dml_subject_type(inner),
8054            Stmt::Group(g) => self.expr_as_type_name(&g.subject),
8055            Stmt::Select(sel) => {
8056                // <Module::Type>expr — type name comes from the cast target
8057                if let Expr::TypeCast(tc) = &sel.result
8058                    && let Some((module, name)) = tc.ty.as_named()
8059                    && module.map(|m| m != "std").unwrap_or(false)
8060                {
8061                    return Ok(name.to_string());
8062                }
8063                // `select resource.revisions` — a walk names no type of its
8064                // own; what it lands on is the type the statement yields.
8065                if let Expr::Path(path) = &sel.result
8066                    && !path.partial
8067                    && path.steps.len() > 1
8068                    && let Some(ast::PathStep::Name(root)) = path.steps.first()
8069                    && let Ok(root_td) = self.resolve_path_root(root)
8070                    && let (_, Some(target)) = self.walk_path_types(root_td, &path.steps[1..], MAX_COMPUTED_SPLICES)
8071                {
8072                    return Ok(format!("{}::{}", target.module, target.name));
8073                }
8074                // SELECT-over-SELECT: get the type from the inner select's result
8075                let (type_name, _, _, _) = self.extract_type_and_shape(&sel.result)?;
8076                Ok(type_name)
8077            }
8078        }
8079    }
8080
8081    /// `id in (…)` over the rows a DML subject path traverses to, so a
8082    /// mutation written against a traversal touches those rows and no others.
8083    fn compile_subject_path_rows(&mut self, subject: &ast::Path, target_alias: &str) -> Result<IrExpr, PyQLError> {
8084        // Traversing to `.id` rather than stopping at the objects: a path that
8085        // ends on a link or a type intersection has nothing to project, and the
8086        // ids are what the narrowing compares against anyway.
8087        let mut steps = subject.steps.clone();
8088        steps.push(ast::PathStep::Name("id".to_string()));
8089        let ids = ast::Path { steps, partial: false };
8090        let synthetic = ast::SelectStmt {
8091            result: Expr::Path(ids.clone()),
8092            filter: None,
8093            order_by: vec![],
8094            offset: None,
8095            limit: None,
8096            lock: None,
8097        };
8098        let rows = self.compile_path_select(&synthetic, &ids, &[], false)?;
8099        Ok(IrExpr::BinOp(Box::new(IrBinOp {
8100            left: IrExpr::ColumnRef {
8101                alias: target_alias.to_string(),
8102                column: "id".to_string(),
8103                pg_type: "uuid".to_string(),
8104            },
8105            op: ast::BinOpKind::In,
8106            right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(rows)))),
8107        })))
8108    }
8109
8110    /// The type a DML subject names, however it is written: a type, a `with`
8111    /// binding, or a traversal that ends on one.
8112    fn subject_type_name(&self, subject: &Expr) -> Result<String, PyQLError> {
8113        if let Expr::Path(p) = subject
8114            && p.steps.len() > 1
8115            && let Some(ast::PathStep::Name(root)) = p.steps.first()
8116            && let Ok(root_td) = self.resolve_path_root(root)
8117            && let (_, Some(target)) = self.walk_path_types(root_td, &p.steps[1..], MAX_COMPUTED_SPLICES)
8118        {
8119            return Ok(format!("{}::{}", target.module, target.name));
8120        }
8121        // A subject that is just a `with` binding names rows, not a type, so
8122        // the type is the one the binding was bound to. `compile_update`
8123        // wants the binding's own name (it narrows the update to those rows);
8124        // a reader asking what type the statement yields wants this.
8125        if let Expr::Path(p) = subject
8126            && !p.partial
8127            && let [ast::PathStep::Name(root)] = p.steps.as_slice()
8128            && let Some(bound) = self.cte_object_type(root)
8129        {
8130            return Ok(bound);
8131        }
8132        self.expr_as_type_name(subject)
8133    }
8134
8135    fn expr_as_type_name(&self, expr: &Expr) -> Result<String, PyQLError> {
8136        if std::env::var("PYLON_DBG_SUBJ").is_ok() {
8137            eprintln!(
8138                "DBG subj {:.90?}
8139{}",
8140                expr,
8141                std::backtrace::Backtrace::force_capture()
8142            );
8143        }
8144        match expr {
8145            // `detached T` names the same type; the prefix only says the set
8146            // is not correlated with the enclosing one.
8147            Expr::Detached(inner) => self.expr_as_type_name(inner),
8148            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
8149                if let ast::PathStep::Name(n) = &p.steps[0] {
8150                    return Ok(n.clone());
8151                }
8152                Err(self.type_err("expected a type name"))
8153            }
8154            // `select (a union b)` as a SELECT-over-SELECT subject: every
8155            // branch carries the same object type, and that is the subject.
8156            Expr::Union(_, _) => {
8157                let branches = self
8158                    .object_union_branches(expr)
8159                    .ok_or_else(|| self.type_err("expected a type name as SELECT subject"))?;
8160                self.common_union_type(&branches)
8161                    .ok_or_else(|| self.type_err("expected a type name as SELECT subject"))
8162            }
8163            // `placed.line_items` — a walk names no type of its own; the type
8164            // meant is the one it lands on, which is how `dml_subject_type`
8165            // already reads the same path.
8166            Expr::Path(p)
8167                if !p.partial
8168                    && p.steps.len() > 1
8169                    && let Some(ast::PathStep::Name(root)) = p.steps.first()
8170                    && let Ok(root_td) = self.resolve_path_root(root)
8171                    && let (_, Some(target)) = self.walk_path_types(root_td, &p.steps[1..], MAX_COMPUTED_SPLICES) =>
8172            {
8173                Ok(format!("{}::{}", target.module, target.name))
8174            }
8175            _ => Err(self.type_err("expected a type name as SELECT subject")),
8176        }
8177    }
8178
8179    // ── INSERT ────────────────────────────────────────────────────────────────────
8180
8181    fn compile_insert(&mut self, ins: &ast::InsertStmt) -> Result<IrInsert, PyQLError> {
8182        // Isolate this insert's own nested-DML discoveries (see
8183        // `pending_nested_ctes`'s doc comment) from whatever an enclosing
8184        // compile (e.g. this insert itself being the nested DML inside an
8185        // *outer* insert's link value) had pending, so each level attaches
8186        // only its own CTEs to its own `IrInsert`.
8187        let outer_pending_nested_ctes = std::mem::take(&mut self.pending_nested_ctes);
8188        let type_name = ins.subject.qualified_name();
8189        let td = self.resolve_type(&type_name)?;
8190        if td.abstract_ && td.materialized {
8191            return Err(self.type_err(&format!(
8192                "cannot insert into interface type '{}::{}'; insert into a concrete type instead",
8193                td.module, td.name
8194            )));
8195        }
8196        let alias = self.fresh_alias();
8197        let target = IrSource {
8198            poly: None,
8199            type_name: format!("{}::{}", td.module, td.name),
8200            table: td.table.clone(),
8201            alias: alias.clone(),
8202        };
8203
8204        // Multi-link shape elements (`tags := ...` / `tags += ...`) populate
8205        // the junction table once the row exists; everything else goes
8206        // through the normal scalar/link assignment path.
8207        let mut shape = ins.shape.clone();
8208        for link in &td.links {
8209            let unset = !shape.iter().any(|el| path_leaf(&el.path).is_ok_and(|n| n == link.name));
8210            for rw in link.rewrites.iter().filter(|rw| rw.on & 1 != 0) {
8211                if let Some(value) = subject_default_insert(&rw.handler, &link.name)
8212                    && unset
8213                {
8214                    shape.push(ShapeElement {
8215                        path: ast::Path::relative(&link.name),
8216                        splat: None,
8217                        nested: None,
8218                        compexpr: Some(value),
8219                        op: ShapeOp::Assign,
8220                        filter: None,
8221                        order_by: vec![],
8222                        offset: None,
8223                        limit: None,
8224                        marker_offset: None,
8225                    });
8226                }
8227            }
8228        }
8229        // Pointer defaults the shape leaves out. Every default is applied by
8230        // expanding it into the insert's own shape;
8231        // the ones a column DEFAULT can hold are left to it here, so only the
8232        // rest — a default reading a session global, or selecting the object to
8233        // link to — is expanded.
8234        for (pointer, pyql) in inlined_pointer_defaults(td, self.schema) {
8235            if shape.iter().any(|el| path_leaf(&el.path).is_ok_and(|n| n == pointer)) {
8236                continue;
8237            }
8238            let value = crate::parse::parse_pointer_expr(&pyql)?;
8239            shape.push(default_shape_element(&pointer, value));
8240        }
8241        let mut multi_link_appends = vec![];
8242        let mut scalar_elements: Vec<ShapeElement> = vec![];
8243        for el in &shape {
8244            let pointer_name = match path_leaf(&el.path) {
8245                Ok(n) => n,
8246                Err(_) => {
8247                    scalar_elements.push(el.clone());
8248                    continue;
8249                }
8250            };
8251            if let Some(ml) = Self::resolve_multilink(td, pointer_name) {
8252                match el.op {
8253                    ShapeOp::Remove => {
8254                        return Err(self.type_err(&format!(
8255                            "cannot use `-=` for multi-link '{pointer_name}' in an insert; \
8256                         there is nothing to remove from yet"
8257                        )));
8258                    }
8259                    ShapeOp::Assign | ShapeOp::Append => {
8260                        if let Some(expr) = &el.compexpr {
8261                            let (jt, module, src_col, tgt_col, through_td) =
8262                                self.own_multilink_junction_info(td, ml)?;
8263                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8264                            multi_link_appends.push(IrMultiLinkMutation {
8265                                junction_table: jt,
8266                                module,
8267                                source_col: src_col,
8268                                target_col: tgt_col,
8269                                values,
8270                                single: false,
8271                            });
8272                        }
8273                    }
8274                }
8275            } else if let Some(l) = Self::resolve_link(td, pointer_name).filter(|l| l.is_junction_backed()) {
8276                // A junction-backed single link is "a multi-link capped to
8277                // one row" (D1) — on insert there's nothing to replace yet,
8278                // so `:=` populates the junction table the same way a
8279                // multi-link's own `:=`/`+=` does above.
8280                if matches!(el.op, ShapeOp::Remove) {
8281                    return Err(self.type_err(&format!(
8282                        "cannot use `-=` for link '{pointer_name}' in an insert; \
8283                         there is nothing to remove from yet"
8284                    )));
8285                }
8286                if let Some(expr) = &el.compexpr {
8287                    // `:= {}` / `:= <Type>{}` at insert time — same as
8288                    // omitting the pointer entirely: nothing to append, no
8289                    // junction row created yet.
8290                    if !is_empty_set_expr(expr) {
8291                        let (jt, module, src_col, tgt_col, through_td) = self.own_link_junction_info(td, l)?;
8292                        let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8293                        multi_link_appends.push(IrMultiLinkMutation {
8294                            junction_table: jt,
8295                            module,
8296                            source_col: src_col,
8297                            target_col: tgt_col,
8298                            values,
8299                            single: true,
8300                        });
8301                    }
8302                }
8303            } else {
8304                scalar_elements.push(el.clone());
8305            }
8306        }
8307
8308        let assignments = self.compile_assignments(&scalar_elements, td, &alias)?;
8309        // Rewrites apply in the table's own `BEFORE` trigger — see
8310        // `export::rewrite_trigger_infos`.
8311        let rewrites = vec![];
8312        let unless_conflict = match ins.unless_conflict.as_ref() {
8313            Some(uc) => {
8314                let (conflict, else_appends) = self.compile_conflict(uc, td)?;
8315                multi_link_appends.extend(else_appends);
8316                Some(conflict)
8317            }
8318            None => None,
8319        };
8320        let returning = Self::pk_returning(td);
8321        let type_name = format!("{}::{}", td.module, td.name);
8322        let enqueue_vector = td
8323            .vector_indexes
8324            .iter()
8325            .map(|vi| VectorEnqueueInfo {
8326                type_name: type_name.clone(),
8327                index_name: vi.index_name.clone(),
8328            })
8329            .collect();
8330        let enqueue_search = collect_search_enqueue(td, &type_name, "index");
8331        let nested_ctes = std::mem::replace(&mut self.pending_nested_ctes, outer_pending_nested_ctes);
8332
8333        let guard = match self.pending_insert_guard.take() {
8334            Some(condition) => Some(self.compile_expr(&condition, td, &alias)?),
8335            None => None,
8336        };
8337        // Only a `for` body's nested insert needs to name its own id (see
8338        // `IrInsert::id_default_sql`); carried here because the schema is in
8339        // scope, and ignored everywhere else.
8340        let id_default_sql =
8341            Self::resolve_property(td, "id").map(|p| p.default_sql.clone().unwrap_or_else(|| "uuidv7()".to_string()));
8342        Ok(IrInsert {
8343            guard,
8344            target,
8345            assignments,
8346            unless_conflict,
8347            rewrites,
8348            id_default_sql,
8349            returning,
8350            enqueue_vector,
8351            enqueue_search,
8352            multi_link_appends,
8353            nested_ctes,
8354        })
8355    }
8356
8357    fn compile_assignments(
8358        &mut self,
8359        elements: &[ShapeElement],
8360        td: &TypeDescriptor,
8361        alias: &str,
8362    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8363        self.compile_assignments_inner(elements, td, alias, false)
8364    }
8365
8366    fn compile_assignments_for_update(
8367        &mut self,
8368        elements: &[ShapeElement],
8369        td: &TypeDescriptor,
8370        alias: &str,
8371    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8372        self.compile_assignments_inner(elements, td, alias, true)
8373    }
8374
8375    fn compile_assignments_inner(
8376        &mut self,
8377        elements: &[ShapeElement],
8378        td: &TypeDescriptor,
8379        alias: &str,
8380        deny_readonly: bool,
8381    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8382        // A value assigned in a mutation body is not output, and a link value
8383        // among them compiles to a one-column correlated subquery — an implicit
8384        // `id` beside the column it selects would make that subquery illegal.
8385        self.without_implicit_id(|this| this.compile_assignments_rows(elements, td, alias, deny_readonly))
8386    }
8387
8388    fn compile_assignments_rows(
8389        &mut self,
8390        elements: &[ShapeElement],
8391        td: &TypeDescriptor,
8392        alias: &str,
8393        deny_readonly: bool,
8394    ) -> Result<Vec<(String, IrExpr)>, PyQLError> {
8395        elements
8396            .iter()
8397            .map(|el| {
8398                let pointer_name = path_leaf(&el.path)?;
8399                let expr = el.compexpr.as_ref().ok_or_else(|| {
8400                    PyQLError::Type(PyQLTypeError {
8401                        message: format!("INSERT pointer '{pointer_name}' has no value expression"),
8402                        position: Position { line: 0, col: 0 },
8403                    })
8404                })?;
8405
8406                // Validate the pointer exists
8407                let column = if let Some(p) = Self::resolve_property(td, pointer_name) {
8408                    // A primary-key ("id") property: an UPDATE never allows
8409                    // reassigning it (deny_readonly is true there, regardless
8410                    // of the session config); an INSERT only allows an
8411                    // explicit value when allow_user_specified_id is set.
8412                    if p.is_pk && (deny_readonly || !self.config.allow_user_specified_id) {
8413                        return Err(PyQLError::Type(PyQLTypeError {
8414                            message: "cannot assign to property 'id'".to_string(),
8415                            position: Position { line: 0, col: 0 },
8416                        }));
8417                    }
8418                    if deny_readonly && p.is_readonly {
8419                        return Err(PyQLError::Type(PyQLTypeError {
8420                            message: format!("cannot update property '{pointer_name}': it is declared as read-only"),
8421                            position: Position { line: 0, col: 0 },
8422                        }));
8423                    }
8424                    p.name.clone()
8425                } else if let Some(l) = Self::resolve_link(td, pointer_name) {
8426                    if deny_readonly && l.is_readonly {
8427                        return Err(PyQLError::Type(PyQLTypeError {
8428                            message: format!("cannot update link '{pointer_name}': it is declared as read-only"),
8429                            position: Position { line: 0, col: 0 },
8430                        }));
8431                    }
8432                    if l.is_junction_backed() {
8433                        // Reachable only via `UNLESS CONFLICT ... ELSE (UPDATE
8434                        // ... SET { ... })` — `compile_insert`/`compile_update`'s
8435                        // own shape-classification loop intercepts a junction-
8436                        // backed link before it ever reaches this generic
8437                        // scalar-assignment path; the ELSE clause has no
8438                        // junction-mutation mechanism of its own to reuse.
8439                        return Err(PyQLError::Type(PyQLTypeError {
8440                            message: format!(
8441                                "'{pointer_name}' is a junction-backed link and cannot be \
8442                                 assigned inside an UNLESS CONFLICT ELSE clause"
8443                            ),
8444                            position: Position { line: 0, col: 0 },
8445                        }));
8446                    }
8447                    // Link assignment via subquery: `company := (SELECT Company FILTER ...)`
8448                    // Compile as a scalar subquery returning the target pk (the FK uuid).
8449                    // A one-element set is that element — `credentials := {
8450                    // (insert Credentials { … }) }` says the same thing as
8451                    // assigning the insert directly.
8452                    let fk_col = format!("{}_id", l.name);
8453                    if let Some(ir_expr) = self.compile_link_key(expr, td, alias)? {
8454                        return Ok((fk_col, ir_expr));
8455                    }
8456                    fk_col
8457                } else if Self::resolve_multilink(td, pointer_name).is_some() {
8458                    // Same reach as the junction-backed link above: the
8459                    // shape-classification loop in `compile_insert` and
8460                    // `compile_update` takes multi-links first, so one only
8461                    // arrives here from an `UNLESS CONFLICT ... ELSE` clause,
8462                    // which has no junction-mutation mechanism to reuse.
8463                    // Saying the pointer does not exist sends the reader
8464                    // looking for a typo in a name that is plainly right.
8465                    return Err(PyQLError::Type(PyQLTypeError {
8466                        message: format!(
8467                            "'{pointer_name}' is a multi-link and cannot be mutated inside an \
8468                             UNLESS CONFLICT ELSE clause"
8469                        ),
8470                        position: Position { line: 0, col: 0 },
8471                    }));
8472                } else if Self::resolve_multilink(td, pointer_name).is_some() {
8473                    return Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)));
8474                } else if self.resolve_computed(td, pointer_name).is_some() {
8475                    // Reported as "no link or property 'x'. Did you mean 'x'?"
8476                    // before: the suggester's name list includes computeds,
8477                    // the resolution above does not, so it handed back the very
8478                    // name it had just refused.
8479                    return Err(self.type_err(&format!(
8480                        "cannot assign to '{pointer_name}': it is a computed pointer on \
8481                         {}::{}, which has no stored column to write",
8482                        td.module, td.name
8483                    )));
8484                } else {
8485                    return Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)));
8486                };
8487
8488                // `{}` (empty set) in assignment position means NULL.
8489                let ir_expr = if matches!(expr, Expr::Set(v) if v.is_empty()) {
8490                    IrExpr::Null
8491                } else {
8492                    self.compile_expr(expr, td, alias)?
8493                };
8494                Ok((column, ir_expr))
8495            })
8496            .collect()
8497    }
8498
8499    // ── UPDATE ────────────────────────────────────────────────────────────────────
8500
8501    fn compile_update(&mut self, upd: &ast::UpdateStmt) -> Result<IrUpdate, PyQLError> {
8502        // Taken before anything nested is compiled, so a nested statement
8503        // cannot pick up a guard meant for this one.
8504        let pending_guard = self.pending_update_guard.take();
8505        // See `compile_insert`'s identical save/restore of `pending_nested_ctes`.
8506        let outer_pending_nested_ctes = std::mem::take(&mut self.pending_nested_ctes);
8507        // `update (select T filter …).link set { … }` — the subject walks off a
8508        // sub-select rather than off a named root. The select is hoisted into
8509        // the statement's own WITH and the walk re-rooted at that binding,
8510        // giving the `with s := (select …) update s.link set { … }` spelling
8511        // the path branch below already handles.
8512        if !matches!(&upd.subject, Expr::Path(_)) {
8513            let (base, fields) = Self::peel_field_access_chain(&upd.subject);
8514            if let Expr::SubQuery(inner_stmt) = base
8515                && !fields.is_empty()
8516                && matches!(inner_stmt.as_ref(), Stmt::Select(_))
8517            {
8518                let inner = self.compile_stmt(inner_stmt)?;
8519                let cte_name = self.fresh_nested_cte_name();
8520                let type_name = self.register_cte(&cte_name, &inner);
8521                self.hoisted_ctes.push(IrCteDef {
8522                    name: cte_name.clone(),
8523                    stmt: inner,
8524                    type_name,
8525                    correlated_to: None,
8526                });
8527                let mut steps = vec![ast::PathStep::Name(cte_name)];
8528                steps.extend(fields.into_iter().map(ast::PathStep::Name));
8529                let rerooted = ast::UpdateStmt {
8530                    subject: Expr::Path(ast::Path { steps, partial: false }),
8531                    filter: upd.filter.clone(),
8532                    shape: upd.shape.clone(),
8533                };
8534                self.pending_nested_ctes = outer_pending_nested_ctes;
8535                self.pending_update_guard = pending_guard;
8536                return self.compile_update(&rerooted);
8537            }
8538        }
8539        // `update account.preferences[is IndividualPreferences] set …` — the
8540        // subject is a traversal rather than a name, so the rows to update are
8541        // the ones it lands on: the table is the type the walk ends on, and the
8542        // update is narrowed to the ids the traversal yields.
8543        if let Expr::Path(subject) = &upd.subject
8544            && subject.steps.len() > 1
8545            && let Some(ast::PathStep::Name(root)) = subject.steps.first()
8546            && let Ok(root_td) = self.resolve_path_root(root)
8547            && let (_, Some(target_td)) = self.walk_path_types(root_td, &subject.steps[1..], MAX_COMPUTED_SPLICES)
8548        {
8549            let narrowed = ast::UpdateStmt {
8550                subject: Expr::Path(ast::Path {
8551                    steps: vec![ast::PathStep::Name(format!("{}::{}", target_td.module, target_td.name))],
8552                    partial: false,
8553                }),
8554                filter: upd.filter.clone(),
8555                shape: upd.shape.clone(),
8556            };
8557            self.pending_nested_ctes = outer_pending_nested_ctes;
8558            self.pending_update_guard = pending_guard;
8559            let mut ir = self.compile_update(&narrowed)?;
8560            // Built after the update, so the comparison names that update's own
8561            // alias: with a nested statement's CTE in the FROM, a bare `id`
8562            // could mean either relation.
8563            let rows = self.compile_subject_path_rows(subject, &ir.target.alias)?;
8564            ir.filter = Some(and_conditions(ir.filter.take(), vec![rows]).expect("row set is present"));
8565            return Ok(ir);
8566        }
8567        let type_name = self.expr_as_type_name(&upd.subject)?;
8568        // `update account set …` where `account` is a `with` binding: the
8569        // binding names the rows to update, so it decides the table *and*
8570        // narrows the update to its own rows. Without the narrowing this would
8571        // resolve to the type and rewrite every row in the table.
8572        let bound_rows = self.cte_object_type(&type_name);
8573        // `for p in … union (update p set …)` — the same for a loop variable,
8574        // which names the one row this iteration holds. Without it the UPDATE
8575        // went out with no WHERE at all and rewrote every row of the table.
8576        let iterated = self
8577            .for_var_types
8578            .contains_key(&type_name)
8579            .then(|| self.for_var_ref(&type_name));
8580        let td = self.resolve_path_root(&type_name)?;
8581        let alias = self.fresh_alias();
8582        let target = IrSource {
8583            poly: None,
8584            type_name: format!("{}::{}", td.module, td.name),
8585            table: td.table.clone(),
8586            alias: alias.clone(),
8587        };
8588
8589        let declared_filter = upd
8590            .filter
8591            .as_ref()
8592            .map(|f| self.compile_expr(f, td, &alias))
8593            .transpose()?;
8594        let filter = match bound_rows {
8595            Some(_) => {
8596                let membership = IrExpr::BinOp(Box::new(IrBinOp {
8597                    left: IrExpr::ColumnRef {
8598                        alias: alias.clone(),
8599                        column: "id".to_string(),
8600                        pg_type: "uuid".to_string(),
8601                    },
8602                    op: ast::BinOpKind::In,
8603                    right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::Select(IrSelect::schema_bound(
8604                        IrSource {
8605                            poly: None,
8606                            type_name: format!("{}::{}", td.module, td.name),
8607                            table: format!("@cte:{type_name}"),
8608                            alias: self.fresh_alias(),
8609                        },
8610                        vec![],
8611                        None,
8612                    )))),
8613                }));
8614                Some(and_conditions(declared_filter, vec![membership]).expect("membership is present"))
8615            }
8616            None => declared_filter,
8617        };
8618        let filter = match iterated {
8619            Some(value) => {
8620                let this_row = IrExpr::BinOp(Box::new(IrBinOp {
8621                    left: IrExpr::ColumnRef {
8622                        alias: alias.clone(),
8623                        column: "id".to_string(),
8624                        pg_type: "uuid".to_string(),
8625                    },
8626                    op: ast::BinOpKind::Eq,
8627                    right: value,
8628                }));
8629                Some(and_conditions(filter, vec![this_row]).expect("the row condition is present"))
8630            }
8631            None => filter,
8632        };
8633        // `(update cart set { … }) if not exists(existing) else {}` — the
8634        // condition narrows the rows this update touches. Left to filter what
8635        // is read back instead, the update would still run.
8636        let filter = match pending_guard {
8637            Some(condition) => {
8638                let guard = self.compile_expr(&condition, td, &alias)?;
8639                and_conditions(filter, vec![guard])
8640            }
8641            None => filter,
8642        };
8643
8644        // Classify shape elements by kind.
8645        let mut multi_link_clears = vec![];
8646        let mut multi_link_replaces = vec![];
8647        let mut multi_link_appends = vec![];
8648        let mut multi_link_removals = vec![];
8649        let mut scalar_elements: Vec<ShapeElement> = vec![];
8650
8651        for el in &upd.shape {
8652            let pointer_name = match path_leaf(&el.path) {
8653                Ok(n) => n,
8654                Err(_) => {
8655                    scalar_elements.push(el.clone());
8656                    continue;
8657                }
8658            };
8659
8660            if let Some(ml) = Self::resolve_multilink(td, pointer_name) {
8661                let (jt, module, src_col, tgt_col, through_td) = self.own_multilink_junction_info(td, ml)?;
8662
8663                match el.op {
8664                    ShapeOp::Assign => {
8665                        let is_empty = el
8666                            .compexpr
8667                            .as_ref()
8668                            .map(|e| matches!(e, Expr::Set(v) if v.is_empty()))
8669                            .unwrap_or(false);
8670                        if is_empty {
8671                            // := {} — clear all junction rows
8672                            multi_link_clears.push(IrMultiLinkClear {
8673                                junction_table: jt,
8674                                module,
8675                                source_col: src_col,
8676                            });
8677                        } else if let Some(expr) = &el.compexpr {
8678                            // := expr — replace (clear + insert)
8679                            multi_link_clears.push(IrMultiLinkClear {
8680                                junction_table: jt.clone(),
8681                                module: module.clone(),
8682                                source_col: src_col.clone(),
8683                            });
8684                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8685                            multi_link_replaces.push(IrMultiLinkMutation {
8686                                junction_table: jt,
8687                                module,
8688                                source_col: src_col,
8689                                target_col: tgt_col,
8690                                values,
8691                                single: false,
8692                            });
8693                        }
8694                    }
8695                    ShapeOp::Append => {
8696                        if let Some(expr) = &el.compexpr {
8697                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8698                            multi_link_appends.push(IrMultiLinkMutation {
8699                                junction_table: jt,
8700                                module,
8701                                source_col: src_col,
8702                                target_col: tgt_col,
8703                                values,
8704                                single: false,
8705                            });
8706                        }
8707                    }
8708                    ShapeOp::Remove => {
8709                        if let Some(expr) = &el.compexpr {
8710                            let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8711                            if has_any_link_props(&values) {
8712                                return Err(self.type_err(
8713                                    "link properties (`@prop := value`) cannot be assigned \
8714                                     when removing a link (`-=`)",
8715                                ));
8716                            }
8717                            multi_link_removals.push(IrMultiLinkMutation {
8718                                junction_table: jt,
8719                                module,
8720                                source_col: src_col,
8721                                target_col: tgt_col,
8722                                values,
8723                                single: false,
8724                            });
8725                        }
8726                    }
8727                }
8728            } else if let Some(l) = Self::resolve_link(td, pointer_name).filter(|l| l.is_junction_backed()) {
8729                // Same replace-via-clear-then-insert shape a multi-link's
8730                // own `:=` uses — only `:=` is meaningful for a single
8731                // link, whether FK-backed or junction-backed.
8732                if !matches!(el.op, ShapeOp::Assign) {
8733                    return Err(self.type_err(&format!(
8734                        "'{pointer_name}' is a single link; only `:=` is supported, not `+=`/`-=`"
8735                    )));
8736                }
8737                let (jt, module, src_col, tgt_col, through_td) = self.own_link_junction_info(td, l)?;
8738                let is_empty = el.compexpr.as_ref().map(is_empty_set_expr).unwrap_or(false);
8739                if is_empty {
8740                    // := {} — clear the junction row
8741                    multi_link_clears.push(IrMultiLinkClear {
8742                        junction_table: jt,
8743                        module,
8744                        source_col: src_col,
8745                    });
8746                } else if let Some(expr) = &el.compexpr {
8747                    multi_link_clears.push(IrMultiLinkClear {
8748                        junction_table: jt.clone(),
8749                        module: module.clone(),
8750                        source_col: src_col.clone(),
8751                    });
8752                    let values = self.compile_multilink_values(expr, td, &alias, through_td)?;
8753                    multi_link_replaces.push(IrMultiLinkMutation {
8754                        junction_table: jt,
8755                        module,
8756                        source_col: src_col,
8757                        target_col: tgt_col,
8758                        values,
8759                        single: true,
8760                    });
8761                }
8762            } else {
8763                scalar_elements.push(el.clone());
8764            }
8765        }
8766
8767        let assignments = self.compile_assignments_for_update(&scalar_elements, td, &alias)?;
8768        // See the insert's own: rewrites apply in the table's `BEFORE` trigger.
8769        let rewrites = vec![];
8770        let returning = Self::pk_returning(td);
8771
8772        let (poly_implementors, poly_columns) = if self.is_polymorphic(td) {
8773            (
8774                self.find_poly_implementors(&format!("{}::{}", td.module, td.name)),
8775                Self::poly_dml_columns(td),
8776            )
8777        } else {
8778            (vec![], vec![])
8779        };
8780
8781        // Only enqueue indexes whose source pointers are touched by this update.
8782        let written_cols: std::collections::HashSet<&str> = assignments.iter().map(|(c, _)| c.as_str()).collect();
8783        let type_name = format!("{}::{}", td.module, td.name);
8784        let enqueue_vector: Vec<VectorEnqueueInfo> = td
8785            .vector_indexes
8786            .iter()
8787            .filter(|vi| vi.pointers.iter().any(|f| written_cols.contains(f.as_str())))
8788            .map(|vi| VectorEnqueueInfo {
8789                type_name: type_name.clone(),
8790                index_name: vi.index_name.clone(),
8791            })
8792            .collect();
8793        let enqueue_search = collect_search_enqueue(td, &type_name, "index");
8794        // Nested-DML CTEs (see IrUpdate::nested_ctes) compose with every
8795        // other UPDATE shape — multi-link mutation, interface-type fan-out,
8796        // and vector/search enqueue each have their own emitter branch
8797        // (emit_update_stmt / emit_poly_update_stmt) that now prepends
8798        // these CTEs and adds the FROM clause a hoisted CTE reference needs.
8799        let nested_ctes = std::mem::replace(&mut self.pending_nested_ctes, outer_pending_nested_ctes);
8800
8801        Ok(IrUpdate {
8802            target,
8803            filter,
8804            assignments,
8805            rewrites,
8806            returning,
8807            multi_link_clears,
8808            multi_link_replaces,
8809            multi_link_appends,
8810            multi_link_removals,
8811            poly_implementors,
8812            poly_columns,
8813            enqueue_vector,
8814            enqueue_search,
8815            nested_ctes,
8816        })
8817    }
8818
8819    /// Extract junction table info for a multi-link (or a junction-backed
8820    /// single link, which shares this exact storage shape — D2):
8821    /// (junction_table, module, source_col, target_col, through_td).
8822    /// `through_td` is the junction type's own TypeDescriptor for a
8823    /// `Through[...]` link (needed to validate/compile `@prop := expr`
8824    /// link-property assignments against its real properties) — `None` for
8825    /// a Standard (implicit) junction table, which has no user-declared
8826    /// properties at all.
8827    fn junction_info_for(
8828        &mut self,
8829        td: &TypeDescriptor,
8830        name: &str,
8831        target: &str,
8832        through: &Option<String>,
8833    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
8834        let (junction_table, module, source_col, target_col, through_td) =
8835            self.own_junction_info_for(td, name, target, through)?;
8836        let owner_derived = through_td.is_none_or(|through_td| through_td.junction);
8837        let junction_table = if owner_derived {
8838            self.owner_junction(td, name, through_td)
8839        } else {
8840            junction_table
8841        };
8842        Ok((junction_table, module, source_col, target_col, through_td))
8843    }
8844
8845    /// Every junction table a read of `td`'s multi-links can name, quoted as
8846    /// the emitter spells it. A `through` type brings its own table; an
8847    /// owner-derived junction is named after the owner, and a subtype's
8848    /// carries that subtype's table, so each implementor contributes one.
8849    fn junction_tables_of(&mut self, td: &TypeDescriptor) -> Result<Vec<String>, PyQLError> {
8850        let mut tables = vec![];
8851        for multilink in &td.multilinks {
8852            match &multilink.through {
8853                Some(through) => {
8854                    let through_td = self.resolve_type(through)?;
8855                    tables.push(format!("\"{}\"", through_td.table));
8856                }
8857                None => {
8858                    tables.push(format!("\"{}.{}\"", td.table, multilink.name));
8859                    for implementor in self.find_poly_implementors(&format!("{}::{}", td.module, td.name)) {
8860                        tables.push(format!("\"{}.{}\"", implementor.table, multilink.name));
8861                    }
8862                }
8863            }
8864        }
8865        Ok(tables)
8866    }
8867
8868    /// The junction a read of `td`'s multi-link `name` goes through: its own
8869    /// table, or, when `td` has subtypes, the union of theirs beside it.
8870    fn owner_junction(&self, td: &TypeDescriptor, name: &str, through_td: Option<&TypeDescriptor>) -> String {
8871        let own = format!("{}.{}", td.table, name);
8872        if !self.has_subtypes(td) {
8873            return own;
8874        }
8875        let tables = self
8876            .find_poly_implementors(&format!("{}::{}", td.module, td.name))
8877            .into_iter()
8878            .map(|implementor| (implementor.module, format!("{}.{}", implementor.table, name)))
8879            .collect::<Vec<_>>();
8880        let mut columns = vec!["source".to_string(), "target".to_string()];
8881        if let Some(through_td) = through_td {
8882            columns.extend(Self::poly_dml_columns(through_td).into_iter().filter(|c| c != "id"));
8883        }
8884        super::inherited_junction(&tables, &columns)
8885    }
8886
8887    /// `junction_info_for` naming the owner's own junction table, which is
8888    /// the one a write to that owner's rows goes to.
8889    fn own_junction_info_for(
8890        &mut self,
8891        td: &TypeDescriptor,
8892        name: &str,
8893        target: &str,
8894        through: &Option<String>,
8895    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
8896        match through {
8897            None => Ok((
8898                format!("{}.{}", td.table, name),
8899                td.module.clone(),
8900                "source".to_string(),
8901                "target".to_string(),
8902                None,
8903            )),
8904            Some(through_qname) => {
8905                let through_td = self.resolve_type(through_qname)?;
8906                let src_type = format!("{}::{}", td.module, td.name);
8907                let source_col = through_td
8908                    .links
8909                    .iter()
8910                    .find(|l| l.target == src_type)
8911                    .map(|l| l.name.clone())
8912                    .unwrap_or_else(|| "source".to_string());
8913                // A self-referencing through-link (source type == target
8914                // type, e.g. Person.friends via a PersonFriend with two
8915                // Person-typed links) would otherwise match the same link
8916                // for both sides — prefer a differently-named one first,
8917                // matching the tie-break already used for the read-side
8918                // join resolution elsewhere in this file.
8919                let target_col = through_td
8920                    .links
8921                    .iter()
8922                    .find(|l| l.target == target && l.name != source_col)
8923                    .or_else(|| through_td.links.iter().find(|l| l.target == target))
8924                    .map(|l| l.name.clone())
8925                    .unwrap_or_else(|| "target".to_string());
8926                // A dedicated `@pylon.junction` type has no physical table
8927                // of its own — `emit_one_junction_table` always names it
8928                // `"{owner.table}.{name}"`, one per *owner* (so that e.g.
8929                // an interface-inherited junction-backed link gets one
8930                // physically separate table per concrete implementor,
8931                // never a single table shared — and thus impossibly
8932                // FK'd — across several). `through_td.table` only holds
8933                // the right name here by accident, for the common case of
8934                // exactly one owner ever referencing that junction type
8935                // (the Python walker pre-renames it for that one owner).
8936                // A non-junction "through" type, in contrast, *is* a real,
8937                // independently-queryable object with its own genuine
8938                // table — `through_td.table` is correct for that case and
8939                // must stay as-is.
8940                let (junction_table, junction_module) = if through_td.junction {
8941                    (format!("{}.{}", td.table, name), td.module.clone())
8942                } else {
8943                    (through_td.table.clone(), through_td.module.clone())
8944                };
8945                Ok((
8946                    junction_table,
8947                    junction_module,
8948                    source_col,
8949                    target_col,
8950                    Some(through_td),
8951                ))
8952            }
8953        }
8954    }
8955
8956    fn multilink_junction_info(
8957        &mut self,
8958        td: &TypeDescriptor,
8959        ml: &MultiLinkDescriptor,
8960    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
8961        self.junction_info_for(td, &ml.name, &ml.target, &ml.through)
8962    }
8963
8964    fn own_multilink_junction_info(
8965        &mut self,
8966        td: &TypeDescriptor,
8967        ml: &MultiLinkDescriptor,
8968    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
8969        self.own_junction_info_for(td, &ml.name, &ml.target, &ml.through)
8970    }
8971
8972    fn own_link_junction_info(
8973        &mut self,
8974        td: &TypeDescriptor,
8975        l: &LinkDescriptor,
8976    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
8977        self.own_junction_info_for(td, &l.name, &l.target, &l.through)
8978    }
8979
8980    /// Same as `multilink_junction_info`, for a junction-backed single link.
8981    fn link_junction_info(
8982        &mut self,
8983        td: &TypeDescriptor,
8984        l: &LinkDescriptor,
8985    ) -> Result<(String, String, String, String, Option<&'a TypeDescriptor>), PyQLError> {
8986        self.junction_info_for(td, &l.name, &l.target, &l.through)
8987    }
8988
8989    /// Build the `IrMultiLinkJoin` a multi-link or junction-backed single
8990    /// link's forward read-side join uses (`IrPathJoin::Multi`, a shape's
8991    /// `IrMultiLinkPointer`, or — for a junction-backed single link — the
8992    /// junction variant of `IrSingleLinkCorrelation`).
8993    fn build_multilink_join(
8994        &mut self,
8995        td: &TypeDescriptor,
8996        name: &str,
8997        target: &str,
8998        through: &Option<String>,
8999    ) -> Result<IrMultiLinkJoin, PyQLError> {
9000        if let Some(through_qname) = through {
9001            let through_td = self.resolve_type(through_qname)?;
9002            if through_td.junction {
9003                // See `junction_info_for`'s doc comment: a dedicated
9004                // junction type's physical table is always owner-derived
9005                // (one per concrete implementor), never `through_td.table`
9006                // itself.
9007                Ok(IrMultiLinkJoin::Standard {
9008                    junction_table: self.owner_junction(td, name, Some(through_td)),
9009                    module: td.module.clone(),
9010                })
9011            } else {
9012                let source_qname = format!("{}::{}", td.module, td.name);
9013                let source_col = through_td
9014                    .links
9015                    .iter()
9016                    .find(|l| l.target == source_qname)
9017                    .ok_or_else(|| {
9018                        PyQLError::Type(PyQLTypeError {
9019                            message: format!("through type {through_qname} has no link to {source_qname}"),
9020                            position: Position { line: 0, col: 0 },
9021                        })
9022                    })?
9023                    .name
9024                    .clone();
9025                let target_col = through_td
9026                    .links
9027                    .iter()
9028                    .find(|l| l.target == target && l.name != source_col)
9029                    .or_else(|| through_td.links.iter().find(|l| l.target == target))
9030                    .ok_or_else(|| {
9031                        PyQLError::Type(PyQLTypeError {
9032                            message: format!("through type {through_qname} has no link to target {target}"),
9033                            position: Position { line: 0, col: 0 },
9034                        })
9035                    })?
9036                    .name
9037                    .clone();
9038                Ok(IrMultiLinkJoin::Through {
9039                    junction_table: through_td.table.clone(),
9040                    module: through_td.module.clone(),
9041                    source_col,
9042                    target_col,
9043                })
9044            }
9045        } else {
9046            let junction_table = self.owner_junction(td, name, None);
9047            Ok(IrMultiLinkJoin::Standard {
9048                junction_table: match self.junction_read_overrides.get(&junction_table) {
9049                    Some(o) => o.junction.clone(),
9050                    None => junction_table,
9051                },
9052                module: td.module.clone(),
9053            })
9054        }
9055    }
9056
9057    /// Compile the RHS of a multilink `+=`, `-=`, or `:= expr` into an
9058    /// `IrMultiLinkValues`. `td`/`alias` are the record being updated (link-
9059    /// property value expressions like `@weight := <float64>$w` compile
9060    /// against this scope, same as any other UPDATE SET assignment — they
9061    /// cannot reference the linked target's own properties, only the outer
9062    /// record's or bound params/literals). `through_td` is the junction
9063    /// type's own TypeDescriptor for a `Through[...]` multi-link, or `None`
9064    /// for a Standard junction (which has no properties to assign).
9065    fn compile_multilink_values(
9066        &mut self,
9067        expr: &Expr,
9068        td: &'a TypeDescriptor,
9069        alias: &str,
9070        through_td: Option<&'a TypeDescriptor>,
9071    ) -> Result<IrMultiLinkValues, PyQLError> {
9072        // Same reason as `compile_assignments_inner`: these rows are the
9073        // junction's target ids, not output, and each value is read as one
9074        // column.
9075        self.without_implicit_id(|this| this.compile_multilink_values_inner(expr, td, alias, through_td))
9076    }
9077
9078    fn compile_multilink_values_inner(
9079        &mut self,
9080        expr: &Expr,
9081        td: &'a TypeDescriptor,
9082        alias: &str,
9083        through_td: Option<&'a TypeDescriptor>,
9084    ) -> Result<IrMultiLinkValues, PyQLError> {
9085        // `a union b` — combine both sides; each keeps its own link_props
9086        // (different targets in one `+=` can carry different property values).
9087        if let Expr::Union(a, b) = expr {
9088            let left = self.compile_multilink_values(a, td, alias, through_td)?;
9089            let right = self.compile_multilink_values(b, td, alias, through_td)?;
9090            return Ok(IrMultiLinkValues {
9091                source: IrMultiLinkValueSource::Union(Box::new(left), Box::new(right)),
9092                link_props: vec![],
9093            });
9094        }
9095
9096        // `emails := (insert …) if exists($email) else {}` — the same choice
9097        // between two sets the select path reads, and the same rewrite: the
9098        // insert carries the condition itself, since Postgres runs a
9099        // data-modifying CTE whether or not anything reads it.
9100        if matches!(expr, Expr::IfElse(_))
9101            && let Some(rewritten) = self.object_if_else_as_union(expr)
9102        {
9103            return self.compile_multilink_values(&rewritten, td, alias, through_td);
9104        }
9105
9106        // `notifications += (select .notifications { @read_at := … } filter …)`
9107        // — the link properties sit *inside* the select. Lifted out, the shape
9108        // branch below assigns them and the bare select goes down the
9109        // relative-path branch, which is what each already knows how to do.
9110        if let Expr::SubQuery(inner) = expr
9111            && let Stmt::Select(sel) = inner.as_ref()
9112            && let Expr::Shape(sh) = &sel.result
9113            && !sh.elements.is_empty()
9114            && sh
9115                .elements
9116                .iter()
9117                .all(|el| matches!(el.path.steps.as_slice(), [ast::PathStep::LinkProp(_)]))
9118            && let Some(base) = sh.expr.clone()
9119        {
9120            let lifted = Expr::Shape(Box::new(ast::ShapeExpr {
9121                expr: Some(Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
9122                    result: base,
9123                    ..sel.clone()
9124                })))),
9125                elements: sh.elements.clone(),
9126                marker_offset: sh.marker_offset,
9127            }));
9128            return self.compile_multilink_values(&lifted, td, alias, through_td);
9129        }
9130
9131        // `expr { @prop := value, ... }` — link-property assignments layered
9132        // onto an inner target-selecting expression.
9133        if let Expr::Shape(shape) = expr {
9134            let inner_expr = shape
9135                .expr
9136                .as_ref()
9137                .ok_or_else(|| self.type_err("multilink value shape must have a base expression"))?;
9138            let mut inner = self.compile_multilink_values(inner_expr, td, alias, through_td)?;
9139            // `(select .notifications { @read_at := … @read_at })` — over a
9140            // walk of a link with properties, `@prop` reads the row's current
9141            // value off the junction that walk crosses.
9142            let walked_junction = match (&inner.source, inner_expr) {
9143                (IrMultiLinkValueSource::PathSelect(ps), Expr::SubQuery(stmt)) => {
9144                    match (stmt.as_ref(), ps.joins.last()) {
9145                        (Stmt::Select(sel), Some(IrPathJoin::Multi { junction_alias, .. })) => match &sel.result {
9146                            Expr::Path(p) if p.partial => match p.steps.as_slice() {
9147                                [ast::PathStep::Name(link)] => Self::resolve_multilink(td, link)
9148                                    .and_then(|m| m.through.clone())
9149                                    .map(|through| (through, junction_alias.clone())),
9150                                _ => None,
9151                            },
9152                            _ => None,
9153                        },
9154                        _ => None,
9155                    }
9156                }
9157                _ => None,
9158            };
9159
9160            let Some(through) = through_td else {
9161                return Err(self.type_err(
9162                    "link properties (`@prop := value`) are only valid on a multi-link \
9163                     declared with `Through[...]`",
9164                ));
9165            };
9166
9167            for el in &shape.elements {
9168                let prop_name = match el.path.steps.as_slice() {
9169                    [ast::PathStep::LinkProp(name)] => name.clone(),
9170                    _ => return Err(self.type_err("only `@prop := value` link-property assignments are valid here")),
9171                };
9172                let prop = Self::resolve_property(through, &prop_name)
9173                    .ok_or_else(|| self.field_err(&prop_name, &format!("{}::{}", through.module, through.name)))?;
9174                if prop.is_readonly {
9175                    return Err(self.type_err(&format!(
9176                        "cannot set link property '{prop_name}': it is declared as read-only"
9177                    )));
9178                }
9179                let value_expr = el
9180                    .compexpr
9181                    .as_ref()
9182                    .ok_or_else(|| self.type_err(&format!("link property '{prop_name}' must be assigned a value")))?;
9183                let scoped = walked_junction.is_some();
9184                if scoped {
9185                    self.link_prop_scope.push(walked_junction.clone());
9186                }
9187                let ir_expr = self.compile_expr(value_expr, td, alias);
9188                if scoped {
9189                    self.link_prop_scope.pop();
9190                }
9191                inner.link_props.push((prop.name.clone(), ir_expr?));
9192            }
9193            return Ok(inner);
9194        }
9195
9196        // `prices := assert_distinct(<union>)` — the assert checks the targets
9197        // before they become junction rows, and passes them through otherwise.
9198        if let Expr::FunctionCall(f) = expr
9199            && (f.module.is_none() || f.module.as_deref() == Some("std"))
9200            && matches!(f.name.as_str(), "assert_exists" | "assert_distinct")
9201            && let [arg] = f.args.as_slice()
9202        {
9203            let arg = arg.clone();
9204            let inner = self.compile_multilink_values(&arg, td, alias, through_td)?;
9205            // The ids travel through the check as a plain array and come back
9206            // unnested, which leaves no row for a per-target link property to
9207            // ride along on.
9208            let mut props = vec![];
9209            crate::sql::collect_link_prop_names(&inner, &mut props);
9210            if !props.is_empty() {
9211                return Err(self.type_err(&format!(
9212                    "'{}' cannot be applied to a link that carries link properties ({}) —                      the check reads the targets alone",
9213                    f.name,
9214                    props.join(", "),
9215                )));
9216            }
9217            let message = self.assert_message(f, Some((td, alias)))?;
9218            return Ok(IrMultiLinkValues {
9219                source: IrMultiLinkValueSource::Asserted {
9220                    fn_name: f.name.clone(),
9221                    inner: Box::new(inner),
9222                    message,
9223                },
9224                link_props: vec![],
9225            });
9226        }
9227
9228        // `translations := (select { teaser, title })` — a select with no
9229        // clauses of its own is the expression it wraps, and the forms below
9230        // already know what to do with that. Left wrapped, a free row source
9231        // (a set literal, a union) reaches the generic subquery arm and is
9232        // refused, while the identical unwrapped `{ teaser, title }` compiles.
9233        if let Expr::SubQuery(inner) = expr
9234            && let Stmt::Select(sel) = inner.as_ref()
9235            && sel.filter.is_none()
9236            && sel.order_by.is_empty()
9237            && sel.offset.is_none()
9238            && sel.limit.is_none()
9239            && matches!(sel.result, Expr::Set(_) | Expr::Union(_, _))
9240        {
9241            let result = sel.result.clone();
9242            return self.compile_multilink_values(&result, td, alias, through_td);
9243        }
9244
9245        // CTE reference: bare name matching a registered CTE
9246        if let Some(name) = self.resolve_cte_name(expr) {
9247            return Ok(IrMultiLinkValues {
9248                source: IrMultiLinkValueSource::CteRef(name.to_string()),
9249                link_props: vec![],
9250            });
9251        }
9252
9253        // `{ a, b }` — a set literal of targets, each contributing its own rows.
9254        if let Expr::Set(elements) = expr
9255            && !elements.is_empty()
9256        {
9257            let mut combined: Option<IrMultiLinkValues> = None;
9258            for element in elements {
9259                let one = self.compile_multilink_values(element, td, alias, through_td)?;
9260                combined = Some(match combined {
9261                    None => one,
9262                    Some(previous) => IrMultiLinkValues {
9263                        source: IrMultiLinkValueSource::Union(Box::new(previous), Box::new(one)),
9264                        link_props: vec![],
9265                    },
9266                });
9267            }
9268            return Ok(combined.expect("elements is non-empty"));
9269        }
9270
9271        // `social_accounts := (for p in … union (insert …))` — the loop's rows
9272        // are the link's targets, read back from the CTE it is hoisted into,
9273        // the same way a bare nested insert is.
9274        if let Expr::SubQuery(inner) = expr
9275            && let Stmt::For(_) = inner.as_ref()
9276        {
9277            let (cte_name, _) = self.hoist_dml_as_cte(inner.as_ref())?;
9278            return Ok(IrMultiLinkValues {
9279                source: IrMultiLinkValueSource::CteRef(cte_name),
9280                link_props: vec![],
9281            });
9282        }
9283
9284        // `emails := (insert Email { … })` — a nested insert whose rows become
9285        // the link's targets, read back from the CTE it is hoisted into.
9286        if let Expr::SubQuery(inner) = expr
9287            && matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
9288        {
9289            let cte_name = self.hoist_nested_dml(inner)?;
9290            return Ok(IrMultiLinkValues {
9291                source: IrMultiLinkValueSource::CteRef(cte_name),
9292                link_props: vec![],
9293            });
9294        }
9295
9296        // `emails := (select .emails filter .primary)` — a relative path here
9297        // is relative to the row being written, so it is rooted at that type
9298        // and correlated back to it; compiled bare it has no object to
9299        // resolve against and reads as a free select.
9300        if let Expr::SubQuery(inner) = expr
9301            && let Stmt::Select(sel) = inner.as_ref()
9302            && let Expr::Path(path) = &sel.result
9303            && path.partial
9304        {
9305            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
9306            steps.extend(path.steps.iter().cloned());
9307            let rooted = ast::Path { steps, partial: false };
9308            let synthetic = ast::SelectStmt {
9309                result: Expr::Path(rooted.clone()),
9310                filter: sel.filter.clone(),
9311                order_by: sel.order_by.clone(),
9312                offset: sel.offset.clone(),
9313                limit: sel.limit.clone(),
9314                lock: None,
9315            };
9316            let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
9317            Self::correlate_path_select(&mut ps, alias);
9318            return Ok(IrMultiLinkValues {
9319                source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9320                link_props: vec![],
9321            });
9322        }
9323
9324        // Parenthesised subquery
9325        if let Expr::SubQuery(inner) = expr {
9326            return match self.compile_stmt(inner)? {
9327                IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
9328                    Ok(IrMultiLinkValues {
9329                        source: IrMultiLinkValueSource::Select(Box::new(s)),
9330                        link_props: vec![],
9331                    })
9332                }
9333                IrStmt::PathSelect(ps) => Ok(IrMultiLinkValues {
9334                    source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9335                    link_props: vec![],
9336                }),
9337                _ => Err(self.type_err("multilink value must resolve to a SELECT or path query")),
9338            };
9339        }
9340
9341        if let Expr::FunctionCall(fc) = expr {
9342            let synthetic = ast::SelectStmt {
9343                result: expr.clone(),
9344                filter: None,
9345                order_by: vec![],
9346                offset: None,
9347                limit: None,
9348                lock: None,
9349            };
9350            if let Some(fs) = self.try_compile_fn_object_select(fc, &[], &synthetic, false)? {
9351                return Ok(IrMultiLinkValues {
9352                    source: IrMultiLinkValueSource::Function(Box::new(fs)),
9353                    link_props: vec![],
9354                });
9355            }
9356        }
9357
9358        // Absolute path expression (type reference or path traversal)
9359        if let Expr::Path(p) = expr
9360            && !p.partial
9361        {
9362            let fake_sel = ast::SelectStmt {
9363                result: expr.clone(),
9364                filter: None,
9365                order_by: vec![],
9366                offset: None,
9367                limit: None,
9368                lock: None,
9369            };
9370            return match self.compile_stmt(&Stmt::Select(fake_sel))? {
9371                IrStmt::PathSelect(ps) => Ok(IrMultiLinkValues {
9372                    source: IrMultiLinkValueSource::PathSelect(Box::new(ps)),
9373                    link_props: vec![],
9374                }),
9375                IrStmt::Select(s) if matches!(s.rows.as_slice(), [IrRowSource::Bound { .. }]) => {
9376                    Ok(IrMultiLinkValues {
9377                        source: IrMultiLinkValueSource::Select(Box::new(s)),
9378                        link_props: vec![],
9379                    })
9380                }
9381                _ => Err(self.type_err("expected a path expression for multilink value")),
9382            };
9383        }
9384
9385        Err(self.type_err("multilink value must be a CTE reference, parenthesised subquery, or type path"))
9386    }
9387
9388    // ── DELETE ────────────────────────────────────────────────────────────────────
9389
9390    fn compile_delete(&mut self, del: &ast::DeleteStmt) -> Result<IrDelete, PyQLError> {
9391        // Taken before anything nested is compiled, so a nested statement
9392        // cannot pick up a guard meant for this one.
9393        let pending_guard = self.pending_delete_guard.take();
9394        let type_name = self.expr_as_type_name(&del.subject)?;
9395        // `delete previous` where `previous` is a `with` binding: the binding
9396        // names the rows to delete, so it decides the table *and* narrows the
9397        // delete to its own rows — exactly as `compile_update` does. Without
9398        // the narrowing this would resolve to the type and empty the table.
9399        let bound_rows = self.cte_object_type(&type_name);
9400        let td = self.resolve_path_root(&type_name)?;
9401        let alias = self.fresh_alias();
9402        let target = IrSource {
9403            poly: None,
9404            type_name: format!("{}::{}", td.module, td.name),
9405            table: td.table.clone(),
9406            alias: alias.clone(),
9407        };
9408
9409        let declared_filter = del
9410            .filter
9411            .as_ref()
9412            .map(|f| self.compile_expr(f, td, &alias))
9413            .transpose()?;
9414        let filter = match bound_rows {
9415            Some(_) => {
9416                let membership = IrExpr::BinOp(Box::new(IrBinOp {
9417                    left: IrExpr::ColumnRef {
9418                        alias: alias.clone(),
9419                        column: "id".to_string(),
9420                        pg_type: "uuid".to_string(),
9421                    },
9422                    op: ast::BinOpKind::In,
9423                    right: IrExpr::ArrayFromSelect(Box::new(IrArraySource::Select(IrSelect::schema_bound(
9424                        IrSource {
9425                            poly: None,
9426                            type_name: format!("{}::{}", td.module, td.name),
9427                            table: format!("@cte:{type_name}"),
9428                            alias: self.fresh_alias(),
9429                        },
9430                        vec![],
9431                        None,
9432                    )))),
9433                }));
9434                Some(and_conditions(declared_filter, vec![membership]).expect("membership is present"))
9435            }
9436            None => declared_filter,
9437        };
9438        let filter = match pending_guard {
9439            Some(condition) => {
9440                let guard = self.compile_expr(&condition, td, &alias)?;
9441                and_conditions(filter, vec![guard])
9442            }
9443            None => filter,
9444        };
9445
9446        let returning = Self::pk_returning(td);
9447
9448        let (poly_implementors, poly_columns) = if self.is_polymorphic(td) {
9449            (
9450                self.find_poly_implementors(&format!("{}::{}", td.module, td.name)),
9451                Self::poly_dml_columns(td),
9452            )
9453        } else {
9454            (vec![], vec![])
9455        };
9456        let qname = format!("{}::{}", td.module, td.name);
9457        let enqueue_search = collect_search_enqueue(td, &qname, "delete");
9458
9459        Ok(IrDelete {
9460            target,
9461            filter,
9462            returning,
9463            poly_implementors,
9464            poly_columns,
9465            enqueue_search,
9466        })
9467    }
9468
9469    // ── Shape compilation ─────────────────────────────────────────────────────────
9470
9471    fn compile_shape(
9472        &mut self,
9473        elements: &[ShapeElement],
9474        td: &TypeDescriptor,
9475        alias: &str,
9476        module: &str,
9477    ) -> Result<Vec<IrShapePointer>, PyQLError> {
9478        if elements.is_empty() {
9479            // No explicit shape: implicit { id } only.
9480            return Ok(Self::pk_returning(td));
9481        }
9482
9483        let mut pointers = vec![];
9484        for el in elements {
9485            if let Some(splat) = &el.splat {
9486                // Check if this is a type-intersection splat: [is Type].*
9487                if let Some(ast::PathStep::TypeIntersection(type_ref)) = el.path.steps.first() {
9488                    let type_ref = type_ref.clone();
9489                    pointers.extend(self.compile_type_intersection_splat(&type_ref, splat, td, alias)?);
9490                } else {
9491                    pointers.extend(self.compile_splat(splat, td, alias, module)?);
9492                }
9493            } else {
9494                pointers.push(self.compile_shape_element(el, td, alias, module)?);
9495            }
9496        }
9497        self.prepend_implicit_id(&mut pointers, td);
9498        Ok(pointers)
9499    }
9500
9501    /// Put `id` at the front of a shape that did not select one, which every
9502    /// binary-protocol query carries whether or not it asked. Without it,
9503    /// `o.id` on a shape like `options: { value }` reads as unset rather than
9504    /// as the row's id.
9505    ///
9506    /// Skipped in the two places that cannot carry it: inside a mutation's own body and under
9507    /// a cast to `json` — see `implicit_id_in_shapes`. A shape that names `id`
9508    /// itself keeps its own pointer, and its position, untouched.
9509    fn prepend_implicit_id(&self, pointers: &mut Vec<IrShapePointer>, td: &TypeDescriptor) {
9510        if !self.implicit_id_in_shapes {
9511            return;
9512        }
9513        let Some(pk) = td.properties.iter().find(|p| p.is_pk) else {
9514            return;
9515        };
9516        if pointers.iter().any(|p| p.alias() == pk.name) {
9517            return;
9518        }
9519        pointers.insert(
9520            0,
9521            IrShapePointer::Scalar(IrScalarPointer {
9522                implicit_id: true,
9523                marker_offset: None,
9524                alias: pk.name.clone(),
9525                column: pk.name.clone(),
9526                pg_type: pk.pg_type.clone(),
9527                tuple_shape: None,
9528            }),
9529        );
9530    }
9531
9532    /// Run `body` with implicit ids suppressed, restoring the previous setting
9533    /// however it ends.
9534    fn without_implicit_id<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T, PyQLError>) -> Result<T, PyQLError> {
9535        let saved = std::mem::replace(&mut self.implicit_id_in_shapes, false);
9536        let result = body(self);
9537        self.implicit_id_in_shapes = saved;
9538        result
9539    }
9540
9541    /// Expand `*` → all scalars; `**` → all scalars + all single links with implicit `{ id }`.
9542    fn compile_splat(
9543        &mut self,
9544        splat: &ast::Splat,
9545        td: &TypeDescriptor,
9546        alias: &str,
9547        module: &str,
9548    ) -> Result<Vec<IrShapePointer>, PyQLError> {
9549        let mut pointers: Vec<IrShapePointer> = td
9550            .properties
9551            .iter()
9552            .map(|p| {
9553                IrShapePointer::Scalar(IrScalarPointer {
9554                    implicit_id: false,
9555                    marker_offset: None,
9556                    alias: p.name.clone(),
9557                    column: p.name.clone(),
9558                    pg_type: p.pg_type.clone(),
9559                    tuple_shape: self.resolve_property_tuple_shape(p),
9560                })
9561            })
9562            .collect();
9563
9564        for cd in &td.computed.clone() {
9565            // `*` is properties; links arrive only with `**`. A computed
9566            // standing for objects is a link however it is written, so it
9567            // waits for the deep form too.
9568            if matches!(splat, ast::Splat::Shallow) && self.computed_is_object_valued(cd, td) {
9569                continue;
9570            }
9571            pointers.push(self.compile_declared_computed(cd, td, alias, module, None, &[])?);
9572        }
9573
9574        if matches!(splat, ast::Splat::Deep) {
9575            for l in &td.links {
9576                let target_td = self.resolve_type(&l.target)?;
9577                let sub_alias = self.fresh_alias();
9578                // `**` fetches every property (not further nested links) of
9579                // a linked object — a `Shallow` (`*`) expansion of the
9580                // target type, one level deep. Recursing with `Deep` here
9581                // instead would walk the target's own links too, which for
9582                // a two-way or cyclic link graph never terminates.
9583                let target_module = target_td.module.clone();
9584                let sub_shape = self.compile_splat(&ast::Splat::Shallow, target_td, &sub_alias, &target_module)?;
9585                let subquery = IrSelect::schema_bound(
9586                    IrSource {
9587                        poly: self.link_target_fanout(target_td),
9588                        type_name: format!("{}::{}", target_td.module, target_td.name),
9589                        table: target_td.table.clone(),
9590                        alias: sub_alias.clone(),
9591                    },
9592                    sub_shape,
9593                    None,
9594                );
9595                let correlation = if l.is_junction_backed() {
9596                    let join = self.build_multilink_join(td, &l.name, &l.target, &l.through)?;
9597                    IrSingleLinkCorrelation::Junction {
9598                        join,
9599                        target_pk: "id".to_string(),
9600                    }
9601                } else {
9602                    IrSingleLinkCorrelation::Fk {
9603                        fk_column: format!("{}_id", l.name),
9604                        target_pk: "id".to_string(),
9605                    }
9606                };
9607                pointers.push(IrShapePointer::SingleLink(IrSingleLinkPointer {
9608                    marker_offset: None,
9609                    alias: l.name.clone(),
9610                    correlation,
9611                    subquery,
9612                    link_properties: vec![],
9613                }));
9614            }
9615
9616            for ml in &td.multilinks {
9617                let sub_alias = self.fresh_alias();
9618                let target_td = self.resolve_type(&ml.target)?;
9619                // See the single-link loop above — same Shallow-not-Deep
9620                // reasoning applies to multilink targets.
9621                let target_module = target_td.module.clone();
9622                let sub_shape = self.compile_splat(&ast::Splat::Shallow, target_td, &sub_alias, &target_module)?;
9623
9624                let join = if let Some(through_qname) = &ml.through {
9625                    let through_td = self.resolve_type(through_qname)?;
9626                    if through_td.junction {
9627                        // See `junction_info_for`'s doc comment: owner-derived.
9628                        IrMultiLinkJoin::Standard {
9629                            junction_table: self.owner_junction(td, &ml.name, Some(through_td)),
9630                            module: td.module.clone(),
9631                        }
9632                    } else {
9633                        let source_qname = format!("{}::{}", td.module, td.name);
9634                        let source_col = through_td
9635                            .links
9636                            .iter()
9637                            .find(|l| l.target == source_qname)
9638                            .ok_or_else(|| {
9639                                PyQLError::Type(PyQLTypeError {
9640                                    message: format!(
9641                                        "through type {through_qname} has no link to source type {source_qname}"
9642                                    ),
9643                                    position: Position { line: 0, col: 0 },
9644                                })
9645                            })?
9646                            .name
9647                            .clone();
9648                        let target_col = through_td
9649                            .links
9650                            .iter()
9651                            .find(|l| l.target == ml.target && l.name != source_col)
9652                            .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
9653                            .ok_or_else(|| {
9654                                PyQLError::Type(PyQLTypeError {
9655                                    message: format!(
9656                                        "through type {through_qname} has no link to target type {}",
9657                                        ml.target
9658                                    ),
9659                                    position: Position { line: 0, col: 0 },
9660                                })
9661                            })?
9662                            .name
9663                            .clone();
9664                        IrMultiLinkJoin::Through {
9665                            junction_table: through_td.table.clone(),
9666                            module: through_td.module.clone(),
9667                            source_col,
9668                            target_col,
9669                        }
9670                    }
9671                } else {
9672                    // See `junction_info_for`: the junction table is named
9673                    // after the owner and lives in the owner's schema, which a
9674                    // splat reaching in from another module is not.
9675                    IrMultiLinkJoin::Standard {
9676                        junction_table: self.owner_junction(td, &ml.name, None),
9677                        module: td.module.clone(),
9678                    }
9679                };
9680
9681                let subquery = self.link_target_select(
9682                    target_td,
9683                    IrSource {
9684                        poly: None,
9685                        type_name: format!("{}::{}", target_td.module, target_td.name),
9686                        table: target_td.table.clone(),
9687                        alias: sub_alias.clone(),
9688                    },
9689                    sub_shape,
9690                );
9691
9692                let link_properties = self.splat_link_properties(ml)?;
9693                pointers.push(IrShapePointer::MultiLink(IrMultiLinkPointer {
9694                    marker_offset: None,
9695                    alias: ml.name.clone(),
9696                    join,
9697                    subquery,
9698                    link_properties,
9699                    single: false,
9700                }));
9701            }
9702        }
9703
9704        Ok(pointers)
9705    }
9706
9707    /// The link properties a splat over a multi-link's targets carries: every
9708    /// property of its `@pylon.junction` through type.
9709    fn splat_link_properties(&self, ml: &MultiLinkDescriptor) -> Result<Vec<IrLinkProp>, PyQLError> {
9710        let Some(through_qname) = &ml.through else {
9711            return Ok(vec![]);
9712        };
9713        let through_td = self.resolve_type(through_qname)?;
9714        if !through_td.junction {
9715            return Ok(vec![]);
9716        }
9717        Ok(through_td
9718            .properties
9719            .iter()
9720            .filter(|p| p.name != "id")
9721            .map(|p| IrLinkProp { name: p.name.clone() })
9722            .collect())
9723    }
9724
9725    // ── Type-intersection helpers ─────────────────────────────────────────────────
9726
9727    /// Expand `[is ConcreteType].*` → scalar subqueries for each non-inherited property.
9728    fn compile_type_intersection_splat(
9729        &mut self,
9730        type_ref: &ast::ObjectRef,
9731        splat: &ast::Splat,
9732        parent_td: &TypeDescriptor,
9733        parent_alias: &str,
9734    ) -> Result<Vec<IrShapePointer>, PyQLError> {
9735        let type_name = match &type_ref.module {
9736            Some(m) => format!("{}::{}", m, type_ref.name),
9737            None => type_ref.name.clone(),
9738        };
9739        let concrete_td = self.resolve_type(&type_name)?;
9740        let concrete_qname = format!("{}::{}", concrete_td.module, concrete_td.name);
9741        let concrete_table = concrete_td.table.clone();
9742
9743        // Interface properties: those in the parent (interface) td
9744        let interface_props: std::collections::HashSet<String> =
9745            parent_td.properties.iter().map(|p| p.name.clone()).collect();
9746
9747        // Emit scalar subquery for each property not already in the interface
9748        let props: Vec<_> = concrete_td
9749            .properties
9750            .iter()
9751            .filter(|p| !interface_props.contains(&p.name))
9752            .cloned()
9753            .collect();
9754
9755        // Same for computed pointers not already defined on the interface —
9756        // `[is Concrete].*` must include the concrete type's own computed
9757        // properties (e.g. `full_name`), not just its stored properties.
9758        let interface_computed: std::collections::HashSet<String> =
9759            parent_td.computed.iter().map(|c| c.name.clone()).collect();
9760        let computed: Vec<_> = concrete_td
9761            .computed
9762            .iter()
9763            .filter(|c| !interface_computed.contains(&c.name))
9764            .filter(|c| !matches!(splat, ast::Splat::Shallow) || !self.computed_is_object_valued(c, concrete_td))
9765            .cloned()
9766            .collect();
9767
9768        // For deep splat, also include links
9769        let links: Vec<_> = if matches!(splat, ast::Splat::Deep) {
9770            concrete_td.links.to_vec()
9771        } else {
9772            vec![]
9773        };
9774
9775        let mut pointers = vec![];
9776        for prop in props {
9777            let sub_alias = self.fresh_alias();
9778            let filter = IrExpr::BinOp(Box::new(IrBinOp {
9779                left: IrExpr::ColumnRef {
9780                    alias: sub_alias.clone(),
9781                    column: "id".to_string(),
9782                    pg_type: "uuid".to_string(),
9783                },
9784                op: ast::BinOpKind::Eq,
9785                right: IrExpr::ColumnRef {
9786                    alias: parent_alias.to_string(),
9787                    column: "id".to_string(),
9788                    pg_type: "uuid".to_string(),
9789                },
9790            }));
9791            let subquery = IrSelect::schema_bound(
9792                IrSource {
9793                    poly: None,
9794                    type_name: concrete_qname.clone(),
9795                    table: concrete_table.clone(),
9796                    alias: sub_alias,
9797                },
9798                vec![IrShapePointer::Scalar(IrScalarPointer {
9799                    implicit_id: false,
9800                    marker_offset: None,
9801                    alias: prop.name.clone(),
9802                    column: prop.name.clone(),
9803                    pg_type: prop.pg_type.clone(),
9804                    tuple_shape: self.resolve_property_tuple_shape(&prop),
9805                })],
9806                Some(filter),
9807            );
9808            pointers.push(IrShapePointer::Computed(IrComputedPointer {
9809                marker_offset: None,
9810                alias: prop.name.clone(),
9811                expr: IrExpr::Subquery(Box::new(subquery)),
9812            }));
9813        }
9814
9815        for cd in computed {
9816            let sub_alias = self.fresh_alias();
9817            let filter = IrExpr::BinOp(Box::new(IrBinOp {
9818                left: IrExpr::ColumnRef {
9819                    alias: sub_alias.clone(),
9820                    column: "id".to_string(),
9821                    pg_type: "uuid".to_string(),
9822                },
9823                op: ast::BinOpKind::Eq,
9824                right: IrExpr::ColumnRef {
9825                    alias: parent_alias.to_string(),
9826                    column: "id".to_string(),
9827                    pg_type: "uuid".to_string(),
9828                },
9829            }));
9830            let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
9831            let inner_ir = self.compile_expr(&expr_ast, concrete_td, &sub_alias)?;
9832            let subquery = IrSelect::schema_bound(
9833                IrSource {
9834                    poly: None,
9835                    type_name: concrete_qname.clone(),
9836                    table: concrete_table.clone(),
9837                    alias: sub_alias,
9838                },
9839                vec![IrShapePointer::Computed(IrComputedPointer {
9840                    marker_offset: None,
9841                    alias: cd.name.clone(),
9842                    expr: inner_ir,
9843                })],
9844                Some(filter),
9845            );
9846            pointers.push(IrShapePointer::Computed(IrComputedPointer {
9847                marker_offset: None,
9848                alias: cd.name.clone(),
9849                expr: IrExpr::Subquery(Box::new(subquery)),
9850            }));
9851        }
9852
9853        // For deep splat, include single-link pointers as subqueries
9854        for link in links {
9855            let target_td = self.resolve_type(&link.target)?;
9856            let sub_alias = self.fresh_alias();
9857            let filter = if link.is_junction_backed() {
9858                let (jt_table, jt_module, jt_src_col, jt_tgt_col, _) =
9859                    self.junction_info_for(concrete_td, &link.name, &link.target, &link.through)?;
9860                let jt_alias = self.fresh_alias();
9861                let jt_filter = IrExpr::BinOp(Box::new(IrBinOp {
9862                    left: IrExpr::BinOp(Box::new(IrBinOp {
9863                        left: IrExpr::ColumnRef {
9864                            alias: jt_alias.clone(),
9865                            column: jt_src_col,
9866                            pg_type: "uuid".to_string(),
9867                        },
9868                        op: ast::BinOpKind::Eq,
9869                        right: IrExpr::ColumnRef {
9870                            alias: parent_alias.to_string(),
9871                            column: "id".to_string(),
9872                            pg_type: "uuid".to_string(),
9873                        },
9874                    })),
9875                    op: ast::BinOpKind::And,
9876                    right: IrExpr::BinOp(Box::new(IrBinOp {
9877                        left: IrExpr::ColumnRef {
9878                            alias: jt_alias.clone(),
9879                            column: jt_tgt_col,
9880                            pg_type: "uuid".to_string(),
9881                        },
9882                        op: ast::BinOpKind::Eq,
9883                        right: IrExpr::ColumnRef {
9884                            alias: sub_alias.clone(),
9885                            column: "id".to_string(),
9886                            pg_type: "uuid".to_string(),
9887                        },
9888                    })),
9889                }));
9890                let exists_select = IrSelect::schema_bound(
9891                    IrSource {
9892                        poly: None,
9893                        type_name: format!("{}::__jt__", jt_module),
9894                        table: jt_table,
9895                        alias: jt_alias,
9896                    },
9897                    vec![],
9898                    Some(jt_filter),
9899                );
9900                IrExpr::UnaryOp(Box::new(IrUnaryOp {
9901                    op: ast::UnaryOpKind::Exists,
9902                    operand: IrExpr::Subquery(Box::new(exists_select)),
9903                }))
9904            } else {
9905                IrExpr::BinOp(Box::new(IrBinOp {
9906                    left: IrExpr::ColumnRef {
9907                        alias: sub_alias.clone(),
9908                        column: "id".to_string(),
9909                        pg_type: "uuid".to_string(),
9910                    },
9911                    op: ast::BinOpKind::Eq,
9912                    right: IrExpr::ColumnRef {
9913                        alias: parent_alias.to_string(),
9914                        column: format!("{}_id", link.name),
9915                        pg_type: "uuid".to_string(),
9916                    },
9917                }))
9918            };
9919            let sub_shape = Self::pk_returning(target_td);
9920            let subquery = IrSelect::schema_bound(
9921                IrSource {
9922                    poly: self.link_target_fanout(target_td),
9923                    type_name: format!("{}::{}", target_td.module, target_td.name),
9924                    table: target_td.table.clone(),
9925                    alias: sub_alias,
9926                },
9927                sub_shape,
9928                Some(filter),
9929            );
9930            pointers.push(IrShapePointer::Computed(IrComputedPointer {
9931                marker_offset: None,
9932                alias: link.name.clone(),
9933                expr: IrExpr::Subquery(Box::new(subquery)),
9934            }));
9935        }
9936
9937        Ok(pointers)
9938    }
9939
9940    /// Compile `[is ConcreteType].pointer_name` → `IrShapePointer`.
9941    fn compile_type_intersection_pointer(
9942        &mut self,
9943        type_ref: &ast::ObjectRef,
9944        tail_steps: &[ast::PathStep],
9945        parent_alias: &str,
9946        marker_offset: Option<usize>,
9947    ) -> Result<IrShapePointer, PyQLError> {
9948        let expr = self.compile_type_intersection_expr_steps(type_ref, tail_steps, parent_alias)?;
9949        // Alias is the last Name step
9950        let alias = match tail_steps.last() {
9951            Some(ast::PathStep::Name(n)) => n.clone(),
9952            _ => return Err(self.type_err("type intersection must end with a pointer name")),
9953        };
9954        Ok(IrShapePointer::Computed(IrComputedPointer {
9955            alias,
9956            expr,
9957            marker_offset,
9958        }))
9959    }
9960
9961    /// Compile `[is Type].name` as an `IrExpr` (for computed pointer / expression context).
9962    fn compile_type_intersection_expr(
9963        &mut self,
9964        steps: &[ast::PathStep],
9965        td: &TypeDescriptor,
9966        parent_alias: &str,
9967    ) -> Result<IrExpr, PyQLError> {
9968        use ast::PathStep;
9969        let type_ref = match steps.first() {
9970            Some(PathStep::TypeIntersection(tr)) => tr.clone(),
9971            _ => return Err(self.type_err("expected type intersection")),
9972        };
9973        // The fast path reads one stored column off the narrowed type.
9974        // Anything else — a computed, a link, further traversal — falls
9975        // through to the general builder, rooted at the narrowed type
9976        // (whose rows share the interface row's id).
9977        match self.compile_type_intersection_expr_steps(&type_ref, &steps[1..], parent_alias) {
9978            Ok(ir) => Ok(ir),
9979            Err(fast_path_err) => {
9980                let p = ast::Path {
9981                    steps: steps.to_vec(),
9982                    partial: true,
9983                };
9984                self.compile_partial_path_as_subquery(&p, td, parent_alias)
9985                    .map_err(|_| fast_path_err)
9986            }
9987        }
9988    }
9989
9990    /// Shared: build scalar subquery for `[is ConcreteType]` + tail pointer steps.
9991    fn compile_type_intersection_expr_steps(
9992        &mut self,
9993        type_ref: &ast::ObjectRef,
9994        tail_steps: &[ast::PathStep],
9995        parent_alias: &str,
9996    ) -> Result<IrExpr, PyQLError> {
9997        use ast::PathStep;
9998        let type_name = match &type_ref.module {
9999            Some(m) => format!("{}::{}", m, type_ref.name),
10000            None => type_ref.name.clone(),
10001        };
10002        let concrete_td = self.resolve_type(&type_name)?;
10003        let concrete_qname = format!("{}::{}", concrete_td.module, concrete_td.name);
10004        let concrete_table = concrete_td.table.clone();
10005
10006        let pointer_name = match tail_steps.first() {
10007            Some(PathStep::Name(n)) => n.as_str(),
10008            _ => return Err(self.type_err("type intersection must be followed by a pointer name, e.g. [is Type].name")),
10009        };
10010
10011        let prop = concrete_td
10012            .properties
10013            .iter()
10014            .find(|p| p.name == pointer_name)
10015            .ok_or_else(|| self.field_err(pointer_name, &concrete_qname))?;
10016        let prop_name = prop.name.clone();
10017        let prop_type = prop.pg_type.clone();
10018
10019        let sub_alias = self.fresh_alias();
10020        let filter = IrExpr::BinOp(Box::new(IrBinOp {
10021            left: IrExpr::ColumnRef {
10022                alias: sub_alias.clone(),
10023                column: "id".to_string(),
10024                pg_type: "uuid".to_string(),
10025            },
10026            op: ast::BinOpKind::Eq,
10027            right: IrExpr::ColumnRef {
10028                alias: parent_alias.to_string(),
10029                column: "id".to_string(),
10030                pg_type: "uuid".to_string(),
10031            },
10032        }));
10033
10034        let poly = self.poly_fanout_for(&concrete_qname);
10035        Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
10036            IrSource {
10037                poly,
10038                type_name: concrete_qname,
10039                table: concrete_table,
10040                alias: sub_alias,
10041            },
10042            vec![IrShapePointer::Scalar(IrScalarPointer {
10043                implicit_id: false,
10044                marker_offset: None,
10045                alias: prop_name.clone(),
10046                column: prop_name,
10047                pg_type: prop_type,
10048                tuple_shape: self.resolve_property_tuple_shape(prop),
10049            })],
10050            Some(filter),
10051        ))))
10052    }
10053
10054    fn compile_shape_element(
10055        &mut self,
10056        el: &ShapeElement,
10057        td: &TypeDescriptor,
10058        alias: &str,
10059        module: &str,
10060    ) -> Result<IrShapePointer, PyQLError> {
10061        // Type intersection pointer: [is Type].pointer_name (without compexpr)
10062        if let Some(ast::PathStep::TypeIntersection(type_ref)) = el.path.steps.first()
10063            && el.compexpr.is_none()
10064            && el.path.steps.len() >= 2
10065        {
10066            // `[is T].configs: { … }` — a shape says the pointer is read as
10067            // objects, which the scalar route below cannot give: it resolves
10068            // a stored property and nothing else.
10069            if let Some(nested) = el.nested.as_deref()
10070                && !nested.is_empty()
10071                && let Some(ast::PathStep::Name(leaf)) = el.path.steps.last()
10072                && let Some(ptr) = self.try_compile_pointer_expr(
10073                    &leaf.clone(),
10074                    &Expr::Path(el.path.clone()),
10075                    td,
10076                    alias,
10077                    module,
10078                    el.marker_offset,
10079                    nested,
10080                )?
10081            {
10082                return Ok(ptr);
10083            }
10084            let type_ref = type_ref.clone();
10085            return self.compile_type_intersection_pointer(&type_ref, &el.path.steps[1..], alias, el.marker_offset);
10086        }
10087
10088        let pointer_name = path_leaf(&el.path)?;
10089
10090        // __type__ is a virtual property: the fully-qualified type name as a string.
10091        // It's always injected at position 0 for internal use; explicit inclusion adds
10092        // it as a regular computed pointer at a later position so Python can read it.
10093        if pointer_name == "__type__" && el.compexpr.is_none() {
10094            let expr = if self.is_polymorphic(td) {
10095                IrExpr::ColumnRef {
10096                    alias: alias.to_string(),
10097                    column: "__type__".to_string(),
10098                    pg_type: "text".to_string(),
10099                }
10100            } else {
10101                IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
10102            };
10103            return Ok(IrShapePointer::Computed(IrComputedPointer {
10104                marker_offset: el.marker_offset,
10105                alias: "__type__".to_string(),
10106                expr,
10107            }));
10108        }
10109
10110        // `p := assert_exists(.prices { … })` — the assert is a check on the
10111        // pointer's own set, not part of a value expression, so the pointer is
10112        // compiled from the argument and the check reads the rows it
10113        // aggregates. Taken as an expression instead, an object-valued
10114        // argument is "part of a larger expression", which is exactly what an
10115        // object-returning function refuses.
10116        if let Some(compexpr) = &el.compexpr
10117            && let Some((call, value_expr, check_expr)) =
10118                Self::asserted_pointer_expr(compexpr, el.nested.as_deref().unwrap_or(&[]))
10119        {
10120            let element_for = |expr: Expr| ShapeElement {
10121                compexpr: Some(expr),
10122                nested: None,
10123                ..el.clone()
10124            };
10125            // Compiled speculatively: a scalar argument's assert is an ordinary
10126            // function call and belongs on the expression path below, and an
10127            // argument that does not compile at all should report its own error
10128            // from there rather than this one.
10129            if let Ok(ptr) = self.compile_shape_element(&element_for(value_expr), td, alias, module)
10130                && ptr.is_object_pointer()
10131            {
10132                let check = match check_expr {
10133                    Some(expr) => Some(self.compile_shape_element(&element_for(expr), td, alias, module)?),
10134                    None => None,
10135                };
10136                let message = self.assert_message(&call, Some((td, alias)))?;
10137                return Ok(IrShapePointer::Asserted(Box::new(IrAssertedPointer {
10138                    fn_name: call.name,
10139                    inner: ptr,
10140                    check,
10141                    message,
10142                })));
10143            }
10144        }
10145
10146        // `t := (select .teams limit 1) if cond else {}` — one branch names
10147        // objects and the other nothing, so the pointer is that branch with
10148        // the condition folded into its own filter.
10149        if let Some(compexpr) = &el.compexpr
10150            && let Some(guarded) = guarded_object_branch(compexpr)
10151        {
10152            let element = ShapeElement {
10153                compexpr: Some(guarded),
10154                ..el.clone()
10155            };
10156            return self.compile_shape_element(&element, td, alias, module);
10157        }
10158
10159        // Computed override: `pointer := expr`
10160        if let Some(compexpr) = &el.compexpr {
10161            // A link-valued RHS (`.multilink`, `.<backlink[is T] { … }`,
10162            // `(select .link filter … limit 1)`) is a pointer in its own
10163            // right, not an expression — see `try_compile_pointer_expr`.
10164            // `(select .resource { rev := … }) { changed_at := .rev.created_at }`
10165            // — a shape written at the point of use replaces the one the
10166            // subject carries, so whatever that one declared has to stay in
10167            // scope or the replacement cannot read it.
10168            let inner_declared: Vec<ShapeElement> = Self::replaced_subject_shape(compexpr)
10169                .iter()
10170                .filter(|e| e.compexpr.is_some())
10171                .cloned()
10172                .collect();
10173            let restore = (!inner_declared.is_empty()).then(|| {
10174                let mut scope = self.active_declared_pointers.clone();
10175                scope.extend(inner_declared);
10176                std::mem::replace(&mut self.active_declared_pointers, scope)
10177            });
10178            let pointer = self.try_compile_pointer_expr(
10179                pointer_name,
10180                compexpr,
10181                td,
10182                alias,
10183                module,
10184                el.marker_offset,
10185                el.nested.as_deref().unwrap_or(&[]),
10186            );
10187            if let Some(previous) = restore {
10188                self.active_declared_pointers = previous;
10189            }
10190            if let Some(ptr) = pointer? {
10191                return Ok(ptr);
10192            }
10193            // `p := marketplace::retrieve_listing_prices(.id) { amount }` — an
10194            // object-returning call written inline. A declared computed with
10195            // the same body already takes this route; a shape written on it
10196            // belongs to the rows it yields.
10197            if let Some((fc, call_nested)) = Self::object_call_with_shape(compexpr, el.nested.as_deref())
10198                && let Some(fs) = self.compile_fn_object_source(fc, &call_nested)?
10199            {
10200                return Ok(IrShapePointer::Computed(IrComputedPointer {
10201                    marker_offset: el.marker_offset,
10202                    alias: pointer_name.to_string(),
10203                    expr: IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectFunction(Box::new(fs)))),
10204                }));
10205            }
10206            let ir = self.compile_expr(compexpr, td, alias)?;
10207            // Cross-scope TypeIs: promote to set-valued shape pointer.
10208            if let IrExpr::ArrayFromSelect(src) = ir {
10209                if let IrArraySource::RawExpr {
10210                    source,
10211                    poly_implementors,
10212                    poly_columns,
10213                    expr,
10214                } = *src
10215                {
10216                    return Ok(IrShapePointer::ScalarSet(IrScalarSetPointer {
10217                        alias: pointer_name.to_string(),
10218                        source,
10219                        poly_implementors,
10220                        poly_columns,
10221                        bool_expr: expr,
10222                    }));
10223                }
10224                return Ok(IrShapePointer::Computed(IrComputedPointer {
10225                    marker_offset: el.marker_offset,
10226                    alias: pointer_name.to_string(),
10227                    expr: IrExpr::ArrayFromSelect(src),
10228                }));
10229            }
10230            return Ok(IrShapePointer::Computed(IrComputedPointer {
10231                marker_offset: el.marker_offset,
10232                alias: pointer_name.to_string(),
10233                expr: ir,
10234            }));
10235        }
10236
10237        // Scalar property
10238        if let Some(p) = Self::resolve_property(td, pointer_name) {
10239            return Ok(IrShapePointer::Scalar(IrScalarPointer {
10240                implicit_id: false,
10241                marker_offset: el.marker_offset,
10242                alias: pointer_name.to_string(),
10243                column: p.name.clone(),
10244                pg_type: p.pg_type.clone(),
10245                tuple_shape: self.resolve_property_tuple_shape(p),
10246            }));
10247        }
10248
10249        // Single link
10250        if let Some(l) = Self::resolve_link(td, pointer_name) {
10251            let target_td = self.resolve_type(&l.target)?;
10252            let sub_alias = self.fresh_alias();
10253            let nested_elements = el.nested.as_deref().unwrap_or(&[]);
10254
10255            // A junction-backed link can carry `@prop` read references
10256            // (e.g. `spouse: { name, @since }`), the same as a multi-link's
10257            // own nested shape (`compile_multilink_pointer`) — partition
10258            // those out before compiling the rest as a regular shape.
10259            let (regular_els, link_properties): (Vec<ShapeElement>, Vec<IrLinkProp>) = if l.is_junction_backed() {
10260                let mut regular = Vec::new();
10261                let mut props = Vec::new();
10262                for nel in nested_elements {
10263                    if let [ast::PathStep::LinkProp(name)] = nel.path.steps.as_slice() {
10264                        props.push(IrLinkProp { name: name.clone() });
10265                    } else {
10266                        regular.push(nel.clone());
10267                    }
10268                }
10269                (regular, props)
10270            } else {
10271                (nested_elements.to_vec(), vec![])
10272            };
10273
10274            let sub_shape = self.compile_shape(&regular_els, target_td, &sub_alias, &target_td.module.clone())?;
10275            let subquery = self.link_target_select(
10276                target_td,
10277                IrSource {
10278                    poly: None,
10279                    type_name: format!("{}::{}", target_td.module, target_td.name),
10280                    table: target_td.table.clone(),
10281                    alias: sub_alias,
10282                },
10283                sub_shape,
10284            );
10285            let correlation = if l.is_junction_backed() {
10286                let join = self.build_multilink_join(td, &l.name, &l.target, &l.through)?;
10287                IrSingleLinkCorrelation::Junction {
10288                    join,
10289                    target_pk: "id".to_string(),
10290                }
10291            } else {
10292                IrSingleLinkCorrelation::Fk {
10293                    fk_column: format!("{}_id", l.name),
10294                    target_pk: "id".to_string(),
10295                }
10296            };
10297            return Ok(IrShapePointer::SingleLink(IrSingleLinkPointer {
10298                marker_offset: el.marker_offset,
10299                alias: pointer_name.to_string(),
10300                correlation,
10301                subquery,
10302                link_properties,
10303            }));
10304        }
10305
10306        // Multi-link
10307        if Self::resolve_multilink(td, pointer_name).is_some() {
10308            return self.compile_multilink_pointer(pointer_name, pointer_name, td, alias, module, el);
10309        }
10310
10311        // Schema-defined computed pointer
10312        if let Some(cd) = self.resolve_computed(td, pointer_name) {
10313            return self.compile_declared_computed(
10314                &cd,
10315                td,
10316                alias,
10317                module,
10318                el.marker_offset,
10319                el.nested.as_deref().unwrap_or(&[]),
10320            );
10321        }
10322
10323        // Declared by the binding this shape is read off, rather than by the
10324        // type — compiled against the binding's own row, as it was written.
10325        if let Some(declared) = self
10326            .active_declared_pointers
10327            .iter()
10328            .find(|d| path_leaf(&d.path).is_ok_and(|n| n == pointer_name))
10329            .cloned()
10330            && let Some(expr) = declared.compexpr.clone()
10331        {
10332            let nested = if el.nested.as_deref().unwrap_or(&[]).is_empty() {
10333                declared.nested.clone().unwrap_or_default()
10334            } else {
10335                el.nested.clone().unwrap_or_default()
10336            };
10337            return self.compile_computed_expr(pointer_name, &expr, td, alias, module, el.marker_offset, &nested, None);
10338        }
10339
10340        Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
10341    }
10342
10343    /// A computed pointer whose path walks *through* a multi-valued step before
10344    /// landing on objects — `members := .memberships.member`.
10345    ///
10346    /// Rooted at the enclosing type and correlated back to the enclosing row,
10347    /// exactly as `compile_partial_path_as_subquery` does for a value, then
10348    /// aggregated so the rows survive as rows.
10349    #[allow(clippy::too_many_arguments)]
10350    fn compile_chained_link_pointer(
10351        &mut self,
10352        pointer_name: &str,
10353        path: &ast::Path,
10354        td: &TypeDescriptor,
10355        alias: &str,
10356        nested: &[ShapeElement],
10357        modifiers: Option<&ast::SelectStmt>,
10358        multi: bool,
10359        tail: usize,
10360    ) -> Result<IrShapePointer, PyQLError> {
10361        let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
10362        steps.extend(path.steps.iter().cloned());
10363        let full_path = ast::Path { steps, partial: false };
10364        let synthetic = ast::SelectStmt {
10365            result: Expr::Path(full_path.clone()),
10366            filter: modifiers.and_then(|m| m.filter.clone()),
10367            order_by: modifiers.map(|m| m.order_by.clone()).unwrap_or_default(),
10368            offset: modifiers.and_then(|m| m.offset.clone()),
10369            limit: modifiers.and_then(|m| m.limit.clone()),
10370            lock: None,
10371        };
10372        let mut path_select = self.compile_path_select_with_tail(&synthetic, &full_path, nested, false, tail, &[])?;
10373        Self::correlate_path_select(&mut path_select, alias);
10374        // A walk that crosses a multi step stands for a set, so it is
10375        // aggregated; one that cannot is a single object and is read as one,
10376        // rather than an array of length one.
10377        let expr = if multi {
10378            IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(path_select))))
10379        } else {
10380            IrExpr::ObjectPathSubquery(Box::new(path_select))
10381        };
10382        Ok(IrShapePointer::Computed(IrComputedPointer {
10383            marker_offset: None,
10384            alias: pointer_name.to_string(),
10385            expr,
10386        }))
10387    }
10388
10389    fn compile_multilink_pointer(
10390        &mut self,
10391        output_alias: &str,
10392        ml_name: &str,
10393        td: &TypeDescriptor,
10394        _parent_alias: &str,
10395        module: &str,
10396        el: &ShapeElement,
10397    ) -> Result<IrShapePointer, PyQLError> {
10398        let ml = Self::resolve_multilink(td, ml_name)
10399            .expect("caller verified multilink exists")
10400            .clone();
10401        let target_td = self.resolve_type(&ml.target)?;
10402        let sub_alias = self.fresh_alias();
10403        let nested_elements = el.nested.as_deref().unwrap_or(&[]);
10404
10405        // Partition @prop link-property elements from regular shape elements.
10406        let mut link_properties: Vec<IrLinkProp> = Vec::new();
10407        let mut regular_els: Vec<ShapeElement> = Vec::new();
10408        for nel in nested_elements {
10409            if let [ast::PathStep::LinkProp(name)] = nel.path.steps.as_slice() {
10410                link_properties.push(IrLinkProp { name: name.clone() });
10411            } else {
10412                regular_els.push(nel.clone());
10413            }
10414        }
10415        let has_splat = nested_elements.iter().any(|nel| {
10416            nel.splat.is_some() && !matches!(nel.path.steps.first(), Some(ast::PathStep::TypeIntersection(_)))
10417        });
10418        if has_splat {
10419            for prop in self.splat_link_properties(&ml)? {
10420                if !link_properties.iter().any(|existing| existing.name == prop.name) {
10421                    link_properties.push(prop);
10422                }
10423            }
10424        }
10425
10426        let sub_shape = self.compile_shape(&regular_els, target_td, &sub_alias, &target_td.module.clone())?;
10427
10428        let join = if let Some(through_qname) = &ml.through {
10429            let through_td = self.resolve_type(through_qname)?;
10430            if through_td.junction {
10431                // Junction type: columns are always named `source` and
10432                // `target`. See `junction_info_for`'s doc comment: the
10433                // physical table is owner-derived, never `through_td.table`.
10434                IrMultiLinkJoin::Standard {
10435                    junction_table: self.owner_junction(td, ml_name, Some(through_td)),
10436                    module: td.module.clone(),
10437                }
10438            } else {
10439                let source_qname = format!("{}::{}", td.module, td.name);
10440                let source_col = through_td
10441                    .links
10442                    .iter()
10443                    .find(|l| l.target == source_qname)
10444                    .ok_or_else(|| {
10445                        PyQLError::Type(PyQLTypeError {
10446                            message: format!("through type {through_qname} has no link to source type {source_qname}"),
10447                            position: Position { line: 0, col: 0 },
10448                        })
10449                    })?
10450                    .name
10451                    .clone();
10452                let target_col = through_td
10453                    .links
10454                    .iter()
10455                    .find(|l| l.target == ml.target && l.name != source_col)
10456                    .or_else(|| through_td.links.iter().find(|l| l.target == ml.target))
10457                    .ok_or_else(|| {
10458                        PyQLError::Type(PyQLTypeError {
10459                            message: format!("through type {through_qname} has no link to target type {}", ml.target),
10460                            position: Position { line: 0, col: 0 },
10461                        })
10462                    })?
10463                    .name
10464                    .clone();
10465                IrMultiLinkJoin::Through {
10466                    junction_table: through_td.table.clone(),
10467                    module: through_td.module.clone(),
10468                    source_col,
10469                    target_col,
10470                }
10471            }
10472        } else {
10473            IrMultiLinkJoin::Standard {
10474                junction_table: self.owner_junction(td, ml_name, None),
10475                module: module.to_string(),
10476            }
10477        };
10478
10479        // `@prop` in this link's own modifiers reads the junction row, so
10480        // the through type has to be in scope while they compile.
10481        self.link_prop_scope
10482            .push(ml.through.clone().map(|t| (t, "jt".to_string())));
10483        let modifiers = (|c: &mut Self| -> Result<SelectModifiers, PyQLError> {
10484            Ok((
10485                el.filter
10486                    .as_ref()
10487                    .map(|f| c.compile_expr(f, target_td, &sub_alias))
10488                    .transpose()?,
10489                el.order_by
10490                    .iter()
10491                    .map(|s| c.compile_sort(s, target_td, &sub_alias))
10492                    .collect::<Result<_, _>>()?,
10493                el.offset
10494                    .as_ref()
10495                    .map(|e| c.compile_expr(e, target_td, &sub_alias))
10496                    .transpose()?,
10497                el.limit
10498                    .as_ref()
10499                    .map(|e| c.compile_expr(e, target_td, &sub_alias))
10500                    .transpose()?,
10501            ))
10502        })(self);
10503        self.link_prop_scope.pop();
10504        let (filter, order_by, offset, limit) = modifiers?;
10505        let subquery = IrSelect {
10506            rows: vec![IrRowSource::Bound {
10507                source: IrSource {
10508                    poly: self.link_target_fanout(target_td),
10509                    type_name: format!("{}::{}", target_td.module, target_td.name),
10510                    table: target_td.table.clone(),
10511                    alias: sub_alias.clone(),
10512                },
10513                shape: sub_shape,
10514            }],
10515            filter,
10516            order_by,
10517            offset,
10518            limit,
10519            distinct: false,
10520            dml_source: None,
10521            polymorphic: false,
10522            poly_implementors: vec![],
10523            poly_columns: vec![],
10524            lock: None,
10525        };
10526
10527        Ok(IrShapePointer::MultiLink(IrMultiLinkPointer {
10528            marker_offset: el.marker_offset,
10529            alias: output_alias.to_string(),
10530            join,
10531            subquery,
10532            link_properties,
10533            single: false,
10534        }))
10535    }
10536
10537    /// Compile `pointer := .<backlink_name[is OwnerType] { shape }` (a
10538    /// backlink used as a computed pointer inside another type's shape —
10539    /// the missing piece that made backlinks unusable for anything beyond
10540    /// `filter exists .<...>`). Mirrors `compile_multilink_pointer`'s
10541    /// shape/subquery construction, but the owner type's rows are
10542    /// correlated in reverse: via their own FK column for a single-link
10543    /// backlink source (`IrMultiLinkJoin::BacklinkFk`), or via the same
10544    /// junction table a forward multi-link would use with the owner/current
10545    /// column roles swapped (`IrMultiLinkJoin::BacklinkJunction`).
10546    fn compile_backlink_pointer(
10547        &mut self,
10548        output_alias: &str,
10549        path: &ast::Path,
10550        current_qname: &str,
10551        nested_elements: &[ShapeElement],
10552        marker_offset: Option<usize>,
10553        modifiers: Option<&ast::SelectStmt>,
10554    ) -> Result<IrShapePointer, PyQLError> {
10555        use ast::PathStep;
10556
10557        let backlink_name = match path.steps.first() {
10558            Some(PathStep::Backlink(n)) => n.clone(),
10559            _ => return Err(self.type_err("internal: expected backlink step")),
10560        };
10561        let type_ref = match path.steps.get(1) {
10562            Some(PathStep::TypeIntersection(tr)) => tr,
10563            _ => {
10564                return Err(PyQLError::Type(PyQLTypeError {
10565                    message: format!(
10566                        "backlink '.< {backlink_name}' requires a type intersection, \
10567                     e.g.: .< {backlink_name}[is SomeType]"
10568                    ),
10569                    position: Position { line: 0, col: 0 },
10570                }));
10571            }
10572        };
10573        if path.steps.len() > 2 {
10574            return Err(self.type_err(
10575                "further path traversal after a backlink shape is not yet supported \
10576                 (e.g. '.<link[is Type].property') — attach a nested shape instead: \
10577                 '.<link[is Type] { property }'",
10578            ));
10579        }
10580
10581        let type_name = match &type_ref.module {
10582            Some(m) => format!("{}::{}", m, type_ref.name),
10583            None => type_ref.name.clone(),
10584        };
10585        let owner_td = self.backlink_owner(self.resolve_type(&type_name)?, &backlink_name, current_qname)?;
10586        let owner_qname = format!("{}::{}", owner_td.module, owner_td.name);
10587
10588        let join = if let Some(l) = owner_td
10589            .links
10590            .iter()
10591            .find(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
10592        {
10593            if l.is_junction_backed() {
10594                let (junction_table, module, owner_col, current_col, _) = self.link_junction_info(owner_td, l)?;
10595                IrMultiLinkJoin::BacklinkJunction {
10596                    junction_table,
10597                    module,
10598                    owner_col,
10599                    current_col,
10600                }
10601            } else {
10602                IrMultiLinkJoin::BacklinkFk {
10603                    fk_col: format!("{}_id", backlink_name),
10604                }
10605            }
10606        } else if let Some(ml) = owner_td
10607            .multilinks
10608            .iter()
10609            .find(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
10610            .cloned()
10611        {
10612            let (junction_table, module, _, _, _) = self.multilink_junction_info(owner_td, &ml)?;
10613            IrMultiLinkJoin::BacklinkJunction {
10614                junction_table,
10615                module,
10616                owner_col: "source".to_string(),
10617                current_col: "target".to_string(),
10618            }
10619        } else {
10620            return Err(PyQLError::Type(PyQLTypeError {
10621                message: format!(
10622                    "type {} has no link or multi-link '{}' pointing to {}",
10623                    owner_qname, backlink_name, current_qname,
10624                ),
10625                position: Position { line: 0, col: 0 },
10626            }));
10627        };
10628
10629        let sub_alias = self.fresh_alias();
10630        let sub_shape = self.compile_shape(nested_elements, owner_td, &sub_alias, &owner_td.module.clone())?;
10631
10632        // The `pointer := expr` grammar (`parse_shape_element`'s `:=`
10633        // branch) never parses trailing FILTER/ORDER BY/OFFSET/LIMIT after
10634        // the RHS expression — those per-link modifiers only exist on the
10635        // separate no-`:=` "bare inclusion with nested shape" parse path
10636        // that `compile_multilink_pointer`'s other call site reads
10637        // `el.filter` etc. from. Writing the RHS as a sub-select
10638        // (`pointer := (select .<link[is T] { … } filter … limit 1)`) is the
10639        // way to get them here, and `modifiers` carries that inner select.
10640        let (filter, order_by, offset, limit) = match modifiers {
10641            Some(sel) => self.compile_path_modifiers(sel, owner_td, &sub_alias)?,
10642            None => (None, vec![], None, None),
10643        };
10644        let subquery = IrSelect {
10645            rows: vec![IrRowSource::Bound {
10646                source: IrSource {
10647                    poly: self.poly_fanout_for(&owner_qname),
10648                    type_name: owner_qname,
10649                    table: owner_td.table.clone(),
10650                    alias: sub_alias.clone(),
10651                },
10652                shape: sub_shape,
10653            }],
10654            filter,
10655            order_by,
10656            offset,
10657            limit,
10658            distinct: false,
10659            dml_source: None,
10660            polymorphic: false,
10661            poly_implementors: vec![],
10662            poly_columns: vec![],
10663            lock: None,
10664        };
10665
10666        Ok(IrShapePointer::MultiLink(IrMultiLinkPointer {
10667            alias: output_alias.to_string(),
10668            join,
10669            subquery,
10670            link_properties: vec![],
10671            marker_offset,
10672            single: self.backlink_is_single(owner_td, &backlink_name, current_qname) || limits_to_one(modifiers),
10673        }))
10674    }
10675
10676    /// Compile a pointer the *schema* declares as computed (as opposed to one
10677    /// written inline in the query). A declared computed whose expression is
10678    /// a sub-select over a link — `(select .emails filter .primary limit 1)`
10679    /// — is an object pointer, exactly as if it had been written inline, so
10680    /// it takes the same route; everything else is a scalar expression.
10681    /// An object-returning function call as a row source, with the shape the
10682    /// pointer asked for (its target's own pk when none was written).
10683    fn compile_fn_object_source(
10684        &mut self,
10685        fc: &ast::FunctionCall,
10686        nested: &[ShapeElement],
10687    ) -> Result<Option<IrFunctionSelect>, PyQLError> {
10688        let Some(fd) = self.schema.functions.iter().find(|f| {
10689            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
10690            module_matches && f.name == fc.name && f.return_is_object
10691        }) else {
10692            return Ok(None);
10693        };
10694        let (fn_module, fn_name, return_type_name) = (fd.module.clone(), fd.name.clone(), fd.return_pg_type.clone());
10695        let return_td = self.resolve_type(&return_type_name)?;
10696        let alias = self.fresh_alias();
10697        let shape = if nested.is_empty() {
10698            Self::pk_returning(return_td)
10699        } else {
10700            self.compile_shape(nested, return_td, &alias, &return_td.module)?
10701        };
10702        let mut args = fc
10703            .args
10704            .iter()
10705            .map(|a| self.compile_free_expr(a))
10706            .collect::<Result<Vec<_>, _>>()?;
10707        // A function reading a session global takes them as its first
10708        // argument — the same forwarding a select over the call gets.
10709        if let Some(globals) = self.globals_arg_for_call(&format!("{fn_module}::{fn_name}"))? {
10710            args.insert(0, globals);
10711        }
10712        Ok(Some(IrFunctionSelect {
10713            fn_module,
10714            fn_name,
10715            fn_args: args,
10716            alias,
10717            type_name: format!("{}::{}", return_td.module, return_td.name),
10718            polymorphic: false,
10719            poly_implementors: vec![],
10720            poly_columns: vec![],
10721            shape,
10722            filter: None,
10723            order_by: vec![],
10724            offset: None,
10725            limit: None,
10726            distinct: false,
10727        }))
10728    }
10729
10730    /// `assert_exists(x)`, `assert_exists(x { … })`, `assert_distinct(x) { … }`
10731    /// — the assert, the set it checks, and the shape to read that set with.
10732    ///
10733    /// Only the one-argument forms, and not `assert_single`: the message
10734    /// overload takes a second argument the wrapper would have to carry, and
10735    /// `assert_single` returns the element rather than the array, so it does
10736    /// not fit the check `IrShapePointer::Asserted` emits.
10737    fn asserted_pointer_expr(expr: &Expr, nested: &[ShapeElement]) -> Option<(ast::FunctionCall, Expr, Option<Expr>)> {
10738        // `(select assert_distinct(X) { … } limit 1)` — the assert is the
10739        // select's subject, so it sees the whole set, while the modifiers
10740        // narrow only what is read back. The two coincide unless a limit or
10741        // offset is written, and then the check needs its own copy without
10742        // them, or it would be asserting over the one row that survives.
10743        // `(select assert_exists(X)) { … }` — the shape after the select is
10744        // the one its subject passes through.
10745        if let Expr::Shape(sh) = expr
10746            && let Some(subquery @ Expr::SubQuery(_)) = sh.expr.as_ref()
10747        {
10748            return Self::asserted_pointer_expr(subquery, &sh.elements);
10749        }
10750        if let Expr::SubQuery(stmt) = expr
10751            && let Stmt::Select(sel) = stmt.as_ref()
10752        {
10753            let (call, inner, _) = Self::asserted_pointer_expr(&sel.result, nested)?;
10754            let rebuild = |offset: Option<Expr>, limit: Option<Expr>| {
10755                Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
10756                    result: inner.clone(),
10757                    filter: sel.filter.clone(),
10758                    order_by: sel.order_by.clone(),
10759                    offset,
10760                    limit,
10761                    lock: sel.lock.clone(),
10762                })))
10763            };
10764            let value = rebuild(sel.offset.clone(), sel.limit.clone());
10765            let check = (sel.offset.is_some() || sel.limit.is_some()).then(|| rebuild(None, None));
10766            return Some((call, value, check));
10767        }
10768        // A shape written after the call (`assert_distinct(f(.id)) { amount }`)
10769        // belongs to the set the assert passes through, so it becomes the
10770        // inner pointer's shape.
10771        let (call, nested) = match expr {
10772            Expr::Shape(sh) => match sh.expr.as_ref() {
10773                Some(Expr::FunctionCall(f)) => (f, sh.elements.clone()),
10774                _ => return None,
10775            },
10776            Expr::FunctionCall(f) => (f, nested.to_vec()),
10777            _ => return None,
10778        };
10779        if call.module.is_some() && call.module.as_deref() != Some("std") {
10780            return None;
10781        }
10782        if !matches!(call.name.as_str(), "assert_exists" | "assert_distinct") {
10783            return None;
10784        }
10785        let [arg] = call.args.as_slice() else {
10786            return None;
10787        };
10788        // A shape written after the call belongs to the set the assert passes
10789        // through, so it is folded onto the argument rather than left as the
10790        // element's nested shape: a union only reads as objects when the shape
10791        // sits directly on it.
10792        let inner = match (&arg, nested.is_empty()) {
10793            (Expr::Shape(_), _) | (_, true) => arg.clone(),
10794            _ => Expr::Shape(Box::new(ast::ShapeExpr {
10795                expr: Some(arg.clone()),
10796                elements: nested.clone(),
10797                marker_offset: None,
10798            })),
10799        };
10800        Some((call.clone(), inner, None))
10801    }
10802
10803    /// A function call written as a pointer's value, with the shape its rows
10804    /// are read through — whether that shape sits after the call
10805    /// (`f(.id) { amount }`) or was parsed as the element's nested shape.
10806    fn object_call_with_shape<'e>(
10807        expr: &'e Expr,
10808        nested: Option<&[ShapeElement]>,
10809    ) -> Option<(&'e ast::FunctionCall, Vec<ShapeElement>)> {
10810        match expr {
10811            Expr::Shape(sh) => match sh.expr.as_ref() {
10812                Some(Expr::FunctionCall(f)) => Some((f, sh.elements.clone())),
10813                _ => None,
10814            },
10815            Expr::FunctionCall(f) => Some((f, nested.unwrap_or(&[]).to_vec())),
10816            _ => None,
10817        }
10818    }
10819
10820    fn compile_declared_computed(
10821        &mut self,
10822        cd: &crate::schema::ComputedDescriptor,
10823        td: &TypeDescriptor,
10824        alias: &str,
10825        module: &str,
10826        marker_offset: Option<usize>,
10827        nested: &[ShapeElement],
10828    ) -> Result<IrShapePointer, PyQLError> {
10829        let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
10830        let declared_on = self.computed_declared_on(td, &cd.name);
10831        self.compile_computed_expr(
10832            &cd.name,
10833            &expr_ast,
10834            td,
10835            alias,
10836            module,
10837            marker_offset,
10838            nested,
10839            declared_on,
10840        )
10841    }
10842
10843    /// The ancestor `td` inherited computed `name` from, nearest first, or
10844    /// `None` when `td` declares it itself. See `SelectAnchor::declared_on`.
10845    fn computed_declared_on(&self, td: &TypeDescriptor, name: &str) -> Option<(String, String)> {
10846        td.bases
10847            .iter()
10848            .chain(td.parents.iter())
10849            .chain(td.interfaces.iter())
10850            .filter_map(|qualified| self.resolve_type(qualified).ok())
10851            .find(|ancestor| ancestor.computed.iter().any(|c| c.name == name))
10852            .map(|ancestor| (ancestor.name.clone(), format!("{}::{}", ancestor.module, ancestor.name)))
10853    }
10854
10855    /// The body of `compile_declared_computed`, for a pointer whose expression
10856    /// is already parsed — a `with` binding's own shape declares one that way.
10857    #[allow(clippy::too_many_arguments)]
10858    fn compile_computed_expr(
10859        &mut self,
10860        name: &str,
10861        expr_ast: &Expr,
10862        td: &TypeDescriptor,
10863        alias: &str,
10864        module: &str,
10865        marker_offset: Option<usize>,
10866        nested: &[ShapeElement],
10867        declared_on: Option<(String, String)>,
10868    ) -> Result<IrShapePointer, PyQLError> {
10869        // The object the pointer is computed on stays in scope for the whole
10870        // expression, including any part of it compiled without a type in
10871        // hand — a `with` binding's right-hand side, say.
10872        self.anchors.push(SelectAnchor {
10873            type_name: td.name.clone(),
10874            qualified: format!("{}::{}", td.module, td.name),
10875            alias: alias.to_string(),
10876            detached: false,
10877            declared_on,
10878        });
10879        let result = (|compiler: &mut Self| -> Result<IrShapePointer, PyQLError> {
10880            if let Some(ptr) =
10881                compiler.try_compile_pointer_expr(name, expr_ast, td, alias, module, marker_offset, nested)?
10882            {
10883                return Ok(ptr);
10884            }
10885            // `manifest := retrieve_connector_manifest(.id)` — a computed whose
10886            // expression is an object-returning call. The rows it yields are
10887            // the pointer's objects; read as a plain expression the call is
10888            // "part of a larger expression", which such a function refuses.
10889            if let Expr::FunctionCall(fc) = expr_ast
10890                && let Some(fs) = compiler.compile_fn_object_source(fc, nested)?
10891            {
10892                return Ok(IrShapePointer::Computed(IrComputedPointer {
10893                    marker_offset,
10894                    alias: name.to_string(),
10895                    expr: IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectFunction(Box::new(fs)))),
10896                }));
10897            }
10898            let ir = compiler.compile_expr(expr_ast, td, alias)?;
10899            Ok(IrShapePointer::Computed(IrComputedPointer {
10900                marker_offset,
10901                alias: name.to_string(),
10902                expr: ir,
10903            }))
10904        })(self);
10905        self.anchors.pop();
10906        result
10907    }
10908
10909    /// Split a sub-select's result into the path it traverses and the nested
10910    /// shape attached to it: `select .posts { title }` → `.posts` + `{ title }`,
10911    /// `select .posts` → `.posts` + no shape.
10912    fn split_path_result(result: &Expr) -> Option<(&ast::Path, &[ShapeElement])> {
10913        match result {
10914            Expr::Path(p) => Some((p, &[])),
10915            // `(select detached T filter … limit 1).field` — the marker says
10916            // the set is not correlated with the enclosing one, which is
10917            // already true of a subject named by its own type.
10918            Expr::Detached(inner) => Self::split_path_result(inner),
10919            Expr::Shape(sh) => match &sh.expr {
10920                Some(Expr::Path(p)) => Some((p, sh.elements.as_slice())),
10921                _ => None,
10922            },
10923            _ => None,
10924        }
10925    }
10926
10927    /// Peel a computed pointer's right-hand side down to the function call
10928    /// it names and the sub-select's modifiers, if that is its shape:
10929    /// `latest(.id)` / `(select latest(.id) filter …)`.
10930    fn function_subject(e: &Expr) -> Option<(&ast::FunctionCall, Option<&ast::SelectStmt>)> {
10931        match e {
10932            Expr::FunctionCall(fc) => Some((fc, None)),
10933            Expr::SubQuery(stmt) => match stmt.as_ref() {
10934                Stmt::Select(sel) => match &sel.result {
10935                    Expr::FunctionCall(fc) => Some((fc, Some(sel))),
10936                    _ => None,
10937                },
10938                _ => None,
10939            },
10940            _ => None,
10941        }
10942    }
10943
10944    /// The object-returning user function `fc` names, if any.
10945    fn resolve_object_fn(&self, fc: &ast::FunctionCall) -> Option<&'a FunctionDescriptor> {
10946        self.schema.functions.iter().find(|f| {
10947            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
10948            module_matches && f.name == fc.name && f.return_is_object
10949        })
10950    }
10951
10952    /// The shape a pointer's subject carried, when a shape written at the
10953    /// point of use replaced it: `(select .resource { rev := … }) { … }`.
10954    /// `pointer_subject` drops it, but its declarations are what the
10955    /// replacement reads `.rev` from.
10956    fn replaced_subject_shape(e: &Expr) -> &[ShapeElement] {
10957        let Expr::Shape(sh) = e else { return &[] };
10958        if sh.elements.is_empty() {
10959            return &[];
10960        }
10961        match sh.expr.as_ref() {
10962            Some(inner) => Self::pointer_subject(inner).map(|(_, nested, _)| nested).unwrap_or(&[]),
10963            None => &[],
10964        }
10965    }
10966
10967    /// Peel a computed pointer's right-hand side down to the path it names,
10968    /// the shape attached to it, and the sub-select carrying its modifiers
10969    /// (if any): `.posts` / `.posts { title }` / `(select .posts limit 1)` /
10970    /// `(select .posts { title } limit 1)` / `(select .posts limit 1) { title }`.
10971    fn pointer_subject(e: &Expr) -> Option<(&ast::Path, &[ShapeElement], Option<&ast::SelectStmt>)> {
10972        match e {
10973            Expr::Path(p) => Some((p, &[], None)),
10974            Expr::SubQuery(stmt) => match stmt.as_ref() {
10975                Stmt::Select(sel) => {
10976                    let (p, inner) = Self::split_path_result(&sel.result)?;
10977                    Some((p, inner, Some(sel)))
10978                }
10979                _ => None,
10980            },
10981            Expr::Shape(sh) => {
10982                let (path, inner, modifiers) = Self::pointer_subject(sh.expr.as_ref()?)?;
10983                let nested = if sh.elements.is_empty() {
10984                    inner
10985                } else {
10986                    sh.elements.as_slice()
10987                };
10988                Some((path, nested, modifiers))
10989            }
10990            _ => None,
10991        }
10992    }
10993
10994    /// `((select .parents filter … limit 1)).parent` — a computed whose value
10995    /// is a field access off its own sub-select. Rewritten as the select over
10996    /// the extended path, plus how many steps the field access contributed:
10997    /// the modifiers belong to the step *before* them, which is what a walk's
10998    /// `tail` pins them to.
10999    fn field_access_over_select(expr: &Expr) -> Option<(ast::SelectStmt, usize)> {
11000        let (root, fields) = Self::peel_field_access_chain(expr);
11001        if fields.is_empty() {
11002            return None;
11003        }
11004        let Expr::SubQuery(stmt) = root else { return None };
11005        let Stmt::Select(sel) = stmt.as_ref() else {
11006            return None;
11007        };
11008        let Expr::Path(path) = &sel.result else { return None };
11009        if !path.partial {
11010            return None;
11011        }
11012        let mut steps = path.steps.clone();
11013        let count = fields.len();
11014        steps.extend(fields.into_iter().map(ast::PathStep::Name));
11015        Some((
11016            ast::SelectStmt {
11017                result: Expr::Path(ast::Path { steps, partial: true }),
11018                ..sel.clone()
11019            },
11020            count,
11021        ))
11022    }
11023
11024    /// A computed pointer whose right-hand side names a *link* rather than
11025    /// computing a value — `pointer := .multilink`, `:= .multilink { … }`,
11026    /// `:= .<backlink[is T] { … }`, and any of those wrapped in a sub-select
11027    /// carrying FILTER/ORDER BY/OFFSET/LIMIT.
11028    ///
11029    /// These are pointers in their own right, so they route to the same
11030    /// builders a bare `pointer: { … } filter … limit N` inclusion uses and
11031    /// come back as real object pointers (arrays of hydrated objects) rather
11032    /// than the bare id or EXISTS boolean that compiling them as an
11033    /// expression would produce. A sub-select's modifiers are exactly the
11034    /// per-link modifiers that inclusion form already carries, which is what
11035    /// makes the re-use exact — the `:=` grammar has no trailing-modifier
11036    /// form of its own.
11037    ///
11038    /// Shared by inline `pointer := …` shape elements and schema-declared
11039    /// computeds, so both kinds behave identically.
11040    ///
11041    /// `Ok(None)` means "not a link-valued RHS": the caller compiles it as
11042    /// an ordinary expression, which is where `(select …).field` — a scalar
11043    /// — is handled.
11044    #[allow(clippy::too_many_arguments)]
11045    fn try_compile_pointer_expr(
11046        &mut self,
11047        pointer_name: &str,
11048        compexpr: &Expr,
11049        td: &TypeDescriptor,
11050        alias: &str,
11051        module: &str,
11052        marker_offset: Option<usize>,
11053        nested_override: &[ShapeElement],
11054    ) -> Result<Option<IrShapePointer>, PyQLError> {
11055        // A nested shape can sit inside the parens (`(select .posts
11056        // { title })`) or after them (`(select .posts) { title }`, `.posts
11057        // { title }`) — the parser folds a trailing `{ }` into an
11058        // `Expr::Shape` wrapping whatever precedes it, never into
11059        // `el.nested` (that field is only populated by the separate no-`:=`
11060        // "bare inclusion with nested shape" parse path).
11061        // `((select .history order by … limit 1)).release` — the field access
11062        // continues the walk, and the modifiers stay on the step they were
11063        // written on.
11064        if let Some((sel, tail)) = Self::field_access_over_select(compexpr)
11065            && let Expr::Path(path) = &sel.result
11066        {
11067            let (head_steps, tail_steps) = path.steps.split_at(path.steps.len() - tail);
11068            let (head_multi, head_target) = self.walk_path_types(td, head_steps, MAX_COMPUTED_SPLICES);
11069            let Some(head_target) = head_target else {
11070                return Ok(None);
11071            };
11072            let (tail_multi, target) = self.walk_path_types(head_target, tail_steps, MAX_COMPUTED_SPLICES);
11073            if target.is_none() {
11074                return Ok(None);
11075            }
11076            let multi = tail_multi || (head_multi && !limits_to_one(Some(&sel)));
11077            if !multi && nested_override.is_empty() {
11078                return Ok(None);
11079            }
11080            let path = path.clone();
11081            return self
11082                .compile_chained_link_pointer(pointer_name, &path, td, alias, nested_override, Some(&sel), multi, tail)
11083                .map(Some);
11084        }
11085        let Some((path, declared_nested, modifiers)) = Self::pointer_subject(compexpr) else {
11086            return Ok(None);
11087        };
11088        if !path.partial {
11089            return Ok(None);
11090        }
11091        // A shape written at the point of use (`authors { name }` on a
11092        // declared computed) wins over the one the declaration itself
11093        // carries, which acts as the default.
11094        let nested = if nested_override.is_empty() {
11095            declared_nested
11096        } else {
11097            nested_override
11098        };
11099
11100        // Only a link pointer of the current type becomes an object pointer;
11101        // a property (`x := .name`, `x := (select .name)`) is a scalar and
11102        // belongs on the expression path.
11103        let ml_name = match path.steps.as_slice() {
11104            [ast::PathStep::Name(n)] if Self::resolve_multilink(td, n).is_some() => n.clone(),
11105            // `.<link[is Owner]` — the objects on the other side of the
11106            // link. Traversing *past* the intersection (`.<link[is
11107            // Owner].name`) is a value, not an object pointer, so it goes
11108            // the expression route instead.
11109            [ast::PathStep::Backlink(_)] | [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_)] => {
11110                let current_qname = format!("{}::{}", td.module, td.name);
11111                let path = path.clone();
11112                let nested = nested.to_vec();
11113                return self
11114                    .compile_backlink_pointer(pointer_name, &path, &current_qname, &nested, marker_offset, modifiers)
11115                    .map(Some);
11116            }
11117            // `.memberships.member` — a chain no single-step builder can
11118            // express: the junction it would need belongs to no one link but
11119            // to the whole walk. Compiled as a path select and aggregated, so
11120            // it stays an object pointer instead of collapsing to the bare ids
11121            // an expression-position path gives — which, worse, was a scalar
11122            // subquery that failed outright the moment a second row matched.
11123            // A leading `[is T]` narrows what the walk starts from
11124            // (`brand := [is BrandOrderLineItem].brand { * }`); the walk
11125            // itself is the same one a bare name starts.
11126            steps
11127                if matches!(
11128                    steps.first(),
11129                    Some(ast::PathStep::Name(_) | ast::PathStep::TypeIntersection(_))
11130                ) =>
11131            {
11132                let (multi, target) = self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES);
11133                // A single-valued walk takes this route only when a shape was
11134                // written on it (`account := resource.account { id }`), which
11135                // is what says an object was meant. Without one, a bare
11136                // `x := .link` still stands for the link's value, as it did
11137                // before there was an object route at all.
11138                if target.is_none() || (!multi && nested.is_empty()) {
11139                    return Ok(None);
11140                }
11141                let path = path.clone();
11142                let nested = nested.to_vec();
11143                return self
11144                    .compile_chained_link_pointer(
11145                        pointer_name,
11146                        &path,
11147                        td,
11148                        alias,
11149                        &nested,
11150                        modifiers,
11151                        multi && !limits_to_one(modifiers),
11152                        0,
11153                    )
11154                    .map(Some);
11155            }
11156            _ => return Ok(None),
11157        };
11158
11159        let synthetic = ShapeElement {
11160            path: path.clone(),
11161            splat: None,
11162            nested: Some(nested.to_vec()),
11163            compexpr: None,
11164            op: ast::ShapeOp::Assign,
11165            filter: modifiers.and_then(|s| s.filter.clone()),
11166            order_by: modifiers.map(|s| s.order_by.clone()).unwrap_or_default(),
11167            offset: modifiers.and_then(|s| s.offset.clone()),
11168            limit: modifiers.and_then(|s| s.limit.clone()),
11169            marker_offset,
11170        };
11171        let mut pointer = self.compile_multilink_pointer(pointer_name, &ml_name, td, alias, module, &synthetic)?;
11172        if let IrShapePointer::MultiLink(link) = &mut pointer {
11173            link.single = limits_to_one(modifiers);
11174        }
11175        Ok(Some(pointer))
11176    }
11177
11178    /// A sub-statement used as an expression: `(select .emails filter
11179    /// .primary limit 1).email`, `(select .posts order by .created desc
11180    /// limit 1).title`.
11181    ///
11182    /// Compiles the inner select as a flat path traversal
11183    /// (`compile_path_select` — the same builder a top-level `select
11184    /// Person.company.name` uses, so forward links, multi-links, backlinks,
11185    /// junction-backed links and type intersections all come along) and
11186    /// returns it as one correlated scalar subquery.
11187    ///
11188    /// A *partial* subject path (`.emails`) is relative to the enclosing
11189    /// object, so it is rooted at the enclosing type and correlated back to
11190    /// the enclosing row by primary key; an absolute one (`Person.name`) is
11191    /// independent and needs no correlation.
11192    ///
11193    /// `extra_fields` are the `.field` steps of an enclosing field-access
11194    /// chain. They are spliced onto the subject path rather than applied to
11195    /// its result, so the subquery projects the scalar column itself instead
11196    /// of an opaque object id.
11197    fn compile_subquery_expr(
11198        &mut self,
11199        stmt: &Stmt,
11200        extra_fields: &[String],
11201        ctx: Option<(&TypeDescriptor, &str)>,
11202        outer_shape: &[ShapeElement],
11203    ) -> Result<IrExpr, PyQLError> {
11204        // `(with x := … select …)` — the bindings have nowhere to live in
11205        // expression position, so they're hoisted to the enclosing
11206        // statement's own WITH clause and the inner statement takes over.
11207        if let Stmt::With(w) = stmt {
11208            for alias in &w.aliases {
11209                if self.bind_inline_if_correlated(&alias.name, &alias.expr, ctx)?
11210                    || self.bind_group(&alias.name, &alias.expr)?
11211                {
11212                    continue;
11213                }
11214                // The same computed inlined twice — `select M { * } filter … .role`
11215                // reaches `Membership.role` from both the splat and the filter —
11216                // hoists its `with` bindings once per inlining, and two CTEs of
11217                // one name is "WITH query name specified more than once". The
11218                // bindings are identical, so the first one stands for both; a
11219                // *different* binding under a name already taken is a real
11220                // collision and says so rather than silently shadowing.
11221                match self.hoisted_binding_sources.get(&alias.name) {
11222                    Some(existing) if *existing == alias.expr => continue,
11223                    Some(_) => {
11224                        return Err(self.type_err(&format!(
11225                            "two different `with` bindings named '{}' end up in one statement; \
11226                             rename one of them",
11227                            alias.name
11228                        )));
11229                    }
11230                    None => {}
11231                }
11232                let (ir_stmt, correlated_to) = self.compile_binding_in_scope(&alias.expr)?;
11233                let sql_name = self.claim_cte_sql_name(&alias.name);
11234                let type_name = self.register_cte(&alias.name, &ir_stmt);
11235                self.hoisted_binding_sources
11236                    .insert(alias.name.clone(), alias.expr.clone());
11237                self.hoisted_ctes.push(IrCteDef {
11238                    name: sql_name,
11239                    stmt: ir_stmt,
11240                    type_name,
11241                    correlated_to,
11242                });
11243            }
11244            let inner = (*w.stmt).clone();
11245            return self.compile_subquery_expr(&inner, extra_fields, ctx, outer_shape);
11246        }
11247
11248        if extra_fields.is_empty()
11249            && let Stmt::Select(sel) = stmt
11250            && let Expr::Shape(sh) = &sel.result
11251            && let Some(Expr::SubQuery(subject)) = &sh.expr
11252            && matches!(subject.as_ref(), Stmt::Group(_))
11253            && let IrStmt::Group(grp) = self.compile_stmt(stmt)?
11254        {
11255            return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::Group(Box::new(grp)))));
11256        }
11257
11258        // `(select (.<a[is T] union .<b[is T]) { id } limit 1)` — the union's
11259        // operands are correlated to the enclosing row, so the whole thing is
11260        // read as one set rather than hoisted branch by branch.
11261        if let Stmt::Select(inner) = stmt
11262            && let Expr::Shape(sh) = &inner.result
11263            && let Some(subject) = sh.expr.as_ref()
11264            && let Some(operands) = Self::union_of_relative_paths(subject)
11265            && let Some((td, alias)) = ctx
11266        {
11267            let elements = sh.elements.clone();
11268            let operands_reach_many = self.relative_paths_reach_many(td, &operands);
11269            let mut branches = Vec::with_capacity(operands.len());
11270            for path in operands {
11271                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
11272                steps.extend(path.steps.iter().cloned());
11273                let rooted = ast::Path { steps, partial: false };
11274                let synthetic = ast::SelectStmt {
11275                    result: Expr::Path(rooted.clone()),
11276                    filter: inner.filter.clone(),
11277                    order_by: vec![],
11278                    offset: None,
11279                    limit: None,
11280                    lock: None,
11281                };
11282                let mut ps = self.compile_path_select(&synthetic, &rooted, &elements, false)?;
11283                Self::correlate_path_select(&mut ps, alias);
11284                branches.push(ps);
11285            }
11286            let limit = inner
11287                .limit
11288                .as_ref()
11289                .map(|l| self.compile_free_expr(l))
11290                .transpose()?
11291                .map(Box::new);
11292            let multi = !matches!(limit.as_deref(), Some(IrExpr::Literal(IrLiteral::Int(1)))) && operands_reach_many;
11293            return Ok(IrExpr::ObjectPathUnion { branches, limit, multi });
11294        }
11295        // `(insert T { … }).id` — a mutation read through a path. The statement
11296        // itself may only run at the top level of a WITH, so it is hoisted into
11297        // one and the path is read back off that binding: exactly what the
11298        // `with x := (insert …) select x.id` spelling already compiles to. With
11299        // no path to read there is nothing for the mutation to stand in for,
11300        // which stays an error.
11301        // Only in free context. Inside a schema-bound shape element
11302        // (`select Person { x := (insert Company { … }).name }`) the mutation
11303        // would run once while every row read its result, which is not what
11304        // writing it there says — that stays rejected.
11305        if matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
11306            && !extra_fields.is_empty()
11307            && ctx.is_none()
11308        {
11309            let type_name = self.dml_subject_type(stmt)?;
11310            let inner = self.compile_stmt(stmt)?;
11311            let cte_name = self.fresh_nested_cte_name();
11312            // `hoisted_ctes`, not `pending_nested_ctes`: this mutation came out
11313            // of an expression in the enclosing statement, not out of another
11314            // statement's link assignment, so it belongs to that statement's
11315            // own WITH. Registering it is what lets the path below resolve
11316            // against it like any other binding.
11317            self.register_cte(&cte_name, &inner);
11318            self.hoisted_ctes.push(IrCteDef {
11319                name: cte_name.clone(),
11320                stmt: inner,
11321                type_name,
11322                correlated_to: None,
11323            });
11324            let mut steps = vec![ast::PathStep::Name(cte_name)];
11325            steps.extend(extra_fields.iter().cloned().map(ast::PathStep::Name));
11326            return self.compile_free_path(&ast::Path { steps, partial: false });
11327        }
11328
11329        let Stmt::Select(sel) = stmt else {
11330            return Err(self.subquery_expr_err(stmt));
11331        };
11332        let has_modifiers =
11333            sel.filter.is_some() || !sel.order_by.is_empty() || sel.offset.is_some() || sel.limit.is_some();
11334
11335        let Some((path, shape_els)) = Self::split_path_result(&sel.result) else {
11336            // `(select account::owner(.id) filter … limit 1).name` — the
11337            // function call is the row source the modifiers apply to.
11338            if let Expr::FunctionCall(fc) = &sel.result {
11339                let fc = fc.clone();
11340                if let Some(ir) = self.try_compile_fn_scalar_subquery(&fc, extra_fields, Some(sel), ctx)? {
11341                    return Ok(ir);
11342                }
11343            }
11344            // `(select <expression>)` with nothing to traverse is just the
11345            // expression itself. With modifiers to honour it is a statement,
11346            // not an expression — `(select count(Visit) filter …)` — so it is
11347            // hoisted into the enclosing WITH and read back by name.
11348            if has_modifiers {
11349                let inner = self.compile_stmt(stmt)?;
11350                let IrStmt::Select(select) = inner else {
11351                    return Err(self.subquery_expr_err(stmt));
11352                };
11353                if !select
11354                    .rows
11355                    .iter()
11356                    .all(|r| matches!(r, IrRowSource::Free(IrFreeExpr::Scalar(_))))
11357                {
11358                    return Err(self.subquery_expr_err(stmt));
11359                }
11360                let mut ir = IrExpr::ScalarSubquery(Box::new(select));
11361                for field in extra_fields {
11362                    ir = Self::project_free_object_field(ir, field);
11363                }
11364                return Ok(ir);
11365            }
11366            let mut ir = self.compile_expr_ctx(&sel.result, ctx)?;
11367            for field in extra_fields {
11368                ir = Self::project_free_object_field(ir, field);
11369            }
11370            return Ok(ir);
11371        };
11372        // A shape whose pointers are relative paths declares local names for
11373        // them, readable by the select's own FILTER/ORDER BY and by whatever
11374        // projects off it — `(select .locators { handle := [is Handle].handle,
11375        // latest := [is Handle].latest } filter .latest limit 1).handle`. Each
11376        // is substituted back into its readers, leaving an ordinary shapeless
11377        // sub-select behind.
11378        let rewritten_sel;
11379        let mut sel = sel;
11380        let mut shape_els = shape_els;
11381        let mut extra_steps: Vec<ast::PathStep> = extra_fields.iter().map(|f| ast::PathStep::Name(f.clone())).collect();
11382        if !shape_els.is_empty()
11383            && let Some(defs) = Self::shape_alias_paths(shape_els)
11384        {
11385            extra_steps = extra_fields
11386                .iter()
11387                .flat_map(|field| match defs.iter().find(|(name, _)| name == field) {
11388                    Some((_, definition)) => definition.steps.clone(),
11389                    None => vec![ast::PathStep::Name(field.clone())],
11390                })
11391                .collect();
11392            rewritten_sel = ast::SelectStmt {
11393                result: sel.result.clone(),
11394                filter: sel.filter.clone().map(|f| Self::substitute_shape_aliases(f, &defs)),
11395                order_by: sel
11396                    .order_by
11397                    .iter()
11398                    .map(|o| ast::SortExpr {
11399                        expr: Self::substitute_shape_aliases(o.expr.clone(), &defs),
11400                        direction: o.direction.clone(),
11401                        nones: o.nones.clone(),
11402                    })
11403                    .collect(),
11404                offset: sel.offset.clone(),
11405                limit: sel.limit.clone(),
11406                lock: sel.lock.clone(),
11407            };
11408            sel = &rewritten_sel;
11409            shape_els = &[];
11410        }
11411
11412        // A shape only matters when the sub-select's own value is the
11413        // result. `(select .emails { address } limit 1).address` projects a
11414        // column straight back out of it, so the shape says nothing the
11415        // projection doesn't — the same reading PyQL gives it.
11416        // `answer := (select brand::AssessmentAnswer { … } filter … limit 1)`
11417        // — a select over a named type, carrying the shape its rows are read
11418        // with. That is an object, not the bare key the branch below gives a
11419        // shapeless one, so the shape has somewhere to go after all.
11420        // `(select names { * })` over an object `with` binding reads the
11421        // binding's rows the same way.
11422        if !shape_els.is_empty()
11423            && extra_fields.is_empty()
11424            && !path.partial
11425            && let [ast::PathStep::Name(type_name)] = path.steps.as_slice()
11426            && let Some(root_td) = match self.cte_object_type(type_name) {
11427                Some(_) => self.resolve_path_root(type_name).ok(),
11428                None => self.resolve_type(type_name).ok(),
11429            }
11430        {
11431            let alias = self.fresh_alias();
11432            let binding = self.cte_object_type(type_name).map(|_| type_name.clone());
11433            let table = match &binding {
11434                Some(name) => self.row_source_table(name, root_td),
11435                None => root_td.table.clone(),
11436            };
11437            let outer_declared = binding.as_ref().map(|name| {
11438                let declared = self.cte_declared_pointers.get(name).cloned().unwrap_or_default();
11439                std::mem::replace(&mut self.active_declared_pointers, declared)
11440            });
11441            let modifiers = self.compile_path_modifiers(sel, root_td, &alias);
11442            let module = root_td.module.clone();
11443            let shape = self.compile_shape(shape_els, root_td, &alias, &module);
11444            if let Some(outer) = outer_declared {
11445                self.active_declared_pointers = outer;
11446            }
11447            let (filter, order_by, offset, limit) = modifiers?;
11448            let shape = shape?;
11449            let source = IrSource {
11450                poly: self.poly_fanout_for(&format!("{}::{}", root_td.module, root_td.name)),
11451                type_name: format!("{}::{}", root_td.module, root_td.name),
11452                table,
11453                alias,
11454            };
11455            let single = selects_at_most_one(sel, root_td);
11456            let mut select = IrSelect::schema_bound(source, shape, filter);
11457            select.order_by = order_by;
11458            select.offset = offset;
11459            select.limit = limit;
11460            // Read as one object, a select yielding several rows aborts the
11461            // query ("more than one row returned by a subquery").
11462            return Ok(if single {
11463                IrExpr::ObjectSubquery(Box::new(select))
11464            } else {
11465                IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(Box::new(select))))
11466            });
11467        }
11468
11469        if !shape_els.is_empty() && extra_fields.is_empty() {
11470            return Err(self.type_err(
11471                "a sub-select with a shape is not valid in expression context — \
11472                 assign it to a computed pointer instead, or project a property \
11473                 off it, e.g. '(select .emails limit 1).address'",
11474            ));
11475        }
11476
11477        // `(with c := … select c)` — the result is the binding itself, which
11478        // is a value already, not something to traverse from.
11479        if !path.partial
11480            && let [ast::PathStep::Name(name)] = path.steps.as_slice()
11481            && self.is_value_binding(name)
11482        {
11483            let mut ir = self.compile_expr_ctx(&sel.result, ctx)?;
11484            for field in extra_fields {
11485                ir = Self::project_free_object_field(ir, field);
11486            }
11487            return Ok(ir);
11488        }
11489
11490        // `(select Company filter .name = 'Acme' limit 1)` — a bare type
11491        // select has nothing to traverse; in expression position it stands
11492        // for the object's primary key, which is what a link's value is.
11493        if !path.partial
11494            && extra_fields.is_empty()
11495            && let [ast::PathStep::Name(type_name)] = path.steps.as_slice()
11496            && let Ok(root_td) = self.resolve_type(type_name)
11497        {
11498            let alias = self.fresh_alias();
11499            let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, root_td, &alias)?;
11500            let source = IrSource {
11501                poly: None,
11502                type_name: format!("{}::{}", root_td.module, root_td.name),
11503                table: root_td.table.clone(),
11504                alias,
11505            };
11506            // Never `pk_returning` here: an empty shape is how the emitter
11507            // spells an EXISTS inner, so a type with no declared pk would
11508            // quietly become `SELECT 1` instead of a key.
11509            let pk = root_td.properties.iter().find(|p| p.is_pk);
11510            let shape = vec![IrShapePointer::Scalar(IrScalarPointer {
11511                implicit_id: false,
11512                marker_offset: None,
11513                alias: "id".to_string(),
11514                column: pk.map(|p| p.name.clone()).unwrap_or_else(|| "id".to_string()),
11515                pg_type: pk.map(|p| p.pg_type.clone()).unwrap_or_else(|| "uuid".to_string()),
11516                tuple_shape: None,
11517            })];
11518            let mut select = IrSelect::schema_bound(source, shape, filter);
11519            select.order_by = order_by;
11520            select.offset = offset;
11521            select.limit = limit;
11522            return Ok(IrExpr::Subquery(Box::new(select)));
11523        }
11524
11525        let mut steps = path.steps.clone();
11526        let correlate = if path.partial {
11527            let Some((td, alias)) = ctx else {
11528                return Err(self.type_err(
11529                    "a relative path in a sub-select needs an enclosing object — \
11530                     write the type name explicitly, e.g. '(select Person.name)'",
11531                ));
11532            };
11533            steps.insert(0, ast::PathStep::Name(format!("{}::{}", td.module, td.name)));
11534            Some(alias.to_string())
11535        } else {
11536            None
11537        };
11538        steps.extend(extra_steps.iter().cloned());
11539        let full_path = ast::Path { steps, partial: false };
11540
11541        let mut ps = self.compile_path_select_with_tail(sel, &full_path, outer_shape, false, extra_steps.len(), &[])?;
11542        if let Some(outer_alias) = correlate {
11543            Self::correlate_path_select(&mut ps, &outer_alias);
11544        }
11545        // A shape written after the projection (`(select … limit 1).account
11546        // { id, name }`) says the object was wanted, not its id, so the walk
11547        // comes back as one rather than being reduced the way a bare path in
11548        // expression position is.
11549        if !outer_shape.is_empty() && matches!(ps.result, IrPathResult::Object { .. }) {
11550            return Ok(IrExpr::ObjectPathSubquery(Box::new(ps)));
11551        }
11552        // `limit 1` is what makes a sub-select over a multi-link single-
11553        // valued — that is the whole point of `(select .emails filter
11554        // .primary limit 1).address`. Any other limit, or none, still stands
11555        // for a set, so it comes back as an array rather than a subquery
11556        // Postgres would reject the moment a second row showed up.
11557        let single = matches!(&sel.limit, Some(Expr::Literal(ast::Literal::Int(1))));
11558        let multi = match &full_path.steps[0] {
11559            ast::PathStep::Name(root) => {
11560                let root_td = self.resolve_path_root(root)?;
11561                // `(select T filter …).field` reads every `T` the filter
11562                // keeps, unless it pins an exclusive pointer.
11563                let every_row_of_a_type = !path.partial
11564                    && self.cte_object_type(root).is_none()
11565                    && self.resolve_type(root).is_ok()
11566                    && !selects_at_most_one(sel, root_td);
11567                every_row_of_a_type || self.path_crosses_multi(root_td, &full_path.steps[1..])
11568            }
11569            _ => false,
11570        };
11571        if multi && !single && matches!(ps.result, IrPathResult::Scalar(..)) {
11572            return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
11573                ps,
11574            )))));
11575        }
11576        Ok(IrExpr::PathSubquery(Box::new(ps)))
11577    }
11578
11579    /// `account::owner(.id).name` and `(select account::owner(.id) filter …
11580    /// limit 1).name` — an object-returning user function used inside a
11581    /// larger expression, projected down to one of its return type's
11582    /// columns.
11583    ///
11584    /// The call itself stays object-valued (there is no way to inline a
11585    /// function that returns a row set), so it becomes the FROM clause of a
11586    /// scalar subquery and `field` becomes what that subquery selects. Only
11587    /// a property of the return type can be projected — reaching further
11588    /// (`fn(x).company.name`) would need joins the function row source has
11589    /// no way to express.
11590    ///
11591    /// `Ok(None)` when `fc` doesn't name an object-returning function, so
11592    /// the caller can fall through to ordinary function-call resolution.
11593    fn try_compile_fn_scalar_subquery(
11594        &mut self,
11595        fc: &ast::FunctionCall,
11596        fields: &[String],
11597        modifiers: Option<&ast::SelectStmt>,
11598        ctx: Option<(&TypeDescriptor, &str)>,
11599    ) -> Result<Option<IrExpr>, PyQLError> {
11600        let fd = self.schema.functions.iter().find(|f| {
11601            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
11602            module_matches && f.name == fc.name && f.return_is_object
11603        });
11604        let Some(fd) = fd else { return Ok(None) };
11605        let (fn_module, fn_name, return_type_name, polymorphic, params) = (
11606            fd.module.clone(),
11607            fd.name.clone(),
11608            fd.return_pg_type.clone(),
11609            fd.return_is_polymorphic,
11610            fd.params.clone(),
11611        );
11612
11613        let qualified = format!("{fn_module}::{fn_name}");
11614        let [field] = fields else {
11615            return Err(self.type_err(&format!(
11616                "function '{qualified}' returns objects, so using it inside an expression needs \
11617                 one of its properties, e.g. '{qualified}(…).name'"
11618            )));
11619        };
11620        if params.len() != fc.args.len() {
11621            return Err(self.type_err(&format!(
11622                "function '{qualified}' expects {} argument(s), got {}",
11623                params.len(),
11624                fc.args.len()
11625            )));
11626        }
11627
11628        let mut fn_args = fc
11629            .args
11630            .iter()
11631            .map(|a| self.compile_expr_ctx(a, ctx))
11632            .collect::<Result<Vec<_>, _>>()?;
11633        if let Some(globals) = self.globals_arg_for_call(&qualified)? {
11634            fn_args.insert(0, globals);
11635        }
11636
11637        let td = self.resolve_type(&return_type_name)?;
11638        let alias = self.fresh_alias();
11639        let Some(prop) = Self::resolve_property(td, field) else {
11640            return Err(self.field_err(field, &return_type_name));
11641        };
11642        let projected = IrShapePointer::Computed(IrComputedPointer {
11643            marker_offset: None,
11644            alias: field.clone(),
11645            expr: IrExpr::ColumnRef {
11646                alias: alias.clone(),
11647                column: prop.name.clone(),
11648                pg_type: prop.pg_type.clone(),
11649            },
11650        });
11651
11652        let (poly_implementors, poly_columns) = if polymorphic {
11653            self.collect_poly_info(&return_type_name)
11654        } else {
11655            (vec![], vec![])
11656        };
11657        let (filter, order_by, offset, limit) = match modifiers {
11658            Some(sel) => {
11659                let td = self.resolve_type(&return_type_name)?;
11660                self.compile_path_modifiers(sel, td, &alias)?
11661            }
11662            None => (None, vec![], None, None),
11663        };
11664
11665        Ok(Some(IrExpr::FnSubquery(Box::new(IrFunctionSelect {
11666            fn_module,
11667            fn_name,
11668            fn_args,
11669            alias,
11670            type_name: return_type_name,
11671            polymorphic,
11672            poly_implementors,
11673            poly_columns,
11674            shape: vec![projected],
11675            filter,
11676            order_by,
11677            offset,
11678            limit,
11679            distinct: false,
11680        }))))
11681    }
11682
11683    /// Whether a declared computed pointer stands for *objects* rather than a
11684    /// value — `members := .memberships.member`, `primary_email := (select
11685    /// .emails filter .primary limit 1)`.
11686    ///
11687    /// `*` expands to properties and `**` adds links, so a computed belongs to
11688    /// whichever side its expression lands on. `ComputedDescriptor` carries no
11689    /// flag saying which, so the expression has to be walked.
11690    fn computed_is_object_valued(&self, cd: &crate::schema::ComputedDescriptor, td: &TypeDescriptor) -> bool {
11691        let Ok(expr) = crate::parse::parse_pointer_expr(&cd.expression) else {
11692            return false;
11693        };
11694        let expr = match Self::field_access_over_select(&expr) {
11695            Some((sel, _)) => Expr::SubQuery(Box::new(Stmt::Select(sel))),
11696            None => expr,
11697        };
11698        let Some((path, _, _)) = Self::pointer_subject(&expr) else {
11699            return false;
11700        };
11701        if !path.partial {
11702            return false;
11703        }
11704        match path.steps.as_slice() {
11705            // A backlink names the objects on the other side of the link.
11706            // Traversing past it (`.<author[is Post].title`) is a value again,
11707            // which the walk below works out for itself.
11708            [ast::PathStep::Backlink(_)] | [ast::PathStep::Backlink(_), ast::PathStep::TypeIntersection(_)] => true,
11709            steps => self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES).1.is_some(),
11710        }
11711    }
11712
11713    /// True when traversing `steps` from `td` crosses a multi-valued step —
11714    /// a multi-link or a backlink. Such a path stands for a *set*, so in
11715    /// expression position it has to come back as an array rather than a
11716    /// scalar subquery (which Postgres would reject at run time the moment a
11717    /// second row showed up).
11718    fn path_crosses_multi(&self, td: &TypeDescriptor, steps: &[ast::PathStep]) -> bool {
11719        self.walk_path_types(td, steps, MAX_COMPUTED_SPLICES).0
11720    }
11721
11722    /// One correlated walk per operand of a coalesce of relative paths, each
11723    /// one filtered to the rows where every operand before it came up empty —
11724    /// which is what makes the arms, read together, mean what `??` means.
11725    fn coalesce_path_branches(
11726        &mut self,
11727        td: &TypeDescriptor,
11728        alias: &str,
11729        operands: &[ast::Path],
11730        elements: &[ast::ShapeElement],
11731    ) -> Result<Vec<IrPathSelect>, PyQLError> {
11732        let mut branches: Vec<IrPathSelect> = Vec::with_capacity(operands.len());
11733        for path in operands {
11734            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
11735            steps.extend(path.steps.iter().cloned());
11736            let rooted = ast::Path { steps, partial: false };
11737            let synthetic = ast::SelectStmt {
11738                result: Expr::Path(rooted.clone()),
11739                filter: None,
11740                order_by: vec![],
11741                offset: None,
11742                limit: None,
11743                lock: None,
11744            };
11745            let mut ps = self.compile_path_select(&synthetic, &rooted, elements, false)?;
11746            Self::correlate_path_select(&mut ps, alias);
11747            let guards: Vec<IrExpr> = branches
11748                .iter()
11749                .map(|earlier| {
11750                    IrExpr::UnaryOp(Box::new(IrUnaryOp {
11751                        op: ast::UnaryOpKind::Not,
11752                        operand: IrExpr::UnaryOp(Box::new(IrUnaryOp {
11753                            op: ast::UnaryOpKind::Exists,
11754                            operand: IrExpr::PathSubquery(Box::new(earlier.clone())),
11755                        })),
11756                    }))
11757                })
11758                .collect();
11759            if !guards.is_empty() {
11760                ps.filter = and_conditions(ps.filter, guards);
11761            }
11762            branches.push(ps);
11763        }
11764        Ok(branches)
11765    }
11766
11767    /// Whether a relative path lands on objects rather than on values — its
11768    /// last named step is a link, a multi-link or a backlink. A coalesce over
11769    /// values is a choice between two sets of scalars, which the ordinary
11770    /// binary-operator path already reads as one.
11771    fn path_lands_on_objects(&self, td: &TypeDescriptor, path: &ast::Path) -> bool {
11772        let Some(last) = path
11773            .steps
11774            .iter()
11775            .rposition(|step| matches!(step, ast::PathStep::Name(_) | ast::PathStep::Backlink(_)))
11776        else {
11777            return false;
11778        };
11779        match &path.steps[last] {
11780            ast::PathStep::Backlink(_) => true,
11781            ast::PathStep::Name(name) => self
11782                .walk_path_types(td, &path.steps[..last], MAX_COMPUTED_SPLICES)
11783                .1
11784                .is_some_and(|owner| {
11785                    Self::resolve_multilink(owner, name).is_some() || Self::resolve_link(owner, name).is_some()
11786                }),
11787            _ => false,
11788        }
11789    }
11790
11791    /// Whether the operands of a union or coalesce of relative paths reach
11792    /// more than one object between them. A leading type intersection roots
11793    /// the walk at the intersected type, as `compile_partial_path_as_subquery`
11794    /// does, so `[is Loop].condition_configs` is read off `Loop`.
11795    fn relative_paths_reach_many(&self, td: &TypeDescriptor, operands: &[ast::Path]) -> bool {
11796        operands.iter().any(|path| {
11797            let (root_td, rest) = match path.steps.first() {
11798                Some(ast::PathStep::TypeIntersection(tr)) => {
11799                    let name = match &tr.module {
11800                        Some(module) => format!("{}::{}", module, tr.name),
11801                        None => tr.name.clone(),
11802                    };
11803                    match self.resolve_type(&name) {
11804                        Ok(resolved) => (resolved, &path.steps[1..]),
11805                        Err(_) => return true,
11806                    }
11807                }
11808                _ => (td, &path.steps[..]),
11809            };
11810            self.path_crosses_multi(root_td, rest)
11811        })
11812    }
11813
11814    /// Walk `steps` from `td` without compiling anything, reporting whether
11815    /// any step is multi-valued and what type the walk ends on. A computed
11816    /// pointer is expanded into the path it stands for — the same splice
11817    /// `compile_path_select` performs — so `.published.title` is recognized
11818    /// as multi-valued when `published` resolves to a multi-link.
11819    ///
11820    /// Gives up (`None` target) rather than guessing on anything it can't
11821    /// resolve; the real compile reports the error.
11822    fn walk_path_types(
11823        &self,
11824        td: &'a TypeDescriptor,
11825        steps: &[ast::PathStep],
11826        depth: usize,
11827    ) -> (bool, Option<&'a TypeDescriptor>) {
11828        let mut current = td;
11829        let mut multi = false;
11830        for (i, step) in steps.iter().enumerate() {
11831            match step {
11832                // `.<link[is Owner]` names the owner type; a bare `.<link`
11833                // spans every type declaring it, so that one lands nowhere in
11834                // particular.
11835                ast::PathStep::Backlink(backlink_name) => {
11836                    let Some(ast::PathStep::TypeIntersection(tr)) = steps.get(i + 1) else {
11837                        return (true, None);
11838                    };
11839                    let owner_name = match &tr.module {
11840                        Some(m) => format!("{}::{}", m, tr.name),
11841                        None => tr.name.clone(),
11842                    };
11843                    let current_qname = format!("{}::{}", current.module, current.name);
11844                    let single = self
11845                        .resolve_type(&owner_name)
11846                        .is_ok_and(|owner| self.backlink_is_single(owner, backlink_name, &current_qname));
11847                    multi |= !single;
11848                    continue;
11849                }
11850                ast::PathStep::TypeIntersection(tr) => {
11851                    let name = match &tr.module {
11852                        Some(m) => format!("{}::{}", m, tr.name),
11853                        None => tr.name.clone(),
11854                    };
11855                    match self.resolve_type(&name) {
11856                        Ok(t) => current = t,
11857                        Err(_) => return (multi, None),
11858                    }
11859                }
11860                ast::PathStep::Name(n) => {
11861                    if let Some(ml) = Self::resolve_multilink(current, n) {
11862                        multi = true;
11863                        match self.resolve_type(&ml.target) {
11864                            Ok(t) => current = t,
11865                            Err(_) => return (multi, None),
11866                        }
11867                        continue;
11868                    }
11869                    if let Some(target) = Self::resolve_link(current, n).map(|l| l.target.clone()) {
11870                        match self.resolve_type(&target) {
11871                            Ok(t) => current = t,
11872                            Err(_) => return (multi, None),
11873                        }
11874                        continue;
11875                    }
11876                    // A computed pointer stands for its own path, or for the
11877                    // object-returning function it calls.
11878                    if depth > 0
11879                        && let Some(cd) = self.resolve_computed(current, n)
11880                        && let Ok(expr) = crate::parse::parse_pointer_expr(&cd.expression)
11881                    {
11882                        if let Some((p, _, modifiers)) = Self::pointer_subject(&expr)
11883                            && p.partial
11884                        {
11885                            let (m, t) = self.walk_path_types(current, &p.steps, depth - 1);
11886                            // `limit 1` of its own caps the computed at one
11887                            // row however many its path crosses.
11888                            let capped = modifiers
11889                                .is_some_and(|m| matches!(&m.limit, Some(Expr::Literal(ast::Literal::Int(1)))));
11890                            multi = multi || (m && !capped);
11891                            match t {
11892                                Some(t) => current = t,
11893                                None => return (multi, None),
11894                            }
11895                            continue;
11896                        }
11897                        if let Some((fc, _)) = Self::function_subject(&expr)
11898                            && let Some(fd) = self.resolve_object_fn(fc)
11899                        {
11900                            multi = multi || fd.return_is_set;
11901                            match self.resolve_type(&fd.return_pg_type) {
11902                                Ok(t) => current = t,
11903                                Err(_) => return (multi, None),
11904                            }
11905                            continue;
11906                        }
11907                    }
11908                    // A property (or something unresolvable): the walk ends.
11909                    return (multi, None);
11910                }
11911                _ => return (multi, None),
11912            }
11913        }
11914        (multi, Some(current))
11915    }
11916
11917    /// Compile a relative path that the step-by-step expression rules can't
11918    /// resolve on their own — deeper than two steps, a computed pointer on a
11919    /// linked type, anything following a backlink — as a single correlated
11920    /// subquery over the whole traversal.
11921    ///
11922    /// `compile_path_select` already implements the general case (forward
11923    /// links, multi-links, backlinks, junction-backed links, type
11924    /// intersections, nested tuple fields, "did you mean" on a typo), so the
11925    /// work here is only to root the path at the enclosing type and
11926    /// correlate it back to the enclosing row by primary key.
11927    ///
11928    /// A leading type intersection is rooted at the *intersected* type
11929    /// instead: `[is Concrete].col` has to read from Concrete's own table,
11930    /// which shares the interface row's id.
11931    fn compile_partial_path_as_subquery(
11932        &mut self,
11933        p: &ast::Path,
11934        td: &TypeDescriptor,
11935        alias: &str,
11936    ) -> Result<IrExpr, PyQLError> {
11937        let (root_name, rest) = match p.steps.first() {
11938            Some(ast::PathStep::TypeIntersection(tr)) => {
11939                let name = match &tr.module {
11940                    Some(m) => format!("{}::{}", m, tr.name),
11941                    None => tr.name.clone(),
11942                };
11943                (name, &p.steps[1..])
11944            }
11945            _ => (format!("{}::{}", td.module, td.name), &p.steps[..]),
11946        };
11947        let root_td = self.resolve_type(&root_name)?;
11948        let multi = self.path_crosses_multi(root_td, rest);
11949
11950        let mut steps = vec![ast::PathStep::Name(root_name)];
11951        steps.extend(rest.iter().cloned());
11952        let full_path = ast::Path { steps, partial: false };
11953        let synthetic = ast::SelectStmt {
11954            result: Expr::Path(full_path.clone()),
11955            filter: None,
11956            order_by: vec![],
11957            offset: None,
11958            limit: None,
11959            lock: None,
11960        };
11961        let mut ps = self.compile_path_select(&synthetic, &full_path, &[], false)?;
11962        Self::correlate_path_select(&mut ps, alias);
11963        if multi && matches!(ps.result, IrPathResult::Scalar(..)) {
11964            Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
11965                ps,
11966            )))))
11967        } else {
11968            Ok(IrExpr::PathSubquery(Box::new(ps)))
11969        }
11970    }
11971
11972    /// A sub-select written relative to the enclosing object (`(select
11973    /// .emails filter .primary)`, with or without a shape), as a path select
11974    /// rooted at that object and correlated back to its row.
11975    ///
11976    /// Several places compile a sub-select with no context and so lose the
11977    /// object a relative path hangs off -- it then resolves in free context
11978    /// and reports the pointer as unknown. `None` when the statement is not
11979    /// of that shape, so the caller can carry on as before.
11980    fn relative_subselect(
11981        &mut self,
11982        stmt: &Stmt,
11983        ctx: Option<(&TypeDescriptor, &str)>,
11984    ) -> Result<Option<IrPathSelect>, PyQLError> {
11985        let (Some((td, alias)), Stmt::Select(sel)) = (ctx, stmt) else {
11986            return Ok(None);
11987        };
11988        let (path, shape): (&ast::Path, &[ShapeElement]) = match &sel.result {
11989            Expr::Path(p) if p.partial => (p, &[]),
11990            Expr::Shape(sh) => match sh.expr.as_ref() {
11991                Some(Expr::Path(p)) if p.partial => (p, sh.elements.as_slice()),
11992                _ => return Ok(None),
11993            },
11994            _ => return Ok(None),
11995        };
11996        let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
11997        steps.extend(path.steps.iter().cloned());
11998        let rooted = ast::Path { steps, partial: false };
11999        let synthetic = ast::SelectStmt {
12000            result: Expr::Path(rooted.clone()),
12001            filter: sel.filter.clone(),
12002            order_by: sel.order_by.clone(),
12003            offset: sel.offset.clone(),
12004            limit: sel.limit.clone(),
12005            lock: None,
12006        };
12007        let mut ps = self.compile_path_select(&synthetic, &rooted, shape, false)?;
12008        Self::correlate_path_select(&mut ps, alias);
12009        Ok(Some(ps))
12010    }
12011
12012    /// Tie a path select's root row to the enclosing row by primary key —
12013    /// what makes a relative path's subquery see only the current object's
12014    /// side of the graph.
12015    fn correlate_path_select(ps: &mut IrPathSelect, outer_alias: &str) {
12016        // A trigger's own row may not be in its table yet (`BEFORE INSERT`):
12017        // the walk starts from the row itself.
12018        if outer_alias == "NEW" || outer_alias == "OLD" {
12019            ps.root.table = format!("@row:{outer_alias}");
12020            ps.root.poly = None;
12021        }
12022        let correlation = IrExpr::BinOp(Box::new(IrBinOp {
12023            left: IrExpr::ColumnRef {
12024                alias: ps.root.alias.clone(),
12025                column: "id".to_string(),
12026                pg_type: "uuid".to_string(),
12027            },
12028            op: ast::BinOpKind::Eq,
12029            right: IrExpr::ColumnRef {
12030                alias: outer_alias.to_string(),
12031                column: "id".to_string(),
12032                pg_type: "uuid".to_string(),
12033            },
12034        }));
12035        ps.filter = Some(match ps.filter.take() {
12036            Some(existing) => IrExpr::BinOp(Box::new(IrBinOp {
12037                left: correlation,
12038                op: ast::BinOpKind::And,
12039                right: existing,
12040            })),
12041            None => correlation,
12042        });
12043    }
12044
12045    /// Names the kind of sub-statement that can't stand in for a value, so
12046    /// the message points at the actual blocker instead of listing every
12047    /// statement keyword.
12048    fn subquery_expr_err(&self, stmt: &Stmt) -> PyQLError {
12049        let what = match stmt {
12050            Stmt::Insert(_) => "an insert",
12051            Stmt::Update(_) => "an update",
12052            Stmt::Delete(_) => "a delete",
12053            Stmt::With(_) => "a `with` block",
12054            Stmt::For(_) => "a `for` loop",
12055            Stmt::Group(_) => "a `group`",
12056            _ => "this sub-statement",
12057        };
12058        self.type_err(&format!(
12059            "{what} cannot stand in for a value — a sub-statement is only valid in expression \
12060             position as a select over a path, e.g. '(select .emails filter .primary limit 1).address'"
12061        ))
12062    }
12063
12064    // ── Expression compilation ────────────────────────────────────────────────────
12065
12066    /// The operands an aggregate reads when its argument is spelled as a set:
12067    /// the elements of a set literal, or the flattened arms of a `union` chain.
12068    /// `min({a, b})` and `min((a union b))` mean the same thing and both read
12069    /// two values, which is the shape `AggOverSet` wants either way. `None` for
12070    /// anything else, leaving the ordinary single-value routes to it.
12071    fn set_operands(expr: &Expr) -> Option<Vec<&Expr>> {
12072        match expr {
12073            // An empty set has no operands to aggregate over, and the row
12074            // source it would emit is not valid SQL -- leave it to the arms
12075            // that give `{}` its own meaning.
12076            Expr::Set(elems) if elems.is_empty() => None,
12077            Expr::Set(elems) => Some(elems.iter().collect()),
12078            Expr::Union(left, right) => {
12079                let mut operands = Self::set_operands(left).unwrap_or_else(|| vec![left.as_ref()]);
12080                operands.extend(Self::set_operands(right).unwrap_or_else(|| vec![right.as_ref()]));
12081                Some(operands)
12082            }
12083            _ => None,
12084        }
12085    }
12086
12087    /// Single expression compiler for both free and schema-bound contexts.
12088    /// `ctx = Some((td, alias))` when a schema type + SQL alias are in scope
12089    /// (enables `.property`/`.link` resolution via `compile_path`); `ctx =
12090    /// None` for free expressions (set literals, tuples, free objects,
12091    /// scalar function calls not touching a table), via `compile_free_path`
12092    /// for the `Path` variant. Most arms are identical either way and just
12093    /// thread `ctx` through recursive calls; the handful that genuinely
12094    /// diverge (`Path`, `BinOp`, `FunctionCall`, `Set`, `Detached`, `TypeIs`)
12095    /// branch internally on `ctx` — see each arm's own comment.
12096    fn compile_expr_ctx(&mut self, expr: &Expr, ctx: Option<(&TypeDescriptor, &str)>) -> Result<IrExpr, PyQLError> {
12097        match expr {
12098            // The main free/schema divergence — kept genuinely two-branched
12099            // (`compile_path` needs a schema type + alias throughout:
12100            // __type__, absolute-path rewrite, 2-step link/property
12101            // traversal, computed-pointer recursion; `compile_free_path`
12102            // only ever resolves for-vars/CTEs/fn-params/enum members).
12103            // Both share the bare-name lookup via `resolve_name_ref`.
12104            Expr::Path(p) => match ctx {
12105                Some((td, alias)) => self.compile_path(p, td, alias),
12106                None => self.compile_free_path(p),
12107            },
12108
12109            // `(select … limit 1).connector.provider[is Individual].staff` — a
12110            // walk off a sub-select including a step with no expression form.
12111            Expr::PathStepOn { .. } => match self.try_compile_walk_off_subquery(expr, ctx)? {
12112                Some(ir) => Ok(ir),
12113                None => Err(self.type_err(
12114                    "a type intersection, link property or backlink needs a path, a binding \
12115                     or a sub-select to walk off",
12116                )),
12117            },
12118
12119            Expr::Literal(lit) => Ok(IrExpr::Literal(match lit {
12120                Literal::Str(s) => IrLiteral::Str(s.clone()),
12121                Literal::Int(n) => IrLiteral::Int(*n),
12122                Literal::Float(f) => IrLiteral::Float(*f),
12123                Literal::Bool(b) => IrLiteral::Bool(*b),
12124            })),
12125
12126            Expr::Parameter(name) => {
12127                let index = self.param_index(name);
12128                Ok(IrExpr::Param { index })
12129            }
12130
12131            Expr::Global(name) => self.compile_global(name),
12132
12133            Expr::Index { expr: e, index: i } => {
12134                let ir_expr = self.compile_expr_ctx(e, ctx)?;
12135                if self.is_json_expr(e, &ir_expr) {
12136                    return match &**i {
12137                        Expr::Literal(Literal::Str(key)) => Ok(IrExpr::JsonbField {
12138                            expr: Box::new(ir_expr),
12139                            field: key.clone(),
12140                        }),
12141                        Expr::Literal(Literal::Int(n)) if *n >= 0 => Ok(IrExpr::JsonbIndex {
12142                            expr: Box::new(ir_expr),
12143                            index: *n as usize,
12144                        }),
12145                        _ => Err(self.type_err("indexing json needs a literal key or a literal position")),
12146                    };
12147                }
12148                let ir_index = self.compile_expr_ctx(i, ctx)?;
12149                let is_array = is_array_expr(&ir_expr);
12150                Ok(IrExpr::Subscript {
12151                    expr: Box::new(ir_expr),
12152                    index: Box::new(ir_index),
12153                    is_array,
12154                })
12155            }
12156
12157            Expr::Slice {
12158                expr: e,
12159                lower: lo,
12160                upper: hi,
12161            } => {
12162                let ir_expr = self.compile_expr_ctx(e, ctx)?;
12163                let is_array = is_array_expr(&ir_expr);
12164                let ir_lower = lo.as_ref().map(|x| self.compile_expr_ctx(x, ctx)).transpose()?;
12165                let ir_upper = hi.as_ref().map(|x| self.compile_expr_ctx(x, ctx)).transpose()?;
12166                Ok(IrExpr::Slice {
12167                    expr: Box::new(ir_expr),
12168                    lower: ir_lower.map(Box::new),
12169                    upper: ir_upper.map(Box::new),
12170                    is_array,
12171                })
12172            }
12173
12174            Expr::TypeCast(tc) => {
12175                // `<AnyType>{}` — an empty set cast to any type, e.g. clearing
12176                // an optional link (`<Company>{}`) — is always just NULL,
12177                // regardless of what pg_type the cast target would otherwise
12178                // resolve to (a schema object type name isn't a scalar cast
12179                // target at all, so resolve_cast_pg_type couldn't handle it
12180                // below anyway). Generalizes the same bare-`{}`-in-assignment-
12181                // position special case in compile_assignments_inner to any
12182                // expression context.
12183                if matches!(&tc.expr, Expr::Set(elems) if elems.is_empty()) {
12184                    return Ok(IrExpr::Null);
12185                }
12186                if let ast::TypeExpr::Tuple { elements } = &tc.ty
12187                    && let Some(ir) = self.try_compile_tuple_literal_cast_ctx(elements, &tc.expr, ctx)?
12188                {
12189                    let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12190                    let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
12191                    return Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
12192                        expr: ir,
12193                        pg_type,
12194                        tuple_shape,
12195                    })));
12196                }
12197                if let ast::TypeExpr::Array { element } = &tc.ty
12198                    && let Some(ir) = self.try_compile_array_literal_cast_ctx(element, &tc.expr, ctx)?
12199                {
12200                    let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12201                    return Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
12202                        expr: ir,
12203                        pg_type,
12204                        tuple_shape: None,
12205                    })));
12206                }
12207                // `<marketplace::BrandAddon>line.listing.id` — casting a key to
12208                // an object type names the row that key identifies, and in
12209                // expression position (a link's value, a comparison) the row
12210                // *is* its key. There is no scalar pg type to cast to, so
12211                // `resolve_cast_pg_type` below reports the object type as
12212                // unknown.
12213                const STDLIB_MODULES: &[&str] = &["std", "cal", "math", "sys", "pgvector", "crypto", "postgis"];
12214                if let Some((module, name)) = tc.ty.as_named()
12215                    && module.map(|m| !STDLIB_MODULES.contains(&m)).unwrap_or(false)
12216                {
12217                    let qname = match module {
12218                        Some(m) => format!("{m}::{name}"),
12219                        None => name.to_string(),
12220                    };
12221                    if self.resolve_enum(&qname).is_none()
12222                        && self.resolve_scalar(&qname).is_none()
12223                        && self.resolve_named_tuple(&qname).is_none()
12224                        && self.resolve_type(&qname).is_ok()
12225                    {
12226                        return self.compile_expr_ctx(&tc.expr, ctx);
12227                    }
12228                }
12229                // A cast to `json` is an output sink: the text it produces is
12230                // the value, so an `id` nobody asked for would show up in it.
12231                // So the flag is cleared for the whole cast.
12232                let inner = if casts_to_json(&tc.ty) {
12233                    self.without_implicit_id(|this| this.compile_expr_ctx(&tc.expr, ctx))?
12234                } else {
12235                    self.compile_expr_ctx(&tc.expr, ctx)?
12236                };
12237                let pg_type = self.resolve_cast_pg_type(&tc.ty)?;
12238
12239                // PostgreSQL has no native jsonb -> {uuid, date/time family,
12240                // interval, array<T>} cast (only jsonb -> {bool, numeric
12241                // family, text} are native as of PG17+) — there's nothing
12242                // generic to defer to the way `to_jsonb(x)` covers every
12243                // scalar in the opposite direction, so extract via `#>>'{}'`
12244                // (the value's raw text form) and cast that, matching what
12245                // `to_json`'s own emitted text already round-trips (ISO 8601
12246                // for datetimes, PG's native interval text, a bare UUID
12247                // string — see `to_jsonb`'s emission for `<json>x`).
12248                if infer_ir_type(&inner) == Some("jsonb") {
12249                    if let ast::TypeExpr::Array { element } = &tc.ty {
12250                        let elem_pg = self.resolve_cast_pg_type(element)?;
12251                        let sql_template =
12252                            format!("ARRAY(SELECT (elem #>> '{{}}')::{elem_pg} FROM jsonb_array_elements($1) AS elem)");
12253                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12254                            return_pg_type: None,
12255                            schema: None,
12256                            name: "jsonb_array_cast".to_string(),
12257                            args: vec![inner],
12258                            sql_template: Some(sql_template),
12259                        }));
12260                    }
12261                    if matches!(
12262                        pg_type.as_str(),
12263                        "uuid" | "timestamptz" | "timestamp" | "date" | "time" | "interval"
12264                    ) {
12265                        let sql_template = format!("(($1 #>> '{{}}'))::{pg_type}");
12266                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12267                            return_pg_type: None,
12268                            schema: None,
12269                            name: "jsonb_scalar_cast".to_string(),
12270                            args: vec![inner],
12271                            sql_template: Some(sql_template),
12272                        }));
12273                    }
12274                }
12275
12276                // The mirror of the `<str>x` routing below: every
12277                // str → date/time cast goes through a function, and those
12278                // are stricter than PostgreSQL's own input parsers, which
12279                // accept `01/16/2026`, a zone-less datetime, or `1 month` as
12280                // a duration. Keyed on the type as written, because
12281                // `duration`, `cal::relative_duration` and
12282                // `cal::date_duration` are all `interval` and each takes a
12283                // different set of units.
12284                if infer_ir_type(&inner) == Some("text")
12285                    && let Some((module, name)) = tc.ty.as_named()
12286                {
12287                    let parser = match (module, name) {
12288                        (None | Some("std"), "datetime") => Some(("std", "to_datetime")),
12289                        (Some("cal"), "local_datetime") => Some(("cal", "to_local_datetime")),
12290                        (Some("cal"), "local_date") => Some(("cal", "to_local_date")),
12291                        (Some("cal"), "local_time") => Some(("cal", "to_local_time")),
12292                        _ => None,
12293                    };
12294                    if let Some((ns, fn_name)) = parser {
12295                        return self.resolve_fn_call(Some(ns), fn_name, vec![inner]);
12296                    }
12297                    // Neither is reachable as a function, only as a cast, so
12298                    // they are internal helpers rather than stdlib
12299                    // overloads (which would also be indistinguishable, both
12300                    // taking one `interval`).
12301                    let helper = match (module, name) {
12302                        (None | Some("std"), "duration") => Some("duration_in"),
12303                        (Some("cal"), "date_duration") => Some("date_duration_in"),
12304                        _ => None,
12305                    };
12306                    if let Some(helper) = helper {
12307                        return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
12308                            return_pg_type: Some("interval".to_string()),
12309                            schema: Some("_pylon".to_string()),
12310                            name: helper.to_string(),
12311                            args: vec![inner],
12312                            sql_template: None,
12313                        }));
12314                    }
12315                }
12316
12317                // `<str>` of a date/time value is `to_str` of it, so that the
12318                // cast and the function cannot disagree. `::text`
12319                // gives a different answer for the same value — a datetime
12320                // renders as `2026-01-16 12:34:56+00` rather than ISO 8601 —
12321                // and the two spellings must not disagree. The rest of the
12322                // family is routed too, where `to_str` *is* `::text`, so the
12323                // rule is about the types rather than about which
12324                // implementations happen to differ today.
12325                if pg_type == "text"
12326                    && matches!(
12327                        infer_ir_type(&inner),
12328                        Some("timestamptz" | "timestamp" | "date" | "time" | "interval")
12329                    )
12330                {
12331                    return self.resolve_fn_call(Some("std"), "to_str", vec![inner]);
12332                }
12333
12334                // PostgreSQL casts a boolean to `int4` and to nothing else, so
12335                // the narrower and wider integers go through it.
12336                let inner = match (pg_type.as_str(), infer_ir_type(&inner)) {
12337                    ("int2" | "int8", Some("boolean")) => IrExpr::TypeCast(Box::new(IrTypeCast {
12338                        expr: inner,
12339                        pg_type: "int4".to_string(),
12340                        tuple_shape: None,
12341                    })),
12342                    _ => inner,
12343                };
12344
12345                let tuple_shape = self.resolve_tuple_cast_shape(&tc.ty);
12346                Ok(IrExpr::TypeCast(Box::new(IrTypeCast {
12347                    expr: inner,
12348                    pg_type,
12349                    tuple_shape,
12350                })))
12351            }
12352
12353            // The same coalesce with no shape after it — `(… ?? …)` standing
12354            // for the ids of the set it reaches. SQL's own COALESCE, which an
12355            // ordinary binary operator compiles to, takes one value per side,
12356            // so a walk reaching many rows has to be read as a set instead.
12357            Expr::BinOp(b)
12358                if b.op == ast::BinOpKind::Coalesce
12359                    && ctx.is_some()
12360                    && Self::coalesce_of_relative_paths(expr).is_some_and(|operands| {
12361                        let td = ctx.expect("checked").0;
12362                        self.relative_paths_reach_many(td, &operands)
12363                            && operands.iter().all(|path| self.path_lands_on_objects(td, path))
12364                    }) =>
12365            {
12366                let (td, alias) = ctx.expect("checked by the guard");
12367                let operands = Self::coalesce_of_relative_paths(expr).expect("checked by the guard");
12368                let branches = self.coalesce_path_branches(td, alias, &operands, &[])?;
12369                Ok(IrExpr::ObjectPathUnion {
12370                    branches,
12371                    limit: None,
12372                    multi: true,
12373                })
12374            }
12375
12376            Expr::BinOp(b) => {
12377                // Only a condition can become an EXISTS — `.<pins.updated_at ??
12378                // .<pins.created_at` is a set of values, not a test on each pin.
12379                let yields_values = matches!(
12380                    b.op,
12381                    ast::BinOpKind::Add
12382                        | ast::BinOpKind::Sub
12383                        | ast::BinOpKind::Mul
12384                        | ast::BinOpKind::Div
12385                        | ast::BinOpKind::FloorDiv
12386                        | ast::BinOpKind::Mod
12387                        | ast::BinOpKind::Pow
12388                        | ast::BinOpKind::Coalesce
12389                        | ast::BinOpKind::Concat
12390                );
12391                if let Some((td, alias)) = ctx
12392                    && !yields_values
12393                {
12394                    if let Some(exists) = self.try_backlink_exists(b, td, alias)? {
12395                        return Ok(exists);
12396                    }
12397                    if let Some(exists) = self.try_multilink_exists(b, td, alias)? {
12398                        return Ok(exists);
12399                    }
12400                }
12401                // `x in {a, b, c}` / `x not in {a, b, c}`: a set *literal*
12402                // specifically on the right of in/not-in compiles to a
12403                // Postgres array, not through the generic Set-literal path
12404                // below (which hard-errors on any bare set literal in
12405                // expression position) — In/NotIn's own SQL emission
12406                // (`= ANY(...)`/`<> ALL(...)`, sql/mod.rs) already expects
12407                // an array-typed right operand, so this is the one
12408                // expression position a set literal is actually meaningful
12409                // in, in either schema-bound or free context.
12410                if matches!(b.op, ast::BinOpKind::In | ast::BinOpKind::NotIn)
12411                    && let Expr::Set(elems) = &b.right
12412                {
12413                    let left = self.compile_expr_ctx(&b.left, ctx)?;
12414                    let items = elems
12415                        .iter()
12416                        .map(|e| self.compile_expr_ctx(e, ctx))
12417                        .collect::<Result<Vec<_>, _>>()?;
12418                    let right = IrExpr::Array(items);
12419                    return Ok(IrExpr::BinOp(Box::new(IrBinOp {
12420                        left,
12421                        op: b.op.clone(),
12422                        right,
12423                    })));
12424                }
12425                // `.account = account_of_transaction()` — comparing objects
12426                // compares identity, so an object-returning function on either
12427                // side stands for the id of the row it returns. Left whole it
12428                // is a function call with nowhere to go but the subject of a
12429                // select.
12430                if matches!(b.op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) {
12431                    let id = ["id".to_string()];
12432                    let left_id = match &b.left {
12433                        Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &id, None, ctx)?,
12434                        _ => None,
12435                    };
12436                    let right_id = match &b.right {
12437                        Expr::FunctionCall(fc) => self.try_compile_fn_scalar_subquery(fc, &id, None, ctx)?,
12438                        _ => None,
12439                    };
12440                    if left_id.is_some() || right_id.is_some() {
12441                        let left = match left_id {
12442                            Some(e) => e,
12443                            None => self.compile_expr_ctx(&b.left, ctx)?,
12444                        };
12445                        let right = match right_id {
12446                            Some(e) => e,
12447                            None => self.compile_expr_ctx(&b.right, ctx)?,
12448                        };
12449                        return Ok(IrExpr::BinOp(Box::new(IrBinOp {
12450                            left,
12451                            op: b.op.clone(),
12452                            right,
12453                        })));
12454                    }
12455                }
12456                let mut left = self.compile_expr_ctx(&b.left, ctx)?;
12457                let mut right = self.compile_expr_ctx(&b.right, ctx)?;
12458                // An ordering comparison takes one value a side, so a walk
12459                // gathered as an array is read back as a scalar subquery — see
12460                // `set_walk_as_scalar`.
12461                if matches!(
12462                    b.op,
12463                    ast::BinOpKind::Lt | ast::BinOpKind::Le | ast::BinOpKind::Gt | ast::BinOpKind::Ge
12464                ) && yields_array(&left) != yields_array(&right)
12465                {
12466                    left = set_walk_as_scalar(left);
12467                    right = set_walk_as_scalar(right);
12468                }
12469                // `A ?? B` over sets is A unless A is empty. An empty set
12470                // gathered as an array is `{}`, not NULL, so `COALESCE` would
12471                // never fall through to B.
12472                if b.op == ast::BinOpKind::Coalesce && (yields_array(&left) || yields_array(&right)) {
12473                    let as_set = |value: IrExpr| {
12474                        if yields_array(&value) {
12475                            value
12476                        } else {
12477                            IrExpr::FunctionCall(IrFunctionCall {
12478                                return_pg_type: None,
12479                                schema: None,
12480                                name: "array_remove".to_string(),
12481                                args: vec![value],
12482                                sql_template: Some("array_remove(ARRAY[$1], NULL)".to_string()),
12483                            })
12484                        }
12485                    };
12486                    let left = as_set(left);
12487                    let condition = IrExpr::FunctionCall(IrFunctionCall {
12488                        return_pg_type: None,
12489                        schema: None,
12490                        name: "cardinality".to_string(),
12491                        args: vec![left.clone()],
12492                        sql_template: Some("(cardinality($1) > 0)".to_string()),
12493                    });
12494                    return Ok(IrExpr::IfElse(Box::new(IrIfElse {
12495                        condition,
12496                        if_: left,
12497                        else_: as_set(right),
12498                    })));
12499                }
12500                // Comparing a value against a *set* — a path that crosses a
12501                // multi-link or a backlink, which arrives here as an array of
12502                // its elements — holds when any element matches, which is what
12503                // the equality means in PyQL and what `= ANY` says in SQL.
12504                if matches!(b.op, ast::BinOpKind::Eq | ast::BinOpKind::Ne) {
12505                    // A `with` binding of more than one row is the same kind
12506                    // of set, reached through its CTE rather than an array.
12507                    let is_set = |e: &IrExpr| match e {
12508                        IrExpr::ArrayFromSelect(_) => true,
12509                        IrExpr::CteRef { name, .. } => self.multi_row_ctes.contains(name),
12510                        _ => false,
12511                    };
12512                    let flipped = is_set(&left) && !is_set(&right) && !is_array_expr(&right);
12513                    let straight = is_set(&right) && !is_set(&left) && !is_array_expr(&left);
12514                    if flipped || straight {
12515                        let (value, set) = if straight { (left, right) } else { (right, left) };
12516                        let membership = IrExpr::BinOp(Box::new(IrBinOp {
12517                            left: value,
12518                            op: ast::BinOpKind::In,
12519                            right: set,
12520                        }));
12521                        return Ok(if matches!(b.op, ast::BinOpKind::Ne) {
12522                            IrExpr::UnaryOp(Box::new(IrUnaryOp {
12523                                op: ast::UnaryOpKind::Not,
12524                                operand: membership,
12525                            }))
12526                        } else {
12527                            membership
12528                        });
12529                    }
12530                }
12531                if let (Some(lt), Some(rt)) = (infer_ir_type(&left), infer_ir_type(&right))
12532                    && !types_compatible(lt, rt)
12533                    && !datetime_arithmetic_compatible(&b.op, lt, rt)
12534                {
12535                    return Err(PyQLError::Type(PyQLTypeError {
12536                        message: format!(
12537                            "operator '{op}' cannot be applied to operands of type \
12538                                 '{lq}' and '{rq}'",
12539                            op = b.op,
12540                            lq = pg_type_to_pyql(lt),
12541                            rq = pg_type_to_pyql(rt),
12542                        ),
12543                        position: Position { line: 0, col: 0 },
12544                    }));
12545                }
12546                Ok(IrExpr::BinOp(Box::new(IrBinOp {
12547                    left: true_division_operand(&b.op, left, &right),
12548                    op: b.op.clone(),
12549                    right,
12550                })))
12551            }
12552
12553            Expr::FunctionCall(f) => {
12554                // notify(Channel, payload) / notify_raw(name, payload) → pg_notify(...).
12555                // Works in both free and schema-bound context (unlike sequence_next
12556                // below) since a trigger handler's payload needs __new__/__old__,
12557                // which only ever resolves schema-bound.
12558                if (f.module.is_none() || f.module.as_deref() == Some("std")) && f.name == "notify" {
12559                    return self.compile_notify(f, ctx);
12560                }
12561                if (f.module.is_none() || f.module.as_deref() == Some("std")) && f.name == "notify_raw" {
12562                    return self.compile_notify_raw(f, ctx);
12563                }
12564
12565                // sequence_next / sequence_reset: type-ref arg → nextval/setval SQL
12566                // (free-only: schema-bound context never special-cased this).
12567                if ctx.is_none()
12568                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
12569                    && (f.name == "sequence_next" || f.name == "sequence_reset")
12570                {
12571                    return self.compile_sequence_fn(f);
12572                }
12573
12574                // assert_single(subquery) [free-only] / assert_single|assert_exists|
12575                // assert_distinct(subquery) [schema-bound] → _pylon.<fn>(ARRAY(subquery)).
12576                // Preserves the existing asymmetry: free context only ever recognized
12577                // "assert_single" here, not the other two.
12578                let assert_names: &[&str] = if ctx.is_some() {
12579                    &["assert_single", "assert_exists", "assert_distinct"]
12580                } else {
12581                    &["assert_single"]
12582                };
12583                if (f.module.is_none() || f.module.as_deref() == Some("std"))
12584                    && assert_names.contains(&f.name.as_str())
12585                    && !f.args.is_empty()
12586                    && let Expr::SubQuery(inner_stmt) = &f.args[0]
12587                {
12588                    let inner = match self.relative_subselect(inner_stmt, ctx)? {
12589                        Some(ps) => IrArraySource::PathSelect(Box::new(ps)),
12590                        None => self.compile_subquery_to_array_source(inner_stmt)?,
12591                    };
12592                    let fn_pg = match f.name.as_str() {
12593                        "assert_single" => "assert_single",
12594                        "assert_exists" => "assert_exists",
12595                        _ => "assert_distinct",
12596                    };
12597                    let mut args = vec![IrExpr::ArrayFromSelect(Box::new(inner))];
12598                    args.extend(self.assert_message(f, ctx)?);
12599                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
12600                        return_pg_type: None,
12601                        schema: Some("_pylon".to_string()),
12602                        name: fn_pg.to_string(),
12603                        args,
12604                        sql_template: None,
12605                    }));
12606                }
12607
12608                // contains(.multilink.scalar, value) → EXISTS (set-membership
12609                // semantics; schema-bound only, needs td/alias to resolve the
12610                // multilink).
12611                if let Some((td, alias)) = ctx
12612                    && (f.module.is_none() || f.module.as_deref() == Some("std"))
12613                    && f.name == "contains"
12614                    && f.args.len() == 2
12615                    && let Expr::Path(p) = &f.args[0]
12616                    && p.partial
12617                    && p.steps.len() >= 2
12618                    && let ast::PathStep::Name(ln) = &p.steps[0]
12619                    && Self::resolve_multilink(td, ln).is_some()
12620                {
12621                    let synthetic = ast::BinOp {
12622                        left: f.args[0].clone(),
12623                        op: ast::BinOpKind::Eq,
12624                        right: f.args[1].clone(),
12625                    };
12626                    if let Some(exists) = self.try_multilink_exists(&synthetic, td, alias)? {
12627                        return Ok(exists);
12628                    }
12629                }
12630
12631                // Single-arg aggregate over a schema-shaped source →
12632                // AggOverQuery. Schema-bound: count(.multilink) correlates via
12633                // the junction/FK table (`.multilink` isn't an ordinary scalar
12634                // path). Free: count(TypeName) / count((select TypeName ...))
12635                // resolves the arg as a schema type reference or subquery.
12636                if f.args.len() == 1
12637                    && f.kwargs.is_empty()
12638                    && let Some(mut ps) = self.compile_shape_field_select(&f.args[0], ctx)?
12639                {
12640                    let IrPathResult::Scalar(column, _) = ps.result else {
12641                        return Err(self.type_err("a shape's pointer read off a walk is a value"));
12642                    };
12643                    let ns = f.module.as_deref().unwrap_or("std");
12644                    let aggregate =
12645                        crate::stdlib::lookup(ns, &f.name)
12646                            .into_iter()
12647                            .find_map(|d| match &d.impl_strategy {
12648                                crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12649                                    Some(sql_name.to_string())
12650                                }
12651                                _ => None,
12652                            });
12653                    let Some(sql_name) = aggregate else {
12654                        return Err(self.type_err(&format!(
12655                            "'{}' over a shape's pointer read off a walk needs an aggregate",
12656                            f.name
12657                        )));
12658                    };
12659                    // Over no rows SQL's aggregates give NULL where PyQL's
12660                    // give the empty set's own value.
12661                    let over_nothing = aggregate_over_nothing_sql(&f.name);
12662                    // An unnested set cannot sit inside the aggregate call;
12663                    // the walk's rows are aggregated from outside instead.
12664                    let subquery = if matches!(&column, IrExpr::FunctionCall(f) if f.name == "unnest" && f.sql_template.is_none())
12665                    {
12666                        ps.result = IrPathResult::Scalar(column, None);
12667                        IrExpr::FunctionCall(IrFunctionCall {
12668                            return_pg_type: None,
12669                            schema: None,
12670                            name: sql_name.clone(),
12671                            args: vec![IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
12672                                ps,
12673                            ))))],
12674                            sql_template: Some(format!(
12675                                "(SELECT {sql_name}(\"_s\".\"v\") FROM unnest($1) AS \"_s\"(\"v\"))"
12676                            )),
12677                        })
12678                    } else {
12679                        let aggregate = IrExpr::FunctionCall(IrFunctionCall {
12680                            return_pg_type: None,
12681                            schema: None,
12682                            name: sql_name,
12683                            args: vec![column],
12684                            sql_template: None,
12685                        });
12686                        ps.result = IrPathResult::Scalar(aggregate, None);
12687                        IrExpr::PathSubquery(Box::new(ps))
12688                    };
12689                    return Ok(match over_nothing {
12690                        Some(value) => IrExpr::FunctionCall(IrFunctionCall {
12691                            return_pg_type: None,
12692                            schema: None,
12693                            name: "coalesce".to_string(),
12694                            args: vec![subquery, IrExpr::RawSql(value.to_string())],
12695                            sql_template: None,
12696                        }),
12697                        None => subquery,
12698                    });
12699                }
12700                if f.args.len() == 1 {
12701                    let arg = &f.args[0];
12702                    // `count(a intersect b)` counts what the operation yields.
12703                    // Read as an array it would count the array: always one.
12704                    if matches!(arg, Expr::Intersect(_, _) | Expr::Except(_, _)) {
12705                        use crate::stdlib::{ImplStrategy, lookup};
12706                        let ns = f.module.as_deref().unwrap_or("std");
12707                        let best = lookup(ns, &f.name)
12708                            .iter()
12709                            .find(|d| d.params.len() == 1)
12710                            .map(|d| d.impl_strategy.clone());
12711                        if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best
12712                            && let IrExpr::SetOp { op, left, right, .. } = self.compile_expr_ctx(arg, ctx)?
12713                        {
12714                            return Ok(IrExpr::SetOp {
12715                                op,
12716                                left,
12717                                right,
12718                                mode: super::SetOpMode::Aggregate(sql_name.to_string()),
12719                            });
12720                        }
12721                    }
12722                    // `array_agg(array_unpack(.permissions))` — the elements
12723                    // of one row's array. PostgreSQL rejects a set-returning
12724                    // call inside an aggregate, so the unpacking becomes the
12725                    // row source the aggregate reads.
12726                    if let Expr::FunctionCall(unpack) = arg
12727                        && unpack.module.as_deref().unwrap_or("std") == "std"
12728                        && unpack.name == "array_unpack"
12729                        && unpack.kwargs.is_empty()
12730                        && let [array] = unpack.args.as_slice()
12731                        && let Some(sql_name) = crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
12732                            .into_iter()
12733                            .find_map(|d| match &d.impl_strategy {
12734                                crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12735                                    Some(sql_name.to_string())
12736                                }
12737                                _ => None,
12738                            })
12739                    {
12740                        let values = self.compile_expr_ctx(array, ctx)?;
12741                        // A walk gathered as an array holds one array per row,
12742                        // which `unnest` would only flatten one level; that
12743                        // shape is aggregated where the walk is built instead.
12744                        if !yields_array(&values) {
12745                            let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
12746                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
12747                                return_pg_type: None,
12748                                schema: None,
12749                                name: sql_name.clone(),
12750                                args: vec![values],
12751                                sql_template: Some(format!(
12752                                    "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
12753                                )),
12754                            }));
12755                        }
12756                    }
12757                    // `sum((select T filter …).amount)` — the sub-select's
12758                    // values arrive gathered as an array; the aggregate
12759                    // takes its elements.
12760                    if matches!(arg, Expr::FieldAccess { .. })
12761                        && matches!(Self::peel_field_access_chain(arg).0, Expr::SubQuery(_))
12762                    {
12763                        let ns = f.module.as_deref().unwrap_or("std");
12764                        let aggregate =
12765                            crate::stdlib::lookup(ns, &f.name)
12766                                .into_iter()
12767                                .find_map(|d| match &d.impl_strategy {
12768                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12769                                        Some(sql_name.to_string())
12770                                    }
12771                                    _ => None,
12772                                });
12773                        if let Some(sql_name) = aggregate {
12774                            let values = self.compile_expr_ctx(arg, ctx)?;
12775                            if matches!(values, IrExpr::ArrayFromSelect(_)) {
12776                                let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
12777                                return Ok(IrExpr::FunctionCall(IrFunctionCall {
12778                                    return_pg_type: None,
12779                                    schema: None,
12780                                    name: sql_name.clone(),
12781                                    args: vec![values],
12782                                    sql_template: Some(format!(
12783                                        "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
12784                                    )),
12785                                }));
12786                            }
12787                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
12788                                return_pg_type: None,
12789                                schema: None,
12790                                name: sql_name,
12791                                args: vec![values],
12792                                sql_template: None,
12793                            }));
12794                        }
12795                    }
12796                    // `max(.<pins.updated_at ?? .<pins.created_at)` — the
12797                    // coalesce picks one whole set, gathered as an array, and
12798                    // the aggregate takes its elements.
12799                    if let Expr::BinOp(b) = arg
12800                        && b.op == ast::BinOpKind::Coalesce
12801                    {
12802                        let values = self.compile_expr_ctx(arg, ctx)?;
12803                        let ns = f.module.as_deref().unwrap_or("std");
12804                        let aggregate =
12805                            crate::stdlib::lookup(ns, &f.name)
12806                                .into_iter()
12807                                .find_map(|d| match &d.impl_strategy {
12808                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12809                                        Some(sql_name.to_string())
12810                                    }
12811                                    _ => None,
12812                                });
12813                        if let Some(sql_name) = aggregate
12814                            && yields_array(&values)
12815                        {
12816                            let over_nothing = aggregate_over_nothing_sql(&f.name).unwrap_or("NULL");
12817                            return Ok(IrExpr::FunctionCall(IrFunctionCall {
12818                                return_pg_type: None,
12819                                schema: None,
12820                                name: sql_name.clone(),
12821                                args: vec![values],
12822                                sql_template: Some(format!(
12823                                    "(SELECT coalesce({sql_name}(\"_s\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_s\"(\"v\"))"
12824                                )),
12825                            }));
12826                        }
12827                        return self.resolve_fn_call(f.module.as_deref(), &f.name, vec![values]);
12828                    }
12829                    // `count(memberships)` — a binding names a set, so the
12830                    // aggregate runs over its rows. Compiled as an ordinary
12831                    // expression it becomes a scalar subquery over the CTE,
12832                    // which any binding of more than one row aborts on.
12833                    if let Some(name) = self.resolve_cte_name(arg) {
12834                        use crate::stdlib::{ImplStrategy, lookup};
12835                        let ns = f.module.as_deref().unwrap_or("std");
12836                        let overloads = lookup(ns, &f.name);
12837                        let best = overloads
12838                            .iter()
12839                            .find(|d| d.params.len() == 1)
12840                            .or_else(|| overloads.first());
12841                        if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best.map(|d| &d.impl_strategy)
12842                            && best.is_some_and(|d| d.params.first().is_some_and(|p| p.ty.is_set()))
12843                        {
12844                            let object = self.cte_types.get(name).is_some_and(|bound| bound.contains("::"));
12845                            return Ok(IrExpr::AggOverCte {
12846                                fn_name: sql_name.to_string(),
12847                                cte: name.to_string(),
12848                                column: (!object).then(|| "v".to_string()),
12849                            });
12850                        }
12851                    }
12852                    if let Some((td, alias)) = ctx {
12853                        // `count((select .<promotion filter …))` — compiled as an
12854                        // expression the sub-select is a scalar subquery, and a
12855                        // bare aggregate around it aggregates the enclosing select.
12856                        if let Expr::SubQuery(stmt) = arg
12857                            && f.name != "array_agg"
12858                            && let Some(sql_name) = crate::stdlib::lookup(f.module.as_deref().unwrap_or("std"), &f.name)
12859                                .into_iter()
12860                                .find_map(|d| match &d.impl_strategy {
12861                                    crate::stdlib::ImplStrategy::SqlBuiltin(sql_name) if d.is_aggregate() => {
12862                                        Some(sql_name.to_string())
12863                                    }
12864                                    _ => None,
12865                                })
12866                            && let Some(mut ps) = self.relative_subselect(stmt, ctx)?
12867                        {
12868                            // An object row is an anonymous record, which `unnest` cannot expand.
12869                            if let IrPathResult::Object { alias, .. } = &ps.result {
12870                                let id = IrExpr::ColumnRef {
12871                                    alias: alias.clone(),
12872                                    column: "id".to_string(),
12873                                    pg_type: "uuid".to_string(),
12874                                };
12875                                ps.result = IrPathResult::Scalar(id, None);
12876                            }
12877                            let aggregate = IrExpr::FunctionCall(IrFunctionCall {
12878                                return_pg_type: None,
12879                                schema: None,
12880                                name: sql_name.clone(),
12881                                args: vec![IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
12882                                    ps,
12883                                ))))],
12884                                sql_template: Some(format!(
12885                                    "(SELECT {sql_name}(\"_s\".\"v\") FROM unnest($1) AS \"_s\"(\"v\"))"
12886                                )),
12887                            });
12888                            return Ok(aggregate_over_nothing(&f.name, aggregate));
12889                        }
12890                        // `max(items.created_at)` inside a FILTER: the
12891                        // aggregate takes the whole set the path names, so it
12892                        // belongs inside a subquery over that set — a bare
12893                        // `max(...)` here is an aggregate in a WHERE clause,
12894                        // which Postgres rejects outright.
12895                        if let Expr::Path(p) = arg
12896                            && !p.partial
12897                            && p.steps.len() > 1
12898                            && let Some(root) = self.find_path_root_in_expr(arg)
12899                        {
12900                            let synthetic = ast::SelectStmt {
12901                                result: Expr::FunctionCall(f.clone()),
12902                                filter: None,
12903                                order_by: vec![],
12904                                offset: None,
12905                                limit: None,
12906                                lock: None,
12907                            };
12908                            let ps = self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
12909                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
12910                        }
12911                        // `sum(.applied_promotions.amount)` — the aggregate
12912                        // takes the set the walk lands on, so the walk becomes
12913                        // the row source of a correlated subquery the aggregate
12914                        // sits inside. Compiled as an expression the walk stands
12915                        // for the array of its elements, leaving the aggregate
12916                        // with an array argument and no overload to match.
12917                        // A backlink is as many-valued: `count(.<revisions)`.
12918                        // So is a computed selecting a set
12919                        // (`Computed[MultiLink[Member], '.staff']`) — one step,
12920                        // but the junction-only count below cannot see through
12921                        // it to a junction table, so it walks the path the
12922                        // computed splices into instead.
12923                        if let Expr::Path(p) = arg
12924                            && p.partial
12925                            && (p.steps.len() > 1 && self.path_crosses_multi(td, &p.steps)
12926                                || matches!(p.steps.first(), Some(ast::PathStep::Backlink(_)))
12927                                || matches!(p.steps.as_slice(), [ast::PathStep::Name(name)]
12928                                    if Self::resolve_multilink(td, name).is_none()
12929                                        && self.path_crosses_multi(td, &p.steps)))
12930                        {
12931                            let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
12932                            steps.extend(p.steps.iter().cloned());
12933                            let rooted = ast::Path { steps, partial: false };
12934                            let mut call = f.clone();
12935                            call.args[0] = Expr::Path(rooted);
12936                            let synthetic = ast::SelectStmt {
12937                                result: Expr::FunctionCall(call),
12938                                filter: None,
12939                                order_by: vec![],
12940                                offset: None,
12941                                limit: None,
12942                                lock: None,
12943                            };
12944                            let root = format!("{}::{}", td.module, td.name);
12945                            let mut ps =
12946                                self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
12947                            Self::correlate_path_select(&mut ps, alias);
12948                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
12949                        }
12950                        if let Expr::Path(p) = arg
12951                            && p.partial
12952                            && p.steps.len() == 1
12953                            && let ast::PathStep::Name(ml_name) = &p.steps[0]
12954                            && Self::resolve_multilink(td, ml_name).is_some()
12955                        {
12956                            use crate::stdlib::{ImplStrategy, lookup};
12957                            let ns = f.module.as_deref().unwrap_or("std");
12958                            let overloads = lookup(ns, &f.name);
12959                            let best = overloads
12960                                .iter()
12961                                .find(|d| d.params.len() == 1)
12962                                .or_else(|| overloads.first());
12963                            if let Some(ImplStrategy::SqlBuiltin(sql_name)) = best.map(|d| &d.impl_strategy) {
12964                                let fn_name = sql_name.to_string();
12965                                let inner = self.multilink_correlation_select(ml_name, td, alias)?;
12966                                return Ok(IrExpr::AggOverQuery {
12967                                    fn_name,
12968                                    inner: Box::new(inner),
12969                                });
12970                            }
12971                        }
12972                    } else {
12973                        // `array_agg(a.sessions.id)` with no type in scope: the
12974                        // aggregate takes the whole set the path names, so it
12975                        // belongs in a subquery over that traversal. Compiled as
12976                        // an expression instead, a multi-valued path stands for
12977                        // the array of its elements, and aggregating *that*
12978                        // nests it one level deep — the same reason the
12979                        // schema-bound branch above routes this way.
12980                        if let Expr::Path(p) = arg
12981                            && !p.partial
12982                            && p.steps.len() > 1
12983                            && let Some(root) = self.find_path_root_in_expr(arg)
12984                        {
12985                            let synthetic = ast::SelectStmt {
12986                                result: Expr::FunctionCall(f.clone()),
12987                                filter: None,
12988                                order_by: vec![],
12989                                offset: None,
12990                                limit: None,
12991                                lock: None,
12992                            };
12993                            let ps = self.compile_expr_as_path_select(&synthetic, &synthetic.result, &root, false)?;
12994                            return Ok(aggregate_over_nothing(&f.name, IrExpr::PathSubquery(Box::new(ps))));
12995                        }
12996                        // `count((delete AuthLink filter .expired))` — the rows
12997                        // a mutation touched are countable like any other set.
12998                        // The mutation becomes its own data-modifying CTE, which
12999                        // Postgres runs regardless, and the aggregate reads it.
13000                        if let Expr::SubQuery(stmt) = arg
13001                            && matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_))
13002                        {
13003                            use crate::stdlib::{ImplStrategy, lookup};
13004                            let ns = f.module.as_deref().unwrap_or("std");
13005                            let overloads = lookup(ns, &f.name);
13006                            let best = overloads
13007                                .iter()
13008                                .find(|d| d.params.len() == 1)
13009                                .or_else(|| overloads.first())
13010                                .cloned();
13011                            if let Some(d) = best
13012                                && let ImplStrategy::SqlBuiltin(sql_name) = &d.impl_strategy
13013                            {
13014                                let fn_name = sql_name.to_string();
13015                                let (cte_name, type_name) = self.hoist_dml_as_cte(stmt.as_ref())?;
13016                                let td = self.resolve_type(&type_name)?;
13017                                let source = IrSource {
13018                                    poly: None,
13019                                    type_name: format!("{}::{}", td.module, td.name),
13020                                    table: format!("@cte:{cte_name}"),
13021                                    alias: self.fresh_alias(),
13022                                };
13023                                let inner = IrSelect::schema_bound(source, Self::pk_returning(td), None);
13024                                return Ok(IrExpr::AggOverQuery {
13025                                    fn_name,
13026                                    inner: Box::new(inner),
13027                                });
13028                            }
13029                        }
13030                        let inner_sel: Option<ast::SelectStmt> = match arg {
13031                            Expr::Path(p) if !p.partial => {
13032                                // Resolve as a schema type if it matches a known type (not enum).
13033                                let qname = p
13034                                    .steps
13035                                    .iter()
13036                                    .filter_map(|s| {
13037                                        if let ast::PathStep::Name(n) = s {
13038                                            Some(n.as_str())
13039                                        } else {
13040                                            None
13041                                        }
13042                                    })
13043                                    .collect::<Vec<_>>()
13044                                    .join("::");
13045                                let is_schema_type = self
13046                                    .schema
13047                                    .types
13048                                    .iter()
13049                                    .any(|t| format!("{}::{}", t.module, t.name) == qname || t.name == qname);
13050                                if is_schema_type {
13051                                    Some(ast::SelectStmt {
13052                                        result: arg.clone(),
13053                                        filter: None,
13054                                        order_by: vec![],
13055                                        offset: None,
13056                                        limit: None,
13057                                        lock: None,
13058                                    })
13059                                } else {
13060                                    None
13061                                }
13062                            }
13063                            Expr::SubQuery(stmt) => {
13064                                if let ast::Stmt::Select(inner) = stmt.as_ref() {
13065                                    Some(inner.clone())
13066                                } else {
13067                                    None
13068                                }
13069                            }
13070                            _ => None,
13071                        };
13072                        if let Some(sel) = inner_sel {
13073                            use crate::stdlib::{ImplStrategy, lookup};
13074                            let ns = f.module.as_deref().unwrap_or("std");
13075                            let overloads = lookup(ns, &f.name);
13076                            let best = overloads
13077                                .iter()
13078                                .find(|d| d.params.len() == 1)
13079                                .or_else(|| overloads.first());
13080                            // `array_agg((select Type.prop))` — the sub-select
13081                            // walks to a scalar, which has no type name to be a
13082                            // schema select's subject. It is the same thing as
13083                            // `array_agg(Type.prop)` with the select's own
13084                            // modifiers on the walk, which is what
13085                            // `compile_expr_as_path_select` already builds.
13086                            // A `distinct` inside the sub-select means the
13087                            // same as one on the argument, and that is where the
13088                            // aggregate can act on it.
13089                            let (inner_result, inner_distinct) = match &sel.result {
13090                                Expr::UnaryOp(u) if matches!(u.op, ast::UnaryOpKind::Distinct) => (&u.operand, true),
13091                                other => (other, false),
13092                            };
13093                            if let Expr::Path(inner_path) = inner_result
13094                                && !inner_path.partial
13095                                && inner_path.steps.len() > 1
13096                                && let Some(ast::PathStep::Name(root)) = inner_path.steps.first()
13097                                && let Ok(root_td) = self.resolve_path_root(root)
13098                                && self
13099                                    .walk_path_types(root_td, &inner_path.steps[1..], MAX_COMPUTED_SPLICES)
13100                                    .1
13101                                    .is_none()
13102                            {
13103                                let root = root.clone();
13104                                let mut inner_call = f.clone();
13105                                inner_call.args = vec![if inner_distinct {
13106                                    Expr::UnaryOp(Box::new(ast::UnaryOp {
13107                                        op: ast::UnaryOpKind::Distinct,
13108                                        operand: inner_result.clone(),
13109                                    }))
13110                                } else {
13111                                    inner_result.clone()
13112                                }];
13113                                let call = ast::SelectStmt {
13114                                    result: Expr::FunctionCall(inner_call),
13115                                    ..sel.clone()
13116                                };
13117                                let ps = self.compile_expr_as_path_select(&call, &call.result, &root, false)?;
13118                                return Ok(IrExpr::PathSubquery(Box::new(ps)));
13119                            }
13120                            if let Some(d) = best
13121                                && let ImplStrategy::SqlBuiltin(sql_name) = &d.impl_strategy
13122                            {
13123                                let fn_name = sql_name.to_string();
13124                                let inner_ir = self.compile_select(&sel, &sel.result, false)?;
13125                                // `array_agg((select brands { * } filter …))` —
13126                                // an array of the objects, rows and shape
13127                                // whole; `array_agg(*)` is not an aggregate
13128                                // Postgres has.
13129                                if fn_name == "array_agg"
13130                                    && matches!(inner_ir.rows.as_slice(), [IrRowSource::Bound { .. }])
13131                                {
13132                                    return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::ObjectSelect(
13133                                        Box::new(inner_ir),
13134                                    ))));
13135                                }
13136                                return Ok(IrExpr::AggOverQuery {
13137                                    fn_name,
13138                                    inner: Box::new(inner_ir),
13139                                });
13140                            }
13141                        }
13142                    }
13143                }
13144
13145                // A set-valued argument → AggOverSet. Schema-bound as much as
13146                // free: an aggregate's argument is a set wherever it is written,
13147                // and each operand is compiled with `ctx` so `.started_at` and
13148                // the like still resolve. The set/shape arm below used to reject
13149                // this outright once a schema was in scope, so `set { x :=
13150                // min({cap, .y}) }` failed where the same call at the top level
13151                // compiled.
13152                if let Some(operands) = f.args.iter().find_map(Self::set_operands) {
13153                    use crate::stdlib::{ImplStrategy, lookup};
13154                    let ns = f.module.as_deref().unwrap_or("std");
13155                    let overloads = lookup(ns, &f.name);
13156                    let best = overloads
13157                        .iter()
13158                        .find(|d| d.params.len() == f.args.len())
13159                        .or_else(|| overloads.first());
13160                    let (schema, fn_name) = match best.map(|d| &d.impl_strategy) {
13161                        Some(ImplStrategy::SqlBuiltin(sql_name)) => (None, sql_name.to_string()),
13162                        Some(_) => {
13163                            return Err(self.type_err(&format!(
13164                                "function '{}::{}' cannot be called with a set literal in this context",
13165                                ns, f.name
13166                            )));
13167                        }
13168                        None => return Err(self.type_err(&format!("function '{}::{}' does not exist", ns, f.name))),
13169                    };
13170                    let elems = operands
13171                        .iter()
13172                        .map(|e| self.compile_expr_ctx(e, ctx))
13173                        .collect::<Result<Vec<_>, _>>()?;
13174                    return Ok(IrExpr::AggOverSet { fn_name, schema, elems });
13175                }
13176
13177                // `any(...)`/`all(...)` say outright that the argument is a
13178                // set, so the comparison inside must not also be warned about.
13179                // The argument is compiled before `resolve_fn_call` ever sees
13180                // which function this is, which is why the guard goes here.
13181                // `any(.grants.account = a and not .grants.deleted)` — every
13182                // `.grants` in it is the same grant, so the condition holds
13183                // per grant; compiled term by term, each walked the link on
13184                // its own.
13185                if let Some((td, _)) = ctx
13186                    && f.module.as_deref().unwrap_or("std") == "std"
13187                    && matches!(f.name.as_str(), "any" | "all")
13188                    && f.kwargs.is_empty()
13189                    && let [condition] = f.args.as_slice()
13190                    && let Some(per_element) = per_element_of_one_multilink(condition, td)
13191                {
13192                    let (link, element_condition) = per_element;
13193                    let condition = if f.name == "all" {
13194                        Expr::UnaryOp(Box::new(ast::UnaryOp {
13195                            op: ast::UnaryOpKind::Not,
13196                            operand: element_condition,
13197                        }))
13198                    } else {
13199                        element_condition
13200                    };
13201                    let exists = Expr::UnaryOp(Box::new(ast::UnaryOp {
13202                        op: ast::UnaryOpKind::Exists,
13203                        operand: Expr::SubQuery(Box::new(Stmt::Select(ast::SelectStmt {
13204                            result: Expr::Path(ast::Path {
13205                                steps: link,
13206                                partial: true,
13207                            }),
13208                            filter: Some(condition),
13209                            order_by: vec![],
13210                            offset: None,
13211                            limit: None,
13212                            lock: None,
13213                        }))),
13214                    }));
13215                    let exists = if f.name == "all" {
13216                        Expr::UnaryOp(Box::new(ast::UnaryOp {
13217                            op: ast::UnaryOpKind::Not,
13218                            operand: exists,
13219                        }))
13220                    } else {
13221                        exists
13222                    };
13223                    return self.compile_expr_ctx(&exists, ctx);
13224                }
13225                // `any(.<company.posts is Post)` — one answer per object the
13226                // path reaches, gathered as an array, so aggregate its elements.
13227                if f.module.as_deref().unwrap_or("std") == "std"
13228                    && matches!(f.name.as_str(), "any" | "all")
13229                    && f.kwargs.is_empty()
13230                    && let [arg @ Expr::TypeIs { .. }] = f.args.as_slice()
13231                    && let answers @ IrExpr::ArrayFromSelect(_) = self.compile_expr_ctx(arg, ctx)?
13232                {
13233                    let (aggregate, over_nothing) = if f.name == "all" {
13234                        ("bool_and", "true")
13235                    } else {
13236                        ("bool_or", "false")
13237                    };
13238                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
13239                        return_pg_type: None,
13240                        schema: None,
13241                        name: aggregate.to_string(),
13242                        args: vec![answers],
13243                        sql_template: Some(format!(
13244                            "(SELECT coalesce({aggregate}(\"_unnested\".\"v\"), {over_nothing}) FROM unnest($1) AS \"_unnested\"(\"v\"))"
13245                        )),
13246                    }));
13247                }
13248                // `all(array_unpack($tags) in .tags.label)` — the comparison is
13249                // made once per element, then aggregated. Inlined as `unnest()`
13250                // the array is a set-returning call, which Postgres refuses in a
13251                // WHERE; over no elements `all` is true and `any` false.
13252                if f.module.as_deref().unwrap_or("std") == "std"
13253                    && matches!(f.name.as_str(), "any" | "all")
13254                    && f.kwargs.is_empty()
13255                    && let [Expr::BinOp(comparison)] = f.args.as_slice()
13256                    && let Expr::FunctionCall(unpack) = &comparison.left
13257                    && unpack.module.as_deref().unwrap_or("std") == "std"
13258                    && unpack.name == "array_unpack"
13259                    && let [array] = unpack.args.as_slice()
13260                {
13261                    let array = self.compile_expr_ctx(array, ctx)?;
13262                    let element = "<unnested element>".to_string();
13263                    self.inline_bindings
13264                        .insert(element.clone(), IrExpr::RawSql("\"_unnested\".\"v\"".to_string()));
13265                    let per_element = self.compile_expr_ctx(
13266                        &Expr::BinOp(Box::new(ast::BinOp {
13267                            left: Expr::Path(ast::Path::absolute(element.clone())),
13268                            op: comparison.op.clone(),
13269                            right: comparison.right.clone(),
13270                        })),
13271                        ctx,
13272                    );
13273                    self.inline_bindings.remove(&element);
13274                    let (aggregate, over_nothing) = if f.name == "all" {
13275                        ("bool_and", "true")
13276                    } else {
13277                        ("bool_or", "false")
13278                    };
13279                    return Ok(IrExpr::FunctionCall(IrFunctionCall {
13280                        return_pg_type: None,
13281                        schema: None,
13282                        name: aggregate.to_string(),
13283                        args: vec![per_element?, array],
13284                        sql_template: Some(format!(
13285                            "(SELECT coalesce({aggregate}($1), {over_nothing}) FROM unnest($2) AS \"_unnested\"(\"v\"))"
13286                        )),
13287                    }));
13288                }
13289                if let Some(args) = self.compile_named_call_args(f, ctx)? {
13290                    return self.resolve_fn_call(f.module.as_deref(), &f.name, args);
13291                }
13292                let is_explicit_set = f.module.as_deref().unwrap_or("std") == "std"
13293                    && matches!(f.name.as_str(), "any" | "all")
13294                    && f.args.len() == 1;
13295                if is_explicit_set {
13296                    self.explicit_set_depth += 1;
13297                }
13298                let args = f
13299                    .args
13300                    .iter()
13301                    .map(|a| self.compile_expr_ctx(a, ctx))
13302                    .collect::<Result<Vec<_>, _>>();
13303                if is_explicit_set {
13304                    self.explicit_set_depth -= 1;
13305                }
13306                self.resolve_fn_call(f.module.as_deref(), &f.name, args?)
13307            }
13308
13309            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Exists => self.compile_exists_ctx(&u.operand, ctx),
13310
13311            // `distinct` in expression position: a set-producing operand
13312            // dedupes its own rows, and a single value — an array, a global, a
13313            // column — is already distinct, so it stands for itself. (A
13314            // statement-level `select distinct …` never reaches here; it is
13315            // unwrapped into the select's own `distinct` flag.)
13316            Expr::UnaryOp(u) if u.op == ast::UnaryOpKind::Distinct => {
13317                let operand = self.compile_expr_ctx(&u.operand, ctx)?;
13318                Ok(match operand {
13319                    IrExpr::PathSubquery(mut ps) => {
13320                        ps.distinct = true;
13321                        IrExpr::PathSubquery(ps)
13322                    }
13323                    IrExpr::ArrayFromSelect(src) => IrExpr::ArrayFromSelect(Box::new(match *src {
13324                        IrArraySource::Select(mut sel) => {
13325                            sel.distinct = true;
13326                            IrArraySource::Select(sel)
13327                        }
13328                        IrArraySource::PathSelect(mut ps) => {
13329                            ps.distinct = true;
13330                            IrArraySource::PathSelect(ps)
13331                        }
13332                        other => other,
13333                    })),
13334                    other => other,
13335                })
13336            }
13337
13338            Expr::UnaryOp(u) => {
13339                let operand = self.compile_expr_ctx(&u.operand, ctx)?;
13340                Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
13341                    op: u.op.clone(),
13342                    operand,
13343                })))
13344            }
13345
13346            Expr::IfElse(ie) => {
13347                let condition = self.compile_expr_ctx(&ie.condition, ctx)?;
13348                let if_ = self.compile_expr_ctx(&ie.if_expr, ctx)?;
13349                let else_ = self.compile_expr_ctx(&ie.else_expr, ctx)?;
13350                Ok(IrExpr::IfElse(Box::new(IrIfElse { condition, if_, else_ })))
13351            }
13352
13353            Expr::Array(elems) => {
13354                // Each element is one value: `[<uuid>$p, snippet.id]` is two
13355                // uuids, not a uuid beside an array of them — PostgreSQL
13356                // refuses to mix the two in one literal.
13357                let items = elems
13358                    .iter()
13359                    .map(|e| self.compile_expr_ctx(e, ctx).map(set_walk_as_scalar))
13360                    .collect::<Result<Vec<_>, _>>()?;
13361                Ok(IrExpr::Array(items))
13362            }
13363
13364            Expr::NamedTuple(fields) => {
13365                let ir = fields
13366                    .iter()
13367                    .map(|(name, e)| Ok((name.clone(), self.compile_expr_ctx(e, ctx)?)))
13368                    .collect::<Result<Vec<_>, PyQLError>>()?;
13369                Ok(IrExpr::NamedTuple {
13370                    fields: ir,
13371                    is_free_object: false,
13372                })
13373            }
13374
13375            Expr::Tuple(elems) => {
13376                let ir = elems
13377                    .iter()
13378                    .map(|e| self.compile_expr_ctx(e, ctx))
13379                    .collect::<Result<Vec<_>, PyQLError>>()?;
13380                Ok(IrExpr::Tuple(ir))
13381            }
13382
13383            Expr::FieldAccess { expr: inner, field } => {
13384                // `(select …).provider[is Individual].staff` arrives here, not
13385                // at the `PathStepOn` arm: the trailing `.staff` is outermost.
13386                if let Some(ir) = self.try_compile_walk_off_subquery(expr, ctx)? {
13387                    return Ok(ir);
13388                }
13389                if let Expr::NamedTuple(fields) = inner.as_ref() {
13390                    let (_, val) = fields.iter().find(|(k, _)| k == field).ok_or_else(|| {
13391                        self.type_err(&format!("{field} is not a member of {}", named_tuple_type_str(fields)))
13392                    })?;
13393                    return self.compile_expr_ctx(val, ctx);
13394                }
13395                if let Some(ps) = self.compile_shape_field_select(expr, ctx)? {
13396                    return Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(
13397                        ps,
13398                    )))));
13399                }
13400                // `(select .emails filter .primary limit 1).address` — the
13401                // field chain is spliced onto the sub-select's own path so
13402                // the subquery projects that column, rather than being read
13403                // as jsonb extraction off an object id.
13404                let (base, fields) = Self::peel_field_access_chain(expr);
13405                if let Expr::SubQuery(stmt) = base {
13406                    let stmt = stmt.as_ref().clone();
13407                    return self.compile_subquery_expr(&stmt, &fields, ctx, &[]);
13408                }
13409                // `account::owner(.id).name` — an object-returning function
13410                // projected to one of its columns.
13411                if let Expr::FunctionCall(fc) = base {
13412                    let fc = fc.clone();
13413                    if let Some(ir) = self.try_compile_fn_scalar_subquery(&fc, &fields, None, ctx)? {
13414                        return Ok(ir);
13415                    }
13416                }
13417                let ir = self.compile_expr_ctx(inner, ctx)?;
13418                Ok(Self::project_free_object_field(ir, field))
13419            }
13420
13421            Expr::TupleIndex { expr: inner, index } => {
13422                match inner.as_ref() {
13423                    Expr::Tuple(elems) => {
13424                        let elem = elems.get(*index).ok_or_else(|| {
13425                            self.type_err(&format!(
13426                                "{index} is not a member of {}",
13427                                positional_tuple_type_str(elems)
13428                            ))
13429                        })?;
13430                        self.compile_expr_ctx(elem, ctx)
13431                    }
13432                    Expr::NamedTuple(fields) => {
13433                        let (_, val) = fields.get(*index).ok_or_else(|| {
13434                            self.type_err(&format!("{index} is not a member of {}", named_tuple_type_str(fields)))
13435                        })?;
13436                        self.compile_expr_ctx(val, ctx)
13437                    }
13438                    // Not a literal to constant-fold — emit a generic runtime
13439                    // jsonb positional access (`$param.1`, `(<tuple<...>>expr).1`, …).
13440                    // When the source is a cast to a statically-known tuple type,
13441                    // bounds-check the index against its arity at compile time
13442                    // (e.g. `2 is not a member of tuple<std::int64, std::str>`).
13443                    _ => {
13444                        if let Expr::TypeCast(tc) = inner.as_ref()
13445                            && let Some(shape) = self.resolve_tuple_cast_shape(&tc.ty)
13446                            && *index >= shape.members.len()
13447                        {
13448                            return Err(self.type_err(&format!(
13449                                "{index} is not a member of {}",
13450                                self.type_expr_to_display_str(&tc.ty)
13451                            )));
13452                        }
13453                        let ir = self.compile_expr_ctx(inner, ctx)?;
13454                        Ok(IrExpr::JsonbIndex {
13455                            expr: Box::new(ir),
13456                            index: *index,
13457                        })
13458                    }
13459                }
13460            }
13461
13462            // `detached` bypasses the implicit root-matches-td correlation
13463            // rewrite. Schema-bound: if `inner` contains a type-rooted path,
13464            // compile it as an independent PathSubquery; otherwise (or when
13465            // already free) fall back to compiling `inner` with NO schema
13466            // binding — note this fallback is deliberately `None`, not
13467            // `ctx`, since `detached` means "evaluate independently of the
13468            // enclosing scope" and free context has no rooted path to find
13469            // in the first place (`find_path_root_in_expr` only ever matches
13470            // against a resolvable schema type name).
13471            Expr::Detached(inner) => {
13472                if ctx.is_some()
13473                    && let Some(root) = self.find_path_root_in_expr(inner)
13474                {
13475                    let synthetic = ast::SelectStmt {
13476                        result: (**inner).clone(),
13477                        filter: None,
13478                        order_by: vec![],
13479                        offset: None,
13480                        limit: None,
13481                        lock: None,
13482                    };
13483                    let ps = self.compile_expr_as_path_select(&synthetic, inner, &root, false)?;
13484                    return Ok(IrExpr::PathSubquery(Box::new(ps)));
13485                }
13486                self.compile_expr_ctx(inner, None)
13487            }
13488
13489            // Free context tolerates an empty set (-> Null) or a singleton
13490            // set (-> its one element) as a convenience; a schema-bound
13491            // expression position hard-errors on ANY set literal instead
13492            // (see the generic Shape|Set arm below) — a plausibly
13493            // intentional semantic difference, preserved exactly as-is.
13494            Expr::Set(elems) if ctx.is_none() && elems.is_empty() => Ok(IrExpr::Null),
13495
13496            Expr::Set(elems) if ctx.is_none() => {
13497                let compiled: Result<Vec<_>, _> = elems.iter().map(|e| self.compile_expr_ctx(e, ctx)).collect();
13498                let mut compiled = compiled?;
13499                if compiled.len() == 1 {
13500                    Ok(compiled.remove(0))
13501                } else {
13502                    Err(self.type_err("multi-element set literal is not supported in free SELECT context"))
13503                }
13504            }
13505
13506            // A free object literal (`{ foo := 'bar' }`, no subject type) is
13507            // valid anywhere an expression is, not just as a whole SELECT's
13508            // result — e.g. nested inside a computed shape element. Compiled
13509            // the same way `compile_free_select` treats it at the top level
13510            // (each field compiled independently, ctx propagated so a
13511            // schema-bound nested free object can still reference `.name`
13512            // etc.), just wrapped as `IrExpr::NamedTuple` (jsonb) instead of
13513            // a whole result row, since here it's a value, not a row source.
13514            // is_free_object: true — this came from curly-brace shape syntax,
13515            // not a paren tuple literal, so the value-shape-tag tree
13516            // (pylon/query.py's shape_value_tags) can tell the frontend to
13517            // render it as an expandable "Object {...}", not a `(...)`
13518            // tuple literal (see ShapeNode::NamedTuple's own doc comment).
13519            Expr::Shape(s) if s.expr.is_none() => {
13520                let fields = s
13521                    .elements
13522                    .iter()
13523                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
13524                        let name = path_leaf(&el.path)?.to_string();
13525                        let expr = el.compexpr.as_ref().ok_or_else(|| {
13526                            self.type_err("free object field must have a value expression (':= expr')")
13527                        })?;
13528                        let compiled = match expr {
13529                            Expr::SubQuery(stmt)
13530                                if matches!(stmt.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) =>
13531                            {
13532                                self.dml_as_value(stmt.as_ref())?
13533                            }
13534                            other => match self.free_object_link_field(other)? {
13535                                Some(object) => object,
13536                                None => self.compile_expr_ctx(other, ctx)?,
13537                            },
13538                        };
13539                        Ok((name, compiled))
13540                    })
13541                    .collect::<Result<Vec<_>, _>>()?;
13542                Ok(IrExpr::NamedTuple {
13543                    fields,
13544                    is_free_object: true,
13545                })
13546            }
13547
13548            // A shape applied to a WITH-bound free object (`test := test {
13549            // test2 }` where `test` isn't a schema object, just a free
13550            // binding) projects the named fields out of it — each element
13551            // either reads the underlying field (by name, via the same
13552            // per-field CTE column `.field` access uses) or, with a `:=`
13553            // override, compiles a brand new value in the current context,
13554            // exactly like a fresh free-object-literal field would.
13555            Expr::Shape(s) if matches!(&s.expr, Some(inner) if self.is_free_cte_ref(inner)) => {
13556                let Some(Expr::Path(root_path)) = &s.expr else {
13557                    unreachable!()
13558                };
13559                let ast::PathStep::Name(root) = &root_path.steps[0] else {
13560                    unreachable!()
13561                };
13562                let fields = s
13563                    .elements
13564                    .iter()
13565                    .map(|el| -> Result<(String, IrExpr), PyQLError> {
13566                        let name = path_leaf(&el.path)?.to_string();
13567                        let expr = match &el.compexpr {
13568                            Some(over) => self.compile_expr_ctx(over, ctx)?,
13569                            None => match self.resolve_cte_field_chain(root, &[name.as_str()]) {
13570                                Some(result) => result?,
13571                                None => {
13572                                    return Err(self.type_err(&format!("free object '{root}' has no field '{name}'")));
13573                                }
13574                            },
13575                        };
13576                        Ok((name, expr))
13577                    })
13578                    .collect::<Result<Vec<_>, _>>()?;
13579                Ok(IrExpr::NamedTuple {
13580                    fields,
13581                    is_free_object: true,
13582                })
13583            }
13584
13585            // `(.<prices[is Listing] union .<sale_prices[is Listing]) { id }`
13586            // — each operand hangs off the enclosing row, so none can be
13587            // hoisted into a CTE the way a standalone union's operands are.
13588            Expr::Shape(sh)
13589                if ctx.is_some()
13590                    && matches!(sh.expr.as_ref(), Some(Expr::Union(_, _)))
13591                    && Self::union_of_relative_paths(sh.expr.as_ref().expect("checked")).is_some() =>
13592            {
13593                let (td, alias) = ctx.expect("checked by the guard");
13594                let operands =
13595                    Self::union_of_relative_paths(sh.expr.as_ref().expect("checked")).expect("checked by the guard");
13596                let elements = sh.elements.clone();
13597                let multi = self.relative_paths_reach_many(td, &operands);
13598                let mut branches = Vec::with_capacity(operands.len());
13599                for path in operands {
13600                    let mut steps = vec![ast::PathStep::Name(format!("{}::{}", td.module, td.name))];
13601                    steps.extend(path.steps.iter().cloned());
13602                    let rooted = ast::Path { steps, partial: false };
13603                    let synthetic = ast::SelectStmt {
13604                        result: Expr::Path(rooted.clone()),
13605                        filter: None,
13606                        order_by: vec![],
13607                        offset: None,
13608                        limit: None,
13609                        lock: None,
13610                    };
13611                    let mut ps = self.compile_path_select(&synthetic, &rooted, &elements, false)?;
13612                    Self::correlate_path_select(&mut ps, alias);
13613                    branches.push(ps);
13614                }
13615                Ok(IrExpr::ObjectPathUnion {
13616                    branches,
13617                    limit: None,
13618                    multi,
13619                })
13620            }
13621
13622            // `([is Conditional].configs ?? [is Loop].configs) { … }` — the
13623            // same correlated walks a union of them gives, except each operand
13624            // after the first only stands in when the ones before it are empty.
13625            Expr::Shape(sh)
13626                if ctx.is_some() && sh.expr.as_ref().and_then(Self::coalesce_of_relative_paths).is_some() =>
13627            {
13628                let (td, alias) = ctx.expect("checked by the guard");
13629                let operands =
13630                    Self::coalesce_of_relative_paths(sh.expr.as_ref().expect("checked")).expect("checked by the guard");
13631                let multi = self.relative_paths_reach_many(td, &operands);
13632                let branches = self.coalesce_path_branches(td, alias, &operands, &sh.elements)?;
13633                Ok(IrExpr::ObjectPathUnion {
13634                    branches,
13635                    limit: None,
13636                    multi,
13637                })
13638            }
13639
13640            Expr::Shape(sh) if matches!(sh.expr.as_ref(), Some(Expr::SubQuery(_))) => {
13641                let sh = sh.clone();
13642                match self.shape_over_subquery(&sh)? {
13643                    Some(ir) => Ok(ir),
13644                    None => Err(PyQLError::Type(PyQLTypeError {
13645                        message: "shapes and set literals are not valid in expression context".into(),
13646                        position: Position { line: 0, col: 0 },
13647                    })),
13648                }
13649            }
13650
13651            Expr::Shape(sh) if Self::shape_over_subquery_projection(sh).is_some() => {
13652                let (stmt, fields) = Self::shape_over_subquery_projection(sh).expect("checked by the guard");
13653                let stmt = stmt.clone();
13654                let elements = sh.elements.clone();
13655                self.compile_subquery_expr(&stmt, &fields, ctx, &elements)
13656            }
13657
13658            // `account := account if cond else {}` — the empty set is a legal
13659            // expression and means "no value", which is what the
13660            // assignment paths already spell `IrExpr::Null`. Only a *non-empty*
13661            // set literal has no expression-position meaning.
13662            Expr::Set(items) if items.is_empty() => Ok(IrExpr::Null),
13663
13664            Expr::Shape(sh)
13665                if matches!(sh.expr.as_ref(), Some(Expr::Path(p))
13666                    if !p.partial
13667                        && matches!(p.steps.first(), Some(ast::PathStep::Name(n))
13668                            if self.cte_object_type(n).is_some() || self.for_var_types.contains_key(n))) =>
13669            {
13670                let expr = expr.clone();
13671                match self.shape_over_binding(&expr)? {
13672                    Some(ir) => Ok(ir),
13673                    None => Err(PyQLError::Type(PyQLTypeError {
13674                        message: "shapes and set literals are not valid in expression context".into(),
13675                        position: Position { line: 0, col: 0 },
13676                    })),
13677                }
13678            }
13679
13680            // A bare shape or set literal is never valid in expression
13681            // position, in either context — preserved exactly as the
13682            // schema-bound side always enforced (the free side's more
13683            // permissive empty/singleton-set handling above is the one
13684            // deliberate exception, handled before this arm).
13685            Expr::Shape(_) | Expr::Set(_) => Err(PyQLError::Type(PyQLTypeError {
13686                message: "shapes and set literals are not valid in expression context".into(),
13687                position: Position { line: 0, col: 0 },
13688            })),
13689
13690            // A `select` over a path compiles to a correlated subquery; DML
13691            // and everything else still has no expression-position meaning.
13692            // Union/Except previously fell through free's generic "not valid
13693            // in free SELECT context" catch-all — these explicit,
13694            // purpose-written messages (already used schema-bound) apply
13695            // equally well with no schema in scope, so they're unconditional
13696            // here rather than ctx-gated.
13697            Expr::SubQuery(stmt) => {
13698                let stmt = stmt.as_ref().clone();
13699                self.compile_subquery_expr(&stmt, &[], ctx, &[])
13700            }
13701
13702            Expr::Union(_, _) => Err(PyQLError::Type(PyQLTypeError {
13703                message: "union is not valid in expression context".into(),
13704                position: Position { line: 0, col: 0 },
13705            })),
13706
13707            // `array_unpack(a) intersect array_unpack(b)` — a set operation
13708            // between two set-valued expressions is itself a set, which in
13709            // expression position is the array of what it yields.
13710            Expr::Except(left, right) | Expr::Intersect(left, right) => {
13711                let op = if matches!(expr, Expr::Intersect(_, _)) {
13712                    super::SetOpKind::Intersect
13713                } else {
13714                    super::SetOpKind::Except
13715                };
13716                let left = self.compile_expr_ctx(left, ctx)?;
13717                let right = self.compile_expr_ctx(right, ctx)?;
13718                Ok(IrExpr::SetOp {
13719                    op,
13720                    left: Box::new(left),
13721                    right: Box::new(right),
13722                    mode: super::SetOpMode::Array,
13723                })
13724            }
13725
13726            // TypeIs (`expr is Type`) is schema-exclusive — compile_type_is
13727            // deeply needs td/alias throughout (interface checks, __type__
13728            // column, alias-scoped bool expr). No free-context equivalent
13729            // existed before the merge (fell to the generic catch-all); this
13730            // is a new, clearer explicit error for that case.
13731            Expr::TypeIs { expr, ty } => match ctx {
13732                Some((td, alias)) => self.compile_type_is(expr, ty, td, alias),
13733                None => match expr.as_ref() {
13734                    Expr::Path(p) => self.compile_path_type_is(p, ty, None),
13735                    _ => Err(self.type_err("'is' type check is not valid in free SELECT context")),
13736                },
13737            },
13738        }
13739    }
13740
13741    fn compile_expr(&mut self, expr: &Expr, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
13742        self.compile_expr_ctx(expr, Some((td, alias)))
13743    }
13744
13745    fn compile_free_expr(&mut self, expr: &Expr) -> Result<IrExpr, PyQLError> {
13746        self.compile_expr_ctx(expr, None)
13747    }
13748
13749    /// `path is T`: whether the objects `path` reaches are of `T` or one of
13750    /// its subtypes, read off their own `__type__`.
13751    fn compile_path_type_is(
13752        &mut self,
13753        path: &ast::Path,
13754        ty: &ast::TypeExpr,
13755        ctx: Option<(&TypeDescriptor, &str)>,
13756    ) -> Result<IrExpr, PyQLError> {
13757        let (ty_module, ty_name) = ty
13758            .as_named()
13759            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
13760        let check_qname = match (ty_module, ctx) {
13761            (Some(module), _) => format!("{module}::{ty_name}"),
13762            (None, Some((td, _))) => format!("{}::{}", td.module, ty_name),
13763            (None, None) => ty_name.to_string(),
13764        };
13765        let check_td = self.resolve_type(&check_qname)?;
13766        let check_qname = format!("{}::{}", check_td.module, check_td.name);
13767        let mut type_path = path.clone();
13768        type_path.steps.push(ast::PathStep::Name("__type__".to_string()));
13769        let own_type = self.compile_expr_ctx(&Expr::Path(type_path), ctx)?;
13770        let implementors: Vec<String> = self
13771            .find_poly_implementors(&check_qname)
13772            .into_iter()
13773            .map(|implementor| implementor.type_name)
13774            .collect();
13775        let is_one_of = |own_type: IrExpr| {
13776            implementors
13777                .iter()
13778                .map(|implementor| {
13779                    IrExpr::BinOp(Box::new(IrBinOp {
13780                        left: own_type.clone(),
13781                        op: ast::BinOpKind::Eq,
13782                        right: IrExpr::Literal(IrLiteral::Str(implementor.clone())),
13783                    }))
13784                })
13785                .reduce(|left, right| {
13786                    IrExpr::BinOp(Box::new(IrBinOp {
13787                        left,
13788                        op: ast::BinOpKind::Or,
13789                        right,
13790                    }))
13791                })
13792                .unwrap_or(IrExpr::Literal(IrLiteral::Bool(false)))
13793        };
13794        // A path that reaches many objects yields one answer per object.
13795        Ok(match own_type {
13796            IrExpr::ArrayFromSelect(source) => match *source {
13797                IrArraySource::PathSelect(mut path_select) => {
13798                    let IrPathResult::Scalar(element, cast) = path_select.result else {
13799                        return Err(self.type_err("'__type__' is read as a value"));
13800                    };
13801                    path_select.result = IrPathResult::Scalar(is_one_of(element), cast);
13802                    IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(path_select)))
13803                }
13804                other => is_one_of(IrExpr::ArrayFromSelect(Box::new(other))),
13805            },
13806            single => is_one_of(single),
13807        })
13808    }
13809
13810    fn compile_type_is(
13811        &mut self,
13812        expr: &Expr,
13813        ty: &ast::TypeExpr,
13814        td: &TypeDescriptor,
13815        alias: &str,
13816    ) -> Result<IrExpr, PyQLError> {
13817        let self_qname = format!("{}::{}", td.module, td.name);
13818
13819        // `.listing is T`, `x is T`: the objects the path reaches are tested,
13820        // not the current row — through their own `__type__`.
13821        let reaches_other_objects = match expr {
13822            Expr::Path(p) if p.partial || p.steps.len() > 1 => {
13823                !matches!(p.steps.last(), Some(ast::PathStep::Name(n)) if n == "__type__")
13824            }
13825            Expr::Path(p) => matches!(
13826                p.steps.as_slice(),
13827                [ast::PathStep::Name(n)] if n != &td.name && n != &self_qname && self.resolve_type(n).is_err()
13828                    && self.resolve_name_ref(n, true).is_some()
13829            ),
13830            _ => false,
13831        };
13832        if reaches_other_objects && let Expr::Path(p) = expr {
13833            return self.compile_path_type_is(p, ty, Some((td, alias)));
13834        }
13835
13836        // Determine whether `expr` refers to the current scope or a different type.
13837        // A 1-step absolute path matching the current td → same scope.
13838        let (source_qname, cross_scope) = match expr {
13839            Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
13840                if let ast::PathStep::Name(n) = &p.steps[0] {
13841                    if n == &td.name || n == &self_qname || self.scope_answers_to(n, &self_qname) {
13842                        (self_qname.clone(), false)
13843                    } else {
13844                        // Attempt to resolve as another type.
13845                        match self.resolve_type(n) {
13846                            Ok(other) => (format!("{}::{}", other.module, other.name), true),
13847                            Err(_) => (self_qname.clone(), false),
13848                        }
13849                    }
13850                } else {
13851                    (self_qname.clone(), false)
13852                }
13853            }
13854            _ => (self_qname.clone(), false),
13855        };
13856
13857        let (ty_module, ty_name) = ty
13858            .as_named()
13859            .ok_or_else(|| self.type_err("cannot use IS with a tuple or array type"))?;
13860        let check_module = ty_module.unwrap_or(td.module.as_str());
13861        let check_qname = format!("{}::{}", check_module, ty_name);
13862        self.resolve_type(&check_qname)?;
13863
13864        if !cross_scope {
13865            // Scalar bool using the current alias.
13866            return Ok(self.type_check_bool_expr(&source_qname, &check_qname, td, alias));
13867        }
13868
13869        // Cross-scope: ARRAY(SELECT bool_expr FROM source_table).
13870        // Collect everything needed before calling fresh_alias (which needs &mut self).
13871        let src_alias = self.fresh_alias();
13872        let src_td = self.resolve_type(&source_qname)?;
13873        let source_table = src_td.table.clone();
13874        let (poly_implementors, poly_columns) = if self.is_polymorphic(src_td) {
13875            (
13876                self.find_poly_implementors(&source_qname),
13877                Self::poly_dml_columns(src_td),
13878            )
13879        } else {
13880            (vec![], vec![])
13881        };
13882        let bool_expr = self.type_check_bool_expr(&source_qname, &check_qname, src_td, &src_alias);
13883
13884        let source = IrSource {
13885            poly: None,
13886            type_name: source_qname,
13887            table: source_table,
13888            alias: src_alias,
13889        };
13890
13891        Ok(IrExpr::ArrayFromSelect(Box::new(IrArraySource::RawExpr {
13892            source,
13893            poly_implementors,
13894            poly_columns,
13895            expr: bool_expr,
13896        })))
13897    }
13898
13899    /// Build the boolean `IrExpr` for `source_qname is check_qname` in the current row's scope.
13900    fn type_check_bool_expr(&self, source_qname: &str, check_qname: &str, td: &TypeDescriptor, alias: &str) -> IrExpr {
13901        if check_qname == source_qname || Self::is_or_implements(td, check_qname) {
13902            return IrExpr::Literal(IrLiteral::Bool(true));
13903        }
13904        if !self.is_polymorphic(td) {
13905            return IrExpr::Literal(IrLiteral::Bool(false));
13906        }
13907        // A row is of the checked type when its own type is that type or one
13908        // of its subtypes.
13909        self.find_poly_implementors(check_qname)
13910            .into_iter()
13911            .map(|implementor| {
13912                IrExpr::BinOp(Box::new(IrBinOp {
13913                    left: IrExpr::ColumnRef {
13914                        alias: alias.to_string(),
13915                        column: "__type__".into(),
13916                        pg_type: "text".into(),
13917                    },
13918                    op: crate::parse::ast::BinOpKind::Eq,
13919                    right: IrExpr::Literal(IrLiteral::Str(implementor.type_name)),
13920                }))
13921            })
13922            .reduce(|left, right| {
13923                IrExpr::BinOp(Box::new(IrBinOp {
13924                    left,
13925                    op: crate::parse::ast::BinOpKind::Or,
13926                    right,
13927                }))
13928            })
13929            .unwrap_or(IrExpr::Literal(IrLiteral::Bool(false)))
13930    }
13931
13932    /// Shared lookup for a bare 1-step name: for-loop variable, CTE binding,
13933    /// or function parameter. Used by both `compile_path`'s schema-bound
13934    /// prefix and `compile_free_path`.
13935    ///
13936    /// `allow_fn_param` used to be `false` from `compile_path`, so a parameter
13937    /// resolved only in free context. An object-returning body like
13938    /// `select Item filter .rank = v` compiles its filter against a schema
13939    /// anchor, so it never consulted `fn_params` and rejected the bare `v` as
13940    /// "absolute paths are not valid in expression context" — while the same
13941    /// parameter in a scalar-returning body worked, which is why the gap went
13942    /// unnoticed. A bare one-step name can only be a variable, CTE binding or
13943    /// parameter in either context (a property is always written `.name`), so
13944    /// there is nothing here for a parameter to shadow.
13945    fn resolve_name_ref(&mut self, name: &str, allow_fn_param: bool) -> Option<IrExpr> {
13946        if self.for_vars.contains_key(name) {
13947            return Some(self.for_var_ref(name));
13948        }
13949        // A free-object-bound CTE (`with x := { a := 1 } select ... x ...`),
13950        // referenced bare with no shape to project through, has nothing to
13951        // expose — a free *object* needs an explicit shape to know what to
13952        // return (unlike a tuple/named tuple, it has no "default"
13953        // projection), so this collapses to an empty free object, the same
13954        // as `Expr::Shape`'s `is_free_cte_ref` case for `x { ... }`.
13955        // It also sidesteps a real problem: this CTE has no "v" column
13956        // (only `IrFreeExpr::Scalar` CTEs get one), so falling through to
13957        // the generic `IrExpr::CteRef` below would reference a column that
13958        // doesn't exist.
13959        if let Some(IrFreeExpr::FreeObject(_)) = self.cte_free_items.get(name) {
13960            return Some(IrExpr::NamedTuple {
13961                fields: vec![],
13962                is_free_object: true,
13963            });
13964        }
13965        if let Some(ir) = self.inline_bindings.get(name) {
13966            return Some(ir.clone());
13967        }
13968        if let Some(t) = self.cte_types.get(name) {
13969            // A scalar binding records its pg type here (see `cte_stmt_type`);
13970            // an object binding records a `module::Type` name, which is not a
13971            // pg type and must not be handed to type inference.
13972            let scalar = !t.contains("::");
13973            return Some(IrExpr::CteRef {
13974                name: self.cte_sql_name(name),
13975                scalar,
13976                pg_type: (scalar && !t.is_empty()).then(|| literal_sentinel_to_pg(t).to_string()),
13977            });
13978        }
13979        if allow_fn_param && let Some(pg_type) = self.fn_params.get(name) {
13980            return Some(IrExpr::FnParam {
13981                name: name.to_string(),
13982                pg_type: pg_type.clone(),
13983            });
13984        }
13985        None
13986    }
13987
13988    /// The `(qualified type, alias)` an absolute `root.prop` should resolve
13989    /// against when the innermost select is `detached`.
13990    ///
13991    /// `None` in the ordinary case — the innermost select is not detached, or
13992    /// nothing outside it binds that type, in which case naming the type still
13993    /// means the current row.
13994    fn enclosing_anchor(&self, root: &str) -> Option<(String, String)> {
13995        let innermost = self.anchors.last()?;
13996        if !innermost.detached || !innermost.answers_to(root) {
13997            return None;
13998        }
13999        self.anchors
14000            .iter()
14001            .rev()
14002            .skip(1)
14003            .find(|a| a.answers_to(root))
14004            .map(|a| (a.qualified.clone(), a.alias.clone()))
14005    }
14006
14007    /// Whether `root` names the scope `self_qname` stands for, beyond its own
14008    /// two spellings — an inherited computed names its declaring type, which
14009    /// is the subject there too. See `SelectAnchor::declared_on`.
14010    fn scope_answers_to(&self, root: &str, self_qname: &str) -> bool {
14011        self.anchors
14012            .last()
14013            .is_some_and(|anchor| anchor.qualified == self_qname && anchor.answers_to(root))
14014    }
14015
14016    /// The innermost enclosing select binding `root` as its subject.
14017    fn outer_anchor(&self, root: &str) -> Option<(String, String)> {
14018        self.anchors
14019            .iter()
14020            .rev()
14021            .find(|a| a.answers_to(root))
14022            .map(|a| (a.qualified.clone(), a.alias.clone()))
14023    }
14024
14025    fn compile_path(&mut self, p: &ast::Path, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
14026        if !p.partial {
14027            if p.steps.len() == 1
14028                && let ast::PathStep::Name(n) = &p.steps[0]
14029            {
14030                if let Some(ir) = self.resolve_name_ref(n, true) {
14031                    return Ok(ir);
14032                }
14033                // __type__ without a leading dot still means the current object's type.
14034                if n == "__type__" {
14035                    return Ok(if self.is_polymorphic(td) {
14036                        IrExpr::ColumnRef {
14037                            alias: alias.to_string(),
14038                            column: "__type__".to_string(),
14039                            pg_type: "text".to_string(),
14040                        }
14041                    } else {
14042                        IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
14043                    });
14044                }
14045            }
14046            // Enum member access: `default::Gender.Female`
14047            if p.steps.len() == 2
14048                && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
14049                && self.resolve_enum(type_ref).is_some()
14050            {
14051                return self.compile_enum_access(type_ref, variant);
14052            }
14053            // `root.field1.field2...` where `root` is a WITH-bound free object.
14054            if let Some(resolved) = self.resolve_cte_path(p) {
14055                return resolved;
14056            }
14057            // `root.field1.field2...` where `root` is a WITH-bound *schema
14058            // object* (`with person := (select detached Person filter ...)
14059            // select ... person.company.name ...`). `compile_path_select`
14060            // already treats a CTE-bound root exactly like a real type name
14061            // (it checks `cte_types` before falling back to `resolve_type`,
14062            // via the `@cte:` source-table sentinel — see its own doc
14063            // comment and `compile_expr_as_path_select`'s sibling case for
14064            // `Detached`), so it already implements the *entire* general
14065            // path-traversal feature set here — forward links, multilinks,
14066            // backlinks, junction-backed links, nested tuple field access,
14067            // "did you mean" on a typo'd name — not just a one-property
14068            // special case. Wrapping its result as `IrExpr::PathSubquery`
14069            // (the same vehicle `Detached` uses for a type-rooted path in
14070            // expression position) turns the whole traversal into one
14071            // correlated scalar/id expression.
14072            if p.steps.len() > 1
14073                && let ast::PathStep::Name(root) = &p.steps[0]
14074                && self
14075                    .cte_types
14076                    .get(root.as_str())
14077                    .map(|t| t.contains("::"))
14078                    .unwrap_or(false)
14079            {
14080                let full_path = ast::Path {
14081                    steps: p.steps.clone(),
14082                    partial: false,
14083                };
14084                let synthetic = ast::SelectStmt {
14085                    result: Expr::Path(full_path.clone()),
14086                    filter: None,
14087                    order_by: vec![],
14088                    offset: None,
14089                    limit: None,
14090                    lock: None,
14091                };
14092                let ps = self.compile_path_select(&synthetic, &full_path, &[], false)?;
14093                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14094            }
14095            // `__new__.prop` / `__old__.prop` — the inserted/updated/deleted
14096            // row a trigger handler is compiled against (see
14097            // `compile_trigger_handler`, which populates `special_anchors`
14098            // per the trigger's declared `on` events). Same rewrite-and-
14099            // recurse trick as the `TypeName.prop` case below, just handing
14100            // `compile_path` the anchor's own alias ("NEW"/"OLD") instead
14101            // of `td`'s. Outside trigger-handler compilation (or for the
14102            // anchor the current trigger's events don't legally bind —
14103            // e.g. `__old__` in an Insert-only trigger) `special_anchors`
14104            // is empty/missing that entry, so this falls through to the
14105            // explicit error below rather than the generic "absolute
14106            // paths" message: `__old__`/`__new__` cannot be used in
14107            // this expression.
14108            if p.steps.len() > 1
14109                && let ast::PathStep::Name(root) = &p.steps[0]
14110                && (root == "__new__" || root == "__old__")
14111            {
14112                if let Some((anchor_td, anchor_alias)) = self.special_anchors.get(root).cloned() {
14113                    let relative = ast::Path {
14114                        steps: p.steps[1..].to_vec(),
14115                        partial: true,
14116                    };
14117                    return self.compile_path(&relative, anchor_td, &anchor_alias);
14118                }
14119                return Err(PyQLError::Resolution(PyQLResolutionError::UnknownField(
14120                    PyQLUnknownFieldError {
14121                        message: format!("{root} cannot be used in this expression"),
14122                        position: Position { line: 0, col: 0 },
14123                    },
14124                )));
14125            }
14126            // `__subject__` is the row a constraint is checked against, which
14127            // is the same row a relative path reads.
14128            if p.steps.len() > 1 && matches!(&p.steps[0], ast::PathStep::Name(root) if root == "__subject__") {
14129                let relative = ast::Path {
14130                    steps: p.steps[1..].to_vec(),
14131                    partial: true,
14132                };
14133                return self.compile_path(&relative, td, alias);
14134            }
14135            // Absolute path rooted at the current td: `TypeName.prop` inside a schema-bound
14136            // expression (e.g. the value side of a BinOp in compile_expr_as_path_select).
14137            // Rewrite to a relative path and compile normally.
14138            if p.steps.len() > 1
14139                && let ast::PathStep::Name(root) = &p.steps[0]
14140            {
14141                let qualified = format!("{}::{}", td.module, td.name);
14142                if *root == td.name || *root == qualified {
14143                    let relative = ast::Path {
14144                        steps: p.steps[1..].to_vec(),
14145                        partial: true,
14146                    };
14147                    // Inside a `detached` select, naming its own type means the
14148                    // enclosing select's row — that is what makes an anti-join
14149                    // compare two different rows.
14150                    if let Some((outer_qualified, outer_alias)) = self.enclosing_anchor(root) {
14151                        let outer_td = self.resolve_type(&outer_qualified)?.clone();
14152                        return self.compile_path(&relative, &outer_td, &outer_alias);
14153                    }
14154                    return self.compile_path(&relative, td, alias);
14155                }
14156                // A prefix naming a type an *enclosing* select already binds —
14157                // `select Font { styles: { font := Font.id } }`. Such a prefix
14158                // factors out to the scope that binds it, so it reads that row
14159                // rather than every row of the type.
14160                if let Some((outer_qualified, outer_alias)) = self.outer_anchor(root) {
14161                    let relative = ast::Path {
14162                        steps: p.steps[1..].to_vec(),
14163                        partial: true,
14164                    };
14165                    let outer_td = self.resolve_type(&outer_qualified)?.clone();
14166                    return self.compile_path(&relative, &outer_td, &outer_alias);
14167                }
14168            }
14169            // `membership.account.id` — a walk off a for-loop variable in
14170            // expression position. `compile_path_select` already knows to
14171            // start such a walk from the row the variable holds, so this is
14172            // that walk read as one subquery.
14173            if let Some(ast::PathStep::Name(var)) = p.steps.first()
14174                && p.steps.len() > 1
14175                && self.for_var_types.contains_key(var)
14176                && !matches!(p.steps[1], ast::PathStep::TypeIntersection(_))
14177            {
14178                let synthetic = ast::SelectStmt {
14179                    result: Expr::Path(p.clone()),
14180                    filter: None,
14181                    order_by: vec![],
14182                    offset: None,
14183                    limit: None,
14184                    lock: None,
14185                };
14186                let ps = self.compile_path_select(&synthetic, p, &[], false)?;
14187                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14188            }
14189            // `o[is Organization]` on a for-loop variable — the narrowing is
14190            // a filter, not a traversal: the row is read from the narrowed
14191            // type's own table by the key the variable holds, so a variable
14192            // bound to something else yields nothing, as it should.
14193            // `line[is BrandOrderLineItem].brand` — the narrowing picks the row
14194            // out of the narrowed type's own table, and the walk continues from
14195            // there. Without the tail this is the row itself, handled below.
14196            if let [
14197                ast::PathStep::Name(var),
14198                ast::PathStep::TypeIntersection(type_ref),
14199                rest @ ..,
14200            ] = p.steps.as_slice()
14201                && !rest.is_empty()
14202                && self.for_var_types.contains_key(var)
14203            {
14204                let type_name = match &type_ref.module {
14205                    Some(m) => format!("{}::{}", m, type_ref.name),
14206                    None => type_ref.name.clone(),
14207                };
14208                let narrowed = self.resolve_type(&type_name)?;
14209                let mut steps = vec![ast::PathStep::Name(format!("{}::{}", narrowed.module, narrowed.name))];
14210                steps.extend(rest.iter().cloned());
14211                let rooted = ast::Path { steps, partial: false };
14212                let synthetic = ast::SelectStmt {
14213                    result: Expr::Path(rooted.clone()),
14214                    filter: None,
14215                    order_by: vec![],
14216                    offset: None,
14217                    limit: None,
14218                    lock: None,
14219                };
14220                let mut ps = self.compile_path_select(&synthetic, &rooted, &[], false)?;
14221                let correlation = IrExpr::BinOp(Box::new(IrBinOp {
14222                    left: IrExpr::ColumnRef {
14223                        alias: ps.root.alias.clone(),
14224                        column: "id".to_string(),
14225                        pg_type: "uuid".to_string(),
14226                    },
14227                    op: ast::BinOpKind::Eq,
14228                    right: self.for_var_ref(var),
14229                }));
14230                ps.filter = and_conditions(ps.filter, vec![correlation]);
14231                return Ok(IrExpr::PathSubquery(Box::new(ps)));
14232            }
14233            if let [ast::PathStep::Name(var), ast::PathStep::TypeIntersection(type_ref)] = p.steps.as_slice()
14234                && self.for_var_types.contains_key(var)
14235            {
14236                let type_name = match &type_ref.module {
14237                    Some(m) => format!("{}::{}", m, type_ref.name),
14238                    None => type_ref.name.clone(),
14239                };
14240                let narrowed = self.resolve_type(&type_name)?;
14241                let narrowed_alias = self.fresh_alias();
14242                let source = IrSource {
14243                    poly: self.poly_fanout_for(&format!("{}::{}", narrowed.module, narrowed.name)),
14244                    type_name: format!("{}::{}", narrowed.module, narrowed.name),
14245                    table: narrowed.table.clone(),
14246                    alias: narrowed_alias.clone(),
14247                };
14248                let filter = IrExpr::BinOp(Box::new(IrBinOp {
14249                    left: IrExpr::ColumnRef {
14250                        alias: narrowed_alias,
14251                        column: "id".to_string(),
14252                        pg_type: "uuid".to_string(),
14253                    },
14254                    op: ast::BinOpKind::Eq,
14255                    right: self.for_var_ref(var),
14256                }));
14257                return Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
14258                    source,
14259                    Self::pk_returning(narrowed),
14260                    Some(filter),
14261                ))));
14262            }
14263            return Err(PyQLError::Type(PyQLTypeError {
14264                message: "absolute paths are not valid in expression context; use .name".into(),
14265                position: Position { line: 0, col: 0 },
14266            }));
14267        }
14268
14269        // A bare `@prop` — the junction row of the multi-link whose own
14270        // modifiers are being compiled. Read from the junction alias the
14271        // emitter puts in scope there ("jt"), the same one a `@prop` in the
14272        // nested shape reads.
14273        if p.partial
14274            && let [ast::PathStep::LinkProp(prop_name)] = p.steps.as_slice()
14275        {
14276            return self.compile_link_prop_ref(prop_name);
14277        }
14278
14279        // Type intersection in expression: [is Type].name — scalar subquery
14280        if p.partial && matches!(p.steps.first(), Some(ast::PathStep::TypeIntersection(_))) {
14281            return self.compile_type_intersection_expr(&p.steps, td, alias);
14282        }
14283
14284        // `.<assessment[is brand::AssessmentRespondent]` — a backlink read as a
14285        // value, with or without a narrowing. No column holds it, so it is the
14286        // traversal as a correlated subquery; the builders below only know
14287        // forward links and reject the intersection outright.
14288        if p.partial && matches!(p.steps.first(), Some(ast::PathStep::Backlink(_))) {
14289            return self.compile_partial_path_as_subquery(p, td, alias);
14290        }
14291        // `.ca.name` where `ca := a if cond else b` — the condition does not
14292        // depend on which branch is taken, so the field access distributes
14293        // over both and the walk happens inside each. Left whole, the walk
14294        // builder has no column to traverse through.
14295        if p.partial
14296            && p.steps.len() > 1
14297            && let Some(ast::PathStep::Name(first)) = p.steps.first()
14298            && let Some(Expr::IfElse(ie)) = self
14299                .active_declared_pointers
14300                .iter()
14301                .find(|d| path_leaf(&d.path).is_ok_and(|name| name == first))
14302                .and_then(|d| d.compexpr.clone())
14303        {
14304            let extend = |branch: &Expr| match branch {
14305                Expr::Path(bp) => {
14306                    let mut steps = bp.steps.clone();
14307                    steps.extend(p.steps[1..].iter().cloned());
14308                    Some(Expr::Path(ast::Path {
14309                        steps,
14310                        partial: bp.partial,
14311                    }))
14312                }
14313                // `(select .<brand[is T] … limit 1).priority` — the walk's
14314                // remaining steps projected off the sub-select.
14315                Expr::SubQuery(_) => p.steps[1..].iter().try_fold(branch.clone(), |expr, step| match step {
14316                    ast::PathStep::Name(field) => Some(Expr::FieldAccess {
14317                        expr: Box::new(expr),
14318                        field: field.clone(),
14319                    }),
14320                    _ => None,
14321                }),
14322                // Nothing to walk from, so nothing is reached.
14323                Expr::Set(items) if items.is_empty() => Some(Expr::Set(vec![])),
14324                Expr::TypeCast(cast) if matches!(&cast.expr, Expr::Set(items) if items.is_empty()) => {
14325                    Some(Expr::Set(vec![]))
14326                }
14327                _ => None,
14328            };
14329            if let (Some(if_expr), Some(else_expr)) = (extend(&ie.if_expr), extend(&ie.else_expr)) {
14330                let distributed = Expr::IfElse(Box::new(ast::IfElse {
14331                    condition: ie.condition.clone(),
14332                    if_expr,
14333                    else_expr,
14334                }));
14335                return self.compile_expr(&distributed, td, alias);
14336            }
14337        }
14338
14339        // A walk that ends in a type intersection (`exists .provider[is
14340        // account::Individual]`) narrows to objects rather than reading a
14341        // pointer, so there is no trailing column for the two-step form to
14342        // read and it goes the way a longer traversal already does.
14343        let narrows_last = matches!(p.steps.last(), Some(ast::PathStep::TypeIntersection(_)));
14344
14345        if p.steps.len() == 2 && !narrows_last {
14346            return self.compile_path_2step(p, td, alias);
14347        }
14348
14349        // Three or more steps: no single column to read, so the whole
14350        // traversal becomes one correlated subquery.
14351        if p.steps.len() != 1 {
14352            return self.compile_partial_path_as_subquery(p, td, alias);
14353        }
14354
14355        let pointer_name = match &p.steps[0] {
14356            ast::PathStep::Name(n) => n.as_str(),
14357            _ => {
14358                return Err(PyQLError::Type(PyQLTypeError {
14359                    message: "type intersections are not valid in expression context".into(),
14360                    position: Position { line: 0, col: 0 },
14361                }));
14362            }
14363        };
14364
14365        // __type__ as an expression: for polymorphic (interface) types, read from the
14366        // inline union column; for concrete types, emit the static qualified name.
14367        if pointer_name == "__type__" {
14368            return Ok(if self.is_polymorphic(td) {
14369                IrExpr::ColumnRef {
14370                    alias: alias.to_string(),
14371                    column: "__type__".to_string(),
14372                    pg_type: "text".to_string(),
14373                }
14374            } else {
14375                IrExpr::Literal(IrLiteral::Str(format!("{}::{}", td.module, td.name)))
14376            });
14377        }
14378
14379        if let Some(prop) = Self::resolve_property(td, pointer_name) {
14380            return Ok(IrExpr::ColumnRef {
14381                alias: alias.to_string(),
14382                column: prop.name.clone(),
14383                pg_type: prop.pg_type.clone(),
14384            });
14385        }
14386
14387        if let Some(link) = Self::resolve_link(td, pointer_name) {
14388            if link.is_junction_backed() {
14389                return self.junction_target_id_expr(td, link, alias);
14390            }
14391            // FK column reference (uuid) — e.g. `.company` → `t0."company_id"`
14392            return Ok(IrExpr::ColumnRef {
14393                alias: alias.to_string(),
14394                column: format!("{}_id", link.name),
14395                pg_type: "uuid".to_string(),
14396            });
14397        }
14398
14399        // Schema-defined computed pointer: inline the expression in place.
14400        if let Some(cd) = self.resolve_computed(td, pointer_name) {
14401            let expr_ast = crate::parse::parse_pointer_expr(&cd.expression).map_err(PyQLError::Syntax)?;
14402            return self.compile_expr(&expr_ast, td, alias);
14403        }
14404
14405        // `o in .organizations` — a multi-link read as a value is the set of
14406        // rows on the other side, which no column holds; the same traversal a
14407        // longer walk already becomes. Only property, link and computed were
14408        // resolved here, so a bare multi-link reported itself as unknown —
14409        // while the suggester, which does know them, offered the name back.
14410        if Self::resolve_multilink(td, pointer_name).is_some() {
14411            return self.compile_partial_path_as_subquery(p, td, alias);
14412        }
14413
14414        // `select (select T { a := … }) filter .a = …` — a pointer the shape
14415        // in scope declared is on no type, so it is read as the expression it
14416        // was written as. The shape route already resolves these; a filter or
14417        // order-by naming one arrives here instead.
14418        if let Some(expr) = self
14419            .active_declared_pointers
14420            .iter()
14421            .find(|d| path_leaf(&d.path).is_ok_and(|n| n == pointer_name))
14422            .and_then(|d| d.compexpr.clone())
14423        {
14424            return self.compile_expr(&expr, td, alias);
14425        }
14426
14427        Err(self.field_err(pointer_name, &format!("{}::{}", td.module, td.name)))
14428    }
14429
14430    /// Free-context counterpart of `compile_path` — no schema type/alias in
14431    /// scope, so only for-loop variables, CTE bindings, function parameters,
14432    /// and enum member access (`default::Gender.Female`) are resolvable;
14433    /// anything property/link-shaped is a hard error.
14434    fn compile_free_path(&mut self, p: &ast::Path) -> Result<IrExpr, PyQLError> {
14435        if p.partial {
14436            // A WITH binding inside a computed pointer or a nested SELECT is
14437            // compiled without a type in scope, but `.name` there still means
14438            // the innermost enclosing set — the one the anchor stack holds.
14439            if let Some((qualified, alias)) = self.anchors.last().map(|a| (a.qualified.clone(), a.alias.clone())) {
14440                let td = self.resolve_type(&qualified)?;
14441                return self.compile_path(p, td, &alias);
14442            }
14443            return Err(self.type_err(
14444                "property reference (.name) is not valid in free SELECT; \
14445                 use a schema-bound SELECT instead",
14446            ));
14447        }
14448        if p.steps.len() == 2
14449            && let [ast::PathStep::Name(type_ref), ast::PathStep::Name(variant)] = p.steps.as_slice()
14450            && self.resolve_enum(type_ref).is_some()
14451        {
14452            return self.compile_enum_access(type_ref, variant);
14453        }
14454        // `root.field1.field2...` where `root` is a WITH-bound free object
14455        // (any length >= 2, including chains through nested free objects).
14456        if let Some(resolved) = self.resolve_cte_path(p) {
14457            return resolved;
14458        }
14459        if p.steps.len() == 1
14460            && let ast::PathStep::Name(n) = &p.steps[0]
14461            && let Some(ir) = self.resolve_name_ref(n, true)
14462        {
14463            return Ok(ir);
14464        }
14465        // `assessment.current_question` in a free shape's field or filter — a
14466        // walk off an object binding. It is the same traversal a bound select
14467        // makes, read back as one subquery; there is no enclosing row to
14468        // resolve it against, and nothing else here knows a binding can root a
14469        // path.
14470        if !p.partial
14471            && p.steps.len() > 1
14472            && let Some(ast::PathStep::Name(root)) = p.steps.first()
14473            && self.cte_object_type(root).is_some()
14474        {
14475            let synthetic = ast::SelectStmt {
14476                result: Expr::Path(p.clone()),
14477                filter: None,
14478                order_by: vec![],
14479                offset: None,
14480                limit: None,
14481                lock: None,
14482            };
14483            let ps = self.compile_path_select(&synthetic, p, &[], false)?;
14484            // A walk made only of forward single links does not multiply rows,
14485            // so its cardinality is the root binding's. Treating every join as
14486            // widening made `(select T filter .id = $x).link.prop` a
14487            // one-element set rather than the value itself.
14488            let widens = ps.joins.iter().any(|join| !matches!(join, IrPathJoin::Single { .. }));
14489            let root_is_multi = self.multi_row_ctes.contains(root.as_str());
14490            let multi = matches!(ps.result, IrPathResult::Scalar(..)) && (widens || root_is_multi);
14491            return Ok(if multi {
14492                IrExpr::ArrayFromSelect(Box::new(IrArraySource::PathSelect(Box::new(ps))))
14493            } else {
14494                IrExpr::PathSubquery(Box::new(ps))
14495            });
14496        }
14497        Err(self.type_err("expression is not valid in free SELECT context"))
14498    }
14499
14500    fn compile_path_2step(&mut self, p: &ast::Path, td: &TypeDescriptor, alias: &str) -> Result<IrExpr, PyQLError> {
14501        let link_name = match &p.steps[0] {
14502            ast::PathStep::Name(n) => n.as_str(),
14503            _ => {
14504                return Err(PyQLError::Type(PyQLTypeError {
14505                    message: "type intersections are not valid in expression context".into(),
14506                    position: Position { line: 0, col: 0 },
14507                }));
14508            }
14509        };
14510        let pointer_name = match &p.steps[1] {
14511            ast::PathStep::Name(n) => n.as_str(),
14512            _ => {
14513                return Err(PyQLError::Type(PyQLTypeError {
14514                    message: "type intersections are not valid in expression context".into(),
14515                    position: Position { line: 0, col: 0 },
14516                }));
14517            }
14518        };
14519
14520        if let Some(link) = Self::resolve_link(td, link_name) {
14521            if pointer_name == "id" {
14522                if link.is_junction_backed() {
14523                    return self.junction_target_id_expr(td, link, alias);
14524                }
14525                return Ok(IrExpr::ColumnRef {
14526                    alias: alias.to_string(),
14527                    column: format!("{}_id", link_name),
14528                    pg_type: "uuid".to_string(),
14529                });
14530            }
14531            let target_td = self.resolve_type(&link.target)?;
14532            if let Some(prop) = Self::resolve_property(target_td, pointer_name) {
14533                let ft_alias = self.fresh_alias();
14534                let target_id_expr = if link.is_junction_backed() {
14535                    self.junction_target_id_expr(td, link, alias)?
14536                } else {
14537                    IrExpr::ColumnRef {
14538                        alias: alias.to_string(),
14539                        column: format!("{}_id", link_name),
14540                        pg_type: "uuid".to_string(),
14541                    }
14542                };
14543                return Ok(IrExpr::Subquery(Box::new(IrSelect::schema_bound(
14544                    IrSource {
14545                        poly: None,
14546                        type_name: format!("{}::{}", target_td.module, target_td.name),
14547                        table: target_td.table.clone(),
14548                        alias: ft_alias.clone(),
14549                    },
14550                    vec![IrShapePointer::Scalar(IrScalarPointer {
14551                        implicit_id: false,
14552                        marker_offset: None,
14553                        alias: prop.name.clone(),
14554                        column: prop.name.clone(),
14555                        pg_type: prop.pg_type.clone(),
14556                        tuple_shape: self.resolve_property_tuple_shape(prop),
14557                    })],
14558                    Some(IrExpr::BinOp(Box::new(IrBinOp {
14559                        left: IrExpr::ColumnRef {
14560                            alias: ft_alias.clone(),
14561                            column: "id".to_string(),
14562                            pg_type: "uuid".to_string(),
14563                        },
14564                        op: ast::BinOpKind::Eq,
14565                        right: target_id_expr,
14566                    }))),
14567                ))));
14568            }
14569            // Not a stored column on the target — a computed pointer, or a
14570            // further link. The general traversal builder resolves both.
14571            return self.compile_partial_path_as_subquery(p, td, alias);
14572        }
14573
14574        // A multi-link path stands for a set of values. Inside a comparison
14575        // it has already been rewritten to an EXISTS (`try_multilink_exists`,
14576        // which runs first); everywhere else it is an array.
14577        if Self::resolve_multilink(td, link_name).is_some() {
14578            return self.compile_partial_path_as_subquery(p, td, alias);
14579        }
14580
14581        // Not a stored pointer at all — a computed one, most likely, which
14582        // the general builder can traverse through by splicing in the path
14583        // it stands for. It raises the same "no link or property" error this
14584        // used to when the name really is unknown (which is what made a
14585        // computed head read as `has no link or property 'x'. Did you mean
14586        // 'x'?` — the suggester could see it, the resolver couldn't).
14587        self.compile_partial_path_as_subquery(p, td, alias)
14588    }
14589
14590    // ── Backlink compilation ─────────────────────────────────────────────────────
14591
14592    /// Detect a backlink path on either side of a BinOp and compile as EXISTS.
14593    fn try_backlink_exists(
14594        &mut self,
14595        b: &ast::BinOp,
14596        td: &TypeDescriptor,
14597        alias: &str,
14598    ) -> Result<Option<IrExpr>, PyQLError> {
14599        use ast::PathStep;
14600        fn is_backlink(p: &ast::Path) -> bool {
14601            p.partial && matches!(p.steps.first(), Some(PathStep::Backlink(_)))
14602        }
14603        let (path_steps, value_ast, flip) = if let Expr::Path(p) = &b.left {
14604            if is_backlink(p) {
14605                (p.steps.as_slice(), &b.right, false)
14606            } else {
14607                return Ok(None);
14608            }
14609        } else if let Expr::Path(p) = &b.right {
14610            if is_backlink(p) {
14611                (p.steps.as_slice(), &b.left, true)
14612            } else {
14613                return Ok(None);
14614            }
14615        } else {
14616            return Ok(None);
14617        };
14618        let value_expr = self.compile_expr(value_ast, td, alias)?;
14619        let current_qname = format!("{}::{}", td.module, td.name);
14620        let exists = self.compile_backlink_as_exists(
14621            path_steps,
14622            Some((b.op.clone(), value_expr, flip)),
14623            &current_qname,
14624            alias,
14625        )?;
14626        Ok(Some(exists))
14627    }
14628
14629    /// Compile a path `[Backlink(name), TypeIntersect(type), ...rest]` into EXISTS.
14630    /// `comparison` is `Some((op, value_expr, flip))` when used in a comparison filter.
14631    /// `current_qname` is the fully-qualified name of the object type being filtered.
14632    fn compile_backlink_as_exists(
14633        &mut self,
14634        steps: &[ast::PathStep],
14635        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
14636        current_qname: &str,
14637        alias: &str,
14638    ) -> Result<IrExpr, PyQLError> {
14639        use ast::PathStep;
14640
14641        let backlink_name = match steps.first() {
14642            Some(PathStep::Backlink(n)) => n.clone(),
14643            _ => return Err(self.type_err("internal: expected backlink step")),
14644        };
14645        // Without a type intersection the backlink spans every type that
14646        // declares the link at this target, so the row qualifies if any one of
14647        // them points at it.
14648        let Some(PathStep::TypeIntersection(type_ref)) = steps.get(1) else {
14649            // Without a type intersection the backlink spans every type that
14650            // declares the link at this target.
14651            return self.backlink_exists_over_owners(
14652                None,
14653                &backlink_name,
14654                &steps[1..],
14655                comparison,
14656                current_qname,
14657                alias,
14658            );
14659        };
14660
14661        let type_name = match &type_ref.module {
14662            Some(m) => format!("{}::{}", m, type_ref.name),
14663            None => type_ref.name.clone(),
14664        };
14665        let target_td = self.resolve_type(&type_name)?;
14666        if self.declares_backlink(target_td, &backlink_name, current_qname) {
14667            return self.backlink_exists_for_owner(
14668                target_td,
14669                &backlink_name,
14670                &steps[2..],
14671                comparison,
14672                current_qname,
14673                alias,
14674            );
14675        }
14676        // The intersection narrows to an interface or mixin that does not
14677        // declare the link itself — its implementors do, and a row of one of
14678        // them satisfies `[is ThatType]` all the same. Qualified, because that
14679        // is how an implementor names what it implements.
14680        let narrow_to = format!("{}::{}", target_td.module, target_td.name);
14681        self.backlink_exists_over_owners(
14682            Some(&narrow_to),
14683            &backlink_name,
14684            &steps[2..],
14685            comparison,
14686            current_qname,
14687            alias,
14688        )
14689    }
14690
14691    /// The type that declares the link a backlink narrowed to `owner_td`
14692    /// reads through.
14693    fn backlink_owner(
14694        &self,
14695        owner_td: &'a TypeDescriptor,
14696        backlink_name: &str,
14697        current_qname: &str,
14698    ) -> Result<&'a TypeDescriptor, PyQLError> {
14699        // `.<passkeys[is account::Account]` — the intersection narrows what
14700        // comes back, and the link itself may be declared further down: here
14701        // `passkeys` is Individual's, and an Individual is an Account. So the
14702        // owner is the concrete type that actually declares it, the same way
14703        // `backlink_exists_over_owners` resolves one.
14704        Ok(if self.declares_backlink(owner_td, backlink_name, current_qname) {
14705            owner_td
14706        } else {
14707            // Qualified, because that is how a type records the interfaces it
14708            // implements; the query may well have written the bare name.
14709            let narrowed = format!("{}::{}", owner_td.module, owner_td.name);
14710            let declaring: Vec<&'a TypeDescriptor> = self
14711                .schema
14712                .types
14713                .iter()
14714                .filter(|t| !t.abstract_ && Self::is_or_implements(t, &narrowed))
14715                .filter(|t| self.declares_backlink(t, backlink_name, current_qname))
14716                .collect();
14717            let declaring = Self::without_inherited_owners(declaring);
14718            match declaring.as_slice() {
14719                [only] => only,
14720                [] => owner_td,
14721                several => {
14722                    return Err(self.type_err(&format!(
14723                        "'{backlink_name}' pointing to {current_qname} is declared by {} types under \
14724                         {narrowed} ({}), so a backlink narrowed to it has no single source to read \
14725                         — narrow to one of them instead",
14726                        several.len(),
14727                        several
14728                            .iter()
14729                            .map(|t| format!("{}::{}", t.module, t.name))
14730                            .collect::<Vec<_>>()
14731                            .join(", "),
14732                    )));
14733                }
14734            }
14735        })
14736    }
14737
14738    /// Whether a backlink narrowed to `owner_td` reaches at most one object,
14739    /// which it does through an exclusive link.
14740    fn backlink_is_single(&self, owner_td: &'a TypeDescriptor, backlink_name: &str, current_qname: &str) -> bool {
14741        let Ok(owner) = self.backlink_owner(owner_td, backlink_name, current_qname) else {
14742            return false;
14743        };
14744        owner
14745            .links
14746            .iter()
14747            .any(|l| l.name == backlink_name && l.is_exclusive && self.link_target_reaches(&l.target, current_qname))
14748            || owner.multilinks.iter().any(|ml| {
14749                ml.name == backlink_name && ml.is_exclusive && self.link_target_reaches(&ml.target, current_qname)
14750            })
14751    }
14752
14753    /// Does `td` declare the link a backlink names, pointing at the type the
14754    /// traversal is standing on?
14755    fn declares_backlink(&self, td: &TypeDescriptor, backlink_name: &str, current_qname: &str) -> bool {
14756        td.links
14757            .iter()
14758            .any(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
14759            || td
14760                .multilinks
14761                .iter()
14762                .any(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
14763    }
14764
14765    /// EXISTS over every type that declares the backlink's link, OR-ed
14766    /// together: a row qualifies if any one of them points at it. `narrow_to`
14767    /// keeps only the types that satisfy an `[is …]` the path asked for.
14768    #[allow(clippy::too_many_arguments)]
14769    /// Drops the owners that only inherit the link from another owner in the
14770    /// list: reading the base already reads its subtypes' rows.
14771    fn without_inherited_owners(owners: Vec<&'a TypeDescriptor>) -> Vec<&'a TypeDescriptor> {
14772        let qnames: Vec<String> = owners.iter().map(|t| format!("{}::{}", t.module, t.name)).collect();
14773        owners
14774            .into_iter()
14775            .filter(|t| !t.bases.iter().any(|base| qnames.contains(base)))
14776            .collect()
14777    }
14778
14779    fn backlink_exists_over_owners(
14780        &mut self,
14781        narrow_to: Option<&str>,
14782        backlink_name: &str,
14783        rest: &[ast::PathStep],
14784        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
14785        current_qname: &str,
14786        alias: &str,
14787    ) -> Result<IrExpr, PyQLError> {
14788        let schema = self.schema;
14789        let owners: Vec<&'a TypeDescriptor> = schema
14790            .types
14791            .iter()
14792            .filter(|t| !t.abstract_)
14793            .filter(|t| narrow_to.is_none_or(|q| Self::is_or_implements(t, q)))
14794            .filter(|t| self.declares_backlink(t, backlink_name, current_qname))
14795            .collect();
14796        let owners = Self::without_inherited_owners(owners);
14797        if owners.is_empty() {
14798            return Err(self.type_err(&match narrow_to {
14799                Some(q) => format!("type {q} has no link or multi-link '{backlink_name}' pointing to {current_qname}"),
14800                None => format!("no type has a link or multi-link '{backlink_name}' pointing to {current_qname}"),
14801            }));
14802        }
14803        let mut combined: Option<IrExpr> = None;
14804        for owner_td in owners {
14805            let one = self.backlink_exists_for_owner(
14806                owner_td,
14807                backlink_name,
14808                rest,
14809                comparison.clone(),
14810                current_qname,
14811                alias,
14812            )?;
14813            combined = Some(match combined {
14814                None => one,
14815                Some(previous) => IrExpr::BinOp(Box::new(IrBinOp {
14816                    left: previous,
14817                    op: ast::BinOpKind::Or,
14818                    right: one,
14819                })),
14820            });
14821        }
14822        Ok(combined.expect("owners is non-empty"))
14823    }
14824
14825    /// Is `td` the type `qname` names, or one that implements/extends it?
14826    fn is_or_implements(td: &TypeDescriptor, qname: &str) -> bool {
14827        format!("{}::{}", td.module, td.name) == qname
14828            || td.interfaces.iter().any(|i| i == qname)
14829            || td.parents.iter().any(|p| p == qname)
14830            || td.bases.iter().any(|b| b == qname)
14831    }
14832
14833    /// A concrete type another concrete type extends (`BrandAddon`, which
14834    /// `BrandAddonBundle` extends). Its own table holds only its own rows, so
14835    /// reading the type means reading the subtypes' tables beside it.
14836    fn has_subtypes(&self, td: &TypeDescriptor) -> bool {
14837        let qname = format!("{}::{}", td.module, td.name);
14838        !td.abstract_ && self.schema.types.iter().any(|t| t.bases.contains(&qname))
14839    }
14840
14841    /// A plain `@pylon.abstract` mixin, materialised as nothing at all.
14842    /// Unlike an interface it backs no view, so naming its table reaches a
14843    /// relation that does not exist.
14844    fn backs_no_relation(td: &TypeDescriptor) -> bool {
14845        td.abstract_ && !td.materialized
14846    }
14847
14848    /// See `IrOutput::subtype_fanouts`.
14849    fn subtype_fanouts(&self) -> HashMap<(String, String), IrPolyFanout> {
14850        self.schema
14851            .types
14852            .iter()
14853            .filter(|t| self.has_subtypes(t) || Self::backs_no_relation(t))
14854            .filter_map(|t| {
14855                let fanout = self.poly_fanout_for(&format!("{}::{}", t.module, t.name))?;
14856                Some(((t.module.clone(), t.table.clone()), fanout))
14857            })
14858            .collect()
14859    }
14860
14861    /// Whether the type's rows come from more than one table — an interface,
14862    /// an abstract type (which has no table of its own), or a concrete type
14863    /// with subtypes — and so are read through the union that carries each
14864    /// row's own `__type__`.
14865    fn is_polymorphic(&self, td: &TypeDescriptor) -> bool {
14866        td.abstract_ || self.has_subtypes(td)
14867    }
14868
14869    /// One owner type's half of `compile_backlink_as_exists`: EXISTS over the
14870    /// rows of `target_td` that link back to `alias`, plus whatever the
14871    /// remaining path steps and comparison require of them.
14872    #[allow(clippy::too_many_arguments)]
14873    fn backlink_exists_for_owner(
14874        &mut self,
14875        target_td: &'a TypeDescriptor,
14876        backlink_name: &str,
14877        rest: &[ast::PathStep],
14878        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
14879        current_qname: &str,
14880        alias: &str,
14881    ) -> Result<IrExpr, PyQLError> {
14882        let backlink_name = backlink_name.to_string();
14883        let target_qname = format!("{}::{}", target_td.module, target_td.name);
14884        let target_table = target_td.table.clone();
14885        let t_alias = self.fresh_alias();
14886
14887        // Backlink source is either a single (FK) link or a multi-link
14888        // (junction table) on the target type — mirrors the equivalent
14889        // link-vs-multilink resolution the general shape-position backlink
14890        // code already does (see the `PathStep::Backlink` handling above in
14891        // `compile_path_expr`/similar), which this filter/exists-specific
14892        // path previously didn't: it only ever checked `target_td.links`,
14893        // so a self-referential-multilink backlink like `Person.friends`
14894        // failed to compile here even though it worked in a shape position.
14895        let join_cond = if let Some(l) = target_td
14896            .links
14897            .iter()
14898            .find(|l| l.name == backlink_name && self.link_target_reaches(&l.target, current_qname))
14899        {
14900            if l.is_junction_backed() {
14901                // Same junction-table EXISTS shape the multi-link branch
14902                // below uses — no direct FK column, since this link is
14903                // itself junction-backed.
14904                let (jt_table, jt_module, jt_owner_col, jt_current_col, _) = self.link_junction_info(target_td, l)?;
14905                let jt_alias = self.fresh_alias();
14906                IrExpr::UnaryOp(Box::new(IrUnaryOp {
14907                    op: ast::UnaryOpKind::Exists,
14908                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
14909                        IrSource {
14910                            poly: None,
14911                            type_name: format!("{}::__jt__", jt_module),
14912                            table: jt_table,
14913                            alias: jt_alias.clone(),
14914                        },
14915                        vec![],
14916                        Some(IrExpr::BinOp(Box::new(IrBinOp {
14917                            left: IrExpr::BinOp(Box::new(IrBinOp {
14918                                left: IrExpr::ColumnRef {
14919                                    alias: jt_alias.clone(),
14920                                    column: jt_owner_col,
14921                                    pg_type: "uuid".to_string(),
14922                                },
14923                                op: ast::BinOpKind::Eq,
14924                                right: IrExpr::ColumnRef {
14925                                    alias: t_alias.clone(),
14926                                    column: "id".to_string(),
14927                                    pg_type: "uuid".to_string(),
14928                                },
14929                            })),
14930                            op: ast::BinOpKind::And,
14931                            right: IrExpr::BinOp(Box::new(IrBinOp {
14932                                left: IrExpr::ColumnRef {
14933                                    alias: jt_alias,
14934                                    column: jt_current_col,
14935                                    pg_type: "uuid".to_string(),
14936                                },
14937                                op: ast::BinOpKind::Eq,
14938                                right: IrExpr::ColumnRef {
14939                                    alias: alias.to_string(),
14940                                    column: "id".to_string(),
14941                                    pg_type: "uuid".to_string(),
14942                                },
14943                            })),
14944                        }))),
14945                    ))),
14946                }))
14947            } else {
14948                let fk_col = format!("{}_id", backlink_name);
14949                // Join condition: target.fk_col = current.id
14950                IrExpr::BinOp(Box::new(IrBinOp {
14951                    left: IrExpr::ColumnRef {
14952                        alias: t_alias.clone(),
14953                        column: fk_col,
14954                        pg_type: "uuid".to_string(),
14955                    },
14956                    op: ast::BinOpKind::Eq,
14957                    right: IrExpr::ColumnRef {
14958                        alias: alias.to_string(),
14959                        column: "id".to_string(),
14960                        pg_type: "uuid".to_string(),
14961                    },
14962                }))
14963            }
14964        } else if let Some(ml) = target_td
14965            .multilinks
14966            .iter()
14967            .find(|ml| ml.name == backlink_name && self.link_target_reaches(&ml.target, current_qname))
14968            .cloned()
14969        {
14970            // Junction row connects t_alias (as the multi-link's owner/
14971            // "source") to the current row (as its "target") — no direct FK
14972            // column on either table, so this is a nested EXISTS over the
14973            // junction table rather than a simple column comparison.
14974            let (jt_table, jt_module, _, _, _) = self.multilink_junction_info(target_td, &ml)?;
14975            let jt_alias = self.fresh_alias();
14976            IrExpr::UnaryOp(Box::new(IrUnaryOp {
14977                op: ast::UnaryOpKind::Exists,
14978                operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
14979                    IrSource {
14980                        poly: None,
14981                        type_name: format!("{}::__jt__", jt_module),
14982                        table: jt_table,
14983                        alias: jt_alias.clone(),
14984                    },
14985                    vec![],
14986                    Some(IrExpr::BinOp(Box::new(IrBinOp {
14987                        left: IrExpr::BinOp(Box::new(IrBinOp {
14988                            left: IrExpr::ColumnRef {
14989                                alias: jt_alias.clone(),
14990                                column: "source".to_string(),
14991                                pg_type: "uuid".to_string(),
14992                            },
14993                            op: ast::BinOpKind::Eq,
14994                            right: IrExpr::ColumnRef {
14995                                alias: t_alias.clone(),
14996                                column: "id".to_string(),
14997                                pg_type: "uuid".to_string(),
14998                            },
14999                        })),
15000                        op: ast::BinOpKind::And,
15001                        right: IrExpr::BinOp(Box::new(IrBinOp {
15002                            left: IrExpr::ColumnRef {
15003                                alias: jt_alias,
15004                                column: "target".to_string(),
15005                                pg_type: "uuid".to_string(),
15006                            },
15007                            op: ast::BinOpKind::Eq,
15008                            right: IrExpr::ColumnRef {
15009                                alias: alias.to_string(),
15010                                column: "id".to_string(),
15011                                pg_type: "uuid".to_string(),
15012                            },
15013                        })),
15014                    }))),
15015                ))),
15016            }))
15017        } else {
15018            return Err(PyQLError::Type(PyQLTypeError {
15019                message: format!(
15020                    "type {} has no link or multi-link '{}' pointing to {}",
15021                    target_qname, backlink_name, current_qname,
15022                ),
15023                position: Position { line: 0, col: 0 },
15024            }));
15025        };
15026
15027        let tail_cond = self.compile_backlink_tail(rest, comparison, &target_qname, &t_alias)?;
15028
15029        let filter = match tail_cond {
15030            Some(tc) => IrExpr::BinOp(Box::new(IrBinOp {
15031                left: join_cond,
15032                op: ast::BinOpKind::And,
15033                right: tc,
15034            })),
15035            None => join_cond,
15036        };
15037
15038        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15039            op: ast::UnaryOpKind::Exists,
15040            operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15041                IrSource {
15042                    poly: None,
15043                    type_name: target_qname,
15044                    table: target_table,
15045                    alias: t_alias,
15046                },
15047                vec![],
15048                Some(filter),
15049            ))),
15050        })))
15051    }
15052
15053    /// Compile the tail steps after `[Backlink, TypeIntersect]`.
15054    fn compile_backlink_tail(
15055        &mut self,
15056        steps: &[ast::PathStep],
15057        comparison: Option<(ast::BinOpKind, IrExpr, bool)>,
15058        target_qname: &str,
15059        t_alias: &str,
15060    ) -> Result<Option<IrExpr>, PyQLError> {
15061        use ast::PathStep;
15062
15063        // `.<contacts[is Account] = account` — nothing after the backlink, so
15064        // the objects it reaches are what is compared, by identity. Returning
15065        // nothing here dropped the comparison, leaving "any account links to
15066        // this row" in its place.
15067        if steps.is_empty() {
15068            return Ok(comparison.map(|(op, value, flip)| {
15069                let key = IrExpr::ColumnRef {
15070                    alias: t_alias.to_string(),
15071                    column: "id".to_string(),
15072                    pg_type: "uuid".to_string(),
15073                };
15074                let (left, right) = if flip { (value, key) } else { (key, value) };
15075                IrExpr::BinOp(Box::new(IrBinOp { left, op, right }))
15076            }));
15077        }
15078
15079        // Chained backlink: recurse (current_qname is now target_qname of the outer backlink)
15080        if matches!(steps.first(), Some(PathStep::Backlink(_))) {
15081            let inner = self.compile_backlink_as_exists(steps, comparison, target_qname, t_alias)?;
15082            return Ok(Some(inner));
15083        }
15084
15085        let target_td = self.resolve_type(target_qname)?;
15086
15087        // Single property or link FK
15088        if let [PathStep::Name(pointer_name)] = steps {
15089            if let Some(prop) = Self::resolve_property(target_td, pointer_name) {
15090                let col = IrExpr::ColumnRef {
15091                    alias: t_alias.to_string(),
15092                    column: prop.name.clone(),
15093                    pg_type: prop.pg_type.clone(),
15094                };
15095                return Ok(Some(Self::apply_comparison(col, comparison)));
15096            }
15097            if let Some(link) = Self::resolve_link(target_td, pointer_name) {
15098                let col = if link.is_junction_backed() {
15099                    self.junction_target_id_expr(target_td, link, t_alias)?
15100                } else {
15101                    IrExpr::ColumnRef {
15102                        alias: t_alias.to_string(),
15103                        column: format!("{}_id", link.name),
15104                        pg_type: "uuid".to_string(),
15105                    }
15106                };
15107                return Ok(Some(Self::apply_comparison(col, comparison)));
15108            }
15109            // Not a property or a link: a multi-link has no single column to
15110            // read, so it falls through to the general tail walk below, which
15111            // reports the same error for a name that is none of the three.
15112        }
15113
15114        // Two forward steps: link then property (single FK join)
15115        if let [PathStep::Name(link_name), PathStep::Name(prop_name)] = steps
15116            && let Some(link) = Self::resolve_link(target_td, link_name)
15117        {
15118            let link_target = link.target.clone();
15119            let target_id_expr = if link.is_junction_backed() {
15120                self.junction_target_id_expr(target_td, link, t_alias)?
15121            } else {
15122                IrExpr::ColumnRef {
15123                    alias: t_alias.to_string(),
15124                    column: format!("{}_id", link_name),
15125                    pg_type: "uuid".to_string(),
15126                }
15127            };
15128            let link_target_td = self.resolve_type(&link_target)?;
15129            let link_target_qname = format!("{}::{}", link_target_td.module, link_target_td.name);
15130            let link_target_table = link_target_td.table.clone();
15131            if let Some(prop) = Self::resolve_property(link_target_td, prop_name) {
15132                let l_alias = self.fresh_alias();
15133                let id_cond = IrExpr::BinOp(Box::new(IrBinOp {
15134                    left: IrExpr::ColumnRef {
15135                        alias: l_alias.clone(),
15136                        column: "id".to_string(),
15137                        pg_type: "uuid".to_string(),
15138                    },
15139                    op: ast::BinOpKind::Eq,
15140                    right: target_id_expr,
15141                }));
15142                let col = IrExpr::ColumnRef {
15143                    alias: l_alias.clone(),
15144                    column: prop.name.clone(),
15145                    pg_type: prop.pg_type.clone(),
15146                };
15147                let prop_cond = Self::apply_comparison(col, comparison);
15148                let full = IrExpr::BinOp(Box::new(IrBinOp {
15149                    left: id_cond,
15150                    op: ast::BinOpKind::And,
15151                    right: prop_cond,
15152                }));
15153                return Ok(Some(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15154                    op: ast::UnaryOpKind::Exists,
15155                    operand: IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15156                        IrSource {
15157                            poly: None,
15158                            type_name: link_target_qname,
15159                            table: link_target_table,
15160                            alias: l_alias,
15161                        },
15162                        vec![],
15163                        Some(full),
15164                    ))),
15165                }))));
15166            }
15167        }
15168
15169        // Anything else is an ordinary path from the backlink's target type, so
15170        // it is compiled as one rather than enumerated arity by arity. The two
15171        // cases above stay because each reads a single column; a step that
15172        // crosses a multi-link has none to read.
15173        let tail = ast::Path {
15174            steps: steps.to_vec(),
15175            partial: true,
15176        };
15177        let walked = self.compile_path(&tail, target_td, t_alias)?;
15178        Ok(Some(Self::apply_comparison(walked, comparison)))
15179    }
15180
15181    /// Apply an optional comparison to a column ref, defaulting to IS NOT NULL.
15182    fn apply_comparison(col: IrExpr, comparison: Option<(ast::BinOpKind, IrExpr, bool)>) -> IrExpr {
15183        match comparison {
15184            Some((op, val, flip)) => {
15185                let (l, r) = if flip { (val, col) } else { (col, val) };
15186                // A walk that crosses a multi-link stands for a *set*, which
15187                // an equality holds against when any element matches — the
15188                // same reading `compile_expr_ctx` gives one anywhere else.
15189                if matches!(op, ast::BinOpKind::Eq | ast::BinOpKind::Ne)
15190                    && matches!(l, IrExpr::ArrayFromSelect(_)) != matches!(r, IrExpr::ArrayFromSelect(_))
15191                {
15192                    let (value, set) = if matches!(r, IrExpr::ArrayFromSelect(_)) {
15193                        (l, r)
15194                    } else {
15195                        (r, l)
15196                    };
15197                    let membership = IrExpr::BinOp(Box::new(IrBinOp {
15198                        left: value,
15199                        op: ast::BinOpKind::In,
15200                        right: set,
15201                    }));
15202                    return if matches!(op, ast::BinOpKind::Ne) {
15203                        IrExpr::UnaryOp(Box::new(IrUnaryOp {
15204                            op: ast::UnaryOpKind::Not,
15205                            operand: membership,
15206                        }))
15207                    } else {
15208                        membership
15209                    };
15210                }
15211                IrExpr::BinOp(Box::new(IrBinOp { left: l, op, right: r }))
15212            }
15213            None => ir_is_not_null(col),
15214        }
15215    }
15216
15217    /// If `b` has a multi-link path (any depth) on either side, compile as EXISTS over the junction.
15218    fn try_multilink_exists(
15219        &mut self,
15220        b: &ast::BinOp,
15221        td: &TypeDescriptor,
15222        alias: &str,
15223    ) -> Result<Option<IrExpr>, PyQLError> {
15224        // A bare `.multilink` counts: comparing the link itself to an object
15225        // (`filter any(.emails = email)`) is the same set-membership question
15226        // as comparing something reached through it, just with no tail.
15227        fn ml_first_name(steps: &[ast::PathStep]) -> Option<&str> {
15228            match steps.first()? {
15229                ast::PathStep::Name(n) => Some(n.as_str()),
15230                _ => None,
15231            }
15232        }
15233
15234        let (path_steps, value_ast, flip) = if let Expr::Path(p) = &b.left {
15235            if p.partial {
15236                if let Some(ln) = ml_first_name(&p.steps) {
15237                    if Self::resolve_multilink(td, ln).is_some() {
15238                        (p.steps.as_slice(), &b.right, false)
15239                    } else {
15240                        return Ok(None);
15241                    }
15242                } else {
15243                    return Ok(None);
15244                }
15245            } else {
15246                return Ok(None);
15247            }
15248        } else if let Expr::Path(p) = &b.right {
15249            if p.partial {
15250                if let Some(ln) = ml_first_name(&p.steps) {
15251                    if Self::resolve_multilink(td, ln).is_some() {
15252                        (p.steps.as_slice(), &b.left, true)
15253                    } else {
15254                        return Ok(None);
15255                    }
15256                } else {
15257                    return Ok(None);
15258                }
15259            } else {
15260                return Ok(None);
15261            }
15262        } else {
15263            return Ok(None);
15264        };
15265
15266        let value_expr = self.compile_expr(value_ast, td, alias)?;
15267        let ml_name = match &path_steps[0] {
15268            ast::PathStep::Name(n) => n.clone(),
15269            _ => return Ok(None),
15270        };
15271        let ml = Self::resolve_multilink(td, &ml_name).unwrap();
15272
15273        // Warn: multi-link traversal in a comparison returns a set, not a single boolean.
15274        // The query works (compiled as EXISTS), but `any()` makes the intent explicit.
15275        if self.explicit_set_depth == 0 {
15276            // Rendered back the way it was written — a path the reader cannot
15277            // find in their own query is worse than no path at all.
15278            let mut pointer_path = String::new();
15279            for step in path_steps {
15280                match step {
15281                    ast::PathStep::Name(n) => {
15282                        pointer_path.push('.');
15283                        pointer_path.push_str(n);
15284                    }
15285                    ast::PathStep::Backlink(n) => {
15286                        pointer_path.push_str(".<");
15287                        pointer_path.push_str(n);
15288                    }
15289                    ast::PathStep::LinkProp(n) => {
15290                        pointer_path.push('@');
15291                        pointer_path.push_str(n);
15292                    }
15293                    ast::PathStep::TypeIntersection(t) => {
15294                        pointer_path.push_str("[is ");
15295                        pointer_path.push_str(&t.qualified_name());
15296                        pointer_path.push(']');
15297                    }
15298                }
15299            }
15300            self.warnings.push(format!(
15301                "possibly more than one element returned by an expression in a FILTER clause \
15302                 (multi-link '{pointer_path}'); wrap with any() to make intent explicit",
15303            ));
15304        }
15305
15306        // Clone what we need to avoid borrow conflicts with self below.
15307        let ml_target = ml.target.clone();
15308        let ml_through = ml.through.clone();
15309        let td_module = td.module.clone();
15310        let td_name = td.name.clone();
15311        let td_table = td.table.clone();
15312
15313        // tail steps are everything after the multi-link name (path_steps[1..])
15314        let tail_steps: Vec<ast::PathStep> = path_steps[1..].to_vec();
15315
15316        let jt_alias = self.fresh_alias();
15317
15318        // Resolve junction table columns.
15319        let (jt_table, jt_module, jt_src_col, jt_tgt_col) = if let Some(through_qname) = &ml_through {
15320            let through_td = self.resolve_type(through_qname)?;
15321            if through_td.junction {
15322                // See `junction_info_for`'s doc comment: owner-derived,
15323                // never `through_td.table` itself.
15324                (
15325                    format!("{}.{}", td_table, ml_name),
15326                    td_module.clone(),
15327                    "source".to_string(),
15328                    "target".to_string(),
15329                )
15330            } else {
15331                let source_qname = format!("{}::{}", td_module, td_name);
15332                let src_col = through_td
15333                    .links
15334                    .iter()
15335                    .find(|l| l.target == source_qname)
15336                    .ok_or_else(|| {
15337                        PyQLError::Type(PyQLTypeError {
15338                            message: format!("through type {through_qname} has no link to {source_qname}"),
15339                            position: Position { line: 0, col: 0 },
15340                        })
15341                    })?
15342                    .name
15343                    .clone();
15344                let tgt_col = through_td
15345                    .links
15346                    .iter()
15347                    .find(|l| l.target == ml_target && l.name != src_col)
15348                    .or_else(|| through_td.links.iter().find(|l| l.target == ml_target))
15349                    .ok_or_else(|| {
15350                        PyQLError::Type(PyQLTypeError {
15351                            message: format!("through type {through_qname} has no link to {ml_target}"),
15352                            position: Position { line: 0, col: 0 },
15353                        })
15354                    })?
15355                    .name
15356                    .clone();
15357                (
15358                    through_td.table.clone(),
15359                    through_td.module.clone(),
15360                    format!("{}_id", src_col),
15361                    format!("{}_id", tgt_col),
15362                )
15363            }
15364        } else {
15365            (
15366                format!("{}.{}", td_table, ml_name),
15367                td_module.clone(),
15368                "source".to_string(),
15369                "target".to_string(),
15370            )
15371        };
15372
15373        // source filter: jt.src_col = parent.id
15374        let src_filter = IrExpr::BinOp(Box::new(IrBinOp {
15375            left: IrExpr::ColumnRef {
15376                alias: jt_alias.clone(),
15377                column: jt_src_col,
15378                pg_type: "uuid".to_string(),
15379            },
15380            op: ast::BinOpKind::Eq,
15381            right: IrExpr::ColumnRef {
15382                alias: alias.to_string(),
15383                column: "id".to_string(),
15384                pg_type: "uuid".to_string(),
15385            },
15386        }));
15387
15388        // Build tail filter: what to compare inside the junction/target EXISTS
15389        let tail_filter = self.compile_path_tail_filter(
15390            &tail_steps,
15391            b.op.clone(),
15392            value_expr,
15393            flip,
15394            &ml_target,
15395            &jt_alias,
15396            &jt_tgt_col,
15397        )?;
15398
15399        let full_filter = IrExpr::BinOp(Box::new(IrBinOp {
15400            left: src_filter,
15401            op: ast::BinOpKind::And,
15402            right: tail_filter,
15403        }));
15404
15405        // EXISTS(SELECT 1 FROM junction jt WHERE ...)
15406        let jt_source = IrSource {
15407            poly: None,
15408            type_name: format!("{}::__jt__", jt_module),
15409            table: jt_table,
15410            alias: jt_alias,
15411        };
15412        let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(jt_source, vec![], Some(full_filter))));
15413
15414        Ok(Some(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15415            op: ast::UnaryOpKind::Exists,
15416            operand: inner,
15417        }))))
15418    }
15419
15420    /// Build a filter expression for the tail steps after the multi-link in an EXISTS context.
15421    ///
15422    /// `steps` = path steps after the multi-link name (e.g. ["company", "id"] for .friends.company.id)
15423    /// `jt_alias` = alias of the junction table row
15424    /// `jt_tgt_col` = column in the junction table holding the target id (e.g. "target" or "friend_id")
15425    /// `target_type` = qualified name of the multi-link's target type (e.g. "default::Person")
15426    #[allow(clippy::too_many_arguments)]
15427    fn compile_path_tail_filter(
15428        &mut self,
15429        steps: &[ast::PathStep],
15430        op: ast::BinOpKind,
15431        value_expr: IrExpr,
15432        flip: bool,
15433        target_type: &str,
15434        jt_alias: &str,
15435        jt_tgt_col: &str,
15436    ) -> Result<IrExpr, PyQLError> {
15437        // No tail at all — the multi-link itself is what is being compared,
15438        // so the junction row's target id is the whole answer.
15439        if steps.is_empty() {
15440            let col_ref = IrExpr::ColumnRef {
15441                alias: jt_alias.to_string(),
15442                column: jt_tgt_col.to_string(),
15443                pg_type: "uuid".to_string(),
15444            };
15445            let (left, right) = if flip {
15446                (value_expr, col_ref)
15447            } else {
15448                (col_ref, value_expr)
15449            };
15450            return Ok(IrExpr::BinOp(Box::new(IrBinOp { left, op, right })));
15451        }
15452
15453        // `.emails[is account::Email].email` — an intersection narrows what the
15454        // walk continues from. The remaining steps read against the narrowed
15455        // type, and because its own table is what they join to, a row of any
15456        // other type drops out on its own: the narrowing needs no separate
15457        // check.
15458        if let Some(ast::PathStep::TypeIntersection(type_ref)) = steps.first() {
15459            let narrowed = self.resolve_type(&type_ref.qualified_name())?;
15460            let narrowed_qname = format!("{}::{}", narrowed.module, narrowed.name);
15461            return self.compile_path_tail_filter(
15462                &steps[1..],
15463                op,
15464                value_expr,
15465                flip,
15466                &narrowed_qname,
15467                jt_alias,
15468                jt_tgt_col,
15469            );
15470        }
15471
15472        let first_name = match steps.first() {
15473            Some(ast::PathStep::Name(n)) => n.clone(),
15474            _ => return Err(self.type_err("expected a property or link name in path")),
15475        };
15476
15477        let target_td = self.resolve_type(target_type)?;
15478        let target_table = target_td.table.clone();
15479
15480        if steps.len() == 1 {
15481            // Terminal step: must be a scalar property or "id"
15482            if first_name == "id" {
15483                // FK optimisation: compare jt.target directly
15484                let col_ref = IrExpr::ColumnRef {
15485                    alias: jt_alias.to_string(),
15486                    column: jt_tgt_col.to_string(),
15487                    pg_type: "uuid".to_string(),
15488                };
15489                let (l, r) = if flip {
15490                    (value_expr, col_ref)
15491                } else {
15492                    (col_ref, value_expr)
15493                };
15494                return Ok(IrExpr::BinOp(Box::new(IrBinOp { left: l, op, right: r })));
15495            }
15496            // Check if it's a link (object) rather than a scalar
15497            // A single link is compared by the foreign key it stores, the
15498            // same way `.link = obj` is one step higher up
15499            // (`any(.access_grants.account = account)`). A multi-link has no
15500            // column to compare and would need a junction of its own.
15501            let single_link = target_td
15502                .links
15503                .iter()
15504                .find(|l| l.name == first_name && !l.is_junction_backed());
15505            if single_link.is_none() && target_td.multilinks.iter().any(|l| l.name == first_name) {
15506                let target_display = target_type.replace("::", ".");
15507                return Err(PyQLError::Type(PyQLTypeError {
15508                    message: format!(
15509                        "operator '{op}' cannot be applied to operands of type '{target_display}' and the value type",
15510                        op = op,
15511                    ),
15512                    position: Position { line: 0, col: 0 },
15513                }));
15514            }
15515            let (prop_name, prop_pg) = match single_link {
15516                Some(link) => (format!("{}_id", link.name), "uuid".to_string()),
15517                None => {
15518                    let prop = target_td
15519                        .properties
15520                        .iter()
15521                        .find(|p| p.name == first_name)
15522                        .ok_or_else(|| self.field_err(&first_name, target_type))?;
15523                    (prop.name.clone(), prop.pg_type.clone())
15524                }
15525            };
15526            let tgt_alias = self.fresh_alias();
15527            // Build EXISTS(SELECT 1 FROM target WHERE target.id = jt.target AND target.prop op value)
15528            let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
15529                left: IrExpr::ColumnRef {
15530                    alias: tgt_alias.clone(),
15531                    column: "id".to_string(),
15532                    pg_type: "uuid".to_string(),
15533                },
15534                op: ast::BinOpKind::Eq,
15535                right: IrExpr::ColumnRef {
15536                    alias: jt_alias.to_string(),
15537                    column: jt_tgt_col.to_string(),
15538                    pg_type: "uuid".to_string(),
15539                },
15540            }));
15541            let prop_col = IrExpr::ColumnRef {
15542                alias: tgt_alias.clone(),
15543                column: prop_name,
15544                pg_type: prop_pg,
15545            };
15546            let (pl, pr) = if flip {
15547                (value_expr, prop_col)
15548            } else {
15549                (prop_col, value_expr)
15550            };
15551            let prop_filter = IrExpr::BinOp(Box::new(IrBinOp {
15552                left: pl,
15553                op,
15554                right: pr,
15555            }));
15556            let full = IrExpr::BinOp(Box::new(IrBinOp {
15557                left: id_filter,
15558                op: ast::BinOpKind::And,
15559                right: prop_filter,
15560            }));
15561            let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15562                IrSource {
15563                    poly: None,
15564                    type_name: target_type.to_string(),
15565                    table: target_table,
15566                    alias: tgt_alias,
15567                },
15568                vec![],
15569                Some(full),
15570            )));
15571            return Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15572                op: ast::UnaryOpKind::Exists,
15573                operand: inner,
15574            })));
15575        }
15576
15577        // steps.len() >= 2: first_name must be a single link (not multi)
15578        if target_td.multilinks.iter().any(|l| l.name == first_name) {
15579            return Err(self.type_err("nested multi-link traversal in comparison is not yet supported"));
15580        }
15581        let link = target_td
15582            .links
15583            .iter()
15584            .find(|l| l.name == first_name)
15585            .ok_or_else(|| self.field_err(&first_name, target_type))?;
15586        if link.is_junction_backed() {
15587            // A junction-backed link's correlation is a subquery, not a
15588            // plain FK column, and this recursive path-tail resolver is
15589            // built entirely around passing a (alias, column) pair down to
15590            // the next level — supporting it here would need a broader
15591            // signature change. Fail loudly rather than silently reference
15592            // a `{name}_id` column that doesn't exist for this link.
15593            return Err(self.type_err(&format!(
15594                "filtering through a junction-backed single link ('{first_name}') nested inside \
15595                 a multi-link path comparison is not yet supported — filter on '.{first_name}' \
15596                 directly instead"
15597            )));
15598        }
15599        let next_target = link.target.clone();
15600        let fk_col = format!("{}_id", first_name);
15601        let tgt_alias = self.fresh_alias();
15602
15603        // FK optimisation for [single_link, "id"]:
15604        if steps.len() == 2
15605            && let Some(ast::PathStep::Name(n)) = steps.get(1)
15606            && n == "id"
15607        {
15608            // Compare tgt.{fk_col} (the FK in current target) directly
15609            // We need an EXISTS over the target to access fk_col
15610            // Actually: EXISTS(target WHERE target.id = jt.target AND target.{fk_col} op value)
15611            let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
15612                left: IrExpr::ColumnRef {
15613                    alias: tgt_alias.clone(),
15614                    column: "id".to_string(),
15615                    pg_type: "uuid".to_string(),
15616                },
15617                op: ast::BinOpKind::Eq,
15618                right: IrExpr::ColumnRef {
15619                    alias: jt_alias.to_string(),
15620                    column: jt_tgt_col.to_string(),
15621                    pg_type: "uuid".to_string(),
15622                },
15623            }));
15624            let fk_ref = IrExpr::ColumnRef {
15625                alias: tgt_alias.clone(),
15626                column: fk_col,
15627                pg_type: "uuid".to_string(),
15628            };
15629            let (fl, fr) = if flip {
15630                (value_expr, fk_ref)
15631            } else {
15632                (fk_ref, value_expr)
15633            };
15634            let fk_filter = IrExpr::BinOp(Box::new(IrBinOp {
15635                left: fl,
15636                op,
15637                right: fr,
15638            }));
15639            let full = IrExpr::BinOp(Box::new(IrBinOp {
15640                left: id_filter,
15641                op: ast::BinOpKind::And,
15642                right: fk_filter,
15643            }));
15644            let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15645                IrSource {
15646                    poly: None,
15647                    type_name: target_type.to_string(),
15648                    table: target_table,
15649                    alias: tgt_alias,
15650                },
15651                vec![],
15652                Some(full),
15653            )));
15654            return Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15655                op: ast::UnaryOpKind::Exists,
15656                operand: inner,
15657            })));
15658        }
15659
15660        // General case: EXISTS(target WHERE target.id = jt.jt_tgt_col AND <tail_filter for steps[1..]>)
15661        // Recursive call uses tgt_alias.fk_col as the "pointer to the next type's id"
15662        let id_filter = IrExpr::BinOp(Box::new(IrBinOp {
15663            left: IrExpr::ColumnRef {
15664                alias: tgt_alias.clone(),
15665                column: "id".to_string(),
15666                pg_type: "uuid".to_string(),
15667            },
15668            op: ast::BinOpKind::Eq,
15669            right: IrExpr::ColumnRef {
15670                alias: jt_alias.to_string(),
15671                column: jt_tgt_col.to_string(),
15672                pg_type: "uuid".to_string(),
15673            },
15674        }));
15675        let nested_filter =
15676            self.compile_path_tail_filter(&steps[1..], op, value_expr, flip, &next_target, &tgt_alias, &fk_col)?;
15677        let full = IrExpr::BinOp(Box::new(IrBinOp {
15678            left: id_filter,
15679            op: ast::BinOpKind::And,
15680            right: nested_filter,
15681        }));
15682        let inner = IrExpr::Subquery(Box::new(IrSelect::schema_bound(
15683            IrSource {
15684                poly: None,
15685                type_name: target_type.to_string(),
15686                table: target_table,
15687                alias: tgt_alias,
15688            },
15689            vec![],
15690            Some(full),
15691        )));
15692        Ok(IrExpr::UnaryOp(Box::new(IrUnaryOp {
15693            op: ast::UnaryOpKind::Exists,
15694            operand: inner,
15695        })))
15696    }
15697}
15698
15699/// Where a walk reads a multi-link this statement also wrote: the CTE holding
15700/// the junction rows, and — when the targets were inserted here too — the CTE
15701/// holding those.
15702#[derive(Clone)]
15703struct JunctionReadOverride {
15704    junction: String,
15705    targets: Option<String>,
15706}
15707
15708/// What an `UNLESS CONFLICT … ELSE (UPDATE …)` clause contributes: the
15709/// `DO UPDATE SET` assignments, and the predicate deciding whether the
15710/// conflicting row is touched at all when the ELSE UPDATE carried a filter.
15711struct ConflictElse {
15712    sets: Vec<(String, IrExpr)>,
15713    predicate: Option<IrExpr>,
15714}
15715
15716impl<'a> Compiler<'a> {
15717    /// Compile `UNLESS CONFLICT [ON expr] [ELSE (UPDATE …)]` into `IrConflict`.
15718    fn compile_conflict(
15719        &mut self,
15720        uc: &ast::UnlessConflict,
15721        td: &TypeDescriptor,
15722    ) -> Result<(IrConflict, Vec<IrMultiLinkMutation>), PyQLError> {
15723        // ON clause: compile with empty alias → bare column name (`"col"` not `"t0"."col"`)
15724        // so the emitter produces `ON CONFLICT ("name")` not `ON CONFLICT ("t0"."name")`.
15725        let on = uc.on.as_ref().map(|e| self.compile_expr(e, td, "")).transpose()?;
15726        let mut appends = vec![];
15727        let mut do_update_where = None;
15728        let do_update = match uc.else_.as_ref() {
15729            Some(e) => {
15730                let resolved = self.compile_conflict_else(e, &mut appends)?;
15731                do_update_where = resolved.predicate;
15732                Some(resolved.sets)
15733            }
15734            None => None,
15735        };
15736        Ok((
15737            IrConflict {
15738                on,
15739                do_update,
15740                do_update_where,
15741            },
15742            appends,
15743        ))
15744    }
15745
15746    /// Compile the ELSE clause of UNLESS CONFLICT, which must be `(UPDATE Type SET { … })`.
15747    ///
15748    /// Assignments are compiled with the target table's own bare name (not
15749    /// schema-qualified, and not the usual `t0`-style fresh alias) as the
15750    /// qualifying "alias" so a self-referencing RHS (`.stock` in `stock :=
15751    /// .stock + 1`) resolves unambiguously to the *existing* conflicting
15752    /// row. A genuinely bare, unqualified column reference here is
15753    /// ambiguous in Postgres between the existing row and the `excluded`
15754    /// pseudo-row (confirmed live: "column reference ... is ambiguous")
15755    /// even though only one of the two is ever actually reachable this way
15756    /// (nothing here ever compiles a reference to `excluded`) — Postgres's
15757    /// own docs describe exactly this qualification: "the existing row
15758    /// using the table's name (or an alias)," no explicit `AS` needed on
15759    /// the INSERT target for that self-reference to work.
15760    fn compile_conflict_else(
15761        &mut self,
15762        expr: &Expr,
15763        appends: &mut Vec<IrMultiLinkMutation>,
15764    ) -> Result<ConflictElse, PyQLError> {
15765        let Expr::SubQuery(stmt) = expr else {
15766            return Err(self.type_err("UNLESS CONFLICT ELSE must be an UPDATE expression, e.g. ELSE (UPDATE …)"));
15767        };
15768        // `else (select usage::Allowance)` — yield the conflicting row rather
15769        // than change it. `DO NOTHING` returns nothing at all, so the row is
15770        // read back by assigning its own key to itself, which is what makes
15771        // `RETURNING` see it.
15772        if let Stmt::Select(sel) = stmt.as_ref()
15773            && sel.filter.is_none()
15774            && sel.limit.is_none()
15775            && sel.offset.is_none()
15776            && let Ok(type_name) = self.expr_as_type_name(&sel.result)
15777            && let Ok(sel_td) = self.resolve_type(&type_name)
15778        {
15779            let table = sel_td.table.clone();
15780            let pk = sel_td
15781                .properties
15782                .iter()
15783                .find(|p| p.is_pk)
15784                .ok_or_else(|| self.type_err(&format!("type '{type_name}' has no primary key to read back")))?;
15785            return Ok(ConflictElse {
15786                sets: vec![(
15787                    pk.name.clone(),
15788                    IrExpr::ColumnRef {
15789                        alias: table,
15790                        column: pk.name.clone(),
15791                        pg_type: pk.pg_type.clone(),
15792                    },
15793                )],
15794                predicate: None,
15795            });
15796        }
15797        let Stmt::Update(upd) = stmt.as_ref() else {
15798            return Err(self.type_err(
15799                "UNLESS CONFLICT ELSE must be an UPDATE that changes the conflicting row, or a \
15800                 SELECT of its type to read it back unchanged",
15801            ));
15802        };
15803        let type_name = self.expr_as_type_name(&upd.subject)?;
15804        let upd_td = self.resolve_type(&type_name)?;
15805        let table = upd_td.table.clone();
15806        // The ON CONFLICT target says *which* row conflicts; the ELSE UPDATE's
15807        // own filter says whether to touch it at all, and it is honoured.
15808        // Dropping it silently turned `unless conflict on .key else (update T
15809        // filter .expires_at < now() set { … })` into an unconditional steal of
15810        // a live advisory lock. Compiled against the table name, which is what
15811        // refers to the *existing* row inside `DO UPDATE` (`excluded` is the
15812        // proposed one).
15813        let do_update_where = upd
15814            .filter
15815            .as_ref()
15816            .map(|f| self.compile_expr(f, upd_td, &table))
15817            .transpose()?;
15818        // A multi-link cannot be written by `DO UPDATE SET` — junction rows
15819        // are separate DML. Appending them to the enclosing insert instead
15820        // applies them to whichever row comes back, inserted or conflicting,
15821        // and the junction insert is `ON CONFLICT DO NOTHING`, so the
15822        // inserted branch (which already wrote the same rows from its own
15823        // shape) is unaffected.
15824        let mut scalar_shape = vec![];
15825        for el in &upd.shape {
15826            let pointer_name = path_leaf(&el.path)?;
15827            let Some(ml) = Self::resolve_multilink(upd_td, pointer_name) else {
15828                scalar_shape.push(el.clone());
15829                continue;
15830            };
15831            if el.op == ShapeOp::Remove {
15832                return Err(self.type_err(&format!(
15833                    "cannot use `-=` for multi-link '{pointer_name}' inside an UNLESS CONFLICT \
15834                     ELSE clause; the rows to remove are not known until the conflict resolves"
15835                )));
15836            }
15837            let Some(value) = &el.compexpr else { continue };
15838            let (jt, module, src_col, tgt_col, through_td) = self.own_multilink_junction_info(upd_td, ml)?;
15839            let values = self.compile_multilink_values(value, upd_td, &table, through_td)?;
15840            appends.push(IrMultiLinkMutation {
15841                junction_table: jt,
15842                module,
15843                source_col: src_col,
15844                target_col: tgt_col,
15845                values,
15846                single: false,
15847            });
15848        }
15849        let sets = self.compile_assignments_for_update(&scalar_shape, upd_td, &table)?;
15850        Ok(ConflictElse {
15851            sets,
15852            predicate: do_update_where,
15853        })
15854    }
15855
15856    /// Compile a nested INSERT/UPDATE/DELETE into its own CTE and return that
15857    /// CTE's name. Postgres cannot run DML inside another statement's value
15858    /// list, so it is hoisted into the enclosing statement's `WITH` and read
15859    /// back from there — see `pending_nested_ctes`.
15860    /// Compile a DML statement into a CTE of the query's own `WITH`.
15861    ///
15862    /// Unlike `hoist_nested_dml`, whose CTE belongs to the enclosing
15863    /// INSERT/UPDATE it was nested inside, this one has no enclosing statement
15864    /// to attach to — the select that names it *is* the top level.
15865    fn hoist_dml_as_cte(&mut self, stmt: &Stmt) -> Result<(String, String), PyQLError> {
15866        let type_name = self.dml_subject_type(stmt)?;
15867        let before = std::mem::take(&mut self.for_vars_read);
15868        let inner = self.compile_stmt(stmt);
15869        let read = std::mem::replace(&mut self.for_vars_read, before);
15870        let correlated_to = self
15871            .for_scope
15872            .iter()
15873            .rev()
15874            .find(|slot| read.contains(*slot))
15875            .map(|slot| format!("_for_{slot}"));
15876        self.for_vars_read.extend(read);
15877        let cte_name = self.fresh_nested_cte_name();
15878        self.hoisted_ctes.push(IrCteDef {
15879            name: cte_name.clone(),
15880            stmt: inner?,
15881            type_name: type_name.clone(),
15882            correlated_to,
15883        });
15884        Ok((cte_name, type_name))
15885    }
15886
15887    fn hoist_nested_dml(&mut self, stmt: &Stmt) -> Result<String, PyQLError> {
15888        let type_name = self.dml_subject_type(stmt)?;
15889        let inner = self.compile_stmt(stmt)?;
15890        let cte_name = self.fresh_nested_cte_name();
15891        self.pending_nested_ctes.push(IrCteDef {
15892            name: cte_name.clone(),
15893            stmt: inner,
15894            type_name,
15895            correlated_to: None,
15896        });
15897        Ok(cte_name)
15898    }
15899
15900    /// Compile `(SELECT TargetType FILTER …)` as a scalar subquery for use in a
15901    /// link assignment (`company := (SELECT Company FILTER .name = $co)`).
15902    /// Returns `IrExpr::Subquery` whose shape is the target pk — the SQL emitter
15903    /// renders this as `(SELECT "alias"."id" FROM … WHERE …)`.
15904    fn compile_link_subquery(&mut self, stmt: &Stmt) -> Result<IrExpr, PyQLError> {
15905        // `preferences := (insert Preferences { … })` — the nested DML used
15906        // directly as the value, which is the same hoist the wrapped
15907        // `select (insert …) { id }` form below goes through.
15908        if matches!(stmt, Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) {
15909            let cte_name = self.hoist_nested_dml(stmt)?;
15910            return Ok(IrExpr::ColumnRef {
15911                alias: cte_name,
15912                column: "id".to_string(),
15913                pg_type: "uuid".to_string(),
15914            });
15915        }
15916        let Stmt::Select(sel) = stmt else {
15917            return Err(self.type_err(
15918                "only SELECT is valid as a link assignment value; \
15919                 use SELECT (INSERT …) { id } to assign from a DML result",
15920            ));
15921        };
15922
15923        // `SELECT (INSERT …) { id }` / `SELECT (UPDATE …) { id }` /
15924        // `SELECT (DELETE …) { id }` — the exact workaround this function's
15925        // own error message above recommends. Postgres has no way to run a
15926        // nested INSERT/UPDATE/DELETE inside another statement's value list
15927        // without hoisting it into a `WITH` CTE first, so that's what this
15928        // does: compile the inner DML as its own statement, stash it in
15929        // `self.pending_nested_ctes` under a fresh CTE name (drained by
15930        // whichever `compile_insert`/`compile_update` is compiling the
15931        // assignment this value belongs to — see those functions' own doc
15932        // comments — and prepended as a `WITH` CTE by the emitter, which
15933        // also switches the outer statement's own row source from
15934        // `VALUES (...)` / a bare `SET` to something that can actually
15935        // reference it), and return a plain reference to that CTE's `id`
15936        // column in place of the subquery.
15937        //
15938        // A prior attempt to handle this by delegating to `compile_select`
15939        // (which does know how to chain a `dml_source`, but only for a
15940        // *top-level* `SELECT (INSERT …) { ... }` statement) compiled
15941        // without error but silently emitted a subquery that dropped the
15942        // nested INSERT and selected an unrelated, arbitrary pre-existing
15943        // row instead — do not repeat that approach.
15944        let nested_dml = match &sel.result {
15945            Expr::SubQuery(inner) if matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) => {
15946                Some(inner.as_ref())
15947            }
15948            Expr::Shape(s) => match s.expr.as_ref() {
15949                Some(Expr::SubQuery(inner))
15950                    if matches!(inner.as_ref(), Stmt::Insert(_) | Stmt::Update(_) | Stmt::Delete(_)) =>
15951                {
15952                    Some(inner.as_ref())
15953                }
15954                _ => None,
15955            },
15956            _ => None,
15957        };
15958        if let Some(inner_stmt) = nested_dml {
15959            let cte_name = self.hoist_nested_dml(inner_stmt)?;
15960            return Ok(IrExpr::ColumnRef {
15961                alias: cte_name,
15962                column: "id".to_string(),
15963                pg_type: "uuid".to_string(),
15964            });
15965        }
15966
15967        let type_name = self.expr_as_type_name(&sel.result)?;
15968        let td = self.resolve_type(&type_name)?;
15969        let alias = self.fresh_alias();
15970
15971        // `(select T filter … order by … limit 1)` — every clause decides
15972        // which row is linked, not just the filter.
15973        let (filter, order_by, offset, limit) = self.compile_path_modifiers(sel, td, &alias)?;
15974        let mut select = IrSelect::schema_bound(
15975            IrSource {
15976                poly: None,
15977                type_name: format!("{}::{}", td.module, td.name),
15978                table: td.table.clone(),
15979                alias,
15980            },
15981            Self::pk_returning(td),
15982            filter,
15983        );
15984        select.order_by = order_by;
15985        select.offset = offset;
15986        select.limit = limit;
15987        Ok(IrExpr::Subquery(Box::new(select)))
15988    }
15989
15990    /// Shared `IrSort` builder for both schema-bound (`compile_sort`) and
15991    /// free (`compile_free_select`'s order-by) contexts — direction/nulls
15992    /// translation is identical either way, only the expr compiler ctx differs.
15993    fn compile_sort_ctx(
15994        &mut self,
15995        s: &ast::SortExpr,
15996        ctx: Option<(&TypeDescriptor, &str)>,
15997    ) -> Result<IrSort, PyQLError> {
15998        Ok(IrSort {
15999            expr: self.compile_expr_ctx(&s.expr, ctx)?,
16000            direction: match s.direction {
16001                SortDirection::Asc => IrSortDir::Asc,
16002                SortDirection::Desc => IrSortDir::Desc,
16003            },
16004            nulls: match s.nones {
16005                NonesOrder::First => IrNulls::First,
16006                NonesOrder::Last => IrNulls::Last,
16007            },
16008        })
16009    }
16010
16011    fn compile_sort(&mut self, s: &ast::SortExpr, td: &TypeDescriptor, alias: &str) -> Result<IrSort, PyQLError> {
16012        self.compile_sort_ctx(s, Some((td, alias)))
16013    }
16014
16015    // ── Stdlib function resolution ────────────────────────────────────────────────
16016
16017    /// Look up `name` in the stdlib (namespace = `module` or `"std"`) and produce
16018    /// the correct `IrExpr::FunctionCall` based on the matching `ImplStrategy`.
16019    /// Falls through to a plain call if no overload is found (unknown / PG built-in).
16020    /// `message := …` of an assert handled outside the generic call route,
16021    /// which would otherwise drop it and raise the assert's own text.
16022    fn assert_message(
16023        &mut self,
16024        f: &ast::FunctionCall,
16025        ctx: Option<(&TypeDescriptor, &str)>,
16026    ) -> Result<Option<IrExpr>, PyQLError> {
16027        let mut message = None;
16028        for (name, value) in &f.kwargs {
16029            if name != "message" {
16030                return Err(self.type_err(&format!("function 'std::{}' has no parameter '{name}'", f.name)));
16031            }
16032            message = Some(self.compile_expr_ctx(value, ctx)?);
16033        }
16034        Ok(message)
16035    }
16036
16037    /// A stdlib call against its named-only parameters — `message :=` of the
16038    /// asserts, every parameter of `cal::to_relative_duration`. The arguments
16039    /// come back in parameter order, each named-only one left out standing
16040    /// at its default. `None` for a call that names nothing and needs no
16041    /// default, which the positional route handles as it always has.
16042    fn compile_named_call_args(
16043        &mut self,
16044        f: &ast::FunctionCall,
16045        ctx: Option<(&TypeDescriptor, &str)>,
16046    ) -> Result<Option<Vec<IrExpr>>, PyQLError> {
16047        use crate::stdlib::NamedDefault;
16048
16049        let ns = f.module.as_deref().unwrap_or("std");
16050        let overloads = crate::stdlib::lookup(ns, &f.name);
16051        let positional = |d: &crate::stdlib::FnDescriptor| d.params.iter().filter(|p| p.named_only.is_none()).count();
16052        let named = |d: &crate::stdlib::FnDescriptor| d.params.iter().filter(|p| p.named_only.is_some()).count();
16053        // A variadic parameter absorbs any number of the positional arguments,
16054        // so it fixes only a floor on how many a call may pass.
16055        let takes_positionally = |d: &crate::stdlib::FnDescriptor| {
16056            if d.is_variadic() {
16057                f.args.len() + 1 >= positional(d)
16058            } else {
16059                positional(d) == f.args.len()
16060            }
16061        };
16062        if f.kwargs.is_empty() {
16063            if overloads.iter().any(|d| takes_positionally(d) && named(d) == 0) {
16064                return Ok(None);
16065            }
16066            // Only when the call passed *more* positionally than an overload's
16067            // positions hold, and no more than its whole parameter list: that
16068            // is a call writing the named-only ones by position, as opposed to
16069            // one whose argument count is simply wrong.
16070            let wrote_a_named_one_positionally =
16071                |d: &crate::stdlib::FnDescriptor| positional(d) < f.args.len() && f.args.len() <= d.params.len();
16072            if !overloads.iter().any(|d| takes_positionally(d))
16073                && let Some(param) = overloads
16074                    .iter()
16075                    .filter(|d| wrote_a_named_one_positionally(d))
16076                    .flat_map(|d| d.params.iter())
16077                    .find(|p| p.named_only.is_some())
16078                    .map(|p| p.keyword())
16079            {
16080                return Err(self.type_err(&format!(
16081                    "function '{ns}::{}' takes '{param}' as a named argument only — e.g. '{param} := …'",
16082                    f.name
16083                )));
16084            }
16085        }
16086        let fits = |d: &&&crate::stdlib::FnDescriptor| {
16087            takes_positionally(d)
16088                && named(d) > 0
16089                && f.kwargs
16090                    .iter()
16091                    .all(|(name, _)| d.params.iter().any(|p| p.keyword() == name && p.named_only.is_some()))
16092        };
16093        let Some(desc) = overloads.iter().find(fits) else {
16094            if let Some((name, _)) = f.kwargs.first() {
16095                let message = if overloads.iter().all(|d| named(d) == 0) {
16096                    format!(
16097                        "function '{ns}::{}' does not take named arguments, got '{name}'",
16098                        f.name
16099                    )
16100                } else {
16101                    format!("function '{ns}::{}' has no parameter '{name}'", f.name)
16102                };
16103                return Err(self.type_err(&message));
16104            }
16105            return Ok(None);
16106        };
16107        let mut args = f
16108            .args
16109            .iter()
16110            .map(|a| self.compile_expr_ctx(a, ctx))
16111            .collect::<Result<Vec<_>, _>>()?;
16112        for param in desc.params.iter().filter(|p| p.named_only.is_some()) {
16113            let arg = match (
16114                f.kwargs.iter().find(|(name, _)| name == param.keyword()),
16115                param.named_only,
16116            ) {
16117                (Some((_, value)), _) => self.compile_expr_ctx(value, ctx)?,
16118                (None, Some(NamedDefault::Required)) => {
16119                    let keyword = param.keyword();
16120                    return Err(self.type_err(&format!(
16121                        "function '{ns}::{}' requires the named argument '{keyword}' — e.g. '{keyword} := …'",
16122                        f.name
16123                    )));
16124                }
16125                (None, Some(NamedDefault::Int(n))) => IrExpr::Literal(IrLiteral::Int(n)),
16126                (None, Some(NamedDefault::Bool(b))) => IrExpr::Literal(IrLiteral::Bool(b)),
16127                (None, Some(NamedDefault::Str(s))) => IrExpr::Literal(IrLiteral::Str(s.to_string())),
16128                (None, _) => IrExpr::Null,
16129            };
16130            args.push(arg);
16131        }
16132        Ok(Some(args))
16133    }
16134
16135    fn resolve_fn_call(&mut self, module: Option<&str>, name: &str, args: Vec<IrExpr>) -> Result<IrExpr, PyQLError> {
16136        use crate::stdlib::{ImplStrategy, lookup};
16137
16138        let ns = module.unwrap_or("std");
16139        // `any`/`all` aggregate a *set* of booleans. Everything that reaches
16140        // here is a single value — a multi-link comparison has already become
16141        // its own EXISTS — and a single boolean is its own any()/all();
16142        // `bool_or` over it would be an aggregate where SQL allows none.
16143        if ns == "std" && matches!(name, "any" | "all") && args.len() == 1 && !is_array_expr(&args[0]) {
16144            return Ok(args.into_iter().next().expect("checked by the guard"));
16145        }
16146        let overloads = lookup(ns, name);
16147
16148        // Arity is decided first and on its own, so a call no overload could
16149        // accept is never type-matched against one anyway. Named-only
16150        // parameters are already materialized into `args` by
16151        // `compile_named_call_args`, so the comparison is against the full
16152        // parameter list; a trailing variadic absorbs zero or more, matching
16153        // `pylon.stdlib._arity_matches`, the same rule the Python surface
16154        // has always applied to `std.foo(...)`.
16155        let by_arity: Vec<&crate::stdlib::FnDescriptor> = overloads
16156            .iter()
16157            .copied()
16158            .filter(|d| {
16159                if d.is_variadic() {
16160                    args.len() + 1 >= d.params.len()
16161                } else {
16162                    d.params.len() == args.len()
16163                }
16164            })
16165            .collect();
16166
16167        // Pick the overload whose parameter types best match the argument
16168        // types. An overload whose declared types are exactly what is known
16169        // of the arguments beats one that merely accepts them:
16170        // `to_str(<bytes>)` is the bytes overload, not the first one whose
16171        // parameter type no check rejects.
16172        let exact = by_arity.iter().copied().find(|d| {
16173            params_for_args(d, args.len())
16174                .zip(&args)
16175                .any(|(p, a)| p.ty.scalar_pg_type().is_some() && infer_ir_type(a).is_some())
16176                && params_for_args(d, args.len()).zip(&args).all(|(p, a)| {
16177                    match (p.ty.scalar_pg_type(), infer_ir_type(a)) {
16178                        (Some(declared), Some(known)) => declared == known,
16179                        _ => pylon_type_matches(a, &p.ty),
16180                    }
16181                })
16182        });
16183        let best = exact.or_else(|| {
16184            by_arity.iter().copied().find(|d| {
16185                params_for_args(d, args.len())
16186                    .zip(&args)
16187                    .all(|(p, a)| pylon_type_matches(a, &p.ty))
16188            })
16189        });
16190
16191        // A parameter that takes one value takes the walk's value, not the
16192        // array its rows were gathered into: `contains(ids, .<menus.id)`
16193        // compares uuids. A `set of …`/`array<…>` parameter asked for the
16194        // whole set and keeps it.
16195        let args_len = args.len();
16196        let args: Vec<IrExpr> = match best {
16197            Some(descriptor) => args
16198                .into_iter()
16199                .enumerate()
16200                .map(
16201                    |(i, arg)| match params_for_args(descriptor, args_len).nth(i).map(|p| &p.ty) {
16202                        Some(crate::stdlib::PylonType::Set(_)) | Some(crate::stdlib::PylonType::Array(_)) | None => arg,
16203                        Some(_) => set_walk_as_scalar(arg),
16204                    },
16205                )
16206                .collect(),
16207            None => args,
16208        };
16209        let args = match best {
16210            Some(descriptor) => pack_variadic_args(descriptor, args),
16211            None => args,
16212        };
16213
16214        // An `optional<…>` return still travels as its own scalar — a call
16215        // that may yield nothing (`json_get`) is typed by what it yields when
16216        // it does, or every cast and overload over it reads as untyped.
16217        let stdlib_return = best
16218            .map(|d| match &d.return_type {
16219                crate::stdlib::PylonType::Optional(inner) => inner.as_ref(),
16220                other => other,
16221            })
16222            .and_then(|ty| ty.scalar_pg_type())
16223            .map(str::to_string);
16224        let (schema, resolved_name, sql_template) = if let Some(desc) = best {
16225            match &desc.impl_strategy {
16226                ImplStrategy::SqlBuiltin(sql_name) => (None, sql_name.to_string(), None),
16227                ImplStrategy::SqlExpression(tmpl) => (None, name.to_string(), Some(tmpl.to_string())),
16228                ImplStrategy::PylonFunction(def) => (Some("_pylon".to_string()), def.name.to_string(), None),
16229                // Binary infix operator: emit as a template so sql/mod.rs's
16230                // generic FunctionCall path (which only ever calls
16231                // `schema.name(args)`) doesn't swallow the operator symbol —
16232                // previously unreachable in practice (only `std::overlaps`
16233                // used this strategy, untested, and would have silently
16234                // resolved to a nonexistent `"std".overlaps(...)` call).
16235                ImplStrategy::SqlOperator(op) if args.len() == 2 => {
16236                    (None, name.to_string(), Some(format!("($1 {op} $2)")))
16237                }
16238                ImplStrategy::TranspilerIntrinsic(intrinsic) => {
16239                    return self.compile_range_intrinsic(intrinsic, name, args);
16240                }
16241                // TranspilerIntrinsic: pass through; handled elsewhere
16242                _ => (module.map(str::to_string), name.to_string(), None),
16243            }
16244        } else {
16245            // Fall back to user-defined scalar functions. Overload
16246            // resolution is by (module, name, argument count) only — true
16247            // Postgres-style resolution by argument *type* isn't
16248            // implemented (a call whose args happen to have the right
16249            // count for the wrong-typed overload still picks that one,
16250            // relying on the forced TypeCast below rather than erroring).
16251            // Candidates are searched for one whose param count actually
16252            // matches the call — taking just the first (module, name)
16253            // match regardless of arg count used to silently pick the
16254            // wrong overload's signature (and then error on arg count)
16255            // whenever an earlier-declared overload happened to have a
16256            // different arity than the one actually being called
16257            // (confirmed live: a 2-arg call to an overload set whose
16258            // first-declared member takes 1 arg reported "expects 1
16259            // argument(s), got 2" even though a 2-arg overload existed).
16260            let candidates: Vec<&FunctionDescriptor> = self
16261                .schema
16262                .functions
16263                .iter()
16264                .filter(|f| {
16265                    let module_matches = module.map(|m| m == f.module.as_str()).unwrap_or(true);
16266                    module_matches && f.name == name && !f.return_is_object
16267                })
16268                .collect();
16269            let user_fn = candidates
16270                .iter()
16271                .find(|f| f.params.len() == args.len())
16272                .copied()
16273                .or_else(|| candidates.first().copied());
16274            if let Some(fd) = user_fn {
16275                if fd.params.len() != args.len() {
16276                    return Err(self.type_err(&format!(
16277                        "function '{}::{}' expects {} argument(s), got {}",
16278                        fd.module,
16279                        fd.name,
16280                        fd.params.len(),
16281                        args.len()
16282                    )));
16283                }
16284                let cast_args = fd
16285                    .params
16286                    .iter()
16287                    .zip(args)
16288                    .map(|(p, a)| {
16289                        IrExpr::TypeCast(Box::new(super::IrTypeCast {
16290                            expr: a,
16291                            pg_type: p.pg_type.clone(),
16292                            tuple_shape: None,
16293                        }))
16294                    })
16295                    .collect();
16296                let qualified = format!("{}::{}", fd.module, fd.name);
16297                let fn_module = fd.module.clone();
16298                let fn_name = fd.name.clone();
16299                let mut call_args: Vec<IrExpr> = cast_args;
16300                if let Some(globals) = self.globals_arg_for_call(&qualified)? {
16301                    call_args.insert(0, globals);
16302                }
16303                // A set-returning function has no single value to type, and
16304                // the declared type of an object-returning one names a type,
16305                // not a column — neither is a scalar `infer_ir_type` may
16306                // report.
16307                let return_pg_type = (!fd.return_is_set).then(|| fd.return_pg_type.clone());
16308                return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16309                    return_pg_type,
16310                    schema: Some(fn_module),
16311                    name: fn_name,
16312                    args: call_args,
16313                    sql_template: None,
16314                }));
16315            }
16316            // An object-returning function is deliberately not a candidate
16317            // above — it compiles through `try_compile_fn_object_select`, which
16318            // only runs when the call is a select's subject. Saying "does not
16319            // exist" for one that plainly does (and naming `default::` for a
16320            // call the user wrote unqualified) sent people looking for a typo
16321            // instead of at the restriction.
16322            if let Some(fd) = self.schema.functions.iter().find(|f| {
16323                let module_matches = module.map(|m| m == f.module.as_str()).unwrap_or(true);
16324                module_matches && f.name == name && f.return_is_object
16325            }) {
16326                return Err(self.type_err(&format!(
16327                    "function '{}::{}' returns objects, so it can only be the subject of a \
16328                     select (`select {}::{}(…) {{ … }}`), not part of a larger expression",
16329                    fd.module, fd.name, fd.module, fd.name
16330                )));
16331            }
16332            let qualified = match module {
16333                Some(m) => format!("{m}::{name}"),
16334                None => name.to_string(),
16335            };
16336            // The name is real and only the call is wrong — say which,
16337            // rather than resolving to whichever overload happened to be
16338            // registered first and letting PostgreSQL reject the emitted
16339            // SQL at query time with a signature the author never wrote.
16340            if !overloads.is_empty() {
16341                return Err(self.type_err(&if by_arity.is_empty() {
16342                    format!(
16343                        "function '{qualified}' takes {}, got {}",
16344                        describe_arities(&overloads),
16345                        args.len()
16346                    )
16347                } else {
16348                    format!(
16349                        "function '{qualified}' has no overload accepting ({}) — it accepts {}",
16350                        describe_args(&args),
16351                        describe_signatures(&by_arity),
16352                    )
16353                }));
16354            }
16355            return Err(self.type_err(&format!(
16356                "function '{qualified}' does not exist{}",
16357                self.suggest_function_name(ns, name)
16358            )));
16359        };
16360
16361        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16362            return_pg_type: stdlib_return,
16363            schema,
16364            name: resolved_name,
16365            args,
16366            sql_template,
16367        }))
16368    }
16369
16370    /// Resolve `std::range(...)`/`std::multirange(...)` — `TranspilerIntrinsic`
16371    /// entries with no real backing function anywhere (no `_pylon` function,
16372    /// no bare PostgreSQL builtin of that literal name). PostgreSQL has no
16373    /// single polymorphic range constructor — the concrete constructor
16374    /// (`int8range`, `numrange`, `tsrange`, `tstzrange`, `daterange`, and
16375    /// their multirange counterparts) is chosen here from the resolved
16376    /// element type of the arguments, since there's nothing generic to defer
16377    /// to at the SQL level the way `to_jsonb(x)` covers "cast to json".
16378    fn compile_range_intrinsic(&self, intrinsic: &str, name: &str, args: Vec<IrExpr>) -> Result<IrExpr, PyQLError> {
16379        match intrinsic {
16380            "range" => {
16381                // Either endpoint names the element type, since a range open
16382                // on one side carries it only on the other.
16383                let bounds = args.len().saturating_sub(3);
16384                let point_ty = args[..bounds].iter().find_map(infer_ir_type).ok_or_else(|| {
16385                    self.type_err(
16386                        "range(): cannot infer the element type from either bound — \
16387                     use an explicit cast, e.g. range(<int64>$lower, <int64>$upper)",
16388                    )
16389                })?;
16390                let ctor = range_ctor_for_pg_type(point_ty).ok_or_else(|| {
16391                    self.type_err(&format!(
16392                        "range(): unsupported element type '{point_ty}' — PostgreSQL only has native \
16393                     ranges over int64, decimal, datetime, cal::local_datetime, and cal::local_date"
16394                    ))
16395                })?;
16396                // `inc_lower`/`inc_upper`/`empty` are named-only and so always
16397                // present, defaults included: four arguments is the form that
16398                // gave only a lower bound, five the one that gave both.
16399                let upper = match args.len() {
16400                    4 => "NULL",
16401                    5 => "$2",
16402                    n => return Err(self.type_err(&format!("range(): unexpected argument count {n}"))),
16403                };
16404                let flags: Vec<Option<bool>> = args[args.len() - 3..].iter().map(bool_literal).collect();
16405                // Written out or defaulted, the three are constants at nearly
16406                // every call site, and folding them keeps a plain `range(a, b)`
16407                // emitting a plain constructor instead of a CASE over literals.
16408                let sql_template = match flags[..] {
16409                    [Some(_), Some(_), Some(true)] => format!("'empty'::{ctor}"),
16410                    [Some(true), Some(false), Some(false)] => format!("{ctor}($1, {upper})"),
16411                    [Some(inc_lower), Some(inc_upper), Some(false)] => format!(
16412                        "{ctor}($1, {upper}, '{}{}')",
16413                        if inc_lower { '[' } else { '(' },
16414                        if inc_upper { ']' } else { ')' },
16415                    ),
16416                    _ => {
16417                        let (lower_inc, upper_inc, empty) = match args.len() {
16418                            4 => ("$2", "$3", "$4"),
16419                            _ => ("$3", "$4", "$5"),
16420                        };
16421                        format!(
16422                            "(CASE WHEN {empty} THEN 'empty'::{ctor} ELSE {ctor}($1, {upper}, \
16423                             (CASE WHEN {lower_inc} THEN '[' ELSE '(' END) \
16424                             || (CASE WHEN {upper_inc} THEN ']' ELSE ')' END)) END)"
16425                        )
16426                    }
16427                };
16428                // `.name` carries the *resolved* constructor (not the
16429                // original `range`) so a wrapping `multirange([range(...)])`
16430                // call can identify the element family — emission always
16431                // goes through `sql_template` above, so this doesn't change
16432                // the SQL text.
16433                // A PostgreSQL range constructor shares its name with the
16434                // type it builds (`int8range(…)` is an `int8range`), so the
16435                // ctor doubles as the return type — which is what lets a
16436                // range reach a `range<anypoint>` parameter as itself rather
16437                // than as an untyped expression.
16438                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16439                    return_pg_type: Some(ctor.to_string()),
16440                    schema: None,
16441                    name: ctor.to_string(),
16442                    args,
16443                    sql_template: Some(sql_template),
16444                }))
16445            }
16446            "multirange" => {
16447                let Some(IrExpr::Array(elems)) = args.first() else {
16448                    return Err(self.type_err("multirange(): argument must be an array literal of ranges"));
16449                };
16450                let first_ctor = elems
16451                    .first()
16452                    .and_then(|e| match e {
16453                        IrExpr::FunctionCall(fc) => Some(fc.name.as_str()),
16454                        _ => None,
16455                    })
16456                    .ok_or_else(|| {
16457                        self.type_err(
16458                            "multirange(): cannot infer the element range type from an empty or non-range \
16459                     array — pass at least one range(...) call, e.g. multirange([range(1, 3)])",
16460                        )
16461                    })?;
16462                let ctor = multirange_ctor_for_range_ctor(first_ctor).ok_or_else(|| {
16463                    self.type_err(&format!("multirange(): unrecognized range constructor '{first_ctor}'"))
16464                })?;
16465                // PostgreSQL's multirange constructors (int8multirange, etc.)
16466                // are VARIADIC — they don't accept a plain array argument
16467                // without the VARIADIC keyword.
16468                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16469                    return_pg_type: Some(ctor.to_string()),
16470                    schema: None,
16471                    name: name.to_string(),
16472                    args,
16473                    sql_template: Some(format!("{ctor}(VARIADIC $1)")),
16474                }))
16475            }
16476            other => Err(self.type_err(&format!("internal error: unhandled TranspilerIntrinsic '{other}'"))),
16477        }
16478    }
16479
16480    // ── Sequence function helpers ──────────────────────────────────────────────────
16481
16482    /// Compile `sequence_next(SeqType)` → `nextval('"module"."Name_seq"')`
16483    /// and `sequence_reset(SeqType[, val])` → `setval(...)`.
16484    fn compile_sequence_fn(&mut self, fc: &ast::FunctionCall) -> Result<IrExpr, PyQLError> {
16485        let (module, scalar_name) = self.resolve_sequence_scalar_arg(fc)?;
16486
16487        if fc.name == "sequence_next" {
16488            if fc.args.len() != 1 {
16489                return Err(self.type_err("sequence_next takes exactly 1 argument"));
16490            }
16491            let sql = format!("nextval('\"{}\".\"{}_seq\"')", module, scalar_name);
16492            return Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16493                return_pg_type: None,
16494                schema: None,
16495                name: "nextval".into(),
16496                args: vec![],
16497                sql_template: Some(sql),
16498            }));
16499        }
16500
16501        // sequence_reset
16502        match fc.args.len() {
16503            1 => {
16504                let sql = format!("setval('\"{}\".\"{}_seq\"', 1, false)", module, scalar_name);
16505                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16506                    return_pg_type: None,
16507                    schema: None,
16508                    name: "setval".into(),
16509                    args: vec![],
16510                    sql_template: Some(sql),
16511                }))
16512            }
16513            2 => {
16514                let val = self.compile_free_expr(&fc.args[1])?;
16515                let sql = format!("setval('\"{}\".\"{}_seq\"', $1, true)", module, scalar_name);
16516                Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16517                    return_pg_type: None,
16518                    schema: None,
16519                    name: "setval".into(),
16520                    args: vec![val],
16521                    sql_template: Some(sql),
16522                }))
16523            }
16524            _ => Err(self.type_err("sequence_reset takes 1 or 2 arguments")),
16525        }
16526    }
16527
16528    /// Resolve the first argument of a sequence function to `(module, scalar_name)`.
16529    /// The argument must be an unqualified path that names a sequence scalar in the schema.
16530    fn resolve_sequence_scalar_arg(&self, fc: &ast::FunctionCall) -> Result<(String, String), PyQLError> {
16531        use crate::parse::ast::{Expr, Path, PathStep};
16532
16533        let arg = fc.args.first().ok_or_else(|| {
16534            self.type_err(&format!(
16535                "{}() requires a sequence scalar type as its first argument",
16536                fc.name
16537            ))
16538        })?;
16539
16540        // The parser encodes `module::Name` as a single PathStep::Name("module::Name"),
16541        // so we split on "::" here to recover the module part.
16542        let (arg_module, arg_name): (Option<&str>, &str) = match arg {
16543            Expr::Path(Path { steps, partial: false }) => match steps.as_slice() {
16544                [PathStep::Name(s)] => {
16545                    if let Some((m, n)) = s.split_once("::") {
16546                        (Some(m), n)
16547                    } else {
16548                        (None, s.as_str())
16549                    }
16550                }
16551                _ => return Err(self.type_err(&format!(
16552                    "{}(): first argument must be a sequence scalar type name (e.g. OrderNumber or default::OrderNumber)",
16553                    fc.name
16554                ))),
16555            },
16556            _ => return Err(self.type_err(&format!(
16557                "{}(): first argument must be a sequence scalar type name (e.g. OrderNumber or default::OrderNumber)",
16558                fc.name
16559            ))),
16560        };
16561
16562        let scalar = self.schema.scalars.iter().find(|s| {
16563            s.is_sequence && s.name == arg_name && arg_module.map(|m| m == s.module.as_str()).unwrap_or(true)
16564        });
16565
16566        match scalar {
16567            Some(s) => Ok((s.module.clone(), s.name.clone())),
16568            None => Err(self.type_err(&format!(
16569                "{}(): '{}' is not a known sequence scalar type",
16570                fc.name, arg_name
16571            ))),
16572        }
16573    }
16574
16575    // ── Channel notify() helpers ────────────────────────────────────────────────
16576
16577    /// PostgreSQL's hard per-NOTIFY-payload limit (`NOTIFY_PAYLOAD_MAX_LENGTH`
16578    /// in the Postgres source) — a payload at or over this is rejected by the
16579    /// server at runtime, always, for every session. Checked here only when
16580    /// the payload is a literal string (the one case actually decidable at
16581    /// compile time — see `static_min_payload_bytes` for how much of an
16582    /// arbitrary payload expression can be sized without running it.
16583    const NOTIFY_PAYLOAD_MAX_BYTES: usize = 8000;
16584
16585    /// The rejection every non-object payload for a Type channel shares.
16586    fn notify_type_payload_err(&self, channel: &str, qname: &str) -> PyQLError {
16587        self.type_err(&format!(
16588            "notify(): payload for Channel '{channel}' (a '{qname}' object channel) must name an object of that \
16589             type — either a with-block binding, or __new__/__old__ inside a trigger handler"
16590        ))
16591    }
16592
16593    /// Compile `notify(Channel, payload)` → `pg_notify('<wire_name>', (<payload>)::text)`.
16594    /// The payload's required shape depends on the Channel's declared kind:
16595    /// - `Type(qname)`: payload must be the bare `__new__`/`__old__` trigger
16596    ///   anchor for that exact type — sends its `.id`, not the whole row (see
16597    ///   this function's own restriction: a general object-typed expression
16598    ///   isn't supported yet, only the trigger-anchor case).
16599    /// - `Scalar(pg_type)`: payload is any expression, best-effort type-checked.
16600    /// - `Object(fields)`: payload must be a free object literal (`{ a := .., b := .. }`)
16601    ///   whose field names exactly match the declared shape.
16602    fn compile_notify(
16603        &mut self,
16604        fc: &ast::FunctionCall,
16605        ctx: Option<(&TypeDescriptor, &str)>,
16606    ) -> Result<IrExpr, PyQLError> {
16607        use crate::parse::ast::{Expr, Path, PathStep};
16608
16609        if fc.args.len() != 2 {
16610            return Err(self.type_err("notify() takes exactly 2 arguments: (Channel, payload)"));
16611        }
16612
16613        let (channel_module, channel_name): (Option<&str>, &str) = match &fc.args[0] {
16614            Expr::Path(Path { steps, partial: false }) => match steps.as_slice() {
16615                [PathStep::Name(s)] => {
16616                    if let Some((m, n)) = s.split_once("::") {
16617                        (Some(m), n)
16618                    } else {
16619                        (None, s.as_str())
16620                    }
16621                }
16622                _ => {
16623                    return Err(self.type_err(
16624                        "notify(): first argument must be a Channel name (e.g. OrderEvents or orders::OrderEvents)",
16625                    ));
16626                }
16627            },
16628            _ => {
16629                return Err(self.type_err(
16630                    "notify(): first argument must be a Channel name (e.g. OrderEvents or orders::OrderEvents)",
16631                ));
16632            }
16633        };
16634        let full_channel_name = match channel_module {
16635            Some(m) => format!("{m}::{channel_name}"),
16636            None => channel_name.to_string(),
16637        };
16638        let channel = self
16639            .resolve_channel(&full_channel_name)
16640            .ok_or_else(|| self.type_err(&format!("notify(): '{channel_name}' is not a known Channel")))?;
16641        let wire_name = channel.wire_name.clone();
16642        let payload_arg = &fc.args[1];
16643
16644        // A bare `select notify(...)` trigger handler has no type at its
16645        // root, so the top-level statement compiles as a *free* select
16646        // (ctx=None) even though `special_anchors` is populated — `__new__`/
16647        // `__old__` only resolve through `compile_path`, which is only ever
16648        // reached when ctx is `Some(..)` (see `Expr::Path`'s arm above).
16649        // Fall back to any bound anchor's (td, alias) as a stand-in ctx so a
16650        // Scalar/Object payload like `__new__.name` or `{ a := __new__.x }`
16651        // still resolves — `compile_path`'s own `__new__`/`__old__` branch
16652        // ignores whatever td/alias ctx carries for those two names anyway,
16653        // it only matters for a real property access on some other root.
16654        // Cloned out of `special_anchors` (rather than borrowed) so it
16655        // doesn't hold an immutable borrow of `self` across the `&mut self`
16656        // `compile_expr_ctx` calls below.
16657        let anchor_fallback: Option<(&'a TypeDescriptor, String)> = self.special_anchors.values().next().cloned();
16658        let ctx = ctx.or_else(|| anchor_fallback.as_ref().map(|(td, alias)| (*td, alias.as_str())));
16659
16660        let payload_ir = match &channel.payload {
16661            crate::schema::ChannelPayload::Type(qname) => {
16662                // The payload for an object channel is the object's `id`, in
16663                // either of the two places one can be named: the `__new__`/
16664                // `__old__` anchor a trigger handler runs against, or a
16665                // with-block binding in an ordinary query — which is what
16666                // lets a notify compose with the mutation that caused it:
16667                //
16668                //   with updated := (update User filter ... set { ... }),
16669                //   select (notify(UserUpdates, updated), updated)
16670                let Expr::Path(Path { steps, partial: false }) = payload_arg else {
16671                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
16672                };
16673                let [PathStep::Name(name)] = steps.as_slice() else {
16674                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
16675                };
16676
16677                if name == "__new__" || name == "__old__" {
16678                    let (anchor_td, alias) = self.special_anchors.get(name.as_str()).cloned().ok_or_else(|| {
16679                        self.type_err(&format!(
16680                            "notify(): '{name}' cannot be used here — it's only bound inside a trigger handler"
16681                        ))
16682                    })?;
16683                    let anchor_qname = format!("{}::{}", anchor_td.module, anchor_td.name);
16684                    if &anchor_qname != qname {
16685                        return Err(self.type_err(&format!(
16686                            "notify(): Channel '{full_channel_name}' expects a payload of type '{qname}', got '{anchor_qname}'"
16687                        )));
16688                    }
16689                    IrExpr::ColumnRef {
16690                        alias,
16691                        column: "id".to_string(),
16692                        pg_type: "uuid".to_string(),
16693                    }
16694                } else if let Some(cte_type) = self.cte_types.get(name.as_str()).cloned() {
16695                    if &cte_type != qname {
16696                        return Err(self.type_err(&format!(
16697                            "notify(): Channel '{full_channel_name}' expects a payload of type '{qname}', got '{cte_type}'"
16698                        )));
16699                    }
16700                    // `CteRef { scalar: false }` emits `(SELECT "id" FROM
16701                    // "<cte>")` — the same id the trigger path sends.
16702                    IrExpr::CteRef {
16703                        name: name.clone(),
16704                        scalar: false,
16705                        pg_type: None,
16706                    }
16707                } else {
16708                    return Err(self.notify_type_payload_err(&full_channel_name, qname));
16709                }
16710            }
16711            crate::schema::ChannelPayload::Scalar(pg_type) => {
16712                let ir = self.compile_expr_ctx(payload_arg, ctx)?;
16713                if let Some(actual) = infer_ir_type(&ir)
16714                    && !types_compatible(actual, pg_type)
16715                {
16716                    return Err(self.type_err(&format!(
16717                            "notify(): Channel '{full_channel_name}' expects a payload of type '{expected}', got '{actual_pyql}'",
16718                            expected = pg_type_to_pyql(pg_type),
16719                            actual_pyql = pg_type_to_pyql(actual),
16720                        )));
16721                }
16722                ir
16723            }
16724            crate::schema::ChannelPayload::Object(declared_fields) => {
16725                let Expr::Shape(sh) = payload_arg else {
16726                    return Err(self.type_err(&format!(
16727                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free \
16728                         object literal, e.g. {{ {} }}",
16729                        declared_fields
16730                            .iter()
16731                            .map(|(n, _)| format!("{n} := .."))
16732                            .collect::<Vec<_>>()
16733                            .join(", ")
16734                    )));
16735                };
16736                if sh.expr.is_some() {
16737                    return Err(self.type_err(&format!(
16738                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free \
16739                         object literal, not a shape over a type"
16740                    )));
16741                }
16742                let ir = self.compile_expr_ctx(payload_arg, ctx)?;
16743                let IrExpr::NamedTuple {
16744                    fields,
16745                    is_free_object: true,
16746                } = &ir
16747                else {
16748                    return Err(self.type_err(&format!(
16749                        "notify(): payload for Channel '{full_channel_name}' (an Object channel) must be a free object literal"
16750                    )));
16751                };
16752                let declared_names: std::collections::HashSet<&str> =
16753                    declared_fields.iter().map(|(n, _)| n.as_str()).collect();
16754                let actual_names: std::collections::HashSet<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
16755                if declared_names != actual_names {
16756                    let mut expected: Vec<&str> = declared_names.iter().copied().collect();
16757                    expected.sort();
16758                    let mut actual: Vec<&str> = actual_names.iter().copied().collect();
16759                    actual.sort();
16760                    return Err(self.type_err(&format!(
16761                        "notify(): payload fields for Channel '{full_channel_name}' don't match — expected {{{}}}, got {{{}}}",
16762                        expected.join(", "), actual.join(", ")
16763                    )));
16764                }
16765                for (name, expr) in fields {
16766                    let Some((_, declared_pg_type)) = declared_fields.iter().find(|(n, _)| n == name) else {
16767                        continue;
16768                    };
16769                    if let Some(actual) = infer_ir_type(expr)
16770                        && !types_compatible(actual, declared_pg_type)
16771                    {
16772                        return Err(self.type_err(&format!(
16773                                "notify(): field '{name}' of Channel '{full_channel_name}' expects type '{expected}', got '{actual_pyql}'",
16774                                expected = pg_type_to_pyql(declared_pg_type),
16775                                actual_pyql = pg_type_to_pyql(actual),
16776                            )));
16777                    }
16778                }
16779                ir
16780            }
16781        };
16782
16783        let floor = static_min_payload_bytes(payload_arg);
16784        if floor >= Self::NOTIFY_PAYLOAD_MAX_BYTES {
16785            return Err(self.type_err(&format!(
16786                "notify(): this payload is at least {floor} bytes, which is at or over PostgreSQL's {}-byte NOTIFY \
16787                 payload limit — the notification would fail at runtime, aborting the transaction that sent it",
16788                Self::NOTIFY_PAYLOAD_MAX_BYTES
16789            )));
16790        }
16791
16792        let sql = format!("pg_notify('{}', ($1)::text)", wire_name.replace('\'', "''"));
16793        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16794            return_pg_type: None,
16795            schema: None,
16796            name: "pg_notify".to_string(),
16797            args: vec![payload_ir],
16798            sql_template: Some(sql),
16799        }))
16800    }
16801
16802    /// Compile `notify_raw(channel_name, payload)` → `pg_notify($1, $2)` — the
16803    /// escape hatch that bypasses Channel resolution and payload-shape
16804    /// checking entirely: both arguments are arbitrary text expressions.
16805    fn compile_notify_raw(
16806        &mut self,
16807        fc: &ast::FunctionCall,
16808        ctx: Option<(&TypeDescriptor, &str)>,
16809    ) -> Result<IrExpr, PyQLError> {
16810        if fc.args.len() != 2 {
16811            return Err(self.type_err("notify_raw() takes exactly 2 arguments: (channel_name, payload)"));
16812        }
16813        let channel_ir = self.compile_expr_ctx(&fc.args[0], ctx)?;
16814        let payload_ir = self.compile_expr_ctx(&fc.args[1], ctx)?;
16815
16816        let floor = static_min_payload_bytes(&fc.args[1]);
16817        if floor >= Self::NOTIFY_PAYLOAD_MAX_BYTES {
16818            return Err(self.type_err(&format!(
16819                "notify_raw(): this payload is at least {floor} bytes, which is at or over PostgreSQL's {}-byte \
16820                 NOTIFY payload limit",
16821                Self::NOTIFY_PAYLOAD_MAX_BYTES
16822            )));
16823        }
16824
16825        Ok(IrExpr::FunctionCall(super::IrFunctionCall {
16826            return_pg_type: None,
16827            schema: None,
16828            name: "pg_notify".to_string(),
16829            args: vec![channel_ir, payload_ir],
16830            sql_template: None,
16831        }))
16832    }
16833
16834    /// The `GLOBALS_ARG` value to pass when calling `qualified`, or `None` if
16835    /// that function does not take one.
16836    ///
16837    /// Inside a function body the caller's own argument is forwarded verbatim;
16838    /// at the top level every session global is packed, rather than just the
16839    /// ones the callee happens to read. Packing the whole set costs one small
16840    /// jsonb per call and keeps the caller from having to know anything about
16841    /// the callee's body.
16842    fn globals_arg_for_call(&mut self, qualified: &str) -> Result<Option<IrExpr>, PyQLError> {
16843        if self.fns_needing_globals.is_none() {
16844            self.fns_needing_globals = Some(functions_needing_globals(self.schema).clone());
16845        }
16846        if !self.fns_needing_globals.as_ref().is_some_and(|s| s.contains(qualified)) {
16847            return Ok(None);
16848        }
16849        if self.in_fn_body {
16850            self.used_globals_arg = true;
16851            return Ok(Some(IrExpr::RawSql(GLOBALS_ARG.to_string())));
16852        }
16853        let session_globals: Vec<String> = self
16854            .schema
16855            .globals
16856            .iter()
16857            .filter(|g| g.computed_expr.is_none())
16858            .map(|g| format!("{}::{}", g.module, g.name))
16859            .collect();
16860        let mut args = Vec::with_capacity(session_globals.len() * 2);
16861        for name in session_globals {
16862            let value = self.compile_global(&name)?;
16863            args.push(IrExpr::Literal(IrLiteral::Str(name)));
16864            args.push(value);
16865        }
16866        Ok(Some(IrExpr::FunctionCall(super::IrFunctionCall {
16867            return_pg_type: None,
16868            schema: None,
16869            name: "jsonb_build_object".to_string(),
16870            args,
16871            sql_template: None,
16872        })))
16873    }
16874
16875    // ── User-defined function helpers ─────────────────────────────────────────────
16876
16877    /// Try to compile `fn(args) { shape }` as a `FunctionSelect` for an object-returning
16878    /// user function.  Returns `None` if no matching user function exists (so the caller
16879    /// can fall through to other dispatch paths).
16880    fn try_compile_fn_object_select(
16881        &mut self,
16882        fc: &ast::FunctionCall,
16883        elements: &[ast::ShapeElement],
16884        s: &ast::SelectStmt,
16885        distinct: bool,
16886    ) -> Result<Option<IrFunctionSelect>, PyQLError> {
16887        use crate::schema::FunctionDescriptor;
16888
16889        let fd: Option<&FunctionDescriptor> = self.schema.functions.iter().find(|f| {
16890            let module_matches = fc.module.as_deref().map(|m| m == f.module.as_str()).unwrap_or(true);
16891            module_matches && f.name == fc.name && f.return_is_object
16892        });
16893        let fd = match fd {
16894            Some(f) => f,
16895            None => return Ok(None),
16896        };
16897        if fd.params.len() != fc.args.len() {
16898            return Err(self.type_err(&format!(
16899                "function '{}::{}' expects {} argument(s), got {}",
16900                fd.module,
16901                fd.name,
16902                fd.params.len(),
16903                fc.args.len()
16904            )));
16905        }
16906
16907        let fn_module = fd.module.clone();
16908        let fn_name = fd.name.clone();
16909        let return_type_name = fd.return_pg_type.clone(); // qualified type name for object returns
16910        let polymorphic = fd.return_is_polymorphic;
16911
16912        let mut fn_args = fc
16913            .args
16914            .iter()
16915            .map(|a| self.compile_free_expr(a))
16916            .collect::<Result<Vec<_>, _>>()?;
16917        let qualified = format!("{}::{}", fn_module, fn_name);
16918        if let Some(globals) = self.globals_arg_for_call(&qualified)? {
16919            fn_args.insert(0, globals);
16920        }
16921
16922        let alias = self.fresh_alias();
16923
16924        // Resolve the return type to build the shape.
16925        let td = self.resolve_type(&return_type_name)?;
16926        let td = td.clone();
16927
16928        let (poly_implementors, poly_columns) = if polymorphic {
16929            self.collect_poly_info(&return_type_name)
16930        } else {
16931            (vec![], vec![])
16932        };
16933
16934        let td_module = td.module.clone();
16935        // Build shape against the return type (treat alias as the source alias).
16936        let shape = self.compile_shape(elements, &td, &alias, &td_module)?;
16937        // The shape's own computeds are in scope for the select's clauses,
16938        // the same as over a type -- added only now, so one pointer still
16939        // cannot read another.
16940        let outer_declared = self.active_declared_pointers.clone();
16941        self.active_declared_pointers
16942            .extend(elements.iter().filter(|el| el.compexpr.is_some()).cloned());
16943        let modifiers = self.compile_path_modifiers(s, &td, &alias);
16944        self.active_declared_pointers = outer_declared;
16945        let (filter, order_by, offset, limit) = modifiers?;
16946
16947        Ok(Some(IrFunctionSelect {
16948            fn_module,
16949            fn_name,
16950            fn_args,
16951            alias,
16952            type_name: return_type_name,
16953            polymorphic,
16954            poly_implementors,
16955            poly_columns,
16956            shape,
16957            filter,
16958            order_by,
16959            offset,
16960            limit,
16961            distinct,
16962        }))
16963    }
16964
16965    /// The interface's own physical columns (properties + `{link}_id`) — the
16966    /// subset every concrete implementor table is guaranteed to share, safe
16967    /// to RETURNING/SELECT uniformly across a poly_implementors fan-out.
16968    /// Shared by compile_select (read path) and the IrUpdate/IrDelete
16969    /// builders (write path) so a DML-as-CTE fan-out (sql/mod.rs) can expose
16970    /// the same columns a polymorphic select would.
16971    /// Point a shape's single-link sub-selects at the nested-DML CTE that
16972    /// supplied their foreign key.
16973    ///
16974    /// `insert Account { credentials := (insert Credentials { … }) } { ** }`
16975    /// runs the nested insert as a data-modifying CTE, and Postgres does not
16976    /// show one statement's CTE writes to the rest of that statement: reading
16977    /// `access."Credentials"` back finds nothing, so the link hydrated as
16978    /// `None` even though the row was there on the next statement (confirmed
16979    /// live). The CTE itself is in scope, and `RETURNING *` gives it every
16980    /// column the shape asks for, so the sub-select reads that instead.
16981    fn read_nested_links_from_their_ctes(dml: &IrStmt, dml_cte: Option<&str>, shape: &mut [IrShapePointer]) {
16982        let (assignments, nested_ctes, appends) = match dml {
16983            IrStmt::Insert(ins) => (&ins.assignments, &ins.nested_ctes, &ins.multi_link_appends),
16984            IrStmt::Update(upd) => (&upd.assignments, &upd.nested_ctes, &upd.multi_link_appends),
16985            _ => return,
16986        };
16987        // A multi-link's junction rows are written by this statement too, in
16988        // a CTE the emitter names after the DML's own — see
16989        // `emit_insert_multilink_ctes`. The targets themselves come from the
16990        // append's value source, which for a nested insert is one of
16991        // `nested_ctes`.
16992        if let Some(dml_cte) = dml_cte {
16993            for pointer in shape.iter_mut() {
16994                let IrShapePointer::MultiLink(link) = pointer else {
16995                    continue;
16996                };
16997                let IrMultiLinkJoin::Standard { junction_table, .. } = &mut link.join else {
16998                    continue;
16999                };
17000                let Some(index) = appends.iter().position(|a| a.junction_table == *junction_table) else {
17001                    continue;
17002                };
17003                *junction_table = format!("@cte:{dml_cte}__ml_add_{index}");
17004                if let IrMultiLinkValueSource::CteRef(target_cte) = &appends[index].values.source {
17005                    for row in &mut link.subquery.rows {
17006                        if let IrRowSource::Bound { source, .. } = row {
17007                            source.table = format!("@cte:{target_cte}");
17008                            source.poly = None;
17009                        }
17010                    }
17011                }
17012            }
17013        }
17014        if nested_ctes.is_empty() {
17015            return;
17016        }
17017        let from_cte: HashMap<&str, &str> = assignments
17018            .iter()
17019            .filter_map(|(column, expr)| match expr {
17020                IrExpr::ColumnRef { alias, column: c, .. }
17021                    if c == "id" && nested_ctes.iter().any(|cte| cte.name == *alias) =>
17022                {
17023                    Some((column.as_str(), alias.as_str()))
17024                }
17025                _ => None,
17026            })
17027            .collect();
17028        if from_cte.is_empty() {
17029            return;
17030        }
17031        for pointer in shape {
17032            let IrShapePointer::SingleLink(link) = pointer else {
17033                continue;
17034            };
17035            let IrSingleLinkCorrelation::Fk { fk_column, .. } = &link.correlation else {
17036                continue;
17037            };
17038            let Some(cte_name) = from_cte.get(fk_column.as_str()) else {
17039                continue;
17040            };
17041            for row in &mut link.subquery.rows {
17042                if let IrRowSource::Bound { source, .. } = row {
17043                    source.table = format!("@cte:{cte_name}");
17044                    source.poly = None;
17045                }
17046            }
17047        }
17048    }
17049
17050    fn poly_dml_columns(td: &TypeDescriptor) -> Vec<String> {
17051        td.properties
17052            .iter()
17053            .map(|p| p.name.clone())
17054            .chain(
17055                td.links
17056                    .iter()
17057                    .filter(|l| !l.is_junction_backed())
17058                    .map(|l| format!("{}_id", l.name)),
17059            )
17060            .collect()
17061    }
17062
17063    /// A nested SELECT over a link's target, expanded inline over the
17064    /// interface's implementors when the target is one.
17065    ///
17066    /// An interface is materialised as a view that carries only the
17067    /// interface's own columns -- no discriminator -- so reading a link
17068    /// through it tags every row as the interface itself and hydrates the
17069    /// interface class rather than the concrete one. Expanding the
17070    /// implementors inline is what the root of a query already does, and each
17071    /// branch supplies its own `__type__`.
17072    fn link_target_select(
17073        &self,
17074        target_td: &TypeDescriptor,
17075        mut source: IrSource,
17076        shape: Vec<IrShapePointer>,
17077    ) -> IrSelect {
17078        source.poly = self.link_target_fanout(target_td);
17079        IrSelect::schema_bound(source, shape, None)
17080    }
17081
17082    /// The fan-out a link's target needs, for the builders that assemble their
17083    /// own source rather than going through `link_target_select`.
17084    fn link_target_fanout(&self, target_td: &TypeDescriptor) -> Option<IrPolyFanout> {
17085        self.poly_fanout_for(&format!("{}::{}", target_td.module, target_td.name))
17086    }
17087
17088    /// Give every interface-typed join target its fan-out, so a traversal that
17089    /// lands on one reads the implementors and carries the real type rather
17090    /// than the interface's view, which has no discriminator to carry.
17091    ///
17092    /// The root is left alone: a path select fans its own root out through
17093    /// `poly_implementors` already, and doing it twice would nest the union.
17094    fn resolve_join_fanouts(&self, path_select: &mut IrPathSelect) {
17095        for join in &mut path_select.joins {
17096            let target = match join {
17097                IrPathJoin::Single { target, .. }
17098                | IrPathJoin::Multi { target, .. }
17099                | IrPathJoin::BacklinkSingle { target, .. }
17100                | IrPathJoin::BacklinkMulti { target, .. }
17101                | IrPathJoin::Function { target, .. }
17102                | IrPathJoin::Lateral { target, .. } => target,
17103            };
17104            if target.poly.is_none() && !target.table.starts_with("@cte:") {
17105                target.poly = self.poly_fanout_for(&target.type_name.clone());
17106            }
17107        }
17108    }
17109
17110    /// The fan-out a source of `type_name` needs, or `None` when it is not an
17111    /// interface and so reads from a table of its own. See `IrSource::poly`.
17112    fn poly_fanout_for(&self, type_name: &str) -> Option<IrPolyFanout> {
17113        let td = self
17114            .schema
17115            .types
17116            .iter()
17117            .find(|t| format!("{}::{}", t.module, t.name) == type_name)?;
17118        // A materialised interface has a view to read; a plain abstract has no
17119        // relation at all, so reading its table name finds nothing. Both are
17120        // answered by the union of the types that carry the columns.
17121        if !td.abstract_ && !self.has_subtypes(td) {
17122            return None;
17123        }
17124        let (implementors, columns) = self.collect_poly_info(type_name);
17125        Some(IrPolyFanout { implementors, columns })
17126    }
17127
17128    /// Collect poly_implementors and poly_columns for a polymorphic return type.
17129    fn collect_poly_info(&self, type_name: &str) -> (Vec<IrPolyImplementor>, Vec<String>) {
17130        let implementors = self.find_poly_implementors(type_name);
17131        let columns = if let Some(td) = self
17132            .schema
17133            .types
17134            .iter()
17135            .find(|t| format!("{}::{}", t.module, t.name) == type_name)
17136        {
17137            // The same set the DML path fans out (`poly_dml_columns`): the
17138            // interface's single-link FKs belong in it too, or reading
17139            // `.profile` off an interface-typed source finds no
17140            // `profile_id` column in the union it was fanned out into.
17141            Self::poly_dml_columns(td)
17142        } else {
17143            vec![]
17144        };
17145        (implementors, columns)
17146    }
17147
17148    // ── vector::search ────────────────────────────────────────────────────────────
17149
17150    /// Try to compile `vector::search` from a function-call AST node.
17151    ///
17152    /// Two overloads are supported:
17153    /// - `vector::search(TypeName, $vec [, index_name := '…'])` — pre-computed vector
17154    /// - `vector::search(TypeName, query := $text [, index_name := '…'])` — text overload;
17155    ///   the Python layer embeds the text and injects `__deferred_vec__` before execution.
17156    ///
17157    /// Returns `None` if the call is not `vector::search`.
17158    fn try_compile_vector_search(
17159        &mut self,
17160        fc: &ast::FunctionCall,
17161        elements: &[ast::ShapeElement],
17162        s: &ast::SelectStmt,
17163    ) -> Result<Option<IrVectorSearch>, PyQLError> {
17164        if fc.module.as_deref() != Some("vector") || fc.name != "search" {
17165            return Ok(None);
17166        }
17167
17168        // Detect text overload: `query :=` kwarg present (no positional second arg needed).
17169        let text_query_arg = fc.kwargs.iter().find(|(k, _)| k == "query").map(|(_, v)| v);
17170        let is_text_overload = text_query_arg.is_some();
17171
17172        if !is_text_overload && fc.args.len() < 2 {
17173            return Err(
17174                self.type_err("vector::search requires either a positional vector argument or `query := $text`")
17175            );
17176        }
17177
17178        // First argument: a type name or a filtered subquery narrowing the candidate set.
17179        //   - Bare name:     `Product` or `default::Product`
17180        //   - Subquery:      `(select Product filter .price < 100)`
17181        //
17182        // For the subquery form we store the raw inner filter AST and compile it below,
17183        // after the source alias is generated, so property column refs use the right alias.
17184        let (type_qname, inner_filter_ast): (String, Option<ast::Expr>) = match &fc.args[0] {
17185            ast::Expr::Path(p) if !p.partial => {
17186                let name = p.steps.iter()
17187                    .filter_map(|s| if let ast::PathStep::Name(n) = s { Some(n.as_str()) } else { None })
17188                    .collect::<Vec<_>>().join("::");
17189                let td = self.resolve_type(&name)
17190                    .map_err(|_| self.type_err(&format!("vector::search: '{}' is not a known type", name)))?;
17191                (format!("{}::{}", td.module, td.name), None)
17192            }
17193            ast::Expr::SubQuery(stmt) => {
17194                if let ast::Stmt::Select(inner_sel) = stmt.as_ref() {
17195                    let inner_type_name = match &inner_sel.result {
17196                        ast::Expr::Path(p) if !p.partial => {
17197                            p.steps.iter()
17198                                .filter_map(|s| if let ast::PathStep::Name(n) = s { Some(n.as_str()) } else { None })
17199                                .collect::<Vec<_>>().join("::")
17200                        }
17201                        _ => return Err(self.type_err(
17202                            "vector::search: subquery first argument must select a single type (e.g. select Product filter …)"
17203                        )),
17204                    };
17205                    let td = self.resolve_type(&inner_type_name)
17206                        .map_err(|_| self.type_err(&format!("vector::search: '{}' is not a known type", inner_type_name)))?;
17207                    let qname = format!("{}::{}", td.module, td.name);
17208                    (qname, inner_sel.filter.clone())
17209                } else {
17210                    return Err(self.type_err("vector::search: subquery first argument must be a SELECT"));
17211                }
17212            }
17213            _ => return Err(self.type_err(
17214                "vector::search: first argument must be a type name or a filtered subquery (e.g. select Product filter …)"
17215            )),
17216        };
17217
17218        // Optional named argument: index_name := '…'
17219        let index_name: Option<String> = fc.kwargs.iter().find(|(k, _)| k == "index_name").and_then(|(_, v)| {
17220            if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
17221                Some(s.clone())
17222            } else {
17223                None
17224            }
17225        });
17226
17227        // Resolve the type and find the VectorIndex.
17228        let td = self.resolve_type(&type_qname)?.clone();
17229        let vi = td
17230            .vector_indexes
17231            .iter()
17232            .find(|vi| vi.index_name.as_deref() == index_name.as_deref())
17233            .ok_or_else(|| {
17234                let key = index_name.as_deref().unwrap_or("<default>");
17235                self.type_err(&format!("type '{}' has no vector index '{}'", type_qname, key))
17236            })?;
17237
17238        let vector_col = vi.column_name();
17239        let distance_op = match vi.metric.as_str() {
17240            "euclidean" => "<->",
17241            "inner_product" => "<#>",
17242            _ => "<=>", // cosine (default)
17243        };
17244
17245        // Build query expression and inference fields.
17246        let (
17247            query_expr,
17248            inference_query_param_name,
17249            inference_query_literal,
17250            inference_model,
17251            inference_type_name,
17252            inference_index_name,
17253        );
17254
17255        if is_text_overload {
17256            // Text overload: register __deferred_vec__ as the SQL param; Python injects
17257            // the embedding result into it before executing the query.
17258            let vec_idx = self.param_index("__deferred_vec__");
17259            let vec_param = IrExpr::Param { index: vec_idx };
17260            // Cast float8[] → vector so a Python list[float] encodes natively.
17261            let inner_cast = IrExpr::TypeCast(Box::new(IrTypeCast {
17262                expr: vec_param,
17263                pg_type: "float8[]".to_string(),
17264                tuple_shape: None,
17265            }));
17266            query_expr = IrExpr::TypeCast(Box::new(IrTypeCast {
17267                expr: inner_cast,
17268                pg_type: "vector".to_string(),
17269                tuple_shape: None,
17270            }));
17271            let query_arg = text_query_arg.unwrap();
17272            inference_query_param_name = Some(match query_arg {
17273                ast::Expr::Parameter(name) => name.clone(),
17274                _ => String::new(),
17275            });
17276            inference_query_literal = match query_arg {
17277                ast::Expr::Literal(ast::Literal::Str(s)) => Some(s.clone()),
17278                _ => None,
17279            };
17280            inference_model = Some(vi.model.clone());
17281            inference_type_name = Some(type_qname.clone());
17282            inference_index_name = Some(index_name.clone());
17283        } else {
17284            // Vector overload: second positional arg is the pre-computed vector.
17285            let raw_query_expr = self.compile_free_expr(&fc.args[1])?;
17286            query_expr = IrExpr::TypeCast(Box::new(IrTypeCast {
17287                expr: raw_query_expr,
17288                pg_type: "vector".to_string(),
17289                tuple_shape: None,
17290            }));
17291            inference_query_param_name = None;
17292            inference_query_literal = None;
17293            inference_model = None;
17294            inference_type_name = None;
17295            inference_index_name = None;
17296        }
17297
17298        let alias = self.fresh_alias();
17299        let source = IrSource {
17300            poly: None,
17301            type_name: type_qname.clone(),
17302            table: td.table.clone(),
17303            alias: alias.clone(),
17304        };
17305
17306        // Compile inner_filter_ast (from subquery first arg) now that we have the alias,
17307        // so property column refs (e.g. `.price`) use the correct table alias.
17308        let pre_filter: Option<IrExpr> = match inner_filter_ast {
17309            Some(ref f) => Some(self.compile_expr(f, &td, &alias)?),
17310            None => None,
17311        };
17312
17313        // Compile the object shape from `object { … }` inside the shape elements.
17314        // `elements` is the outer shape (`{ object { … }, distance }`).
17315        // We find the `object` element and take its sub-shape; everything else is ignored
17316        // at compile time (distance is always emitted; unknown pointers are an error).
17317        let mut object_shape: Vec<IrShapePointer> = vec![];
17318        for el in elements {
17319            if el.splat.is_some() {
17320                continue;
17321            } // ignore splat in outer shape
17322            let pointer_name = match el.path.steps.first() {
17323                Some(ast::PathStep::Name(n)) => n.as_str(),
17324                _ => continue,
17325            };
17326            match pointer_name {
17327                "distance" => { /* always emitted; no sub-shape */ }
17328                "object" => {
17329                    let sub_els = el.nested.as_deref().unwrap_or(&[]);
17330                    object_shape = self.compile_shape(sub_els, &td, &alias, &td.module)?;
17331                }
17332                other => {
17333                    return Err(self.type_err(&format!(
17334                        "vector::search result has no pointer '{}'; valid pointers are 'object' and 'distance'",
17335                        other
17336                    )));
17337                }
17338            }
17339        }
17340
17341        let (outer_filter, order_by_distance, offset, limit) = self.compile_vs_modifiers(s)?;
17342
17343        // Merge pre_filter (from subquery first arg) with any outer filter via AND.
17344        let filter = match (pre_filter, outer_filter) {
17345            (Some(a), Some(b)) => Some(IrExpr::BinOp(Box::new(IrBinOp {
17346                left: a,
17347                op: ast::BinOpKind::And,
17348                right: b,
17349            }))),
17350            (Some(f), None) | (None, Some(f)) => Some(f),
17351            (None, None) => None,
17352        };
17353
17354        Ok(Some(IrVectorSearch {
17355            source,
17356            vector_col,
17357            distance_op,
17358            query_expr,
17359            object_shape,
17360            filter,
17361            order_by_distance,
17362            offset,
17363            limit,
17364            inference_query_param_name,
17365            inference_query_literal,
17366            inference_model,
17367            inference_type_name,
17368            inference_index_name,
17369        }))
17370    }
17371
17372    /// Compile `order by`, `filter`, `offset`, `limit` for a VectorSearch source.
17373    /// Recognises `.distance` (relative path) as the distance expression.
17374    fn compile_vs_modifiers(&mut self, s: &ast::SelectStmt) -> Result<SearchModifiers, PyQLError> {
17375        let mut order_by_distance: Option<IrSortDir> = None;
17376        for sort in &s.order_by {
17377            let is_distance = matches!(&sort.expr,
17378                ast::Expr::Path(p) if p.partial && p.steps.len() == 1
17379                    && matches!(&p.steps[0], ast::PathStep::Name(n) if n == "distance")
17380            );
17381            if is_distance {
17382                let dir = match sort.direction {
17383                    ast::SortDirection::Desc => IrSortDir::Desc,
17384                    ast::SortDirection::Asc => IrSortDir::Asc,
17385                };
17386                order_by_distance = Some(dir);
17387            } else {
17388                return Err(self.type_err("vector::search: only 'order by .distance' is supported as a sort key"));
17389            }
17390        }
17391
17392        let filter = match &s.filter {
17393            Some(f) => Some(self.compile_free_expr(f)?),
17394            None => None,
17395        };
17396        let offset = match &s.offset {
17397            Some(o) => Some(self.compile_free_expr(o)?),
17398            None => None,
17399        };
17400        let limit = match &s.limit {
17401            Some(l) => Some(self.compile_free_expr(l)?),
17402            None => None,
17403        };
17404
17405        Ok((filter, order_by_distance, offset, limit))
17406    }
17407
17408    // ── fts::search ──────────────────────────────────────────────────────────────
17409
17410    /// Try to compile `fts::search(TypeName, $query [, index_name := '…'] [, mode := '…'])`.
17411    /// Returns `None` if the call is not `fts::search`.
17412    fn try_compile_fts_search(
17413        &mut self,
17414        fc: &ast::FunctionCall,
17415        elements: &[ast::ShapeElement],
17416        s: &ast::SelectStmt,
17417    ) -> Result<Option<IrFtsSearch>, PyQLError> {
17418        if fc.module.as_deref() != Some("fts") || fc.name != "search" {
17419            return Ok(None);
17420        }
17421        if fc.args.len() < 2 {
17422            return Err(self.type_err("fts::search requires at least 2 arguments: (TypeName, $query)"));
17423        }
17424
17425        // First argument: a bare type name reference.
17426        let type_qname = match &fc.args[0] {
17427            ast::Expr::Path(p) if !p.partial && p.steps.len() == 1 => {
17428                if let ast::PathStep::Name(n) = &p.steps[0] {
17429                    let td = self
17430                        .resolve_type(n)
17431                        .map_err(|_| self.type_err(&format!("fts::search: '{}' is not a known type", n)))?;
17432                    format!("{}::{}", td.module, td.name)
17433                } else {
17434                    return Err(self.type_err("fts::search: first argument must be a type name"));
17435                }
17436            }
17437            _ => return Err(self.type_err("fts::search: first argument must be a bare type name")),
17438        };
17439
17440        // Optional named arguments.
17441        let index_name: Option<String> = fc.kwargs.iter().find(|(k, _)| k == "index_name").and_then(|(_, v)| {
17442            if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
17443                Some(s.clone())
17444            } else {
17445                None
17446            }
17447        });
17448
17449        let mode_str = fc
17450            .kwargs
17451            .iter()
17452            .find(|(k, _)| k == "mode")
17453            .and_then(|(_, v)| {
17454                if let ast::Expr::Literal(ast::Literal::Str(s)) = v {
17455                    Some(s.as_str())
17456                } else {
17457                    None
17458                }
17459            })
17460            .unwrap_or("BestFields");
17461
17462        let tsquery_fn: &'static str = match mode_str {
17463            "Phrase" => "phraseto_tsquery",
17464            _ => "websearch_to_tsquery", // BestFields and PhrasePrefix both use websearch
17465        };
17466
17467        // Resolve the type and find the SearchIndex.
17468        let td = self.resolve_type(&type_qname)?.clone();
17469        let si = td
17470            .search_indexes
17471            .iter()
17472            .find(|si| si.index_name.as_deref() == index_name.as_deref())
17473            .ok_or_else(|| {
17474                let key = index_name.as_deref().unwrap_or("<default>");
17475                self.type_err(&format!("type '{}' has no search index '{}'", type_qname, key))
17476            })?;
17477
17478        let backend = si.backend.clone();
17479        let search_col = si.column_name();
17480        let is_deferred = backend != SearchBackend::Postgres;
17481        let deferred_index_name = if is_deferred {
17482            Some(si.deferred_index_name(&td.module, &td.name))
17483        } else {
17484            None
17485        };
17486
17487        // Second argument: the query text expression.
17488        // For deferred backends, IDs/scores are the only SQL params ($1/$2). The query
17489        // text is consumed by the Python layer before SQL execution, so we extract its
17490        // name/literal directly from the AST without registering a SQL param for it.
17491        let query_expr;
17492        let deferred_query_param_name;
17493        let deferred_query_literal;
17494        let deferred_ids_param;
17495        let deferred_scores_param;
17496
17497        if is_deferred {
17498            let ids_idx = self.param_index("__deferred_ids__");
17499            let scores_idx = self.param_index("__deferred_scores__");
17500            deferred_ids_param = Some(ids_idx);
17501            deferred_scores_param = Some(scores_idx);
17502            deferred_query_param_name = match &fc.args[1] {
17503                ast::Expr::Parameter(name) => Some(name.clone()),
17504                _ => None,
17505            };
17506            deferred_query_literal = match &fc.args[1] {
17507                ast::Expr::Literal(ast::Literal::Str(s)) => Some(s.clone()),
17508                _ => None,
17509            };
17510            // Placeholder — not used in SQL for the deferred backend.
17511            query_expr = IrExpr::Literal(crate::ir::IrLiteral::Str(String::new()));
17512        } else {
17513            query_expr = self.compile_free_expr(&fc.args[1])?;
17514            deferred_query_param_name = None;
17515            deferred_query_literal = None;
17516            deferred_ids_param = None;
17517            deferred_scores_param = None;
17518        }
17519
17520        let alias = self.fresh_alias();
17521        let source = IrSource {
17522            poly: None,
17523            type_name: type_qname.clone(),
17524            table: td.table.clone(),
17525            alias: alias.clone(),
17526        };
17527
17528        // Compile the object shape from `object { … }` in the outer shape.
17529        let mut object_shape: Vec<IrShapePointer> = vec![];
17530        for el in elements {
17531            if el.splat.is_some() {
17532                continue;
17533            }
17534            let pointer_name = match el.path.steps.first() {
17535                Some(ast::PathStep::Name(n)) => n.as_str(),
17536                _ => continue,
17537            };
17538            match pointer_name {
17539                "score" => { /* always emitted */ }
17540                "object" => {
17541                    let sub_els = el.nested.as_deref().unwrap_or(&[]);
17542                    object_shape = self.compile_shape(sub_els, &td, &alias, &td.module)?;
17543                }
17544                other => {
17545                    return Err(self.type_err(&format!(
17546                        "fts::search result has no pointer '{}'; valid pointers are 'object' and 'score'",
17547                        other
17548                    )));
17549                }
17550            }
17551        }
17552
17553        let (filter, order_by_rank, offset, limit) = self.compile_fts_modifiers(s)?;
17554
17555        Ok(Some(IrFtsSearch {
17556            source,
17557            backend,
17558            search_col,
17559            tsquery_fn,
17560            query_expr,
17561            object_shape,
17562            filter,
17563            order_by_rank,
17564            offset,
17565            limit,
17566            deferred_index_name,
17567            deferred_query_param_name,
17568            deferred_query_literal,
17569            deferred_ids_param,
17570            deferred_scores_param,
17571        }))
17572    }
17573
17574    /// Compile `order by`, `filter`, `offset`, `limit` for a FtsSearch source.
17575    /// Recognises `.score` (relative path) as the score expression.
17576    fn compile_fts_modifiers(&mut self, s: &ast::SelectStmt) -> Result<SearchModifiers, PyQLError> {
17577        let mut order_by_rank: Option<IrSortDir> = None;
17578        for sort in &s.order_by {
17579            let is_rank = matches!(&sort.expr,
17580                ast::Expr::Path(p) if p.partial && p.steps.len() == 1
17581                    && matches!(&p.steps[0], ast::PathStep::Name(n) if n == "score")
17582            );
17583            if is_rank {
17584                let dir = match sort.direction {
17585                    ast::SortDirection::Desc => IrSortDir::Desc,
17586                    ast::SortDirection::Asc => IrSortDir::Asc,
17587                };
17588                order_by_rank = Some(dir);
17589            } else {
17590                return Err(self.type_err("fts::search: only 'order by .score' is supported as a sort key"));
17591            }
17592        }
17593
17594        let filter = match &s.filter {
17595            Some(f) => Some(self.compile_free_expr(f)?),
17596            None => None,
17597        };
17598        let offset = match &s.offset {
17599            Some(o) => Some(self.compile_free_expr(o)?),
17600            None => None,
17601        };
17602        let limit = match &s.limit {
17603            Some(l) => Some(self.compile_free_expr(l)?),
17604            None => None,
17605        };
17606
17607        Ok((filter, order_by_rank, offset, limit))
17608    }
17609
17610    // ── Error helpers ─────────────────────────────────────────────────────────────
17611
17612    fn type_err(&self, msg: &str) -> PyQLError {
17613        PyQLError::Type(PyQLTypeError {
17614            message: msg.to_string(),
17615            position: Position { line: 0, col: 0 },
17616        })
17617    }
17618
17619    fn field_err(&self, field: &str, type_name: &str) -> PyQLError {
17620        if std::env::var("PYLON_DBG_FIELD_ERR").is_ok() {
17621            eprintln!(
17622                "DBG field_err {field} on {type_name}
17623{}",
17624                std::backtrace::Backtrace::force_capture()
17625            );
17626        }
17627        let suggestion = self
17628            .schema
17629            .types
17630            .iter()
17631            .find(|t| format!("{}::{}", t.module, t.name) == type_name)
17632            .and_then(|td| Self::suggest_pointer_name(td, field));
17633        let message = match suggestion {
17634            Some(s) => format!("object type '{type_name}' has no link or property '{field}'. Did you mean '{s}'?"),
17635            None => format!("object type '{type_name}' has no link or property '{field}'"),
17636        };
17637        PyQLError::Resolution(PyQLResolutionError::UnknownField(PyQLUnknownFieldError {
17638            message,
17639            position: Position { line: 0, col: 0 },
17640        }))
17641    }
17642
17643    /// A ` — did you mean …?` fragment for an unresolvable function name, or
17644    /// an empty string.
17645    ///
17646    /// Mirrors `pylon.stdlib.unknown_function_message`, which has always
17647    /// offered this for the `std.foo(...)` attribute form; a name written as
17648    /// PyQL text got nothing, which is how a schema converted from a system
17649    /// that spelled it `uuid_generate_v7j` stayed broken instead of being
17650    /// pointed one character back at `uuid_generate_v7`.
17651    ///
17652    /// The wrong-namespace check comes first and is worth its own wording:
17653    /// the namespaces overlap unevenly — `sqrt`/`abs`/`ceil` are in both
17654    /// `std` and `math`, while `pi`/`ln`/`exp` are only in `math` — so
17655    /// guessing the wrong one is routine, and naming where the function
17656    /// actually lives recovers from it in a way a fuzzy match over the
17657    /// namespace that lacks it cannot.
17658    fn suggest_function_name(&self, ns: &str, name: &str) -> String {
17659        const MIN_SIMILARITY: f64 = 0.7;
17660        if let Some(other) = ["std", "math", "cal", "sys"]
17661            .into_iter()
17662            .find(|o| *o != ns && !crate::stdlib::lookup(o, name).is_empty())
17663        {
17664            return format!(" — it lives in {other}, use {other}::{name}()");
17665        }
17666        crate::stdlib::registry()
17667            .iter()
17668            .filter(|d| d.namespace == ns)
17669            .map(|d| (format!("{ns}::{}", d.name), d.name))
17670            .chain(
17671                self.schema
17672                    .functions
17673                    .iter()
17674                    .map(|f| (format!("{}::{}", f.module, f.name), f.name.as_str())),
17675            )
17676            .map(|(qualified, candidate)| (qualified, strsim::jaro_winkler(name, candidate)))
17677            .filter(|(_, score)| *score >= MIN_SIMILARITY)
17678            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
17679            .map(|(qualified, _)| format!(" — did you mean {qualified}()?"))
17680            .unwrap_or_default()
17681    }
17682
17683    /// Fuzzy-matches `name` against every pointer (property/link/multilink/
17684    /// computed — Pylon's term for a type's own attributes; "field" is a
17685    /// Postgres-level term that doesn't apply here) on `td`, returning the
17686    /// closest candidate when it's plausibly a typo — powers a
17687    /// "Did you mean X?" suggestion for an unknown property/link.
17688    /// Jaro-Winkler (favors a shared prefix, which is where most real typos
17689    /// preserve the most characters, e.g. `nam` -> `name`) with a
17690    /// conservative similarity floor, so an unrelated pointer never gets
17691    /// suggested just because it happens to be the "closest" among an
17692    /// otherwise-dissimilar set of candidates.
17693    fn suggest_pointer_name(td: &TypeDescriptor, name: &str) -> Option<String> {
17694        const MIN_SIMILARITY: f64 = 0.7;
17695        td.properties
17696            .iter()
17697            .map(|p| p.name.as_str())
17698            .chain(td.links.iter().map(|l| l.name.as_str()))
17699            .chain(td.multilinks.iter().map(|m| m.name.as_str()))
17700            .chain(td.computed.iter().map(|c| c.name.as_str()))
17701            .map(|candidate| (candidate, strsim::jaro_winkler(name, candidate)))
17702            .filter(|(_, score)| *score >= MIN_SIMILARITY)
17703            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
17704            .map(|(name, _)| name.to_string())
17705    }
17706
17707    // ── Default returning (pk only) ────────────────────────────────────────────
17708
17709    /// For bare DML (not wrapped in SELECT) return only primary-key properties:
17710    /// `INSERT … ` returns `{ id }`, same for UPDATE/DELETE.
17711    fn pk_returning(td: &TypeDescriptor) -> Vec<IrShapePointer> {
17712        td.properties
17713            .iter()
17714            .filter(|p| p.is_pk)
17715            .map(|p| {
17716                IrShapePointer::Scalar(IrScalarPointer {
17717                    implicit_id: false,
17718                    marker_offset: None,
17719                    alias: p.name.clone(),
17720                    column: p.name.clone(),
17721                    pg_type: p.pg_type.clone(),
17722                    tuple_shape: None,
17723                })
17724            })
17725            .collect()
17726    }
17727}
17728
17729// ── Helpers ─────────────────────────────────────────────────────────────────────
17730
17731/// True for `<json>` / `<std::json>` — the one cast target that turns its
17732/// operand into output text rather than another value.
17733fn casts_to_json(ty: &ast::TypeExpr) -> bool {
17734    matches!(ty.as_named(), Some((module, "json")) if module.is_none_or(|m| m == "std"))
17735}
17736
17737/// True if any node in a multilink value tree (including both sides of any
17738/// nested `union`) carries a `@prop := value` link-property assignment.
17739fn has_any_link_props(vals: &IrMultiLinkValues) -> bool {
17740    if !vals.link_props.is_empty() {
17741        return true;
17742    }
17743    match &vals.source {
17744        IrMultiLinkValueSource::Union(a, b) => has_any_link_props(a) || has_any_link_props(b),
17745        _ => false,
17746    }
17747}
17748
17749/// True for a bare `{}` or a `<AnyType>{}` cast of one — the empty-set
17750/// literal a caller uses to clear an optional pointer, in either form. A
17751/// frontend generating PyQL typically emits the cast form (e.g.
17752/// `<Company>{}`, matching `compile_expr`'s own `Expr::TypeCast` handling of
17753/// this exact case), while hand-written PyQL more often uses the bare form.
17754fn is_empty_set_expr(expr: &Expr) -> bool {
17755    match expr {
17756        Expr::Set(elems) => elems.is_empty(),
17757        Expr::TypeCast(tc) => matches!(&tc.expr, Expr::Set(elems) if elems.is_empty()),
17758        _ => false,
17759    }
17760}
17761
17762/// Extract the single pointer name from a relative path used in a shape element.
17763fn path_leaf(p: &ast::Path) -> Result<&str, PyQLError> {
17764    match p.steps.as_slice() {
17765        [ast::PathStep::Name(n)] => Ok(n.as_str()),
17766        _ => Err(PyQLError::Type(PyQLTypeError {
17767            message: "expected a simple pointer name in shape element".into(),
17768            position: Position { line: 0, col: 0 },
17769        })),
17770    }
17771}
17772
17773/// Map a PyQL type expression to a PostgreSQL type string.
17774fn type_expr_to_pg(ty: &ast::TypeExpr) -> Result<String, PyQLError> {
17775    let Some((module, bare_name)) = ty.as_named() else {
17776        // Callers resolve a structural tuple/array directly before ever
17777        // reaching this function (see `resolve_cast_pg_type`) — reaching here
17778        // with one would be an internal bug, not a user-facing scenario.
17779        return Err(PyQLError::Type(PyQLTypeError {
17780            message: "internal error: structural tuple/array type reached type_expr_to_pg".into(),
17781            position: Position { line: 0, col: 0 },
17782        }));
17783    };
17784
17785    // pgvector:: types map directly to PostgreSQL types.
17786    if module == Some("pgvector") {
17787        return match bare_name {
17788            "vector" => Ok("vector".to_string()),
17789            other => Err(PyQLError::Type(PyQLTypeError {
17790                message: format!("unknown pgvector type '{other}'; valid types are: vector"),
17791                position: Position { line: 0, col: 0 },
17792            })),
17793        };
17794    }
17795
17796    // postgis:: types map directly to PostgreSQL types.
17797    if module == Some("postgis") {
17798        return match bare_name {
17799            "geometry" => Ok("geometry".to_string()),
17800            "geography" => Ok("geography".to_string()),
17801            "box2d" => Ok("box2d".to_string()),
17802            "box3d" => Ok("box3d".to_string()),
17803            other => Err(PyQLError::Type(PyQLTypeError {
17804                message: format!("unknown postgis type '{other}'; valid types are: geometry, geography, box2d, box3d"),
17805                position: Position { line: 0, col: 0 },
17806            })),
17807        };
17808    }
17809
17810    // cal:: types map directly to PostgreSQL types.
17811    if module == Some("cal") {
17812        let pg = match bare_name {
17813            "local_datetime" => "timestamp",
17814            "local_date" => "date",
17815            "local_time" => "time",
17816            "relative_duration" | "date_duration" => "interval",
17817            other => {
17818                return Err(PyQLError::Type(PyQLTypeError {
17819                    message: format!(
17820                        "unknown cal type '{other}'; \
17821                     valid types are: local_datetime, local_date, local_time, \
17822                     relative_duration, date_duration"
17823                    ),
17824                    position: Position { line: 0, col: 0 },
17825                }));
17826            }
17827        };
17828        return Ok(pg.to_string());
17829    }
17830
17831    let name = match module {
17832        Some("std") | None => bare_name,
17833        Some(m) => {
17834            return Err(PyQLError::Type(PyQLTypeError {
17835                message: format!("unknown type '{}::{}'", m, bare_name),
17836                position: Position { line: 0, col: 0 },
17837            }));
17838        }
17839    };
17840    Ok(match name {
17841        "str" => "text",
17842        "int16" => "int2",
17843        "int32" => "int4",
17844        "int64" => "int8",
17845        "float32" => "float4",
17846        "float64" => "float8",
17847        "bool" => "boolean",
17848        "uuid" => "uuid",
17849        "bytes" => "bytea",
17850        "json" => "jsonb",
17851        "decimal" => "numeric",
17852        // bigint and decimal are both PostgreSQL `numeric` — the distinction
17853        // is a Pylon-level scale/precision convention, not a separate PG type.
17854        "bigint" => "numeric",
17855        "datetime" => "timestamptz",
17856        "date" => "date",
17857        "time" => "time",
17858        "duration" => "interval",
17859        other => {
17860            return Err(PyQLError::Type(PyQLTypeError {
17861                message: format!("unknown type '{other}'"),
17862                position: Position { line: 0, col: 0 },
17863            }));
17864        }
17865    }
17866    .to_string())
17867}
17868
17869/// Check whether a compiled expression is compatible with a PylonType parameter.
17870/// Used for overload selection when multiple overloads share the same name.
17871/// Emit `($1 IS NOT NULL)` for a given IR expression.
17872fn ir_is_not_null(expr: IrExpr) -> IrExpr {
17873    IrExpr::FunctionCall(IrFunctionCall {
17874        return_pg_type: None,
17875        schema: None,
17876        name: String::new(),
17877        args: vec![expr],
17878        sql_template: Some("($1 IS NOT NULL)".to_string()),
17879    })
17880}
17881
17882/// "1 argument(s)" / "2 or 3 argument(s)" / "at least 1 argument(s)" — the
17883/// arities an overload set accepts, worded as `pylon.stdlib._arity_error`
17884/// words the same rejection on the Python side.
17885fn describe_arities(overloads: &[&crate::stdlib::FnDescriptor]) -> String {
17886    // Positions only: a named-only parameter is not one of the arguments the
17887    // count is about, so counting it reported `range(…)` as taking four.
17888    let mut arities: Vec<usize> = overloads
17889        .iter()
17890        .map(|d| d.params.iter().filter(|p| p.named_only.is_none()).count())
17891        .collect();
17892    arities.sort_unstable();
17893    arities.dedup();
17894    if overloads.iter().any(|d| d.is_variadic()) {
17895        return format!("at least {} argument(s)", arities[0].saturating_sub(1));
17896    }
17897    let list = arities.iter().map(usize::to_string).collect::<Vec<_>>().join(" or ");
17898    format!("{list} argument(s)")
17899}
17900
17901/// The argument types as far as they could be inferred, for an error that
17902/// has to show the caller what it actually passed.
17903fn describe_args(args: &[IrExpr]) -> String {
17904    args.iter()
17905        .map(|a| infer_ir_type(a).map_or("?", literal_sentinel_to_pg))
17906        .collect::<Vec<_>>()
17907        .join(", ")
17908}
17909
17910/// The parameter lists of the overloads that did match on arity, in PyQL
17911/// spelling — what the caller should have passed.
17912fn describe_signatures(overloads: &[&crate::stdlib::FnDescriptor]) -> String {
17913    overloads
17914        .iter()
17915        .map(|d| {
17916            let params = d
17917                .params
17918                .iter()
17919                .map(|p| match p.named_only {
17920                    // Spelled the way the call has to pass it, so a rejected
17921                    // call isn't told to write a named parameter by position.
17922                    Some(_) => format!("{} := {}", p.keyword(), p.ty.pyql_name()),
17923                    None => p.ty.pyql_name(),
17924                })
17925                .collect::<Vec<_>>()
17926                .join(", ");
17927            format!("({params})")
17928        })
17929        .collect::<Vec<_>>()
17930        .join(" or ")
17931}
17932
17933/// The value of a boolean literal argument, for the call shapes that can be
17934/// resolved at compile time rather than left to a runtime `CASE`.
17935fn bool_literal(expr: &IrExpr) -> Option<bool> {
17936    match expr {
17937        IrExpr::Literal(IrLiteral::Bool(b)) => Some(*b),
17938        _ => None,
17939    }
17940}
17941
17942/// Gather the arguments a variadic parameter absorbed into one array, for the
17943/// overloads that declare named-only parameters after it.
17944///
17945/// PostgreSQL requires `VARIADIC` to be the last parameter, so an overload
17946/// with anything after it declares a plain array instead (see
17947/// `ddl::pg_params`) and is handed the elements already gathered. An overload
17948/// whose variadic really is last keeps passing them one by one, which is what
17949/// `VARIADIC path text[]` expects.
17950fn pack_variadic_args(d: &crate::stdlib::FnDescriptor, args: Vec<IrExpr>) -> Vec<IrExpr> {
17951    let named = d.named_count();
17952    let Some(variadic_at) = d.variadic_index().filter(|v| v + 1 < d.params.len()) else {
17953        return args;
17954    };
17955    let Some(absorbed) = args.len().checked_sub(variadic_at + named) else {
17956        return args;
17957    };
17958    let mut packed: Vec<IrExpr> = Vec::with_capacity(variadic_at + 1 + named);
17959    let mut rest = args.into_iter();
17960    packed.extend(rest.by_ref().take(variadic_at));
17961    packed.push(IrExpr::Array(rest.by_ref().take(absorbed).collect()));
17962    packed.extend(rest);
17963    packed
17964}
17965
17966/// The parameter each of `argc` arguments is checked against.
17967///
17968/// One per declared parameter, except that a variadic one stands in for every
17969/// argument past the fixed ones — `json_get(j, 'a', 'b')` checks both path
17970/// elements against the single variadic `str` parameter rather than leaving
17971/// the third argument unchecked, which a plain `params.iter()` zipped against
17972/// the arguments would do.
17973///
17974/// The named-only parameters keep their own positions at the tail:
17975/// `compile_named_call_args` has already appended one argument per named-only
17976/// parameter, in declaration order, so the arguments a variadic absorbs are
17977/// only the ones between the fixed parameters and that tail.
17978fn params_for_args(d: &crate::stdlib::FnDescriptor, argc: usize) -> impl Iterator<Item = &crate::stdlib::Param> {
17979    let named = d.named_count();
17980    let variadic_at = d.is_variadic().then(|| d.variadic_index()).flatten();
17981    let absorbed = variadic_at.map_or(0, |v| argc.saturating_sub(v + named));
17982    (0..argc).filter_map(move |i| match variadic_at {
17983        Some(v) if i >= v && i < v + absorbed => d.params.get(v),
17984        Some(v) if i >= v => d.params.get(i + 1 - absorbed),
17985        _ => d.params.get(i),
17986    })
17987}
17988
17989fn pylon_type_matches(expr: &IrExpr, ty: &crate::stdlib::PylonType) -> bool {
17990    use crate::stdlib::PylonType as PT;
17991    match ty {
17992        // Wildcard params always match.
17993        PT::Any | PT::AnyOrderable | PT::AnyPoint => true,
17994        PT::Array(_) => is_array_expr(expr),
17995        // Matched on the concrete PostgreSQL type name, which for a
17996        // multirange is `int8multirange`/`tstzmultirange`/… — it carries the
17997        // element family as a prefix, so "is a multirange" is a `contains`,
17998        // not a `starts_with`, and "is a plain range" has to exclude it.
17999        PT::Range(_) => matches!(infer_ir_type(expr), Some(t) if t.ends_with("range") && !t.contains("multirange")),
18000        PT::Multirange(_) => matches!(infer_ir_type(expr), Some(t) if t.contains("multirange")),
18001        // Every parameter type that names a concrete PostgreSQL scalar is
18002        // judged by `types_compatible` — the one implicit-cast graph the
18003        // rest of the compiler already resolves operators against — rather
18004        // than by an exact type-name match. Exactness rejected an integer
18005        // literal reaching a `float64` parameter, which is how
18006        // `cal::to_relative_duration(seconds := 3)` and every other
18007        // int-for-float argument reads.
18008        //
18009        // An argument whose type cannot be inferred does *not* match a
18010        // parameter that names one: an overload is chosen only on evidence,
18011        // and `resolve_fn_call` reports the call rather than guessing.
18012        // For a parameter type with no scalar spelling (`optional<…>` and
18013        // friends) there is nothing to judge, so allow.
18014        _ => match (ty.scalar_pg_type(), infer_ir_type(expr)) {
18015            (Some(declared), Some(known)) => types_compatible(known, declared),
18016            (Some(_), None) => false,
18017            (None, _) => true,
18018        },
18019    }
18020}
18021
18022/// True for a call like `array_unpack` that yields a set rather than one value.
18023fn expr_returns_set(expr: &Expr) -> bool {
18024    let Expr::FunctionCall(call) = expr else {
18025        return false;
18026    };
18027    crate::stdlib::lookup(call.module.as_deref().unwrap_or("std"), &call.name)
18028        .iter()
18029        .any(|d| d.returns_set())
18030}
18031
18032fn literal_sentinel_to_pg(t: &str) -> &str {
18033    match t {
18034        "__int_literal" => "int8",
18035        "__float_literal" => "float8",
18036        other => other,
18037    }
18038}
18039
18040fn is_array_expr(expr: &IrExpr) -> bool {
18041    match through_coalesce(expr) {
18042        IrExpr::Array(_) | IrExpr::ArrayFromSelect(_) => true,
18043        // `str_split(…)[-1]` — a stdlib call declared to return an array,
18044        // which `infer_ir_type` cannot spell because it only names scalars.
18045        IrExpr::FunctionCall(f) if f.schema.is_none() => {
18046            let mut overloads = crate::stdlib::registry().iter().filter(|d| d.name == f.name).peekable();
18047            overloads.peek().is_some() && overloads.all(|d| matches!(d.return_type, crate::stdlib::PylonType::Array(_)))
18048        }
18049        // Everything else an array can arrive as — a column, a cast, a
18050        // global, a `with` binding, a concatenation of any of those — is
18051        // known by the type it carries.
18052        other => matches!(infer_ir_type(other), Some(t) if t.ends_with("[]")),
18053    }
18054}
18055
18056/// A lower bound, computable without running the query, on how many bytes a
18057/// `notify()` payload will serialize to.
18058///
18059/// Only the parts that are already known contribute: string literals count
18060/// their own length, a concatenation sums its sides, a free object counts the
18061/// JSON envelope it will always emit (`{}`, the quoted field names, and the
18062/// `:`/`,` separators) plus whatever its field expressions themselves floor
18063/// at. Anything runtime-valued — a column, a parameter, a function call —
18064/// contributes 0, so this never over-estimates and never rejects a payload
18065/// that could actually have fit.
18066///
18067/// Exists because Postgres's 8000-byte NOTIFY cap is enforced at *runtime*,
18068/// and blowing it aborts the transaction that sent the notification — which,
18069/// for the composed `with update ... select (notify(...), updated)` shape, is
18070/// the write itself.
18071fn static_min_payload_bytes(expr: &ast::Expr) -> usize {
18072    use crate::parse::ast::{BinOpKind, Expr, Literal};
18073    match expr {
18074        Expr::Literal(Literal::Str(s)) => s.len(),
18075        Expr::BinOp(op) if matches!(op.op, BinOpKind::Concat) => {
18076            static_min_payload_bytes(&op.left) + static_min_payload_bytes(&op.right)
18077        }
18078        Expr::Shape(sh) if sh.expr.is_none() => {
18079            // `{"a":,"b":}` — braces, one quoted name and colon per field,
18080            // and a comma between them. Every one of those bytes is emitted
18081            // regardless of what the values turn out to be.
18082            let mut total = 2;
18083            for (i, el) in sh.elements.iter().enumerate() {
18084                if i > 0 {
18085                    total += 1;
18086                }
18087                let name_len = match el.path.steps.last() {
18088                    Some(crate::parse::ast::PathStep::Name(n)) => n.len(),
18089                    _ => 0,
18090                };
18091                total += name_len + 3;
18092                if let Some(value) = &el.compexpr {
18093                    total += static_min_payload_bytes(value);
18094                }
18095            }
18096            total
18097        }
18098        _ => 0,
18099    }
18100}
18101
18102fn expr_to_std_type(expr: &ast::Expr) -> &'static str {
18103    match expr {
18104        ast::Expr::Literal(ast::Literal::Str(_)) => "std::str",
18105        ast::Expr::Literal(ast::Literal::Int(_)) => "std::int64",
18106        ast::Expr::Literal(ast::Literal::Float(_)) => "std::float64",
18107        ast::Expr::Literal(ast::Literal::Bool(_)) => "std::bool",
18108        _ => "anytype",
18109    }
18110}
18111
18112fn named_tuple_type_str(fields: &[(String, ast::Expr)]) -> String {
18113    let inner = fields
18114        .iter()
18115        .map(|(k, v)| format!("{}: {}", k, expr_to_std_type(v)))
18116        .collect::<Vec<_>>()
18117        .join(", ");
18118    format!("tuple<{}>", inner)
18119}
18120
18121fn positional_tuple_type_str(elems: &[ast::Expr]) -> String {
18122    let inner = elems.iter().map(expr_to_std_type).collect::<Vec<_>>().join(", ");
18123    format!("tuple<{}>", inner)
18124}
18125
18126pub(crate) fn infer_ir_type(expr: &IrExpr) -> Option<&str> {
18127    match expr {
18128        IrExpr::ColumnRef { pg_type, .. } => Some(pg_type.as_str()),
18129        IrExpr::TypeCast(tc) => Some(tc.pg_type.as_str()),
18130        IrExpr::FnParam { pg_type, .. } => Some(pg_type.as_str()),
18131        IrExpr::Literal(lit) => Some(match lit {
18132            IrLiteral::Str(_) => "text",
18133            IrLiteral::Int(_) => "__int_literal",
18134            IrLiteral::Float(_) => "__float_literal",
18135            IrLiteral::Bool(_) => "boolean",
18136        }),
18137        IrExpr::EnumLiteral { pg_type, .. } => Some(pg_type.as_str()),
18138        IrExpr::NamedTuple { .. } => Some("jsonb"),
18139        IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } => Some("jsonb"),
18140        IrExpr::GlobalParam { pg_type, .. } => Some(pg_type.as_str()),
18141        // A `with`-bound scalar is typed by what it binds, so a call over one
18142        // resolves to the same overload the bare value would.
18143        IrExpr::CteRef { pg_type, .. } => pg_type.as_deref(),
18144        // A correlated path subquery is typed by whatever it projects — the
18145        // scalar column at the end of the path. An object-valued one yields
18146        // an id, which no operator should silently compare against.
18147        IrExpr::PathSubquery(ps) => match &ps.result {
18148            IrPathResult::Scalar(e, _) => infer_ir_type(e),
18149            IrPathResult::Object { .. } => None,
18150        },
18151        // `a ++ b` and `distinct a` both yield whatever they were given, so
18152        // an array stays recognisable as one through either.
18153        IrExpr::BinOp(b) if b.op == crate::parse::ast::BinOpKind::Concat => {
18154            infer_ir_type(&b.left).or_else(|| infer_ir_type(&b.right))
18155        }
18156        IrExpr::UnaryOp(u) if u.op == crate::parse::ast::UnaryOpKind::Distinct => infer_ir_type(&u.operand),
18157        // `-x` is a number of x's own type; `not x` and `exists x` are always
18158        // boolean.
18159        IrExpr::UnaryOp(u) if u.op == crate::parse::ast::UnaryOpKind::Minus => infer_ir_type(&u.operand),
18160        IrExpr::UnaryOp(u)
18161            if matches!(
18162                u.op,
18163                crate::parse::ast::UnaryOpKind::Not | crate::parse::ast::UnaryOpKind::Exists
18164            ) =>
18165        {
18166            Some("boolean")
18167        }
18168        // `a ?? b` and `a if c else b` yield a value of their branches' own
18169        // type, so a call over either resolves the overload the bare value
18170        // would. Whichever side carries a type decides: the other is routinely
18171        // the untypeable one — an empty set, or a path known only to be
18172        // optional.
18173        IrExpr::BinOp(b) if b.op == crate::parse::ast::BinOpKind::Coalesce => {
18174            infer_ir_type(&b.left).or_else(|| infer_ir_type(&b.right))
18175        }
18176        IrExpr::IfElse(ie) => infer_ir_type(&ie.if_).or_else(|| infer_ir_type(&ie.else_)),
18177        // A slice is the same type as the thing sliced — `substr` of text is
18178        // text, of bytea is bytea, and an array slice is still that array.
18179        // Without this a `to_bytes(x)[12:16]` reaching a `bytes` parameter
18180        // reads as untyped, which is the difference between resolving the
18181        // bytes overload and resolving nothing at all.
18182        IrExpr::Slice { expr, .. } => infer_ir_type(expr),
18183        // `xs[0]` is one element of what `xs` holds — the array type with its
18184        // `[]` taken off. A string or bytes subscript stays the type it
18185        // indexes, the way the `substr` it emits does.
18186        IrExpr::CteFieldRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => pg_type.as_deref(),
18187        IrExpr::Subscript { expr, is_array, .. } => {
18188            // `str_split(s, '::')[0]`: the array a stdlib call returns is not a
18189            // type `infer_ir_type` can name (it only names scalars), so the
18190            // element type comes from the overload's own declared return type.
18191            let Some(base) = infer_ir_type(expr) else {
18192                return stdlib_array_element_type(expr);
18193            };
18194            if *is_array { base.strip_suffix("[]") } else { Some(base) }
18195        }
18196        IrExpr::BinOp(b) => arithmetic_result_type(&b.op, infer_ir_type(&b.left)?, infer_ir_type(&b.right)?),
18197        // The `coalesce` the compiler itself builds — around an aggregate that
18198        // ran over nothing, or a `??` whose sides name rows — carries no
18199        // recorded return type, so it is typed from its arguments like the
18200        // operator it stands in for.
18201        IrExpr::FunctionCall(f) if f.schema.is_none() && f.name == "coalesce" => f.args.iter().find_map(infer_ir_type),
18202        IrExpr::FunctionCall(f) if f.schema.is_none() && matches!(f.name.as_str(), "max" | "min" | "sum") => {
18203            aggregate_result_type(&f.name, infer_ir_type(f.args.first()?)?)
18204        }
18205        IrExpr::AggOverSet {
18206            fn_name, schema: None, ..
18207        } if fn_name == "count" => Some("int8"),
18208        IrExpr::AggOverSet {
18209            fn_name,
18210            schema: None,
18211            elems,
18212        } => aggregate_result_type(fn_name, infer_ir_type(elems.first()?)?),
18213        // Any call whose resolution recorded a scalar return type — a stdlib
18214        // overload (`enc::base64_decode(…)` is bytea) or a user-defined
18215        // function, which is how a computed pointer's declared type gets
18216        // checked against the function it calls at all.
18217        IrExpr::FunctionCall(f) => f.return_pg_type.as_deref(),
18218        _ => None,
18219    }
18220}
18221
18222/// The element type of an array a stdlib call returns, for an expression whose
18223/// own type `infer_ir_type` cannot name. `None` unless every overload of the
18224/// name returns an array of the same scalar — the call would be ambiguous
18225/// otherwise, and guessing is what overload resolution exists to avoid.
18226fn stdlib_array_element_type(expr: &IrExpr) -> Option<&'static str> {
18227    let IrExpr::FunctionCall(f) = expr else {
18228        return None;
18229    };
18230    if f.schema.is_some() {
18231        return None;
18232    }
18233    let mut element: Option<&'static str> = None;
18234    for descriptor in crate::stdlib::registry().iter().filter(|d| d.name == f.name) {
18235        let crate::stdlib::PylonType::Array(inner) = &descriptor.return_type else {
18236            return None;
18237        };
18238        let scalar = inner.scalar_pg_type()?;
18239        if element.is_some_and(|seen| seen != scalar) {
18240            return None;
18241        }
18242        element = Some(scalar);
18243    }
18244    element
18245}
18246
18247/// What `+` and `-` yield over instants and durations — a datetime minus a
18248/// datetime is a duration, a datetime shifted by one is a datetime.
18249/// `duration_to_seconds(datetime_of_transaction() - .started_at)` is the shape
18250/// this exists for, and it resolves no overload while the subtraction reads as
18251/// untyped.
18252fn temporal_result_type(op: &ast::BinOpKind, left: &str, right: &str) -> Option<&'static str> {
18253    use ast::BinOpKind::*;
18254    let instant = |t: &str| matches!(t, "timestamptz" | "timestamp" | "date" | "time");
18255    match (op, left, right) {
18256        (Sub, l, r) if instant(l) && instant(r) => Some("interval"),
18257        (Sub | Add, l, "interval") if instant(l) => Some(match l {
18258            "timestamptz" => "timestamptz",
18259            "timestamp" => "timestamp",
18260            "date" => "date",
18261            _ => "time",
18262        }),
18263        (Add, "interval", r) if instant(r) => Some(match r {
18264            "timestamptz" => "timestamptz",
18265            "timestamp" => "timestamp",
18266            "date" => "date",
18267            _ => "time",
18268        }),
18269        (Add | Sub, "interval", "interval") => Some("interval"),
18270        _ => None,
18271    }
18272}
18273
18274/// The type an arithmetic operator's result carries, from its operands'.
18275/// `None` for anything but a pair of numbers.
18276fn arithmetic_result_type(op: &ast::BinOpKind, left: &str, right: &str) -> Option<&'static str> {
18277    use ast::BinOpKind::*;
18278    if !matches!(op, Add | Sub | Mul | Div | FloorDiv | Mod | Pow) {
18279        return None;
18280    }
18281    let (left, right) = (literal_sentinel_to_pg(left), literal_sentinel_to_pg(right));
18282    if let Some(temporal) = temporal_result_type(op, left, right) {
18283        return Some(temporal);
18284    }
18285    let int = |t: &str| INT_TYPES.contains(&t);
18286    let float = |t: &str| FLOAT_TYPES.contains(&t);
18287    let numeric = |t: &str| NUMERIC_TYPES.contains(&t);
18288    if int(left) && int(right) {
18289        return Some(if matches!(op, Div | Pow) {
18290            "float8"
18291        } else if left == "int8" || right == "int8" {
18292            "int8"
18293        } else if left == "int4" || right == "int4" {
18294            "int4"
18295        } else {
18296            "int2"
18297        });
18298    }
18299    if (float(left) || int(left)) && (float(right) || int(right)) {
18300        return Some(if left == "float4" && right == "float4" {
18301            "float4"
18302        } else {
18303            "float8"
18304        });
18305    }
18306    if (numeric(left) || int(left)) && (numeric(right) || int(right)) {
18307        return Some("numeric");
18308    }
18309    None
18310}
18311
18312/// What `max`, `min` and `sum` over values of `element` yield.
18313fn aggregate_result_type<'a>(name: &str, element: &'a str) -> Option<&'a str> {
18314    let element = literal_sentinel_to_pg(element);
18315    match name {
18316        "max" | "min" => Some(element),
18317        "sum" if INT_TYPES.contains(&element) => Some("int8"),
18318        "sum" if FLOAT_TYPES.contains(&element) || NUMERIC_TYPES.contains(&element) => Some(element),
18319        _ => None,
18320    }
18321}
18322
18323/// The left operand of `left op right`, cast to `float8` when `op` divides
18324/// two integers: PyQL's `/` yields a float64 there, Postgres's truncates.
18325fn true_division_operand(op: &ast::BinOpKind, left: IrExpr, right: &IrExpr) -> IrExpr {
18326    let integer = |expr: &IrExpr| infer_ir_type(expr).is_some_and(|t| INT_TYPES.contains(&literal_sentinel_to_pg(t)));
18327    if *op != ast::BinOpKind::Div || !integer(&left) || !integer(right) {
18328        return left;
18329    }
18330    IrExpr::TypeCast(Box::new(IrTypeCast {
18331        expr: left,
18332        pg_type: "float8".to_string(),
18333        tuple_shape: None,
18334    }))
18335}
18336
18337/// Maps a resolved element `pg_type` (as `infer_ir_type` reports it) to the
18338/// PostgreSQL native range constructor over that type. PG has no float4/
18339/// float8/int2/int4-native range type — only int8range, numrange, tsrange,
18340/// tstzrange, and daterange exist — so untyped int/float literals default
18341/// to the widest native family (int8/numeric) rather than erroring, matching
18342/// how those literals already default elsewhere in Pylon.
18343fn range_ctor_for_pg_type(pg_type: &str) -> Option<&'static str> {
18344    match pg_type {
18345        "int2" | "int4" | "int8" | "__int_literal" => Some("int8range"),
18346        "numeric" | "__float_literal" => Some("numrange"),
18347        "timestamp" => Some("tsrange"),
18348        "timestamptz" => Some("tstzrange"),
18349        "date" => Some("daterange"),
18350        _ => None,
18351    }
18352}
18353
18354/// The multirange counterpart of a range constructor name resolved by
18355/// `range_ctor_for_pg_type`.
18356fn multirange_ctor_for_range_ctor(range_ctor: &str) -> Option<&'static str> {
18357    match range_ctor {
18358        "int8range" => Some("int8multirange"),
18359        "numrange" => Some("nummultirange"),
18360        "tsrange" => Some("tsmultirange"),
18361        "tstzrange" => Some("tstzmultirange"),
18362        "daterange" => Some("datemultirange"),
18363        _ => None,
18364    }
18365}
18366
18367const INT_TYPES: &[&str] = &["int2", "int4", "int8", "__int_literal"];
18368const FLOAT_TYPES: &[&str] = &["float4", "float8", "__float_literal"];
18369/// Postgres `numeric` backs both Pylon's `bigint` and `decimal` (see
18370/// `pg_type_for_scalar_name`) — Pylon doesn't distinguish them at the
18371/// pg_type level, so this bucket covers both.
18372const NUMERIC_TYPES: &[&str] = &["numeric"];
18373
18374/// Pylon's implicit-cast graph for numeric operands:
18375/// `int16 → int32 → int64 → float32 → float64` on one branch and
18376/// `int64 → bigint → decimal` on another — every int width casts to every
18377/// float width and to numeric/decimal, but float and numeric/decimal don't
18378/// cast to each other (they're separate branches past `int64`). Postgres's
18379/// own operator resolution handles the actual mixed-type arithmetic once
18380/// the compile-time gate lets it through (e.g. `int2 + float4` is a native
18381/// Postgres operator) — this only needs to match which operand-type
18382/// combinations are meant to be allowed.
18383pub(crate) fn types_compatible(a: &str, b: &str) -> bool {
18384    if a == b {
18385        return true;
18386    }
18387    let a_int = INT_TYPES.contains(&a);
18388    let b_int = INT_TYPES.contains(&b);
18389    if a_int && b_int {
18390        return true;
18391    }
18392    let a_float = FLOAT_TYPES.contains(&a);
18393    let b_float = FLOAT_TYPES.contains(&b);
18394    if a_float && b_float {
18395        return true;
18396    }
18397    let a_numeric = NUMERIC_TYPES.contains(&a);
18398    let b_numeric = NUMERIC_TYPES.contains(&b);
18399    if a_numeric && b_numeric {
18400        return true;
18401    }
18402    (a_int && b_float) || (a_float && b_int) || (a_int && b_numeric) || (a_numeric && b_int)
18403}
18404
18405/// Datetime/duration `+`/`-` pairs PostgreSQL supports natively (e.g.
18406/// `timestamptz + interval`) that `types_compatible`'s bucket-matching
18407/// (same type, or both-int, or both-float) doesn't cover. Deliberately a
18408/// separate, narrower check from `types_compatible` (also used for UNION-branch
18409/// compatibility, where "a datetime and a duration are interchangeable"
18410/// would be nonsensical) rather than folded into it, so this can't leak
18411/// into a context where "arithmetic-compatible" isn't the same relation as
18412/// "interchangeable." Postgres's own operator resolution is the final
18413/// authority on any (op, operand-order) combination that doesn't actually
18414/// exist (e.g. `interval - timestamptz`) — this only needs to widen the
18415/// compile-time gate far enough to let the legitimate combinations through.
18416fn datetime_arithmetic_compatible(op: &ast::BinOpKind, a: &str, b: &str) -> bool {
18417    if !matches!(op, ast::BinOpKind::Add | ast::BinOpKind::Sub) {
18418        return false;
18419    }
18420    matches!(
18421        (a, b),
18422        ("timestamptz", "interval")
18423            | ("interval", "timestamptz")
18424            | ("timestamp", "interval")
18425            | ("interval", "timestamp")
18426            | ("date", "interval")
18427            | ("interval", "date")
18428            | ("time", "interval")
18429            | ("interval", "time")
18430    )
18431}
18432
18433/// Collect `SearchEnqueueInfo` for every OpenSearch- or Meilisearch-backed
18434/// search index on a type — `Postgres`-backed indexes are excluded since
18435/// they're maintained synchronously by a trigger-updated tsvector column,
18436/// not an async outbox worker.
18437fn collect_search_enqueue(td: &TypeDescriptor, type_name: &str, operation: &'static str) -> Vec<SearchEnqueueInfo> {
18438    td.search_indexes
18439        .iter()
18440        .filter(|si| si.backend == SearchBackend::OpenSearch || si.backend == SearchBackend::Meilisearch)
18441        .map(|si| SearchEnqueueInfo {
18442            type_name: type_name.to_string(),
18443            index_name: si.index_name.clone(),
18444            operation,
18445            backend: si.backend.clone(),
18446        })
18447        .collect()
18448}
18449
18450/// Reverse of `type_expr_to_pg`'s plain-scalar branch — renders a base
18451/// Postgres type back to its canonical PyQL display name. `pub` (not just
18452/// crate-local): reused by `pylon-server`'s `/api/schema` port
18453/// (`_scalar_type_name`'s equivalent) so that display-name logic isn't
18454/// duplicated across crates.
18455///
18456/// `"interval"`/`"date"`/`"time"`/`"timestamp"` are inherently ambiguous
18457/// from the bare pg_type alone: `interval` backs both `std::duration` and
18458/// `cal::relative_duration` (defaults to the former — the more common
18459/// case), while `date`/`time`/`timestamp` (no tz) are unambiguous (only
18460/// `cal::local_date`/`cal::local_time`/`cal::local_datetime` use them, as
18461/// opposed to `timestamptz` for `std::datetime`).
18462pub fn pg_type_to_pyql(pg: &str) -> &str {
18463    match pg {
18464        "text" | "varchar" => "std::str",
18465        "uuid" => "std::uuid",
18466        "int2" => "std::int16",
18467        "int4" => "std::int32",
18468        "int8" => "std::int64",
18469        "float4" => "std::float32",
18470        "float8" => "std::float64",
18471        "boolean" => "std::bool",
18472        "numeric" => "std::decimal",
18473        "timestamptz" => "std::datetime",
18474        "timestamp" => "cal::local_datetime",
18475        "date" => "cal::local_date",
18476        "time" => "cal::local_time",
18477        "interval" => "std::duration",
18478        "bytea" => "std::bytes",
18479        "jsonb" => "std::json",
18480        "__int_literal" => "std::int64",
18481        "__float_literal" => "std::float64",
18482        other => other,
18483    }
18484}