Skip to main content

pylon_core/sql/
mod.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::ir::{
21    IrArraySource, IrCteDef, IrDelete, IrExpr, IrFor, IrForIterator, IrFreeExpr, IrFtsSearch, IrFunctionSelect,
22    IrGlobalCte, IrGroup, IrGroupOutput, IrInsert, IrLiteral, IrLockClause, IrLockStrength, IrLockWait,
23    IrMultiLinkJoin, IrMultiLinkMutation, IrMultiLinkPointer, IrMultiLinkValueSource, IrMultiLinkValues, IrNulls,
24    IrOutput, IrPathJoin, IrPathResult, IrPathSelect, IrPolyFanout, IrPolyImplementor, IrRewrite, IrRowSource,
25    IrScalarPointer, IrScalarSetPointer, IrSelect, IrShapePointer, IrSingleLinkCorrelation, IrSingleLinkPointer,
26    IrSort, IrSortDir, IrSource, IrStmt, IrUpdate, IrVectorSearch, SearchEnqueueInfo, VectorEnqueueInfo,
27};
28use crate::parse::ast::{BinOpKind, UnaryOpKind};
29use crate::query::{Cardinality, InferencePlan, ShapeDescriptor, ShapeNode};
30use std::collections::HashMap;
31
32pub struct SqlOutput {
33    pub sql: String,
34    pub shape: ShapeDescriptor,
35    pub inference_plan: Option<InferencePlan>,
36}
37
38/// Build an UPDATE's `SET` clause fragments (`"col" = expr`, no trailing
39/// comma) from its explicit assignments plus its rewrites, with a rewrite on
40/// a given column overriding — not appending alongside — any explicit
41/// assignment to that same column. Mirrors the INSERT-side `rewrite_cols`
42/// dedup (`ins.assignments` filtered against `ins.rewrites`'s own columns,
43/// present at every INSERT emission site) — every UPDATE emission site
44/// independently lacked the equivalent filtering, so an UPDATE that both
45/// explicitly assigned and had a rewrite declared on the same property
46/// produced two `SET` entries for one column, which Postgres rejects with
47/// "multiple assignments to same column" (confirmed live).
48fn update_set_fragments(assignments: &[(String, IrExpr)], rewrites: &[IrRewrite], indent: &str) -> Vec<String> {
49    let rewrite_cols: std::collections::HashSet<&str> = rewrites.iter().map(|r| r.column.as_str()).collect();
50    assignments
51        .iter()
52        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
53        .map(|(col, expr)| format!("{indent}{} = {}", qi(col), emit_expr(expr)))
54        .chain(
55            rewrites
56                .iter()
57                .map(|rw| format!("{indent}{} = {}", qi(&rw.column), emit_expr(&rw.expr))),
58        )
59        .collect()
60}
61
62/// Emit the SQL body for a computed global CTE — a plain scalar query with a `value` column.
63fn emit_for_global_cte(stmt: &IrStmt) -> String {
64    match stmt {
65        IrStmt::Select(sel) => match sel.rows.as_slice() {
66            [IrRowSource::Bound { source, .. }] => {
67                let alias = &source.alias;
68                let mut sql = format!(
69                    "SELECT {}.\"id\" AS \"value\"\nFROM {} AS {}",
70                    qi(alias),
71                    source_ref(source),
72                    qi(alias)
73                );
74                append_filter(&mut sql, &sel.filter);
75                sql
76            }
77            rows => match rows.first() {
78                Some(IrRowSource::Free(IrFreeExpr::Scalar(e))) => format!("SELECT {} AS \"value\"", emit_expr(e)),
79                _ => "SELECT NULL AS \"value\"".to_string(),
80            },
81        },
82        IrStmt::PathSelect(sel) => {
83            let from_sql = emit_path_joins(&sel.root, &sel.joins);
84            let scalar_expr = match &sel.result {
85                IrPathResult::Scalar(e, _) => emit_expr(e),
86                IrPathResult::Object { alias, .. } => format!("{}.\"id\"", qi(alias)),
87            };
88            let mut sql = format!("SELECT {} AS \"value\"\nFROM {}", scalar_expr, from_sql);
89            append_filter(&mut sql, &sel.filter);
90            append_order_by(&mut sql, &sel.order_by);
91            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
92            sql
93        }
94        _ => "SELECT NULL AS \"value\"".to_string(),
95    }
96}
97
98fn emit_global_cte_parts(global_ctes: &[IrGlobalCte]) -> Vec<String> {
99    global_ctes
100        .iter()
101        .map(|g| match g {
102            IrGlobalCte::Session(s) => format!(
103                "\"{}\" AS (SELECT ${}::{} AS \"value\")",
104                s.cte_name,
105                s.param_index + 1,
106                s.pg_type
107            ),
108            IrGlobalCte::Computed(c) => {
109                let body = emit_for_global_cte(&c.stmt);
110                format!("\"{}\" AS (\n{}\n)", c.cte_name, body)
111            }
112        })
113        .collect()
114}
115
116pub fn emit(ir: &IrOutput) -> SqlOutput {
117    let out = with_subtype_fanouts(&ir.subtype_fanouts, || {
118        with_correlated_ctes(&ir.ctes, || emit_output(ir))
119    });
120    #[cfg(debug_assertions)]
121    if let Some(problem) = forward_cte_reference(&out.sql) {
122        panic!("{problem}\n{}", out.sql);
123    }
124    out
125}
126
127/// A WITH name is in scope only for the bindings that follow it, so a CTE
128/// that reads one defined after it fails at execution time — and does so
129/// complaining about a FROM clause, far from what actually put the two in
130/// the wrong order. Checked here rather than left to PostgreSQL because the
131/// parts are assembled in a dozen places, none of which owns the ordering.
132#[cfg(debug_assertions)]
133fn forward_cte_reference(sql: &str) -> Option<String> {
134    let definitions: Vec<(String, usize)> = sql
135        .match_indices("\" AS (")
136        .filter_map(|(end, _)| {
137            let head = &sql[..end];
138            let start = head.rfind('"')?;
139            Some((head[start + 1..].to_string(), start))
140        })
141        .collect();
142    for (name, defined_at) in &definitions {
143        // A bare quoted name is a CTE; a real relation is always schema
144        // qualified (`"marketplace"."Order"`), so these never collide.
145        let quoted = qi(name);
146        for prefix in ["FROM ", "JOIN ", "CROSS JOIN "] {
147            let needle = format!("{prefix}{quoted}");
148            if let Some(used_at) = sql.find(&needle)
149                && used_at < *defined_at
150            {
151                return Some(format!(
152                    "CTE {quoted} is read at byte {used_at} but not defined until {defined_at}"
153                ));
154            }
155        }
156    }
157    None
158}
159
160fn emit_output(ir: &IrOutput) -> SqlOutput {
161    let mut out = match &ir.stmt {
162        IrStmt::Update(upd) => emit_update_stmt(upd, &ir.ctes),
163        IrStmt::For(f) => emit_for_stmt(f, &ir.ctes),
164        stmt => {
165            let mut o = match stmt {
166                IrStmt::Select(sel) => emit_select_stmt(sel, &ir.ctes),
167                IrStmt::PathSelect(sel) => emit_path_select(sel),
168                IrStmt::Insert(ins) => emit_insert_stmt(ins),
169                IrStmt::Delete(del) => emit_delete_stmt(del),
170                IrStmt::Group(grp) => emit_group(grp),
171                IrStmt::FunctionSelect(sel) => emit_function_select(sel),
172                IrStmt::VectorSearch(vs) => emit_vector_search(vs),
173                IrStmt::FtsSearch(fs) => emit_fts_search(fs),
174                IrStmt::ScalarUnion(branches) => SqlOutput {
175                    sql: emit_scalar_union(branches),
176                    shape: ShapeDescriptor {
177                        root: ShapeNode::Scalar {
178                            name: String::new(),
179                            position: 0,
180                        },
181                    },
182                    inference_plan: None,
183                },
184                IrStmt::Update(_) | IrStmt::For(_) => unreachable!(),
185            };
186            if !ir.ctes.is_empty() {
187                // The statement emitter may already have opened a `WITH` of
188                // its own (multi-link/junction assignment, FOR-loop insert);
189                // a second one is invalid SQL, so merge into it.
190                o.sql = merge_into_existing_with(&o.sql, &emit_user_cte_parts(&ir.ctes))
191                    .unwrap_or_else(|| format!("{}{}", emit_cte_prefix(&ir.ctes), o.sql));
192            }
193            o
194        }
195    };
196
197    if !ir.global_ctes.is_empty() {
198        let global_parts = emit_global_cte_parts(&ir.global_ctes);
199        out.sql = merge_into_existing_with(&out.sql, &global_parts)
200            .unwrap_or_else(|| format!("WITH {}\n{}", global_parts.join(",\n     "), out.sql));
201    }
202
203    out
204}
205
206/// Merge CTE definitions into a statement that already opens with a top-level
207/// `WITH`, or `None` when it doesn't (the caller then builds a fresh prefix in
208/// its own format).
209///
210/// SQL allows exactly one `WITH` per statement, but CTE parts arrive from
211/// several independent places: a user-written `with` block (`ir.ctes`),
212/// globals (`ir.global_ctes`), and the CTEs some statement emitters build for
213/// themselves — a multi-link or junction-backed link assignment needs
214/// `_w__ids`/`_w__ml_add_*`, and the `FOR`-loop insert path needs its VALUES
215/// alias. Whichever runs second has to merge; blindly prefixing produces
216/// `WITH ... WITH ...`, which PostgreSQL rejects with `syntax error at or
217/// near "WITH"` — and only at execution time, since the PyQL compiles fine.
218///
219/// The merged-in parts go *first*: a non-recursive CTE may only reference
220/// siblings declared before it, and the parts already present are the ones
221/// that reference what's being added (a statement's own CTEs consume the user
222/// binding they were built from).
223///
224/// Two prefix spellings occur — `emit_cte_prefix`'s `"WITH\n"` and the inline
225/// `"WITH "` built by individual emitters. Both are 5 bytes before the parts
226/// begin, so one slice covers either.
227/// The array operand for `= ANY(...)` / `<> ALL(...)`.
228///
229/// `x in std::array_unpack(arr)` is the documented way to test membership of
230/// an array parameter, but `array_unpack` emits `unnest()`, and nesting that
231/// inside `ANY` gives `ANY(unnest(arr))` — a set-returning function in
232/// `WHERE`, which PostgreSQL rejects (`set-returning functions are not
233/// allowed in WHERE`). `ANY` already takes an array, so the `unnest` is both
234/// redundant and invalid: pass its argument straight through.
235///
236/// Falls back to the pre-rendered form for anything else, including
237/// `unnest()` reached by some route other than a direct `array_unpack` call.
238fn unwrap_unnest_for_any(right: &IrExpr, rendered: &str) -> String {
239    if let Some(array) = unnest_argument(right) {
240        return array;
241    }
242    // `<Enum>array_unpack($1)` — the cast is written over the set, so over the
243    // array the same cast is to that type's array.
244    if let IrExpr::TypeCast(cast) = right
245        && let Some(array) = unnest_argument(&cast.expr)
246    {
247        return format!("({})::{}[]", array, cast.pg_type);
248    }
249    rendered.to_string()
250}
251
252/// The array a direct `array_unpack` call unpacks, already rendered.
253fn unnest_argument(expr: &IrExpr) -> Option<String> {
254    let IrExpr::FunctionCall(call) = expr else {
255        return None;
256    };
257    (call.schema.is_none() && call.name == "unnest" && call.sql_template.is_none() && call.args.len() == 1)
258        .then(|| emit_expr(&call.args[0]))
259}
260
261fn merge_into_existing_with(sql: &str, parts: &[String]) -> Option<String> {
262    if parts.is_empty() || !(sql.starts_with("WITH\n") || sql.starts_with("WITH ")) {
263        return None;
264    }
265    Some(format!("WITH {},\n     {}", parts.join(",\n     "), &sql[5..]))
266}
267
268// ── Identifier / literal helpers ────────────────────────────────────────────
269
270fn qi(s: &str) -> String {
271    format!("\"{}\"", s.replace('"', "\"\""))
272}
273
274fn pg_schema(module: &str) -> String {
275    if module == "default" {
276        "\"public\"".into()
277    } else {
278        qi(module)
279    }
280}
281
282pub fn pg_schema_str(module: &str) -> String {
283    pg_schema(module)
284}
285
286fn qn(module: &str, name: &str) -> String {
287    format!("{}.{}", pg_schema(module), qi(name))
288}
289
290fn sql_str(s: &str) -> String {
291    format!("'{}'", s.replace('\'', "''"))
292}
293
294/// `'module::Type'::text` — always position 0 in every non-free-type tuple.
295fn type_disc(type_name: &str) -> String {
296    format!("{}::text", sql_str(type_name))
297}
298
299/// Extract the PostgreSQL schema name (module) from `"module::TypeName"`.
300fn module_of(type_name: &str) -> &str {
301    type_name.split("::").next().unwrap_or("public")
302}
303
304fn source_ref(src: &IrSource) -> String {
305    // "@cte:name" sentinel: the source is a WITH-clause CTE, not a real table.
306    if let Some(cte_name) = src.table.strip_prefix("@cte:") {
307        // A binding that reads a loop variable holds one value per iteration,
308        // all of them in the one CTE. A read of it belongs to the iteration
309        // in scope here, which is the key it carries.
310        return match correlated_cte_iterator(cte_name) {
311            Some(iterator) => format!(
312                "(SELECT * FROM {} WHERE {}.{} = {}.\"v\")",
313                qi(cte_name),
314                qi(cte_name),
315                qi(OUTER_KEY),
316                qi(&iterator),
317            ),
318            None => qi(cte_name),
319        };
320    }
321    // "@row:NEW" sentinel: a trigger's own row.
322    if let Some(row) = src.table.strip_prefix("@row:") {
323        return format!("(SELECT ({row}).*)");
324    }
325    // An interface-typed source reads from its implementors, never from the
326    // interface's own view — see `IrSource::poly`.
327    match source_fanout(src) {
328        Some(fanout) => format!("(\n{}\n)", emit_poly_union(&fanout.implementors, &fanout.columns)),
329        None => junction_ref(module_of(&src.type_name), &src.table),
330    }
331}
332
333/// The table a statement writes to: only ever the one named, never the
334/// subtypes a read of it would take in.
335fn target_ref(src: &IrSource) -> String {
336    match src.table.strip_prefix("@cte:") {
337        Some(cte_name) => qi(cte_name),
338        None => qn(module_of(&src.type_name), &src.table),
339    }
340}
341
342thread_local! {
343    /// The statement being emitted's `IrOutput::subtype_fanouts`.
344    static SUBTYPE_FANOUTS: std::cell::RefCell<HashMap<(String, String), IrPolyFanout>> =
345        std::cell::RefCell::new(HashMap::new());
346
347    /// Every CTE of the statement being emitted that holds one value per
348    /// iteration, and the iterator whose key says which.
349    static CORRELATED_CTES: std::cell::RefCell<HashMap<String, String>> =
350        std::cell::RefCell::new(HashMap::new());
351}
352
353/// The iterator a read of `cte_name` has to pin to one iteration of, if that
354/// binding is per-iteration at all.
355fn correlated_cte_iterator(cte_name: &str) -> Option<String> {
356    CORRELATED_CTES.with(|cell| cell.borrow().get(cte_name).cloned())
357}
358
359/// Installs the per-iteration bindings for the emission running inside
360/// `emit`, restoring the previous set after.
361fn with_correlated_ctes<T>(ctes: &[IrCteDef], emit: impl FnOnce() -> T) -> T {
362    fn collect(ctes: &[IrCteDef], into: &mut HashMap<String, String>) {
363        for c in ctes {
364            if let Some(iterator) = &c.correlated_to {
365                into.insert(c.name.clone(), iterator.clone());
366            }
367            match &c.stmt {
368                IrStmt::For(f) => collect(&f.body_ctes, into),
369                IrStmt::Insert(ins) => collect(&ins.nested_ctes, into),
370                IrStmt::Update(upd) => collect(&upd.nested_ctes, into),
371                _ => {}
372            }
373        }
374    }
375    let mut collected = HashMap::new();
376    collect(ctes, &mut collected);
377    let previous = CORRELATED_CTES.with(|cell| cell.replace(collected));
378    let result = emit();
379    CORRELATED_CTES.with(|cell| cell.replace(previous));
380    result
381}
382
383/// Installs `fanouts` for the emission running inside `emit`, restoring the
384/// previous set after, so a nested emission of another output cannot leak.
385fn with_subtype_fanouts<T>(fanouts: &HashMap<(String, String), IrPolyFanout>, emit: impl FnOnce() -> T) -> T {
386    let previous = SUBTYPE_FANOUTS.with(|cell| cell.replace(fanouts.clone()));
387    let result = emit();
388    SUBTYPE_FANOUTS.with(|cell| cell.replace(previous));
389    result
390}
391
392/// The fan-out a read of `src` goes through: its own, or the one a concrete
393/// type with subtypes needs.
394fn source_fanout(src: &IrSource) -> Option<IrPolyFanout> {
395    if let Some(fanout) = &src.poly {
396        return Some(fanout.clone());
397    }
398    if src.table.starts_with('@') {
399        return None;
400    }
401    let key = (module_of(&src.type_name).to_string(), src.table.clone());
402    SUBTYPE_FANOUTS.with(|cell| cell.borrow().get(&key).cloned())
403}
404
405/// A junction table, or the CTE standing in for one. Same `@cte:` sentinel
406/// `source_ref` honours: a multi-link populated by the statement's own nested
407/// DML reads the junction rows back out of the CTE that wrote them, since
408/// Postgres does not show them in the table yet.
409/// The name the emitter gives the CTE wrapping a `SELECT (INSERT/UPDATE …)`'s
410/// own mutation, and the stem of the junction CTEs beside it
411/// (`_dml__ml_add_0`). Public because the compiler has to name those same
412/// CTEs when it points a shape at them — see
413/// `read_nested_links_from_their_ctes`.
414pub const DML_CTE: &str = "_dml";
415
416fn junction_ref(module: &str, junction_table: &str) -> String {
417    if let Some(cte_name) = junction_table.strip_prefix("@cte:") {
418        return qi(cte_name);
419    }
420    let Some((tables, columns)) = crate::ir::parse_inherited_junction(junction_table) else {
421        return qn(module, junction_table);
422    };
423    let columns = columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
424    let branches = tables
425        .iter()
426        .map(|(module, table)| format!("SELECT {columns} FROM {}", qn(module, table)))
427        .collect::<Vec<_>>()
428        .join(" UNION ALL ");
429    format!("({branches})")
430}
431
432/// The discriminator for a path select's object result.
433///
434/// The terminal row source is the one the result came from, so that is the one
435/// whose fan-out decides whether the type is read off the row or fixed.
436fn result_type_disc(path: &IrPathSelect, alias: &str, type_name: &str) -> String {
437    match terminal_source(path, alias) {
438        Some(source) => source_type_disc(source),
439        None => type_disc(type_name),
440    }
441}
442
443fn terminal_source<'a>(path: &'a IrPathSelect, alias: &str) -> Option<&'a IrSource> {
444    if path.root.alias == alias {
445        return Some(&path.root);
446    }
447    path.joins
448        .iter()
449        .map(path_join_target)
450        .find(|target| target.alias == alias)
451}
452
453fn path_join_target(join: &IrPathJoin) -> &IrSource {
454    match join {
455        IrPathJoin::Single { target, .. }
456        | IrPathJoin::Multi { target, .. }
457        | IrPathJoin::BacklinkSingle { target, .. }
458        | IrPathJoin::BacklinkMulti { target, .. }
459        | IrPathJoin::Function { target, .. }
460        | IrPathJoin::Lateral { target, .. } => target,
461    }
462}
463
464/// The type discriminator for a row from `src`: the fanned-out column when the
465/// source reads from implementors, else the one type its table holds.
466fn source_type_disc(src: &IrSource) -> String {
467    match source_fanout(src) {
468        Some(_) => format!("{}.\"__type__\"", qi(&src.alias)),
469        None => type_disc(&src.type_name),
470    }
471}
472
473// ── Polymorphic UNION ALL ───────────────────────────────────────────────────
474
475fn emit_poly_union(implementors: &[IrPolyImplementor], columns: &[String]) -> String {
476    let col_list = columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
477    implementors
478        .iter()
479        .map(|imp| {
480            format!(
481                "    SELECT {}::text AS \"__type__\", {} FROM {}",
482                sql_str(&imp.type_name),
483                col_list,
484                qn(&imp.module, &imp.table),
485            )
486        })
487        .collect::<Vec<_>>()
488        .join("\n    UNION ALL\n")
489}
490
491// ── SELECT statement ────────────────────────────────────────────────────────
492
493fn emit_select_stmt(sel: &IrSelect, ctes: &[IrCteDef]) -> SqlOutput {
494    match sel.rows.as_slice() {
495        [IrRowSource::Bound { source, shape }] => emit_bound_select(sel, source, shape),
496        rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
497            emit_bound_union_select(sel, rows)
498        }
499        rows => emit_free_rows(sel, rows, ctes),
500    }
501}
502
503/// The branches of `select (a union b)` all carry the same object type, so
504/// they share a column set: union them in the FROM clause and project, filter
505/// and order the combined set once, under the shape's single alias.
506/// `common_columns`, when set, are the columns of the ancestor branches of
507/// different types share: each branch projects those and its rows' concrete
508/// type, rather than columns the others do not have.
509fn bound_union_from_clause(rows: &[IrRowSource], common_columns: &[String]) -> String {
510    let columns = common_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
511    rows.iter()
512        .map(|row| match row {
513            IrRowSource::Bound { source, .. } if !common_columns.is_empty() => {
514                let own_type = match source_fanout(source) {
515                    Some(_) => "\"__type__\"".to_string(),
516                    None => format!("{} AS \"__type__\"", type_disc(&source.type_name)),
517                };
518                format!("    SELECT {own_type}, {columns} FROM {}", source_ref(source))
519            }
520            IrRowSource::Bound { source, .. } => format!("    SELECT * FROM {}", source_ref(source)),
521            IrRowSource::Free(_) => unreachable!("caller checked every row is bound"),
522        })
523        .collect::<Vec<_>>()
524        .join("\n    UNION ALL\n")
525}
526
527fn emit_bound_union_select(sel: &IrSelect, rows: &[IrRowSource]) -> SqlOutput {
528    let IrRowSource::Bound { source, shape } = &rows[0] else {
529        unreachable!("caller checked every row is bound")
530    };
531    let alias = &source.alias;
532    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
533    let row_type = if sel.poly_columns.is_empty() {
534        type_disc(&source.type_name)
535    } else {
536        format!("{}.\"__type__\"", qi(alias))
537    };
538    let mut parts = vec![row_type];
539    parts.extend(pointer_exprs);
540
541    let mut sql = format!(
542        "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
543        if sel.distinct { "DISTINCT " } else { "" },
544        parts.join(",\n    "),
545        bound_union_from_clause(rows, &sel.poly_columns),
546        qi(alias),
547    );
548    append_filter(&mut sql, &sel.filter);
549    append_order_by(&mut sql, &sel.order_by);
550    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
551
552    SqlOutput {
553        sql,
554        shape: ShapeDescriptor {
555            root: ShapeNode::Object {
556                name: String::new(),
557                type_name: Some(source.type_name.clone()),
558                position: 0,
559                cardinality: Cardinality::Many,
560                pointers: prepend_type(shape_pointers),
561                has_implicit_id: shape_has_implicit_id(shape),
562            },
563        },
564        inference_plan: None,
565    }
566}
567
568/// Schema-bound SELECT — has a FROM clause, projected column shape, and
569/// (optionally) a DML source / polymorphic fan-out. `source`/`shape` are the
570/// single `IrRowSource::Bound` entry destructured by `emit_select_stmt`;
571/// everything else (filter/order_by/offset/limit/distinct/dml_source/
572/// polymorphic/poly_*) stays on the outer `IrSelect`.
573fn emit_bound_select(sel: &IrSelect, source: &IrSource, shape: &[IrShapePointer]) -> SqlOutput {
574    let alias = &source.alias;
575    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
576
577    let type_expr = if sel.polymorphic {
578        format!("{}.\"__type__\"", qi(alias))
579    } else {
580        type_disc(&source.type_name)
581    };
582    let mut parts = vec![type_expr];
583    parts.extend(pointer_exprs);
584    let tuple = parts.join(",\n    ");
585
586    let distinct = if sel.distinct { "DISTINCT " } else { "" };
587
588    // SELECT-over-DML: wrap inner statement in a CTE, select from it.
589    let from_clause = if let Some(dml) = &sel.dml_source {
590        let mut cte_parts = match dml.as_ref() {
591            IrStmt::Update(upd) if update_has_any_multilink(upd) => emit_update_multilink_ctes(upd, DML_CTE),
592            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => emit_insert_multilink_ctes(ins, DML_CTE),
593            // Nested DML the row's values read from: hoisted ahead of the row
594            // itself, since a data-modifying WITH only works at the top level.
595            IrStmt::Insert(ins) if !ins.nested_ctes.is_empty() => {
596                let mut parts = emit_user_cte_parts(&ins.nested_ctes);
597                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
598                parts
599            }
600            IrStmt::Update(upd) if !upd.nested_ctes.is_empty() => {
601                let mut parts = emit_user_cte_parts(&upd.nested_ctes);
602                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
603                parts
604            }
605            IrStmt::Update(upd) if !upd.poly_implementors.is_empty() => emit_poly_update_dml_ctes(upd, "_dml"),
606            IrStmt::Delete(del) if !del.poly_implementors.is_empty() => emit_poly_delete_dml_ctes(del, "_dml"),
607            _ => vec![format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml))],
608        };
609        let (enqueue_v, enqueue_s) = match dml.as_ref() {
610            IrStmt::Insert(ins) => (ins.enqueue_vector.as_slice(), ins.enqueue_search.as_slice()),
611            IrStmt::Update(upd) => (upd.enqueue_vector.as_slice(), upd.enqueue_search.as_slice()),
612            _ => (&[][..], &[][..]),
613        };
614        cte_parts.extend(enqueue_ctes(enqueue_v, "_dml"));
615        cte_parts.extend(enqueue_search_ctes(enqueue_s, "_dml", enqueue_v.len()));
616        format!(
617            "WITH\n{}\nSELECT {}(\n    {}\n) AS result\nFROM \"_dml\" AS {}",
618            cte_parts.join(",\n"),
619            distinct,
620            tuple,
621            qi(alias)
622        )
623    } else if sel.polymorphic && !source.table.starts_with("@cte:") {
624        // Polymorphic interface with no CTE indirection: fan out to implementor tables.
625        let union_sql = emit_poly_union(&sel.poly_implementors, &sel.poly_columns);
626        format!(
627            "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
628            distinct,
629            tuple,
630            union_sql,
631            qi(alias)
632        )
633    } else {
634        // Concrete table or CTE (pre-filtered): query directly.
635        // For CTE-backed polymorphic sources, __type__ is already present in the CTE result.
636        format!(
637            "SELECT {}(\n    {}\n) AS result\nFROM {} AS {}",
638            distinct,
639            tuple,
640            source_ref(source),
641            qi(alias)
642        )
643    };
644
645    let mut sql = from_clause;
646    append_filter(&mut sql, &sel.filter);
647    append_order_by(&mut sql, &sel.order_by);
648    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
649    append_lock_clause(&mut sql, &sel.lock);
650
651    let root_pointers = prepend_type(shape_pointers);
652    SqlOutput {
653        sql,
654        shape: ShapeDescriptor {
655            root: ShapeNode::Object {
656                name: String::new(),
657                type_name: Some(source.type_name.clone()),
658                position: 0,
659                cardinality: Cardinality::Many,
660                pointers: root_pointers,
661                has_implicit_id: shape_has_implicit_id(shape),
662            },
663        },
664        inference_plan: None,
665    }
666}
667
668/// Emit a DML statement for use as a CTE source, using `RETURNING *` to expose
669/// all columns to the outer SELECT.  The DML's own returning shape is ignored.
670/// `FROM` over the nested-DML CTEs `reading` takes a column of, prefixed by
671/// `lead`; empty when it takes none. One read through a scalar subquery
672/// (`(SELECT "id" FROM cte)`) needs no join, and joining it anyway drops every
673/// row the moment that CTE is empty -- which a guarded branch is.
674fn nested_cte_from(ctes: &[IrCteDef], reading: &str, lead: &str) -> String {
675    let joined: Vec<String> = ctes
676        .iter()
677        .map(|c| qi(&c.name))
678        .filter(|name| reading.contains(&format!("{name}.")))
679        .collect();
680    if joined.is_empty() {
681        return String::new();
682    }
683    format!("{lead}FROM {}", joined.join(", "))
684}
685
686/// Each operand's rows read through the bare `v` every scalar statement
687/// exposes as a CTE source, so operands of different kinds line up.
688fn emit_scalar_union(branches: &[IrStmt]) -> String {
689    let operands: Vec<String> = branches
690        .iter()
691        .enumerate()
692        .map(|(i, branch)| {
693            format!(
694                "SELECT \"v\" FROM (\n{}\n) AS \"_u{i}\"",
695                emit_dml_as_cte_source(branch)
696            )
697        })
698        .collect();
699    format!(
700        "SELECT ROW(v) AS result, v FROM (\n{}\n) AS _scalar",
701        operands.join("\nUNION ALL\n")
702    )
703}
704
705fn emit_dml_as_cte_source(stmt: &IrStmt) -> String {
706    match stmt {
707        IrStmt::ScalarUnion(branches) => format!("    {}", emit_scalar_union(branches)),
708        IrStmt::Insert(ins) => {
709            let rewrite_cols: std::collections::HashSet<&str> =
710                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
711            let cols: Vec<String> = ins
712                .assignments
713                .iter()
714                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
715                .map(|(c, _)| format!("    {}", qi(c)))
716                .chain(ins.rewrites.iter().map(|r| format!("    {}", qi(&r.column))))
717                .collect();
718            let vals: Vec<String> = ins
719                .assignments
720                .iter()
721                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
722                .map(|(_, e)| format!("    {}", emit_expr(e)))
723                .chain(ins.rewrites.iter().map(|r| format!("    {}", emit_expr(&r.expr))))
724                .collect();
725            let mut sql = if !ins.nested_ctes.is_empty() || ins.guard.is_some() {
726                // The row reads columns out of hoisted nested-DML CTEs, so it
727                // selects from them rather than listing literal values. Those
728                // CTEs are emitted by the caller: Postgres only allows a
729                // data-modifying WITH at the top level, never inside a CTE.
730                // A guarded insert likewise has to be a `SELECT … WHERE`, so
731                // that it writes nothing when the condition is false.
732                format!("    {}", emit_insert_row_sql(ins))
733            } else if cols.is_empty() {
734                // See `emit_insert_row_sql`: a row of nothing but defaults.
735                format!("    INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
736            } else {
737                format!(
738                    "    INSERT INTO {} (\n{}\n    ) VALUES (\n{}\n    )",
739                    target_ref(&ins.target),
740                    cols.join(",\n"),
741                    vals.join(",\n"),
742                )
743            };
744            if let Some(conflict) = &ins.unless_conflict {
745                emit_conflict(&mut sql, conflict);
746            }
747            sql.push_str("\n    RETURNING *");
748            sql
749        }
750        IrStmt::Update(upd) => {
751            // Callers must route an update with any multi-link (junction
752            // table) mutation through emit_update_multilink_ctes instead —
753            // junction INSERT/DELETE CTEs are data-modifying and Postgres
754            // requires those to sit at the *top level* of the query, so they
755            // can't be nested inside this function's single-CTE-body return
756            // value. See emit_cte_prefix and emit_select_stmt's dml_source
757            // branch, both of which check has_any_multilink before calling
758            // this function at all.
759            let alias = &upd.target.alias;
760            let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "    ");
761            let mut sql = format!(
762                "    UPDATE {} AS {}\n    SET\n{}",
763                target_ref(&upd.target),
764                qi(alias),
765                sets.join(",\n"),
766            );
767            // The nested CTEs are emitted alongside this body (see
768            // `emit_user_cte_parts`), so the reference to them needs a FROM.
769            sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
770            append_filter(&mut sql, &upd.filter);
771            // Qualified: past a FROM, a bare `*` carries the joined relation's
772            // columns too, and a reader of this CTE then finds two `id`s.
773            sql.push_str(&format!("\n    RETURNING {}.*", qi(alias)));
774            sql
775        }
776        IrStmt::Delete(del) => {
777            let alias = &del.target.alias;
778            let mut sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
779            append_filter(&mut sql, &del.filter);
780            sql.push_str("\n    RETURNING *");
781            sql
782        }
783        IrStmt::Select(inner) => match inner.rows.as_slice() {
784            // `x := (select (select T filter …) filter …)` — the inner select is
785            // what this one reads, the same `_dml` the top-level emitter reads
786            // it from. Read from the table instead, it returned every row of
787            // the type, with neither filter applied.
788            [IrRowSource::Bound { source, .. }]
789                if let Some(read) = inner.dml_source.as_deref()
790                    && !matches!(read, IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::Delete(_)) =>
791            {
792                // A subquery, not a `WITH`: this may already sit inside one.
793                let mut sql = format!(
794                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
795                    if inner.distinct { "DISTINCT " } else { "" },
796                    emit_dml_as_cte_source(read),
797                    qi(&source.alias),
798                );
799                append_filter(&mut sql, &inner.filter);
800                append_order_by(&mut sql, &inner.order_by);
801                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
802                sql
803            }
804            [IrRowSource::Bound { source, .. }] => {
805                // SELECT-over-SELECT: expose raw columns so the outer SELECT can
806                // project its own shape from them, mirroring DML's RETURNING *.
807                let from = if inner.polymorphic && !source.table.starts_with("@cte:") {
808                    format!(
809                        "(\n{}\n    ) AS {}",
810                        emit_poly_union(&inner.poly_implementors, &inner.poly_columns),
811                        qi(&source.alias),
812                    )
813                } else {
814                    format!("{} AS {}", source_ref(source), qi(&source.alias))
815                };
816                let mut sql = format!(
817                    "    SELECT {}* FROM {}",
818                    if inner.distinct { "DISTINCT " } else { "" },
819                    from
820                );
821                append_filter(&mut sql, &inner.filter);
822                append_order_by(&mut sql, &inner.order_by);
823                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
824                sql
825            }
826            // Object union: same FROM-clause union as `emit_bound_union_select`,
827            // but exposing raw columns like every other arm here.
828            rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
829                let IrRowSource::Bound { source, .. } = &rows[0] else {
830                    unreachable!("checked by the guard")
831                };
832                let mut sql = format!(
833                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
834                    if inner.distinct { "DISTINCT " } else { "" },
835                    bound_union_from_clause(rows, &inner.poly_columns),
836                    qi(&source.alias),
837                );
838                append_filter(&mut sql, &inner.filter);
839                append_order_by(&mut sql, &inner.order_by);
840                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
841                sql
842            }
843            // Free rows: full UNION-ALL emission, same as before the merge
844            // (this arm used to be a separate IrStmt::FreeSelect match). Only
845            // `.sql` is used here, and CtePassthrough's SQL text doesn't
846            // depend on the ctes list (only its *shape* resolution does), so
847            // an empty slice is fine.
848            _ => emit_select_stmt(inner, &[]).sql,
849        },
850        IrStmt::FunctionSelect(sel) => {
851            // Expose raw columns so the outer SELECT can project its own shape,
852            // mirroring how IrStmt::Select works as a CTE source.
853            let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
854            let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
855            let mut sql = format!("    SELECT * FROM {} AS {}", fn_call, qi(&sel.alias));
856            append_filter(&mut sql, &sel.filter);
857            append_order_by(&mut sql, &sel.order_by);
858            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
859            sql
860        }
861        IrStmt::For(f) => {
862            // `for x in S union (...)` as a set source: iterate in the FROM
863            // clause and expose the body's raw columns, the same contract the
864            // other arms honour. An empty literal iterator yields no rows, but
865            // still has to name a column for the outer SELECT to project.
866            if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
867                return "    SELECT NULL AS \"id\" WHERE FALSE".to_string();
868            }
869            let (values_from, _) = emit_for_iterator(&f.iterator, &format!("_for_{}", f.var_name));
870            let body =
871                prefix_body_ctes(emit_dml_as_cte_source(&f.body), &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
872            format!(
873                "    SELECT \"_body\".*\n    FROM {}\n    CROSS JOIN LATERAL (\n    {}\n    ) AS \"_body\"",
874                values_from.replace('\n', "\n    "),
875                body,
876            )
877        }
878        IrStmt::Group(grp) if matches!(grp.output, IrGroupOutput::Elements) => emit_group_elements_source(grp),
879        IrStmt::Group(_) | IrStmt::VectorSearch(_) | IrStmt::FtsSearch(_) => {
880            unreachable!("cannot appear as a CTE source")
881        }
882        IrStmt::PathSelect(ps) => {
883            // A path traversal as a CTE body. What it projects depends on
884            // where the path ends: a scalar becomes a `result` column (which
885            // is what `IrExpr::CteRef { scalar: true }` reads back), an
886            // object exposes its raw columns so the outer query can both
887            // traverse it (`@cte:` source) and read its `id`.
888            let distinct = if ps.distinct { "DISTINCT " } else { "" };
889            let projection = match &ps.result {
890                // Same two-column convention a free scalar binding uses:
891                // `result` is the ROW() composite top-level decoding wants,
892                // `v` the bare value `IrExpr::CteRef { scalar: true }` reads.
893                IrPathResult::Scalar(e, _) => format!("{}{} AS v", distinct, emit_expr(e)),
894                IrPathResult::Object { alias, .. } => format!("{}{}.*", distinct, qi(alias)),
895            };
896            let mut sql = format!(
897                "SELECT {}\n    FROM {}",
898                projection,
899                emit_path_joins(&ps.root, &ps.joins)
900            );
901            append_filter(&mut sql, &ps.filter);
902            append_order_by(&mut sql, &ps.order_by);
903            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
904            match &ps.result {
905                IrPathResult::Scalar(e, _) => {
906                    let row_value = if enum_type_of_expr(e).is_some() { "v::text" } else { "v" };
907                    format!("    SELECT ROW({row_value}) AS result, v FROM ({sql}) AS _scalar")
908                }
909                IrPathResult::Object { .. } => format!("    {sql}"),
910            }
911        }
912    }
913}
914
915// ── Polymorphic DML-as-CTE fan-out ──────────────────────────────────────────
916//
917// A DML statement targeting an interface/abstract type can't be a single
918// `UPDATE`/`DELETE ... RETURNING *` the way emit_dml_as_cte_source handles a
919// concrete-type target — Postgres has no single physical relation backing
920// the interface (each concrete implementor has its own table), and a bare
921// `RETURNING *` off whichever one table `source_ref` happened to resolve
922// would only ever see that one implementor's rows and never carry a
923// `__type__` discriminator column at all (confirmed live: querying such a
924// CTE from an outer polymorphic SELECT raised "column t1.__type__ does not
925// exist", since the outer SELECT — see emit_select_stmt and the "CTE-backed
926// polymorphic sources" comment there — assumes any CTE tied to a polymorphic
927// type already exposes one).
928//
929// Mirrors emit_poly_update_stmt/emit_poly_delete_stmt's per-implementor
930// UNION ALL fan-out (one CTE per concrete table, each RETURNING-ing rows,
931// unioned together with a compile-time-known type literal per branch — the
932// type is known per branch even though it can vary per row across the whole
933// statement, since each branch only ever touches one implementor's table).
934// The difference here: those two functions only need `id` back (a
935// standalone DML's own default RETURNING shape), while a DML-as-CTE source
936// must expose whatever columns the *outer* SELECT might project — so this
937// returns the interface's full common column set (poly_columns: every
938// property + link FK the type declares, guaranteed present on every
939// implementor table) instead of just id.
940//
941// Not handled: a multi-link mutation (+=/-=/:=) on the interface's own
942// pointer combined with poly_implementors on the same statement — that
943// would need per-implementor junction-table CTEs too, which
944// emit_update_multilink_ctes doesn't do either today. Callers check
945// update_has_any_multilink first and keep routing that (rarer) combination
946// through the existing (equally not-poly-aware) path rather than silently
947// mishandling it here.
948
949fn emit_poly_update_dml_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
950    let alias = &upd.target.alias;
951    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
952    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
953
954    let mut cte_parts = vec![];
955    let mut union_parts = vec![];
956    for (i, imp) in upd.poly_implementors.iter().enumerate() {
957        let cte_name = format!("{}__u{}", name, i);
958        let mut upd_sql = format!(
959            "UPDATE {} AS {}\nSET {}",
960            qn(&imp.module, &imp.table),
961            qi(alias),
962            sets.join(", "),
963        );
964        append_filter(&mut upd_sql, &upd.filter);
965        upd_sql.push_str(&format!("\nRETURNING {}", col_list));
966        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
967
968        union_parts.push(format!(
969            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
970            sql_str(&imp.type_name),
971            col_list,
972            cte_name,
973        ));
974    }
975    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
976    cte_parts
977}
978
979/// One implementor's own copy of a polymorphic update: it writes to that
980/// implementor's table, and to that implementor's own junction tables — an
981/// interface's junction is a view over them, which Postgres refuses to
982/// insert into.
983fn concrete_poly_update(upd: &IrUpdate, imp: &IrPolyImplementor) -> IrUpdate {
984    let interface_prefix = format!("{}.", upd.target.table);
985    let own_prefix = format!("{}.", imp.table);
986    let own_junction = |table: &mut String, module: &mut String| {
987        if let Some(link) = table.strip_prefix(&interface_prefix) {
988            *table = format!("{own_prefix}{link}");
989            module.clone_from(&imp.module);
990        }
991    };
992    let mut concrete = upd.clone();
993    concrete.poly_implementors = vec![];
994    concrete.target.table = imp.table.clone();
995    concrete.target.type_name = imp.type_name.clone();
996    concrete.target.poly = None;
997    concrete
998        .multi_link_clears
999        .iter_mut()
1000        .for_each(|c| own_junction(&mut c.junction_table, &mut c.module));
1001    concrete
1002        .multi_link_replaces
1003        .iter_mut()
1004        .chain(concrete.multi_link_appends.iter_mut())
1005        .chain(concrete.multi_link_removals.iter_mut())
1006        .for_each(|m| own_junction(&mut m.junction_table, &mut m.module));
1007    concrete
1008}
1009
1010/// `emit_poly_update_dml_ctes` for an update that also mutates a multi-link:
1011/// each implementor gets the whole sibling set `emit_update_multilink_ctes`
1012/// builds, against its own tables.
1013fn emit_poly_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1014    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1015    let mut cte_parts = emit_user_cte_parts(&upd.nested_ctes);
1016    let mut union_parts = vec![];
1017    for (i, imp) in upd.poly_implementors.iter().enumerate() {
1018        let cte_name = format!("{}__u{}", name, i);
1019        cte_parts.extend(emit_update_multilink_cte_parts(
1020            &concrete_poly_update(upd, imp),
1021            &cte_name,
1022            false,
1023        ));
1024        union_parts.push(format!(
1025            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1026            sql_str(&imp.type_name),
1027            col_list,
1028            cte_name,
1029        ));
1030    }
1031    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1032    cte_parts
1033}
1034
1035fn emit_poly_delete_dml_ctes(del: &IrDelete, name: &str) -> Vec<String> {
1036    let alias = &del.target.alias;
1037    let col_list = del.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1038
1039    let mut cte_parts = vec![];
1040    let mut union_parts = vec![];
1041    for (i, imp) in del.poly_implementors.iter().enumerate() {
1042        let cte_name = format!("{}__d{}", name, i);
1043        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
1044        append_filter(&mut del_sql, &del.filter);
1045        del_sql.push_str(&format!("\nRETURNING {}", col_list));
1046        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
1047
1048        union_parts.push(format!(
1049            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1050            sql_str(&imp.type_name),
1051            col_list,
1052            cte_name,
1053        ));
1054    }
1055    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1056    cte_parts
1057}
1058
1059// ── User CTE helpers ────────────────────────────────────────────────────────
1060
1061fn update_has_any_multilink(upd: &IrUpdate) -> bool {
1062    !upd.multi_link_clears.is_empty()
1063        || !upd.multi_link_replaces.is_empty()
1064        || !upd.multi_link_appends.is_empty()
1065        || !upd.multi_link_removals.is_empty()
1066}
1067
1068/// Extra `AND target NOT IN (...)` clause for a junction clear paired with
1069/// a replace at the same index (`friends := expr` — see `IrUpdate.
1070/// multi_link_clears`'s own doc comment on the shared-index pairing).
1071///
1072/// Without this, a target present in *both* the old and new sets gets
1073/// deleted here and then silently fails to come back: the paired INSERT
1074/// (`emit_ml_append_cte`) runs its `ON CONFLICT` check against the same
1075/// statement-wide snapshot every CTE in one `WITH` shares, which still
1076/// shows the row this DELETE is *about to* remove as present — Postgres
1077/// treats that as a real conflict and skips the insert (`ON CONFLICT DO
1078/// NOTHING`) or updates the row this DELETE is also deleting (`DO UPDATE`,
1079/// the link-properties case), not "insert the row back." Confirmed live:
1080/// reassigning a junction-backed single link to its own already-set value,
1081/// or replacing a multi-link with a set overlapping its current one,
1082/// silently dropped every overlapping member. Excluding still-wanted
1083/// targets from the DELETE instead means they're never touched at all, so
1084/// there's nothing for the INSERT to conflict with.
1085fn ml_clear_exclusion(rep: Option<&IrMultiLinkMutation>) -> String {
1086    match rep {
1087        Some(rep) => {
1088            let vals_ref = emit_multilink_values_subquery(&rep.values, &[]);
1089            format!(
1090                " AND {} NOT IN (SELECT \"_v\".\"id\" FROM {} AS \"_v\")",
1091                qi(&rep.target_col),
1092                vals_ref
1093            )
1094        }
1095        None => String::new(),
1096    }
1097}
1098
1099/// Builds the CTE chain for an UPDATE with multi-link (junction table)
1100/// mutations, bound to a single external `name` (a user WITH-binding, or the
1101/// implicit "_dml" wrapper for `SELECT (UPDATE ...)`). Junction INSERT/DELETE
1102/// statements are themselves data-modifying, and Postgres requires
1103/// data-modifying CTEs to sit at the *top level* of the query — they can't
1104/// be nested inside another CTE's own body (confirmed live: nesting them
1105/// raises "WITH clause containing a data-modifying statement must be at the
1106/// top level"). So this returns a flat list of sibling CTE parts, prefixed
1107/// by `name` to stay collision-free alongside any other bound statements in
1108/// the same WITH block, ending in a `"{name}" AS (SELECT * FROM
1109/// "{name}__ids")` passthrough — every other reference to `name` keeps
1110/// seeing the updated row's full columns exactly as `RETURNING *` would have
1111/// exposed them.
1112fn emit_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1113    emit_update_multilink_cte_parts(upd, name, true)
1114}
1115
1116/// `emit_nested` is false for a caller that has already emitted
1117/// `upd.nested_ctes` beside this one: a polymorphic update emits one branch
1118/// per implementor off the same `IrUpdate`, and a `WITH` may name each
1119/// hoisted statement only once.
1120fn emit_update_multilink_cte_parts(upd: &IrUpdate, name: &str, emit_nested: bool) -> Vec<String> {
1121    let alias = &upd.target.alias;
1122    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
1123    let ids_name = format!("{}__ids", name);
1124    // The nested statements its values read from, ahead of it.
1125    let mut parts: Vec<String> = if emit_nested {
1126        emit_user_cte_parts(&upd.nested_ctes)
1127    } else {
1128        vec![]
1129    };
1130
1131    if has_scalar_changes {
1132        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1133        let mut upd_sql = format!(
1134            "UPDATE {} AS {}\nSET {}",
1135            target_ref(&upd.target),
1136            qi(alias),
1137            sets.join(", "),
1138        );
1139        // A nested statement's CTE sits beside this one, so reading its id
1140        // needs a FROM — the same reason the plain update path has one.
1141        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
1142        append_filter(&mut upd_sql, &upd.filter);
1143        // See `emit_dml_as_cte_source`: past a FROM, a bare `*` would carry the
1144        // joined relation's columns into this CTE beside the target's.
1145        upd_sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1146        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, upd_sql));
1147    } else {
1148        let mut sel = format!(
1149            "SELECT {}.* FROM {} AS {}",
1150            qi(alias),
1151            target_ref(&upd.target),
1152            qi(alias),
1153        );
1154        append_filter(&mut sel, &upd.filter);
1155        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sel));
1156    }
1157
1158    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
1159        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
1160        let del = format!(
1161            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"{}\"){}",
1162            qn(&clr.module, &clr.junction_table),
1163            qi(&clr.source_col),
1164            ids_name,
1165            exclude,
1166        );
1167        parts.push(format!("\"{}__clr_{}\" AS (\n{}\n)", name, i, del));
1168    }
1169    for (i, app) in upd.multi_link_appends.iter().enumerate() {
1170        parts.push(emit_ml_append_cte(
1171            app,
1172            &ids_name,
1173            &upd.target.alias,
1174            &format!("{}__ml_add_{}", name, i),
1175        ));
1176    }
1177    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
1178        parts.push(emit_ml_remove_cte(
1179            rem,
1180            &ids_name,
1181            &upd.target.alias,
1182            &format!("{}__ml_rm_{}", name, i),
1183        ));
1184    }
1185    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
1186        parts.push(emit_ml_append_cte(
1187            rep,
1188            &ids_name,
1189            &upd.target.alias,
1190            &format!("{}__ml_rep_{}", name, i),
1191        ));
1192    }
1193
1194    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1195    parts
1196}
1197
1198fn insert_has_any_multilink(ins: &IrInsert) -> bool {
1199    !ins.multi_link_appends.is_empty()
1200}
1201
1202/// Builds the CTE chain for an INSERT with multi-link (junction table)
1203/// assignments at creation time — same top-level-CTE constraint as
1204/// `emit_update_multilink_ctes` (junction INSERTs are themselves
1205/// data-modifying). The row itself is inserted first (`{name}__ids`, via
1206/// `RETURNING *`) so the junction CTEs can reference its freshly-generated id.
1207fn emit_insert_multilink_ctes(ins: &IrInsert, name: &str) -> Vec<String> {
1208    let ids_name = format!("{}__ids", name);
1209    let mut parts: Vec<String> = emit_user_cte_parts(&ins.nested_ctes);
1210
1211    let mut insert_sql = emit_insert_row_sql(ins);
1212    if let Some(conflict) = &ins.unless_conflict {
1213        emit_conflict(&mut insert_sql, conflict);
1214    }
1215    insert_sql.push_str("\nRETURNING *");
1216    parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, insert_sql));
1217
1218    for (i, app) in ins.multi_link_appends.iter().enumerate() {
1219        parts.push(emit_ml_append_cte(
1220            app,
1221            &ids_name,
1222            &ins.target.alias,
1223            &format!("{}__ml_add_{}", name, i),
1224        ));
1225    }
1226
1227    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1228    parts
1229}
1230
1231/// A `for` whose body mutates, bound to a WITH name: the same iteration-driven
1232/// shapes `emit_for_insert`/`emit_for_update` build at the top level, but split
1233/// into sibling CTE parts. A data-modifying statement can neither sit inside
1234/// another CTE's body nor in a LATERAL, so the loop cannot be emitted as the
1235/// binding's own subquery the way a select-bodied one is.
1236/// A `for` whose body mutates, bound to a WITH name. `outer_alias` names an
1237/// enclosing loop's iteration when this loop reads it, which makes every row
1238/// this one writes belong to one iteration of it rather than to all of them. `outer_alias` names
1239/// that loop's iteration when this loop reads it, which makes every row this
1240/// one writes belong to one iteration of it rather than to all of them.
1241fn emit_for_dml_ctes_within(f: &IrFor, name: &str, outer_alias: Option<&str>) -> Vec<String> {
1242    let iter_alias = format!("_for_{}", f.var_name);
1243    let (_, plain_iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
1244    let correlated = outer_alias;
1245    let iter_cte = match correlated {
1246        Some(outer) => emit_nested_for_iterator(&f.iterator, &iter_alias, outer),
1247        None => plain_iter_cte,
1248    };
1249    // The iterator first: a body CTE may read the loop variable, and a WITH
1250    // name is only in scope for the bindings that follow it.
1251    let mut parts: Vec<String> = vec![iter_cte];
1252    parts.extend(emit_user_cte_parts(&f.body_ctes));
1253    let ids_name = format!("{}__ids", name);
1254
1255    // Rows written under a correlated loop carry their iteration key, so the
1256    // enclosing statement can pair them with the row that iteration wrote.
1257    if correlated.is_some()
1258        && let IrStmt::Insert(ins) = f.body.as_ref()
1259        && ins.multi_link_appends.is_empty()
1260    {
1261        let rows_name = format!("{}__rows", name);
1262        let outer_source = format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY));
1263        parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1264        parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1265        parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, rows_name));
1266        return parts;
1267    }
1268
1269    match f.body.as_ref() {
1270        IrStmt::Insert(ins) => {
1271            let rewrite_cols: std::collections::HashSet<&str> =
1272                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1273            let cols: Vec<String> = ins
1274                .assignments
1275                .iter()
1276                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1277                .map(|(c, _)| qi(c))
1278                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1279                .collect();
1280            let values: Vec<String> = ins
1281                .assignments
1282                .iter()
1283                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1284                .map(|(_, e)| emit_expr(e))
1285                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1286                .collect();
1287            // A nested loop that carried the iteration key back means these
1288            // rows need one too, to pair with.
1289            let rows_name = format!("{}__rows", name);
1290            let correlated_targets =
1291                correlated_append_indices(&ins.multi_link_appends, &[&f.body_ctes, &ins.nested_ctes]);
1292            if correlated_targets.is_empty() {
1293                let mut sql = format!(
1294                    "INSERT INTO {} ({})\nSELECT {} FROM {}",
1295                    target_ref(&ins.target),
1296                    cols.join(", "),
1297                    values.join(", "),
1298                    qi(&iter_alias),
1299                );
1300                if let Some(conflict) = &ins.unless_conflict {
1301                    emit_conflict(&mut sql, conflict);
1302                }
1303                sql.push_str("\nRETURNING *");
1304                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1305            } else {
1306                let outer_source = match correlated {
1307                    Some(_) => format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY)),
1308                    None => format!("{}.{}", qi(&iter_alias), qi("v")),
1309                };
1310                parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1311                parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1312            }
1313            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1314                let cte_name = format!("{}__ml_add_{}", name, i);
1315                if correlated_targets.contains(&i) {
1316                    let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
1317                        unreachable!("only a CTE-backed value can carry an iteration key")
1318                    };
1319                    parts.push(emit_correlated_ml_append_cte(append, &rows_name, target, &cte_name));
1320                } else {
1321                    parts.push(emit_ml_append_cte(append, &ids_name, &ins.target.alias, &cte_name));
1322                }
1323            }
1324        }
1325        IrStmt::Update(upd) => {
1326            let alias = &upd.target.alias;
1327            if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
1328                const ITER_COL: &str = "_iter";
1329                // A nested insert gets its id generated here rather than by the
1330                // column default, so the junction below can name the row this
1331                // iteration inserted. `RETURNING` offers no order to pair on.
1332                let generated: Vec<Option<&IrInsert>> = upd
1333                    .multi_link_appends
1334                    .iter()
1335                    .map(|a| per_iteration_insert(a, &upd.nested_ctes))
1336                    .collect();
1337                let new_cols: String = generated
1338                    .iter()
1339                    .enumerate()
1340                    .filter_map(|(i, ins)| {
1341                        ins.map(|ins| {
1342                            format!(
1343                                ", {} AS {}",
1344                                ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
1345                                qi(&format!("_new_{i}"))
1346                            )
1347                        })
1348                    })
1349                    .collect();
1350                let mut ids_sql = format!(
1351                    "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
1352                    ids_name,
1353                    qi(alias),
1354                    qi(&iter_alias),
1355                    qi(ITER_COL),
1356                    new_cols,
1357                    target_ref(&upd.target),
1358                    qi(alias),
1359                    qi(&iter_alias),
1360                );
1361                append_filter(&mut ids_sql, &upd.filter);
1362                ids_sql.push_str("\n)");
1363                parts.push(ids_sql);
1364                for (i, append) in upd.multi_link_appends.iter().enumerate() {
1365                    match generated[i] {
1366                        Some(ins) => {
1367                            parts.push(emit_for_nested_insert_cte(
1368                                ins,
1369                                &ids_name,
1370                                &iter_alias,
1371                                &format!("{}__ml_ins_{}", name, i),
1372                                &format!("_new_{i}"),
1373                            ));
1374                            parts.push(emit_for_ml_append_cte(
1375                                append,
1376                                &ids_name,
1377                                &format!("{}__ml_add_{}", name, i),
1378                                &format!("_new_{i}"),
1379                            ));
1380                        }
1381                        None => parts.push(emit_for_ml_append_cte(
1382                            append,
1383                            &ids_name,
1384                            &format!("{}__ml_add_{}", name, i),
1385                            ITER_COL,
1386                        )),
1387                    }
1388                }
1389            } else {
1390                let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1391                let mut sql = format!(
1392                    "UPDATE {} AS {}\nSET {}\nFROM {}",
1393                    target_ref(&upd.target),
1394                    qi(alias),
1395                    sets.join(", "),
1396                    qi(&iter_alias),
1397                );
1398                append_filter(&mut sql, &upd.filter);
1399                sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1400                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1401            }
1402        }
1403        // `for line in … union (for component in … union (insert …))` — one
1404        // iterator CTE per loop, the inner carrying the outer's key, and the
1405        // insert driven from the two joined on it.
1406        IrStmt::For(inner) => {
1407            let inner_alias = format!("_for_{}", inner.var_name);
1408            parts.push(emit_nested_for_iterator(&inner.iterator, &inner_alias, &iter_alias));
1409            parts.extend(emit_user_cte_parts(&inner.body_ctes));
1410            let IrStmt::Insert(ins) = inner.body.as_ref() else {
1411                unreachable!("a nested for-loop's own body is an insert: {:?}", inner.body)
1412            };
1413            let rewrite_cols: std::collections::HashSet<&str> =
1414                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1415            let cols: Vec<String> = ins
1416                .assignments
1417                .iter()
1418                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1419                .map(|(c, _)| qi(c))
1420                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1421                .collect();
1422            let values: Vec<String> = ins
1423                .assignments
1424                .iter()
1425                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1426                .map(|(_, e)| emit_expr(e))
1427                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1428                .collect();
1429            let mut sql = format!(
1430                "INSERT INTO {} ({})\nSELECT {} FROM {}",
1431                target_ref(&ins.target),
1432                cols.join(", "),
1433                values.join(", "),
1434                nested_for_from(&inner_alias, &iter_alias),
1435            );
1436            if let Some(conflict) = &ins.unless_conflict {
1437                emit_conflict(&mut sql, conflict);
1438            }
1439            sql.push_str("\nRETURNING *");
1440            parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1441            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1442                parts.push(emit_ml_append_cte(
1443                    append,
1444                    &ids_name,
1445                    &ins.target.alias,
1446                    &format!("{}__ml_add_{}", name, i),
1447                ));
1448            }
1449        }
1450        // Unreachable: only a mutating body is routed here.
1451        other => unreachable!("for-loop body is not a mutation: {other:?}"),
1452    }
1453
1454    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1455    parts
1456}
1457
1458/// Expands a list of user-bound WITH names into their top-level CTE parts —
1459/// usually one part per name, except an UPDATE or INSERT with any multi-link
1460/// mutation expands into several sibling parts (see
1461/// emit_update_multilink_ctes / emit_insert_multilink_ctes), and an UPDATE or
1462/// DELETE targeting an interface/abstract type similarly fans out into one
1463/// part per concrete implementor (see emit_poly_update_dml_ctes /
1464/// emit_poly_delete_dml_ctes) — neither can be nested inside a single name's
1465/// own CTE body.
1466fn emit_user_cte_parts(ctes: &[IrCteDef]) -> Vec<String> {
1467    let mut parts: Vec<String> = vec![];
1468    for c in ctes {
1469        // A hoisted statement may have hoisted one of its own —
1470        // `credentials := (insert access::Credentials { … })` inside an update.
1471        // Those CTEs are emitted beside it rather than inside its body, which
1472        // is where its own SQL expects to read them from.
1473        // The multi-link builders emit a statement's own, ahead of it.
1474        let nested: &[IrCteDef] = match &c.stmt {
1475            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => &[],
1476            IrStmt::Update(upd) if update_has_any_multilink(upd) => &[],
1477            IrStmt::Insert(ins) => &ins.nested_ctes,
1478            IrStmt::Update(upd) => &upd.nested_ctes,
1479            _ => &[],
1480        };
1481        if !nested.is_empty() {
1482            parts.extend(emit_user_cte_parts(nested));
1483        }
1484        if let IrStmt::Update(upd) = &c.stmt {
1485            if !upd.poly_implementors.is_empty() {
1486                parts.extend(if update_has_any_multilink(upd) {
1487                    emit_poly_update_multilink_ctes(upd, &c.name)
1488                } else {
1489                    emit_poly_update_dml_ctes(upd, &c.name)
1490                });
1491                continue;
1492            }
1493            if update_has_any_multilink(upd) {
1494                parts.extend(emit_update_multilink_ctes(upd, &c.name));
1495                continue;
1496            }
1497        }
1498        if let IrStmt::Insert(ins) = &c.stmt
1499            && insert_has_any_multilink(ins)
1500        {
1501            parts.extend(emit_insert_multilink_ctes(ins, &c.name));
1502            continue;
1503        }
1504        if let IrStmt::Delete(del) = &c.stmt
1505            && !del.poly_implementors.is_empty()
1506        {
1507            parts.extend(emit_poly_delete_dml_ctes(del, &c.name));
1508            continue;
1509        }
1510        if let IrStmt::For(f) = &c.stmt
1511            && matches!(f.body.as_ref(), IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::For(_))
1512        {
1513            parts.extend(emit_for_dml_ctes_within(f, &c.name, c.correlated_to.as_deref()));
1514            continue;
1515        }
1516        let body = emit_dml_as_cte_source(&c.stmt);
1517        parts.push(match &c.correlated_to {
1518            // Evaluated once per iteration and keyed by it, since a CTE is
1519            // evaluated once for the whole statement whatever it reads.
1520            Some(iterator) => format!(
1521                "\"{}\" AS (\nSELECT {}.\"v\" AS {}, \"_row\".*\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_row\"\n)",
1522                c.name,
1523                qi(iterator),
1524                qi(OUTER_KEY),
1525                qi(iterator),
1526                body,
1527            ),
1528            None => format!("\"{}\" AS (\n{}\n)", c.name, body),
1529        });
1530    }
1531    parts
1532}
1533
1534/// Emit `WITH "name" AS (...), ...` prefix (WITH keyword + trailing newline included).
1535fn emit_cte_prefix(ctes: &[IrCteDef]) -> String {
1536    format!("WITH\n{}\n", emit_user_cte_parts(ctes).join(",\n"))
1537}
1538
1539/// Collects every link-property name assigned anywhere in a multilink value
1540/// tree (both sides of any nested `union`), in first-seen order — the SQL
1541/// layer needs one consistent column list across every unioned branch, even
1542/// when different targets set different (or no) properties.
1543pub(crate) fn collect_link_prop_names(vals: &IrMultiLinkValues, names: &mut Vec<String>) {
1544    for (name, _) in &vals.link_props {
1545        if !names.contains(name) {
1546            names.push(name.clone());
1547        }
1548    }
1549    match &vals.source {
1550        IrMultiLinkValueSource::Union(a, b) => {
1551            collect_link_prop_names(a, names);
1552            collect_link_prop_names(b, names);
1553        }
1554        IrMultiLinkValueSource::Asserted { inner, .. } => collect_link_prop_names(inner, names),
1555        _ => {}
1556    }
1557}
1558
1559/// `, <expr> AS "name"` for each entry in `prop_names` — the value assigned to
1560/// *this* node if any, else a bare `NULL` (Postgres infers its type from the
1561/// other union branch's typed value in the same column position; if no
1562/// branch ever sets it, the INSERT's target column type coerces it).
1563fn emit_link_prop_cols(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1564    prop_names
1565        .iter()
1566        .map(|name| match vals.link_props.iter().find(|(n, _)| n == name) {
1567            Some((_, expr)) => format!(", {} AS {}", emit_expr(expr), qi(name)),
1568            None => format!(", NULL AS {}", qi(name)),
1569        })
1570        .collect()
1571}
1572
1573/// Emit `(SELECT id[, prop, ...] FROM ...)` sub-expression for a multi-link
1574/// values source. `prop_names` is the full set of link-property names used
1575/// anywhere in the enclosing mutation (see `collect_link_prop_names`) — every
1576/// leaf projects all of them so a `union` of heterogeneous branches has a
1577/// consistent column list.
1578fn emit_multilink_values_subquery(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1579    emit_multilink_values_inner(vals, prop_names, true)
1580}
1581
1582/// `bare_cte_ok` is false wherever the result has to be a SELECT in its own
1583/// right — a `UNION ALL` operand, or the body of an assert. A CTE reference
1584/// otherwise emits just its quoted name, which only works where the caller
1585/// aliases it into a FROM.
1586fn emit_multilink_values_inner(vals: &IrMultiLinkValues, prop_names: &[String], bare_cte_ok: bool) -> String {
1587    if let IrMultiLinkValueSource::Union(a, b) = &vals.source {
1588        return format!(
1589            "({}\nUNION ALL\n{})",
1590            emit_multilink_values_inner(a, prop_names, false),
1591            emit_multilink_values_inner(b, prop_names, false),
1592        );
1593    }
1594
1595    // The assert wraps the whole value, so the ids are collected once, passed
1596    // through the check, and unnested back into rows. Reading the inner value
1597    // twice — once for the check, once for the rows — would run any `insert`
1598    // inside it twice.
1599    if let IrMultiLinkValueSource::Asserted {
1600        fn_name,
1601        inner,
1602        message,
1603    } = &vals.source
1604    {
1605        let inner_sql = emit_multilink_values_inner(inner, &[], false);
1606        let prop_cols = emit_link_prop_cols(vals, prop_names);
1607        return format!(
1608            "(SELECT unnest(\"_pylon\".{}(ARRAY(SELECT \"_v\".\"id\" FROM {} AS \"_v\"){})) AS \"id\"{})",
1609            qi(fn_name),
1610            inner_sql,
1611            assert_message_arg(message),
1612            prop_cols,
1613        );
1614    }
1615
1616    let prop_cols = emit_link_prop_cols(vals, prop_names);
1617
1618    match &vals.source {
1619        IrMultiLinkValueSource::CteRef(name) => {
1620            if prop_cols.is_empty() && bare_cte_ok {
1621                // Just the CTE name; will be aliased at the call site.
1622                format!("\"{}\"", name)
1623            } else {
1624                format!("(SELECT \"_s\".\"id\"{} FROM \"{}\" AS \"_s\")", prop_cols, name)
1625            }
1626        }
1627        IrMultiLinkValueSource::Select(s) => {
1628            // Compiler only ever constructs this variant for a single
1629            // schema-bound row (compile_subquery_exists's callers reject
1630            // free rows before wrapping) — see compiler.rs's multilink-
1631            // value-source sites.
1632            let [IrRowSource::Bound { source, .. }] = s.rows.as_slice() else {
1633                unreachable!("IrMultiLinkValueSource::Select is always schema-bound")
1634            };
1635            let alias = &source.alias;
1636            let mut sql = format!(
1637                "(SELECT {}.\"id\"{} FROM {} AS {}",
1638                qi(alias),
1639                prop_cols,
1640                source_ref(source),
1641                qi(alias)
1642            );
1643            append_filter(&mut sql, &s.filter);
1644            sql.push(')');
1645            sql
1646        }
1647        IrMultiLinkValueSource::PathSelect(ps) => {
1648            let root_alias = &ps.root.alias;
1649            // The targets are the rows the walk lands on, not the one it
1650            // starts from.
1651            let target_alias = match &ps.result {
1652                IrPathResult::Object { alias, .. } => alias,
1653                IrPathResult::Scalar(..) => root_alias,
1654            };
1655            let mut sql = format!(
1656                "(SELECT {}.\"id\"{} FROM {} AS {}",
1657                qi(target_alias),
1658                prop_cols,
1659                source_ref(&ps.root),
1660                qi(root_alias)
1661            );
1662            for join in &ps.joins {
1663                sql.push_str(&emit_path_join_sql(join));
1664            }
1665            append_filter(&mut sql, &ps.filter);
1666            sql.push(')');
1667            sql
1668        }
1669        IrMultiLinkValueSource::Function(fs) => {
1670            let args = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1671            let mut sql = format!(
1672                "(SELECT {}.\"id\"{} FROM {}.{}({}) AS {}",
1673                qi(&fs.alias),
1674                prop_cols,
1675                pg_schema(&fs.fn_module),
1676                qi(&fs.fn_name),
1677                args,
1678                qi(&fs.alias),
1679            );
1680            append_filter(&mut sql, &fs.filter);
1681            sql.push(')');
1682            sql
1683        }
1684        IrMultiLinkValueSource::Union(..) | IrMultiLinkValueSource::Asserted { .. } => {
1685            unreachable!("handled above")
1686        }
1687    }
1688}
1689
1690/// SQL fragment for a single path join (used in multilink values emission).
1691fn emit_path_join_sql(join: &IrPathJoin) -> String {
1692    match join {
1693        IrPathJoin::Lateral { inner, target } => {
1694            let projected = match &inner.result {
1695                IrPathResult::Object { alias, .. } => alias.clone(),
1696                IrPathResult::Scalar(..) => unreachable!("a lateral step is only built for an object-valued computed"),
1697            };
1698            let mut body = format!(
1699                "SELECT {}.* FROM {}",
1700                qi(&projected),
1701                emit_path_joins(&inner.root, &inner.joins)
1702            );
1703            append_filter(&mut body, &inner.filter);
1704            append_order_by(&mut body, &inner.order_by);
1705            append_offset_limit(&mut body, &inner.offset, &inner.limit);
1706            format!(" JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias))
1707        }
1708        IrPathJoin::Function {
1709            fn_module,
1710            fn_name,
1711            args,
1712            target,
1713        } => {
1714            let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1715            format!(
1716                " CROSS JOIN LATERAL {}.{}({}) AS {}",
1717                pg_schema(fn_module),
1718                qi(fn_name),
1719                args_sql,
1720                qi(&target.alias),
1721            )
1722        }
1723        IrPathJoin::Single {
1724            source_alias,
1725            fk_col,
1726            target,
1727        } => {
1728            format!(
1729                " JOIN {} AS {} ON {}.\"id\" = {}.{}",
1730                source_ref(target),
1731                qi(&target.alias),
1732                qi(&target.alias),
1733                qi(source_alias),
1734                qi(fk_col)
1735            )
1736        }
1737        IrPathJoin::Multi {
1738            source_alias,
1739            junction_alias,
1740            join: ml_join,
1741            target,
1742        } => {
1743            let (jt_ref, src_col, tgt_col) = match ml_join {
1744                IrMultiLinkJoin::Standard { junction_table, module } => (
1745                    junction_ref(module, junction_table),
1746                    "source".to_string(),
1747                    "target".to_string(),
1748                ),
1749                IrMultiLinkJoin::Through {
1750                    junction_table,
1751                    module,
1752                    source_col,
1753                    target_col,
1754                } => (
1755                    junction_ref(module, junction_table),
1756                    source_col.clone(),
1757                    target_col.clone(),
1758                ),
1759                // A forward `IrPathJoin::Multi` step is always built from a
1760                // real multi-link (`compile_path_select`'s middle-step
1761                // handling), never a backlink — the Backlink* variants only
1762                // ever appear inside `IrShapePointer::MultiLink.join`.
1763                IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
1764                    unreachable!("a forward multi-link path step never uses a backlink join variant")
1765                }
1766            };
1767            format!(
1768                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.{} = {}.\"id\"",
1769                jt_ref,
1770                qi(junction_alias),
1771                qi(junction_alias),
1772                qi(&src_col),
1773                qi(source_alias),
1774                source_ref(target),
1775                qi(&target.alias),
1776                qi(junction_alias),
1777                qi(&tgt_col),
1778                qi(&target.alias),
1779            )
1780        }
1781        IrPathJoin::BacklinkSingle {
1782            source_alias,
1783            fk_col,
1784            target,
1785        } => {
1786            format!(
1787                " JOIN {} AS {} ON {}.{} = {}.\"id\"",
1788                source_ref(target),
1789                qi(&target.alias),
1790                qi(&target.alias),
1791                qi(fk_col),
1792                qi(source_alias),
1793            )
1794        }
1795        IrPathJoin::BacklinkMulti {
1796            source_alias,
1797            junction_alias,
1798            junction_table,
1799            module,
1800            owner_col,
1801            current_col,
1802            target,
1803        } => {
1804            format!(
1805                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.\"id\" = {}.{}",
1806                junction_ref(module, junction_table),
1807                qi(junction_alias),
1808                qi(junction_alias),
1809                qi(current_col),
1810                qi(source_alias),
1811                source_ref(target),
1812                qi(&target.alias),
1813                qi(&target.alias),
1814                qi(junction_alias),
1815                qi(owner_col),
1816            )
1817        }
1818    }
1819}
1820
1821/// Emit the CTE clause for a junction table INSERT (append / replace-insert).
1822/// `ids_name` is the row-source CTE to join against (usually "_ids", but
1823/// callers hoisting this into a shared top-level WITH block alongside other
1824/// user-bound statements pass a name prefixed for collision-safety instead).
1825/// The loop variable an append's value stands for, when the value is exactly
1826/// that variable (`memberships += membership`).
1827///
1828/// Such a value is compiled as a select over the target table narrowed to the
1829/// key the variable holds. Inside a `for` that key is already carried on the
1830/// driving rows, so the junction insert can read it from there instead of
1831/// correlating to a CTE it has no range-table entry for.
1832pub(crate) fn append_value_is_the_loop_variable(values: &IrMultiLinkValues, var: &str) -> bool {
1833    let IrMultiLinkValueSource::Select(sel) = &values.source else {
1834        return false;
1835    };
1836    let Some(IrExpr::BinOp(cmp)) = sel.filter.as_ref() else {
1837        return false;
1838    };
1839    matches!(
1840        (&cmp.left, &cmp.op, &cmp.right),
1841        (
1842            IrExpr::ColumnRef { column, .. },
1843            crate::parse::ast::BinOpKind::Eq,
1844            IrExpr::ForVar { name, .. },
1845        ) if column == "id" && name == var
1846    ) && values.link_props.is_empty()
1847}
1848
1849/// `emit_ml_append_cte` for a `for` body: the target comes from the column the
1850/// driving rows carry rather than from a correlated subquery.
1851/// The nested insert a multi-link append's value names, when it is one of this
1852/// update's own hoisted CTEs. Inside a `for` body such an insert runs once per
1853/// iteration, so it cannot stay a standalone CTE.
1854pub(crate) fn per_iteration_insert<'c>(mutation: &IrMultiLinkMutation, nested: &'c [IrCteDef]) -> Option<&'c IrInsert> {
1855    let IrMultiLinkValueSource::CteRef(name) = &mutation.values.source else {
1856        return None;
1857    };
1858    if !mutation.values.link_props.is_empty() {
1859        return None;
1860    }
1861    nested.iter().find(|c| &c.name == name).and_then(|c| match &c.stmt {
1862        IrStmt::Insert(ins) if ins.id_default_sql.is_some() && ins.multi_link_appends.is_empty() => Some(ins),
1863        _ => None,
1864    })
1865}
1866
1867/// A nested insert inside a `for` body: one row per driving row, with its id
1868/// taken from the column the driving CTE generated so the junction can pair
1869/// with it. The iterator CTE is joined in because the inserted values are
1870/// correlated subqueries that read the loop variable.
1871fn emit_for_nested_insert_cte(
1872    ins: &IrInsert,
1873    ids_name: &str,
1874    iter_alias: &str,
1875    cte_name: &str,
1876    id_col: &str,
1877) -> String {
1878    let mut cols = vec![qi("id")];
1879    let mut values = vec![format!("\"{}\".{}", ids_name, qi(id_col))];
1880    for (col, expr) in &ins.assignments {
1881        cols.push(qi(col));
1882        values.push(emit_expr(expr));
1883    }
1884    format!(
1885        "\"{}\" AS (\nINSERT INTO {} ({})\nSELECT {} FROM \"{}\", {}\nWHERE \"{}\".{} = {}.\"v\"\nRETURNING {}\n)",
1886        cte_name,
1887        target_ref(&ins.target),
1888        cols.join(", "),
1889        values.join(", "),
1890        ids_name,
1891        qi(iter_alias),
1892        ids_name,
1893        qi("_iter"),
1894        qi(iter_alias),
1895        qi("id"),
1896    )
1897}
1898
1899fn emit_for_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, cte_name: &str, iter_col: &str) -> String {
1900    format!(
1901        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"{}\".\"id\", \"{}\".{} FROM \"{}\"\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
1902        cte_name,
1903        qn(&mutation.module, &mutation.junction_table),
1904        qi(&mutation.source_col),
1905        qi(&mutation.target_col),
1906        ids_name,
1907        ids_name,
1908        qi(iter_col),
1909        ids_name,
1910        qi(&mutation.source_col),
1911        qi(&mutation.target_col),
1912    )
1913}
1914
1915/// `ids_alias` is the name the rows being written go by inside the value:
1916/// a walk off the row (`+= (select .notifications …)`) is correlated to it.
1917fn emit_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
1918    let mut prop_names = vec![];
1919    collect_link_prop_names(&mutation.values, &mut prop_names);
1920    let vals_ref = emit_multilink_values_subquery(&mutation.values, &prop_names);
1921
1922    let extra_cols: String = prop_names.iter().map(|n| format!(", {}", qi(n))).collect();
1923    let extra_select: String = prop_names.iter().map(|n| format!(", \"_v\".{}", qi(n))).collect();
1924
1925    // Re-checking an already-linked target with a new property value must
1926    // update in place rather than error — a bare `DO NOTHING` (the no-
1927    // properties case) would silently keep the old value instead.
1928    let conflict_clause = if mutation.single {
1929        // A junction-backed single link's table has `PRIMARY KEY (source)`
1930        // alone (D3, cardinality-one) — the paired `_clr_i` DELETE (see
1931        // `ml_clear_exclusion`) removes any existing row for this source in
1932        // a sibling CTE that shares this whole statement's one snapshot, so
1933        // this INSERT still "sees" that old row and treats it as a
1934        // same-key conflict regardless of what the new target is — a plain
1935        // `DO NOTHING` would then silently drop a genuine reassignment.
1936        // `source` alone is always this table's conflict target, so this
1937        // must always be an upsert, not just when link properties exist.
1938        let mut sets = vec![format!(
1939            "{} = EXCLUDED.{}",
1940            qi(&mutation.target_col),
1941            qi(&mutation.target_col)
1942        )];
1943        sets.extend(prop_names.iter().map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n))));
1944        format!(
1945            "ON CONFLICT ({}) DO UPDATE SET {}",
1946            qi(&mutation.source_col),
1947            sets.join(", ")
1948        )
1949    } else if prop_names.is_empty() {
1950        "ON CONFLICT DO NOTHING".to_string()
1951    } else {
1952        let sets: Vec<String> = prop_names
1953            .iter()
1954            .map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n)))
1955            .collect();
1956        format!(
1957            "ON CONFLICT ({}, {}) DO UPDATE SET {}",
1958            qi(&mutation.source_col),
1959            qi(&mutation.target_col),
1960            sets.join(", "),
1961        )
1962    };
1963
1964    // A subquery value may read the row it is written for, so it joins
1965    // laterally; a bare CTE name cannot take LATERAL.
1966    let lateral = if vals_ref.starts_with('(') { "LATERAL " } else { "" };
1967    let ins = format!(
1968        "INSERT INTO {} ({}, {}{})\nSELECT {}.\"id\", \"_v\".\"id\"{} FROM \"{}\" AS {} CROSS JOIN {}{} AS \"_v\"\n{}\nRETURNING {}, {}",
1969        qn(&mutation.module, &mutation.junction_table),
1970        qi(&mutation.source_col),
1971        qi(&mutation.target_col),
1972        extra_cols,
1973        qi(ids_alias),
1974        extra_select,
1975        ids_name,
1976        qi(ids_alias),
1977        lateral,
1978        vals_ref,
1979        conflict_clause,
1980        qi(&mutation.source_col),
1981        qi(&mutation.target_col),
1982    );
1983    format!("\"{}\" AS (\n{}\n)", cte_name, ins)
1984}
1985
1986/// Emit the CTE clause for a junction table DELETE (remove). See
1987/// emit_ml_append_cte re: `ids_name` and `ids_alias`.
1988fn emit_ml_remove_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
1989    // Removal never carries link-property assignments (rejected at compile
1990    // time in ir/compiler.rs), so no extra columns are ever needed here.
1991    let vals_ref = emit_multilink_values_subquery(&mutation.values, &[]);
1992    // A walk off the row (`-= (select .line_items …)`) is correlated to
1993    // `ids_alias`, so the rows being written are joined in under that name
1994    // rather than read through an uncorrelated `IN`.
1995    let del = format!(
1996        "DELETE FROM {} AS \"_jn\"\nUSING \"{}\" AS {}\nWHERE \"_jn\".{} = {}.\"id\"\n  AND \"_jn\".{} IN (SELECT \"id\" FROM {})\nRETURNING \"_jn\".{}, \"_jn\".{}",
1997        qn(&mutation.module, &mutation.junction_table),
1998        ids_name,
1999        qi(ids_alias),
2000        qi(&mutation.source_col),
2001        qi(ids_alias),
2002        qi(&mutation.target_col),
2003        vals_ref,
2004        qi(&mutation.source_col),
2005        qi(&mutation.target_col),
2006    );
2007    format!("\"{}\" AS (\n{}\n)", cte_name, del)
2008}
2009
2010// ── Conflict helper ─────────────────────────────────────────────────────────
2011
2012use crate::ir::IrConflict;
2013
2014fn emit_conflict(sql: &mut String, conflict: &IrConflict) {
2015    // `unless conflict on (.email, .invitee)` names several columns, not one
2016    // composite value: Postgres infers the arbiter index from a column list,
2017    // and would look for an expression index on the tuple instead.
2018    let on_sql = conflict.on.as_ref().map(|e| match e {
2019        IrExpr::Tuple(elements) => format!("({})", elements.iter().map(emit_expr).collect::<Vec<_>>().join(", ")),
2020        other => format!("({})", emit_expr(other)),
2021    });
2022    match (&on_sql, &conflict.do_update) {
2023        (None, None) => sql.push_str(" ON CONFLICT DO NOTHING"),
2024        (Some(on), None) => sql.push_str(&format!(" ON CONFLICT {} DO NOTHING", on)),
2025        (None, Some(updates)) => {
2026            sql.push_str(&format!(" ON CONFLICT DO UPDATE SET {}", do_update_sets(updates)));
2027        }
2028        (Some(on), Some(updates)) => {
2029            sql.push_str(&format!(
2030                " ON CONFLICT {} DO UPDATE SET {}",
2031                on,
2032                do_update_sets(updates),
2033            ));
2034        }
2035    }
2036    if conflict.do_update.is_some()
2037        && let Some(predicate) = &conflict.do_update_where
2038    {
2039        sql.push_str(&format!(" WHERE {}", emit_expr(predicate)));
2040    }
2041}
2042
2043fn do_update_sets(updates: &[(String, IrExpr)]) -> String {
2044    updates
2045        .iter()
2046        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
2047        .collect::<Vec<_>>()
2048        .join(", ")
2049}
2050
2051// ── FREE SELECT ─────────────────────────────────────────────────────────────
2052
2053/// Types returned as plain top-level columns rather than wrapped in the
2054/// `result` ROW() composite, so each keeps its own natural top-level shape.
2055fn is_integer_expr(expr: &IrExpr) -> bool {
2056    match expr {
2057        IrExpr::ColumnRef { pg_type, .. } => matches!(
2058            pg_type.as_str(),
2059            "int2" | "int4" | "int8" | "integer" | "bigint" | "smallint"
2060        ),
2061        IrExpr::Literal(crate::ir::IrLiteral::Int(_)) => true,
2062        IrExpr::BinOp(op) => is_integer_expr(&op.left) && is_integer_expr(&op.right),
2063        _ => false,
2064    }
2065}
2066
2067fn is_raw_scalar(expr: &IrExpr) -> bool {
2068    matches!(expr, IrExpr::Array(_))
2069        || matches!(expr, IrExpr::TypeCast(c) if c.pg_type == "jsonb")
2070        || matches!(expr, IrExpr::NamedTuple { .. })
2071        || matches!(expr, IrExpr::Tuple(_))
2072        || matches!(expr, IrExpr::JsonbField { .. })
2073        || matches!(expr, IrExpr::JsonbIndex { .. })
2074}
2075
2076/// Free (non-schema-bound) SELECT rows — one or more UNION ALL branches with
2077/// a ROW(…) wrapper. `rows` is `sel.rows` with any leading `Bound` entries
2078/// already ruled out by `emit_select_stmt`'s dispatch (a schema object and a
2079/// free literal can never appear in the same UNION — `check_union_type_compat`
2080/// rejects that at compile time), so every entry here is `IrRowSource::Free`.
2081fn emit_free_rows(sel: &IrSelect, rows: &[IrRowSource], ctes: &[IrCteDef]) -> SqlOutput {
2082    use crate::query::ShapeNode;
2083
2084    let items: Vec<&IrFreeExpr> = rows
2085        .iter()
2086        .map(|r| match r {
2087            IrRowSource::Free(item) => item,
2088            IrRowSource::Bound { .. } => unreachable!("mixed Bound/Free rows rejected at compile time"),
2089        })
2090        .collect();
2091
2092    if items.is_empty() {
2093        return SqlOutput {
2094            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
2095            shape: ShapeDescriptor {
2096                root: ShapeNode::Scalar {
2097                    name: String::new(),
2098                    position: 0,
2099                },
2100            },
2101            inference_plan: None,
2102        };
2103    }
2104
2105    // assert_exists / assert_distinct: set-returning — emit as unnest, not UNION ALL
2106    if items.len() == 1
2107        && let IrFreeExpr::AssertSet {
2108            fn_name,
2109            inner,
2110            message,
2111        } = items[0]
2112    {
2113        let array_sql = emit_array_source(inner) + &assert_message_arg(message);
2114        let mut sql = format!(
2115            "SELECT ROW(v) AS result FROM unnest(\"_pylon\".{}({})) AS _assert(v)",
2116            fn_name, array_sql,
2117        );
2118        if sel.distinct {
2119            sql = format!("SELECT DISTINCT * FROM ({}) AS \"_distinct\"", sql);
2120        }
2121        append_order_by(&mut sql, &sel.order_by);
2122        append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2123        return SqlOutput {
2124            sql,
2125            shape: ShapeDescriptor {
2126                root: ShapeNode::Scalar {
2127                    name: String::new(),
2128                    position: 0,
2129                },
2130            },
2131            inference_plan: None,
2132        };
2133    }
2134
2135    let shape_root = free_item_shape(items.first().unwrap(), ctes);
2136
2137    let branches: Vec<String> = items
2138        .iter()
2139        .map(|item| match item {
2140            IrFreeExpr::Scalar(expr) => {
2141                // An object standing as the whole result is already the
2142                // composite row `result` is meant to be — wrapping it in
2143                // another ROW() would bury its columns one level down from
2144                // where its shape node says they are.
2145                if matches!(expr, IrExpr::ObjectSubquery(_)) {
2146                    return format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _obj", emit_expr(expr));
2147                }
2148                // Arrays and jsonb are returned as plain top-level columns
2149                // rather than wrapped, so they keep their own shape node.
2150                if is_raw_scalar(expr) {
2151                    // `v` alongside it for the same reason the wrapped form
2152                    // has one: a `with` binding of an array is read by name.
2153                    format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _raw", emit_expr(expr))
2154                } else {
2155                    // `result` is a ROW() composite for top-level decoding.
2156                    // `v` is the unwrapped scalar for use in CteRef expression context.
2157                    // Wrap in a subquery so volatile functions (nextval, etc.) are called once.
2158                    // Enum values also need a ::text cast inside the ROW() — same
2159                    // unregistered-OID problem as arrays/jsonb — but the bare `v`
2160                    // column stays natively typed for CteRef expression use.
2161                    let e = emit_expr(expr);
2162                    let row_value = if enum_type_of_expr(expr).is_some() {
2163                        "v::text"
2164                    } else {
2165                        "v"
2166                    };
2167                    // In a set of several, an element that is empty is not
2168                    // in the set at all (`{a.id, b.id}` with no `a` is just
2169                    // `b.id`) — where SQL would carry it as a NULL row.
2170                    let present = if items.len() > 1 { " WHERE v IS NOT NULL" } else { "" };
2171                    format!("SELECT ROW({row_value}) AS result, v FROM (SELECT {e} AS v) AS _scalar{present}")
2172                }
2173            }
2174            IrFreeExpr::FreeObject(fields) => {
2175                // Each field is computed once in an inner subquery (so a
2176                // volatile expression like nextval() isn't evaluated twice) and
2177                // exposed both packed into the `result` composite (for whole-
2178                // object passthrough/decoding) and as its own named column (for
2179                // `IrExpr::CteFieldRef` — `with x := {a := ...} select x.a`).
2180                let inner_cols: Vec<String> = fields
2181                    .iter()
2182                    .enumerate()
2183                    .map(|(i, (_, e))| format!("{} AS \"_f{}\"", emit_expr(e), i))
2184                    .collect();
2185                let row_items: Vec<String> = fields
2186                    .iter()
2187                    .enumerate()
2188                    .map(|(i, (_, e))| {
2189                        if enum_type_of_expr(e).is_some() {
2190                            format!("\"_f{}\"::text", i)
2191                        } else {
2192                            format!("\"_f{}\"", i)
2193                        }
2194                    })
2195                    .collect();
2196                let named_cols: Vec<String> = fields
2197                    .iter()
2198                    .enumerate()
2199                    .map(|(i, (name, _))| format!("\"_f{}\" AS {}", i, qi(name)))
2200                    .collect();
2201                format!(
2202                    "SELECT ROW({}) AS result, {} FROM (SELECT {}) AS _obj",
2203                    row_items.join(", "),
2204                    named_cols.join(", "),
2205                    inner_cols.join(", "),
2206                )
2207            }
2208            IrFreeExpr::Tuple(exprs) => {
2209                if exprs.len() == 1 {
2210                    format!("SELECT ROW({}) AS result", emit_free_field_expr(&exprs[0]))
2211                } else {
2212                    let parts: Vec<String> = exprs.iter().map(emit_free_field_expr).collect();
2213                    format!("SELECT ({}) AS result", parts.join(", "))
2214                }
2215            }
2216            IrFreeExpr::NamedTupleRow(fields) => {
2217                let parts: Vec<String> = fields.iter().map(|(_, e)| emit_free_field_expr(e)).collect();
2218                if parts.len() == 1 {
2219                    format!("SELECT ROW({}) AS result", parts[0])
2220                } else {
2221                    format!("SELECT ({}) AS result", parts.join(", "))
2222                }
2223            }
2224            IrFreeExpr::AssertSet { .. } => unreachable!("AssertSet is handled by early return above"),
2225            IrFreeExpr::CtePassthrough(name) => format!("SELECT \"result\" FROM {}", qi(name)),
2226        })
2227        .collect();
2228
2229    let union_sql = branches.join("\nUNION ALL\n");
2230
2231    let mut sql = if sel.distinct {
2232        // Wrap UNION ALL in an outer SELECT DISTINCT to deduplicate.
2233        format!("SELECT DISTINCT * FROM (\n{}\n) AS \"_distinct\"", union_sql)
2234    } else {
2235        union_sql
2236    };
2237    // A free SELECT's FILTER has no row of its own to sit beside, so it gates
2238    // the result the branches already produced rather than joining their own
2239    // WHERE clauses.
2240    if let Some(filter) = &sel.filter {
2241        sql = format!(
2242            "SELECT * FROM (\n{}\n) AS \"_filtered\"\nWHERE {}",
2243            sql,
2244            emit_expr(filter)
2245        );
2246    }
2247    append_order_by(&mut sql, &sel.order_by);
2248    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2249
2250    SqlOutput {
2251        sql,
2252        shape: ShapeDescriptor { root: shape_root },
2253        inference_plan: None,
2254    }
2255}
2256
2257/// Determine whether `expr`'s runtime SQL type is a custom/enum type whose
2258/// OID is assigned by the database rather than fixed — and
2259/// if so, the Postgres-schema-qualified enum type name for `ShapeNode::Enum`
2260/// tagging (same convention `pg_quoted_to_pylon` expects). Covers both a
2261/// column reference to an enum-typed property (quoted pg_type) and a bare
2262/// enum literal (`default::Gender.Male`) — the two ways an enum value can
2263/// appear as a free scalar/object/tuple field, which (unlike a schema
2264/// object's `emit_scalar`) has no dedicated per-pointer descriptor to carry
2265/// this, so it must be recovered from the expression itself.
2266fn enum_type_of_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2267    match expr {
2268        IrExpr::ColumnRef { pg_type, .. } => QualifiedPgType::of(pg_type),
2269        IrExpr::EnumLiteral { pg_type, .. } => Some(QualifiedPgType {
2270            name: pg_quoted_to_pylon(pg_type),
2271            is_array: false,
2272        }),
2273        _ => None,
2274    }
2275}
2276
2277/// The enum type a shape position decodes as — wider than `enum_type_of_expr`,
2278/// which only answers for the values that need a `::text` cast emitting.
2279///
2280/// `[is Concrete].*` wraps each of the concrete type's properties in a
2281/// correlated subquery of its own, so the enum sits a level down and the
2282/// subquery's own emission has already cast it. Without looking through to it
2283/// the outer shape stays a plain `Scalar` and the label is handed back as a
2284/// bare string rather than the enum member.
2285fn enum_type_of_shape_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2286    if let Some(qualified) = enum_type_of_expr(expr) {
2287        return Some(qualified);
2288    }
2289    let IrExpr::Subquery(select) = expr else {
2290        return None;
2291    };
2292    let [IrRowSource::Bound { shape, .. }] = select.rows.as_slice() else {
2293        return None;
2294    };
2295    let [IrShapePointer::Scalar(scalar)] = shape.as_slice() else {
2296        return None;
2297    };
2298    QualifiedPgType::of(&scalar.pg_type)
2299}
2300
2301/// Emit `expr` for use as a free object/tuple field value — casts to
2302/// `::text` when it's enum-typed (see `enum_type_of_expr`), which keeps the
2303/// value's shape independent of the enum's database-assigned type OID.
2304/// (`pylon-pgcon` also discovers enum OIDs at connect time now, so this cast
2305/// is belt-and-braces rather than load-bearing for decoding.)
2306fn emit_free_field_expr(expr: &IrExpr) -> String {
2307    match enum_type_of_expr(expr) {
2308        Some(qualified) => format!("{}{}", emit_expr(expr), qualified.text_cast()),
2309        None => emit_expr(expr),
2310    }
2311}
2312
2313/// Shape node for one free scalar/object/tuple field — `Enum` when the
2314/// value is enum-typed (see `enum_type_of_expr`), else a plain `Scalar`.
2315fn free_field_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2316    use crate::query::{Cardinality, ShapeNode};
2317    if let IrExpr::ObjectSubquery(sel) = expr {
2318        let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2319            unreachable!("an object subquery is always schema-bound")
2320        };
2321        let (_, nodes) = build_shape(shape, &source.alias);
2322        return ShapeNode::Object {
2323            name: name.to_string(),
2324            type_name: Some(source.type_name.clone()),
2325            position,
2326            cardinality: Cardinality::Optional,
2327            pointers: prepend_type(nodes),
2328            has_implicit_id: shape_has_implicit_id(shape),
2329        };
2330    }
2331    // A walk landing on one object: `(select … limit 1).latest_data { … }`.
2332    if matches!(expr, IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { .. }) {
2333        return expr_shape_node(name, position, expr);
2334    }
2335    // A set of objects keeps its rows' own shape, as it does on a schema
2336    // shape; described as a scalar, each row hydrates as a bare tuple.
2337    if let IrExpr::ArrayFromSelect(source) = expr
2338        && matches!(
2339            source.as_ref(),
2340            IrArraySource::ObjectSelect(_)
2341                | IrArraySource::ObjectFunction(_)
2342                | IrArraySource::PathSelect(_)
2343                | IrArraySource::Group(_)
2344        )
2345    {
2346        return expr_shape_node(name, position, expr);
2347    }
2348    match enum_type_of_shape_expr(expr) {
2349        Some(qualified) => qualified.shape_node(name.to_string(), position),
2350        None => ShapeNode::Scalar {
2351            name: name.to_string(),
2352            position,
2353        },
2354    }
2355}
2356
2357/// Shape node for an arbitrary compiled expression appearing at a shape
2358/// position (a computed pointer, or a free scalar/tuple/object field) —
2359/// `NamedTuple`/`JsonScalar` when the expression produces jsonb (tuple
2360/// literals, nested free objects, tuple-typed casts), `RawScalar` when
2361/// it is returned as a bare top-level column, `Enum` when
2362/// enum-typed, else a plain `Scalar`.
2363fn expr_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2364    use crate::query::{Cardinality, ShapeNode};
2365    match expr {
2366        IrExpr::ObjectPathUnion { branches, multi, .. } => {
2367            let first = branches.first().expect("a union has at least one branch");
2368            let IrPathResult::Object {
2369                alias,
2370                type_name,
2371                shape,
2372            } = &first.result
2373            else {
2374                unreachable!("an object path union's branches always land on objects")
2375            };
2376            let (_, pointer_nodes) = build_shape(shape, alias);
2377            let object = ShapeNode::Object {
2378                name: if *multi { String::new() } else { name.to_string() },
2379                type_name: Some(type_name.clone()),
2380                position: if *multi { 0 } else { position },
2381                cardinality: if *multi {
2382                    Cardinality::Many
2383                } else {
2384                    Cardinality::Optional
2385                },
2386                pointers: prepend_type(pointer_nodes),
2387                has_implicit_id: shape_has_implicit_id(shape),
2388            };
2389            if *multi {
2390                ShapeNode::Array {
2391                    name: name.to_string(),
2392                    position,
2393                    element: Box::new(object),
2394                }
2395            } else {
2396                object
2397            }
2398        }
2399        IrExpr::ObjectPathSubquery(ps) => {
2400            let IrPathResult::Object {
2401                alias,
2402                type_name,
2403                shape,
2404            } = &ps.result
2405            else {
2406                unreachable!("an object path subquery always lands on an object")
2407            };
2408            let (_, pointer_nodes) = build_shape(shape, alias);
2409            ShapeNode::Object {
2410                name: name.to_string(),
2411                type_name: Some(type_name.clone()),
2412                position,
2413                cardinality: Cardinality::Optional,
2414                pointers: prepend_type(pointer_nodes),
2415                has_implicit_id: shape_has_implicit_id(shape),
2416            }
2417        }
2418        IrExpr::TypeCast(c) if c.tuple_shape.is_some() => {
2419            let shape = c.tuple_shape.as_ref().unwrap();
2420            ShapeNode::NamedTuple {
2421                name: name.to_string(),
2422                position,
2423                type_name: shape.type_name.clone(),
2424                members: Some(shape.members.clone()),
2425                is_free_object: false,
2426            }
2427        }
2428        // A `<json>` cast at a shape position is an ordinary column of the row
2429        // tuple, so it needs the pointer's own name and position like any
2430        // other leaf. `JsonScalar` is the *root-only* form, where the result
2431        // column itself is the value and there is no pointer to name — it
2432        // carries neither field, and `free_item_shape` is what selects it.
2433        IrExpr::TypeCast(c) if c.pg_type == "jsonb" => ShapeNode::Scalar {
2434            name: name.to_string(),
2435            position,
2436        },
2437        IrExpr::NamedTuple { is_free_object, .. } => ShapeNode::NamedTuple {
2438            name: name.to_string(),
2439            position,
2440            type_name: None,
2441            members: None,
2442            is_free_object: *is_free_object,
2443        },
2444        // A path select aggregated into an array keeps its rows' own shape, so
2445        // the elements hydrate as objects rather than as opaque scalars.
2446        IrExpr::ArrayFromSelect(source) => match source.as_ref() {
2447            IrArraySource::ObjectFunction(fs) => {
2448                let (_, nodes) = build_shape(&fs.shape, &fs.alias);
2449                ShapeNode::Array {
2450                    name: name.to_string(),
2451                    position,
2452                    element: Box::new(ShapeNode::Object {
2453                        name: String::new(),
2454                        type_name: Some(fs.type_name.clone()),
2455                        position: 0,
2456                        cardinality: Cardinality::Many,
2457                        pointers: prepend_type(nodes),
2458                        has_implicit_id: shape_has_implicit_id(&fs.shape),
2459                    }),
2460                }
2461            }
2462            IrArraySource::Group(grp) => ShapeNode::Array {
2463                name: name.to_string(),
2464                position,
2465                element: Box::new(emit_group(grp).shape.root),
2466            },
2467            IrArraySource::ObjectSelect(sel) => {
2468                let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2469                    unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2470                };
2471                let (_, nodes) = build_shape(shape, &source.alias);
2472                ShapeNode::Array {
2473                    name: name.to_string(),
2474                    position,
2475                    element: Box::new(ShapeNode::Object {
2476                        name: String::new(),
2477                        type_name: Some(source.type_name.clone()),
2478                        position: 0,
2479                        cardinality: Cardinality::Many,
2480                        pointers: prepend_type(nodes),
2481                        has_implicit_id: shape_has_implicit_id(shape),
2482                    }),
2483                }
2484            }
2485            IrArraySource::PathSelect(ps) => match &ps.result {
2486                IrPathResult::Object {
2487                    alias,
2488                    type_name,
2489                    shape,
2490                } => {
2491                    let (_, pointer_nodes) = build_shape(shape, alias);
2492                    ShapeNode::Array {
2493                        name: name.to_string(),
2494                        position,
2495                        element: Box::new(ShapeNode::Object {
2496                            name: String::new(),
2497                            type_name: Some(type_name.clone()),
2498                            position: 0,
2499                            cardinality: Cardinality::Many,
2500                            pointers: prepend_type(pointer_nodes),
2501                            has_implicit_id: shape_has_implicit_id(shape),
2502                        }),
2503                    }
2504                }
2505                IrPathResult::Scalar(..) => ShapeNode::Scalar {
2506                    name: name.to_string(),
2507                    position,
2508                },
2509            },
2510            _ => ShapeNode::Scalar {
2511                name: name.to_string(),
2512                position,
2513            },
2514        },
2515        e if is_raw_scalar(e) => ShapeNode::RawScalar,
2516        e => free_field_shape_node(name, position, e),
2517    }
2518}
2519
2520fn free_item_shape(item: &IrFreeExpr, ctes: &[IrCteDef]) -> crate::query::ShapeNode {
2521    use crate::query::{Cardinality, ShapeNode};
2522    match item {
2523        IrFreeExpr::Scalar(e) => match e {
2524            // Top level: the result column *is* the jsonb value rather than a
2525            // field inside a row tuple. A tuple-shaped cast is excluded because
2526            // `expr_shape_node` decodes that as a `NamedTuple`.
2527            IrExpr::TypeCast(c) if c.pg_type == "jsonb" && c.tuple_shape.is_none() => ShapeNode::JsonScalar,
2528            _ => expr_shape_node("", 0, e),
2529        },
2530        IrFreeExpr::FreeObject(fields) => ShapeNode::Object {
2531            name: String::new(),
2532            type_name: None,
2533            position: 0,
2534            cardinality: Cardinality::Many,
2535            pointers: fields
2536                .iter()
2537                .enumerate()
2538                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2539                .collect(),
2540            has_implicit_id: false,
2541        },
2542        IrFreeExpr::Tuple(exprs) => ShapeNode::Tuple {
2543            position: 0,
2544            elements: exprs
2545                .iter()
2546                .enumerate()
2547                .map(|(i, e)| free_field_shape_node("", i, e))
2548                .collect(),
2549            names: None,
2550        },
2551        IrFreeExpr::NamedTupleRow(fields) => ShapeNode::Tuple {
2552            position: 0,
2553            elements: fields
2554                .iter()
2555                .enumerate()
2556                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2557                .collect(),
2558            names: Some(fields.iter().map(|(name, _)| name.clone()).collect()),
2559        },
2560        IrFreeExpr::AssertSet { .. } => ShapeNode::Scalar {
2561            name: String::new(),
2562            position: 0,
2563        },
2564        // The referenced CTE's own `result` column carries whatever shape its
2565        // defining free row has (a bare scalar, a multi-field free object, a
2566        // tuple, or even another passthrough) — resolve it by looking the CTE
2567        // up rather than assuming Scalar, otherwise a multi-field free object
2568        // bound in a `WITH` clause decodes as just its first field's value.
2569        IrFreeExpr::CtePassthrough(name) => ctes
2570            .iter()
2571            .find(|c| &c.name == name)
2572            .and_then(|c| match &c.stmt {
2573                IrStmt::Select(sel) => match sel.rows.first() {
2574                    Some(IrRowSource::Free(inner)) => Some(free_item_shape(inner, ctes)),
2575                    _ => None,
2576                },
2577                _ => None,
2578            })
2579            .unwrap_or(ShapeNode::Scalar {
2580                name: String::new(),
2581                position: 0,
2582            }),
2583    }
2584}
2585
2586// ── PATH SELECT ─────────────────────────────────────────────────────────────
2587
2588fn emit_path_joins(root: &IrSource, joins: &[IrPathJoin]) -> String {
2589    let mut parts = vec![format!("{} AS {}", source_ref(root), qi(&root.alias))];
2590    for join in joins {
2591        match join {
2592            IrPathJoin::Single {
2593                source_alias,
2594                fk_col,
2595                target,
2596            } => {
2597                parts.push(format!(
2598                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2599                    source_ref(target),
2600                    qi(&target.alias),
2601                    qi(source_alias),
2602                    qi(fk_col),
2603                    qi(&target.alias),
2604                ));
2605            }
2606            IrPathJoin::Multi {
2607                source_alias,
2608                junction_alias,
2609                join,
2610                target,
2611            } => {
2612                match join {
2613                    IrMultiLinkJoin::Standard { junction_table, module } => {
2614                        parts.push(format!(
2615                            "JOIN {} AS {} ON {}.\"source\" = {}.\"id\"",
2616                            junction_ref(module, junction_table),
2617                            qi(junction_alias),
2618                            qi(junction_alias),
2619                            qi(source_alias),
2620                        ));
2621                        parts.push(format!(
2622                            "JOIN {} AS {} ON {}.\"id\" = {}.\"target\"",
2623                            source_ref(target),
2624                            qi(&target.alias),
2625                            qi(&target.alias),
2626                            qi(junction_alias),
2627                        ));
2628                    }
2629                    IrMultiLinkJoin::Through {
2630                        junction_table,
2631                        module,
2632                        source_col,
2633                        target_col,
2634                    } => {
2635                        parts.push(format!(
2636                            "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2637                            junction_ref(module, junction_table),
2638                            qi(junction_alias),
2639                            qi(junction_alias),
2640                            qi(source_col),
2641                            qi(source_alias),
2642                        ));
2643                        parts.push(format!(
2644                            "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2645                            source_ref(target),
2646                            qi(&target.alias),
2647                            qi(&target.alias),
2648                            qi(junction_alias),
2649                            qi(target_col),
2650                        ));
2651                    }
2652                    // See the identical comment in `emit_path_join_sql` above.
2653                    IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
2654                        unreachable!("a forward multi-link path step never uses a backlink join variant")
2655                    }
2656                }
2657            }
2658            IrPathJoin::BacklinkSingle {
2659                source_alias,
2660                fk_col,
2661                target,
2662            } => {
2663                parts.push(format!(
2664                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2665                    source_ref(target),
2666                    qi(&target.alias),
2667                    qi(&target.alias),
2668                    qi(fk_col),
2669                    qi(source_alias),
2670                ));
2671            }
2672            // LATERAL, not a plain join: the arguments read from the alias
2673            // the traversal has reached, which a non-lateral function call
2674            // in FROM cannot see.
2675            IrPathJoin::Function {
2676                fn_module,
2677                fn_name,
2678                args,
2679                target,
2680            } => {
2681                let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2682                parts.push(format!(
2683                    "CROSS JOIN LATERAL {}.{}({}) AS {}",
2684                    pg_schema(fn_module),
2685                    qi(fn_name),
2686                    args_sql,
2687                    qi(&target.alias),
2688                ));
2689            }
2690            IrPathJoin::Lateral { inner, target } => {
2691                let projected = match &inner.result {
2692                    IrPathResult::Object { alias, .. } => alias.clone(),
2693                    IrPathResult::Scalar(..) => {
2694                        unreachable!("a lateral step is only built for an object-valued computed")
2695                    }
2696                };
2697                let mut body = format!(
2698                    "SELECT {}.* FROM {}",
2699                    qi(&projected),
2700                    emit_path_joins(&inner.root, &inner.joins)
2701                );
2702                append_filter(&mut body, &inner.filter);
2703                append_order_by(&mut body, &inner.order_by);
2704                append_offset_limit(&mut body, &inner.offset, &inner.limit);
2705                parts.push(format!("JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias)));
2706            }
2707            IrPathJoin::BacklinkMulti {
2708                source_alias,
2709                junction_alias,
2710                junction_table,
2711                module,
2712                owner_col,
2713                current_col,
2714                target,
2715            } => {
2716                parts.push(format!(
2717                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2718                    junction_ref(module, junction_table),
2719                    qi(junction_alias),
2720                    qi(junction_alias),
2721                    qi(current_col),
2722                    qi(source_alias),
2723                ));
2724                parts.push(format!(
2725                    "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2726                    source_ref(target),
2727                    qi(&target.alias),
2728                    qi(&target.alias),
2729                    qi(junction_alias),
2730                    qi(owner_col),
2731                ));
2732            }
2733        }
2734    }
2735    parts.join("\n")
2736}
2737
2738/// Emit `ARRAY(SELECT scalar FROM source [JOINs] [WHERE filter])`.
2739fn emit_array_source(src: &IrArraySource) -> String {
2740    match src {
2741        IrArraySource::Select(s) => {
2742            // compile_subquery_to_array_source only ever constructs this
2743            // variant for a single schema-bound row.
2744            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2745                unreachable!("IrArraySource::Select is always schema-bound")
2746            };
2747            let scalar = match shape.first() {
2748                Some(IrShapePointer::Scalar(sf)) => format!("{}.{}", qi(&source.alias), qi(&sf.column)),
2749                _ => format!("{}.\"id\"", qi(&source.alias)),
2750            };
2751            let mut sql = format!("SELECT {} FROM {} AS {}", scalar, source_ref(source), qi(&source.alias));
2752            append_filter(&mut sql, &s.filter);
2753            format!("ARRAY({})", sql)
2754        }
2755        IrArraySource::ObjectFunction(fs) => {
2756            let (exprs, _) = build_shape(&fs.shape, &fs.alias);
2757            let mut parts = vec![sql_str(&fs.type_name) + "::text"];
2758            parts.extend(exprs);
2759            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2760            let mut sql = format!(
2761                "SELECT (\n    {}\n) FROM {}.{}({}) AS {}",
2762                parts.join(",\n    "),
2763                pg_schema(&fs.fn_module),
2764                qi(&fs.fn_name),
2765                args_sql,
2766                qi(&fs.alias),
2767            );
2768            append_filter(&mut sql, &fs.filter);
2769            append_order_by(&mut sql, &fs.order_by);
2770            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
2771            format!("ARRAY({})", sql)
2772        }
2773        IrArraySource::ObjectSelect(s) => {
2774            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2775                unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2776            };
2777            let (exprs, _) = build_shape(shape, &source.alias);
2778            let mut parts = vec![source_type_disc(source)];
2779            parts.extend(exprs);
2780            let mut sql = format!(
2781                "SELECT (\n    {}\n) FROM {} AS {}",
2782                parts.join(",\n    "),
2783                source_ref(source),
2784                qi(&source.alias)
2785            );
2786            append_filter(&mut sql, &s.filter);
2787            append_order_by(&mut sql, &s.order_by);
2788            append_offset_limit(&mut sql, &s.offset, &s.limit);
2789            format!("ARRAY({})", sql)
2790        }
2791        IrArraySource::Group(grp) => format!("ARRAY({})", emit_group(grp).sql),
2792        IrArraySource::StmtColumn { stmt, column } => format!(
2793            "ARRAY(SELECT {} FROM (\n{}\n) AS \"_rows\")",
2794            qi(column),
2795            emit_dml_as_cte_source(stmt),
2796        ),
2797        IrArraySource::PathSelect(ps) => {
2798            let scalar = match &ps.result {
2799                IrPathResult::Scalar(e, _) => emit_expr(e),
2800                // Aggregating objects keeps the whole row, not the id: this is
2801                // how a computed pointer that walks through a multi-link comes
2802                // back as hydrated rows rather than a list of uuids.
2803                IrPathResult::Object {
2804                    alias,
2805                    type_name,
2806                    shape,
2807                } => {
2808                    let (pointer_exprs, _) = build_shape(shape, alias);
2809                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
2810                    parts.extend(pointer_exprs);
2811                    format!("(\n    {}\n)", parts.join(",\n    "))
2812                }
2813            };
2814            let from_sql = emit_path_joins(&ps.root, &ps.joins);
2815            let mut sql = format!(
2816                "SELECT {}{} FROM {}",
2817                if ps.distinct { "DISTINCT " } else { "" },
2818                scalar,
2819                from_sql
2820            );
2821            append_filter(&mut sql, &ps.filter);
2822            append_order_by(&mut sql, &ps.order_by);
2823            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
2824            format!("ARRAY({})", sql)
2825        }
2826        IrArraySource::RawExpr {
2827            source,
2828            poly_implementors,
2829            poly_columns,
2830            expr,
2831        } => {
2832            let from_sql = if !poly_implementors.is_empty() {
2833                format!(
2834                    "(\n{}\n) AS {}",
2835                    emit_poly_union(poly_implementors, poly_columns),
2836                    qi(&source.alias)
2837                )
2838            } else {
2839                format!("{} AS {}", source_ref(source), qi(&source.alias))
2840            };
2841            format!("ARRAY(SELECT {} FROM {})", emit_expr(expr), from_sql)
2842        }
2843    }
2844}
2845
2846/// Emit a group-key expression, casting schema-qualified enum ColumnRefs to `::text`
2847/// so they decode outside of a typed composite.
2848fn emit_key_expr(expr: &IrExpr) -> String {
2849    if let IrExpr::ColumnRef { alias, column, pg_type } = expr
2850        && let Some(qualified) = QualifiedPgType::of(pg_type)
2851    {
2852        let col_ref = if alias.is_empty() {
2853            qi(column)
2854        } else {
2855            format!("{}.{}", qi(alias), qi(column))
2856        };
2857        return format!("{}{}", col_ref, qualified.text_cast());
2858    }
2859    emit_expr(expr)
2860}
2861
2862/// The rows a group reads, as `(FROM, WHERE)`. A per-group OFFSET/LIMIT
2863/// can't be a plain LIMIT — that would cut whole groups — so the rows are
2864/// ranked within each key first and only the wanted slice kept.
2865fn group_rows(grp: &IrGroup) -> (String, Option<String>) {
2866    let alias = &grp.source.alias;
2867    if grp.limit.is_none() && grp.offset.is_none() {
2868        return (
2869            format!("{} AS {}", source_ref(&grp.source), qi(alias)),
2870            grp.filter.as_ref().map(emit_expr),
2871        );
2872    }
2873    let ranked = format!(
2874        "(SELECT {}.*, row_number() OVER (PARTITION BY {}{}) AS \"__rk\"\n    FROM {} AS {}{}) AS {}",
2875        qi(alias),
2876        group_by_sql(grp),
2877        group_element_order(grp),
2878        source_ref(&grp.source),
2879        qi(alias),
2880        grp.filter
2881            .as_ref()
2882            .map(|f| format!("\n    WHERE {}", emit_expr(f)))
2883            .unwrap_or_default(),
2884        qi(alias),
2885    );
2886    let lower = grp.offset.as_ref().map(emit_expr).unwrap_or_else(|| "0".to_string());
2887    let mut conds = vec![format!("\"__rk\" > {}", lower)];
2888    if let Some(l) = &grp.limit {
2889        conds.push(format!("\"__rk\" <= {} + {}", lower, emit_expr(l)));
2890    }
2891    (ranked, Some(conds.join(" AND ")))
2892}
2893
2894fn group_by_sql(grp: &IrGroup) -> String {
2895    grp.keys
2896        .iter()
2897        .map(|(_, key_expr)| emit_expr(key_expr))
2898        .collect::<Vec<_>>()
2899        .join(", ")
2900}
2901
2902fn group_element_order(grp: &IrGroup) -> String {
2903    if grp.order_by.is_empty() {
2904        return String::new();
2905    }
2906    let s: Vec<_> = grp.order_by.iter().map(emit_sort_clause).collect();
2907    format!(" ORDER BY {}", s.join(", "))
2908}
2909
2910/// A `for` over a group's elements used as a CTE body: the rows themselves,
2911/// so the outer query can traverse them like any object binding.
2912fn emit_group_elements_source(grp: &IrGroup) -> String {
2913    let (from_sql, cond) = group_rows(grp);
2914    let mut sql = format!("SELECT {}.*\n    FROM {}", qi(&grp.source.alias), from_sql);
2915    if let Some(cond) = cond {
2916        sql.push_str(&format!("\n    WHERE {}", cond));
2917    }
2918    sql
2919}
2920
2921fn emit_group(grp: &IrGroup) -> SqlOutput {
2922    match &grp.output {
2923        IrGroupOutput::Groups => emit_group_rows(grp),
2924        IrGroupOutput::Projection(projection) => emit_group_projection(
2925            grp,
2926            &projection.pointers,
2927            &projection.order_by,
2928            &projection.offset,
2929            &projection.limit,
2930        ),
2931        IrGroupOutput::Elements => emit_group_elements(grp),
2932    }
2933}
2934
2935fn emit_group_projection(
2936    grp: &IrGroup,
2937    pointers: &[IrShapePointer],
2938    order_by: &[IrSort],
2939    offset: &Option<IrExpr>,
2940    limit: &Option<IrExpr>,
2941) -> SqlOutput {
2942    let (exprs, nodes) = build_shape(pointers, &grp.source.alias);
2943    let mut parts = vec!["NULL::text".to_string()];
2944    parts.extend(exprs);
2945    let (from_sql, cond) = group_rows(grp);
2946    let mut sql = format!(
2947        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
2948        parts.join(",\n    "),
2949        from_sql
2950    );
2951    if let Some(cond) = cond {
2952        sql.push_str(&format!("\nWHERE {}", cond));
2953    }
2954    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
2955    if !order_by.is_empty() {
2956        let sorts: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
2957        sql.push_str(&format!("\nORDER BY {}", sorts.join(", ")));
2958    }
2959    append_offset_limit(&mut sql, offset, limit);
2960    SqlOutput {
2961        sql,
2962        shape: ShapeDescriptor {
2963            root: ShapeNode::Object {
2964                name: String::new(),
2965                type_name: None,
2966                position: 0,
2967                cardinality: Cardinality::Many,
2968                pointers: nodes,
2969                has_implicit_id: false,
2970            },
2971        },
2972        inference_plan: None,
2973    }
2974}
2975
2976fn emit_group_elements(grp: &IrGroup) -> SqlOutput {
2977    let (exprs, nodes) = build_shape(&grp.shape, &grp.source.alias);
2978    let mut parts = vec![source_type_disc(&grp.source)];
2979    parts.extend(exprs);
2980    let (from_sql, cond) = group_rows(grp);
2981    let mut sql = format!(
2982        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
2983        parts.join(",\n    "),
2984        from_sql
2985    );
2986    if let Some(cond) = cond {
2987        sql.push_str(&format!("\nWHERE {}", cond));
2988    }
2989    SqlOutput {
2990        sql,
2991        shape: ShapeDescriptor {
2992            root: ShapeNode::Object {
2993                name: String::new(),
2994                type_name: Some(grp.source.type_name.clone()),
2995                position: 0,
2996                cardinality: Cardinality::Many,
2997                pointers: prepend_type(nodes),
2998                has_implicit_id: shape_has_implicit_id(&grp.shape),
2999            },
3000        },
3001        inference_plan: None,
3002    }
3003}
3004
3005fn emit_group_rows(grp: &IrGroup) -> SqlOutput {
3006    let alias = &grp.source.alias;
3007    let (shape_exprs, shape_nodes) = build_shape(&grp.shape, alias);
3008
3009    // Build the elements ROW: type discriminator at pos 0, then shape pointers.
3010    let mut elem_row_parts = vec![source_type_disc(&grp.source)];
3011    elem_row_parts.extend(shape_exprs);
3012    let elem_row = elem_row_parts.join(",\n            ");
3013
3014    // Key positions: NULL at pos 0 (type slot), keys at 1..=N, grouping at N+1, elements at N+2.
3015    let n_keys = grp.keys.len();
3016    let grouping_pos = n_keys + 1;
3017    let elements_pos = n_keys + 2;
3018
3019    // Build key SQL expressions and ShapeNodes.
3020    let mut key_exprs_sql: Vec<String> = vec![];
3021    let mut key_nodes: Vec<ShapeNode> = vec![];
3022    for (i, (key_name, key_expr)) in grp.keys.iter().enumerate() {
3023        let pos = i + 1;
3024        if let IrExpr::ColumnRef { pg_type, .. } = key_expr
3025            && let Some(qualified) = QualifiedPgType::of(pg_type)
3026        {
3027            key_exprs_sql.push(emit_key_expr(key_expr));
3028            key_nodes.push(qualified.shape_node(key_name.clone(), pos));
3029            continue;
3030        }
3031        key_exprs_sql.push(emit_expr(key_expr));
3032        key_nodes.push(ShapeNode::Scalar {
3033            name: key_name.clone(),
3034            position: pos,
3035        });
3036    }
3037
3038    // Build the outer SELECT tuple.
3039    let mut outer_parts = vec!["NULL::text".to_string()];
3040    outer_parts.extend(key_exprs_sql.clone());
3041    let key_names_sql = grp
3042        .keys
3043        .iter()
3044        .map(|(name, _)| format!("'{}'", name))
3045        .collect::<Vec<_>>()
3046        .join(", ");
3047    outer_parts.push(format!("ARRAY[{}]::text[]", key_names_sql));
3048    outer_parts.push(format!(
3049        "array_agg(ROW(\n            {}\n        )::record{})",
3050        elem_row,
3051        group_element_order(grp)
3052    ));
3053
3054    let outer_tuple = outer_parts.join(",\n    ");
3055    let (from_sql, cond) = group_rows(grp);
3056    let mut sql = format!("SELECT (\n    {}\n) AS \"result\"\nFROM {}", outer_tuple, from_sql,);
3057    if let Some(cond) = cond {
3058        sql.push_str(&format!("\nWHERE {}", cond));
3059    }
3060    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
3061
3062    // ShapeNode for each element (Object with the selected pointers).
3063    let element_node = ShapeNode::Object {
3064        name: String::new(),
3065        type_name: Some(grp.source.type_name.clone()),
3066        position: 0,
3067        cardinality: Cardinality::Many,
3068        pointers: prepend_type(shape_nodes),
3069        has_implicit_id: shape_has_implicit_id(&grp.shape),
3070    };
3071
3072    let root = ShapeNode::Group {
3073        key_nodes,
3074        grouping_position: grouping_pos,
3075        elements_position: elements_pos,
3076        element: Box::new(element_node),
3077    };
3078
3079    SqlOutput {
3080        sql,
3081        shape: ShapeDescriptor { root },
3082        inference_plan: None,
3083    }
3084}
3085
3086fn emit_poly_union_type_only(implementors: &[IrPolyImplementor]) -> String {
3087    implementors
3088        .iter()
3089        .map(|imp| {
3090            format!(
3091                "    SELECT {}::text AS \"__type__\" FROM {}",
3092                sql_str(&imp.type_name),
3093                qn(&imp.module, &imp.table),
3094            )
3095        })
3096        .collect::<Vec<_>>()
3097        .join("\n    UNION ALL\n")
3098}
3099
3100fn emit_path_select(sel: &IrPathSelect) -> SqlOutput {
3101    let distinct = if sel.distinct { "DISTINCT " } else { "" };
3102    let from_sql = if !sel.poly_implementors.is_empty() {
3103        format!(
3104            "(\n{}\n) AS {}",
3105            emit_poly_union_type_only(&sel.poly_implementors),
3106            qi(&sel.root.alias),
3107        )
3108    } else {
3109        emit_path_joins(&sel.root, &sel.joins)
3110    };
3111
3112    let (result_expr, shape_root) = match &sel.result {
3113        IrPathResult::Scalar(ir_expr, tuple_shape) => {
3114            // Named tuples / jsonb field accesses can't be decoded inside ROW() — emit raw.
3115            let is_nt = matches!(ir_expr, IrExpr::NamedTuple { .. })
3116                || matches!(ir_expr, IrExpr::Tuple(_))
3117                || matches!(ir_expr, IrExpr::JsonbField { .. })
3118                || matches!(ir_expr, IrExpr::JsonbIndex { .. })
3119                || matches!(ir_expr, IrExpr::ColumnRef { pg_type, .. } if pg_type.starts_with("__nt__:"))
3120                || tuple_shape.is_some();
3121            if is_nt {
3122                let expr_sql = format!("{} AS result", emit_expr(ir_expr));
3123                let shape = if matches!(
3124                    ir_expr,
3125                    IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::Tuple(_)
3126                ) {
3127                    ShapeNode::RawScalar
3128                } else if let Some(shape) = tuple_shape {
3129                    // A bare tuple-typed property reference (nominal or
3130                    // structural) — real member shape already resolved at
3131                    // compile time (see resolve_property_tuple_shape), same
3132                    // as a `Type { tuple_property }` shape query gets via
3133                    // emit_scalar, instead of falling back to `members: None`.
3134                    ShapeNode::NamedTuple {
3135                        name: String::new(),
3136                        position: 0,
3137                        type_name: shape.type_name.clone(),
3138                        members: Some(shape.members.clone()),
3139                        is_free_object: false,
3140                    }
3141                } else {
3142                    let type_name = match ir_expr {
3143                        IrExpr::ColumnRef { pg_type, .. } => pg_type.strip_prefix("__nt__:").map(|s| s.to_string()),
3144                        _ => None,
3145                    };
3146                    ShapeNode::NamedTuple {
3147                        name: String::new(),
3148                        position: 0,
3149                        type_name,
3150                        members: None,
3151                        is_free_object: false,
3152                    }
3153                };
3154                (expr_sql, shape)
3155            } else {
3156                // Schema-qualified types (enums, domains) have unknown OIDs inside ROW() —
3157                // cast to text so the value's shape doesn't depend on a runtime OID.
3158                if let IrExpr::ColumnRef { pg_type, .. } = ir_expr {
3159                    if let Some(qualified) = QualifiedPgType::of(pg_type) {
3160                        let expr = format!("ROW({}{}) AS result", emit_expr(ir_expr), qualified.text_cast());
3161                        let shape = qualified.shape_node(String::new(), 0);
3162                        (expr, shape)
3163                    } else {
3164                        let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3165                        (
3166                            expr,
3167                            ShapeNode::Scalar {
3168                                name: String::new(),
3169                                position: 0,
3170                            },
3171                        )
3172                    }
3173                } else {
3174                    let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3175                    (
3176                        expr,
3177                        ShapeNode::Scalar {
3178                            name: String::new(),
3179                            position: 0,
3180                        },
3181                    )
3182                }
3183            }
3184        }
3185        IrPathResult::Object {
3186            alias,
3187            type_name,
3188            shape,
3189        } => {
3190            let (pointer_exprs, pointer_nodes) = build_shape(shape, alias);
3191            let mut parts = vec![result_type_disc(sel, alias, type_name)];
3192            parts.extend(pointer_exprs);
3193            let expr = format!("(\n    {}\n) AS result", parts.join(",\n    "));
3194            let shape_root = ShapeNode::Object {
3195                name: String::new(),
3196                type_name: Some(type_name.clone()),
3197                position: 0,
3198                cardinality: Cardinality::Many,
3199                pointers: prepend_type(pointer_nodes),
3200                has_implicit_id: shape_has_implicit_id(shape),
3201            };
3202            (expr, shape_root)
3203        }
3204    };
3205
3206    let mut sql = format!("SELECT {}{}\nFROM {}", distinct, result_expr, from_sql);
3207    append_filter(&mut sql, &sel.filter);
3208    append_order_by(&mut sql, &sel.order_by);
3209    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
3210
3211    SqlOutput {
3212        sql,
3213        shape: ShapeDescriptor { root: shape_root },
3214        inference_plan: None,
3215    }
3216}
3217
3218// ── FOR LOOP ─────────────────────────────────────────────────────────────────
3219
3220/// The loop's iterator as a relation with a single column named `v`, in both
3221/// the forms the two body kinds need: one for a `FROM` clause, one for a CTE.
3222/// True for an expression PostgreSQL hands back as `jsonb`, whose value a
3223/// scalar cast has to reach into rather than render.
3224fn yields_jsonb(expr: &IrExpr) -> bool {
3225    match expr {
3226        IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::NamedTuple { .. } => true,
3227        IrExpr::TypeCast(c) => c.pg_type == "jsonb",
3228        IrExpr::ColumnRef { pg_type, .. } | IrExpr::FnParam { pg_type, .. } => pg_type == "jsonb",
3229        IrExpr::CteRef { pg_type, .. } => pg_type.as_deref() == Some("jsonb"),
3230        IrExpr::FunctionCall(f) if f.schema.is_none() => {
3231            let mut overloads = crate::stdlib::registry().iter().filter(|d| d.name == f.name).peekable();
3232            overloads.peek().is_some() && overloads.all(|d| matches!(d.return_type, crate::stdlib::PylonType::Json))
3233        }
3234        _ => false,
3235    }
3236}
3237
3238fn emit_for_iterator(it: &IrForIterator, iter_alias: &str) -> (String, String) {
3239    match it {
3240        IrForIterator::Values { exprs, pg_type } => {
3241            let rows: Vec<String> = exprs
3242                .iter()
3243                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3244                .collect();
3245            (
3246                format!("(VALUES {}) AS {}(\"v\")", rows.join(", "), qi(iter_alias)),
3247                format!("{}(\"v\") AS (VALUES {})", qi(iter_alias), rows.join(", ")),
3248            )
3249        }
3250        IrForIterator::Query { stmt, scalar } => {
3251            let inner = emit_dml_as_cte_source(stmt);
3252            // A scalar-yielding select already exposes `v` (the same
3253            // convention a scalar `with` binding uses); an object-yielding
3254            // one exposes its raw columns, so the loop variable binds `id`.
3255            let body = if *scalar {
3256                inner
3257            } else {
3258                format!("    SELECT \"id\" AS v FROM (\n{}\n    ) AS _src", inner)
3259            };
3260            (
3261                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3262                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3263            )
3264        }
3265        IrForIterator::SetReturning { expr, pg_type } => {
3266            let body = format!("    SELECT {}::{} AS v", emit_expr(expr), pg_type);
3267            (
3268                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3269                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3270            )
3271        }
3272    }
3273}
3274
3275/// The iterator CTE of a `for` nested in another one. The enclosing iterator
3276/// goes in its FROM, so the walk it iterates can read the outer loop variable,
3277/// and its key rides along as `_outer` for the body to join back on — the bond
3278/// Gel's own `merge_iterator` puts between a chain of iterator CTEs.
3279fn emit_nested_for_iterator(it: &IrForIterator, iter_alias: &str, outer_alias: &str) -> String {
3280    const OUTER: &str = "_outer";
3281    match it {
3282        IrForIterator::Values { exprs, pg_type } => {
3283            let rows: Vec<String> = exprs
3284                .iter()
3285                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3286                .collect();
3287            format!(
3288                "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, (VALUES {}) AS \"_vals\"(\"v\")\n)",
3289                qi(iter_alias),
3290                qi(outer_alias),
3291                qi(OUTER),
3292                qi(outer_alias),
3293                rows.join(", "),
3294            )
3295        }
3296        IrForIterator::Query { stmt, scalar } => {
3297            let inner = emit_dml_as_cte_source(stmt);
3298            let projected = if *scalar { "\"_src\".\"v\"" } else { "\"_src\".\"id\"" };
3299            format!(
3300                "{} AS (\nSELECT {}.\"v\" AS {}, {} AS v\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_src\"\n)",
3301                qi(iter_alias),
3302                qi(outer_alias),
3303                qi(OUTER),
3304                projected,
3305                qi(outer_alias),
3306                inner,
3307            )
3308        }
3309        IrForIterator::SetReturning { expr, pg_type } => format!(
3310            "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, LATERAL (SELECT {}::{} AS v) AS \"_vals\"\n)",
3311            qi(iter_alias),
3312            qi(outer_alias),
3313            qi(OUTER),
3314            qi(outer_alias),
3315            emit_expr(expr),
3316            pg_type,
3317        ),
3318    }
3319}
3320
3321/// `FROM <inner iterator> JOIN <outer iterator> ON …` — both loop variables in
3322/// scope for the body, each still read as `"_for_<name>"."v"`.
3323fn nested_for_from(inner_alias: &str, outer_alias: &str) -> String {
3324    format!(
3325        "{} JOIN {} ON {}.\"v\" = {}.\"_outer\"",
3326        qi(inner_alias),
3327        qi(outer_alias),
3328        qi(outer_alias),
3329        qi(inner_alias),
3330    )
3331}
3332
3333/// Put the bindings a `for` body declared in front of the body itself: they may
3334/// read the loop variable, so they cannot sit in the enclosing WITH clause.
3335/// A `for` body's CTEs as they must read inside the LATERAL that runs the body.
3336///
3337/// `emit_user_cte_parts` keys a binding that reads the loop variable by the
3338/// iteration, joining `_for_<slot>` to carry its key — right where the binding
3339/// sits in the statement's own WITH beside the iterator CTE, as the DML forms
3340/// emit it. Inside the LATERAL there is no such relation: `_for_<slot>` is the
3341/// outer FROM item's alias, so `FROM "_for_<slot>"` is SQL PostgreSQL rejects
3342/// ("relation does not exist"), and spelling it legally would widen the binding
3343/// to every iteration's rows at once. The LATERAL has already pinned the
3344/// iteration to one row, and the correlated column reference the body carries
3345/// reads it, so the key is neither needed nor correct here.
3346fn uncorrelated_body_ctes(f: &IrFor) -> Vec<IrCteDef> {
3347    f.body_ctes
3348        .iter()
3349        .map(|cte| IrCteDef {
3350            correlated_to: None,
3351            ..cte.clone()
3352        })
3353        .collect()
3354}
3355
3356fn prefix_body_ctes(sql: String, body_ctes: &[IrCteDef]) -> String {
3357    if body_ctes.is_empty() {
3358        return sql;
3359    }
3360    merge_into_existing_with(&sql, &emit_user_cte_parts(body_ctes))
3361        .unwrap_or_else(|| format!("{}{}", emit_cte_prefix(body_ctes), sql))
3362}
3363
3364fn emit_for_stmt(f: &IrFor, user_ctes: &[IrCteDef]) -> SqlOutput {
3365    let iter_alias = format!("_for_{}", f.var_name);
3366
3367    if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
3368        let empty = SqlOutput {
3369            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
3370            shape: ShapeDescriptor {
3371                root: ShapeNode::Scalar {
3372                    name: String::new(),
3373                    position: 0,
3374                },
3375            },
3376            inference_plan: None,
3377        };
3378        return empty;
3379    }
3380
3381    let (values_from, iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
3382
3383    match f.body.as_ref() {
3384        IrStmt::Insert(ins) => emit_for_insert(ins, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3385        IrStmt::Update(upd) => emit_for_update(upd, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3386        body => {
3387            let body_out = match body {
3388                IrStmt::Select(sel) => emit_select_stmt(sel, user_ctes),
3389                IrStmt::PathSelect(sel) => emit_path_select(sel),
3390                // Unreachable: `Compiler::compile_for` rejects every other
3391                // body kind with a PyQL error before an `IrFor` is built.
3392                other => unreachable!("for-loop body should have been rejected at compile time: {other:?}"),
3393            };
3394            let indent_body = prefix_body_ctes(body_out.sql, &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
3395            let cte_prefix = if !user_ctes.is_empty() {
3396                emit_cte_prefix(user_ctes)
3397            } else {
3398                String::new()
3399            };
3400            let sql = format!(
3401                "{}SELECT \"_body\".result\nFROM {}\nCROSS JOIN LATERAL (\n    {}\n) AS \"_body\"",
3402                cte_prefix, values_from, indent_body,
3403            );
3404            SqlOutput {
3405                sql,
3406                shape: body_out.shape,
3407                inference_plan: None,
3408            }
3409        }
3410    }
3411}
3412
3413/// A `for` whose body updates: driven from the iteration with `UPDATE … FROM`,
3414/// since a LATERAL -- what the select-bodied form uses -- cannot hold DML.
3415/// Both the SET clause and the filter may read the loop variable, and both are
3416/// already compiled against the iteration alias.
3417fn emit_for_update(
3418    upd: &IrUpdate,
3419    iter_alias: &str,
3420    iter_cte: &str,
3421    user_ctes: &[IrCteDef],
3422    body_ctes: &[IrCteDef],
3423) -> SqlOutput {
3424    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3425    cte_parts.extend(emit_user_cte_parts(body_ctes));
3426    cte_parts.push(iter_cte.to_string());
3427
3428    let alias = &upd.target.alias;
3429    // With only junction rows to write there is nothing to SET, so the driving
3430    // rows are selected rather than updated -- the same shape a multi-link-only
3431    // update takes outside a loop.
3432    if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
3433        const ITER_COL: &str = "_iter";
3434        let ids_name = "_ids";
3435        // A nested insert gets its id generated here rather than by the column
3436        // default, so the junction below can name the row *this* iteration
3437        // inserted — `RETURNING` offers no order to pair on.
3438        let generated: Vec<Option<&IrInsert>> = upd
3439            .multi_link_appends
3440            .iter()
3441            .map(|a| per_iteration_insert(a, &upd.nested_ctes))
3442            .collect();
3443        let new_cols: String = generated
3444            .iter()
3445            .enumerate()
3446            .filter_map(|(i, ins)| {
3447                ins.map(|ins| {
3448                    format!(
3449                        ", {} AS {}",
3450                        ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3451                        qi(&format!("_new_{i}"))
3452                    )
3453                })
3454            })
3455            .collect();
3456        let mut ids_sql = format!(
3457            "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
3458            ids_name,
3459            qi(alias),
3460            qi(iter_alias),
3461            qi(ITER_COL),
3462            new_cols,
3463            target_ref(&upd.target),
3464            qi(alias),
3465            qi(iter_alias),
3466        );
3467        append_filter(&mut ids_sql, &upd.filter);
3468        ids_sql.push_str("\n)");
3469        cte_parts.push(ids_sql);
3470        for (i, append) in upd.multi_link_appends.iter().enumerate() {
3471            match generated[i] {
3472                Some(ins) => {
3473                    cte_parts.push(emit_for_nested_insert_cte(
3474                        ins,
3475                        ids_name,
3476                        iter_alias,
3477                        &format!("_ml_ins_{i}"),
3478                        &format!("_new_{i}"),
3479                    ));
3480                    cte_parts.push(emit_for_ml_append_cte(
3481                        append,
3482                        ids_name,
3483                        &format!("_ml_add_{i}"),
3484                        &format!("_new_{i}"),
3485                    ));
3486                }
3487                None => cte_parts.push(emit_for_ml_append_cte(
3488                    append,
3489                    ids_name,
3490                    &format!("_ml_add_{i}"),
3491                    ITER_COL,
3492                )),
3493            }
3494        }
3495        let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, ids_name);
3496        let mut parts = vec![type_disc(&upd.target.type_name)];
3497        parts.extend(pointer_exprs);
3498        let sql = format!(
3499            "WITH {}\nSELECT (\n    {}\n) AS result\nFROM \"{}\"",
3500            cte_parts.join(",\n"),
3501            parts.join(",\n    "),
3502            ids_name,
3503        );
3504        return SqlOutput {
3505            sql,
3506            shape: crate::query::ShapeDescriptor {
3507                root: ShapeNode::Object {
3508                    name: String::new(),
3509                    type_name: Some(upd.target.type_name.clone()),
3510                    position: 0,
3511                    cardinality: Cardinality::Many,
3512                    pointers: prepend_type(shape_nodes),
3513                    has_implicit_id: shape_has_implicit_id(&upd.returning),
3514                },
3515            },
3516            inference_plan: None,
3517        };
3518    }
3519    let sets: Vec<String> = upd
3520        .assignments
3521        .iter()
3522        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
3523        .chain(
3524            upd.rewrites
3525                .iter()
3526                .map(|rw| format!("{} = {}", qi(&rw.column), emit_expr(&rw.expr))),
3527        )
3528        .collect();
3529    let mut sql = format!(
3530        "WITH {}\nUPDATE {} AS {}\nSET {}\nFROM {}",
3531        cte_parts.join(",\n"),
3532        target_ref(&upd.target),
3533        qi(alias),
3534        sets.join(", "),
3535        qi(iter_alias),
3536    );
3537    append_filter(&mut sql, &upd.filter);
3538    let mut parts = vec![type_disc(&upd.target.type_name)];
3539    let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, alias);
3540    parts.extend(pointer_exprs);
3541    sql.push_str(&format!("\nRETURNING (\n    {}\n) AS result", parts.join(",\n    ")));
3542    SqlOutput {
3543        sql,
3544        shape: crate::query::ShapeDescriptor {
3545            root: ShapeNode::Object {
3546                name: String::new(),
3547                type_name: Some(upd.target.type_name.clone()),
3548                position: 0,
3549                cardinality: Cardinality::Many,
3550                pointers: prepend_type(shape_nodes),
3551                has_implicit_id: shape_has_implicit_id(&upd.returning),
3552            },
3553        },
3554        inference_plan: None,
3555    }
3556}
3557
3558/// The iteration key a correlated nested CTE carries, so rows written per
3559/// iteration can be paired back with the row that iteration wrote. `RETURNING`
3560/// can only name the inserted row's own columns, so the pairing is computed in
3561/// a staging CTE beside the insert rather than read back out of it.
3562const OUTER_KEY: &str = "_outer";
3563
3564/// `SELECT <id> AS "id", <iter>."v" AS "_outer", <value> AS "<column>", …` —
3565/// the rows one iteration-driven insert will write, with their ids generated
3566/// here rather than by the column default so a junction can name them before
3567/// the insert has run.
3568fn emit_iteration_rows_cte(ins: &IrInsert, cte_name: &str, iter_alias: &str, outer_source: &str) -> String {
3569    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3570    let mut selected = vec![
3571        format!(
3572            "{} AS {}",
3573            ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3574            qi("id")
3575        ),
3576        format!("{} AS {}", outer_source, qi(OUTER_KEY)),
3577    ];
3578    selected.extend(
3579        ins.assignments
3580            .iter()
3581            .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3582            .map(|(c, e)| format!("{} AS {}", emit_expr(e), qi(c)))
3583            .chain(
3584                ins.rewrites
3585                    .iter()
3586                    .map(|r| format!("{} AS {}", emit_expr(&r.expr), qi(&r.column))),
3587            ),
3588    );
3589    format!(
3590        "\"{}\" AS (\nSELECT {}\nFROM {}\n)",
3591        cte_name,
3592        selected.join(", "),
3593        qi(iter_alias),
3594    )
3595}
3596
3597/// The insert that reads its rows back out of the staging CTE above.
3598fn emit_insert_from_rows_cte(ins: &IrInsert, cte_name: &str, rows_name: &str) -> String {
3599    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3600    let cols: Vec<String> = std::iter::once(qi("id"))
3601        .chain(
3602            ins.assignments
3603                .iter()
3604                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3605                .map(|(c, _)| qi(c)),
3606        )
3607        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3608        .collect();
3609    let mut sql = format!(
3610        "INSERT INTO {} ({})\nSELECT {} FROM \"{}\"",
3611        target_ref(&ins.target),
3612        cols.join(", "),
3613        cols.join(", "),
3614        rows_name,
3615    );
3616    if let Some(conflict) = &ins.unless_conflict {
3617        emit_conflict(&mut sql, conflict);
3618    }
3619    sql.push_str("\nRETURNING *");
3620    format!("\"{}\" AS (\n{}\n)", cte_name, sql)
3621}
3622
3623/// A junction append whose targets were written per iteration: pair the two
3624/// staging CTEs on the iteration key rather than cross-joining every row
3625/// written against every row that wrote one.
3626fn emit_correlated_ml_append_cte(
3627    mutation: &IrMultiLinkMutation,
3628    rows_name: &str,
3629    targets_name: &str,
3630    cte_name: &str,
3631) -> String {
3632    format!(
3633        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"_src\".{}, \"_tgt\".{} FROM \"{}\" AS \"_src\" JOIN \"{}\" AS \"_tgt\" ON \"_tgt\".{} = \"_src\".{}\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
3634        cte_name,
3635        qn(&mutation.module, &mutation.junction_table),
3636        qi(&mutation.source_col),
3637        qi(&mutation.target_col),
3638        qi("id"),
3639        qi("id"),
3640        rows_name,
3641        targets_name,
3642        qi(OUTER_KEY),
3643        qi(OUTER_KEY),
3644        qi(&mutation.source_col),
3645        qi(&mutation.target_col),
3646    )
3647}
3648
3649/// Which of `appends` name a CTE that wrote its rows per iteration, and so
3650/// pair on the iteration key instead of cross-joining.
3651fn correlated_append_indices(appends: &[IrMultiLinkMutation], defs: &[&[IrCteDef]]) -> Vec<usize> {
3652    appends
3653        .iter()
3654        .enumerate()
3655        .filter(|(_, append)| match &append.values.source {
3656            IrMultiLinkValueSource::CteRef(target) => defs
3657                .iter()
3658                .flat_map(|group| group.iter())
3659                .any(|c| &c.name == target && c.correlated_to.is_some()),
3660            _ => false,
3661        })
3662        .map(|(i, _)| i)
3663        .collect()
3664}
3665
3666fn emit_for_insert(
3667    ins: &IrInsert,
3668    iter_alias: &str,
3669    iter_cte: &str,
3670    user_ctes: &[IrCteDef],
3671    body_ctes: &[IrCteDef],
3672) -> SqlOutput {
3673    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3674
3675    let cols: Vec<String> = ins
3676        .assignments
3677        .iter()
3678        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3679        .map(|(c, _)| qi(c))
3680        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3681        .collect();
3682    let sel_exprs: Vec<String> = ins
3683        .assignments
3684        .iter()
3685        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3686        .map(|(_, e)| emit_expr(e))
3687        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
3688        .collect();
3689
3690    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3691    // The iterator first: a body CTE, and any DML nested in the row's values,
3692    // may read the loop variable, and a WITH name is only in scope for the
3693    // bindings that follow it.
3694    cte_parts.push(iter_cte.to_string());
3695    cte_parts.extend(emit_user_cte_parts(body_ctes));
3696    cte_parts.extend(emit_user_cte_parts(&ins.nested_ctes));
3697    // A nested loop that carried the iteration key back means this insert's
3698    // own rows need one too, to pair with.
3699    let correlated_targets = correlated_append_indices(&ins.multi_link_appends, &[body_ctes, &ins.nested_ctes]);
3700
3701    let mut insert_sql = format!(
3702        "INSERT INTO {} ({})\nSELECT {} FROM {}",
3703        target_ref(&ins.target),
3704        cols.join(", "),
3705        sel_exprs.join(", "),
3706        qi(iter_alias),
3707    );
3708    if let Some(conflict) = &ins.unless_conflict {
3709        emit_conflict(&mut insert_sql, conflict);
3710    }
3711    let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
3712
3713    if ins.multi_link_appends.is_empty() {
3714        let mut sql = format!("WITH {}\n{}", cte_parts.join(",\n"), insert_sql);
3715        if let Some(r) = returning_sql {
3716            sql.push_str(&r);
3717        }
3718        return SqlOutput {
3719            sql,
3720            shape,
3721            inference_plan: None,
3722        };
3723    }
3724
3725    // A junction row needs the id of the row this iteration just wrote, so
3726    // the insert becomes a CTE the junction inserts read back from.
3727    const IDS: &str = "_for_dml__ids";
3728    const ROWS: &str = "_for_dml__rows";
3729    if correlated_targets.is_empty() {
3730        insert_sql.push_str("\nRETURNING *");
3731        cte_parts.push(format!("\"{}\" AS (\n{}\n)", IDS, insert_sql));
3732    } else {
3733        let outer_source = format!("{}.{}", qi(iter_alias), qi("v"));
3734        cte_parts.push(emit_iteration_rows_cte(ins, ROWS, iter_alias, &outer_source));
3735        cte_parts.push(emit_insert_from_rows_cte(ins, IDS, ROWS));
3736    }
3737    for (i, append) in ins.multi_link_appends.iter().enumerate() {
3738        let cte_name = format!("_for_dml__ml_add_{}", i);
3739        if correlated_targets.contains(&i) {
3740            let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
3741                unreachable!("only a CTE-backed value can carry an iteration key")
3742            };
3743            cte_parts.push(emit_correlated_ml_append_cte(append, ROWS, target, &cte_name));
3744        } else {
3745            cte_parts.push(emit_ml_append_cte(append, IDS, &ins.target.alias, &cte_name));
3746        }
3747    }
3748
3749    let projection = match &returning_sql {
3750        // `emit_returning_shape` spells the projection as a RETURNING clause;
3751        // read off a CTE it is the same tuple in a SELECT list.
3752        Some(r) => r.trim_start_matches('\n').replacen("RETURNING ", "SELECT ", 1),
3753        None => "SELECT 1".to_string(),
3754    };
3755    SqlOutput {
3756        sql: format!("WITH {}\n{}\nFROM \"{}\"", cte_parts.join(",\n"), projection, IDS),
3757        shape,
3758        inference_plan: None,
3759    }
3760}
3761
3762// ── INSERT ──────────────────────────────────────────────────────────────────
3763
3764// ── Vector index enqueue helpers ─────────────────────────────────────────────
3765
3766/// Build one `"_eqN" AS (INSERT INTO _pylon."IndexOutbox" ...)` CTE string.
3767fn enqueue_cte_sql(eq: &VectorEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
3768    let index_name_sql = match &eq.index_name {
3769        None => "NULL".to_string(),
3770        Some(name) => sql_str(name),
3771    };
3772    format!(
3773        concat!(
3774            "\"{}\" AS (\n",
3775            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
3776            "        (object_id, type_name, index_kind, index_name)\n",
3777            "    SELECT \"id\", {}, 'Vector'::_pylon.\"IndexKind\", {}\n",
3778            "    FROM \"{}\"\n",
3779            "    ON CONFLICT (object_id, index_kind, index_name)\n",
3780            "    DO UPDATE SET status = 'Pending', enqueued_at = now()\n",
3781            ")",
3782        ),
3783        cte_name,
3784        sql_str(&eq.type_name),
3785        index_name_sql,
3786        source_cte,
3787    )
3788}
3789
3790/// Build the full list of enqueue CTE strings for a set of vector indexes.
3791fn enqueue_ctes(enqueue: &[VectorEnqueueInfo], source_cte: &str) -> Vec<String> {
3792    enqueue
3793        .iter()
3794        .enumerate()
3795        .map(|(i, eq)| enqueue_cte_sql(eq, source_cte, &format!("_eq{}", i)))
3796        .collect()
3797}
3798
3799/// The `_pylon."IndexOutbox".index_kind` enum label for a search backend —
3800/// `Postgres`-backed indexes never reach this (see `collect_search_enqueue`),
3801/// so there's no corresponding `IndexKind` value for that variant.
3802fn search_backend_index_kind(backend: &crate::schema::SearchBackend) -> &'static str {
3803    match backend {
3804        crate::schema::SearchBackend::OpenSearch => "OpenSearch",
3805        crate::schema::SearchBackend::Meilisearch => "Meilisearch",
3806        crate::schema::SearchBackend::Postgres => {
3807            unreachable!("Postgres-backed search indexes are never collected into SearchEnqueueInfo")
3808        }
3809    }
3810}
3811
3812/// Build one OpenSearch/Meilisearch outbox CTE string.
3813fn enqueue_search_cte_sql(eq: &SearchEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
3814    let index_name_sql = match &eq.index_name {
3815        None => "NULL".to_string(),
3816        Some(name) => sql_str(name),
3817    };
3818    format!(
3819        concat!(
3820            "\"{}\" AS (\n",
3821            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
3822            "        (object_id, type_name, index_kind, index_name, operation)\n",
3823            "    SELECT \"id\", {}, '{}'::_pylon.\"IndexKind\", {}, {}\n",
3824            "    FROM \"{}\"\n",
3825            "    ON CONFLICT (object_id, index_kind, index_name)\n",
3826            "    DO UPDATE SET status = 'Pending', operation = EXCLUDED.operation, enqueued_at = now()\n",
3827            ")",
3828        ),
3829        cte_name,
3830        sql_str(&eq.type_name),
3831        search_backend_index_kind(&eq.backend),
3832        index_name_sql,
3833        sql_str(eq.operation),
3834        source_cte,
3835    )
3836}
3837
3838/// Build the full list of OpenSearch/Meilisearch enqueue CTE strings.
3839fn enqueue_search_ctes(enqueue: &[SearchEnqueueInfo], source_cte: &str, offset: usize) -> Vec<String> {
3840    enqueue
3841        .iter()
3842        .enumerate()
3843        .map(|(i, eq)| enqueue_search_cte_sql(eq, source_cte, &format!("_es{}", offset + i)))
3844        .collect()
3845}
3846
3847/// Build `SELECT (...) AS result FROM "cte_name"` plus its ShapeDescriptor,
3848/// mirroring `emit_returning_shape` but for the CTE-wrapper SELECT path.
3849fn shape_select_from_cte(
3850    target: &IrSource,
3851    returning: &[IrShapePointer],
3852    cte_name: &str,
3853) -> (ShapeDescriptor, Option<String>) {
3854    if returning.is_empty() {
3855        return (
3856            ShapeDescriptor {
3857                root: ShapeNode::Scalar {
3858                    name: String::new(),
3859                    position: 0,
3860                },
3861            },
3862            None,
3863        );
3864    }
3865    let (pointer_exprs, shape_pointers) = build_shape(returning, "");
3866    let mut parts = vec![type_disc(&target.type_name)];
3867    parts.extend(pointer_exprs);
3868    let tuple = parts.join(",\n    ");
3869    let sql = format!("SELECT (\n    {}\n) AS result\nFROM {}", tuple, qi(cte_name));
3870    let root_pointers = prepend_type(shape_pointers);
3871    let shape = ShapeDescriptor {
3872        root: ShapeNode::Object {
3873            name: String::new(),
3874            type_name: Some(target.type_name.clone()),
3875            position: 0,
3876            cardinality: Cardinality::Required,
3877            pointers: root_pointers,
3878            has_implicit_id: shape_has_implicit_id(returning),
3879        },
3880    };
3881    (shape, Some(sql))
3882}
3883
3884/// Builds the base `INSERT INTO t (cols) VALUES (...)` row source — or,
3885/// when `ins.nested_ctes` is non-empty, `INSERT INTO t (cols) SELECT ...
3886/// FROM cte1, cte2, ...` instead, since a VALUES list can't reference a
3887/// CTE's columns (needed when a link's value is sourced from a hoisted
3888/// nested INSERT/UPDATE/DELETE — see `IrInsert::nested_ctes`). Each nested
3889/// CTE is guaranteed exactly one row, so an implicit cross join is safe.
3890/// Shared by the plain wrap path and the multi-link junction wrap path
3891/// below, both of which need this same VALUES/SELECT choice.
3892fn emit_insert_row_sql(ins: &IrInsert) -> String {
3893    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3894    let cols: Vec<String> = ins
3895        .assignments
3896        .iter()
3897        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3898        .map(|(c, _)| qi(c))
3899        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3900        .collect();
3901    let vals: Vec<String> = ins
3902        .assignments
3903        .iter()
3904        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3905        .map(|(_, e)| emit_expr(e))
3906        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
3907        .collect();
3908    // A guarded insert writes nothing when its condition is false, so the row
3909    // comes from a `SELECT` that returns none -- `VALUES` always yields one.
3910    if let Some(guard) = &ins.guard {
3911        let from_ctes = nested_cte_from(&ins.nested_ctes, &vals.join(","), " ");
3912        let projection = if cols.is_empty() {
3913            String::new()
3914        } else {
3915            format!(" {}", vals.join(", "))
3916        };
3917        let column_list = if cols.is_empty() {
3918            String::new()
3919        } else {
3920            format!(" ({})", cols.join(", "))
3921        };
3922        return format!(
3923            "INSERT INTO {}{} SELECT{}{} WHERE {}",
3924            target_ref(&ins.target),
3925            column_list,
3926            projection,
3927            from_ctes,
3928            emit_expr(guard),
3929        );
3930    }
3931    if cols.is_empty() && ins.nested_ctes.is_empty() {
3932        // `insert Preferences {}` — a row made entirely of its own defaults.
3933        // An empty column list is not SQL; `DEFAULT VALUES` is how it is said.
3934        format!("INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
3935    } else if ins.nested_ctes.is_empty() {
3936        format!(
3937            "INSERT INTO {} ({}) VALUES ({})",
3938            target_ref(&ins.target),
3939            cols.join(", "),
3940            vals.join(", ")
3941        )
3942    } else {
3943        format!(
3944            "INSERT INTO {} ({}) SELECT {}{}",
3945            target_ref(&ins.target),
3946            cols.join(", "),
3947            vals.join(", "),
3948            nested_cte_from(&ins.nested_ctes, &vals.join(","), " "),
3949        )
3950    }
3951}
3952
3953fn emit_insert_stmt(ins: &IrInsert) -> SqlOutput {
3954    if ins.enqueue_vector.is_empty()
3955        && ins.enqueue_search.is_empty()
3956        && !insert_has_any_multilink(ins)
3957        && ins.nested_ctes.is_empty()
3958    {
3959        let mut sql = emit_insert_row_sql(ins);
3960        if let Some(conflict) = &ins.unless_conflict {
3961            emit_conflict(&mut sql, conflict);
3962        }
3963        let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
3964        if let Some(r) = returning_sql {
3965            sql.push_str(&r);
3966        }
3967        return SqlOutput {
3968            sql,
3969            shape,
3970            inference_plan: None,
3971        };
3972    }
3973
3974    // Wrap path: needed for outbox enqueue CTEs, junction-table population
3975    // (see emit_insert_multilink_ctes), and/or hoisted nested-DML CTEs (see
3976    // IrInsert::nested_ctes) — all of which require either the row's own id
3977    // or a WITH prefix, so the plain single-statement INSERT above can't be
3978    // used.
3979    let mut cte_parts = if insert_has_any_multilink(ins) {
3980        emit_insert_multilink_ctes(ins, "_w")
3981    } else {
3982        let mut cte_parts = emit_user_cte_parts(&ins.nested_ctes);
3983        let mut insert_sql = emit_insert_row_sql(ins);
3984        if let Some(conflict) = &ins.unless_conflict {
3985            emit_conflict(&mut insert_sql, conflict);
3986        }
3987        insert_sql.push_str("\nRETURNING \"id\"");
3988        cte_parts.push(format!("\"_w\" AS (\n{}\n)", insert_sql));
3989        cte_parts
3990    };
3991    cte_parts.extend(enqueue_ctes(&ins.enqueue_vector, "_w"));
3992    cte_parts.extend(enqueue_search_ctes(&ins.enqueue_search, "_w", ins.enqueue_vector.len()));
3993
3994    let (shape, select_sql) = shape_select_from_cte(&ins.target, &ins.returning, "_w");
3995    let sql = format!(
3996        "WITH\n{}\n{}",
3997        cte_parts.join(",\n"),
3998        select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
3999    );
4000    SqlOutput {
4001        sql,
4002        shape,
4003        inference_plan: None,
4004    }
4005}
4006
4007// ── UPDATE ──────────────────────────────────────────────────────────────────
4008
4009fn emit_poly_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4010    let alias = &upd.target.alias;
4011    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4012
4013    // Nested-DML CTEs first — a single hoisted CTE (e.g. a nested INSERT)
4014    // is materialized once and safely referenced from every per-implementor
4015    // branch below via its own FROM clause; at most one branch's filter
4016    // will ever actually match a row, since each row belongs to exactly
4017    // one concrete implementor table.
4018    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4019    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4020    let mut union_parts = vec![];
4021    let from_ctes = nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n");
4022
4023    let has_any_multilink = !upd.multi_link_clears.is_empty()
4024        || !upd.multi_link_replaces.is_empty()
4025        || !upd.multi_link_appends.is_empty()
4026        || !upd.multi_link_removals.is_empty();
4027    for (i, imp) in upd.poly_implementors.iter().enumerate() {
4028        let cte_name = format!("_u{}", i);
4029        // Junction rows live in each implementor's own junction table, so
4030        // with any to write each implementor is updated as the concrete type
4031        // it is.
4032        if has_any_multilink {
4033            cte_parts.extend(emit_update_multilink_cte_parts(
4034                &concrete_poly_update(upd, imp),
4035                &cte_name,
4036                false,
4037            ));
4038        } else {
4039            let mut upd_sql = format!(
4040                "UPDATE {} AS {}\nSET {}{}",
4041                qn(&imp.module, &imp.table),
4042                qi(alias),
4043                sets.join(", "),
4044                from_ctes,
4045            );
4046            append_filter(&mut upd_sql, &upd.filter);
4047            upd_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4048            cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
4049        }
4050
4051        let r_alias = format!("_r{}", i);
4052        union_parts.push(format!(
4053            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4054            sql_str(&imp.type_name),
4055            qi(&r_alias),
4056            cte_name,
4057            qi(&r_alias),
4058        ));
4059    }
4060
4061    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4062
4063    let (shape, _) = emit_returning_shape(&upd.target, &upd.returning, true);
4064    SqlOutput {
4065        sql,
4066        shape,
4067        inference_plan: None,
4068    }
4069}
4070
4071fn emit_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4072    if !upd.poly_implementors.is_empty() {
4073        return emit_poly_update_stmt(upd, user_ctes);
4074    }
4075    let alias = &upd.target.alias;
4076    let (shape, returning_sql) = emit_returning_shape(&upd.target, &upd.returning, true);
4077
4078    let has_any_multilink = !upd.multi_link_clears.is_empty()
4079        || !upd.multi_link_replaces.is_empty()
4080        || !upd.multi_link_appends.is_empty()
4081        || !upd.multi_link_removals.is_empty();
4082
4083    if !has_any_multilink && upd.enqueue_vector.is_empty() && upd.enqueue_search.is_empty() {
4084        // No junction changes, no enqueue — plain UPDATE (possibly with a
4085        // user CTE prefix and/or hoisted nested-DML CTEs from a link value
4086        // like `author := (select (insert Person {...}) { id })` — see
4087        // IrUpdate::nested_ctes). A nested CTE's `id` column is referenced
4088        // from the SET clause, which needs a `FROM` clause to see it —
4089        // ordinary `UPDATE ... SET ...` has no FROM of its own otherwise.
4090        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4091        let mut sql = format!(
4092            "UPDATE {} AS {}\nSET {}",
4093            target_ref(&upd.target),
4094            qi(alias),
4095            sets.join(", "),
4096        );
4097        sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4098        append_filter(&mut sql, &upd.filter);
4099        if let Some(r) = returning_sql {
4100            sql.push_str(&r);
4101        }
4102        // The user's bindings first: a nested statement may read them, and a
4103        // CTE only sees the ones before it.
4104        let combined_ctes: Vec<IrCteDef> = user_ctes
4105            .iter()
4106            .cloned()
4107            .chain(upd.nested_ctes.iter().cloned())
4108            .collect();
4109        if !combined_ctes.is_empty() {
4110            sql = format!("{}{}", emit_cte_prefix(&combined_ctes), sql);
4111        }
4112        return SqlOutput {
4113            sql,
4114            shape,
4115            inference_plan: None,
4116        };
4117    }
4118
4119    if !has_any_multilink && (!upd.enqueue_vector.is_empty() || !upd.enqueue_search.is_empty()) {
4120        // No junction changes but need to enqueue — wrap UPDATE in a CTE.
4121        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4122        let mut upd_sql = format!(
4123            "    UPDATE {} AS {}\n    SET {}",
4124            target_ref(&upd.target),
4125            qi(alias),
4126            sets.join(", "),
4127        );
4128        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
4129        append_filter(&mut upd_sql, &upd.filter);
4130        upd_sql.push_str("\n    RETURNING \"id\"");
4131
4132        let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4133        cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4134        cte_parts.push(format!("\"_w\" AS (\n{}\n)", upd_sql));
4135        cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_w"));
4136        cte_parts.extend(enqueue_search_ctes(&upd.enqueue_search, "_w", upd.enqueue_vector.len()));
4137
4138        let (shape2, select_sql) = shape_select_from_cte(&upd.target, &upd.returning, "_w");
4139        let sql = format!(
4140            "WITH\n{}\n{}",
4141            cte_parts.join(",\n"),
4142            select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4143        );
4144        return SqlOutput {
4145            sql,
4146            shape: shape2,
4147            inference_plan: None,
4148        };
4149    }
4150
4151    // CTE-based UPDATE for junction table mutations.
4152    let result_expr = if !upd.returning.is_empty() {
4153        let (pointer_exprs, _) = build_shape(&upd.returning, alias);
4154        let mut parts = vec![type_disc(&upd.target.type_name)];
4155        parts.extend(pointer_exprs);
4156        parts.join(",\n    ")
4157    } else {
4158        format!("{}.id", qi(alias))
4159    };
4160
4161    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
4162
4163    // Nested-DML CTEs first (see IrUpdate::nested_ctes) — only relevant when
4164    // has_scalar_changes, since nested_ctes is only ever populated while
4165    // compiling scalar assignments, which is exactly the condition below.
4166    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4167    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4168
4169    // _ids: the target rows (updated or selected).
4170    if has_scalar_changes {
4171        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4172        let mut upd_sql = format!(
4173            "UPDATE {} AS {}\nSET {}",
4174            target_ref(&upd.target),
4175            qi(alias),
4176            sets.join(", "),
4177        );
4178        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4179        append_filter(&mut upd_sql, &upd.filter);
4180        upd_sql.push_str("\nRETURNING *");
4181        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", upd_sql));
4182    } else {
4183        let mut sel = format!(
4184            "SELECT {}.* FROM {} AS {}",
4185            qi(alias),
4186            target_ref(&upd.target),
4187            qi(alias),
4188        );
4189        append_filter(&mut sel, &upd.filter);
4190        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", sel));
4191    }
4192
4193    // Junction clears (`:= {}` and `:= expr` — the clear part of replace).
4194    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
4195        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
4196        let del = format!(
4197            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"_ids\"){}",
4198            qn(&clr.module, &clr.junction_table),
4199            qi(&clr.source_col),
4200            exclude,
4201        );
4202        cte_parts.push(format!("\"_clr_{}\" AS (\n{}\n)", i, del));
4203    }
4204
4205    // Junction appends (`+=`).
4206    for (i, app) in upd.multi_link_appends.iter().enumerate() {
4207        cte_parts.push(emit_ml_append_cte(
4208            app,
4209            "_ids",
4210            &upd.target.alias,
4211            &format!("_ml_add_{}", i),
4212        ));
4213    }
4214
4215    // Junction removals (`-=`).
4216    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
4217        cte_parts.push(emit_ml_remove_cte(
4218            rem,
4219            "_ids",
4220            &upd.target.alias,
4221            &format!("_ml_rm_{}", i),
4222        ));
4223    }
4224
4225    // Junction inserts for replace (`:= expr` — insert after the clear).
4226    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
4227        cte_parts.push(emit_ml_append_cte(
4228            rep,
4229            "_ids",
4230            &upd.target.alias,
4231            &format!("_ml_rep_{}", i),
4232        ));
4233    }
4234
4235    // Enqueue CTEs (source is _ids which has all columns including id).
4236    cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_ids"));
4237    cte_parts.extend(enqueue_search_ctes(
4238        &upd.enqueue_search,
4239        "_ids",
4240        upd.enqueue_vector.len(),
4241    ));
4242
4243    let sql = format!(
4244        "WITH\n{}\nSELECT (\n    {}\n) AS result\nFROM \"_ids\" AS {}",
4245        cte_parts.join(",\n"),
4246        result_expr,
4247        qi(alias),
4248    );
4249    SqlOutput {
4250        sql,
4251        shape,
4252        inference_plan: None,
4253    }
4254}
4255
4256// ── DELETE ──────────────────────────────────────────────────────────────────
4257
4258fn emit_delete_stmt(del: &IrDelete) -> SqlOutput {
4259    if !del.poly_implementors.is_empty() {
4260        return emit_poly_delete_stmt(del);
4261    }
4262    let alias = &del.target.alias;
4263
4264    if del.enqueue_search.is_empty() {
4265        let mut sql = format!("DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4266        append_filter(&mut sql, &del.filter);
4267        let (shape, returning_sql) = emit_returning_shape(&del.target, &del.returning, true);
4268        if let Some(r) = returning_sql {
4269            sql.push_str(&r);
4270        }
4271        return SqlOutput {
4272            sql,
4273            shape,
4274            inference_plan: None,
4275        };
4276    }
4277
4278    // Wrap DELETE in a CTE to enqueue OpenSearch delete jobs.
4279    let mut del_sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4280    append_filter(&mut del_sql, &del.filter);
4281    del_sql.push_str("\n    RETURNING \"id\"");
4282
4283    let mut cte_parts = vec![format!("\"_del\" AS (\n{}\n)", del_sql)];
4284    cte_parts.extend(enqueue_search_ctes(&del.enqueue_search, "_del", 0));
4285
4286    let (shape, select_sql) = shape_select_from_cte(&del.target, &del.returning, "_del");
4287    let sql = format!(
4288        "WITH\n{}\n{}",
4289        cte_parts.join(",\n"),
4290        select_sql.unwrap_or_else(|| "SELECT * FROM \"_del\"".to_string()),
4291    );
4292    SqlOutput {
4293        sql,
4294        shape,
4295        inference_plan: None,
4296    }
4297}
4298
4299fn emit_poly_delete_stmt(del: &IrDelete) -> SqlOutput {
4300    let alias = &del.target.alias;
4301    let mut cte_parts = vec![];
4302    let mut union_parts = vec![];
4303
4304    for (i, imp) in del.poly_implementors.iter().enumerate() {
4305        let cte_name = format!("_d{}", i);
4306        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
4307        append_filter(&mut del_sql, &del.filter);
4308        del_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4309        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
4310
4311        let r_alias = format!("_r{}", i);
4312        union_parts.push(format!(
4313            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4314            sql_str(&imp.type_name),
4315            qi(&r_alias),
4316            cte_name,
4317            qi(&r_alias),
4318        ));
4319    }
4320
4321    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4322
4323    let (shape, _) = emit_returning_shape(&del.target, &del.returning, true);
4324    SqlOutput {
4325        sql,
4326        shape,
4327        inference_plan: None,
4328    }
4329}
4330
4331// ── RETURNING helper ─────────────────────────────────────────────────────────
4332
4333/// Builds the RETURNING clause and ShapeDescriptor for DML.
4334/// `with_alias`: UPDATE/DELETE can use the table alias; INSERT cannot.
4335fn emit_returning_shape(
4336    target: &IrSource,
4337    returning: &[IrShapePointer],
4338    with_alias: bool,
4339) -> (ShapeDescriptor, Option<String>) {
4340    if returning.is_empty() {
4341        return (
4342            ShapeDescriptor {
4343                root: ShapeNode::Scalar {
4344                    name: String::new(),
4345                    position: 0,
4346                },
4347            },
4348            None,
4349        );
4350    }
4351
4352    let alias = if with_alias { target.alias.as_str() } else { "" };
4353    let (pointer_exprs, shape_pointers) = build_shape(returning, alias);
4354
4355    let mut parts = vec![type_disc(&target.type_name)];
4356    parts.extend(pointer_exprs);
4357    let tuple = parts.join(",\n    ");
4358    let sql = format!("\nRETURNING (\n    {}\n) AS result", tuple);
4359
4360    let root_pointers = prepend_type(shape_pointers);
4361    let shape = ShapeDescriptor {
4362        root: ShapeNode::Object {
4363            name: String::new(),
4364            type_name: Some(target.type_name.clone()),
4365            position: 0,
4366            cardinality: Cardinality::Required,
4367            pointers: root_pointers,
4368            has_implicit_id: shape_has_implicit_id(returning),
4369        },
4370    };
4371    (shape, Some(sql))
4372}
4373
4374// ── Shape emission ───────────────────────────────────────────────────────────
4375
4376fn emit_scalar_set(f: &IrScalarSetPointer, pos: usize) -> (String, ShapeNode) {
4377    let from_sql = if !f.poly_implementors.is_empty() {
4378        format!(
4379            "(\n{}\n) AS {}",
4380            emit_poly_union(&f.poly_implementors, &f.poly_columns),
4381            qi(&f.source.alias)
4382        )
4383    } else {
4384        format!("{} AS {}", source_ref(&f.source), qi(&f.source.alias))
4385    };
4386    let sql = format!(
4387        "(SELECT COALESCE(array_agg(ROW({})::record), ARRAY[]::record[]) FROM {})",
4388        emit_expr(&f.bool_expr),
4389        from_sql,
4390    );
4391    let node = ShapeNode::Array {
4392        name: f.alias.clone(),
4393        position: pos,
4394        element: Box::new(ShapeNode::Scalar {
4395            name: String::new(),
4396            position: 0,
4397        }),
4398    };
4399    (sql, node)
4400}
4401
4402/// Build SQL expressions and ShapeNodes for `pointers`, starting at position 1
4403/// (position 0 is always the type discriminator, added by the caller).
4404/// True when the compiler put an `id` at the front of this shape that the
4405/// query never asked for — see `IrScalarPointer::implicit_id`.
4406fn shape_has_implicit_id(pointers: &[IrShapePointer]) -> bool {
4407    matches!(pointers.first(), Some(IrShapePointer::Scalar(p)) if p.implicit_id)
4408}
4409
4410fn build_shape(pointers: &[IrShapePointer], table_alias: &str) -> (Vec<String>, Vec<ShapeNode>) {
4411    let mut exprs = Vec::new();
4412    let mut nodes = Vec::new();
4413
4414    for (i, pointer) in pointers.iter().enumerate() {
4415        let (sql, node) = emit_shape_pointer(pointer, table_alias, i + 1);
4416        exprs.push(sql);
4417        nodes.push(node);
4418    }
4419
4420    (exprs, nodes)
4421}
4422
4423/// A `$1`, `$2`, … template with each placeholder replaced by its argument's
4424/// SQL, in one pass over the template alone. Replacing one number at a time
4425/// over the growing string would rewrite a query parameter an argument
4426/// carries (`$5` inside the first argument, replaced again as the fifth), and
4427/// `$1` also matches the start of `$10`.
4428fn fill_template(template: &str, args: &[String]) -> String {
4429    let mut out = String::with_capacity(template.len());
4430    let mut chars = template.char_indices().peekable();
4431    while let Some((start, c)) = chars.next() {
4432        if c != '$' {
4433            out.push(c);
4434            continue;
4435        }
4436        let mut end = start + 1;
4437        while let Some((i, d)) = chars.peek().copied()
4438            && d.is_ascii_digit()
4439        {
4440            end = i + d.len_utf8();
4441            chars.next();
4442        }
4443        match template[start + 1..end].parse::<usize>() {
4444            Ok(n) if (1..=args.len()).contains(&n) => out.push_str(&args[n - 1]),
4445            _ => out.push_str(&template[start..end]),
4446        }
4447    }
4448    out
4449}
4450
4451/// `, message` for an assert given `message := …`, else nothing.
4452fn assert_message_arg(message: &Option<IrExpr>) -> String {
4453    message
4454        .as_ref()
4455        .map(|m| format!(", ({})::text", emit_expr(m)))
4456        .unwrap_or_default()
4457}
4458
4459fn emit_shape_pointer(pointer: &IrShapePointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4460    match pointer {
4461        IrShapePointer::Scalar(f) => emit_scalar(f, table_alias, pos),
4462        IrShapePointer::SingleLink(f) => emit_single_link(f, table_alias, pos),
4463        IrShapePointer::MultiLink(f) => emit_multi_link(f, table_alias, pos),
4464        IrShapePointer::Computed(f) => (emit_expr(&f.expr), expr_shape_node(&f.alias, pos, &f.expr)),
4465        IrShapePointer::ScalarSet(f) => emit_scalar_set(f, pos),
4466        IrShapePointer::Asserted(a) => {
4467            let (sql, node) = emit_shape_pointer(&a.inner, table_alias, pos);
4468            // The inner pointer aggregates rows to `record[]`, which a PL/pgSQL
4469            // function cannot take as a parameter at all -- hence the cast to
4470            // `text[]`, whose rendering compares element-for-element the same
4471            // way. Bound in a sub-select so the pointer's own subquery is
4472            // evaluated once rather than in both the check and the result, and
4473            // read through `cardinality` because the assert hands back the
4474            // array it was given.
4475            // A pointer landing on one object reads as a lone record, not an
4476            // array, so its set is re-read through `ARRAY(…)`; the unlimited
4477            // check form would otherwise fail on more than one row.
4478            let checked_set = match &a.check {
4479                Some(check) if emits_one_object(check) => {
4480                    format!("ARRAY{}", emit_shape_pointer(check, table_alias, pos).0)
4481                }
4482                Some(check) => emit_shape_pointer(check, table_alias, pos).0,
4483                None if emits_one_object(&a.inner) => format!("ARRAY{sql}"),
4484                None => "\"_a\".\"v\"".to_string(),
4485            };
4486            let checked = format!(
4487                "(SELECT \"_a\".\"v\" FROM (SELECT {sql} AS \"v\") AS \"_a\"\n                     WHERE cardinality(\"_pylon\".{}({checked_set}::text[]{})) >= 0)",
4488                qi(&a.fn_name),
4489                assert_message_arg(&a.message),
4490            );
4491            (checked, node)
4492        }
4493    }
4494}
4495
4496/// True when the pointer is emitted as a single `(SELECT …)` record rather
4497/// than an aggregated array of them.
4498fn emits_one_object(pointer: &IrShapePointer) -> bool {
4499    match pointer {
4500        IrShapePointer::SingleLink(_) => true,
4501        IrShapePointer::Computed(c) => matches!(
4502            c.expr,
4503            IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { multi: false, .. }
4504        ),
4505        IrShapePointer::Asserted(a) => emits_one_object(&a.inner),
4506        _ => false,
4507    }
4508}
4509
4510/// Convert a PostgreSQL schema-qualified type name (`"module"."TypeName"`) to
4511/// a Pylon-qualified name (`module::TypeName`).
4512fn pg_quoted_to_pylon(pg_type: &str) -> String {
4513    let inner = pg_type.trim_start_matches('"');
4514    if let Some(idx) = inner.find(r#""."#) {
4515        let module = &inner[..idx];
4516        let type_name = inner[idx + 3..].trim_end_matches('"');
4517        format!("{}::{}", module, type_name)
4518    } else {
4519        pg_type.to_string()
4520    }
4521}
4522
4523/// A schema-qualified custom type (an enum or a domain) named in a property's
4524/// `pg_type`, together with whether the column holds an array of it.
4525///
4526/// Both forms are emitted as text -- `::text` for a scalar, `::text[]` for an
4527/// array -- so a value's shape never depends on an OID the database assigns
4528/// when the migration creating the type runs. The array case has to be told
4529/// apart from the scalar one: `"access"."AuthenticationMethod"[]` starts with a
4530/// quote just as `"account"."InterfaceAppearance"` does, and reading it as a
4531/// scalar enum casts a whole array to a single `text` (`'{MagicLink}'`) and
4532/// shapes it as one enum value.
4533struct QualifiedPgType {
4534    /// Pylon-qualified name of the element type, e.g. `access::AuthenticationMethod`.
4535    name: String,
4536    is_array: bool,
4537}
4538
4539impl QualifiedPgType {
4540    fn of(pg_type: &str) -> Option<Self> {
4541        let (element, is_array) = match pg_type.strip_suffix("[]") {
4542            Some(element) => (element, true),
4543            None => (pg_type, false),
4544        };
4545        element.starts_with('"').then(|| Self {
4546            name: pg_quoted_to_pylon(element),
4547            is_array,
4548        })
4549    }
4550
4551    /// The cast that carries this value back as text.
4552    fn text_cast(&self) -> &'static str {
4553        if self.is_array { "::text[]" } else { "::text" }
4554    }
4555
4556    /// How the decoded value is shaped: a single enum, or a list of them.
4557    fn shape_node(&self, name: String, position: usize) -> ShapeNode {
4558        let element = ShapeNode::Enum {
4559            name: name.clone(),
4560            position,
4561            enum_type: self.name.clone(),
4562        };
4563        if !self.is_array {
4564            return element;
4565        }
4566        ShapeNode::Array {
4567            name,
4568            // Each element arrives as the label itself rather than as a field
4569            // of a record, which the decoders read at position 0.
4570            element: Box::new(ShapeNode::Enum {
4571                name: String::new(),
4572                position: 0,
4573                enum_type: self.name.clone(),
4574            }),
4575            position,
4576        }
4577    }
4578}
4579
4580fn emit_scalar(f: &IrScalarPointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4581    if let Some(nt_name) = f.pg_type.strip_prefix("__nt__:") {
4582        let sql = if table_alias.is_empty() {
4583            format!("{}::jsonb", qi(&f.column))
4584        } else {
4585            format!("{}.{}::jsonb", qi(table_alias), qi(&f.column))
4586        };
4587        return (
4588            sql,
4589            ShapeNode::NamedTuple {
4590                name: f.alias.clone(),
4591                position: pos,
4592                type_name: Some(nt_name.to_string()),
4593                members: f.tuple_shape.as_ref().map(|s| s.members.clone()),
4594                is_free_object: false,
4595            },
4596        );
4597    }
4598    // Schema-qualified custom types (enums, domains) have runtime OIDs unknown to a static
4599    // anonymous_record_decode. Cast to text — the string label is all the decoder needs.
4600    if let Some(qualified) = QualifiedPgType::of(&f.pg_type) {
4601        let cast = qualified.text_cast();
4602        let sql = if table_alias.is_empty() {
4603            format!("{}{}", qi(&f.column), cast)
4604        } else {
4605            format!("{}.{}{}", qi(table_alias), qi(&f.column), cast)
4606        };
4607        return (sql, qualified.shape_node(f.alias.clone(), pos));
4608    }
4609    // A structural pylon.Tuple[...]-typed property — same jsonb column shape
4610    // as the nominal `__nt__:` case above, just with no registered dataclass
4611    // to hydrate (type_name stays None).
4612    if let Some(shape) = &f.tuple_shape {
4613        let sql = if table_alias.is_empty() {
4614            format!("{}::jsonb", qi(&f.column))
4615        } else {
4616            format!("{}.{}::jsonb", qi(table_alias), qi(&f.column))
4617        };
4618        return (
4619            sql,
4620            ShapeNode::NamedTuple {
4621                name: f.alias.clone(),
4622                position: pos,
4623                type_name: shape.type_name.clone(),
4624                members: Some(shape.members.clone()),
4625                is_free_object: false,
4626            },
4627        );
4628    }
4629    let sql = if table_alias.is_empty() {
4630        format!("{}::{}", qi(&f.column), f.pg_type)
4631    } else {
4632        format!("{}.{}::{}", qi(table_alias), qi(&f.column), f.pg_type)
4633    };
4634    (
4635        sql,
4636        ShapeNode::Scalar {
4637            name: f.alias.clone(),
4638            position: pos,
4639        },
4640    )
4641}
4642
4643fn emit_single_link(f: &IrSingleLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
4644    let sub = &f.subquery;
4645    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
4646        unreachable!("single-link subquery is always schema-bound")
4647    };
4648    let sub_alias = &source.alias;
4649
4650    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
4651    let mut parts = vec![source_type_disc(source)];
4652    parts.extend(sub_exprs);
4653
4654    // Link properties: read from the junction table alias "jt" — only ever
4655    // populated for a junction-backed link (see `IrSingleLinkPointer`'s doc
4656    // comment), which is exactly when `f.correlation` below puts "jt" in
4657    // scope.
4658    for lp in &f.link_properties {
4659        parts.push(format!("\"jt\".{}", qi(&lp.name)));
4660        let pos = sub_nodes.len() + 1;
4661        sub_nodes.push(ShapeNode::Scalar {
4662            name: format!("@{}", lp.name),
4663            position: pos,
4664        });
4665    }
4666
4667    let tuple = parts.join(",\n        ");
4668
4669    let (from_sql, mut where_parts) = match &f.correlation {
4670        IrSingleLinkCorrelation::Fk { fk_column, target_pk } => {
4671            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
4672            let cond = format!(
4673                "{}.{} = {}.{}",
4674                qi(parent_alias),
4675                qi(fk_column),
4676                qi(sub_alias),
4677                qi(target_pk),
4678            );
4679            (from, vec![cond])
4680        }
4681        // Junction-backed — same join shape a multi-link's correlated
4682        // subquery uses; cardinality-one is a schema-level invariant on the
4683        // junction table, not something this query needs to enforce itself.
4684        IrSingleLinkCorrelation::Junction { join, target_pk: _ } => match join {
4685            IrMultiLinkJoin::Standard { junction_table, module } => {
4686                let from = format!(
4687                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
4688                    junction_ref(module, junction_table),
4689                    source_ref(source),
4690                    qi(sub_alias),
4691                    qi(sub_alias),
4692                );
4693                let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
4694                (from, vec![cond])
4695            }
4696            IrMultiLinkJoin::Through {
4697                junction_table,
4698                module,
4699                source_col,
4700                target_col,
4701            } => {
4702                let from = format!(
4703                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4704                    junction_ref(module, junction_table),
4705                    source_ref(source),
4706                    qi(sub_alias),
4707                    qi(sub_alias),
4708                    qi(target_col),
4709                );
4710                let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
4711                (from, vec![cond])
4712            }
4713            IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
4714                unreachable!("a junction-backed single link's own forward join is always Standard or Through")
4715            }
4716        },
4717    };
4718    if let Some(filter) = &sub.filter {
4719        where_parts.push(emit_expr(filter));
4720    }
4721
4722    let mut sql = format!(
4723        "(SELECT (\n        {}\n    )\n    {}\n    WHERE {}",
4724        tuple,
4725        from_sql,
4726        where_parts.join(" AND "),
4727    );
4728    if !sub.order_by.is_empty() {
4729        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
4730        sql.push_str(&format!("\n    ORDER BY {}", s.join(", ")));
4731    }
4732    sql.push(')');
4733
4734    let node = ShapeNode::Object {
4735        name: f.alias.clone(),
4736        type_name: Some(source.type_name.clone()),
4737        position: pos,
4738        cardinality: Cardinality::Optional,
4739        pointers: prepend_type(sub_nodes),
4740        has_implicit_id: shape_has_implicit_id(shape),
4741    };
4742    (sql, node)
4743}
4744
4745fn emit_multi_link(f: &IrMultiLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
4746    let sub = &f.subquery;
4747    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
4748        unreachable!("multi-link subquery is always schema-bound")
4749    };
4750    let sub_alias = &source.alias;
4751
4752    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
4753    let mut row_parts = vec![source_type_disc(source)];
4754    row_parts.extend(sub_exprs);
4755
4756    // Link properties: read from the junction table alias "jt".
4757    // The ShapeNode name carries the `@` prefix so hydration stores it as
4758    // `@prop` in the object's __dict__ and the REPL displays it with `@`.
4759    for lp in &f.link_properties {
4760        row_parts.push(format!("\"jt\".{}", qi(&lp.name)));
4761        // Positions are 1-based (0 = type discriminator). sub_nodes.len() gives
4762        // the count of already-assigned positions, so the next position is len+1.
4763        let pos = sub_nodes.len() + 1;
4764        sub_nodes.push(ShapeNode::Scalar {
4765            name: format!("@{}", lp.name),
4766            position: pos,
4767        });
4768    }
4769
4770    let row = row_parts.join(",\n            ");
4771
4772    // ORDER BY inside array_agg
4773    let order_sql = if !sub.order_by.is_empty() {
4774        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
4775        format!(" ORDER BY {}", s.join(", "))
4776    } else {
4777        String::new()
4778    };
4779
4780    let (from_sql, source_cond) = match &f.join {
4781        IrMultiLinkJoin::Standard { junction_table, module } => {
4782            let from = format!(
4783                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
4784                junction_ref(module, junction_table),
4785                source_ref(source),
4786                qi(sub_alias),
4787                qi(sub_alias),
4788            );
4789            let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
4790            (from, cond)
4791        }
4792        IrMultiLinkJoin::Through {
4793            junction_table,
4794            module,
4795            source_col,
4796            target_col,
4797        } => {
4798            let from = format!(
4799                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4800                junction_ref(module, junction_table),
4801                source_ref(source),
4802                qi(sub_alias),
4803                qi(sub_alias),
4804                qi(target_col),
4805            );
4806            let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
4807            (from, cond)
4808        }
4809        // Reverse of a single link: no junction table, the owner
4810        // (sub-select) rows are correlated directly by their own FK column.
4811        IrMultiLinkJoin::BacklinkFk { fk_col } => {
4812            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
4813            let cond = format!("{}.{} = {}.id", qi(sub_alias), qi(fk_col), qi(parent_alias));
4814            (from, cond)
4815        }
4816        // Reverse of a multi-link: same junction table shape as `Standard`/
4817        // `Through`, with the owner/current column roles swapped — the
4818        // sub-select's own rows join via `owner_col`, the outer (current)
4819        // row correlates via `current_col`.
4820        IrMultiLinkJoin::BacklinkJunction {
4821            junction_table,
4822            module,
4823            owner_col,
4824            current_col,
4825        } => {
4826            let from = format!(
4827                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4828                junction_ref(module, junction_table),
4829                source_ref(source),
4830                qi(sub_alias),
4831                qi(sub_alias),
4832                qi(owner_col),
4833            );
4834            let cond = format!("\"jt\".{} = {}.id", qi(current_col), qi(parent_alias));
4835            (from, cond)
4836        }
4837    };
4838
4839    let mut where_parts = vec![source_cond];
4840    if let Some(filter) = &sub.filter {
4841        where_parts.push(emit_expr(filter));
4842    }
4843
4844    if f.single {
4845        let mut sql = format!(
4846            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
4847            row,
4848            from_sql,
4849            where_parts.join(" AND "),
4850        );
4851        append_order_by(&mut sql, &sub.order_by);
4852        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
4853        sql.push(')');
4854        let node = ShapeNode::Object {
4855            name: f.alias.clone(),
4856            type_name: Some(source.type_name.clone()),
4857            position: pos,
4858            cardinality: Cardinality::Optional,
4859            pointers: prepend_type(sub_nodes),
4860            has_implicit_id: shape_has_implicit_id(shape),
4861        };
4862        return (sql, node);
4863    }
4864
4865    if f.single {
4866        let mut sql = format!(
4867            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
4868            row,
4869            from_sql,
4870            where_parts.join(" AND "),
4871        );
4872        append_order_by(&mut sql, &sub.order_by);
4873        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
4874        sql.push(')');
4875        let node = ShapeNode::Object {
4876            name: f.alias.clone(),
4877            type_name: Some(source.type_name.clone()),
4878            position: pos,
4879            cardinality: Cardinality::Optional,
4880            pointers: prepend_type(sub_nodes),
4881            has_implicit_id: shape_has_implicit_id(shape),
4882        };
4883        return (sql, node);
4884    }
4885
4886    let sql = if sub.limit.is_some() || sub.offset.is_some() {
4887        // OFFSET/LIMIT cut the *rows* that go into the array, so they can't
4888        // sit next to the aggregate — the aggregate collapses them to one
4889        // row first. Select the row's parts (and any sort keys) in a derived
4890        // table that carries the modifiers, then aggregate over that.
4891        let mut cols: Vec<String> = row_parts
4892            .iter()
4893            .enumerate()
4894            .map(|(i, part)| format!("{} AS \"c{}\"", part, i))
4895            .collect();
4896        let agg_row: Vec<String> = (0..row_parts.len()).map(|i| format!("\"__lim\".\"c{}\"", i)).collect();
4897        let mut agg_order: Vec<String> = vec![];
4898        for (i, s) in sub.order_by.iter().enumerate() {
4899            cols.push(format!("{} AS \"s{}\"", emit_expr(&s.expr), i));
4900            agg_order.push(emit_sort_clause(&IrSort {
4901                expr: IrExpr::ColumnRef {
4902                    alias: "__lim".to_string(),
4903                    column: format!("s{}", i),
4904                    pg_type: String::new(),
4905                },
4906                direction: s.direction.clone(),
4907                nulls: s.nulls.clone(),
4908            }));
4909        }
4910        let mut inner = format!(
4911            "SELECT {}\n    {}\n    WHERE {}",
4912            cols.join(",\n        "),
4913            from_sql,
4914            where_parts.join(" AND "),
4915        );
4916        append_order_by(&mut inner, &sub.order_by);
4917        append_offset_limit(&mut inner, &sub.offset, &sub.limit);
4918        let agg_order_sql = if agg_order.is_empty() {
4919            String::new()
4920        } else {
4921            format!(" ORDER BY {}", agg_order.join(", "))
4922        };
4923        format!(
4924            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    FROM ({}) AS \"__lim\")",
4925            agg_row.join(",\n            "),
4926            agg_order_sql,
4927            inner,
4928        )
4929    } else {
4930        format!(
4931            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    {}\n    WHERE {})",
4932            row,
4933            order_sql,
4934            from_sql,
4935            where_parts.join(" AND "),
4936        )
4937    };
4938
4939    let node = ShapeNode::Array {
4940        name: f.alias.clone(),
4941        position: pos,
4942        element: Box::new(ShapeNode::Object {
4943            name: String::new(),
4944            type_name: Some(source.type_name.clone()),
4945            position: 0,
4946            cardinality: Cardinality::Required,
4947            pointers: prepend_type(sub_nodes),
4948            has_implicit_id: shape_has_implicit_id(shape),
4949        }),
4950    };
4951    (sql, node)
4952}
4953
4954/// Prepend `ShapeNode::Scalar { name: "__type__", position: 0 }` and shift
4955/// existing nodes' positions by 1.
4956fn prepend_type(nodes: Vec<ShapeNode>) -> Vec<ShapeNode> {
4957    let mut out = vec![ShapeNode::Scalar {
4958        name: "__type__".into(),
4959        position: 0,
4960    }];
4961    out.extend(nodes);
4962    out
4963}
4964
4965// ── SQL clause helpers ──────────────────────────────────────────────────────
4966
4967fn append_filter(sql: &mut String, filter: &Option<IrExpr>) {
4968    if let Some(f) = filter {
4969        sql.push_str(&format!("\nWHERE {}", emit_expr(f)));
4970    }
4971}
4972
4973fn append_order_by(sql: &mut String, order_by: &[IrSort]) {
4974    if !order_by.is_empty() {
4975        let s: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
4976        sql.push_str(&format!("\nORDER BY {}", s.join(", ")));
4977    }
4978}
4979
4980fn append_offset_limit(sql: &mut String, offset: &Option<IrExpr>, limit: &Option<IrExpr>) {
4981    if let Some(o) = offset {
4982        sql.push_str(&format!("\nOFFSET {}", emit_expr(o)));
4983    }
4984    if let Some(l) = limit {
4985        sql.push_str(&format!("\nLIMIT {}", emit_expr(l)));
4986    }
4987}
4988
4989/// `FOR UPDATE`/`FOR SHARE`/... — Postgres's own grammar places this last,
4990/// after `ORDER BY`/`LIMIT`/`OFFSET`, so this must be the final thing
4991/// appended to a bound SELECT's SQL. No `OF table_name` — the outer
4992/// SELECT's own aliased table is always the only lockable target (nested
4993/// shape pointers are correlated subqueries, never part of the outer FROM),
4994/// so a bare `FOR UPDATE` already targets exactly the right row(s).
4995fn append_lock_clause(sql: &mut String, lock: &Option<IrLockClause>) {
4996    let Some(lock) = lock else { return };
4997    let strength = match lock.strength {
4998        IrLockStrength::Update => "UPDATE",
4999        IrLockStrength::NoKeyUpdate => "NO KEY UPDATE",
5000        IrLockStrength::Share => "SHARE",
5001        IrLockStrength::KeyShare => "KEY SHARE",
5002    };
5003    sql.push_str(&format!("\nFOR {}", strength));
5004    match lock.wait {
5005        IrLockWait::Block => {}
5006        IrLockWait::NoWait => sql.push_str(" NOWAIT"),
5007        IrLockWait::SkipLocked => sql.push_str(" SKIP LOCKED"),
5008    }
5009}
5010
5011fn emit_sort_clause(s: &IrSort) -> String {
5012    let dir = match s.direction {
5013        IrSortDir::Asc => "ASC",
5014        IrSortDir::Desc => "DESC",
5015    };
5016    let nulls = match s.nulls {
5017        IrNulls::First => "NULLS FIRST",
5018        IrNulls::Last => "NULLS LAST",
5019    };
5020    format!("{} {} {}", emit_expr(&s.expr), dir, nulls)
5021}
5022
5023// ── Expression emission ─────────────────────────────────────────────────────
5024
5025/// One side of `a intersect b` as the rows of the set it stands for: an
5026/// array's elements, a binding's rows, else the single value.
5027fn set_operand(expr: &IrExpr) -> String {
5028    match expr {
5029        IrExpr::ArrayFromSelect(_) => format!("SELECT unnest({})", emit_expr(expr)),
5030        IrExpr::CteRef { name, scalar, .. } => {
5031            format!("SELECT {} FROM {}", if *scalar { "\"v\"" } else { "\"id\"" }, qi(name))
5032        }
5033        _ => format!("SELECT {}", emit_expr(expr)),
5034    }
5035}
5036
5037/// `emit_expr`, reading the types with subtypes through `fanouts` the way
5038/// `emit` does — see `IrOutput::subtype_fanouts`.
5039pub fn emit_expr_with_fanouts(expr: &IrExpr, fanouts: &HashMap<(String, String), IrPolyFanout>) -> String {
5040    with_subtype_fanouts(fanouts, || emit_expr(expr))
5041}
5042
5043pub fn emit_expr(expr: &IrExpr) -> String {
5044    match expr {
5045        IrExpr::ColumnRef { alias, column, .. } => {
5046            if alias.is_empty() {
5047                qi(column)
5048            } else if alias == "NEW" || alias == "OLD" {
5049                // plpgsql's trigger row variables (`ir::compile_trigger_handler`'s
5050                // `__new__`/`__old__` anchors) are recognized by the plpgsql
5051                // parser as bare, unquoted identifiers — quoting them like an
5052                // ordinary table alias (`"NEW"."col"`) turns this into an
5053                // *ordinary* SQL column reference instead, which Postgres then
5054                // rejects with "missing FROM-clause entry for table NEW" the
5055                // moment it appears inside a nested SQL command (e.g. the
5056                // VALUES list of an `INSERT` the trigger handler runs) — there's
5057                // no real `NEW` table to resolve it against there, only
5058                // plpgsql's own row-variable substitution recognizes it.
5059                format!("{alias}.{}", qi(column))
5060            } else {
5061                format!("{}.{}", qi(alias), qi(column))
5062            }
5063        }
5064        IrExpr::Param { index } => format!("${}", index + 1),
5065        IrExpr::Literal(lit) => emit_literal(lit),
5066        IrExpr::BinOp(op) => {
5067            let l = emit_expr(&op.left);
5068            let r = emit_expr(&op.right);
5069            match op.op {
5070                BinOpKind::Add => format!("({} + {})", l, r),
5071                BinOpKind::Sub => format!("({} - {})", l, r),
5072                BinOpKind::Mul => format!("({} * {})", l, r),
5073                BinOpKind::Div => format!("({} / {})", l, r),
5074                BinOpKind::FloorDiv => {
5075                    if is_integer_expr(&op.left) && is_integer_expr(&op.right) {
5076                        format!("({} / {})", l, r)
5077                    } else {
5078                        format!("floor(({}) / ({}))", l, r)
5079                    }
5080                }
5081                BinOpKind::Mod => format!("({} % {})", l, r),
5082                BinOpKind::Pow => format!("power({}, {})", l, r),
5083                BinOpKind::Eq => format!("({} = {})", l, r),
5084                BinOpKind::Ne => format!("({} <> {})", l, r),
5085                BinOpKind::Lt => format!("({} < {})", l, r),
5086                BinOpKind::Le => format!("({} <= {})", l, r),
5087                BinOpKind::Gt => format!("({} > {})", l, r),
5088                BinOpKind::Ge => format!("({} >= {})", l, r),
5089                BinOpKind::And => format!("({} AND {})", l, r),
5090                BinOpKind::Or => format!("({} OR {})", l, r),
5091                BinOpKind::Like => format!("({} LIKE {})", l, r),
5092                BinOpKind::Ilike => format!("({} ILIKE {})", l, r),
5093                BinOpKind::NotLike => format!("({} NOT LIKE {})", l, r),
5094                BinOpKind::NotIlike => format!("({} NOT ILIKE {})", l, r),
5095                // `ANY`/`ALL` take the array directly, so unwrap a
5096                // right-hand `std::array_unpack` rather than emitting its
5097                // `unnest()` — a set-returning function is not allowed in
5098                // `WHERE`, and PostgreSQL rejects it at execution time even
5099                // though the PyQL and the SQL both look fine.
5100                BinOpKind::In => format!("({} = ANY({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5101                BinOpKind::NotIn => format!("({} <> ALL({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5102                BinOpKind::Coalesce => format!("COALESCE({}, {})", l, r),
5103                BinOpKind::CoalesceEq => format!("({} IS NOT DISTINCT FROM {})", l, r),
5104                BinOpKind::CoalesceNe => format!("({} IS DISTINCT FROM {})", l, r),
5105                BinOpKind::Concat => format!("({} || {})", l, r),
5106            }
5107        }
5108        IrExpr::UnaryOp(op) => {
5109            let inner = emit_expr(&op.operand);
5110            match op.op {
5111                UnaryOpKind::Not => format!("(NOT {})", inner),
5112                UnaryOpKind::Minus => format!("(-{})", inner),
5113                UnaryOpKind::Exists => format!("EXISTS({})", inner),
5114                // A value that reached expression position is a single one,
5115                // and a single value is already distinct — `DISTINCT x` is not
5116                // an SQL expression. Set-valued operands carry their own
5117                // DISTINCT, applied where the set is built.
5118                UnaryOpKind::Distinct => inner,
5119            }
5120        }
5121        IrExpr::FunctionCall(f) => {
5122            let args: Vec<_> = f.args.iter().map(emit_expr).collect();
5123            if let Some(tmpl) = &f.sql_template {
5124                return fill_template(tmpl, &args);
5125            }
5126            let name = match &f.schema {
5127                Some(s) => format!("{}.{}", pg_schema(s), qi(&f.name)),
5128                None => f.name.clone(),
5129            };
5130            format!("{}({})", name, args.join(", "))
5131        }
5132        IrExpr::TypeCast(c) => {
5133            // PostgreSQL doesn't support arbitrary_type::jsonb; to_jsonb() accepts any input.
5134            // String literals have type "unknown" in PG, so cast to text first.
5135            if c.pg_type == "jsonb" {
5136                match &c.expr {
5137                    // A bare `$N` parameter has no PG type at all until told
5138                    // otherwise. to_jsonb($N) can't resolve it — it's a
5139                    // polymorphic function, which needs a *concrete* input
5140                    // type to dispatch on, and an unknown-typed placeholder
5141                    // gives it nothing to work with ("could not determine
5142                    // polymorphic type because input has type unknown").
5143                    // A direct ($N)::jsonb cast works because PG specially
5144                    // resolves an unknown-typed parameter against an
5145                    // explicit cast — the same reason `$1::uuid` works fine
5146                    // elsewhere in this file.
5147                    IrExpr::Param { .. } => format!("({})::jsonb", emit_expr(&c.expr)),
5148                    IrExpr::Literal(IrLiteral::Str(_)) => {
5149                        format!("to_jsonb({}::text)", emit_expr(&c.expr))
5150                    }
5151                    _ => format!("to_jsonb({})", emit_expr(&c.expr)),
5152                }
5153            } else if yields_jsonb(&c.expr) {
5154                // Casting json to a scalar yields the value, not its JSON
5155                // spelling — `<str>` of a json string is the string, with no
5156                // quotes around it. `::text` would keep them.
5157                format!("(({}) #>> '{{}}')::{}", emit_expr(&c.expr), c.pg_type)
5158            } else {
5159                format!("({})::{}", emit_expr(&c.expr), c.pg_type)
5160            }
5161        }
5162        IrExpr::IfElse(ie) => format!(
5163            "CASE WHEN {} THEN {} ELSE {} END",
5164            emit_expr(&ie.condition),
5165            emit_expr(&ie.if_),
5166            emit_expr(&ie.else_),
5167        ),
5168        IrExpr::Array(elems) => {
5169            if elems.is_empty() {
5170                "ARRAY[]::text[]".to_string()
5171            } else {
5172                let parts: Vec<String> = elems.iter().map(emit_expr).collect();
5173                format!("ARRAY[{}]", parts.join(", "))
5174            }
5175        }
5176        IrExpr::Null => "NULL".to_string(),
5177        IrExpr::AggOverSet {
5178            fn_name,
5179            schema: _,
5180            elems,
5181        } => {
5182            let union_all = elems
5183                .iter()
5184                .map(|e| format!("SELECT {}", emit_expr(e)))
5185                .collect::<Vec<_>>()
5186                .join(" UNION ALL ");
5187            format!("(SELECT {}(v) FROM ({}) AS _set(v))", fn_name, union_all)
5188        }
5189        IrExpr::AggOverCte { fn_name, cte, column } => format!(
5190            "(SELECT {}({}) FROM {})",
5191            fn_name,
5192            column.as_deref().map(qi).unwrap_or_else(|| "*".to_string()),
5193            qi(cte),
5194        ),
5195        IrExpr::ExistsOverCte { cte, column } => match column {
5196            Some(column) => format!("EXISTS(SELECT 1 FROM {} WHERE {} IS NOT NULL)", qi(cte), qi(column)),
5197            None => format!("EXISTS(SELECT 1 FROM {})", qi(cte)),
5198        },
5199        IrExpr::AggOverQuery { fn_name, inner } => {
5200            let inner_sql = emit_select_stmt(inner, &[]).sql;
5201            format!("(SELECT {}(*) FROM ({}) _agg)", fn_name, inner_sql)
5202        }
5203        IrExpr::ArrayFromSelect(src) => emit_array_source(src),
5204        IrExpr::ScalarSubquery(sel) => {
5205            format!(
5206                "(SELECT \"v\" FROM (\n{}\n) AS \"_scalar_sub\")",
5207                emit_select_stmt(sel, &[]).sql
5208            )
5209        }
5210
5211        IrExpr::SetOp { op, left, right, mode } => {
5212            let set = format!("({}) {} ({})", set_operand(left), op.sql(), set_operand(right));
5213            match mode {
5214                crate::ir::SetOpMode::Exists => format!("EXISTS({set})"),
5215                crate::ir::SetOpMode::Array => format!("ARRAY({set})"),
5216                crate::ir::SetOpMode::Aggregate(f) => {
5217                    format!("(SELECT {f}(\"_s\".\"v\") FROM ({set}) AS \"_s\"(\"v\"))")
5218                }
5219            }
5220        }
5221
5222        IrExpr::CteRef { name, scalar, .. } => {
5223            // scalar CTEs emit `ROW(expr) AS result, expr AS v`; use `v` for
5224            // expression context so we get the plain scalar type, not record.
5225            let col = if *scalar { "v" } else { "id" };
5226            format!("(SELECT \"{}\" FROM \"{}\")", col, name)
5227        }
5228
5229        IrExpr::CteFieldRef { name, field, .. } => {
5230            format!("(SELECT {} FROM {})", qi(field), qi(name))
5231        }
5232
5233        IrExpr::ForVar { name, .. } => format!("\"_for_{}\".\"v\"", name),
5234
5235        IrExpr::EnumLiteral { pg_type, variant } => {
5236            format!("'{}'::{}", variant.replace('\'', "''"), pg_type)
5237        }
5238
5239        IrExpr::GlobalParam { index, pg_type } => {
5240            format!("(${}::{})", index + 1, pg_type)
5241        }
5242
5243        IrExpr::GlobalRef { cte_name } => {
5244            format!("(SELECT \"value\" FROM \"{}\")", cte_name)
5245        }
5246
5247        IrExpr::NamedTuple { fields, .. } => {
5248            let pairs: Vec<String> = fields
5249                .iter()
5250                .flat_map(|(k, v)| [format!("'{}'", k.replace('\'', "''")), emit_expr(v)])
5251                .collect();
5252            format!("jsonb_build_object({})", pairs.join(", "))
5253        }
5254
5255        IrExpr::Tuple(elems) => {
5256            let items: Vec<String> = elems.iter().map(emit_expr).collect();
5257            format!("jsonb_build_array({})", items.join(", "))
5258        }
5259
5260        IrExpr::Subscript { expr, index, is_array } => {
5261            let e = emit_expr(expr);
5262            let i = emit_expr(index);
5263            if *is_array {
5264                format!("_pylon.array_subscript({}, ({})::bigint)", e, i)
5265            } else {
5266                format!("_pylon.str_subscript({}, ({})::bigint)", e, i)
5267            }
5268        }
5269
5270        IrExpr::Slice {
5271            expr,
5272            lower,
5273            upper,
5274            is_array,
5275        } => {
5276            let e = emit_expr(expr);
5277            if *is_array {
5278                let lo = lower
5279                    .as_deref()
5280                    .map(|x| format!("({}) + 1", emit_expr(x)))
5281                    .unwrap_or_else(|| "1".to_string());
5282                let hi = upper.as_deref().map(emit_expr).unwrap_or_default();
5283                if hi.is_empty() {
5284                    format!("({})[{}:]", e, lo)
5285                } else {
5286                    format!("({})[{}:{}]", e, lo, hi)
5287                }
5288            } else {
5289                // substr(expr, start, length) for text/bytea.
5290                let start = lower
5291                    .as_deref()
5292                    .map(|x| format!("({}) + 1", emit_expr(x)))
5293                    .unwrap_or_else(|| "1".to_string());
5294                match upper.as_deref() {
5295                    Some(hi_expr) => {
5296                        let lo_val = lower.as_deref().map(emit_expr).unwrap_or_else(|| "0".to_string());
5297                        // GREATEST(0, ...) so reversed bounds yield '' instead of a PG error.
5298                        format!(
5299                            "substr({}, {}, GREATEST(0, ({}) - ({})))",
5300                            e,
5301                            start,
5302                            emit_expr(hi_expr),
5303                            lo_val
5304                        )
5305                    }
5306                    None => format!("substr({}, {})", e, start),
5307                }
5308            }
5309        }
5310
5311        IrExpr::JsonbField { expr, field } => {
5312            format!("({}->{})", emit_expr(expr), sql_str(field))
5313        }
5314
5315        IrExpr::JsonbIndex { expr, index } => {
5316            format!("({}->{})", emit_expr(expr), index)
5317        }
5318
5319        IrExpr::FnParam { name, .. } => qi(name),
5320
5321        IrExpr::FnSubquery(fs) => {
5322            let scalar = match fs.shape.as_slice() {
5323                [IrShapePointer::Computed(c)] => emit_expr(&c.expr),
5324                _ => unreachable!("a function scalar subquery always projects exactly one computed pointer"),
5325            };
5326            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
5327            let mut sql = format!(
5328                "(SELECT {}{}\nFROM {}.{}({}) AS {}",
5329                if fs.distinct { "DISTINCT " } else { "" },
5330                scalar,
5331                pg_schema(&fs.fn_module),
5332                qi(&fs.fn_name),
5333                args_sql,
5334                qi(&fs.alias),
5335            );
5336            append_filter(&mut sql, &fs.filter);
5337            append_order_by(&mut sql, &fs.order_by);
5338            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5339            sql.push(')');
5340            sql
5341        }
5342
5343        IrExpr::PathSubquery(ps) => {
5344            let scalar = match &ps.result {
5345                IrPathResult::Scalar(e, _) => emit_expr(e),
5346                IrPathResult::Object { alias, .. } => format!("{}.\"id\"", qi(alias)),
5347            };
5348            let from_sql = emit_path_joins(&ps.root, &ps.joins);
5349            let mut sql = format!(
5350                "(SELECT {}{}\nFROM {}",
5351                if ps.distinct { "DISTINCT " } else { "" },
5352                scalar,
5353                from_sql
5354            );
5355            append_filter(&mut sql, &ps.filter);
5356            append_order_by(&mut sql, &ps.order_by);
5357            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5358            sql.push(')');
5359            sql
5360        }
5361
5362        IrExpr::ObjectPathUnion { branches, limit, multi } => {
5363            let arms: Vec<String> = branches
5364                .iter()
5365                .map(|ps| {
5366                    let IrPathResult::Object {
5367                        alias,
5368                        type_name,
5369                        shape,
5370                    } = &ps.result
5371                    else {
5372                        unreachable!("an object path union's branches always land on objects")
5373                    };
5374                    let (pointer_exprs, _) = build_shape(shape, alias);
5375                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
5376                    parts.extend(pointer_exprs);
5377                    let mut sql = format!(
5378                        "SELECT (\n    {}\n) AS \"r\"\nFROM {}",
5379                        parts.join(",\n    "),
5380                        emit_path_joins(&ps.root, &ps.joins),
5381                    );
5382                    append_filter(&mut sql, &ps.filter);
5383                    sql
5384                })
5385                .collect();
5386            let mut sql = format!(
5387                "{}(SELECT \"r\" FROM (\n{}\n) AS \"_u\"",
5388                if *multi { "ARRAY" } else { "" },
5389                arms.join("\nUNION ALL\n")
5390            );
5391            if let Some(limit) = limit {
5392                sql.push_str(&format!("\nLIMIT {}", emit_expr(limit)));
5393            }
5394            sql.push(')');
5395            sql
5396        }
5397
5398        IrExpr::ObjectPathSubquery(ps) => {
5399            let IrPathResult::Object {
5400                alias,
5401                type_name,
5402                shape,
5403            } = &ps.result
5404            else {
5405                unreachable!("an object path subquery always lands on an object")
5406            };
5407            let (pointer_exprs, _) = build_shape(shape, alias);
5408            let mut parts = vec![result_type_disc(ps, alias, type_name)];
5409            parts.extend(pointer_exprs);
5410            let mut sql = format!(
5411                "(SELECT (\n    {}\n)\nFROM {}",
5412                parts.join(",\n    "),
5413                emit_path_joins(&ps.root, &ps.joins),
5414            );
5415            append_filter(&mut sql, &ps.filter);
5416            append_order_by(&mut sql, &ps.order_by);
5417            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5418            sql.push(')');
5419            sql
5420        }
5421
5422        IrExpr::ObjectSubquery(sel) => {
5423            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5424                unreachable!("an object subquery is always schema-bound")
5425            };
5426            let alias = &source.alias;
5427            let (sub_exprs, _) = build_shape(shape, alias);
5428            let mut row_parts = vec![source_type_disc(source)];
5429            row_parts.extend(sub_exprs);
5430            let mut sql = format!(
5431                "(SELECT (\n        {}\n    )\n    FROM {} AS {}",
5432                row_parts.join(",\n        "),
5433                source_ref(source),
5434                qi(alias),
5435            );
5436            append_filter(&mut sql, &sel.filter);
5437            append_order_by(&mut sql, &sel.order_by);
5438            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5439            sql.push(')');
5440            sql
5441        }
5442
5443        IrExpr::Subquery(sel) => {
5444            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5445                unreachable!("scalar/exists subquery is always schema-bound")
5446            };
5447            let alias = &source.alias;
5448            let mut sql = if shape.is_empty() {
5449                // EXISTS inner: SELECT 1 FROM …
5450                format!("(SELECT 1\nFROM {} AS {}", source_ref(source), qi(alias))
5451            } else if let Some(c) = shape.iter().find_map(|f| {
5452                if let IrShapePointer::Computed(c) = f {
5453                    Some(c)
5454                } else {
5455                    None
5456                }
5457            }) {
5458                // Computed subquery (e.g. a type-intersection splat's
5459                // computed pointer): select the compiled expression itself,
5460                // not a bare column — the expression already references
5461                // `alias`'s own columns via ColumnRef.
5462                format!(
5463                    "(SELECT {}\nFROM {} AS {}",
5464                    emit_expr(&c.expr),
5465                    source_ref(source),
5466                    qi(alias)
5467                )
5468            } else {
5469                // Scalar subquery: SELECT alias.col FROM …
5470                let pk_col = shape
5471                    .iter()
5472                    .find_map(|f| {
5473                        if let IrShapePointer::Scalar(s) = f {
5474                            Some(s.column.as_str())
5475                        } else {
5476                            None
5477                        }
5478                    })
5479                    .unwrap_or("id");
5480                format!(
5481                    "(SELECT {}.{}\nFROM {} AS {}",
5482                    qi(alias),
5483                    qi(pk_col),
5484                    source_ref(source),
5485                    qi(alias),
5486                )
5487            };
5488            append_filter(&mut sql, &sel.filter);
5489            append_order_by(&mut sql, &sel.order_by);
5490            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5491            sql.push(')');
5492            sql
5493        }
5494        IrExpr::RawSql(s) => format!("({s})"),
5495    }
5496}
5497
5498// ── Vector search ────────────────────────────────────────────────────────────
5499
5500fn emit_vector_search(vs: &IrVectorSearch) -> SqlOutput {
5501    let alias = &vs.source.alias;
5502    let dist_sql = format!(
5503        "{}.{} {} {}",
5504        qi(alias),
5505        qi(&vs.vector_col),
5506        vs.distance_op,
5507        emit_expr(&vs.query_expr),
5508    );
5509
5510    // Build the object sub-tuple.  If object_shape is empty, include all properties
5511    // (type disc + id implicitly come from build_shape when no elements provided;
5512    //  with no shape elements the shape is empty, so we fall back to "just id").
5513    // Use build_shape when there are explicit shape pointers; otherwise emit a minimal tuple.
5514    let (obj_tuple, object_shape_nodes) = if vs.object_shape.is_empty() {
5515        // No explicit shape: produce (type_disc, id) as minimum.
5516        let type_expr = type_disc(&vs.source.type_name);
5517        let id_expr = format!("{}.\"id\"", qi(alias));
5518        let tuple = format!("{},\n    {}", type_expr, id_expr);
5519        let id_node = ShapeNode::Scalar {
5520            name: "id".to_string(),
5521            position: 1,
5522        };
5523        (tuple, vec![id_node])
5524    } else {
5525        let (pointer_exprs, shape_pointers) = build_shape(&vs.object_shape, alias);
5526        let mut parts = vec![type_disc(&vs.source.type_name)];
5527        parts.extend(pointer_exprs);
5528        (parts.join(",\n    "), prepend_type(shape_pointers))
5529    };
5530
5531    // Outer tuple: NULL (type slot), object sub-tuple at pos 1, distance at pos 2.
5532    let outer = format!(
5533        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5534        obj_tuple, dist_sql,
5535    );
5536    let mut sql = format!(
5537        "SELECT (\n    {}\n) AS result\nFROM {} AS {}",
5538        outer,
5539        source_ref(&vs.source),
5540        qi(alias),
5541    );
5542    append_filter(&mut sql, &vs.filter);
5543
5544    // ORDER BY distance if requested.
5545    if let Some(dir) = &vs.order_by_distance {
5546        let dir_sql = match dir {
5547            IrSortDir::Asc => "ASC",
5548            IrSortDir::Desc => "DESC",
5549        };
5550        sql.push_str(&format!("\nORDER BY {} {}", dist_sql, dir_sql));
5551    }
5552    append_offset_limit(&mut sql, &vs.offset, &vs.limit);
5553
5554    let object_node = ShapeNode::Object {
5555        name: "object".to_string(),
5556        type_name: Some(vs.source.type_name.clone()),
5557        position: 1,
5558        cardinality: Cardinality::Many,
5559        pointers: object_shape_nodes,
5560        has_implicit_id: shape_has_implicit_id(&vs.object_shape),
5561    };
5562    let shape = ShapeDescriptor {
5563        root: ShapeNode::VectorSearch {
5564            object_position: 1,
5565            distance_position: 2,
5566            object_node: Box::new(object_node),
5567        },
5568    };
5569    let inference_plan = vs.inference_model.as_ref().map(|model_name| InferencePlan::Embedding {
5570        model_name: model_name.clone(),
5571        type_name: vs.inference_type_name.clone().unwrap_or_default(),
5572        index_name: vs.inference_index_name.clone().unwrap_or(None),
5573        query_param_name: vs.inference_query_param_name.clone().unwrap_or_default(),
5574        query_literal: vs.inference_query_literal.clone(),
5575    });
5576    SqlOutput {
5577        sql,
5578        shape,
5579        inference_plan,
5580    }
5581}
5582
5583// ── FTS search ───────────────────────────────────────────────────────────────
5584
5585fn emit_fts_search(fs: &IrFtsSearch) -> SqlOutput {
5586    use crate::schema::SearchBackend;
5587    if fs.backend != SearchBackend::Postgres {
5588        return emit_fts_search_deferred(fs);
5589    }
5590
5591    let alias = &fs.source.alias;
5592    let search_col = format!("{}.{}", qi(alias), qi(&fs.search_col));
5593    let query_sql = emit_expr(&fs.query_expr);
5594    let tsquery = format!("{}('english', {})", fs.tsquery_fn, query_sql);
5595    let rank_sql = format!("ts_rank({}, {})", search_col, tsquery);
5596
5597    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5598        let type_expr = type_disc(&fs.source.type_name);
5599        let id_expr = format!("{}.\"id\"", qi(alias));
5600        let tuple = format!("{},\n    {}", type_expr, id_expr);
5601        let id_node = ShapeNode::Scalar {
5602            name: "id".to_string(),
5603            position: 1,
5604        };
5605        (tuple, vec![id_node])
5606    } else {
5607        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5608        let mut parts = vec![type_disc(&fs.source.type_name)];
5609        parts.extend(pointer_exprs);
5610        (parts.join(",\n    "), prepend_type(shape_pointers))
5611    };
5612
5613    let outer = format!(
5614        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5615        obj_tuple, rank_sql,
5616    );
5617    let mut sql = format!(
5618        "SELECT (\n    {}\n) AS result\nFROM {} AS {}\nWHERE {} @@ {}",
5619        outer,
5620        source_ref(&fs.source),
5621        qi(alias),
5622        search_col,
5623        tsquery,
5624    );
5625    if let Some(f) = &fs.filter {
5626        sql.push_str(&format!(" AND ({})", emit_expr(f)));
5627    }
5628    if let Some(dir) = &fs.order_by_rank {
5629        let dir_sql = match dir {
5630            IrSortDir::Asc => "ASC",
5631            IrSortDir::Desc => "DESC",
5632        };
5633        sql.push_str(&format!("\nORDER BY {} {}", rank_sql, dir_sql));
5634    }
5635    append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5636
5637    let object_node = ShapeNode::Object {
5638        name: "object".to_string(),
5639        type_name: Some(fs.source.type_name.clone()),
5640        position: 1,
5641        cardinality: Cardinality::Many,
5642        pointers: object_shape_nodes,
5643        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
5644    };
5645    let shape = ShapeDescriptor {
5646        root: ShapeNode::FtsSearch {
5647            object_position: 1,
5648            rank_position: 2,
5649            object_node: Box::new(object_node),
5650        },
5651    };
5652    SqlOutput {
5653        sql,
5654        shape,
5655        inference_plan: None,
5656    }
5657}
5658
5659fn emit_fts_search_deferred(fs: &IrFtsSearch) -> SqlOutput {
5660    let alias = &fs.source.alias;
5661    let ids_idx = fs
5662        .deferred_ids_param
5663        .expect("deferred_ids_param must be set for deferred backend");
5664    let scores_idx = fs
5665        .deferred_scores_param
5666        .expect("deferred_scores_param must be set for deferred backend");
5667    let ids_param = format!("${}", ids_idx + 1);
5668    let scores_param = format!("${}", scores_idx + 1);
5669
5670    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5671        let type_expr = type_disc(&fs.source.type_name);
5672        let id_expr = format!("{}.\"id\"", qi(alias));
5673        let tuple = format!("{},\n    {}", type_expr, id_expr);
5674        let id_node = ShapeNode::Scalar {
5675            name: "id".to_string(),
5676            position: 1,
5677        };
5678        (tuple, vec![id_node])
5679    } else {
5680        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5681        let mut parts = vec![type_disc(&fs.source.type_name)];
5682        parts.extend(pointer_exprs);
5683        (parts.join(",\n    "), prepend_type(shape_pointers))
5684    };
5685
5686    let outer = format!(
5687        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    \"_os\".\"score\"",
5688        obj_tuple,
5689    );
5690    let mut sql = format!(
5691        concat!(
5692            "SELECT (\n    {}\n) AS result\n",
5693            "FROM {} AS {}\n",
5694            "JOIN UNNEST({}::uuid[], {}::float8[]) AS \"_os\"(\"id\", \"score\")\n",
5695            "    ON \"_os\".\"id\" = {}.\"id\"",
5696        ),
5697        outer,
5698        source_ref(&fs.source),
5699        qi(alias),
5700        ids_param,
5701        scores_param,
5702        qi(alias),
5703    );
5704    if let Some(f) = &fs.filter {
5705        sql.push_str(&format!("\nWHERE ({})", emit_expr(f)));
5706    }
5707    if let Some(dir) = &fs.order_by_rank {
5708        let dir_sql = match dir {
5709            IrSortDir::Asc => "ASC",
5710            IrSortDir::Desc => "DESC",
5711        };
5712        sql.push_str(&format!("\nORDER BY \"_os\".\"score\" {}", dir_sql));
5713    }
5714    // limit/offset are passed to OpenSearch as size/from, not emitted in Postgres SQL.
5715    let size = fs.limit.as_ref().and_then(|lim| {
5716        if let IrExpr::Literal(IrLiteral::Int(n)) = lim {
5717            Some(*n as usize)
5718        } else {
5719            None
5720        }
5721    });
5722
5723    let object_node = ShapeNode::Object {
5724        name: "object".to_string(),
5725        type_name: Some(fs.source.type_name.clone()),
5726        position: 1,
5727        cardinality: Cardinality::Many,
5728        pointers: object_shape_nodes,
5729        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
5730    };
5731    let shape = ShapeDescriptor {
5732        root: ShapeNode::FtsSearch {
5733            object_position: 1,
5734            rank_position: 2,
5735            object_node: Box::new(object_node),
5736        },
5737    };
5738    let backend_str = match fs.backend {
5739        crate::schema::SearchBackend::Meilisearch => "meilisearch",
5740        _ => "opensearch",
5741    };
5742    let inference_plan = Some(InferencePlan::Search {
5743        backend: backend_str.to_string(),
5744        index_name: fs.deferred_index_name.clone().unwrap_or_default(),
5745        query_param_name: fs.deferred_query_param_name.clone().unwrap_or_default(),
5746        query_literal: fs.deferred_query_literal.clone(),
5747        size,
5748    });
5749    SqlOutput {
5750        sql,
5751        shape,
5752        inference_plan,
5753    }
5754}
5755
5756// ── Function select ──────────────────────────────────────────────────────────
5757
5758fn emit_function_select(sel: &IrFunctionSelect) -> SqlOutput {
5759    let alias = &sel.alias;
5760    let (pointer_exprs, shape_pointers) = build_shape(&sel.shape, alias);
5761
5762    let type_expr = if sel.polymorphic {
5763        format!("{}.\"__type__\"", qi(alias))
5764    } else {
5765        type_disc(&sel.type_name)
5766    };
5767    let mut parts = vec![type_expr];
5768    parts.extend(pointer_exprs);
5769    let tuple = parts.join(",\n    ");
5770    let distinct = if sel.distinct { "DISTINCT " } else { "" };
5771
5772    let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
5773    let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
5774
5775    let from_clause = if sel.polymorphic {
5776        // Polymorphic: we can't peek inside the function — assume it returns
5777        // a __type__ column since we control the DDL.  Use the fn call directly.
5778        format!("{} AS {}", fn_call, qi(alias))
5779    } else {
5780        format!("{} AS {}", fn_call, qi(alias))
5781    };
5782
5783    let mut sql = format!("SELECT {}(\n    {}\n) AS result\nFROM {}", distinct, tuple, from_clause,);
5784    append_filter(&mut sql, &sel.filter);
5785    append_order_by(&mut sql, &sel.order_by);
5786    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5787
5788    let root_pointers = prepend_type(shape_pointers);
5789    SqlOutput {
5790        sql,
5791        shape: ShapeDescriptor {
5792            root: ShapeNode::Object {
5793                name: String::new(),
5794                type_name: Some(sel.type_name.clone()),
5795                position: 0,
5796                cardinality: Cardinality::Many,
5797                pointers: root_pointers,
5798                has_implicit_id: shape_has_implicit_id(&sel.shape),
5799            },
5800        },
5801        inference_plan: None,
5802    }
5803}
5804
5805/// Emit the SQL body expression for a user-defined function DDL.
5806///
5807/// For a bare scalar free select emits just the expression — e.g. `"a" + "b"`.
5808/// For everything else (object functions, non-scalar free selects) emits a
5809/// full `SELECT … FROM …` via `emit_dml_as_cte_source`.
5810pub fn emit_fn_body(ir: &crate::ir::IrOutput) -> String {
5811    with_subtype_fanouts(&ir.subtype_fanouts, || emit_output_fn_body(ir))
5812}
5813
5814fn emit_output_fn_body(ir: &crate::ir::IrOutput) -> String {
5815    let body = match &ir.stmt {
5816        IrStmt::Select(sel) if matches!(sel.rows.as_slice(), [IrRowSource::Free(IrFreeExpr::Scalar(_))]) => {
5817            let IrRowSource::Free(IrFreeExpr::Scalar(e)) = &sel.rows[0] else {
5818                unreachable!()
5819            };
5820            format!("SELECT {}", emit_expr(e))
5821        }
5822        other => emit_dml_as_cte_source(other),
5823    };
5824    if ir.ctes.is_empty() {
5825        body
5826    } else {
5827        let cte_prefix = emit_cte_prefix(&ir.ctes);
5828        format!("{}{}", cte_prefix, body)
5829    }
5830}
5831
5832fn emit_literal(lit: &IrLiteral) -> String {
5833    match lit {
5834        IrLiteral::Str(s) => sql_str(s),
5835        IrLiteral::Int(i) => i.to_string(),
5836        IrLiteral::Float(f) => {
5837            // Explicit ::float8 cast — a bare untyped numeral like `1.0`
5838            // defaults to Postgres `numeric`, but an un-cast PyQL float
5839            // literal means `float64`; only an explicit `<decimal>`/`123n`
5840            // literal should ever produce a real decimal. Without this,
5841            // `numeric`'s different wire OID
5842            // also broke decoding inside ROW() composites (see
5843            // _pg_decode_numeric in pylon/client.py, still needed for
5844            // genuine decimal casts).
5845            let s = f.to_string();
5846            let s = if s.contains('.') || s.contains('e') {
5847                s
5848            } else {
5849                format!("{}.0", s)
5850            };
5851            format!("({}::float8)", s)
5852        }
5853        IrLiteral::Bool(b) => {
5854            if *b {
5855                "TRUE".into()
5856            } else {
5857                "FALSE".into()
5858            }
5859        }
5860    }
5861}
5862
5863// ── Tests ────────────────────────────────────────────────────────────────────
5864
5865#[cfg(test)]
5866mod tests {
5867    use super::*;
5868    use crate::ir;
5869    use crate::parse;
5870    use crate::schema::{
5871        FunctionDescriptor, FunctionParamDescriptor, GlobalDescriptor, LinkDescriptor, MultiLinkDescriptor,
5872        NamedTupleDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor,
5873    };
5874
5875    #[test]
5876    fn a_template_argument_keeps_the_parameters_it_carries() {
5877        // The first argument carries query parameter `$2`; filled one number
5878        // at a time, it would be rewritten into the second argument.
5879        let filled = fill_template("f($1, $2, $10)", &["($2)::int".to_string(), "b".to_string()]);
5880        assert_eq!(filled, "f(($2)::int, b, $10)");
5881    }
5882
5883    fn make_schema() -> SchemaDescriptor {
5884        SchemaDescriptor {
5885            types: vec![
5886                TypeDescriptor {
5887                    name: "Person".into(),
5888                    module: "default".into(),
5889                    table: "Person".into(),
5890                    abstract_: false,
5891                    materialized: false,
5892                    description: None,
5893                    parents: vec![],
5894                    interfaces: vec![],
5895                    bases: vec![],
5896                    properties: vec![
5897                        PropertyDescriptor {
5898                            name: "id".into(),
5899                            pg_type: "uuid".into(),
5900                            nullable: false,
5901                            default_sql: Some("uuidv7()".into()),
5902                            default_pyql: None,
5903                            description: None,
5904                            check_constraints: vec![],
5905                            is_exclusive: true,
5906                            is_pk: true,
5907                            is_readonly: true,
5908                            rewrites: vec![],
5909                            tuple_members: None,
5910                            column_type: None,
5911                        },
5912                        PropertyDescriptor {
5913                            name: "name".into(),
5914                            pg_type: "text".into(),
5915                            nullable: false,
5916                            default_sql: None,
5917                            default_pyql: None,
5918                            description: None,
5919                            check_constraints: vec![],
5920                            is_exclusive: false,
5921                            is_pk: false,
5922                            is_readonly: false,
5923                            rewrites: vec![],
5924                            tuple_members: None,
5925                            column_type: None,
5926                        },
5927                        PropertyDescriptor {
5928                            name: "age".into(),
5929                            pg_type: "int8".into(),
5930                            nullable: true,
5931                            default_sql: None,
5932                            default_pyql: None,
5933                            description: None,
5934                            check_constraints: vec![],
5935                            is_exclusive: false,
5936                            is_pk: false,
5937                            is_readonly: false,
5938                            rewrites: vec![],
5939                            tuple_members: None,
5940                            column_type: None,
5941                        },
5942                    ],
5943                    links: vec![LinkDescriptor {
5944                        name: "company".into(),
5945                        target: "default::Company".into(),
5946                        nullable: true,
5947                        through: None,
5948                        description: None,
5949                        default_pyql: None,
5950                        is_exclusive: false,
5951                        is_readonly: false,
5952                        rewrites: vec![],
5953                        on_delete: vec![],
5954                    }],
5955                    multilinks: vec![MultiLinkDescriptor {
5956                        name: "posts".into(),
5957                        target: "default::Post".into(),
5958                        through: None,
5959                        nullable: false,
5960                        description: None,
5961                        default_pyql: None,
5962                        on_delete: vec![],
5963                        is_exclusive: false,
5964                    }],
5965                    computed: vec![],
5966                    constraints: vec![],
5967                    indexes: vec![],
5968                    partition: None,
5969                    vector_indexes: vec![],
5970                    search_indexes: vec![],
5971                    triggers: vec![],
5972                    junction: false,
5973                    signals: vec![],
5974                },
5975                TypeDescriptor {
5976                    name: "Company".into(),
5977                    module: "default".into(),
5978                    table: "Company".into(),
5979                    abstract_: false,
5980                    materialized: false,
5981                    description: None,
5982                    parents: vec![],
5983                    interfaces: vec![],
5984                    bases: vec![],
5985                    properties: vec![PropertyDescriptor {
5986                        name: "name".into(),
5987                        pg_type: "text".into(),
5988                        nullable: false,
5989                        default_sql: None,
5990                        default_pyql: None,
5991                        description: None,
5992                        check_constraints: vec![],
5993                        is_exclusive: false,
5994                        is_pk: false,
5995                        is_readonly: false,
5996                        rewrites: vec![],
5997                        tuple_members: None,
5998                        column_type: None,
5999                    }],
6000                    links: vec![],
6001                    multilinks: vec![],
6002                    computed: vec![],
6003                    constraints: vec![],
6004                    indexes: vec![],
6005                    partition: None,
6006                    vector_indexes: vec![],
6007                    search_indexes: vec![],
6008                    triggers: vec![],
6009                    junction: false,
6010                    signals: vec![],
6011                },
6012                TypeDescriptor {
6013                    name: "Post".into(),
6014                    module: "default".into(),
6015                    table: "Post".into(),
6016                    abstract_: false,
6017                    materialized: false,
6018                    description: None,
6019                    parents: vec![],
6020                    interfaces: vec![],
6021                    bases: vec![],
6022                    properties: vec![PropertyDescriptor {
6023                        name: "title".into(),
6024                        pg_type: "text".into(),
6025                        nullable: false,
6026                        default_sql: None,
6027                        default_pyql: None,
6028                        description: None,
6029                        check_constraints: vec![],
6030                        is_exclusive: false,
6031                        is_pk: false,
6032                        is_readonly: false,
6033                        rewrites: vec![],
6034                        tuple_members: None,
6035                        column_type: None,
6036                    }],
6037                    links: vec![],
6038                    multilinks: vec![],
6039                    computed: vec![],
6040                    constraints: vec![],
6041                    indexes: vec![],
6042                    partition: None,
6043                    vector_indexes: vec![],
6044                    search_indexes: vec![],
6045                    triggers: vec![],
6046                    junction: false,
6047                    signals: vec![],
6048                },
6049            ],
6050            scalars: vec![],
6051            enums: vec![],
6052            named_tuples: vec![],
6053            globals: vec![],
6054            functions: vec![],
6055            aliases: vec![],
6056            channels: vec![],
6057            ..Default::default()
6058        }
6059    }
6060
6061    fn compile_and_emit(query: &str) -> SqlOutput {
6062        let schema = make_schema();
6063        compile_and_emit_with(query, &schema)
6064    }
6065
6066    fn compile_and_emit_with(query: &str, schema: &SchemaDescriptor) -> SqlOutput {
6067        let ast = parse::parse(query).expect("parse failed");
6068        let ir = ir::compile(&ast, schema).expect("IR compile failed");
6069        emit(&ir)
6070    }
6071
6072    #[test]
6073    fn a_nested_read_inside_a_with_opens_no_second_with() {
6074        let out = compile_and_emit_with(
6075            "WITH a := (SELECT Person FILTER .age > 1), b := (SELECT Person LIMIT 1) SELECT (SELECT (a UNION b))",
6076            &make_schema(),
6077        );
6078        assert_eq!(out.sql.matches("WITH").count(), 1, "{}", out.sql);
6079    }
6080
6081    #[test]
6082    fn a_comparison_against_a_multi_row_with_binding_tests_membership() {
6083        // Read as a scalar subquery, the binding aborts the query on its
6084        // second row: "more than one row returned by a subquery used as an
6085        // expression".
6086        let out = compile_and_emit_with(
6087            "WITH companies := (SELECT Company FILTER .name = 'x') SELECT Person FILTER .company = companies",
6088            &make_schema(),
6089        );
6090        assert!(
6091            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6092            "{}",
6093            out.sql
6094        );
6095
6096        let out = compile_and_emit_with(
6097            "WITH companies := (SELECT Company) SELECT Person FILTER any(.company = companies)",
6098            &make_schema(),
6099        );
6100        assert!(
6101            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6102            "{}",
6103            out.sql
6104        );
6105
6106        let out = compile_and_emit_with(
6107            "WITH companies := (SELECT Company) SELECT Person FILTER .company != companies",
6108            &make_schema(),
6109        );
6110        assert!(out.sql.contains("NOT (\"t1\".\"company_id\" = ANY("), "{}", out.sql);
6111    }
6112
6113    #[test]
6114    fn a_comparison_against_a_single_row_with_binding_stays_a_scalar_read() {
6115        let out = compile_and_emit_with(
6116            "WITH company := (SELECT Company LIMIT 1) SELECT Person FILTER .company = company",
6117            &make_schema(),
6118        );
6119        assert!(out.sql.contains("= (SELECT \"id\" FROM \"company\")"), "{}", out.sql);
6120    }
6121
6122    #[test]
6123    fn an_aggregate_over_a_backlink_counts_inside_a_subquery() {
6124        let out = compile_and_emit_with("SELECT Post { n := count(.<posts) }", &make_schema());
6125        assert!(
6126            !out.sql.contains("count((SELECT"),
6127            "an aggregate over a scalar subquery:\n{}",
6128            out.sql
6129        );
6130        assert!(out.sql.contains("count("), "{}", out.sql);
6131    }
6132
6133    #[test]
6134    fn an_aggregate_over_a_relative_subselect_counts_inside_a_subquery() {
6135        let out = compile_and_emit_with(
6136            "SELECT Post { n := count((SELECT .<posts FILTER .age > 1)) }",
6137            &make_schema(),
6138        );
6139        assert!(
6140            !out.sql.contains("count((SELECT"),
6141            "an aggregate over a scalar subquery:\n{}",
6142            out.sql
6143        );
6144        assert!(
6145            out.sql.contains(r#"(SELECT count("_s"."v") FROM unnest(ARRAY("#),
6146            "{}",
6147            out.sql
6148        );
6149    }
6150
6151    #[test]
6152    fn any_over_a_link_type_check_tests_each_element() {
6153        let out = compile_and_emit_with(
6154            "SELECT Company { name } FILTER any(.<company.posts IS Post)",
6155            &make_schema(),
6156        );
6157        assert!(
6158            !out.sql.contains("bool_or(ARRAY"),
6159            "an aggregate over outer rows:\n{}",
6160            out.sql
6161        );
6162        assert!(
6163            out.sql
6164                .contains("FROM unnest(ARRAY(SELECT ('default::Post' = 'default::Post')"),
6165            "{}",
6166            out.sql
6167        );
6168    }
6169
6170    #[test]
6171    fn a_link_type_check_reads_the_linked_object() {
6172        let out = compile_and_emit_with("SELECT Person { name } FILTER .company IS Company", &make_schema());
6173        assert!(!out.sql.contains("WHERE FALSE"), "{}", out.sql);
6174        assert!(out.sql.contains("'default::Company'"), "{}", out.sql);
6175    }
6176
6177    /// `make_schema` with an `Admin` extending `Person`, in a table of its own.
6178    fn schema_with_a_subtype() -> SchemaDescriptor {
6179        let mut schema = make_schema();
6180        let mut admin = schema.types[0].clone();
6181        admin.name = "Admin".into();
6182        admin.table = "Admin".into();
6183        admin.bases = vec!["default::Person".into()];
6184        schema.types.push(admin);
6185        schema
6186    }
6187
6188    #[test]
6189    fn a_type_with_subtypes_reads_their_rows_too() {
6190        let out = compile_and_emit_with("SELECT Person { name }", &schema_with_a_subtype());
6191        assert!(
6192            out.sql.contains("'default::Admin'::text AS \"__type__\"") && out.sql.contains("FROM \"public\".\"Admin\""),
6193            "expected the subtype's table in the read:\n{}",
6194            out.sql
6195        );
6196    }
6197
6198    #[test]
6199    fn a_path_from_a_type_with_subtypes_reads_their_rows_too() {
6200        let out = compile_and_emit_with("SELECT Person.name", &schema_with_a_subtype());
6201        assert!(out.sql.contains("FROM \"public\".\"Admin\""), "{}", out.sql);
6202    }
6203
6204    #[test]
6205    fn a_multilink_of_a_type_with_subtypes_reads_their_junctions_too() {
6206        let out = compile_and_emit_with("SELECT Person { posts: { title } }", &schema_with_a_subtype());
6207        assert!(
6208            out.sql.contains(
6209                "(SELECT \"source\", \"target\" FROM \"public\".\"Person.posts\" UNION ALL \
6210                 SELECT \"source\", \"target\" FROM \"public\".\"Admin.posts\")"
6211            ),
6212            "expected the junctions unioned:\n{}",
6213            out.sql
6214        );
6215    }
6216
6217    #[test]
6218    fn is_a_supertype_matches_its_subtypes() {
6219        let out = compile_and_emit_with(
6220            "SELECT Person { name } FILTER Person IS Admin",
6221            &schema_with_a_subtype(),
6222        );
6223        assert!(out.sql.contains("\"__type__\" = 'default::Admin'"), "{}", out.sql);
6224    }
6225
6226    #[test]
6227    fn a_write_to_a_type_with_subtypes_reaches_their_tables() {
6228        let out = compile_and_emit_with("UPDATE Person SET { age := 1 }", &schema_with_a_subtype());
6229        assert!(
6230            out.sql.contains("UPDATE \"public\".\"Person\"") && out.sql.contains("UPDATE \"public\".\"Admin\""),
6231            "expected both tables updated:\n{}",
6232            out.sql
6233        );
6234        assert!(
6235            !out.sql.contains("UPDATE (\n"),
6236            "a write never targets the union:\n{}",
6237            out.sql
6238        );
6239    }
6240
6241    #[test]
6242    fn test_comparing_a_multilink_itself_to_an_object() {
6243        // `filter any(.posts = post)` — the multi-link with no tail step. It
6244        // used to reach `compile_path`, which only knows properties, links
6245        // and computeds, and reported "has no link or property 'posts'".
6246        let out = compile_and_emit_with(
6247            "WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER any(.posts = p)",
6248            &make_schema(),
6249        );
6250        assert!(
6251            out.sql.contains("EXISTS((SELECT 1\nFROM \"public\".\"Person.posts\""),
6252            "expected a junction EXISTS:\n{}",
6253            out.sql
6254        );
6255        assert!(
6256            out.sql.contains("\"target\" = (SELECT \"id\" FROM \"p\")"),
6257            "expected the junction target compared to the bound object's id:\n{}",
6258            out.sql
6259        );
6260    }
6261
6262    #[test]
6263    fn test_comparing_a_multilink_itself_warns_without_any() {
6264        let ast = parse::parse("WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER .posts = p").unwrap();
6265        let ir = ir::compile(&ast, &make_schema()).unwrap();
6266        assert!(
6267            ir.warnings.iter().any(|w| w.contains("multi-link '.posts'")),
6268            "expected the set-valued FILTER warning, got: {:?}",
6269            ir.warnings
6270        );
6271    }
6272
6273    #[test]
6274    fn test_detached_names_the_type_of_a_nested_select() {
6275        // `detached T` as an insert's link value: the prefix used to hide the
6276        // type name from the nested select's subject resolution, which then
6277        // reported "expected a type name as SELECT subject".
6278        let out = compile_and_emit_with(
6279            "INSERT Person { name := $n, company := (SELECT detached Company FILTER .name = $c LIMIT 1) }",
6280            &make_schema(),
6281        );
6282        assert!(
6283            out.sql.contains("INSERT INTO \"public\".\"Person\""),
6284            "expected the insert to compile:\n{}",
6285            out.sql
6286        );
6287        assert!(
6288            out.sql.contains("FROM \"public\".\"Company\""),
6289            "expected the detached select to read the Company table:\n{}",
6290            out.sql
6291        );
6292    }
6293
6294    #[test]
6295    fn test_select_over_an_update_on_a_with_binding() {
6296        // A bare `with` binding as an update's subject names rows, not a
6297        // type. The top-level form already knew that; wrapping it in a select
6298        // asked what type the statement yields and got the binding's name,
6299        // reported as "unknown type 'p'".
6300        let out = compile_and_emit_with(
6301            "WITH p := (SELECT Person FILTER .id = $a LIMIT 1) SELECT (UPDATE p SET { name := $n }) { id }",
6302            &make_schema(),
6303        );
6304        assert!(
6305            out.sql.contains("UPDATE \"public\".\"Person\""),
6306            "expected the update to resolve to the bound type's table:\n{}",
6307            out.sql
6308        );
6309    }
6310
6311    #[test]
6312    fn test_a_mutation_as_a_free_object_field() {
6313        // Already allowed as an item of a free *set*; a named field of a free
6314        // object is the same question and used to answer "an update cannot
6315        // stand in for a value".
6316        let out = compile_and_emit_with(
6317            "SELECT { renamed := (UPDATE Person FILTER .id = $a SET { name := $n }), n := 1 }",
6318            &make_schema(),
6319        );
6320        assert!(
6321            out.sql.contains("UPDATE \"public\".\"Person\""),
6322            "expected the mutation to become a data-modifying CTE:\n{}",
6323            out.sql
6324        );
6325        assert!(
6326            out.sql
6327                .contains("(SELECT \"t1\".\"id\"\nFROM \"_nested_dml_0\" AS \"t1\") AS \"_f0\""),
6328            "expected the field to carry the rows the mutation touched:\n{}",
6329            out.sql
6330        );
6331    }
6332
6333    #[test]
6334    fn test_a_sub_selects_filter_stays_on_its_own_subject_when_a_field_chain_projects_off_it() {
6335        // `(select Person filter .id = $a).company.name` filters People, not
6336        // Companies. The merge that splices the field chain onto the inner
6337        // select used to leave the filter to bind against the last step.
6338        let out = compile_and_emit_with("SELECT (SELECT Person FILTER .name = $n).company.name", &make_schema());
6339        assert!(
6340            out.sql.contains("WHERE (\"t0\".\"name\" = $1)"),
6341            "the inner filter belongs to the Person alias:\n{}",
6342            out.sql
6343        );
6344    }
6345
6346    #[test]
6347    fn test_reading_back_a_nested_insert_through_its_own_statement() {
6348        let out = compile_and_emit_with(
6349            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) { id, posts: { title } }",
6350            &make_schema(),
6351        );
6352        assert!(out.sql.contains("__ml_add_0"), "got:\n{}", out.sql);
6353    }
6354
6355    #[test]
6356    fn test_reading_back_a_nested_insert_within_its_own_statement() {
6357        // Postgres does not show one statement's CTE writes to the rest of
6358        // that statement, so reading the link back off the base tables found
6359        // nothing and it hydrated as empty/None (confirmed live).
6360        let out = compile_and_emit_with(
6361            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) \
6362             { id, posts: { title } }",
6363            &make_schema(),
6364        );
6365        assert!(
6366            out.sql.contains("FROM \"_dml__ml_add_0\" AS \"jt\""),
6367            "the junction rows must come from the CTE that wrote them:\n{}",
6368            out.sql
6369        );
6370        assert!(
6371            out.sql.contains("\"_nested_dml_0\""),
6372            "and the targets from the nested insert's own CTE:\n{}",
6373            out.sql
6374        );
6375    }
6376
6377    #[test]
6378    fn test_a_nested_single_link_insert_reads_back_from_its_cte() {
6379        let out = compile_and_emit_with(
6380            "SELECT (INSERT Person { name := $n, company := (INSERT Company { name := $c }) }) \
6381             { id, company: { name } }",
6382            &make_schema(),
6383        );
6384        assert!(
6385            out.sql.contains("FROM \"_nested_dml_0\""),
6386            "the linked row must be read from the CTE that inserted it:\n{}",
6387            out.sql
6388        );
6389    }
6390
6391    #[test]
6392    fn test_a_free_object_field_holding_an_object_keeps_it_an_object() {
6393        // Gel compiles a free shape into a real object type whose fields are
6394        // real pointers, so an object field stays an object. Pylon used to
6395        // build the free object as jsonb, which flattened it to the bare id.
6396        let out = compile_and_emit_with(
6397            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }",
6398            &make_schema(),
6399        );
6400        assert!(
6401            out.sql.contains("'default::Company'::text"),
6402            "the field must carry the object's own row:\n{}",
6403            out.sql
6404        );
6405    }
6406
6407    #[test]
6408    fn test_projecting_an_object_off_a_free_object() {
6409        // `.co` is a path step through a pointer, not jsonb extraction, so
6410        // what comes back is the object with its shape.
6411        let out = compile_and_emit_with(
6412            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }.co",
6413            &make_schema(),
6414        );
6415        assert!(
6416            !out.sql.contains("jsonb_build_object"),
6417            "projecting a pointer must not go through jsonb:\n{}",
6418            out.sql
6419        );
6420        assert!(
6421            out.sql.contains("SELECT v AS result, v FROM"),
6422            "the object row is already the result row:\n{}",
6423            out.sql
6424        );
6425    }
6426
6427    #[test]
6428    fn test_a_shape_on_a_single_valued_object_path() {
6429        // `c := .company { name }` — an object route existed only for walks
6430        // that crossed a multi step, so a single-valued one fell through to
6431        // expression position and reported "shapes and set literals are not
6432        // valid in expression context".
6433        let out = compile_and_emit_with("SELECT Person { c := .company { name } }", &make_schema());
6434        assert!(
6435            out.sql.contains("'default::Company'::text"),
6436            "the pointer must carry the object's own row:\n{}",
6437            out.sql
6438        );
6439        assert!(
6440            !out.sql.contains("ARRAY(SELECT"),
6441            "a single-valued walk is one object, not an array of one:\n{}",
6442            out.sql
6443        );
6444    }
6445
6446    #[test]
6447    fn test_a_shapeless_single_link_still_reads_as_its_value() {
6448        // The object route is taken only when a shape says an object was
6449        // meant; without one this stays what it always was.
6450        let out = compile_and_emit_with("SELECT Person { c := .company }", &make_schema());
6451        assert!(
6452            !out.sql.contains("'default::Company'::text"),
6453            "a bare link reference must not grow an object row:\n{}",
6454            out.sql
6455        );
6456    }
6457
6458    #[test]
6459    fn test_a_tuple_element_holding_a_shaped_object() {
6460        // `select (c { name }, 1)` — an element is the same question a free
6461        // object's field asks, and used to report "shapes and set literals
6462        // are not valid in expression context".
6463        let out = compile_and_emit_with(
6464            "WITH c := (SELECT Company LIMIT 1) SELECT (c { name }, 1)",
6465            &make_schema(),
6466        );
6467        assert!(
6468            out.sql.contains("'default::Company'::text"),
6469            "the element must carry the object's own row:\n{}",
6470            out.sql
6471        );
6472    }
6473
6474    #[test]
6475    fn test_an_object_returning_function_as_a_link_value() {
6476        // `created_by := account_of_transaction()` — the function names the
6477        // row to link to, so its key is what gets stored. The schema itself
6478        // writes this as a link default; written out in a query it used to be
6479        // refused as "part of a larger expression".
6480        let mut schema = make_schema();
6481        // make_schema's Company carries only a name; a link's value is its key.
6482        let company = schema
6483            .types
6484            .iter_mut()
6485            .find(|t| t.name == "Company")
6486            .expect("make_schema declares Company");
6487        company.properties.push(PropertyDescriptor {
6488            name: "id".into(),
6489            pg_type: "uuid".into(),
6490            nullable: false,
6491            default_sql: None,
6492            default_pyql: None,
6493            description: None,
6494            check_constraints: vec![],
6495            is_exclusive: true,
6496            is_pk: true,
6497            is_readonly: true,
6498            rewrites: vec![],
6499            tuple_members: None,
6500            column_type: None,
6501        });
6502        schema.functions.push(crate::schema::FunctionDescriptor {
6503            name: "current_company".into(),
6504            module: "default".into(),
6505            params: vec![],
6506            return_pg_type: "default::Company".into(),
6507            return_is_object: true,
6508            return_is_set: false,
6509            return_is_polymorphic: false,
6510            volatility: "stable".into(),
6511            body: "select Company limit 1".into(),
6512        });
6513        let out = compile_and_emit_with("INSERT Person { name := $n, company := current_company() }", &schema);
6514        assert!(
6515            out.sql.contains("FROM \"public\".\"current_company\"()"),
6516            "the function must supply the foreign key:\n{}",
6517            out.sql
6518        );
6519    }
6520
6521    /// `make_schema` plus a `Company`-returning function and a `Person.company`
6522    /// link defaulting to it — jaldis's `Creatable.created_by :=
6523    /// account_of_transaction()` in miniature.
6524    fn make_schema_with_an_object_returning_default() -> SchemaDescriptor {
6525        let mut schema = make_schema();
6526        let company = schema
6527            .types
6528            .iter_mut()
6529            .find(|t| t.name == "Company")
6530            .expect("make_schema declares Company");
6531        company.properties.push(PropertyDescriptor {
6532            name: "id".into(),
6533            pg_type: "uuid".into(),
6534            nullable: false,
6535            default_sql: None,
6536            default_pyql: None,
6537            description: None,
6538            check_constraints: vec![],
6539            is_exclusive: true,
6540            is_pk: true,
6541            is_readonly: true,
6542            rewrites: vec![],
6543            tuple_members: None,
6544            column_type: None,
6545        });
6546        schema.functions.push(crate::schema::FunctionDescriptor {
6547            name: "current_company".into(),
6548            module: "default".into(),
6549            params: vec![],
6550            return_pg_type: "default::Company".into(),
6551            return_is_object: true,
6552            return_is_set: false,
6553            return_is_polymorphic: false,
6554            volatility: "stable".into(),
6555            body: "select Company limit 1".into(),
6556        });
6557        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6558        let company_link = person.links.iter_mut().find(|l| l.name == "company").unwrap();
6559        company_link.default_pyql = Some("current_company()".into());
6560        schema
6561    }
6562
6563    #[test]
6564    fn an_object_returning_link_default_is_applied_by_the_insert() {
6565        // A column DEFAULT cannot hold this one — PostgreSQL evaluates it with
6566        // no query in scope — so the insert has to carry it, the way Gel's
6567        // `_gen_pointers_from_defaults` expands every default into the shape.
6568        let schema = make_schema_with_an_object_returning_default();
6569        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
6570        assert!(
6571            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
6572            "the default must supply the foreign key:\n{}",
6573            out.sql
6574        );
6575    }
6576
6577    #[test]
6578    fn an_explicit_value_wins_over_an_inlined_default() {
6579        let schema = make_schema_with_an_object_returning_default();
6580        let out = compile_and_emit_with("INSERT Person { name := $n, company := {} }", &schema);
6581        assert!(
6582            !out.sql.contains("current_company"),
6583            "the shape named the pointer, so the default does not apply:\n{}",
6584            out.sql
6585        );
6586    }
6587
6588    #[test]
6589    fn an_object_returning_link_default_gets_no_column_default() {
6590        // Emitting one produces DDL PostgreSQL refuses to run; it used to be
6591        // dropped silently here, leaving the pointer with no default at all.
6592        let schema = make_schema_with_an_object_returning_default();
6593        let ddl = crate::export::export_schema(&schema).unwrap();
6594        assert!(
6595            !ddl.contains("\"company_id\" uuid DEFAULT"),
6596            "the column must carry no default:\n{ddl}"
6597        );
6598        crate::validate::validate_schema_types(&schema).expect("an inlined default is not an error");
6599    }
6600
6601    #[test]
6602    fn a_column_expressible_default_stays_in_the_ddl() {
6603        // Only what a column DEFAULT cannot hold moves into the insert.
6604        let mut schema = make_schema();
6605        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6606        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
6607        age.default_pyql = Some("21".into());
6608        assert!(
6609            crate::ir::inlined_pointer_defaults(schema.types.iter().find(|t| t.name == "Person").unwrap(), &schema)
6610                .is_empty()
6611        );
6612        let ddl = crate::export::export_schema(&schema).unwrap();
6613        assert!(ddl.contains("DEFAULT 21"), "{ddl}");
6614        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
6615        assert!(
6616            !out.sql.contains("21"),
6617            "the column DEFAULT still applies it:\n{}",
6618            out.sql
6619        );
6620    }
6621
6622    /// `make_schema` plus an `Employee` extending `Person`, where `Person`
6623    /// declares a computed naming itself — jaldis's
6624    /// `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)` in miniature.
6625    fn make_schema_with_an_inherited_self_naming_computed() -> SchemaDescriptor {
6626        let mut schema = make_schema();
6627        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6628        person.computed.push(crate::schema::ComputedDescriptor {
6629            name: "own_name".into(),
6630            expression: "default::Person.name".into(),
6631            return_type: Some("text".into()),
6632            link_target: None,
6633            link_multi: false,
6634        });
6635        let mut employee = person.clone();
6636        employee.name = "Employee".into();
6637        employee.table = "Employee".into();
6638        employee.bases = vec!["default::Person".into()];
6639        schema.types.push(employee);
6640        schema
6641    }
6642
6643    #[test]
6644    fn an_inherited_computed_naming_its_declaring_type_still_means_the_row() {
6645        // Gel compiles a computed once, against the type that declares it, so
6646        // the name reaches the subject on every subtype that inherits it.
6647        // Pylon compiles it again per subtype, where the name matched nothing
6648        // and the reference read the whole table instead.
6649        let schema = make_schema_with_an_inherited_self_naming_computed();
6650        let out = compile_and_emit_with("SELECT Employee { own_name }", &schema);
6651        assert!(
6652            !out.sql.contains("\"Person\""),
6653            "the computed must read the Employee row, not Person's table:\n{}",
6654            out.sql
6655        );
6656        crate::validate::validate_schema_types(&schema).expect("an inherited computed is single-valued");
6657    }
6658
6659    #[test]
6660    fn an_inherited_type_check_computed_tests_the_row_it_is_read_on() {
6661        // jaldis's own: `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)`,
6662        // read on `BrandAddonBundle`. `is` had a same-scope check of its own
6663        // that only knew the two spellings of the type being compiled, so on
6664        // the subtype it read `BrandAddon` as a separate set — many values
6665        // behind a declared `bool`.
6666        let mut schema = make_schema();
6667        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6668        person.computed.push(crate::schema::ComputedDescriptor {
6669            name: "employed".into(),
6670            expression: "(default::Person is default::Employee)".into(),
6671            return_type: Some("boolean".into()),
6672            link_target: None,
6673            link_multi: false,
6674        });
6675        let mut employee = person.clone();
6676        employee.name = "Employee".into();
6677        employee.table = "Employee".into();
6678        employee.bases = vec!["default::Person".into()];
6679        schema.types.push(employee);
6680
6681        crate::validate::validate_schema_types(&schema).expect("the check is single-valued on both types");
6682        let out = compile_and_emit_with("SELECT Employee { employed }", &schema);
6683        assert!(
6684            !out.sql.contains("\"Person\""),
6685            "the check must read the Employee row's own type:\n{}",
6686            out.sql
6687        );
6688    }
6689
6690    #[test]
6691    fn a_shape_writing_the_computed_out_itself_does_not_get_the_anchor() {
6692        // The rule Gel actually has is narrow: only a *declared* computed is
6693        // compiled against its declaring type. Naming a supertype anywhere
6694        // else is that supertype's own set — confirmed against Gel, where
6695        // `select BrandAddonBundle { n := count(BrandAddon) }` is the full
6696        // count while the same shape naming `BrandAddonBundle` is 1. So the
6697        // same expression written out in a shape keeps resolving on its own,
6698        // even where it shadows the inherited computed's name.
6699        let schema = make_schema_with_an_inherited_self_naming_computed();
6700        let ast = parse::parse("SELECT Employee { own_name := default::Person.name }").expect("parse failed");
6701        let Err(error) = ir::compile(&ast, &schema) else {
6702            panic!("an absolute path has nothing to anchor to here")
6703        };
6704        assert!(
6705            format!("{error:?}").contains("absolute paths are not valid"),
6706            "{error:?}"
6707        );
6708    }
6709
6710    #[test]
6711    fn a_default_that_compiles_nowhere_is_still_an_error() {
6712        // jaldis carried `sequence_next(INTROSPECT marketplace::OrderNo)`, Gel
6713        // syntax Pylon cannot parse. Inlining must not turn that into silence.
6714        let mut schema = make_schema();
6715        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
6716        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
6717        age.default_pyql = Some("sequence_next(INTROSPECT default::AgeSeq)".into());
6718        let errs = crate::validate::validate_schema_types(&schema).unwrap_err();
6719        let (_, msg, _) = errs[0].class_name_message_position();
6720        assert!(msg.contains("default"), "{msg}");
6721    }
6722
6723    #[test]
6724    fn test_comparing_a_link_with_an_object_returning_function() {
6725        // Comparing objects compares identity, so the call stands for the id
6726        // of the row it returns. Left whole it is a function call with nowhere
6727        // to go but the subject of a select.
6728        let mut schema = make_schema();
6729        // make_schema's Company carries only a name; identity needs its key.
6730        let company = schema
6731            .types
6732            .iter_mut()
6733            .find(|t| t.name == "Company")
6734            .expect("make_schema declares Company");
6735        company.properties.push(PropertyDescriptor {
6736            name: "id".into(),
6737            pg_type: "uuid".into(),
6738            nullable: false,
6739            default_sql: None,
6740            default_pyql: None,
6741            description: None,
6742            check_constraints: vec![],
6743            is_exclusive: true,
6744            is_pk: true,
6745            is_readonly: true,
6746            rewrites: vec![],
6747            tuple_members: None,
6748            column_type: None,
6749        });
6750        schema.functions.push(crate::schema::FunctionDescriptor {
6751            name: "current_company".into(),
6752            module: "default".into(),
6753            params: vec![],
6754            return_pg_type: "default::Company".into(),
6755            return_is_object: true,
6756            return_is_set: false,
6757            return_is_polymorphic: false,
6758            volatility: "stable".into(),
6759            body: "select Company limit 1".into(),
6760        });
6761        let out = compile_and_emit_with("SELECT Person FILTER .company = current_company()", &schema);
6762        assert!(
6763            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
6764            "the link's key against the function's row:\n{}",
6765            out.sql
6766        );
6767    }
6768
6769    #[test]
6770    fn test_choosing_between_two_object_sets() {
6771        // `A if cond else B` over objects is not the scalar CASE an if/else
6772        // over values gives: it stands for A's rows when the condition holds
6773        // and B's when it does not.
6774        let out = compile_and_emit_with(
6775            "WITH a := (SELECT Person FILTER .name = $n), b := (SELECT Person) \
6776             SELECT (a IF EXISTS a ELSE b) { name }",
6777            &make_schema(),
6778        );
6779        assert!(
6780            out.sql.contains("UNION ALL"),
6781            "expected one branch per side:\n{}",
6782            out.sql
6783        );
6784    }
6785
6786    #[test]
6787    fn test_a_union_operand_written_inline() {
6788        // A union reads one relation per branch, so an operand that names no
6789        // relation yet -- a sub-select, or an insert in a get-or-create -- is
6790        // hoisted into a CTE of its own first.
6791        let out = compile_and_emit_with(
6792            "SELECT ((SELECT Person FILTER .name = $n) UNION (SELECT Person)) { name }",
6793            &make_schema(),
6794        );
6795        assert!(
6796            out.sql.starts_with("WITH"),
6797            "expected the operands hoisted:\n{}",
6798            out.sql
6799        );
6800        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
6801    }
6802
6803    #[test]
6804    fn test_a_for_loop_variable_over_objects() {
6805        // The variable holds the row's key, so a path off it reads that row
6806        // back. It used to be treated as a scalar throughout, and any path
6807        // rooted at one reported "unknown type".
6808        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p.name)", &make_schema());
6809        assert!(
6810            out.sql.contains("FROM \"public\".\"Person\"") && out.sql.contains("\"_for_p\".\"v\""),
6811            "the body must read the row the variable names:\n{}",
6812            out.sql
6813        );
6814    }
6815
6816    #[test]
6817    fn test_a_shape_on_a_for_loop_variable() {
6818        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p { name })", &make_schema());
6819        assert!(
6820            out.sql.contains("'default::Person'::text"),
6821            "a shape on the variable yields its object:\n{}",
6822            out.sql
6823        );
6824    }
6825
6826    #[test]
6827    fn test_a_for_loop_variable_over_values_is_still_a_scalar() {
6828        let out = compile_and_emit_with("FOR n IN {1, 2} UNION (SELECT n)", &make_schema());
6829        assert!(out.sql.contains("VALUES"), "{}", out.sql);
6830    }
6831
6832    #[test]
6833    fn test_exists_over_a_sub_select_on_a_relative_path() {
6834        // `exists ((select .posts filter …))` — the sub-select is relative to
6835        // the enclosing object, so it needs the same rooting and correlation
6836        // `count((select .posts))` gets. Compiled without them it resolved in
6837        // free context and called the pointer unknown.
6838        let out = compile_and_emit_with(
6839            "SELECT Person { id } FILTER EXISTS ((SELECT .posts FILTER .title = $t))",
6840            &make_schema(),
6841        );
6842        assert!(out.sql.contains("EXISTS("), "{}", out.sql);
6843        assert!(
6844            out.sql.contains("\"Person.posts\""),
6845            "the junction join must survive into the EXISTS:\n{}",
6846            out.sql
6847        );
6848        assert!(
6849            out.sql.contains("\"t0\".\"id\""),
6850            "and it must correlate to the enclosing row:\n{}",
6851            out.sql
6852        );
6853    }
6854
6855    #[test]
6856    fn test_counting_the_rows_a_mutation_touched() {
6857        // `count((delete X filter …))` — the mutation runs as its own
6858        // data-modifying CTE and the aggregate reads it back, rather than
6859        // being refused as "a delete cannot stand in for a value".
6860        let out = compile_and_emit_with("SELECT count((DELETE Person FILTER .name = $n))", &make_schema());
6861        assert!(out.sql.starts_with("WITH"), "{}", out.sql);
6862        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""), "{}", out.sql);
6863        assert!(out.sql.contains("count(*)"), "{}", out.sql);
6864    }
6865
6866    #[test]
6867    fn test_a_conflict_target_naming_several_columns() {
6868        // `unless conflict on (.a, .b)` names columns for Postgres to infer
6869        // the arbiter index from; emitted as one composite value it would
6870        // look for an expression index on the tuple instead.
6871        let out = compile_and_emit_with(
6872            "INSERT Person { name := $n } UNLESS CONFLICT ON (.name, .id) ELSE (UPDATE Person SET { name := $n })",
6873            &make_schema(),
6874        );
6875        assert!(
6876            out.sql.contains("ON CONFLICT (\"name\", \"id\")"),
6877            "expected a column list:\n{}",
6878            out.sql
6879        );
6880    }
6881
6882    #[test]
6883    fn test_a_for_loop_over_a_path() {
6884        // `for post in p.posts union (…)` — a walk off a binding names a set
6885        // to iterate just as a sub-select does, only written without the
6886        // parentheses.
6887        let out = compile_and_emit_with(
6888            "WITH p := (SELECT Person LIMIT 1) FOR post IN p.posts UNION (SELECT post.title)",
6889            &make_schema(),
6890        );
6891        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
6892    }
6893
6894    #[test]
6895    fn test_a_relative_sub_select_keeps_its_enclosing_object() {
6896        // `assert_exists((select .posts { title }))` and `posts := (select
6897        // .posts filter …)` in an update both compiled their sub-select with
6898        // no enclosing object, so the relative path resolved in free context
6899        // and the pointer came back unknown.
6900        let out = compile_and_emit_with(
6901            "SELECT Person { ps := assert_exists((SELECT .posts { title })) }",
6902            &make_schema(),
6903        );
6904        assert!(out.sql.contains("assert_exists"), "{}", out.sql);
6905        assert!(
6906            out.sql.contains("\"Person.posts\""),
6907            "the walk must reach the junction:\n{}",
6908            out.sql
6909        );
6910    }
6911
6912    #[test]
6913    fn test_a_relative_sub_select_as_a_multi_link_value() {
6914        let out = compile_and_emit_with(
6915            "UPDATE Person FILTER .name = $n SET { posts := (SELECT .posts FILTER .title = $t) }",
6916            &make_schema(),
6917        );
6918        assert!(
6919            out.sql.contains("\"Person.posts\""),
6920            "the value must read the row's own posts:\n{}",
6921            out.sql
6922        );
6923    }
6924
6925    #[test]
6926    fn test_a_shape_written_after_a_sub_select() {
6927        // `(select Post filter …) { title }` means the same as putting the
6928        // shape inside the parentheses, so the shape is pushed onto the inner
6929        // statement's own result rather than refused.
6930        let out = compile_and_emit_with(
6931            "SELECT Person { ps := (SELECT Post FILTER .title = $t) { title } }",
6932            &make_schema(),
6933        );
6934        assert!(
6935            out.sql.contains("'default::Post'::text"),
6936            "the pointer must carry the object rows:\n{}",
6937            out.sql
6938        );
6939    }
6940
6941    #[test]
6942    fn test_a_shape_after_a_sub_select_with_a_with_block() {
6943        let out = compile_and_emit_with(
6944            "SELECT Person { ps := (WITH t := $t SELECT Post FILTER .title = t) { title } }",
6945            &make_schema(),
6946        );
6947        assert!(out.sql.contains("'default::Post'::text"), "{}", out.sql);
6948    }
6949
6950    #[test]
6951    fn test_a_computed_declared_as_an_object_returning_call() {
6952        // `recent := recent()` — the rows the function yields are the
6953        // pointer's objects. Read as a plain expression the call was "part of
6954        // a larger expression", which such a function refuses, so a splat over
6955        // a type carrying one could not compile at all.
6956        let mut schema = make_schema();
6957        schema.functions.push(crate::schema::FunctionDescriptor {
6958            name: "recent".into(),
6959            module: "default".into(),
6960            params: vec![],
6961            return_pg_type: "default::Post".into(),
6962            return_is_object: true,
6963            return_is_set: true,
6964            return_is_polymorphic: false,
6965            volatility: "stable".into(),
6966            body: "select Post".into(),
6967        });
6968        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
6969            name: "recent".into(),
6970            expression: "default::recent()".into(),
6971            return_type: None,
6972            link_target: None,
6973            link_multi: false,
6974        });
6975        let out = compile_and_emit_with("SELECT Person { recent }", &schema);
6976        assert!(
6977            out.sql.contains("ARRAY(SELECT") && out.sql.contains("'default::Post'::text"),
6978            "the pointer must carry the function's rows:\n{}",
6979            out.sql
6980        );
6981    }
6982
6983    #[test]
6984    fn test_one_branch_of_an_object_if_else_is_empty() {
6985        // `{}` contributes no rows, so Gel's own rewrite (`SELECT A WHERE
6986        // Cond UNION ALL SELECT B WHERE NOT Cond`) reduces to the other
6987        // branch under its guard.
6988        let out = compile_and_emit_with(
6989            "WITH p := (SELECT Person LIMIT 1) SELECT p { name } IF EXISTS p ELSE {}",
6990            &make_schema(),
6991        );
6992        assert!(!out.sql.contains("UNION ALL"), "nothing to union with:\n{}", out.sql);
6993        assert!(out.sql.contains("'default::Person'::text"), "{}", out.sql);
6994    }
6995
6996    #[test]
6997    fn test_a_conditional_insert_is_guarded_by_its_condition() {
6998        // `(insert …) if cond else {}` — guarding a reader would not do: the
6999        // insert is a data-modifying CTE that Postgres runs regardless, so
7000        // the condition goes into the insert itself and it writes nothing
7001        // when false. Verified live: running it twice leaves one row.
7002        let out = compile_and_emit_with("SELECT (INSERT Person { name := $n }) IF FALSE ELSE {}", &make_schema());
7003        assert!(
7004            out.sql.contains("SELECT") && out.sql.contains("WHERE"),
7005            "a guarded insert selects its row rather than listing values:\n{}",
7006            out.sql
7007        );
7008        assert!(!out.sql.contains("VALUES"), "VALUES always yields a row:\n{}", out.sql);
7009    }
7010
7011    #[test]
7012    fn test_a_conditional_update_carries_its_condition() {
7013        // Guarding the branch only filters what is read back -- the mutation
7014        // is its own CTE and Postgres runs it regardless. An update has a
7015        // WHERE of its own to fold the condition into, which is what keeps it
7016        // from running when the condition is false.
7017        let out = compile_and_emit(
7018            "SELECT (UPDATE Person FILTER .name = $n SET { name := $m }) IF EXISTS (SELECT Company) ELSE {}",
7019        );
7020        let update = out.sql.find("UPDATE").expect("an update is emitted");
7021        let where_clause = out.sql[update..].find("WHERE").expect("the update is filtered");
7022        assert!(
7023            out.sql[update + where_clause..].contains("EXISTS"),
7024            "the condition narrows the rows the update touches:\n{}",
7025            out.sql
7026        );
7027    }
7028
7029    #[test]
7030    fn test_a_conditional_delete_carries_its_condition() {
7031        // Guarding the branch would only filter what is read back — Postgres
7032        // runs a data-modifying CTE regardless — so the condition has to be
7033        // ANDed into the DELETE's own WHERE, as it already is for an update.
7034        let out = compile_and_emit("SELECT (DELETE Person FILTER .name = $n) IF FALSE ELSE {}");
7035        let delete = out
7036            .sql
7037            .split("DELETE FROM")
7038            .nth(1)
7039            .expect("the delete should still be emitted");
7040        let where_clause = delete.split("WHERE").nth(1).expect("the delete should be filtered");
7041        assert!(
7042            where_clause.contains("FALSE") || where_clause.contains("false"),
7043            "the condition must narrow the delete itself:\n{}",
7044            out.sql
7045        );
7046    }
7047
7048    #[test]
7049    fn test_a_pointer_a_binding_declared_in_its_own_shape() {
7050        // `p := (select Person { boss := … })` then `p { boss }` — the
7051        // pointer is on no type, so reading it back has to find it through
7052        // the binding it was written on, and compile it against that row.
7053        let out = compile_and_emit_with(
7054            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT p { name, co: { name } }",
7055            &make_schema(),
7056        );
7057        assert!(
7058            out.sql.contains("'default::Company'::text"),
7059            "the declared pointer must be inlined:\n{}",
7060            out.sql
7061        );
7062    }
7063
7064    #[test]
7065    fn test_a_binding_declared_pointer_read_from_a_tuple_element() {
7066        let out = compile_and_emit_with(
7067            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT (p { co: { name } }, 1)",
7068            &make_schema(),
7069        );
7070        assert!(out.sql.contains("'default::Company'::text"), "{}", out.sql);
7071    }
7072
7073    #[test]
7074    fn test_a_for_loop_variable_shaped_inside_a_tuple() {
7075        // The variable holds one row's key, so the element has to be narrowed
7076        // to it. Unfiltered it read the whole table and answered with rows
7077        // the loop never named -- wrong, and silently so.
7078        let out = compile_and_emit_with(
7079            "WITH p := (SELECT Person LIMIT 1) FOR q IN p.posts UNION (SELECT (a := q { title }, b := 1))",
7080            &make_schema(),
7081        );
7082        assert!(
7083            out.sql.contains("\"_for_q\".\"v\""),
7084            "the element must be narrowed to the loop's row:\n{}",
7085            out.sql
7086        );
7087    }
7088
7089    #[test]
7090    fn test_choosing_between_two_walks() {
7091        // `(select p.posts …) if cond else (select p.posts …)` — a walk names
7092        // no type of its own, so neither branch could be resolved.
7093        let out = compile_and_emit_with(
7094            "WITH p := (SELECT Person LIMIT 1), q := (SELECT p.posts LIMIT 1) IF TRUE ELSE (SELECT p.posts) \
7095             SELECT q { title }",
7096            &make_schema(),
7097        );
7098        assert!(out.sql.contains("UNION ALL"), "one branch per side:\n{}", out.sql);
7099    }
7100
7101    #[test]
7102    fn test_a_bare_coalesce_of_correlated_walks_reaching_many_objects() {
7103        // Without a shape the walks stand for the ids they reach, which is
7104        // still a set -- SQL's COALESCE takes one value per side.
7105        let out = compile_and_emit_with(
7106            "SELECT Post { owners := .<posts[is Person] ?? .<posts[is Person] }",
7107            &make_schema(),
7108        );
7109        assert!(
7110            !out.sql.contains("COALESCE("),
7111            "not a value-by-value choice:\n{}",
7112            out.sql
7113        );
7114        assert!(
7115            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7116            "the arms are aggregated:\n{}",
7117            out.sql
7118        );
7119    }
7120
7121    #[test]
7122    fn test_a_union_of_correlated_walks_reaching_many() {
7123        // Without the LIMIT the union stands for every object both walks
7124        // reach, so its arms have to be aggregated. Read as a lone record it
7125        // compiled fine and failed in PostgreSQL on the second row, with
7126        // "more than one row returned by a subquery used as an expression".
7127        let out = compile_and_emit_with(
7128            "SELECT Post { owners := (.<posts[is Person] UNION .<posts[is Person]) { name } }",
7129            &make_schema(),
7130        );
7131        assert!(
7132            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7133            "the arms are aggregated:\n{}",
7134            out.sql
7135        );
7136    }
7137
7138    #[test]
7139    fn test_a_coalesce_of_correlated_walks_reaching_many() {
7140        // `([is A].links ?? [is B].links) { … }` — the same set a union of
7141        // them gives, so it aggregates the same way.
7142        let out = compile_and_emit_with(
7143            "SELECT Post { owners := (.<posts[is Person] ?? .<posts[is Person]) { name } }",
7144            &make_schema(),
7145        );
7146        assert!(
7147            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7148            "the arms are aggregated:\n{}",
7149            out.sql
7150        );
7151    }
7152
7153    #[test]
7154    fn test_a_union_of_correlated_walks() {
7155        // `(.<a[is T] union .<b[is T]) { id }` — each operand hangs off the
7156        // enclosing row, so none can be hoisted into a CTE the way a
7157        // standalone union's operands are; they are read as one set instead.
7158        let out = compile_and_emit_with(
7159            "SELECT Post { owners := (SELECT (.<posts[is Person] UNION .<posts[is Person]) { name } LIMIT 1) }",
7160            &make_schema(),
7161        );
7162        assert!(out.sql.contains("UNION ALL"), "one arm per operand:\n{}", out.sql);
7163        assert!(
7164            out.sql.contains("AS \"_u\""),
7165            "the arms are read as one set:\n{}",
7166            out.sql
7167        );
7168    }
7169
7170    #[test]
7171    fn test_a_for_loop_whose_body_updates() {
7172        // Driven from the iteration with `UPDATE … FROM`: the select-bodied
7173        // form uses a LATERAL, which cannot hold DML. Verified live -- three
7174        // rows iterated, three rows updated.
7175        let out = compile_and_emit_with(
7176            "FOR p IN (SELECT Person) UNION (UPDATE Person FILTER .id = p.id SET { name := $n })",
7177            &make_schema(),
7178        );
7179        assert!(out.sql.contains("UPDATE \"public\".\"Person\""), "{}", out.sql);
7180        assert!(
7181            out.sql.contains("FROM \"_for_p\""),
7182            "driven from the iteration:\n{}",
7183            out.sql
7184        );
7185        assert!(
7186            !out.sql.contains("LATERAL"),
7187            "DML cannot sit in a LATERAL:\n{}",
7188            out.sql
7189        );
7190    }
7191
7192    #[test]
7193    fn test_a_for_loop_body_appends_the_row_it_is_iterating() {
7194        let out = compile_and_emit(
7195            "WITH t := (SELECT Post) FOR q IN t UNION (UPDATE Person FILTER .id = $i SET { posts += q })",
7196        );
7197        // One junction row per iteration, taken from the iteration itself --
7198        // not every Post, which an uncorrelated value subquery would append.
7199        assert!(
7200            out.sql.contains("\"_iter\"") && out.sql.contains("FROM \"_ids\""),
7201            "junction rows driven from the iteration:\n{}",
7202            out.sql
7203        );
7204        assert!(
7205            !out.sql.contains("LATERAL"),
7206            "DML cannot sit in a LATERAL:\n{}",
7207            out.sql
7208        );
7209    }
7210
7211    #[test]
7212    fn test_select_on_a_for_loop_variable_reads_only_its_row() {
7213        let out = compile_and_emit("FOR p IN (SELECT Person) UNION (SELECT (SELECT p) { name })");
7214        assert!(
7215            out.sql.contains("= \"_for_p\".\"v\""),
7216            "narrowed to the row the variable holds:\n{}",
7217            out.sql
7218        );
7219    }
7220
7221    #[test]
7222    fn test_a_walk_off_a_for_loop_variable_reads_the_binding() {
7223        // The rows a sibling CTE just wrote are not in the base table yet, so
7224        // a walk that re-reads the table finds nothing.
7225        let out = compile_and_emit("WITH ps := (SELECT Post) FOR q IN ps UNION (SELECT Person FILTER .name = q.title)");
7226        assert!(
7227            out.sql.contains("FROM \"ps\""),
7228            "the walk reads the binding:\n{}",
7229            out.sql
7230        );
7231    }
7232
7233    #[test]
7234    fn test_a_mutating_for_loop_binding_becomes_sibling_ctes() {
7235        let out = compile_and_emit(
7236            "WITH made := (FOR n IN {'a', 'b'} UNION (INSERT Post { title := n })) SELECT count(made)",
7237        );
7238        assert!(
7239            out.sql.contains("\"made__ids\" AS (\nINSERT INTO"),
7240            "the insert is its own top-level CTE:\n{}",
7241            out.sql
7242        );
7243        assert!(
7244            !out.sql.contains("LATERAL"),
7245            "DML cannot sit in a LATERAL:\n{}",
7246            out.sql
7247        );
7248    }
7249
7250    #[test]
7251    fn test_count_of_a_binding_counts_its_rows() {
7252        // Read as an ordinary expression a binding becomes a scalar subquery,
7253        // which aborts on the second row.
7254        let out = compile_and_emit("WITH people := (SELECT Person) SELECT count(people)");
7255        assert!(out.sql.contains("(SELECT count(*) FROM \"people\")"), "{}", out.sql);
7256    }
7257
7258    #[test]
7259    fn test_dividing_integers_yields_a_float() {
7260        for (query, expected) in [
7261            ("SELECT 100 / 133", "((100)::float8 / 133)"),
7262            ("SELECT Person { a := .age / 2 }", "::float8 / 2)"),
7263            ("SELECT Person.age / 2", "::float8 / 2)"),
7264            (
7265                "WITH total := max({133}), current := 38 - 1 SELECT 100 / total * current",
7266                "(((100)::float8 / (SELECT \"v\" FROM \"total\")) * (SELECT \"v\" FROM \"current\"))",
7267            ),
7268            (
7269                "WITH p := (SELECT Person LIMIT 1), index := p.age - 1, total := max(Person.age) SELECT 100 / total * index",
7270                "((100)::float8 / (SELECT \"v\" FROM \"total\"))",
7271            ),
7272        ] {
7273            let out = compile_and_emit(query);
7274            assert!(out.sql.contains(expected), "{query}:\n{}", out.sql);
7275        }
7276    }
7277
7278    #[test]
7279    fn test_dividing_anything_but_two_integers_is_left_to_postgres() {
7280        for query in [
7281            "SELECT 100 // 133",
7282            "SELECT 100 / 1.5",
7283            "SELECT <decimal>100 / <decimal>3",
7284            "SELECT 100 / <decimal>3",
7285        ] {
7286            let out = compile_and_emit(query);
7287            assert!(!out.sql.contains("::float8 /"), "{query}:\n{}", out.sql);
7288        }
7289    }
7290
7291    #[test]
7292    fn test_a_free_object_under_a_condition_stays_an_object() {
7293        for query in [
7294            "SELECT { a := 1 } IF true ELSE {}",
7295            "SELECT {} IF true ELSE { a := 1 }",
7296            "SELECT { a := 1 } IF true ELSE {} LIMIT 1",
7297        ] {
7298            let out = compile_and_emit(query);
7299            assert!(
7300                matches!(&out.shape.root, ShapeNode::Object { type_name: None, pointers, .. } if pointers.len() == 1),
7301                "{query}: {:?}\n{}",
7302                out.shape.root,
7303                out.sql
7304            );
7305        }
7306    }
7307
7308    #[test]
7309    fn test_exists_of_a_binding_asks_for_any_row() {
7310        let out = compile_and_emit("WITH people := (SELECT Person) SELECT exists people");
7311        assert!(out.sql.contains("EXISTS(SELECT 1 FROM \"people\")"), "{}", out.sql);
7312
7313        let out = compile_and_emit("WITH names := {'a', 'b'} SELECT exists names");
7314        assert!(
7315            out.sql
7316                .contains("EXISTS(SELECT 1 FROM \"names\" WHERE \"v\" IS NOT NULL)"),
7317            "{}",
7318            out.sql
7319        );
7320    }
7321
7322    #[test]
7323    fn test_a_conditional_insert_beside_a_read_branch_unions_both() {
7324        let out = compile_and_emit(
7325            "WITH existing := (SELECT Person FILTER .name = $n LIMIT 1) \
7326             SELECT (existing IF EXISTS existing ELSE (INSERT Person { name := $n })) { name }",
7327        );
7328        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7329        assert!(
7330            out.sql.contains("INSERT INTO") && out.sql.contains("WHERE (NOT EXISTS(SELECT 1 FROM \"existing\"))"),
7331            "the insert carries the condition itself:\n{}",
7332            out.sql
7333        );
7334    }
7335
7336    /// `@pylon.abstract` — unlike an interface it is materialised as nothing
7337    /// at all, so nothing can read a relation under its name. Modelled on
7338    /// `Person`, the one test type carrying the `id` every implementor of a
7339    /// real abstract has.
7340    fn make_schema_with_a_plain_abstract() -> SchemaDescriptor {
7341        let mut schema = make_schema();
7342        let mut archivable = schema
7343            .types
7344            .iter()
7345            .find(|t| t.name == "Person")
7346            .expect("Person is in the test schema")
7347            .clone();
7348        archivable.name = "Archivable".into();
7349        archivable.table = "Archivable".into();
7350        archivable.abstract_ = true;
7351        archivable.materialized = false;
7352        archivable.links = vec![];
7353        archivable.multilinks = vec![];
7354        schema.types.push(archivable);
7355        let person = schema
7356            .types
7357            .iter_mut()
7358            .find(|t| t.name == "Person")
7359            .expect("just found");
7360        person.parents.push("default::Archivable".into());
7361        schema
7362    }
7363
7364    #[test]
7365    fn test_an_intersection_with_a_plain_abstract_fans_out() {
7366        let schema = make_schema_with_a_plain_abstract();
7367        let out = compile_and_emit_with("SELECT Post { who := [is default::Archivable].name }", &schema);
7368        assert!(
7369            !out.sql.contains("\"Archivable\""),
7370            "a plain abstract backs no relation:\n{}",
7371            out.sql
7372        );
7373        assert!(
7374            out.sql.contains("\"public\".\"Person\""),
7375            "read from the type that carries the column:\n{}",
7376            out.sql
7377        );
7378    }
7379
7380    /// The same plain abstract, but keeping `Person`'s `company` link so a
7381    /// backlink can be narrowed to it the way jaldis narrows one to
7382    /// `default::Creatable`.
7383    fn make_schema_with_a_linking_plain_abstract() -> SchemaDescriptor {
7384        let mut schema = make_schema_with_a_plain_abstract();
7385        let person_links = schema
7386            .types
7387            .iter()
7388            .find(|t| t.name == "Person")
7389            .expect("Person is in the test schema")
7390            .links
7391            .clone();
7392        let archivable = schema
7393            .types
7394            .iter_mut()
7395            .find(|t| t.name == "Archivable")
7396            .expect("just added");
7397        archivable.links = person_links;
7398        schema
7399    }
7400
7401    #[test]
7402    fn test_a_plain_abstract_in_a_subquery_fans_out() {
7403        let schema = make_schema_with_a_plain_abstract();
7404        let out = compile_and_emit_with(
7405            "SELECT Person FILTER .id IN (SELECT default::Archivable FILTER .age > 1).id",
7406            &schema,
7407        );
7408        assert!(
7409            !out.sql.contains("\"Archivable\""),
7410            "a plain abstract backs no relation:\n{}",
7411            out.sql
7412        );
7413    }
7414
7415    #[test]
7416    fn test_a_backlink_narrowed_to_a_plain_abstract_fans_out() {
7417        let schema = make_schema_with_a_linking_plain_abstract();
7418        let out = compile_and_emit_with(
7419            "SELECT Company FILTER .<company[is default::Archivable].name = 'a'",
7420            &schema,
7421        );
7422        assert!(
7423            !out.sql.contains("\"Archivable\""),
7424            "a plain abstract backs no relation:\n{}",
7425            out.sql
7426        );
7427    }
7428
7429    /// `Account` as a real interface (a view), with a plain abstract only its
7430    /// implementor carries — jaldis reads `[is default::Auditable].*` off
7431    /// `marketplace::IdentifiableListing` exactly this way.
7432    fn make_interface_schema_with_a_plain_abstract() -> SchemaDescriptor {
7433        let mut schema = make_interface_schema();
7434        let mut archivable = schema
7435            .types
7436            .iter()
7437            .find(|t| t.name == "Individual")
7438            .expect("Individual is in the interface schema")
7439            .clone();
7440        archivable.name = "Archivable".into();
7441        archivable.table = "Archivable".into();
7442        archivable.abstract_ = true;
7443        archivable.materialized = false;
7444        archivable.interfaces = vec![];
7445        archivable.links = vec![];
7446        archivable.multilinks = vec![];
7447        archivable.computed = vec![];
7448        schema.types.push(archivable);
7449        for t in schema.types.iter_mut() {
7450            match t.name.as_str() {
7451                // A view to read, so the splat cannot fall back to it.
7452                "Account" => t.materialized = true,
7453                "Individual" => t.parents.push("default::Archivable".into()),
7454                _ => {}
7455            }
7456        }
7457        schema
7458    }
7459
7460    #[test]
7461    fn test_a_splat_over_a_plain_abstract_fans_out() {
7462        let schema = make_interface_schema_with_a_plain_abstract();
7463        let out = compile_and_emit_with("SELECT Account { id, [is default::Archivable].* }", &schema);
7464        assert!(
7465            !out.sql.contains("\"Archivable\""),
7466            "a plain abstract backs no relation:\n{}",
7467            out.sql
7468        );
7469        assert!(
7470            out.sql.contains("first_name"),
7471            "the splat still reaches the mixin's own pointers:\n{}",
7472            out.sql
7473        );
7474    }
7475
7476    #[test]
7477    fn test_an_aggregate_over_a_relative_walk_runs_inside_the_subquery() {
7478        // Compiled as an expression the walk stands for the array of its
7479        // elements, leaving the aggregate with an array argument.
7480        let out = compile_and_emit("SELECT Person { latest := max(.posts.title) }");
7481        assert!(out.sql.contains("(SELECT max("), "{}", out.sql);
7482        assert!(!out.sql.contains("max(ARRAY("), "{}", out.sql);
7483    }
7484
7485    /// `Company.staff` — a computed standing for a narrowed backlink, the
7486    /// shape jaldis reaches for wherever one type owns rows of another.
7487    fn make_schema_with_a_computed_backlink() -> SchemaDescriptor {
7488        use crate::schema::ComputedDescriptor;
7489        let mut schema = make_schema();
7490        let company = schema
7491            .types
7492            .iter_mut()
7493            .find(|t| t.name == "Company")
7494            .expect("Company is in the test schema");
7495        company.computed = vec![ComputedDescriptor {
7496            name: "staff".into(),
7497            expression: ".<company[is default::Person]".into(),
7498            return_type: None,
7499            link_target: None,
7500            link_multi: false,
7501        }];
7502        schema
7503    }
7504
7505    #[test]
7506    fn test_a_computed_backlink_reads_the_objects_it_lands_on() {
7507        let schema = make_schema_with_a_computed_backlink();
7508        let out = compile_and_emit_with("SELECT Company { staff: { name } }", &schema);
7509        assert!(
7510            out.sql.contains("\"public\".\"Person\""),
7511            "the walk reaches Person:\n{}",
7512            out.sql
7513        );
7514    }
7515
7516    #[test]
7517    fn test_a_computed_backlinks_modifiers_belong_to_what_it_lands_on() {
7518        // Bound to the declaring type instead, `.name` is a field Company has
7519        // not got and the query is rejected outright.
7520        let schema = make_schema_with_a_computed_backlink();
7521        let out = compile_and_emit_with("SELECT Company.staff { name } FILTER .name = 'ada'", &schema);
7522        assert!(out.sql.contains("'ada'"), "{}", out.sql);
7523        assert!(
7524            out.sql.contains("\"public\".\"Person\""),
7525            "filtered on the Person the walk lands on:\n{}",
7526            out.sql
7527        );
7528    }
7529
7530    #[test]
7531    fn test_an_unreserved_keyword_names_a_free_shape_field() {
7532        // `last`, `first` and `order` are unreserved in EdgeQL, so a shape can
7533        // carry them. Read as a set literal, the `:=` has nowhere to go.
7534        let out = compile_and_emit("SELECT { last := 2, first := 1, order := 3 }");
7535        assert!(out.sql.contains("\"last\""), "{}", out.sql);
7536        assert!(out.sql.contains("\"first\""), "{}", out.sql);
7537        assert!(out.sql.contains("\"order\""), "{}", out.sql);
7538    }
7539
7540    #[test]
7541    fn test_a_shaped_sub_select_over_a_type_is_an_object() {
7542        // Shapeless it stands for the row's key; with a shape it is the object
7543        // the shape reads, which used to be refused outright.
7544        let out = compile_and_emit("SELECT Person { latest := (SELECT Post { title } LIMIT 1) }");
7545        assert!(out.sql.contains("\"title\""), "{}", out.sql);
7546    }
7547
7548    #[test]
7549    fn test_a_walk_inside_a_path_selects_shape_finds_its_row() {
7550        // The shape is compiled against the type the walk landed on, so a
7551        // relative path inside it has that row to resolve against.
7552        let out = compile_and_emit("SELECT Person.posts { mine := (SELECT Post FILTER .title = .title LIMIT 1) }");
7553        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
7554    }
7555
7556    #[test]
7557    fn test_a_walk_off_a_binding_in_a_free_select() {
7558        // A free select has no row of its own, so the walk is read back as one
7559        // subquery; resolved as a type name the binding is simply unknown.
7560        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := p.company.name }");
7561        assert!(out.sql.contains("\"p\""), "{}", out.sql);
7562        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
7563    }
7564
7565    #[test]
7566    fn test_a_free_selects_filter_can_root_at_a_binding() {
7567        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := 1 } FILTER p.company.name = 'Acme'");
7568        assert!(out.sql.contains("'Acme'"), "{}", out.sql);
7569    }
7570
7571    #[test]
7572    fn test_a_trailing_shape_on_a_field_access_select() {
7573        let out = compile_and_emit("SELECT (SELECT Person).company { name }");
7574        assert!(out.sql.contains("\"name\""), "{}", out.sql);
7575        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
7576    }
7577
7578    #[test]
7579    fn test_a_union_of_walks_names_each_branch() {
7580        // A walk names no relation, and a union is emitted one relation per
7581        // branch — so each is hoisted into a CTE of its own first.
7582        let out = compile_and_emit(
7583            "WITH p := (SELECT Person FILTER .name = $a), q := (SELECT Person FILTER .name = $b), \
7584             t := (SELECT p.posts UNION q.posts) SELECT t { title }",
7585        );
7586        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7587        assert!(out.sql.contains("\"title\""), "{}", out.sql);
7588    }
7589
7590    #[test]
7591    fn test_sibling_for_loops_each_get_their_own_iterator() {
7592        // One WITH clause cannot hold `_for_p` twice, and two loops written
7593        // over the same variable name are ordinary.
7594        let out = compile_and_emit(
7595            "WITH a := (SELECT Post), b := (SELECT Post), \
7596             x := (FOR p IN a UNION (INSERT Person { name := p.title, age := 1 })), \
7597             y := (FOR p IN b UNION (INSERT Person { name := p.title, age := 2 })) \
7598             SELECT x",
7599        );
7600        assert!(out.sql.contains("\"_for_p\" AS"), "{}", out.sql);
7601        assert!(out.sql.contains("\"_for_p__1\" AS"), "{}", out.sql);
7602    }
7603
7604    #[test]
7605    fn test_a_nested_for_carries_the_outer_loops_key() {
7606        // The inner iterator has the outer one in its FROM, and the insert
7607        // joins the two back on the key it carries — Gel's own iterator bond.
7608        let out = compile_and_emit(
7609            "WITH made := (FOR p IN (SELECT Person) UNION ( \
7610               FOR q IN p.posts UNION (INSERT Company { name := q.title }) \
7611             )) SELECT count(made)",
7612        );
7613        assert!(
7614            out.sql.contains("\"_outer\""),
7615            "the inner iterator carries the bond:\n{}",
7616            out.sql
7617        );
7618        assert!(
7619            out.sql.contains("JOIN \"_for_p\" ON"),
7620            "the insert reads both loop variables:\n{}",
7621            out.sql
7622        );
7623        assert!(
7624            !out.sql.contains("LATERAL (\nINSERT"),
7625            "DML cannot sit in a LATERAL:\n{}",
7626            out.sql
7627        );
7628    }
7629
7630    #[test]
7631    fn test_a_link_value_can_be_a_walk_off_a_binding() {
7632        // `(select placed.line_items limit 1)` — the walk names no type of its
7633        // own, so reading one off it used to report the path as no type at all.
7634        let mut schema = make_schema();
7635        let company = schema
7636            .types
7637            .iter_mut()
7638            .find(|t| t.name == "Company")
7639            .expect("make_schema declares Company");
7640        company.properties.push(PropertyDescriptor {
7641            name: "id".into(),
7642            pg_type: "uuid".into(),
7643            nullable: false,
7644            default_sql: None,
7645            default_pyql: None,
7646            description: None,
7647            check_constraints: vec![],
7648            is_exclusive: true,
7649            is_pk: true,
7650            is_readonly: true,
7651            rewrites: vec![],
7652            tuple_members: None,
7653            column_type: None,
7654        });
7655        let out = compile_and_emit_with(
7656            "WITH others := (SELECT Person) \
7657             SELECT (INSERT Person { name := $n, age := 1, company := (SELECT others.company LIMIT 1) })",
7658            &schema,
7659        );
7660        assert!(out.sql.contains("\"company_id\""), "{}", out.sql);
7661    }
7662
7663    #[test]
7664    fn test_a_shape_over_a_coalesce_of_walks() {
7665        // Each operand is a walk off the enclosing row, and every one after the
7666        // first only stands in when the ones before it are empty.
7667        let schema = make_schema_with_computed_links();
7668        let out = compile_and_emit_with("SELECT Person { x := (.published ?? .plain) { title } }", &schema);
7669        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7670        assert!(
7671            out.sql.contains("NOT EXISTS"),
7672            "the later branch only stands in when the first is empty:\n{}",
7673            out.sql
7674        );
7675    }
7676
7677    #[test]
7678    fn test_an_aggregate_over_a_walk_onto_objects_counts_its_rows() {
7679        // Falling through instead put the aggregate back through the very
7680        // expression route that sent it here, and the recursion took the
7681        // process down with it.
7682        let out = compile_and_emit("WITH c := (SELECT Company) SELECT c { n := count(.<company[is default::Person]) }");
7683        assert!(out.sql.contains("count("), "{}", out.sql);
7684        assert!(out.sql.contains("\"public\".\"Person\""), "{}", out.sql);
7685    }
7686
7687    #[test]
7688    fn an_aggregate_over_a_computed_set_counts_inside_a_subquery() {
7689        // `count(.plain)` is one step, so the junction-only count a stored
7690        // multi-link gets cannot apply — it has no junction to read. Taking
7691        // the walk as a value instead made the whole select an aggregate
7692        // query, which Postgres rejects ("column must appear in the GROUP BY
7693        // clause").
7694        let schema = make_schema_with_computed_links();
7695        let out = compile_and_emit_with("SELECT Person { n := count(.plain) }", &schema);
7696        assert!(
7697            !out.sql.contains("count((SELECT"),
7698            "the aggregate belongs inside the walk's subquery, not around it:\n{}",
7699            out.sql
7700        );
7701        assert!(
7702            out.sql.contains("(SELECT count(") && out.sql.contains("\"public\".\"Post\""),
7703            "it counts the rows the computed's own path lands on:\n{}",
7704            out.sql
7705        );
7706    }
7707
7708    /// The specialized count for a stored multi-link reads its junction table
7709    /// alone — the computed case above must not have pulled it off that route.
7710    #[test]
7711    fn an_aggregate_over_a_stored_multi_link_still_counts_its_junction_rows() {
7712        let schema = make_schema_with_computed_links();
7713        let out = compile_and_emit_with("SELECT Person { n := count(.posts) }", &schema);
7714        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
7715        assert!(
7716            !out.sql.contains("JOIN \"public\".\"Post\""),
7717            "counting junction rows needs no join onto the targets:\n{}",
7718            out.sql
7719        );
7720    }
7721
7722    #[test]
7723    fn test_a_select_can_name_its_own_result() {
7724        // `SELECT OptionallyAliasedExpr` in EdgeQL: the alias names the result
7725        // and the select's own clauses may read it by that name.
7726        let out = compile_and_emit("SELECT oldest := max(Person.age)");
7727        assert!(out.sql.contains("max("), "{}", out.sql);
7728        let filtered = compile_and_emit("SELECT n := Person.age FILTER n > 18");
7729        assert!(
7730            filtered.sql.contains("18"),
7731            "the alias resolves to the result:\n{}",
7732            filtered.sql
7733        );
7734    }
7735
7736    #[test]
7737    fn test_exists_sees_a_computed_pointer() {
7738        // The suggester could see the name the resolver had just reported as
7739        // unknown — `has no link or property 'published'. Did you mean
7740        // 'published'?`
7741        let schema = make_schema_with_computed_links();
7742        let out = compile_and_emit_with("SELECT Person FILTER EXISTS .published", &schema);
7743        assert!(out.sql.contains("EXISTS"), "{}", out.sql);
7744        assert!(
7745            out.sql.contains("\"public\".\"Post\""),
7746            "the computed's own path:\n{}",
7747            out.sql
7748        );
7749    }
7750
7751    #[test]
7752    fn test_walking_through_a_computed_field_access() {
7753        use crate::schema::ComputedDescriptor;
7754        // `parent := (select .parents … limit 1).parent` — the modifiers belong
7755        // to the step before the field access, not to what it lands on.
7756        let mut schema = make_schema_with_computed_links();
7757        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
7758        person.computed.push(ComputedDescriptor {
7759            name: "latest_title".into(),
7760            expression: "(select .posts order by .title desc limit 1).title".into(),
7761            return_type: None,
7762            link_target: None,
7763            link_multi: false,
7764        });
7765        let out = compile_and_emit_with("SELECT Person { x := .latest_title }", &schema);
7766        assert!(out.sql.contains("\"title\""), "{}", out.sql);
7767        assert!(
7768            out.sql.contains("LIMIT"),
7769            "the computed's own limit survives:\n{}",
7770            out.sql
7771        );
7772    }
7773
7774    #[test]
7775    fn test_a_computed_field_access_onto_an_object_takes_a_shape() {
7776        use crate::schema::ComputedDescriptor;
7777        // Compiled as an expression, the pointer came back as the bare id of
7778        // the object it lands on, whatever shape was written on it.
7779        let mut schema = make_schema_with_computed_links();
7780        let post = schema.types.iter_mut().find(|t| t.name == "Post").expect("Post");
7781        post.links.push(LinkDescriptor {
7782            name: "author".into(),
7783            target: "default::Person".into(),
7784            nullable: true,
7785            through: None,
7786            description: None,
7787            default_pyql: None,
7788            is_exclusive: false,
7789            is_readonly: false,
7790            rewrites: vec![],
7791            on_delete: vec![],
7792        });
7793        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
7794        person.computed.retain(|c| c.name != "looper");
7795        person.computed.push(ComputedDescriptor {
7796            name: "latest_author".into(),
7797            expression: "((select .posts order by .title desc limit 1)).author".into(),
7798            return_type: None,
7799            link_target: None,
7800            link_multi: false,
7801        });
7802        let out = compile_and_emit_with("SELECT Person { latest_author: { name } }", &schema);
7803        assert!(out.sql.contains("\"name\""), "{}", out.sql);
7804        assert!(
7805            out.sql.contains("\"title\" DESC"),
7806            "the order stays on the posts:\n{}",
7807            out.sql
7808        );
7809        assert!(
7810            out.sql.contains("LIMIT"),
7811            "the computed's own limit survives:\n{}",
7812            out.sql
7813        );
7814        let splat = compile_and_emit_with("SELECT Person { * }", &schema);
7815        assert!(
7816            !splat.sql.contains("latest_author"),
7817            "`*` leaves links out:\n{}",
7818            splat.sql
7819        );
7820    }
7821
7822    #[test]
7823    fn test_a_narrowed_pointer_can_carry_a_shape() {
7824        // `[is T].posts: { title }` — the parser stopped at the `:`, and the
7825        // route behind it resolved a stored property and nothing else, so a
7826        // link reported itself as the name it had just been asked for.
7827        let out = compile_and_emit("SELECT Company { x := 1, [is default::Person].posts: { title } }");
7828        assert!(out.sql.contains("\"title\""), "{}", out.sql);
7829        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
7830    }
7831
7832    #[test]
7833    fn test_select_type_name_as_a_path_step() {
7834        let out = compile_and_emit("SELECT Person.__type__");
7835        assert!(out.sql.contains("ROW('default::Person')"), "{}", out.sql);
7836        assert!(out.sql.contains("\"Person\" AS \"t0\""), "{}", out.sql);
7837    }
7838
7839    #[test]
7840    fn test_aggregate_over_a_with_bound_object_set() {
7841        let out = compile_and_emit("WITH people := (SELECT Person) SELECT max(people.age)");
7842        assert!(out.sql.contains("\"people\" AS ("));
7843        assert!(out.sql.contains("max("));
7844        // Reads the binding, not the base table again.
7845        assert!(!out.sql.contains("FROM \"default\".\"Person\" AS \"t1\""));
7846    }
7847
7848    #[test]
7849    fn test_select_union_of_two_object_bindings() {
7850        let out = compile_and_emit("WITH a := (SELECT Person LIMIT 1), b := (SELECT Person) SELECT (a UNION b)");
7851        assert!(out.sql.contains("SELECT * FROM \"a\""));
7852        assert!(out.sql.contains("UNION ALL"));
7853        assert!(out.sql.contains("SELECT * FROM \"b\""));
7854    }
7855
7856    #[test]
7857    fn test_select_union_of_different_object_types_is_rejected() {
7858        let schema = make_schema();
7859        let ast = parse::parse("WITH a := (SELECT Person), b := (SELECT Company) SELECT (a UNION b)").unwrap();
7860        let Err(err) = ir::compile(&ast, &schema) else {
7861            panic!("union of unrelated types must be rejected")
7862        };
7863        assert!(format!("{err}").contains("UNION"));
7864    }
7865
7866    #[test]
7867    fn test_for_union_as_a_function_body() {
7868        let schema = make_schema();
7869        let descriptor = FunctionDescriptor {
7870            name: "recent".into(),
7871            module: "default".into(),
7872            params: vec![],
7873            return_pg_type: "default::Person".into(),
7874            return_is_object: true,
7875            return_is_set: true,
7876            return_is_polymorphic: false,
7877            volatility: "stable".into(),
7878            body: "for n in {1, 2} union (select Person filter .age = n)".into(),
7879        };
7880        let ir = ir::compile_fn_body(&descriptor, &schema).expect("function body must compile");
7881        let body = emit_fn_body(&ir);
7882        assert!(body.contains("VALUES"), "{body}");
7883        assert!(body.contains("CROSS JOIN LATERAL"), "{body}");
7884        assert!(body.contains("\"_body\".*"), "{body}");
7885    }
7886
7887    #[test]
7888    fn test_with_binding_in_a_computed_reads_the_enclosing_object() {
7889        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
7890        assert!(out.sql.contains("\"name\""), "{}", out.sql);
7891    }
7892
7893    #[test]
7894    fn test_subject_path_update_names_its_own_alias() {
7895        let mut schema = make_schema();
7896        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
7897        company.properties.insert(
7898            0,
7899            PropertyDescriptor {
7900                name: "id".into(),
7901                pg_type: "uuid".into(),
7902                nullable: false,
7903                default_sql: Some("uuidv7()".into()),
7904                default_pyql: None,
7905                description: None,
7906                check_constraints: vec![],
7907                is_exclusive: true,
7908                is_pk: true,
7909                is_readonly: true,
7910                rewrites: vec![],
7911                tuple_members: None,
7912                column_type: None,
7913            },
7914        );
7915        // The row set narrowing the update compares against *that update's*
7916        // alias; a bare `id` would be ambiguous once a nested statement's CTE
7917        // joins the FROM.
7918        let out = compile_and_emit_with("SELECT (UPDATE Person.company SET { name := 'x' }) { name }", &schema);
7919        assert!(out.sql.contains("\"t1\".\"id\" = ANY("), "{}", out.sql);
7920    }
7921
7922    #[test]
7923    fn test_an_update_past_a_from_returns_only_its_own_columns() {
7924        // `UPDATE … FROM cte … RETURNING *` returns the joined relation's
7925        // columns as well, so a CTE built that way carries two `id`s and any
7926        // later reference to one is ambiguous.
7927        let out = compile_and_emit(
7928            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
7929        );
7930        assert!(out.sql.contains("FROM \"_nested_dml_0\""), "{}", out.sql);
7931        assert!(
7932            out.sql.contains("RETURNING \"t0\".*"),
7933            "the update names its own target:\n{}",
7934            out.sql
7935        );
7936    }
7937
7938    #[test]
7939    fn test_nested_insert_in_a_mutation_written_as_a_free_set() {
7940        let out = compile_and_emit(
7941            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
7942        );
7943        assert!(
7944            out.sql.contains("INSERT INTO \"public\".\"Company\""),
7945            "the hoisted insert is defined, not just referenced:\n{}",
7946            out.sql
7947        );
7948    }
7949
7950    #[test]
7951    fn test_nested_insert_as_a_link_value_in_an_update() {
7952        // The hoisted insert sits beside the update rather than inside it, so
7953        // the update needs a FROM to read its id, and its own `id` has to name
7954        // the update's alias — a bare one could mean either relation.
7955        let out = compile_and_emit(
7956            "SELECT (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) { name }",
7957        );
7958        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
7959        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
7960        assert!(
7961            out.sql.contains("FROM \"_nested_dml_0\""),
7962            "the update reads the hoisted CTE:\n{}",
7963            out.sql
7964        );
7965    }
7966
7967    #[test]
7968    fn test_nested_insert_as_a_link_value() {
7969        let out = compile_and_emit(
7970            "SELECT (INSERT Person { name := 'a', company := (INSERT Company { name := 'c' }) }) { name }",
7971        );
7972        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
7973        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
7974    }
7975
7976    #[test]
7977    fn test_nested_insert_in_a_one_element_set_is_that_insert() {
7978        let out = compile_and_emit(
7979            "SELECT (INSERT Person { name := 'a', company := { (INSERT Company { name := 'c' }) } }) { name }",
7980        );
7981        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
7982    }
7983
7984    #[test]
7985    fn test_insert_with_no_assignments_uses_default_values() {
7986        let out = compile_and_emit("SELECT (INSERT Person { name := 'a', company := (INSERT Company {}) }) { name }");
7987        assert!(out.sql.contains("DEFAULT VALUES"), "{}", out.sql);
7988    }
7989
7990    #[test]
7991    fn test_backlink_narrowed_to_an_interface_its_implementor_declares() {
7992        // `notes` is declared on Individual; `[is Account]` is satisfied by an
7993        // Individual row, so the backlink resolves through the implementor.
7994        use crate::schema::{LinkDescriptor, TypeDescriptor};
7995        let mut schema = make_interface_schema();
7996        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
7997        schema.types.push(TypeDescriptor {
7998            name: "Note".into(),
7999            module: "default".into(),
8000            table: "Note".into(),
8001            abstract_: false,
8002            materialized: true,
8003            description: None,
8004            parents: vec![],
8005            interfaces: vec![],
8006            bases: vec![],
8007            properties: individual.properties[..1].to_vec(),
8008            links: vec![],
8009            multilinks: vec![],
8010            computed: vec![],
8011            constraints: vec![],
8012            indexes: vec![],
8013            partition: None,
8014            vector_indexes: vec![],
8015            search_indexes: vec![],
8016            triggers: vec![],
8017            junction: false,
8018            signals: vec![],
8019        });
8020        schema
8021            .types
8022            .iter_mut()
8023            .find(|t| t.name == "Individual")
8024            .unwrap()
8025            .links
8026            .push(LinkDescriptor {
8027                name: "note".into(),
8028                target: "default::Note".into(),
8029                nullable: true,
8030                description: None,
8031                default_pyql: None,
8032                is_exclusive: false,
8033                is_readonly: false,
8034                rewrites: vec![],
8035                on_delete: vec![],
8036                through: None,
8037            });
8038        let out = compile_and_emit_with("SELECT Note FILTER EXISTS .<note[is Account]", &schema);
8039        assert!(out.sql.contains("\"Individual\""), "{}", out.sql);
8040    }
8041
8042    #[test]
8043    fn test_subject_reads_the_row_a_constraint_checks() {
8044        let schema = make_schema();
8045        let sql = ir::compile_constraint_expr("__subject__.age > 18", "default::Person", &schema)
8046            .expect("__subject__ names the row under check");
8047        assert!(sql.contains("\"age\""), "{sql}");
8048    }
8049
8050    #[test]
8051    fn test_free_select_filter_gates_the_result_and_warns() {
8052        let schema = make_schema();
8053        let ast = parse::parse("SELECT count(Person) FILTER (Person.age > 18)").unwrap();
8054        let ir = ir::compile(&ast, &schema).unwrap();
8055        let out = emit(&ir);
8056        assert!(
8057            out.sql.contains("EXISTS("),
8058            "the condition gates the count:\n{}",
8059            out.sql
8060        );
8061        assert!(
8062            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
8063            "a set-valued filter warns: {:?}",
8064            ir.warnings
8065        );
8066    }
8067
8068    #[test]
8069    fn test_free_sub_select_with_modifiers_reads_inline() {
8070        // The inner statement reads the enclosing row, so it cannot become a
8071        // CTE ahead of the FROM clause that defines it.
8072        let out = compile_and_emit(
8073            "SELECT Person { n := (WITH mine := .name SELECT count(Post) FILTER (Post.title = mine)) }",
8074        );
8075        assert!(out.sql.contains("_scalar_sub"), "{}", out.sql);
8076        assert!(!out.sql.contains("WITH"), "nothing is hoisted:\n{}", out.sql);
8077    }
8078
8079    #[test]
8080    fn test_correlated_with_binding_is_not_hoisted() {
8081        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
8082        assert!(
8083            !out.sql.contains("\"own\" AS ("),
8084            "the binding is inlined:\n{}",
8085            out.sql
8086        );
8087        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8088    }
8089
8090    #[test]
8091    fn test_array_literal_binding_is_readable_and_typed() {
8092        let out = compile_and_emit("WITH order := ['a', 'b'] SELECT std::find(order, 'b')");
8093        assert!(out.sql.contains("array_position"), "its type is known:\n{}", out.sql);
8094        assert!(
8095            out.sql.contains("AS v"),
8096            "the binding exposes a value column:\n{}",
8097            out.sql
8098        );
8099    }
8100
8101    #[test]
8102    fn test_any_over_a_single_boolean_is_that_boolean() {
8103        let out = compile_and_emit("SELECT Person FILTER std::any(.age > 18)");
8104        assert!(!out.sql.contains("bool_or"), "no aggregate in WHERE:\n{}", out.sql);
8105        assert!(out.sql.contains("> 18"), "{}", out.sql);
8106    }
8107
8108    #[test]
8109    fn test_comparing_a_value_to_a_set_is_membership() {
8110        let schema = make_schema_with_computed_links();
8111        let out = compile_and_emit_with("SELECT Person FILTER .name = Person.posts.title", &schema);
8112        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8113    }
8114
8115    #[test]
8116    fn test_for_over_a_with_binding_iterates_every_row() {
8117        let out = compile_and_emit(
8118            "WITH names := (SELECT Person.name) FOR n IN names UNION (SELECT Person FILTER .name = n)",
8119        );
8120        assert!(out.sql.contains("FROM \"names\""), "{}", out.sql);
8121        assert!(!out.sql.contains("VALUES"), "the whole set is iterated:\n{}", out.sql);
8122    }
8123
8124    #[test]
8125    fn test_for_body_bindings_stay_inside_the_body() {
8126        // `matching` reads the loop variable, so it cannot be hoisted to the
8127        // statement's own WITH — nothing there can see `_for_n`.
8128        let out = compile_and_emit(
8129            "WITH names := (SELECT Person.name) \
8130             FOR n IN names UNION (WITH matching := (SELECT Person FILTER .name = n) SELECT matching)",
8131        );
8132        let lateral = out.sql.find("CROSS JOIN LATERAL").expect("a lateral body");
8133        let binding = out.sql.find("\"matching\" AS (").expect("the body's binding");
8134        assert!(binding > lateral, "the binding sits inside the body:\n{}", out.sql);
8135    }
8136
8137    #[test]
8138    fn test_array_valued_binding_picks_the_array_overload() {
8139        // The binding carries `text[]`, so the call resolves to the array
8140        // overload even though the value itself is no longer an array literal.
8141        let out = compile_and_emit("WITH l := <array<std::str>>$0 SELECT std::find(l, 'a')");
8142        assert!(out.sql.contains("array_position"), "{}", out.sql);
8143        assert!(!out.sql.contains("strpos"), "{}", out.sql);
8144    }
8145
8146    #[test]
8147    fn test_aggregate_over_a_path_in_a_filter_is_a_subquery() {
8148        let out = compile_and_emit("SELECT Person FILTER .age = std::max(Person.age)");
8149        assert!(
8150            out.sql.contains("(SELECT max("),
8151            "an aggregate in WHERE needs its own query:\n{}",
8152            out.sql
8153        );
8154    }
8155
8156    #[test]
8157    fn test_distinct_over_a_single_value_is_that_value() {
8158        let out = compile_and_emit("SELECT Person { n := (DISTINCT .name) }");
8159        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8160        assert!(!out.sql.contains("DISTINCT \""), "{}", out.sql);
8161    }
8162
8163    #[test]
8164    fn test_narrowing_an_interface_joins_the_implementor_table() {
8165        // `first_name` lives on Individual's own table; the Account view has
8166        // only the columns every implementor shares.
8167        let mut schema = make_interface_schema();
8168        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
8169        account.materialized = true;
8170        let out = compile_and_emit_with("SELECT Account[is Individual].first_name", &schema);
8171        assert!(
8172            out.sql.contains("JOIN \"public\".\"Individual\""),
8173            "the implementor's own table is joined:\n{}",
8174            out.sql
8175        );
8176        assert!(out.sql.contains("\"first_name\""), "{}", out.sql);
8177    }
8178
8179    #[test]
8180    fn test_sub_select_shape_declares_names_its_filter_and_projection_read() {
8181        let out = compile_and_emit("SELECT Person { n := (SELECT .posts { t := .title } FILTER .t = 'x' LIMIT 1).t }");
8182        assert!(out.sql.contains("'x'"), "{}", out.sql);
8183        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8184    }
8185
8186    #[test]
8187    fn test_assert_over_an_object_set_returns_rows() {
8188        let out = compile_and_emit("SELECT std::assert_distinct((SELECT Person))");
8189        assert!(
8190            out.sql.contains("\"assert_distinct\"(ARRAY(SELECT \"id\""),
8191            "{}",
8192            out.sql
8193        );
8194        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8195        assert!(out.sql.contains("\"Person\" AS \"t1\""), "{}", out.sql);
8196        assert!(!out.sql.contains("unnest("), "{}", out.sql);
8197    }
8198
8199    #[test]
8200    fn test_assert_over_a_for_union_reads_its_rows() {
8201        let out =
8202            compile_and_emit("SELECT std::assert_distinct((FOR n IN {1, 2} UNION (SELECT Person FILTER .age = n)))");
8203        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
8204        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8205    }
8206
8207    #[test]
8208    fn test_declared_computed_with_binding_reads_its_own_object() {
8209        // Validation compiles a declared computed on its own, with no
8210        // enclosing SELECT — `.name` in the binding still means this object's.
8211        use crate::schema::ComputedDescriptor;
8212        let schema = make_schema();
8213        let cd = ComputedDescriptor {
8214            name: "shouted".into(),
8215            expression: "(with own := .name select std::str_upper(own))".into(),
8216            return_type: None,
8217            link_target: None,
8218            link_multi: false,
8219        };
8220        let ir = ir::compile_computed_in_type(&cd, "default::Person", &schema)
8221            .expect("a computed may read the object it is declared on");
8222        assert!(ir.is_some());
8223    }
8224
8225    #[test]
8226    fn test_sub_select_modifiers_scope_to_its_own_subject() {
8227        // `.title` belongs to Post, the sub-select's subject — not to Person,
8228        // where the projection `.author.name` lands.
8229        let mut schema = make_schema();
8230        let post = schema
8231            .types
8232            .iter_mut()
8233            .find(|t| t.name == "Post")
8234            .expect("Post is in the test schema");
8235        post.links.push(LinkDescriptor {
8236            name: "author".into(),
8237            target: "default::Person".into(),
8238            nullable: true,
8239            description: None,
8240            default_pyql: None,
8241            is_exclusive: false,
8242            is_readonly: false,
8243            rewrites: vec![],
8244            on_delete: vec![],
8245            through: None,
8246        });
8247        let out = compile_and_emit_with(
8248            "SELECT Person { a := (SELECT .posts FILTER .title = 'x' LIMIT 1).author.name }",
8249            &schema,
8250        );
8251        assert!(out.sql.contains("'x'"), "{}", out.sql);
8252    }
8253
8254    #[test]
8255    fn test_free_select_set_literal() {
8256        let schema = make_schema();
8257        let ast = parse::parse("SELECT {1, 2, 3}").unwrap();
8258        let ir = ir::compile(&ast, &schema).unwrap();
8259        let out = emit(&ir);
8260        // Three UNION ALL branches
8261        assert_eq!(out.sql.matches("UNION ALL").count(), 2);
8262        assert!(out.sql.contains("1 AS v"));
8263        assert!(out.sql.contains("2 AS v"));
8264        assert!(out.sql.contains("3 AS v"));
8265        assert!(out.sql.contains("ROW(v) AS result"));
8266        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
8267    }
8268
8269    #[test]
8270    fn test_free_select_free_object() {
8271        let schema = make_schema();
8272        let ast = parse::parse("SELECT { foo := 'bar', n := 42 }").unwrap();
8273        let ir = ir::compile(&ast, &schema).unwrap();
8274        let out = emit(&ir);
8275        assert!(out.sql.contains("'bar'"));
8276        assert!(out.sql.contains("42"));
8277        assert!(out.sql.contains("AS result"));
8278        // Shape should describe an object with pointers foo and n
8279        let crate::query::ShapeNode::Object {
8280            pointers, type_name, ..
8281        } = &out.shape.root
8282        else {
8283            panic!("expected Object shape")
8284        };
8285        assert!(type_name.is_none());
8286        assert_eq!(pointers.len(), 2);
8287        assert!(matches!(&pointers[0], crate::query::ShapeNode::Scalar { name, position: 0 } if name == "foo"));
8288        assert!(matches!(&pointers[1], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "n"));
8289    }
8290
8291    #[test]
8292    fn test_free_select_object_with_enum_field_casts_to_text_and_tags_shape() {
8293        // Regression: `select { gender := default::Gender.Male }` failed at
8294        // runtime with "no decoder for composite type element ... " —
8295        // A custom enum's OID is database-assigned, so inside an anonymous ROW()
8296        // composite. free_item_shape/emit_free_select always treated every
8297        // free-object field as a plain untyped Scalar, never casting an
8298        // enum-valued field to ::text (unlike a schema object's emit_scalar,
8299        // which already does this for a real enum-typed property column).
8300        let mut schema = make_schema();
8301        schema.enums.push(crate::schema::EnumDescriptor {
8302            name: "Gender".into(),
8303            module: "default".into(),
8304            members: vec!["Male".into(), "Female".into()],
8305        });
8306        let out = compile_and_emit_with("select { gender := default::Gender.Male }", &schema);
8307        // The enum value is computed once (`'Male'::"public"."Gender" AS
8308        // "_f0"`) and reused for both the `result` composite (cast to
8309        // `::text` there, since an enum's OID is database-assigned inside an
8310        // anonymous ROW()) and the plain per-field column exposed for
8311        // `IrExpr::CteFieldRef` access — so the `::text` cast now applies
8312        // to that computed column, not inline on the enum literal itself.
8313        assert!(
8314            out.sql.contains("'Male'::\"public\".\"Gender\""),
8315            "expected the enum literal, got:\n{}",
8316            out.sql
8317        );
8318        assert!(
8319            out.sql.contains("ROW(\"_f0\"::text) AS result"),
8320            "expected the ROW composite to cast the enum field to text, got:\n{}",
8321            out.sql
8322        );
8323        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
8324            panic!("expected Object shape")
8325        };
8326        assert_eq!(pointers.len(), 1);
8327        assert!(
8328            matches!(
8329                &pointers[0],
8330                crate::query::ShapeNode::Enum { name, position: 0, enum_type }
8331                    if name == "gender" && enum_type == "public::Gender"
8332            ),
8333            "expected Enum-tagged shape, got: {:?}",
8334            pointers[0]
8335        );
8336    }
8337
8338    #[test]
8339    fn test_free_select_bare_enum_literal_casts_to_text_inside_row() {
8340        // Same bug, bare scalar form: `select default::Gender.Male;` (no
8341        // shape/object wrapper) also wraps the value in ROW() for top-level
8342        // top-level decoding.
8343        let mut schema = make_schema();
8344        schema.enums.push(crate::schema::EnumDescriptor {
8345            name: "Gender".into(),
8346            module: "default".into(),
8347            members: vec!["Male".into(), "Female".into()],
8348        });
8349        let out = compile_and_emit_with("select default::Gender.Male", &schema);
8350        assert!(
8351            out.sql.contains("ROW(v::text) AS result"),
8352            "expected ROW(v::text), got:\n{}",
8353            out.sql
8354        );
8355        assert!(
8356            matches!(
8357                &out.shape.root,
8358                crate::query::ShapeNode::Enum { enum_type, .. } if enum_type == "public::Gender"
8359            ),
8360            "expected Enum-tagged shape, got: {:?}",
8361            out.shape.root
8362        );
8363    }
8364
8365    // ── FOR UPDATE / FOR SHARE row-locking clause ──────────────────────────────
8366
8367    #[test]
8368    fn test_for_update_defaults_to_blocking() {
8369        let out = compile_and_emit("SELECT Person FOR UPDATE");
8370        assert!(out.sql.trim_end().ends_with("FOR UPDATE"), "got:\n{}", out.sql);
8371    }
8372
8373    #[test]
8374    fn test_for_update_skip_locked() {
8375        let out = compile_and_emit("SELECT Person FOR UPDATE SKIP LOCKED");
8376        assert!(
8377            out.sql.trim_end().ends_with("FOR UPDATE SKIP LOCKED"),
8378            "got:\n{}",
8379            out.sql
8380        );
8381    }
8382
8383    #[test]
8384    fn test_for_update_nowait() {
8385        let out = compile_and_emit("SELECT Person FOR UPDATE NOWAIT");
8386        assert!(out.sql.trim_end().ends_with("FOR UPDATE NOWAIT"), "got:\n{}", out.sql);
8387    }
8388
8389    #[test]
8390    fn test_for_no_key_update_skip_locked() {
8391        let out = compile_and_emit("SELECT Person FOR NO KEY UPDATE SKIP LOCKED");
8392        assert!(
8393            out.sql.trim_end().ends_with("FOR NO KEY UPDATE SKIP LOCKED"),
8394            "got:\n{}",
8395            out.sql
8396        );
8397    }
8398
8399    #[test]
8400    fn test_for_share() {
8401        let out = compile_and_emit("SELECT Person FOR SHARE");
8402        assert!(out.sql.trim_end().ends_with("FOR SHARE"), "got:\n{}", out.sql);
8403    }
8404
8405    #[test]
8406    fn test_for_key_share_nowait() {
8407        let out = compile_and_emit("SELECT Person FOR KEY SHARE NOWAIT");
8408        assert!(
8409            out.sql.trim_end().ends_with("FOR KEY SHARE NOWAIT"),
8410            "got:\n{}",
8411            out.sql
8412        );
8413    }
8414
8415    #[test]
8416    fn test_for_update_comes_after_order_by_limit_offset_in_emitted_sql() {
8417        // Postgres's own grammar places the locking clause last — confirm
8418        // the emitter matches, not just that all the pieces are present.
8419        let out = compile_and_emit("SELECT Person { name } ORDER BY .name OFFSET 1 LIMIT 5 FOR UPDATE SKIP LOCKED");
8420        let order_pos = out.sql.find("ORDER BY").unwrap();
8421        let offset_pos = out.sql.find("OFFSET").unwrap();
8422        let limit_pos = out.sql.find("LIMIT").unwrap();
8423        let for_pos = out.sql.find("FOR UPDATE").unwrap();
8424        assert!(
8425            order_pos < offset_pos && offset_pos < limit_pos && limit_pos < for_pos,
8426            "got:\n{}",
8427            out.sql
8428        );
8429    }
8430
8431    #[test]
8432    fn test_for_update_combined_with_distinct_is_rejected() {
8433        let ast = parse::parse("SELECT DISTINCT Person FOR UPDATE").expect("parse failed");
8434        let err = ir::compile(&ast, &make_schema())
8435            .err()
8436            .expect("expected a compile error");
8437        assert!(err.to_string().contains("DISTINCT"), "unexpected: {err}");
8438    }
8439
8440    #[test]
8441    fn test_for_update_combined_with_select_over_insert_is_rejected() {
8442        let ast = parse::parse("SELECT (INSERT Person { name := 'Alice' }) { name } FOR UPDATE").expect("parse failed");
8443        let err = ir::compile(&ast, &make_schema())
8444            .err()
8445            .expect("expected a compile error");
8446        assert!(err.to_string().contains("INSERT"), "unexpected: {err}");
8447    }
8448
8449    #[test]
8450    fn test_for_update_on_an_interface_type_is_rejected() {
8451        fn id_prop() -> PropertyDescriptor {
8452            PropertyDescriptor {
8453                name: "id".into(),
8454                pg_type: "uuid".into(),
8455                nullable: false,
8456                default_sql: Some("uuidv7()".into()),
8457                default_pyql: None,
8458                description: None,
8459                check_constraints: vec![],
8460                is_exclusive: true,
8461                is_pk: true,
8462                is_readonly: true,
8463                rewrites: vec![],
8464                tuple_members: None,
8465                column_type: None,
8466            }
8467        }
8468        let schema = SchemaDescriptor {
8469            types: vec![
8470                TypeDescriptor {
8471                    name: "Account".into(),
8472                    module: "default".into(),
8473                    table: "Account".into(),
8474                    abstract_: true,
8475                    materialized: true,
8476                    description: None,
8477                    parents: vec![],
8478                    interfaces: vec![],
8479                    bases: vec![],
8480                    properties: vec![id_prop()],
8481                    links: vec![],
8482                    multilinks: vec![],
8483                    computed: vec![],
8484                    constraints: vec![],
8485                    indexes: vec![],
8486                    partition: None,
8487                    vector_indexes: vec![],
8488                    search_indexes: vec![],
8489                    triggers: vec![],
8490                    junction: false,
8491                    signals: vec![],
8492                },
8493                TypeDescriptor {
8494                    name: "Individual".into(),
8495                    module: "default".into(),
8496                    table: "Individual".into(),
8497                    abstract_: false,
8498                    materialized: true,
8499                    description: None,
8500                    parents: vec![],
8501                    interfaces: vec!["default::Account".into()],
8502                    bases: vec![],
8503                    properties: vec![id_prop()],
8504                    links: vec![],
8505                    multilinks: vec![],
8506                    computed: vec![],
8507                    constraints: vec![],
8508                    indexes: vec![],
8509                    partition: None,
8510                    vector_indexes: vec![],
8511                    search_indexes: vec![],
8512                    triggers: vec![],
8513                    junction: false,
8514                    signals: vec![],
8515                },
8516            ],
8517            scalars: vec![],
8518            enums: vec![],
8519            named_tuples: vec![],
8520            globals: vec![],
8521            functions: vec![],
8522            aliases: vec![],
8523            channels: vec![],
8524            ..Default::default()
8525        };
8526        let ast = parse::parse("SELECT Account FOR UPDATE").expect("parse failed");
8527        let err = ir::compile(&ast, &schema).err().expect("expected a compile error");
8528        assert!(err.to_string().contains("interface"), "unexpected: {err}");
8529    }
8530
8531    #[test]
8532    fn test_schema_select_distinct_emits_distinct_keyword() {
8533        // Regression: compile_stmt's Distinct/Detached unwrap and
8534        // compile_select's own re-derivation of the same unwrap were merged
8535        // into a single pass-through (Phase 2 of the compile_expr merge) —
8536        // confirm `select distinct` still compiles and emits DISTINCT.
8537        let out = compile_and_emit("SELECT DISTINCT Person { name }");
8538        assert!(out.sql.contains("DISTINCT"), "expected DISTINCT in SQL:\n{}", out.sql);
8539    }
8540
8541    #[test]
8542    fn test_schema_select_detached_compiles_as_ordinary_select() {
8543        // Same merge — `detached` at the top level of a schema select is a
8544        // no-op (already independent); confirm it still compiles cleanly
8545        // instead of erroring or double-unwrapping.
8546        let out = compile_and_emit("SELECT DETACHED Person { name }");
8547        assert!(
8548            out.sql.contains("\"name\""),
8549            "expected name column in SQL:\n{}",
8550            out.sql
8551        );
8552    }
8553
8554    #[test]
8555    fn test_detached_subquery_correlates_against_the_outer_row() {
8556        // The anti-join idiom: "the Person with no other Person of the same
8557        // name that is younger". `detached` is what makes the inner `Person` a
8558        // different row from the outer one, so the bare `Person.name` inside
8559        // has to resolve to the *outer* alias. It used to be stripped as a
8560        // no-op, which compiled to `t1.name = t1.name` — a tautology that
8561        // quietly matched every row instead of erroring.
8562        let out = compile_and_emit(
8563            "SELECT Person { name } FILTER NOT EXISTS (\
8564             SELECT DETACHED Person FILTER .name = Person.name AND .age > Person.age)",
8565        );
8566        assert!(
8567            out.sql.contains("\"t1\".\"name\" = \"t0\".\"name\""),
8568            "the inner row should be compared against the outer one:\n{}",
8569            out.sql
8570        );
8571        assert!(
8572            !out.sql.contains("\"t1\".\"name\" = \"t1\".\"name\""),
8573            "self-comparison means the correlation was lost:\n{}",
8574            out.sql
8575        );
8576    }
8577
8578    #[test]
8579    fn test_an_assert_on_a_pointer_checks_its_rows() {
8580        // The pointer's own subquery is bound once and read back through the
8581        // check, rather than appearing in both — and the cast is what makes
8582        // the call legal at all, since PL/pgSQL cannot take a `record[]`.
8583        let out = compile_and_emit("SELECT Person { name, p := assert_exists(.posts { title }) }");
8584        assert!(
8585            out.sql
8586                .contains(r#"cardinality("_pylon"."assert_exists"("_a"."v"::text[]))"#),
8587            "the assert should read the aggregated rows:\n{}",
8588            out.sql
8589        );
8590        assert_eq!(
8591            out.sql.matches("array_agg(ROW(").count(),
8592            1,
8593            "the pointer's subquery should be evaluated once, not once per use:\n{}",
8594            out.sql
8595        );
8596    }
8597
8598    #[test]
8599    fn test_an_assert_on_a_scalar_stays_an_ordinary_call() {
8600        // Only an object pointer takes the wrapper; `assert_exists(.name)` is
8601        // a value expression and must keep compiling as the function call it is.
8602        let out = compile_and_emit("SELECT Person { n := assert_exists(.name) }");
8603        assert!(
8604            !out.sql.contains(r#""_a"."v""#),
8605            "a scalar assert should not be wrapped as a pointer check:\n{}",
8606            out.sql
8607        );
8608    }
8609
8610    #[test]
8611    fn test_a_backlink_tail_walks_past_the_first_step() {
8612        // `.<link[is T].multilink.prop` — the tail after a backlink was read
8613        // one arity at a time (property, or link-then-property), so anything
8614        // crossing a multi-link had no single column to read and was refused.
8615        // Counted against the migrated data it agreed with Gel: 4 and 4.
8616        let out = compile_and_emit("SELECT Post FILTER any(.<posts[is Person].posts.title = $t)");
8617        assert!(
8618            out.sql.contains(r#""public"."Person.posts""#),
8619            "the tail should walk the multi-link's junction:\n{}",
8620            out.sql
8621        );
8622    }
8623
8624    #[test]
8625    fn test_an_intersection_narrows_the_rest_of_a_multilink_walk() {
8626        // `.posts[is Post].title` — the narrowed type's own table is what the
8627        // remaining steps join to, so a row of any other type drops out
8628        // without a separate check. Agreed with Gel on real data (1 and 1).
8629        let out = compile_and_emit("SELECT Person FILTER any(.posts[is Post].title = $t)");
8630        assert!(
8631            out.sql.contains(r#""public"."Post""#),
8632            "the walk should continue from the narrowed type:\n{}",
8633            out.sql
8634        );
8635    }
8636
8637    #[test]
8638    fn test_a_union_of_bindings_emits_selects_not_bare_names() {
8639        // A CTE reference normally emits just its quoted name, which works
8640        // where the caller aliases it into a FROM. As a `UNION ALL` operand it
8641        // has to be a SELECT of its own — `("a" UNION ALL "b")` is not SQL.
8642        let out = compile_and_emit_with(
8643            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
8644             SELECT (INSERT Person { name := $n, posts := (a UNION b) })",
8645            &make_schema(),
8646        );
8647        assert!(
8648            out.sql.contains(r#"(SELECT "_s"."id" FROM "a" AS "_s")"#),
8649            "each union operand should be a select:\n{}",
8650            out.sql
8651        );
8652    }
8653
8654    #[test]
8655    fn test_a_bare_narrowing_reads_as_a_type_check_and_as_an_object() {
8656        // `exists [is Sub]` is whether the row *is* one; `x := [is Sub] { … }`
8657        // is that row read as it. Both used to fall off the end of the path
8658        // walk ("invalid path step" / "empty path traversal").
8659        for query in [
8660            "SELECT Person FILTER EXISTS [is Person]",
8661            "SELECT Person { p := [is Person] { name } }",
8662        ] {
8663            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query}: {e}"));
8664            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
8665        }
8666    }
8667
8668    #[test]
8669    fn test_a_multilink_takes_a_set_wrapped_in_a_select() {
8670        // `translations := (select { a, b })` — a select with no clauses of its
8671        // own is the expression it wraps. Left wrapped, a free row source (a
8672        // set literal, a union) reached the generic subquery arm and was
8673        // refused, while the identical unwrapped `{ a, b }` compiled.
8674        let out = compile_and_emit_with(
8675            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
8676             SELECT (INSERT Person { name := $n, posts := (SELECT { a, b }) })",
8677            &make_schema(),
8678        );
8679        assert!(
8680            out.sql.contains(r#""public"."Person.posts""#),
8681            "both targets should reach the junction:\n{}",
8682            out.sql
8683        );
8684    }
8685
8686    #[test]
8687    fn test_an_aggregate_over_a_subselected_path() {
8688        // `array_agg((select Person.name))` — the sub-select walks to a scalar,
8689        // which has no type name to be a schema select's subject. It means the
8690        // same as `array_agg(Person.name)` with the select's own modifiers on
8691        // the walk, which the path-select builder already handles. Checked
8692        // against Gel's own answer, element for element.
8693        let out = compile_and_emit("SELECT array_agg((SELECT Person.name))");
8694        assert!(
8695            out.sql.contains("array_agg") && out.sql.contains(r#""public"."Person""#),
8696            "the aggregate should wrap the walk's column:\n{}",
8697            out.sql
8698        );
8699    }
8700
8701    #[test]
8702    fn test_a_distinct_aggregate_argument_reaches_the_aggregate() {
8703        // `array_agg(distinct x)` used to emit `array_agg(x)`: the unary
8704        // `distinct` emits its operand unchanged, because it is normally
8705        // applied where the set is built — which an aggregate argument is not.
8706        // Both spellings mean the same thing and both were checked against
8707        // Gel, which returns one element where the old output returned two.
8708        for query in [
8709            "SELECT array_agg(DISTINCT Person.name)",
8710            "SELECT array_agg((SELECT DISTINCT Person.name))",
8711        ] {
8712            let out = compile_and_emit(query);
8713            assert!(
8714                out.sql.contains("array_agg(DISTINCT "),
8715                "{query} should deduplicate inside the aggregate:\n{}",
8716                out.sql
8717            );
8718        }
8719    }
8720
8721    #[test]
8722    fn test_is_not_negates_the_type_check() {
8723        // `x IS NOT T` is Gel's own `Expr IS NOT TypeExpr` — the negation of
8724        // the check, not a comparison against some type `not T`. Counted
8725        // against the migrated data it came out as 3 of 3, matching Gel.
8726        let positive = compile_and_emit("SELECT Person FILTER Person IS Person");
8727        let negative = compile_and_emit("SELECT Person FILTER Person IS NOT Person");
8728        assert!(
8729            negative.sql.contains("NOT") && !positive.sql.contains("NOT"),
8730            "the negation should reach the SQL:\npositive:\n{}\nnegative:\n{}",
8731            positive.sql,
8732            negative.sql
8733        );
8734    }
8735
8736    #[test]
8737    fn test_a_cast_takes_a_prefix_operator_as_its_operand() {
8738        // `<bool>exists x` is valid EdgeQL — Gel's cast takes a whole `Expr` at
8739        // CAST precedence. Pylon's cast went straight to a postfix expression,
8740        // so any prefix operator after one failed to parse.
8741        for query in ["SELECT <bool>EXISTS (SELECT Person LIMIT 1)", "SELECT <int64>-1"] {
8742            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query} should parse: {e}"));
8743            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
8744        }
8745    }
8746
8747    #[test]
8748    fn test_a_bare_multilink_reads_as_a_set() {
8749        // `p in .posts` — a multi-link read as a value, not traversed through.
8750        // The single-step resolver knew properties, links and computeds but
8751        // never multi-links, so the pointer reported itself as unknown while
8752        // the suggester, which does know them, offered the name straight back.
8753        let out =
8754            compile_and_emit("WITH p := (SELECT DETACHED Post LIMIT 1) SELECT Person { name } FILTER p IN .posts");
8755        assert!(
8756            out.sql.contains(r#""public"."Person.posts""#),
8757            "the membership test should read the link's junction:\n{}",
8758            out.sql
8759        );
8760    }
8761
8762    #[test]
8763    fn test_a_select_reads_its_own_shape_computed_in_its_clauses() {
8764        // `select T { x := … } order by .x` — Gel puts a shape's computeds in
8765        // scope for the select's own clauses (but not for a sibling pointer,
8766        // which it rejects, so the scope stops at the clauses).
8767        let out = compile_and_emit("SELECT Person { n := .name } ORDER BY .n ASC");
8768        assert!(
8769            out.sql.contains("ORDER BY"),
8770            "the order by should compile against the declared pointer:\n{}",
8771            out.sql
8772        );
8773    }
8774
8775    #[test]
8776    fn test_a_declared_pointer_can_be_walked_through() {
8777        // `p { boss := … }` read back as `.boss.name` — the walk builder
8778        // resolved a *schema* computed as a step but not one a shape in scope
8779        // declared, so anything past the first step reported it as unknown.
8780        let out = compile_and_emit_with(
8781            "WITH p := (SELECT Person { boss := .company } LIMIT 1) SELECT p { name, boss_name := .boss.name }",
8782            &make_schema(),
8783        );
8784        assert!(
8785            out.sql.contains(r#""public"."Company""#),
8786            "the walk should reach the declared pointer's own target table:\n{}",
8787            out.sql
8788        );
8789    }
8790
8791    #[test]
8792    fn test_a_replaced_subject_shape_stays_in_scope() {
8793        // `(select .company { n := .name }) { n }` — a shape at the point of
8794        // use replaces the subject's own, so what that one declared has to
8795        // stay readable or the replacement cannot see it. Before, this was an
8796        // outright compile error.
8797        let out = compile_and_emit("SELECT Person { c := (SELECT .company { n := .name }) { n } }");
8798        assert!(
8799            out.sql.contains(r#"'default::Company'::text"#) && out.sql.contains(r#""t2"."name""#),
8800            "`n` should resolve to the company's own column:\n{}",
8801            out.sql
8802        );
8803    }
8804
8805    #[test]
8806    fn test_a_sibling_shape_pointer_is_not_in_scope() {
8807        // Gel rejects one shape pointer reading another; matching that is what
8808        // keeps four graph queries reported as the application bugs they are.
8809        let schema = make_schema();
8810        let ast = parse::parse("SELECT Person { n := .name, copy := .n }").unwrap();
8811        assert!(ir::compile(&ast, &schema).is_err());
8812    }
8813
8814    #[test]
8815    fn test_shape_over_a_with_binding_reads_the_binding() {
8816        // `metadata := metadata { … }` — a binding names a row source, so a
8817        // shape on it projects that source. The pointer route only took
8818        // relative paths, so this fell through to the expression route and was
8819        // rejected as "shapes are not valid in expression context".
8820        let out = compile_and_emit("WITH c := (SELECT Company LIMIT 1) SELECT Person { name, employer := c { name } }");
8821        assert!(
8822            out.sql.contains("\"c\""),
8823            "the pointer should read the binding's CTE:\n{}",
8824            out.sql
8825        );
8826    }
8827
8828    #[test]
8829    fn test_shape_over_a_bare_type_name_is_still_rejected() {
8830        // Unlike a binding, a type name in an enclosing scope is a factored
8831        // prefix, so a shape on one does not mean "every row of the table".
8832        let schema = make_schema();
8833        let ast = parse::parse("SELECT Person { name, other := Company { name } }").unwrap();
8834        assert!(ir::compile(&ast, &schema).is_err());
8835    }
8836
8837    #[test]
8838    fn test_nested_shape_reads_a_prefix_bound_by_the_outer_select() {
8839        // `Person.name` inside the `posts` shape names a type the enclosing
8840        // select binds, so EdgeQL reads the outer row — not every Person. The
8841        // prefix used to be rejected outright because only the *innermost*
8842        // subject was checked.
8843        let out = compile_and_emit("SELECT Person { name, posts: { title, who := Person.name } }");
8844        assert!(
8845            out.sql.contains(r#""t0"."name""#),
8846            "the nested computed should read the outer Person alias:\n{}",
8847            out.sql
8848        );
8849        assert!(
8850            !out.sql.contains(r#"FROM "public"."Person" "t2""#),
8851            "the prefix should not open a second Person source:\n{}",
8852            out.sql
8853        );
8854    }
8855
8856    #[test]
8857    fn test_prefix_naming_a_type_out_of_scope_is_rejected() {
8858        // Nothing binds `Company` here, so the prefix stays an absolute path —
8859        // Pylon must not silently read the whole table.
8860        let schema = make_schema();
8861        let ast = parse::parse("SELECT Person { name, who := Company.name }").unwrap();
8862        assert!(ir::compile(&ast, &schema).is_err());
8863    }
8864
8865    #[test]
8866    fn test_free_select_tuple() {
8867        let schema = make_schema();
8868        let ast = parse::parse("SELECT (1, 2)").unwrap();
8869        let ir = ir::compile(&ast, &schema).unwrap();
8870        let out = emit(&ir);
8871        assert!(out.sql.contains("1"));
8872        assert!(out.sql.contains("2"));
8873        assert!(out.sql.contains("AS result"));
8874        assert!(matches!(out.shape.root, crate::query::ShapeNode::Tuple { .. }));
8875    }
8876
8877    #[test]
8878    fn test_free_select_scalar_literal() {
8879        let schema = make_schema();
8880        let ast = parse::parse("SELECT 'hello'").unwrap();
8881        let ir = ir::compile(&ast, &schema).unwrap();
8882        let out = emit(&ir);
8883        assert!(out.sql.contains("SELECT 'hello' AS v"));
8884        assert!(out.sql.contains("ROW(v) AS result"));
8885        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
8886    }
8887
8888    #[test]
8889    fn test_float_literal_casts_to_float8() {
8890        // Regression: an un-cast float literal like `1.0` is untyped in
8891        // Postgres and defaults to `numeric`, not `float64` — silently
8892        // changing PyQL's semantics (only an explicit `<decimal>`/`123n`
8893        // literal should ever produce a real decimal) and breaking decode
8894        // inside ROW() composites (numeric's wire format differs from
8895        // float8's). An explicit ::float8 cast keeps it a real float.
8896        let out = compile_and_emit("SELECT 1.0");
8897        assert!(
8898            out.sql.contains("(1.0::float8)"),
8899            "expected explicit float8 cast, got:\n{}",
8900            out.sql
8901        );
8902    }
8903
8904    #[test]
8905    fn test_free_select_array_literal() {
8906        let schema = make_schema();
8907        let ast = parse::parse("SELECT [1, 2, 3]").unwrap();
8908        let ir = ir::compile(&ast, &schema).unwrap();
8909        let out = emit(&ir);
8910        assert!(out.sql.contains("SELECT ARRAY[1, 2, 3] AS v"), "{}", out.sql);
8911        assert!(out.sql.contains("SELECT v AS result, v FROM"), "{}", out.sql);
8912        assert!(matches!(out.shape.root, crate::query::ShapeNode::RawScalar));
8913    }
8914
8915    #[test]
8916    fn test_select_scalars() {
8917        let out = compile_and_emit("SELECT Person { name, age }");
8918        assert!(out.sql.contains("'default::Person'::text"));
8919        assert!(out.sql.contains("\"name\"::text"));
8920        assert!(out.sql.contains("\"age\"::int8"));
8921        assert!(out.sql.contains("FROM \"public\".\"Person\""));
8922        assert!(out.sql.contains(") AS result"));
8923    }
8924
8925    #[test]
8926    fn test_select_filter_param() {
8927        let out = compile_and_emit("SELECT Person { name } FILTER .name = $name");
8928        assert!(out.sql.contains("WHERE"));
8929        assert!(out.sql.contains("$1"));
8930    }
8931
8932    #[test]
8933    fn test_filter_in_set_literal_compiles_to_any_array() {
8934        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol'}");
8935        assert!(out.sql.contains("= ANY(ARRAY['Carol'])"), "got:\n{}", out.sql);
8936    }
8937
8938    #[test]
8939    fn test_filter_in_multi_element_set_literal_compiles_to_any_array() {
8940        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol', 'Bob'}");
8941        assert!(out.sql.contains("= ANY(ARRAY['Carol', 'Bob'])"), "got:\n{}", out.sql);
8942    }
8943
8944    /// `x IN std::array_unpack(arr)` is the documented way to test membership
8945    /// of an array parameter. It used to emit `= ANY(unnest(arr))` — a
8946    /// set-returning function inside `WHERE`, which PostgreSQL rejects at
8947    /// execution time (`set-returning functions are not allowed in WHERE`)
8948    /// even though both the PyQL and the SQL parse cleanly. `ANY` takes the
8949    /// array directly, so the `unnest` has to go.
8950    #[test]
8951    fn test_filter_in_array_unpack_passes_the_array_straight_to_any() {
8952        let out = compile_and_emit("SELECT Person { name } FILTER .name IN std::array_unpack(<array<str>>$names)");
8953        assert!(
8954            !out.sql.contains("ANY(unnest("),
8955            "unnest inside ANY is invalid in WHERE:\n{}",
8956            out.sql
8957        );
8958        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
8959    }
8960
8961    #[test]
8962    fn test_filter_not_in_array_unpack_passes_the_array_straight_to_all() {
8963        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN std::array_unpack(<array<str>>$names)");
8964        assert!(
8965            !out.sql.contains("ALL(unnest("),
8966            "unnest inside ALL is invalid in WHERE:\n{}",
8967            out.sql
8968        );
8969        assert!(out.sql.contains("<> ALL("), "got:\n{}", out.sql);
8970    }
8971
8972    /// `all(array_unpack($t) in .posts.title)` quantifies over the unpacked
8973    /// array, not over `.posts`. Read as a condition on one `.posts` element
8974    /// it would both ask the opposite question and leave the `unnest()` in a
8975    /// `WHERE`, which PostgreSQL rejects at execution time.
8976    #[test]
8977    fn test_all_over_unpacked_array_aggregates_the_array_not_the_multilink() {
8978        let out = compile_and_emit(
8979            "SELECT Person { name } FILTER all(std::array_unpack(<array<str>>$titles) IN .posts.title)",
8980        );
8981        assert!(out.sql.contains("bool_and("), "got:\n{}", out.sql);
8982        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
8983    }
8984
8985    /// The mirror of the above: the multilink is what is quantified over and
8986    /// the array is only the haystack, so `IN` still folds into `= ANY(array)`
8987    /// and nothing unnests.
8988    #[test]
8989    fn test_any_over_a_multilink_against_an_unpacked_array_still_folds_into_any() {
8990        let out = compile_and_emit(
8991            "SELECT Person { name } FILTER any(.posts.title IN std::array_unpack(<array<str>>$titles))",
8992        );
8993        assert!(!out.sql.contains("unnest("), "got:\n{}", out.sql);
8994        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
8995    }
8996
8997    #[test]
8998    fn test_any_over_unpacked_array_aggregates_the_array_not_the_multilink() {
8999        let out = compile_and_emit(
9000            "SELECT Person { name } FILTER any(std::array_unpack(<array<str>>$titles) IN .posts.title)",
9001        );
9002        assert!(out.sql.contains("bool_or("), "got:\n{}", out.sql);
9003        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
9004    }
9005
9006    /// A schema with a `text[]` property, which `make_schema` has none of.
9007    fn schema_with_array_property() -> SchemaDescriptor {
9008        let mut schema = make_schema();
9009        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
9010            name: "perms".into(),
9011            pg_type: "text[]".into(),
9012            nullable: true,
9013            default_sql: None,
9014            default_pyql: None,
9015            description: None,
9016            check_constraints: vec![],
9017            is_exclusive: false,
9018            is_pk: false,
9019            is_readonly: false,
9020            rewrites: vec![],
9021            tuple_members: None,
9022            column_type: None,
9023        });
9024        schema
9025    }
9026
9027    /// `array_agg(array_unpack(teams.perms))` flattens every row's array into
9028    /// one. Compiled as written it emitted `array_agg(unnest(...))`, which
9029    /// PostgreSQL rejects at execution time ("aggregate function calls cannot
9030    /// contain set-returning function calls"), so the unpacking has to happen
9031    /// in a row source the aggregate reads from outside.
9032    #[test]
9033    fn test_aggregate_over_an_unpacked_walk_unnests_in_a_row_source() {
9034        let out = compile_and_emit_with(
9035            "WITH teams := (SELECT Person { perms } FILTER .age > 1) SELECT std::array_agg(std::array_unpack(teams.perms))",
9036            &schema_with_array_property(),
9037        );
9038        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9039        assert!(
9040            out.sql.contains(
9041                r#"coalesce(array_agg("_s"."v"), '{}') FROM unnest(ARRAY(SELECT unnest("t1"."perms") FROM "teams" AS "t1"))"#
9042            ),
9043            "got:\n{}",
9044            out.sql
9045        );
9046    }
9047
9048    #[test]
9049    fn test_aggregate_over_an_unpacked_type_rooted_path_unnests_in_a_row_source() {
9050        let out = compile_and_emit_with(
9051            "SELECT std::count(std::array_unpack(Person.perms))",
9052            &schema_with_array_property(),
9053        );
9054        assert!(!out.sql.contains("count(unnest("), "got:\n{}", out.sql);
9055        assert!(
9056            out.sql
9057                .contains(r#"FROM unnest(ARRAY(SELECT unnest("t0"."perms") FROM "public"."Person" AS "t0"))"#),
9058            "got:\n{}",
9059            out.sql
9060        );
9061    }
9062
9063    /// One array standing on its own — a parameter, or one row's property —
9064    /// is already the row source, so it needs no walk gathered around it.
9065    #[test]
9066    fn test_aggregate_over_an_unpacked_parameter_unnests_the_array_itself() {
9067        let out = compile_and_emit("SELECT std::array_agg(std::array_unpack(<array<str>>$names))");
9068        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9069        assert!(
9070            out.sql.contains(r#"FROM unnest(($1)::text[]) AS "_s"("v")"#),
9071            "got:\n{}",
9072            out.sql
9073        );
9074    }
9075
9076    #[test]
9077    fn test_aggregate_over_an_unpacked_property_unnests_that_row_s_array() {
9078        let out = compile_and_emit_with(
9079            "SELECT Person { n := std::array_agg(std::array_unpack(.perms)) }",
9080            &schema_with_array_property(),
9081        );
9082        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9083        assert!(
9084            out.sql.contains(r#"FROM unnest("t0"."perms") AS "_s"("v")"#),
9085            "got:\n{}",
9086            out.sql
9087        );
9088    }
9089
9090    /// `array_agg` over no rows is `[]` in Gel and NULL in SQL, which reaches
9091    /// a caller as a missing value where a list was promised.
9092    #[test]
9093    fn test_array_agg_over_nothing_is_an_empty_array_not_null() {
9094        for query in [
9095            "SELECT std::array_agg(Person.name)",
9096            "SELECT std::array_agg((SELECT Person).name)",
9097        ] {
9098            let out = compile_and_emit(query);
9099            assert!(
9100                out.sql.contains("coalesce(") && out.sql.contains("'{}'"),
9101                "{query} got:\n{}",
9102                out.sql
9103            );
9104        }
9105    }
9106
9107    /// The `coalesce` that empty array wears must not hide what the binding
9108    /// holds: `contains(ids, .id)` picks its overload off the binding's type,
9109    /// and an array read as text silently becomes a substring search.
9110    #[test]
9111    fn test_an_array_agg_binding_stays_an_array_through_its_coalesce() {
9112        let out = compile_and_emit(
9113            "WITH ids := std::array_agg(Person.id) SELECT Person { name } FILTER std::contains(ids, .id)",
9114        );
9115        assert!(
9116            out.sql.contains("@> ARRAY["),
9117            "expected the array overload, got:\n{}",
9118            out.sql
9119        );
9120        assert!(
9121            !out.sql.contains("strpos("),
9122            "the string overload must not win, got:\n{}",
9123            out.sql
9124        );
9125    }
9126
9127    /// An aggregate over a walk that is *not* unpacked keeps its own row
9128    /// source — the rewrite above must not reach it.
9129    #[test]
9130    fn test_aggregate_over_a_plain_walk_keeps_its_row_source() {
9131        let out = compile_and_emit("SELECT std::array_agg(Person.name)");
9132        assert!(
9133            out.sql.contains(r#"array_agg("t0"."name")"#) && out.sql.contains(r#"FROM "public"."Person""#),
9134            "got:\n{}",
9135            out.sql
9136        );
9137    }
9138
9139    /// The order check is a safety net for every emitter that assembles a
9140    /// WITH clause, so it has to actually catch one.
9141    #[test]
9142    fn test_the_cte_order_check_catches_a_forward_reference() {
9143        let bad = "WITH \"b\" AS (\n SELECT * FROM \"a\"\n),\n\"a\" AS (\n SELECT 1\n)\nSELECT 1";
9144        assert!(
9145            super::forward_cte_reference(bad).is_some(),
9146            "expected a forward reference"
9147        );
9148        let good = "WITH \"a\" AS (\n SELECT 1\n),\n\"b\" AS (\n SELECT * FROM \"a\"\n)\nSELECT 1";
9149        assert_eq!(super::forward_cte_reference(good), None, "ordered CTEs must pass");
9150    }
9151
9152    /// `.status IN <Enum>array_unpack($1)` — the cast sits between `IN` and
9153    /// the unpack, and used to hide it, leaving a set-returning function in a
9154    /// `WHERE` that PostgreSQL rejects at execution time.
9155    #[test]
9156    fn test_a_cast_unpacked_array_still_folds_into_any() {
9157        let out = compile_and_emit("SELECT Person { name } FILTER .name IN <str>std::array_unpack(<array<str>>$names)");
9158        assert!(
9159            !out.sql.contains("unnest("),
9160            "the unpack must fold into ANY, got:\n{}",
9161            out.sql
9162        );
9163        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9164    }
9165
9166    /// A bare `array_unpack` outside `IN` still has to unnest — the unwrap is
9167    /// specific to `ANY`/`ALL`, which take an array rather than a set.
9168    #[test]
9169    fn test_array_unpack_outside_in_still_unnests() {
9170        let out = compile_and_emit("SELECT std::array_unpack(<array<str>>$names)");
9171        assert!(out.sql.contains("unnest("), "got:\n{}", out.sql);
9172    }
9173
9174    #[test]
9175    fn test_filter_not_in_set_literal_compiles_to_all_array() {
9176        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN {'Carol'}");
9177        assert!(out.sql.contains("<> ALL(ARRAY['Carol'])"), "got:\n{}", out.sql);
9178    }
9179
9180    #[test]
9181    fn test_deep_splat_fetches_single_link_target_properties_not_just_id() {
9182        // `**` on a single link must expand to the target type's own
9183        // properties (one level of `*`, not `**` again — recursing into
9184        // the target's own links would never terminate for a cyclic link
9185        // graph), not just an implicit `{ id }`.
9186        let out = compile_and_emit("SELECT Person { ** }");
9187        assert!(
9188            out.sql.contains("\"name\""),
9189            "expected Company.name pulled in via .company's ** expansion, got:\n{}",
9190            out.sql
9191        );
9192    }
9193
9194    #[test]
9195    fn test_deep_splat_fetches_multilink_target_properties_not_just_id() {
9196        let out = compile_and_emit("SELECT Person { ** }");
9197        assert!(
9198            out.sql.contains("\"title\""),
9199            "expected Post.title pulled in via .posts' ** expansion, got:\n{}",
9200            out.sql
9201        );
9202    }
9203
9204    #[test]
9205    fn test_schema_type_cast_select() {
9206        let out = compile_and_emit("SELECT <default::Person><uuid>'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'");
9207        assert!(out.sql.contains("FROM \"public\".\"Person\""));
9208        assert!(out.sql.contains("WHERE"));
9209        assert!(out.sql.contains("'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'"));
9210    }
9211
9212    #[test]
9213    fn test_select_single_link() {
9214        let out = compile_and_emit("SELECT Person { name, company { name } }");
9215        assert!(out.sql.contains("'default::Company'::text"));
9216        assert!(out.sql.contains("FROM \"public\".\"Company\""));
9217        // join condition: parent FK column = target PK
9218        assert!(out.sql.contains("\"company_id\" = "));
9219    }
9220
9221    #[test]
9222    fn test_select_multi_link() {
9223        let out = compile_and_emit("SELECT Person { name, posts { title } }");
9224        assert!(out.sql.contains("array_agg(ROW("));
9225        assert!(out.sql.contains("ARRAY[]::record[]"));
9226        assert!(out.sql.contains("'default::Post'::text"));
9227        assert!(out.sql.contains("\"Person.posts\""));
9228    }
9229
9230    fn compile_err(query: &str) -> String {
9231        let schema = make_schema();
9232        let ast = parse::parse(query).expect("parse failed");
9233        match ir::compile(&ast, &schema) {
9234            Ok(_) => panic!("expected a compile error"),
9235            Err(e) => format!("{e}"),
9236        }
9237    }
9238
9239    #[test]
9240    fn test_multi_link_limit_cuts_rows_before_aggregating() {
9241        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' limit 1 }");
9242        // The LIMIT has to sit on the row source, not next to array_agg —
9243        // the aggregate has already collapsed the rows by then.
9244        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9245        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
9246        let agg = out.sql.find("array_agg").unwrap();
9247        let limit = out.sql.find("LIMIT 1").unwrap();
9248        assert!(agg < limit, "{}", out.sql);
9249        assert!(out.sql.contains("\"t1\".\"title\"::text AS \"c1\""), "{}", out.sql);
9250    }
9251
9252    #[test]
9253    fn test_multi_link_order_by_with_limit_orders_the_aggregate_too() {
9254        let out = compile_and_emit("SELECT Person { posts: { title } order by .title desc offset 1 limit 2 }");
9255        // The sort key rides along as its own column so the array keeps the
9256        // derived table's order rather than relying on aggregation order.
9257        assert!(out.sql.contains("\"t1\".\"title\" AS \"s0\""), "{}", out.sql);
9258        assert!(
9259            out.sql.contains("ORDER BY \"__lim\".\"s0\" DESC NULLS LAST"),
9260            "{}",
9261            out.sql
9262        );
9263        assert!(out.sql.contains("OFFSET 1"), "{}", out.sql);
9264        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
9265    }
9266
9267    #[test]
9268    fn test_multi_link_without_limit_keeps_the_flat_aggregate() {
9269        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' }");
9270        assert!(!out.sql.contains("__lim"), "{}", out.sql);
9271        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
9272    }
9273
9274    #[test]
9275    fn test_sub_select_as_computed_pointer_is_a_link_pointer() {
9276        let out = compile_and_emit("SELECT Person { recent := (select .posts filter .title = 'x' limit 1) { title } }");
9277        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9278            panic!()
9279        };
9280        // Named after the pointer, not after the link it selects from, and
9281        // one object: `limit 1` makes the sub-select at most one row.
9282        let ShapeNode::Object {
9283            name,
9284            pointers: elem_pointers,
9285            ..
9286        } = &pointers[2]
9287        else {
9288            panic!("{:?}", pointers[2])
9289        };
9290        assert_eq!(name, "recent");
9291        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
9292        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
9293        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9294    }
9295
9296    #[test]
9297    fn test_sub_select_shape_inside_the_parens_is_equivalent() {
9298        let outer = compile_and_emit("SELECT Person { recent := (select .posts limit 1) { title } }");
9299        let inner = compile_and_emit("SELECT Person { recent := (select .posts { title } limit 1) }");
9300        assert_eq!(outer.sql, inner.sql);
9301    }
9302
9303    #[test]
9304    fn test_sub_select_field_access_is_a_correlated_scalar_subquery() {
9305        let out = compile_and_emit("SELECT Person { t := (select .posts filter .title = 'x' limit 1).title }");
9306        // Rooted at the enclosing type and correlated back to its row, so
9307        // the subquery only ever sees this Person's posts.
9308        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9309        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
9310        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9311        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9312            panic!()
9313        };
9314        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, .. } if name == "t"));
9315    }
9316
9317    #[test]
9318    fn test_sub_select_field_access_over_a_single_link() {
9319        let out = compile_and_emit("SELECT Person { c := (select .company).name }");
9320        assert!(out.sql.contains("\"t1\".\"company_id\" = \"t2\".\"id\""), "{}", out.sql);
9321        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9322    }
9323
9324    #[test]
9325    fn test_sub_select_field_access_in_a_filter() {
9326        let out = compile_and_emit("SELECT Person { name } filter (select .posts limit 1).title = 'x'");
9327        assert!(out.sql.contains("WHERE ((SELECT \"t2\".\"title\""), "{}", out.sql);
9328        assert!(out.sql.contains("LIMIT 1) = 'x')"), "{}", out.sql);
9329    }
9330
9331    #[test]
9332    fn test_sub_select_over_an_absolute_path_is_not_correlated() {
9333        let out = compile_and_emit("SELECT Person { n := (select Company.name limit 1) }");
9334        assert!(out.sql.contains("FROM \"public\".\"Company\""), "{}", out.sql);
9335        assert!(!out.sql.contains("= \"t0\".\"id\""), "{}", out.sql);
9336    }
9337
9338    #[test]
9339    fn test_backlink_sub_select_carries_its_modifiers() {
9340        let out = compile_and_emit("SELECT Post { authors := (select .<posts[is Person] { name } limit 2) }");
9341        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
9342        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
9343        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9344            panic!()
9345        };
9346        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
9347    }
9348
9349    #[test]
9350    fn test_with_bound_scalar_is_typed_for_overload_resolution() {
9351        // A `with`-bound value had no inferred type, so a call over one fell
9352        // back to the first registered overload and needed an explicit cast.
9353        let out = compile_and_emit("WITH xs := (select Person.name) SELECT contains(xs, 'a')");
9354        assert!(
9355            out.sql.contains("strpos((SELECT \"v\" FROM \"xs\"), 'a')"),
9356            "{}",
9357            out.sql
9358        );
9359    }
9360
9361    /// `Person` with a translation-style computed model: one computed
9362    /// resolves a filtered subset of a multi-link, others read through it.
9363    fn make_schema_with_computed_links() -> SchemaDescriptor {
9364        use crate::schema::ComputedDescriptor;
9365        let mut schema = make_schema();
9366        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9367        person.computed = vec![
9368            ComputedDescriptor {
9369                name: "published".into(),
9370                expression: "(select .posts filter .title != '')".into(),
9371                return_type: None,
9372                link_target: None,
9373                link_multi: false,
9374            },
9375            ComputedDescriptor {
9376                name: "plain".into(),
9377                expression: ".posts".into(),
9378                return_type: None,
9379                link_target: None,
9380                link_multi: false,
9381            },
9382            ComputedDescriptor {
9383                name: "featured".into(),
9384                expression: "(select .published filter .title != 'draft')".into(),
9385                return_type: None,
9386                link_target: None,
9387                link_multi: false,
9388            },
9389            ComputedDescriptor {
9390                name: "looper".into(),
9391                expression: "(select .looper)".into(),
9392                return_type: None,
9393                link_target: None,
9394                link_multi: false,
9395            },
9396            ComputedDescriptor {
9397                name: "capped".into(),
9398                expression: "(select .posts limit 1)".into(),
9399                return_type: None,
9400                link_target: None,
9401                link_multi: false,
9402            },
9403        ];
9404        schema
9405    }
9406
9407    /// `Person.recent` computed from an object-returning function call, the
9408    /// shape jaldis's translation model uses.
9409    fn make_schema_with_object_fn_computed() -> SchemaDescriptor {
9410        use crate::schema::{ComputedDescriptor, FunctionDescriptor, FunctionParamDescriptor};
9411        let mut schema = make_schema();
9412        schema.functions = vec![FunctionDescriptor {
9413            name: "latest".into(),
9414            module: "default".into(),
9415            params: vec![FunctionParamDescriptor {
9416                name: "owner".into(),
9417                pg_type: "uuid".into(),
9418            }],
9419            return_pg_type: "default::Post".into(),
9420            return_is_object: true,
9421            return_is_set: true,
9422            return_is_polymorphic: false,
9423            volatility: "stable".into(),
9424            body: String::new(),
9425        }];
9426        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9427        person.computed = vec![
9428            ComputedDescriptor {
9429                name: "recent".into(),
9430                expression: "latest(.id)".into(),
9431                return_type: None,
9432                link_target: None,
9433                link_multi: false,
9434            },
9435            ComputedDescriptor {
9436                name: "vetted".into(),
9437                expression: "(select latest(.id) filter .title != '')".into(),
9438                return_type: None,
9439                link_target: None,
9440                link_multi: false,
9441            },
9442        ];
9443        schema
9444    }
9445
9446    #[test]
9447    fn test_path_traverses_through_a_function_backed_computed() {
9448        // `recent := latest(.id)` has no path to splice in, so the call
9449        // itself becomes the next row source — LATERAL, because its
9450        // arguments read the alias the traversal has reached.
9451        let schema = make_schema_with_object_fn_computed();
9452        let out = compile_and_emit_with("SELECT Person { t := .recent.title }", &schema);
9453        assert!(
9454            out.sql
9455                .contains("CROSS JOIN LATERAL \"public\".\"latest\"(\"t1\".\"id\") AS \"t2\""),
9456            "{}",
9457            out.sql
9458        );
9459        // The function returns a set, so the traversal is multi-valued.
9460        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
9461    }
9462
9463    #[test]
9464    fn test_sub_select_over_a_function_backed_computed() {
9465        // The jaldis translation model, minimal: a function-backed computed
9466        // resolves the set, a scalar computed picks an attribute out of it.
9467        let schema = make_schema_with_object_fn_computed();
9468        let out = compile_and_emit_with(
9469            "SELECT Person { t := (select .recent filter .title = 'x' limit 1).title }",
9470            &schema,
9471        );
9472        assert!(
9473            out.sql.contains("CROSS JOIN LATERAL \"public\".\"latest\""),
9474            "{}",
9475            out.sql
9476        );
9477        assert!(out.sql.contains("\"t2\".\"title\" = 'x'"), "{}", out.sql);
9478        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9479        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
9480    }
9481
9482    #[test]
9483    fn test_function_backed_computed_carries_its_own_filter() {
9484        let schema = make_schema_with_object_fn_computed();
9485        let out = compile_and_emit_with(
9486            "SELECT Person { t := (select .vetted filter .title = 'x' limit 1).title }",
9487            &schema,
9488        );
9489        assert!(
9490            out.sql
9491                .contains("(\"t2\".\"title\" = 'x') AND (\"t2\".\"title\" <> '')"),
9492            "{}",
9493            out.sql
9494        );
9495    }
9496
9497    #[test]
9498    fn test_function_backed_computed_checks_its_argument_count() {
9499        use crate::schema::ComputedDescriptor;
9500        let mut schema = make_schema_with_object_fn_computed();
9501        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9502        person.computed = vec![ComputedDescriptor {
9503            name: "recent".into(),
9504            expression: "latest()".into(),
9505            return_type: None,
9506            link_target: None,
9507            link_multi: false,
9508        }];
9509        let ast = parse::parse("SELECT Person { t := .recent.title }").unwrap();
9510        let err = match ir::compile(&ast, &schema) {
9511            Ok(_) => panic!("expected a compile error"),
9512            Err(e) => format!("{e}"),
9513        };
9514        assert!(err.contains("expects 1 argument(s), got 0"), "{err}");
9515    }
9516
9517    #[test]
9518    fn test_path_traverses_through_a_computed_pointer() {
9519        // `.published` is itself computed, so it has no column to join on —
9520        // its own path takes the step's place and its filter rides along.
9521        let schema = make_schema_with_computed_links();
9522        let out = compile_and_emit_with("SELECT Person { t := .published.title }", &schema);
9523        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
9524        assert!(
9525            out.sql
9526                .contains("(\"t1\".\"id\" = \"t0\".\"id\") AND (\"t2\".\"title\" <> '')"),
9527            "{}",
9528            out.sql
9529        );
9530    }
9531
9532    #[test]
9533    fn test_sub_select_over_a_computed_pointer_projects_a_property() {
9534        // The shape the jaldis translation model is built on:
9535        // `(select .translation filter .attribute = X limit 1).value`.
9536        let schema = make_schema_with_computed_links();
9537        let out = compile_and_emit_with(
9538            "SELECT Person { t := (select .published filter .title = 'a' limit 1).title }",
9539            &schema,
9540        );
9541        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9542        // Both the computed's own filter and the use-site one apply.
9543        assert!(out.sql.contains("\"t2\".\"title\" = 'a'"), "{}", out.sql);
9544        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
9545    }
9546
9547    #[test]
9548    fn test_chained_computed_pointers_each_contribute_their_filter() {
9549        let schema = make_schema_with_computed_links();
9550        let out = compile_and_emit_with("SELECT Person { t := .featured.title }", &schema);
9551        assert_eq!(out.sql.matches("\"public\".\"Person.posts\"").count(), 1, "{}", out.sql);
9552        assert!(out.sql.contains("\"t2\".\"title\" <> 'draft'"), "{}", out.sql);
9553        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
9554    }
9555
9556    #[test]
9557    fn test_computed_pointer_naming_a_bare_link_is_traversable() {
9558        let schema = make_schema_with_computed_links();
9559        let out = compile_and_emit_with("SELECT Person { t := .plain.title }", &schema);
9560        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
9561    }
9562
9563    #[test]
9564    fn test_a_cycle_of_computed_pointers_is_reported_not_hung_on() {
9565        let schema = make_schema_with_computed_links();
9566        let ast = parse::parse("SELECT Person { t := .looper.title }").unwrap();
9567        let err = match ir::compile(&ast, &schema) {
9568            Ok(_) => panic!("expected a compile error"),
9569            Err(e) => format!("{e}"),
9570        };
9571        assert!(err.contains("expands into itself"), "{err}");
9572    }
9573
9574    #[test]
9575    fn test_a_cycle_of_computed_pointers_read_as_a_value_is_reported_not_hung_on() {
9576        // Read as the last step, the computed was compiled as an expression,
9577        // which walked the same path again with no count of how often.
9578        let schema = make_schema_with_computed_links();
9579        for query in ["SELECT Person { looper }", "SELECT Person { * }"] {
9580            let ast = parse::parse(query).unwrap();
9581            let err = match ir::compile(&ast, &schema) {
9582                Ok(_) => panic!("expected a compile error for {query}"),
9583                Err(e) => format!("{e}"),
9584            };
9585            assert!(err.contains("expands into itself"), "{query}: {err}");
9586        }
9587    }
9588
9589    #[test]
9590    fn test_traversing_through_a_limited_computed_uses_a_lateral() {
9591        // `capped` picks one row per Person, so the traversal correlates
9592        // through a LATERAL rather than a plain join — and its `limit 1`
9593        // means the result is one title, not an array of them.
9594        let schema = make_schema_with_computed_links();
9595        let out = compile_and_emit_with("SELECT Person { t := .capped.title }", &schema);
9596        assert!(out.sql.contains("JOIN LATERAL ("), "{}", out.sql);
9597        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9598        assert!(!out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
9599    }
9600
9601    #[test]
9602    fn test_backlink_from_a_type_whose_interface_the_link_targets() {
9603        use crate::schema::{LinkDescriptor, TypeDescriptor};
9604        let mut schema = make_interface_schema();
9605        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
9606        schema.types.push(TypeDescriptor {
9607            name: "Note".into(),
9608            module: "default".into(),
9609            table: "Note".into(),
9610            abstract_: false,
9611            materialized: true,
9612            description: None,
9613            parents: vec![],
9614            interfaces: vec![],
9615            bases: vec![],
9616            properties: individual.properties[..1].to_vec(),
9617            // Targets the *interface*, which `Individual` implements.
9618            links: vec![LinkDescriptor {
9619                name: "owner".into(),
9620                target: "default::Account".into(),
9621                nullable: true,
9622                through: None,
9623                description: None,
9624                default_pyql: None,
9625                is_exclusive: false,
9626                is_readonly: false,
9627                rewrites: vec![],
9628                on_delete: vec![],
9629            }],
9630            multilinks: vec![],
9631            computed: vec![],
9632            constraints: vec![],
9633            indexes: vec![],
9634            partition: None,
9635            vector_indexes: vec![],
9636            search_indexes: vec![],
9637            triggers: vec![],
9638            junction: false,
9639            signals: vec![],
9640        });
9641        // Reported "link 'Note::owner' does not target 'Individual'" — but a
9642        // link to an interface accepts every implementor, and they share ids.
9643        let out = compile_and_emit_with("SELECT Individual { notes := .<owner[is Note] }", &schema);
9644        assert!(out.sql.contains("\"public\".\"Note\""), "{}", out.sql);
9645        assert!(out.sql.contains("\"owner_id\" = \"t0\".id"), "{}", out.sql);
9646    }
9647
9648    #[test]
9649    fn test_with_block_in_expression_position_hoists_its_bindings() {
9650        // A `with` has nowhere to put its CTEs mid-expression, so they move
9651        // to the enclosing statement's own WITH clause.
9652        let out = compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select c) }");
9653        assert!(out.sql.starts_with("WITH\n\"c\" AS ("), "{}", out.sql);
9654        assert!(out.sql.contains("(SELECT \"v\" FROM \"c\")"), "{}", out.sql);
9655    }
9656
9657    #[test]
9658    fn test_hoisted_binding_is_usable_in_the_expression_around_it() {
9659        let out =
9660            compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select .name ++ c) }");
9661        assert!(
9662            out.sql.contains("(\"t0\".\"name\" || (SELECT \"v\" FROM \"c\"))"),
9663            "{}",
9664            out.sql
9665        );
9666    }
9667
9668    #[test]
9669    fn test_hoisted_binding_joins_the_statements_own_with_clause() {
9670        let out = compile_and_emit(
9671            "WITH top := (select Person.name limit 1) \
9672             SELECT Person { t := (with c := (select Company.name limit 1) select c) }",
9673        );
9674        let top = out.sql.find("\"top\" AS (").expect("top-level binding");
9675        let inner = out.sql.find("\"c\" AS (").expect("hoisted binding");
9676        assert!(top < inner, "the statement's own bindings come first:\n{}", out.sql);
9677    }
9678
9679    #[test]
9680    fn test_a_binding_named_after_a_keyword_is_usable() {
9681        // The lexer is case-insensitive, so `order` arrives as the ORDER
9682        // token and read as "expected an expression, found 'order'" — and the
9683        // binding keeps the casing it was written with, not the keyword
9684        // table's.
9685        let out = compile_and_emit("WITH order := (select Person.name) SELECT Person { name } FILTER .name = order");
9686        assert!(out.sql.contains("(SELECT \"v\" FROM \"order\")"), "{}", out.sql);
9687    }
9688
9689    #[test]
9690    fn test_computed_declared_on_an_interface_is_visible_from_an_implementor() {
9691        use crate::schema::ComputedDescriptor;
9692        let mut schema = make_interface_schema();
9693        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
9694        account.computed = vec![ComputedDescriptor {
9695            name: "tier".into(),
9696            expression: "'gold'".into(),
9697            return_type: None,
9698            link_target: None,
9699            link_multi: false,
9700        }];
9701        let individual = schema.types.iter_mut().find(|t| t.name == "Individual").unwrap();
9702        individual.computed = vec![];
9703        // Reported as "has no link or property 'tier'. Did you mean 'tier'?"
9704        // — the suggester searched interfaces, the resolver didn't.
9705        for q in ["SELECT Individual { tier }", "SELECT Individual { t := .tier }"] {
9706            let out = compile_and_emit_with(q, &schema);
9707            assert!(out.sql.contains("'gold'"), "{q}: {}", out.sql);
9708        }
9709    }
9710
9711    #[test]
9712    fn test_computed_may_carry_trailing_modifiers_without_a_select() {
9713        use crate::schema::ComputedDescriptor;
9714        let mut schema = make_schema();
9715        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9716        person.computed = vec![ComputedDescriptor {
9717            name: "recent".into(),
9718            expression: ".posts order by .title desc limit 5".into(),
9719            return_type: None,
9720            link_target: None,
9721            link_multi: false,
9722        }];
9723        // A schema fragment has no statement around it to hang modifiers
9724        // off, so this used to be "expected an expression, found 'order'".
9725        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
9726        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
9727        assert!(out.sql.contains("ORDER BY \"t1\".\"title\" DESC"), "{}", out.sql);
9728    }
9729
9730    #[test]
9731    fn test_computed_may_lead_with_a_bare_select() {
9732        use crate::schema::ComputedDescriptor;
9733        let mut schema = make_schema();
9734        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9735        person.computed = vec![ComputedDescriptor {
9736            name: "recent".into(),
9737            expression: "select .posts order by .title desc limit 5".into(),
9738            return_type: None,
9739            link_target: None,
9740            link_multi: false,
9741        }];
9742        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
9743        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
9744    }
9745
9746    #[test]
9747    fn test_sub_select_with_a_shape_may_project_a_property_off_it() {
9748        let out = compile_and_emit("SELECT Person { t := (select .posts { title } limit 1).title }");
9749        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
9750        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9751    }
9752
9753    #[test]
9754    fn test_sub_statement_error_names_what_it_got() {
9755        let err = compile_err("SELECT Person { t := (insert Company { name := 'a' }) }");
9756        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
9757    }
9758
9759    #[test]
9760    fn test_with_bound_scalar_path_select_emits_a_real_select() {
9761        // The CTE body used to be just the FROM clause — `"xs" AS (
9762        // "public"."Person" AS "t0" )` — which is not SQL at all.
9763        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name }");
9764        assert!(
9765            out.sql
9766                .contains("SELECT ROW(v) AS result, v FROM (SELECT \"t0\".\"name\" AS v"),
9767            "{}",
9768            out.sql
9769        );
9770    }
9771
9772    #[test]
9773    fn test_with_bound_scalar_path_select_is_typed_as_its_result() {
9774        // Binding `Person.name` binds text, not `default::Person`, so a
9775        // reference to it reads the CTE's value column rather than an `id`.
9776        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name } FILTER .name IN xs");
9777        assert!(out.sql.contains("ANY((SELECT \"v\" FROM \"xs\"))"), "{}", out.sql);
9778    }
9779
9780    #[test]
9781    fn test_distinct_survives_a_with_binding() {
9782        let out = compile_and_emit("WITH xs := (select distinct Person.name) SELECT Person { name }");
9783        assert!(out.sql.contains("SELECT DISTINCT \"t0\".\"name\" AS v"), "{}", out.sql);
9784    }
9785
9786    #[test]
9787    fn test_for_over_a_derived_set_iterates_every_row() {
9788        // A non-literal iterator used to be wrapped in a one-row VALUES, so
9789        // the body ran once against a scalar subquery instead of per row.
9790        let out = compile_and_emit("FOR x IN (select Person.name) UNION (SELECT Person { name } FILTER .name = x)");
9791        assert!(!out.sql.contains("VALUES"), "{}", out.sql);
9792        assert!(out.sql.contains(") AS \"_for_x\""), "{}", out.sql);
9793        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
9794        assert!(out.sql.contains("\"t1\".\"name\" = \"_for_x\".\"v\""), "{}", out.sql);
9795    }
9796
9797    #[test]
9798    fn test_for_insert_over_a_derived_set_uses_it_as_the_cte() {
9799        let out = compile_and_emit("FOR x IN (select Person.name) UNION (INSERT Company { name := x })");
9800        assert!(out.sql.starts_with("WITH \"_for_x\" AS ("), "{}", out.sql);
9801        assert!(
9802            out.sql.contains("SELECT \"_for_x\".\"v\" FROM \"_for_x\""),
9803            "{}",
9804            out.sql
9805        );
9806    }
9807
9808    #[test]
9809    fn test_for_over_objects_binds_their_id() {
9810        let out = compile_and_emit("FOR p IN (select Person) UNION (INSERT Company { name := <str>p })");
9811        assert!(out.sql.contains("SELECT \"id\" AS v FROM ("), "{}", out.sql);
9812    }
9813
9814    #[test]
9815    fn test_for_over_a_non_select_statement_is_rejected() {
9816        let ast = parse::parse("FOR x IN (INSERT Company { name := 'a' }) UNION (SELECT Person { name })").unwrap();
9817        let schema = make_schema();
9818        let err = match ir::compile(&ast, &schema) {
9819            Ok(_) => panic!("expected a compile error"),
9820            Err(e) => format!("{e}"),
9821        };
9822        assert!(err.contains("only a select can be iterated"), "{err}");
9823    }
9824
9825    #[test]
9826    fn test_multi_link_path_outside_a_comparison_is_an_array() {
9827        // It stands for a set of values — as a scalar subquery Postgres
9828        // would reject it the moment a Person had two posts.
9829        let out = compile_and_emit("SELECT Person { t := .posts.title }");
9830        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
9831        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9832    }
9833
9834    #[test]
9835    fn test_multi_link_path_inside_a_comparison_is_still_exists() {
9836        let out = compile_and_emit("SELECT Person { name } filter .posts.title = 'x'");
9837        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
9838        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
9839    }
9840
9841    #[test]
9842    fn test_path_traversal_deeper_than_two_steps() {
9843        let schema = make_schema_with_through();
9844        let out = compile_and_emit_with("SELECT Person { t := .friends.friends.name }", &schema);
9845        // Two junction hops, then the property — one subquery, not an error.
9846        assert_eq!(out.sql.matches("\"public\".\"PersonFriend\"").count(), 2, "{}", out.sql);
9847        assert!(out.sql.contains("ARRAY(SELECT \"t4\".\"name\""), "{}", out.sql);
9848    }
9849
9850    #[test]
9851    fn test_coalesce_over_backlinked_values_is_a_set_choice() {
9852        // The backlink was taken for a comparison and rewritten to an EXISTS,
9853        // with a COALESCE of a value and an array inside it.
9854        let out = compile_and_emit("SELECT Post ORDER BY max(.<posts[is Person].age ?? .<posts[is Person].age)");
9855        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
9856        assert!(!out.sql.contains("COALESCE("), "{}", out.sql);
9857        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
9858        assert!(
9859            out.sql
9860                .contains("(SELECT coalesce(max(\"_s\".\"v\"), NULL) FROM unnest("),
9861            "{}",
9862            out.sql
9863        );
9864    }
9865
9866    #[test]
9867    fn test_coalesce_over_multilink_values_is_a_set_choice() {
9868        let out = compile_and_emit("SELECT Person { t := .posts.title ?? .posts.title }");
9869        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
9870        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
9871    }
9872
9873    #[test]
9874    fn test_coalesce_of_a_set_with_a_single_value_falls_back_to_it() {
9875        let out = compile_and_emit("SELECT Post { a := .<posts[is Person].age ?? 0 }");
9876        assert!(out.sql.contains("ELSE array_remove(ARRAY[0], NULL) END"), "{}", out.sql);
9877    }
9878
9879    #[test]
9880    fn test_traversal_after_a_backlink() {
9881        let out = compile_and_emit("SELECT Post { t := .<posts[is Person].name }");
9882        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"name\""), "{}", out.sql);
9883        assert!(out.sql.contains("\"t3\".\"target\" = \"t1\".\"id\""), "{}", out.sql);
9884    }
9885
9886    #[test]
9887    fn test_computed_pointer_on_a_linked_type_is_reachable() {
9888        use crate::schema::ComputedDescriptor;
9889        let mut schema = make_schema();
9890        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
9891        company.computed = vec![ComputedDescriptor {
9892            name: "shout".into(),
9893            expression: ".name ++ '!'".into(),
9894            return_type: Some("text".into()),
9895            link_target: None,
9896            link_multi: false,
9897        }];
9898        // `.company.shout` used to report "has no link or property 'shout'"
9899        // while helpfully suggesting 'shout' — path traversal never looked
9900        // at the target type's computed pointers.
9901        let out = compile_and_emit_with("SELECT Person { t := .company.shout }", &schema);
9902        assert!(out.sql.contains("(\"t2\".\"name\" || '!')"), "{}", out.sql);
9903    }
9904
9905    #[test]
9906    fn test_computed_pointer_behind_a_type_intersection() {
9907        let schema = make_interface_schema();
9908        let out = compile_and_emit_with("SELECT Account { n := [is Individual].full_name }", &schema);
9909        assert!(out.sql.contains("upper(\"t1\".\"first_name\")"), "{}", out.sql);
9910        assert!(out.sql.contains("FROM \"public\".\"Individual\""), "{}", out.sql);
9911        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9912    }
9913
9914    #[test]
9915    fn test_computed_pointer_cannot_be_traversed_through() {
9916        use crate::schema::ComputedDescriptor;
9917        let mut schema = make_schema();
9918        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
9919        company.computed = vec![ComputedDescriptor {
9920            name: "shout".into(),
9921            expression: ".name ++ '!'".into(),
9922            return_type: Some("text".into()),
9923            link_target: None,
9924            link_multi: false,
9925        }];
9926        let ast = parse::parse("SELECT Person { t := .company.shout.nope }").unwrap();
9927        let err = match ir::compile(&ast, &schema) {
9928            Ok(_) => panic!("expected a compile error"),
9929            Err(e) => format!("{e}"),
9930        };
9931        assert!(err.contains("is a computed pointer"), "{err}");
9932    }
9933
9934    #[test]
9935    fn test_sub_select_over_a_multi_link_path_keeps_its_modifiers() {
9936        let out = compile_and_emit("SELECT Person { t := (select .posts.title order by .title desc limit 2) }");
9937        assert!(out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
9938        assert!(out.sql.contains("ORDER BY \"t2\".\"title\" DESC"), "{}", out.sql);
9939        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
9940    }
9941
9942    #[test]
9943    fn test_limit_one_over_a_multi_link_path_stays_a_scalar() {
9944        // `limit 1` is what makes the set single-valued, so this one is a
9945        // scalar subquery rather than a one-element array.
9946        let out = compile_and_emit("SELECT Person { t := (select .posts.title limit 1) }");
9947        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
9948        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9949    }
9950
9951    #[test]
9952    fn test_computed_multilink_can_carry_a_nested_shape() {
9953        let out = compile_and_emit("SELECT Person { p := .posts { title } }");
9954        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9955            panic!()
9956        };
9957        let ShapeNode::Array { name, element, .. } = &pointers[2] else {
9958            panic!("{:?}", pointers[2])
9959        };
9960        assert_eq!(name, "p");
9961        let ShapeNode::Object {
9962            pointers: elem_pointers,
9963            ..
9964        } = element.as_ref()
9965        else {
9966            panic!()
9967        };
9968        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
9969    }
9970
9971    #[test]
9972    fn test_schema_declared_backlink_computed_is_an_object_pointer() {
9973        use crate::schema::ComputedDescriptor;
9974        let mut schema = make_schema();
9975        let post = schema.types.iter_mut().find(|t| t.name == "Post").unwrap();
9976        post.computed = vec![ComputedDescriptor {
9977            name: "authors".into(),
9978            expression: ".<posts[is Person]".into(),
9979            return_type: None,
9980            link_target: None,
9981            link_multi: false,
9982        }];
9983        // A backlink computed used to compile as an EXISTS boolean here —
9984        // the pointer builders were only reachable from an inline `:=`.
9985        let out = compile_and_emit_with("SELECT Post { authors { name } }", &schema);
9986        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
9987        assert!(out.sql.contains("\"t1\".\"name\"::text"), "{}", out.sql);
9988        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9989            panic!()
9990        };
9991        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
9992    }
9993
9994    #[test]
9995    fn test_schema_declared_multilink_computed_defaults_to_ids_without_a_shape() {
9996        use crate::schema::ComputedDescriptor;
9997        let mut schema = make_schema();
9998        schema.types[0].computed = vec![ComputedDescriptor {
9999            name: "everything".into(),
10000            expression: ".posts".into(),
10001            return_type: None,
10002            link_target: None,
10003            link_multi: false,
10004        }];
10005        let out = compile_and_emit_with("SELECT Person { everything }", &schema);
10006        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10007            panic!()
10008        };
10009        assert!(matches!(&pointers[2], ShapeNode::Array { name, .. } if name == "everything"));
10010    }
10011
10012    #[test]
10013    fn test_schema_declared_computed_sub_select_is_a_link_pointer() {
10014        use crate::schema::ComputedDescriptor;
10015        let mut schema = make_schema();
10016        schema.types[0].computed = vec![
10017            ComputedDescriptor {
10018                name: "recent".into(),
10019                expression: "(select .posts order by .title desc limit 1)".into(),
10020                return_type: None,
10021                link_target: None,
10022                link_multi: false,
10023            },
10024            ComputedDescriptor {
10025                name: "recent_title".into(),
10026                expression: "(select .posts order by .title desc limit 1).title".into(),
10027                return_type: Some("text".into()),
10028                link_target: None,
10029                link_multi: false,
10030            },
10031        ];
10032        let out = compile_and_emit_with("SELECT Person { recent { title }, recent_title }", &schema);
10033        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10034            panic!()
10035        };
10036        // A declared computed gets the same treatment as one written inline:
10037        // object-valued when it selects a link, scalar when it projects one.
10038        assert!(matches!(&pointers[2], ShapeNode::Object { name, .. } if name == "recent"));
10039        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, .. } if name == "recent_title"));
10040        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
10041        assert_eq!(out.sql.matches("LIMIT 1").count(), 2, "{}", out.sql);
10042        crate::validate::validate_schema_types(&schema).expect("schema should validate");
10043    }
10044
10045    /// `select (insert T { … }).id` — the mutation is hoisted into the
10046    /// statement's own WITH and the field read off it, which is what the
10047    /// `with x := (insert …) select x.id` spelling already produced. Without
10048    /// the CTE the projection would read the whole table instead.
10049    #[test]
10050    fn test_a_mutation_read_through_a_path_runs_as_a_hoisted_cte() {
10051        let out = compile_and_emit("SELECT (INSERT Person { name := 'x' }).name");
10052        assert!(
10053            out.sql.contains("\"_nested_dml_0\" AS (") && out.sql.contains("INSERT INTO"),
10054            "the mutation must run as a CTE:\n{}",
10055            out.sql
10056        );
10057        assert!(
10058            out.sql.contains("FROM \"_nested_dml_0\""),
10059            "the projection must read the mutation's own rows:\n{}",
10060            out.sql
10061        );
10062    }
10063
10064    #[test]
10065    fn test_dml_sub_statement_in_expression_position_still_rejected() {
10066        let err = compile_err("SELECT Person { x := (insert Company { name := 'c' }).name }");
10067        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
10068    }
10069
10070    #[test]
10071    fn test_sub_select_with_a_shape_in_expression_position_is_rejected() {
10072        // Only when the shape *is* the result — projecting a property off
10073        // it is fine, since the shape then says nothing the projection
10074        // doesn't (see test_sub_select_with_a_shape_may_project_a_property_off_it).
10075        let err = compile_err("SELECT Person { name } filter (select .posts { title }) = 1");
10076        assert!(err.contains("sub-select with a shape"), "{err}");
10077    }
10078
10079    fn make_schema_with_through() -> SchemaDescriptor {
10080        let id_prop = || PropertyDescriptor {
10081            name: "id".into(),
10082            pg_type: "uuid".into(),
10083            nullable: false,
10084            default_sql: Some("gen_random_uuid()".into()),
10085            description: None,
10086            default_pyql: None,
10087            check_constraints: vec![],
10088            is_exclusive: true,
10089            is_pk: true,
10090            is_readonly: true,
10091            rewrites: vec![],
10092            tuple_members: None,
10093            column_type: None,
10094        };
10095        let name_prop = || PropertyDescriptor {
10096            name: "name".into(),
10097            pg_type: "text".into(),
10098            nullable: false,
10099            default_sql: None,
10100            description: None,
10101            check_constraints: vec![],
10102            default_pyql: None,
10103            is_exclusive: false,
10104            is_pk: false,
10105            is_readonly: false,
10106            rewrites: vec![],
10107            tuple_members: None,
10108            column_type: None,
10109        };
10110        SchemaDescriptor {
10111            types: vec![
10112                TypeDescriptor {
10113                    name: "Person".into(),
10114                    module: "default".into(),
10115                    table: "Person".into(),
10116                    abstract_: false,
10117                    materialized: false,
10118                    description: None,
10119                    parents: vec![],
10120                    interfaces: vec![],
10121                    bases: vec![],
10122                    properties: vec![id_prop(), name_prop()],
10123                    links: vec![],
10124                    multilinks: vec![MultiLinkDescriptor {
10125                        name: "friends".into(),
10126                        target: "default::Person".into(),
10127                        through: Some("default::PersonFriend".into()),
10128                        nullable: false,
10129                        description: None,
10130                        default_pyql: None,
10131                        on_delete: vec![],
10132                        is_exclusive: false,
10133                    }],
10134                    computed: vec![],
10135                    constraints: vec![],
10136                    indexes: vec![],
10137                    partition: None,
10138                    vector_indexes: vec![],
10139                    search_indexes: vec![],
10140                    triggers: vec![],
10141                    junction: false,
10142                    signals: vec![],
10143                },
10144                TypeDescriptor {
10145                    name: "PersonFriend".into(),
10146                    module: "default".into(),
10147                    table: "PersonFriend".into(),
10148                    abstract_: false,
10149                    materialized: false,
10150                    description: None,
10151                    parents: vec![],
10152                    interfaces: vec![],
10153                    bases: vec![],
10154                    properties: vec![id_prop()],
10155                    links: vec![
10156                        LinkDescriptor {
10157                            name: "person".into(),
10158                            target: "default::Person".into(),
10159                            nullable: false,
10160                            through: None,
10161                            description: None,
10162                            default_pyql: None,
10163                            is_exclusive: false,
10164                            is_readonly: false,
10165                            rewrites: vec![],
10166                            on_delete: vec![],
10167                        },
10168                        LinkDescriptor {
10169                            name: "friend".into(),
10170                            target: "default::Person".into(),
10171                            nullable: false,
10172                            through: None,
10173                            description: None,
10174                            default_pyql: None,
10175                            is_exclusive: false,
10176                            is_readonly: false,
10177                            rewrites: vec![],
10178                            on_delete: vec![],
10179                        },
10180                    ],
10181                    multilinks: vec![],
10182                    computed: vec![],
10183                    constraints: vec![],
10184                    indexes: vec![],
10185                    partition: None,
10186                    vector_indexes: vec![],
10187                    search_indexes: vec![],
10188                    triggers: vec![],
10189                    junction: false,
10190                    signals: vec![],
10191                },
10192            ],
10193            scalars: vec![],
10194            enums: vec![],
10195            named_tuples: vec![],
10196            globals: vec![],
10197            functions: vec![],
10198            aliases: vec![],
10199            channels: vec![],
10200            ..Default::default()
10201        }
10202    }
10203
10204    #[test]
10205    fn test_select_through_multi_link() {
10206        let schema = make_schema_with_through();
10207        let ast = crate::parse::parse("SELECT Person { name, friends { name } }").unwrap();
10208        let ir = crate::ir::compile(&ast, &schema).unwrap();
10209        let out = emit(&ir);
10210        // Junction table is the PersonFriend table, not the standard dotted name
10211        assert!(out.sql.contains("\"public\".\"PersonFriend\""));
10212        // Source FK column (person → Person) and target FK column (friend → Person)
10213        assert!(out.sql.contains("\"friend\""));
10214        assert!(out.sql.contains("\"person\""));
10215        // Still emits array_agg pattern
10216        assert!(out.sql.contains("array_agg(ROW("));
10217    }
10218
10219    /// Person/Org — a junction-backed single link (`Person.spouse: Link[Org,
10220    /// through(Marriage)]`, exclusive). Deliberately targets a different
10221    /// type (Org) rather than a self-link, for the same reason
10222    /// `test_select_through_multi_link` avoids Person-to-Person — see the
10223    /// comment on `make_schema_with_through` above.
10224    #[test]
10225    fn test_limited_multi_link_keeps_a_nested_multi_link_in_scope() {
10226        // The nested pointer's own correlated subquery moves inside the
10227        // derived table the LIMIT sits on, where its alias is still bound.
10228        let schema = make_schema_with_through();
10229        let ast = crate::parse::parse("SELECT Person { friends: { name, friends { name } } limit 1 }").unwrap();
10230        let ir = crate::ir::compile(&ast, &schema).unwrap();
10231        let out = emit(&ir);
10232        assert_eq!(out.sql.matches("array_agg(ROW(").count(), 2, "{}", out.sql);
10233        let derived = out.sql.find("AS \"__lim\"").unwrap();
10234        let nested = out.sql.rfind("array_agg(ROW(").unwrap();
10235        assert!(
10236            nested < derived,
10237            "nested aggregate must sit inside the derived table:\n{}",
10238            out.sql
10239        );
10240    }
10241
10242    fn make_schema_with_junction_backed_link() -> SchemaDescriptor {
10243        let id_prop = || PropertyDescriptor {
10244            name: "id".into(),
10245            pg_type: "uuid".into(),
10246            nullable: false,
10247            default_sql: Some("gen_random_uuid()".into()),
10248            description: None,
10249            default_pyql: None,
10250            check_constraints: vec![],
10251            is_exclusive: true,
10252            is_pk: true,
10253            is_readonly: true,
10254            rewrites: vec![],
10255            tuple_members: None,
10256            column_type: None,
10257        };
10258        let name_prop = || PropertyDescriptor {
10259            name: "name".into(),
10260            pg_type: "text".into(),
10261            nullable: false,
10262            default_sql: None,
10263            description: None,
10264            check_constraints: vec![],
10265            default_pyql: None,
10266            is_exclusive: false,
10267            is_pk: false,
10268            is_readonly: false,
10269            rewrites: vec![],
10270            tuple_members: None,
10271            column_type: None,
10272        };
10273        SchemaDescriptor {
10274            types: vec![
10275                TypeDescriptor {
10276                    name: "Person".into(),
10277                    module: "default".into(),
10278                    table: "Person".into(),
10279                    abstract_: false,
10280                    materialized: false,
10281                    description: None,
10282                    parents: vec![],
10283                    interfaces: vec![],
10284                    bases: vec![],
10285                    properties: vec![id_prop(), name_prop()],
10286                    links: vec![LinkDescriptor {
10287                        name: "spouse".into(),
10288                        target: "default::Org".into(),
10289                        nullable: true,
10290                        through: Some("default::Marriage".into()),
10291                        description: None,
10292                        default_pyql: None,
10293                        is_exclusive: true,
10294                        is_readonly: false,
10295                        rewrites: vec![],
10296                        on_delete: vec![],
10297                    }],
10298                    multilinks: vec![],
10299                    computed: vec![],
10300                    constraints: vec![],
10301                    indexes: vec![],
10302                    partition: None,
10303                    vector_indexes: vec![],
10304                    search_indexes: vec![],
10305                    triggers: vec![],
10306                    junction: false,
10307                    signals: vec![],
10308                },
10309                TypeDescriptor {
10310                    name: "Org".into(),
10311                    module: "default".into(),
10312                    table: "Org".into(),
10313                    abstract_: false,
10314                    materialized: false,
10315                    description: None,
10316                    parents: vec![],
10317                    interfaces: vec![],
10318                    bases: vec![],
10319                    properties: vec![id_prop(), name_prop()],
10320                    links: vec![],
10321                    multilinks: vec![],
10322                    computed: vec![],
10323                    constraints: vec![],
10324                    indexes: vec![],
10325                    partition: None,
10326                    vector_indexes: vec![],
10327                    search_indexes: vec![],
10328                    triggers: vec![],
10329                    junction: false,
10330                    signals: vec![],
10331                },
10332                TypeDescriptor {
10333                    name: "Marriage".into(),
10334                    module: "default".into(),
10335                    table: "Person.spouse".into(),
10336                    abstract_: false,
10337                    materialized: false,
10338                    description: None,
10339                    parents: vec![],
10340                    interfaces: vec![],
10341                    bases: vec![],
10342                    properties: vec![
10343                        id_prop(),
10344                        PropertyDescriptor {
10345                            name: "since".into(),
10346                            pg_type: "int8".into(),
10347                            nullable: true,
10348                            default_sql: None,
10349                            description: None,
10350                            check_constraints: vec![],
10351                            default_pyql: None,
10352                            is_exclusive: false,
10353                            is_pk: false,
10354                            is_readonly: false,
10355                            rewrites: vec![],
10356                            tuple_members: None,
10357                            column_type: None,
10358                        },
10359                    ],
10360                    links: vec![
10361                        LinkDescriptor {
10362                            name: "source".into(),
10363                            target: "default::Person".into(),
10364                            nullable: false,
10365                            through: None,
10366                            description: None,
10367                            default_pyql: None,
10368                            is_exclusive: false,
10369                            is_readonly: false,
10370                            rewrites: vec![],
10371                            on_delete: vec![],
10372                        },
10373                        LinkDescriptor {
10374                            name: "target".into(),
10375                            target: "default::Org".into(),
10376                            nullable: false,
10377                            through: None,
10378                            description: None,
10379                            default_pyql: None,
10380                            is_exclusive: false,
10381                            is_readonly: false,
10382                            rewrites: vec![],
10383                            on_delete: vec![],
10384                        },
10385                    ],
10386                    multilinks: vec![],
10387                    computed: vec![],
10388                    constraints: vec![],
10389                    indexes: vec![],
10390                    partition: None,
10391                    vector_indexes: vec![],
10392                    search_indexes: vec![],
10393                    triggers: vec![],
10394                    junction: true,
10395                    signals: vec![],
10396                },
10397            ],
10398            scalars: vec![],
10399            enums: vec![],
10400            named_tuples: vec![],
10401            globals: vec![],
10402            functions: vec![],
10403            aliases: vec![],
10404            channels: vec![],
10405            ..Default::default()
10406        }
10407    }
10408
10409    #[test]
10410    fn test_select_shape_over_junction_backed_single_link() {
10411        let schema = make_schema_with_junction_backed_link();
10412        let ast = crate::parse::parse("SELECT Person { name, spouse { name } }").unwrap();
10413        let ir = crate::ir::compile(&ast, &schema).unwrap();
10414        let out = emit(&ir);
10415        // Junction table is the through type's own table, joined like a multi-link's.
10416        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
10417        assert!(out.sql.contains("\"jt\".source"), "got:\n{}", out.sql);
10418        assert!(out.sql.contains("\"jt\".target"), "got:\n{}", out.sql);
10419        // Cardinality-one: a scalar correlated subquery, never array_agg.
10420        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
10421        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
10422    }
10423
10424    #[test]
10425    fn test_select_shape_over_junction_backed_single_link_with_link_property() {
10426        // A junction-backed single link can read its own link properties
10427        // (`@since`) in a nested shape, same as a multi-link's `@weight` —
10428        // this is the one read site with no reusable FK-based form
10429        // (`IrSingleLinkPointer` needed its own `link_properties`, mirroring
10430        // `IrMultiLinkPointer`'s).
10431        let schema = make_schema_with_junction_backed_link();
10432        let ast = crate::parse::parse("SELECT Person { name, spouse { name, @since } }").unwrap();
10433        let ir = crate::ir::compile(&ast, &schema).unwrap();
10434        let out = emit(&ir);
10435        assert!(out.sql.contains("\"jt\".\"since\""), "got:\n{}", out.sql);
10436        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
10437    }
10438
10439    #[test]
10440    fn test_select_path_over_junction_backed_single_link() {
10441        let schema = make_schema_with_junction_backed_link();
10442        let ast = crate::parse::parse("SELECT Person.spouse { name }").unwrap();
10443        let ir = crate::ir::compile(&ast, &schema).unwrap();
10444        let out = emit(&ir);
10445        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
10446        assert!(out.sql.contains("\"source\""), "got:\n{}", out.sql);
10447        assert!(out.sql.contains("\"target\""), "got:\n{}", out.sql);
10448        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
10449    }
10450
10451    #[test]
10452    fn test_insert_with_junction_backed_single_link() {
10453        let schema = make_schema_with_junction_backed_link();
10454        let out = compile_and_emit_with(
10455            "INSERT Person { name := $name, \
10456                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
10457            &schema,
10458        );
10459        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
10460        assert!(out.sql.contains("\"since\""), "got:\n{}", out.sql);
10461        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
10462        // A fresh row has no prior junction entry, but the emitted ON
10463        // CONFLICT target must still name only `source` — no `(source,
10464        // target)` composite unique constraint exists on this table (D3).
10465        // The conflict action is always an upsert (not DO NOTHING) since a
10466        // same-statement reassignment's paired DELETE shares this
10467        // statement's snapshot and can still "conflict" with the old row.
10468        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
10469    }
10470
10471    #[test]
10472    fn test_update_replace_junction_backed_single_link() {
10473        let schema = make_schema_with_junction_backed_link();
10474        let out = compile_and_emit_with(
10475            "UPDATE Person FILTER .id = $id SET { \
10476                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
10477            &schema,
10478        );
10479        // Replace = clear the existing junction row, then insert the new one.
10480        assert!(
10481            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
10482            "got:\n{}",
10483            out.sql
10484        );
10485        assert!(
10486            out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
10487            "got:\n{}",
10488            out.sql
10489        );
10490        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
10491        // The paired DELETE must not race the new INSERT for a target that's
10492        // being kept, but for a single link (PK is `source` alone) ANY
10493        // existing row conflicts regardless of target, so the upsert above
10494        // is what actually makes reassignment work, not the exclusion here.
10495        assert!(out.sql.contains("NOT IN"), "got:\n{}", out.sql);
10496    }
10497
10498    /// `unless conflict on .k else (update T filter … set { … })` — the ON
10499    /// CONFLICT target says which row conflicts, the filter says whether to
10500    /// touch it. Dropping the filter turned automator's advisory lock into an
10501    /// unconditional steal of a live holder's row.
10502    #[test]
10503    fn test_unless_conflict_else_update_keeps_its_filter() {
10504        let out = compile_and_emit(
10505            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name \
10506             ELSE (UPDATE Person FILTER .age < 30 SET { name := 'b' })",
10507        );
10508        assert!(
10509            out.sql.contains("DO UPDATE SET") && out.sql.contains("WHERE"),
10510            "the ELSE UPDATE's filter must survive as a DO UPDATE predicate:\n{}",
10511            out.sql
10512        );
10513        assert!(
10514            out.sql.contains("\"Person\".\"age\""),
10515            "the predicate must read the existing row, not `excluded`:\n{}",
10516            out.sql
10517        );
10518    }
10519
10520    /// No filter, no predicate — an unconditional ELSE UPDATE still updates.
10521    #[test]
10522    fn test_unless_conflict_else_update_without_a_filter_has_no_predicate() {
10523        let out = compile_and_emit(
10524            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { name := 'b' })",
10525        );
10526        let after_set = out.sql.split("DO UPDATE SET").nth(1).unwrap_or_default();
10527        assert!(
10528            !after_set.contains("WHERE"),
10529            "got a predicate we never asked for:\n{}",
10530            out.sql
10531        );
10532    }
10533
10534    #[test]
10535    fn test_junction_backed_single_link_rejected_in_unless_conflict_else() {
10536        // `compile_conflict_else` calls `compile_assignments_for_update`
10537        // directly on the raw ELSE-clause shape, bypassing the
10538        // shape-classification loop `compile_insert`/`compile_update` use to
10539        // intercept a junction-backed link before it reaches the plain
10540        // FK-column assignment path — must fail loudly here instead of
10541        // emitting a `spouse_id` column that doesn't exist.
10542        let schema = make_schema_with_junction_backed_link();
10543        let ast = parse::parse(
10544            "INSERT Person { name := $name } \
10545             UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { \
10546                spouse := (SELECT Org FILTER .id = $oid) })",
10547        )
10548        .unwrap();
10549        assert!(ir::compile(&ast, &schema).is_err());
10550    }
10551
10552    #[test]
10553    fn test_update_clear_junction_backed_single_link() {
10554        let schema = make_schema_with_junction_backed_link();
10555        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := {} }", &schema);
10556        assert!(
10557            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
10558            "got:\n{}",
10559            out.sql
10560        );
10561        assert!(
10562            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
10563            "clearing must not also insert:\n{}",
10564            out.sql
10565        );
10566    }
10567
10568    #[test]
10569    fn test_update_clear_junction_backed_single_link_with_cast_empty_set() {
10570        // Regression: the frontend (generateStatements.ts's `setNull`
10571        // handling) always emits the cast form `<Type>{}`, never the bare
10572        // `{}` — the junction-backed clear path originally only recognized
10573        // bare `Expr::Set([])`, so this form fell through to
10574        // `compile_multilink_values` and errored with "multilink value must
10575        // be a CTE reference, parenthesised subquery, or type path" instead
10576        // of clearing (confirmed live via the Data Explorer).
10577        let schema = make_schema_with_junction_backed_link();
10578        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := <Org>{} }", &schema);
10579        assert!(
10580            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
10581            "got:\n{}",
10582            out.sql
10583        );
10584        assert!(
10585            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
10586            "clearing must not also insert:\n{}",
10587            out.sql
10588        );
10589    }
10590
10591    /// Product/Tag/ProductTag — the actual real-world shape link properties
10592    /// were built for (mirrors pylon-demo). Deliberately *not* built off
10593    /// `make_schema_with_through` (Person self-linking to Person): a
10594    /// same-type through-link hits a pre-existing, unrelated column-
10595    /// resolution bug in `multilink_junction_info` (both source_col and
10596    /// target_col resolve to the same link name when source type == target
10597    /// type) — tracked separately, not fixed here.
10598    fn make_schema_with_through_and_prop() -> SchemaDescriptor {
10599        let id_prop = || PropertyDescriptor {
10600            name: "id".into(),
10601            pg_type: "uuid".into(),
10602            nullable: false,
10603            default_sql: Some("gen_random_uuid()".into()),
10604            description: None,
10605            default_pyql: None,
10606            check_constraints: vec![],
10607            is_exclusive: true,
10608            is_pk: true,
10609            is_readonly: true,
10610            rewrites: vec![],
10611            tuple_members: None,
10612            column_type: None,
10613        };
10614        let name_prop = || PropertyDescriptor {
10615            name: "name".into(),
10616            pg_type: "text".into(),
10617            nullable: false,
10618            default_sql: None,
10619            description: None,
10620            check_constraints: vec![],
10621            default_pyql: None,
10622            is_exclusive: false,
10623            is_pk: false,
10624            is_readonly: false,
10625            rewrites: vec![],
10626            tuple_members: None,
10627            column_type: None,
10628        };
10629        SchemaDescriptor {
10630            types: vec![
10631                TypeDescriptor {
10632                    name: "Product".into(),
10633                    module: "default".into(),
10634                    table: "Product".into(),
10635                    abstract_: false,
10636                    materialized: false,
10637                    description: None,
10638                    parents: vec![],
10639                    interfaces: vec![],
10640                    bases: vec![],
10641                    properties: vec![id_prop(), name_prop()],
10642                    links: vec![],
10643                    multilinks: vec![MultiLinkDescriptor {
10644                        name: "tags".into(),
10645                        target: "default::Tag".into(),
10646                        through: Some("default::ProductTag".into()),
10647                        nullable: false,
10648                        description: None,
10649                        default_pyql: None,
10650                        on_delete: vec![],
10651                        is_exclusive: false,
10652                    }],
10653                    computed: vec![],
10654                    constraints: vec![],
10655                    indexes: vec![],
10656                    partition: None,
10657                    vector_indexes: vec![],
10658                    search_indexes: vec![],
10659                    triggers: vec![],
10660                    junction: false,
10661                    signals: vec![],
10662                },
10663                TypeDescriptor {
10664                    name: "Tag".into(),
10665                    module: "default".into(),
10666                    table: "Tag".into(),
10667                    abstract_: false,
10668                    materialized: false,
10669                    description: None,
10670                    parents: vec![],
10671                    interfaces: vec![],
10672                    bases: vec![],
10673                    properties: vec![id_prop(), name_prop()],
10674                    links: vec![],
10675                    multilinks: vec![],
10676                    computed: vec![],
10677                    constraints: vec![],
10678                    indexes: vec![],
10679                    partition: None,
10680                    vector_indexes: vec![],
10681                    search_indexes: vec![],
10682                    triggers: vec![],
10683                    junction: false,
10684                    signals: vec![],
10685                },
10686                TypeDescriptor {
10687                    name: "ProductTag".into(),
10688                    module: "default".into(),
10689                    table: "Product.tags".into(),
10690                    abstract_: false,
10691                    materialized: false,
10692                    description: None,
10693                    parents: vec![],
10694                    interfaces: vec![],
10695                    bases: vec![],
10696                    properties: vec![
10697                        id_prop(),
10698                        PropertyDescriptor {
10699                            name: "weight".into(),
10700                            pg_type: "float8".into(),
10701                            nullable: false,
10702                            default_sql: None,
10703                            default_pyql: None,
10704                            description: None,
10705                            check_constraints: vec![],
10706                            is_exclusive: false,
10707                            is_pk: false,
10708                            is_readonly: false,
10709                            rewrites: vec![],
10710                            tuple_members: None,
10711                            column_type: None,
10712                        },
10713                    ],
10714                    // No declared Link pointers — matches pylon-demo's actual
10715                    // ProductTag, which relies on multilink_junction_info's
10716                    // "source"/"target" defaults.
10717                    links: vec![],
10718                    multilinks: vec![],
10719                    computed: vec![],
10720                    constraints: vec![],
10721                    indexes: vec![],
10722                    partition: None,
10723                    vector_indexes: vec![],
10724                    search_indexes: vec![],
10725                    triggers: vec![],
10726                    junction: true,
10727                    signals: vec![],
10728                },
10729            ],
10730            scalars: vec![],
10731            enums: vec![],
10732            named_tuples: vec![],
10733            globals: vec![],
10734            functions: vec![],
10735            aliases: vec![],
10736            channels: vec![],
10737            ..Default::default()
10738        }
10739    }
10740
10741    #[test]
10742    fn test_link_property_in_a_links_own_filter() {
10743        // `filter @weight > x` reads the junction row of the link being
10744        // filtered — `@prop` used to parse only as a postfix on a named
10745        // path, so a bare one was "expected an expression, found '@'".
10746        let schema = make_schema_with_through_and_prop();
10747        let out = compile_and_emit_with("SELECT Product { tags: { } filter @weight > 0.5 }", &schema);
10748        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
10749    }
10750
10751    #[test]
10752    fn test_exists_on_a_link_property() {
10753        // `exists @weight` — the junction column the link's own modifiers are
10754        // compiled against. `exists` only knew how to take a pointer name.
10755        let schema = make_schema_with_through_and_prop();
10756        let out = compile_and_emit_with("SELECT Product { tags: { } filter exists @weight }", &schema);
10757        assert!(out.sql.contains("\"jt\".\"weight\" IS NOT NULL"), "{}", out.sql);
10758    }
10759
10760    #[test]
10761    fn test_link_property_in_a_computed_link_filter() {
10762        let schema = make_schema_with_through_and_prop();
10763        let out = compile_and_emit_with(
10764            "SELECT Product { best := (select .tags filter @weight > 0.5 limit 1) { @weight } }",
10765            &schema,
10766        );
10767        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
10768        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10769    }
10770
10771    #[test]
10772    fn test_link_property_in_a_projected_sub_select_reads_the_junction() {
10773        // The flat-join form names the junction with an ordinary alias
10774        // rather than "jt", so the scope has to carry the alias too.
10775        let schema = make_schema_with_through_and_prop();
10776        let out = compile_and_emit_with(
10777            "SELECT Product { t := (select .tags filter @weight > 0.5 limit 1).id }",
10778            &schema,
10779        );
10780        assert!(out.sql.contains("\"t3\".\"weight\" > (0.5::float8)"), "{}", out.sql);
10781    }
10782
10783    #[test]
10784    fn test_unknown_link_property_names_the_through_type() {
10785        let schema = make_schema_with_through_and_prop();
10786        let ast = parse::parse("SELECT Product { tags: { } filter @nope > 0.5 }").unwrap();
10787        let err = match ir::compile(&ast, &schema) {
10788            Ok(_) => panic!("expected a compile error"),
10789            Err(e) => format!("{e}"),
10790        };
10791        assert!(err.contains("default::ProductTag") && err.contains("nope"), "{err}");
10792    }
10793
10794    #[test]
10795    fn test_link_property_outside_a_link_says_where_it_belongs() {
10796        let schema = make_schema_with_through_and_prop();
10797        let ast = parse::parse("SELECT Product { t := @weight }").unwrap();
10798        let err = match ir::compile(&ast, &schema) {
10799            Ok(_) => panic!("expected a compile error"),
10800            Err(e) => format!("{e}"),
10801        };
10802        assert!(err.contains("is a link property"), "{err}");
10803    }
10804
10805    #[test]
10806    fn test_multilink_append_with_link_property() {
10807        let schema = make_schema_with_through_and_prop();
10808        let out = compile_and_emit_with(
10809            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
10810            &schema,
10811        );
10812        // Extra junction column present in both the INSERT column list and the SELECT list.
10813        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
10814        // Re-linking an existing pair with a new weight must update in place,
10815        // not silently keep the old value (a bare `DO NOTHING` would).
10816        assert!(
10817            out.sql
10818                .contains("ON CONFLICT (\"source\", \"target\") DO UPDATE SET \"weight\" = EXCLUDED.\"weight\""),
10819            "missing upsert conflict clause:\n{}",
10820            out.sql
10821        );
10822    }
10823
10824    #[test]
10825    fn test_multilink_append_union_with_different_link_property_values() {
10826        // The realistic Data Explorer scenario: multiple checked targets in
10827        // one `+=`, each with its own distinct property value — expressed as
10828        // a `union` of individually-shaped target selects.
10829        let schema = make_schema_with_through_and_prop();
10830        let out = compile_and_emit_with(
10831            "UPDATE Product FILTER .id = $id SET { \
10832                tags += (SELECT Tag FILTER .id = $aid) { @weight := <float64>$w1 } \
10833                    union (SELECT Tag FILTER .id = $bid) { @weight := <float64>$w2 } \
10834            }",
10835            &schema,
10836        );
10837        assert!(
10838            out.sql.contains("UNION ALL"),
10839            "expected a UNION ALL between the two shaped targets:\n{}",
10840            out.sql
10841        );
10842        // Both branches must select the same "weight" column (with their own
10843        // value) so the union has a consistent column list.
10844        assert_eq!(
10845            out.sql.matches("AS \"weight\"").count(),
10846            2,
10847            "each union branch must project its own weight:\n{}",
10848            out.sql
10849        );
10850    }
10851
10852    #[test]
10853    fn test_multilink_append_without_link_property_keeps_do_nothing() {
10854        // No `@prop := ...` anywhere — must keep the original DO NOTHING
10855        // behavior (no spurious upsert/extra columns for the common case).
10856        let schema = make_schema_with_through_and_prop();
10857        let out = compile_and_emit_with(
10858            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) }",
10859            &schema,
10860        );
10861        assert!(
10862            out.sql.contains("ON CONFLICT DO NOTHING"),
10863            "expected plain DO NOTHING when no link properties are set:\n{}",
10864            out.sql
10865        );
10866        assert!(
10867            !out.sql.contains("\"weight\""),
10868            "unexpected weight column with no link property assignment:\n{}",
10869            out.sql
10870        );
10871    }
10872
10873    #[test]
10874    fn test_multilink_link_property_rejected_on_standard_junction() {
10875        // Person.posts is a Standard (implicit) junction — no user-declared
10876        // properties, so `@prop := ...` must be rejected at compile time.
10877        let ast = crate::parse::parse(
10878            "UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $t) { @weight := <float64>$w } }",
10879        ).unwrap();
10880        match crate::ir::compile(&ast, &make_schema()) {
10881            Ok(_) => panic!("expected a compile error for link property on a Standard junction"),
10882            Err(e) => assert!(
10883                e.to_string().contains("Through"),
10884                "expected a Through[...]-related error, got: {e}"
10885            ),
10886        }
10887    }
10888
10889    #[test]
10890    fn test_multilink_link_property_rejected_on_remove() {
10891        let schema = make_schema_with_through_and_prop();
10892        let ast = crate::parse::parse(
10893            "UPDATE Product FILTER .id = $id SET { tags -= (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
10894        )
10895        .unwrap();
10896        match crate::ir::compile(&ast, &schema) {
10897            Ok(_) => panic!("expected a compile error for link property on a remove (-=)"),
10898            Err(e) => assert!(
10899                e.to_string().contains("removing"),
10900                "expected a remove-related error, got: {e}"
10901            ),
10902        }
10903    }
10904
10905    #[test]
10906    fn test_insert_with_multilink_assignment() {
10907        // Regression test: `insert Product { tags := ... }` used to fail with
10908        // "object type 'default::Product' has no link or property 'tags'" —
10909        // compile_assignments_inner never checked resolve_multilink, and
10910        // IrInsert had no multi-link handling at all.
10911        let schema = make_schema_with_through_and_prop();
10912        let out = compile_and_emit_with(
10913            "INSERT Product { name := $name, tags := (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
10914            &schema,
10915        );
10916        // The row insert must happen before the junction insert references its id.
10917        assert!(
10918            out.sql.contains("\"_w__ids\" AS (\nINSERT INTO"),
10919            "missing row-insert CTE:\n{}",
10920            out.sql
10921        );
10922        assert!(
10923            out.sql.contains("\"_w__ml_add_0\" AS ("),
10924            "missing junction-append CTE:\n{}",
10925            out.sql
10926        );
10927        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
10928        assert!(
10929            out.sql.contains("\"_w\" AS (\n    SELECT * FROM \"_w__ids\"\n)"),
10930            "missing _w passthrough:\n{}",
10931            out.sql
10932        );
10933        assert_eq!(
10934            out.sql.matches("WITH\n").count(),
10935            1,
10936            "must be a single flat top-level WITH block:\n{}",
10937            out.sql
10938        );
10939    }
10940
10941    /// A user `with` binding *consumed by* a statement that builds CTEs of
10942    /// its own. The binding is emitted by the generic `ir.ctes` path while
10943    /// the multi-link/junction machinery has already opened its own `WITH`;
10944    /// prefixing a second one produced `WITH ... WITH ...`, which compiles
10945    /// cleanly and then fails at execution with `syntax error at or near
10946    /// "WITH"`. Covers both link kinds, since each reaches the junction
10947    /// emitter by a different route.
10948    #[test]
10949    fn test_user_with_binding_merges_into_a_statements_own_ctes() {
10950        let schema = make_schema_with_through_and_prop();
10951        for query in [
10952            "with t := (select Tag filter .id = $tid) insert Product { name := $name, tags := t }",
10953            "with t := (select Tag filter .id = $tid) \
10954             update Product filter .name = $name set { tags += t }",
10955        ] {
10956            let out = compile_and_emit_with(query, &schema);
10957            let flat: String = out.sql.split_whitespace().collect::<Vec<_>>().join(" ");
10958            assert!(
10959                !flat.contains(") WITH "),
10960                "a second WITH clause is invalid SQL:\n{}",
10961                out.sql
10962            );
10963            assert_eq!(
10964                out.sql.matches("WITH").count(),
10965                1,
10966                "exactly one WITH keyword expected:\n{}",
10967                out.sql
10968            );
10969        }
10970    }
10971
10972    #[test]
10973    fn test_with_bound_insert_with_multilink_assignment() {
10974        // Same as above, but bound via WITH (the Data Explorer's actual
10975        // shape) — exercises emit_user_cte_parts's insert_has_any_multilink
10976        // branch instead of emit_insert_stmt's bare-statement path.
10977        let schema = make_schema_with_through_and_prop();
10978        let out = compile_and_emit_with(
10979            "with insert0 := (insert Product { name := $name, tags := (select Tag filter .id = $tid) }) select insert0",
10980            &schema,
10981        );
10982        assert!(
10983            out.sql.contains("\"insert0__ids\" AS (\nINSERT INTO"),
10984            "missing row-insert CTE:\n{}",
10985            out.sql
10986        );
10987        assert!(
10988            out.sql.contains("\"insert0__ml_add_0\" AS ("),
10989            "missing junction-append CTE:\n{}",
10990            out.sql
10991        );
10992        assert!(
10993            out.sql
10994                .contains("\"insert0\" AS (\n    SELECT * FROM \"insert0__ids\"\n)"),
10995            "missing insert0 passthrough:\n{}",
10996            out.sql
10997        );
10998        assert_eq!(
10999            out.sql.matches("WITH\n").count(),
11000            1,
11001            "must be a single flat top-level WITH block:\n{}",
11002            out.sql
11003        );
11004    }
11005
11006    #[test]
11007    fn test_with_block_cte_over_computed_global_merges_into_single_with_clause() {
11008        // Regression: `with user := (select global current_user) select user;`
11009        // produced two separate top-level `WITH` keywords ("syntax error at
11010        // or near WITH" from Postgres) whenever the computed global's own
11011        // expression referenced a session global (registering a global CTE
11012        // in addition to the user-defined "user" CTE). The merge check in
11013        // emit() only recognized the "WITH " (space) prefix convention, not
11014        // emit_cte_prefix's "WITH\n" (newline) convention used here, so it
11015        // prepended a second WITH block instead of merging into the first.
11016        let mut schema = make_schema();
11017        schema.globals.push(GlobalDescriptor {
11018            name: "current_user_id".into(),
11019            module: "default".into(),
11020            scalar_type: "std::uuid".into(),
11021            required: false,
11022            default_expr: None,
11023            computed_expr: None,
11024        });
11025        schema.globals.push(GlobalDescriptor {
11026            name: "current_user".into(),
11027            module: "default".into(),
11028            scalar_type: "Person".into(),
11029            required: false,
11030            default_expr: None,
11031            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11032        });
11033        let out = compile_and_emit_with("with\n  user := (select global current_user)\nselect user;", &schema);
11034        assert_eq!(
11035            out.sql.matches("WITH").count(),
11036            1,
11037            "must be a single WITH clause, got:\n{}",
11038            out.sql
11039        );
11040    }
11041
11042    #[test]
11043    fn test_several_mutations_in_a_free_set_each_become_a_cte() {
11044        let schema = make_schema();
11045        let out = compile_and_emit_with(
11046            "select { (update Person filter .name = 'a' set { age := 1 }), \
11047                      (update Company filter .name = 'b' set { name := 'c' }) }",
11048            &schema,
11049        );
11050        assert_eq!(
11051            out.sql.matches("UPDATE").count(),
11052            2,
11053            "both mutations have to run, got:\n{}",
11054            out.sql
11055        );
11056        assert!(out.sql.contains("WITH"), "each becomes a CTE, got:\n{}", out.sql);
11057    }
11058
11059    #[test]
11060    fn test_updating_a_binding_touches_only_its_own_rows() {
11061        // The binding names the rows to update, so resolving it to its type
11062        // without narrowing would rewrite every row in the table.
11063        let schema = make_schema();
11064        let out = compile_and_emit_with(
11065            "with mine := (select Person filter .name = 'a') select { (update mine set { age := 1 }) }",
11066            &schema,
11067        );
11068        assert!(
11069            out.sql.contains("UPDATE") && out.sql.contains("= ANY(ARRAY(SELECT"),
11070            "expected the update narrowed to the binding's rows, got:\n{}",
11071            out.sql
11072        );
11073    }
11074
11075    #[test]
11076    fn test_updating_a_traversal_touches_only_the_rows_it_lands_on() {
11077        let mut schema = make_schema();
11078        // The traversal's target needs an id to narrow against; every real
11079        // schema declares one, this one leaves it off Company.
11080        let company = schema
11081            .types
11082            .iter_mut()
11083            .find(|t| t.name == "Company")
11084            .expect("test schema has a Company type");
11085        company.properties.push(crate::schema::PropertyDescriptor {
11086            name: "id".into(),
11087            pg_type: "uuid".into(),
11088            nullable: false,
11089            default_sql: Some("uuidv7()".into()),
11090            default_pyql: None,
11091            description: None,
11092            check_constraints: vec![],
11093            is_exclusive: true,
11094            is_pk: true,
11095            is_readonly: true,
11096            rewrites: vec![],
11097            tuple_members: None,
11098            column_type: None,
11099        });
11100        let out = compile_and_emit_with(
11101            "with mine := (select Person filter .name = 'a') select { (update mine.company set { name := 'x' }) }",
11102            &schema,
11103        );
11104        assert!(
11105            out.sql.contains("= ANY(ARRAY(SELECT"),
11106            "expected the update narrowed to the traversal's rows, got:\n{}",
11107            out.sql
11108        );
11109        assert!(
11110            out.sql.contains("\"Company\""),
11111            "expected the table the traversal ends on, got:\n{}",
11112            out.sql
11113        );
11114    }
11115
11116    #[test]
11117    fn test_aggregate_over_a_multi_valued_path_keeps_the_set_flat() {
11118        // Regression: `array_agg(a.posts.title)` compiled the path as an
11119        // expression, where a multi-valued path stands for the array of its
11120        // elements — so the aggregate wrapped that array and the result came
11121        // back one level deep, `[[t1, t2]]` where Gel gives `[t1, t2]`.
11122        let schema = make_schema();
11123        let out = compile_and_emit_with(
11124            "with a := (select Person limit 1) select { titles := array_agg(a.posts.title) }",
11125            &schema,
11126        );
11127        assert!(
11128            !out.sql.contains("array_agg(ARRAY("),
11129            "the aggregate must take the set, not the array standing for it, got:\n{}",
11130            out.sql
11131        );
11132        assert!(
11133            out.sql.contains("array_agg(") && out.sql.contains("\"title\""),
11134            "expected the aggregate over the traversal's own column, got:\n{}",
11135            out.sql
11136        );
11137        // The free object keeps its composite emission, so values stay typed
11138        // rather than passing through jsonb as strings.
11139        assert!(
11140            out.sql.contains("ROW(") && !out.sql.contains("jsonb_build_object"),
11141            "expected a composite row, got:\n{}",
11142            out.sql
11143        );
11144    }
11145
11146    #[test]
11147    fn test_with_bound_root_read_by_name_inside_a_nested_select() {
11148        // Regression: `compile_path_select` resolved a `with`-bound root
11149        // correctly, but the multi-valued check straight after it re-resolved
11150        // the same root as a plain type, so reading the binding by name from a
11151        // nested sub-select failed with "unknown type 'owner'".
11152        let schema = make_schema();
11153        let out = compile_and_emit_with(
11154            "with
11155  owner := (select Person limit 1)
11156select owner { posts := (select owner.posts.title) };",
11157            &schema,
11158        );
11159        assert!(
11160            out.sql.contains("WITH") && out.sql.contains("\"title\""),
11161            "expected the nested select to traverse the binding, got:\n{}",
11162            out.sql
11163        );
11164    }
11165
11166    #[test]
11167    fn test_path_traversal_into_with_bound_cte_of_object_type() {
11168        // Regression: `with user := (select global current_user) select
11169        // user.name;` failed with "unknown type 'user'" — compile_path_select
11170        // (used for multi-step absolute paths like `user.name`) only ever
11171        // tried resolve_type(root_name), never checking whether the root
11172        // name is a WITH-block CTE bound to an object type.
11173        let mut schema = make_schema();
11174        schema.globals.push(GlobalDescriptor {
11175            name: "current_user_id".into(),
11176            module: "default".into(),
11177            scalar_type: "std::uuid".into(),
11178            required: false,
11179            default_expr: None,
11180            computed_expr: None,
11181        });
11182        schema.globals.push(GlobalDescriptor {
11183            name: "current_user".into(),
11184            module: "default".into(),
11185            scalar_type: "Person".into(),
11186            required: false,
11187            default_expr: None,
11188            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11189        });
11190        let out = compile_and_emit_with(
11191            "with\n  user := (select global current_user)\nselect user.name;",
11192            &schema,
11193        );
11194        assert!(
11195            out.sql.contains("FROM \"user\""),
11196            "expected path traversal from the CTE, got:\n{}",
11197            out.sql
11198        );
11199        assert_eq!(
11200            out.sql.matches("WITH").count(),
11201            1,
11202            "must be a single WITH clause, got:\n{}",
11203            out.sql
11204        );
11205    }
11206
11207    #[test]
11208    fn test_with_bound_schema_object_property_access_in_filter_expression() {
11209        // Regression: `with person := (select detached Person filter ...)
11210        // select Person filter .id not in person.id;` failed with "absolute
11211        // paths are not valid in expression context" — compile_path's
11212        // schema-bound-CTE-in-expression-position case only ever handled a
11213        // WITH-bound *free object* (resolve_cte_path), never a WITH-bound
11214        // *schema object* CTE, even though its own SELECT * CTE source
11215        // (emit_dml_as_cte_source) always exposes every raw column.
11216        let schema = make_schema();
11217        let out = compile_and_emit_with(
11218            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11219             select Person filter .id not in person.id;",
11220            &schema,
11221        );
11222        assert!(
11223            out.sql.contains("<> ALL((SELECT") && out.sql.contains(".\"id\"") && out.sql.contains("FROM \"person\""),
11224            "got:\n{}",
11225            out.sql
11226        );
11227    }
11228
11229    #[test]
11230    fn test_with_bound_schema_object_scalar_property_access() {
11231        // Not just `.id` — any scalar property of the bound type is
11232        // reachable, since the CTE source is `SELECT *` regardless of the
11233        // binding's own declared shape.
11234        let schema = make_schema();
11235        let out = compile_and_emit_with(
11236            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11237             select Person filter .name = person.name;",
11238            &schema,
11239        );
11240        assert!(
11241            out.sql.contains("(SELECT") && out.sql.contains(".\"name\"") && out.sql.contains("FROM \"person\""),
11242            "got:\n{}",
11243            out.sql
11244        );
11245    }
11246
11247    #[test]
11248    fn test_with_bound_schema_object_link_traversal_reaches_nested_property() {
11249        // Generalization: a with-bound schema object's CTE source is
11250        // `SELECT *`, so it's traversable exactly like a real type name —
11251        // not just its own scalar properties, but a *link's* properties too
11252        // (`person.company.name`), via the same path-traversal machinery
11253        // `TypeName.a.b.c` already uses (compile_path_select), wrapped as a
11254        // correlated subquery (IrExpr::PathSubquery).
11255        let schema = make_schema();
11256        let out = compile_and_emit_with(
11257            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11258             select Company filter .name = person.company.name;",
11259            &schema,
11260        );
11261        assert!(
11262            out.sql.contains("(SELECT") && out.sql.contains("\"name\"") && out.sql.contains("\"Company\""),
11263            "got:\n{}",
11264            out.sql
11265        );
11266    }
11267
11268    #[test]
11269    fn test_with_bound_schema_object_bare_link_reduces_to_id() {
11270        // A bare link at the end of the chain (`person.company`, no further
11271        // traversal) reduces to the linked object's id, matching the
11272        // existing bare-CTE-reference convention (IrExpr::CteRef).
11273        let schema = make_schema();
11274        let out = compile_and_emit_with(
11275            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11276             select Person filter .id = person.company;",
11277            &schema,
11278        );
11279        assert!(
11280            out.sql.contains("(SELECT") && out.sql.contains("\"id\""),
11281            "got:\n{}",
11282            out.sql
11283        );
11284    }
11285
11286    #[test]
11287    fn test_with_bound_schema_object_unknown_property_suggests_a_close_match() {
11288        let schema = make_schema();
11289        let ast = parse::parse(
11290            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11291             select Person filter .name = person.nam;",
11292        )
11293        .unwrap();
11294        match ir::compile(&ast, &schema) {
11295            Err(err) => assert!(format!("{err}").contains("Did you mean 'name'"), "got: {err}"),
11296            Ok(_) => panic!("expected a compile error"),
11297        }
11298    }
11299
11300    #[test]
11301    fn test_with_bound_free_object_passthrough_preserves_all_fields() {
11302        // Regression: `with test := { test2 := 1.0, test3 := 'str' } select
11303        // test;` decoded as just `1.0` (the CTE's first field) — a bare
11304        // reference to a free-object CTE (`IrFreeExpr::CtePassthrough`) had
11305        // its shape hardcoded to `ShapeNode::Scalar` regardless of what the
11306        // CTE actually held.
11307        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test;");
11308        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11309            panic!("expected Object shape, got {:?}", out.shape.root)
11310        };
11311        assert_eq!(pointers.len(), 2);
11312        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, .. } if name == "test2"));
11313        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, .. } if name == "test3"));
11314    }
11315
11316    #[test]
11317    fn test_with_bound_free_object_field_access() {
11318        // Regression: `with test := {...} select test.test2;` failed with
11319        // "unknown type 'test'" — any absolute path with 2+ steps
11320        // unconditionally routed to compile_path_select, which only knows
11321        // how to resolve real schema types, never a free-value CTE.
11322        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test.test2;");
11323        assert!(out.sql.contains("\"test2\" FROM \"test\""), "got:\n{}", out.sql);
11324    }
11325
11326    #[test]
11327    fn test_with_bound_free_object_nested_field_access_chain() {
11328        // A field-access chain through a *nested* free object literal
11329        // (`test.test3.foo`) must resolve the first hop via the CTE's own
11330        // materialized column, then extract `foo` as jsonb from that value
11331        // — the fix must not be hardcoded to exactly 2 path steps.
11332        let out =
11333            compile_and_emit("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.foo;");
11334        assert!(out.sql.contains("\"test3\" FROM \"test\""), "got:\n{}", out.sql);
11335        assert!(
11336            out.sql.contains("->'foo'"),
11337            "expected jsonb field extraction, got:\n{}",
11338            out.sql
11339        );
11340    }
11341
11342    #[test]
11343    fn test_with_bound_free_object_nested_field_access_wrong_field_errors() {
11344        // A typo'd field name anywhere in the chain must still be a compile
11345        // error, not silently emit SQL that returns NULL at runtime.
11346        let schema = make_schema();
11347        let ast = parse::parse("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.nope;")
11348            .unwrap();
11349        assert!(ir::compile(&ast, &schema).is_err());
11350    }
11351
11352    #[test]
11353    fn test_nested_free_object_literal_in_computed_shape_element() {
11354        // Regression: a free object literal (`{ foo := 'bar' }`) nested
11355        // inside a computed shape element — not the top-level SELECT
11356        // result — hit "shapes and set literals are not valid in
11357        // expression context"; free object literals were only ever handled
11358        // at the statement level.
11359        let out = compile_and_emit("select default::Person { id, test := { foo := 'bar' } };");
11360        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11361            panic!("expected Object shape")
11362        };
11363        let test_node = pointers
11364            .iter()
11365            .find(|p| matches!(p, ShapeNode::NamedTuple { name, .. } if name == "test"))
11366            .unwrap_or_else(|| panic!("expected a NamedTuple shape node for 'test', got {:?}", pointers));
11367        assert!(matches!(
11368            test_node,
11369            ShapeNode::NamedTuple {
11370                is_free_object: true,
11371                ..
11372            }
11373        ));
11374    }
11375
11376    #[test]
11377    fn test_bare_free_cte_reference_in_computed_shape_collapses_to_empty() {
11378        // A free-object CTE referenced bare (no shape) as a computed shape
11379        // element's value has nothing to project — a free object needs an
11380        // explicit shape to know what to expose (unlike a tuple, which is
11381        // a plain value with no such requirement).
11382        let out = compile_and_emit(
11383            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
11384             select default::Person { id, test := test };",
11385        );
11386        assert!(
11387            out.sql.contains("jsonb_build_object()"),
11388            "expected an empty free object, got:\n{}",
11389            out.sql
11390        );
11391    }
11392
11393    #[test]
11394    fn test_shaped_free_cte_reference_projects_fields() {
11395        // `test := test { test2 }` must project just the named field out
11396        // of the underlying free object — neither collapsing to empty nor
11397        // returning every field.
11398        let out = compile_and_emit(
11399            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
11400             select default::Person { id, test := test { test2 } };",
11401        );
11402        assert!(out.sql.contains("jsonb_build_object('test2'"), "got:\n{}", out.sql);
11403        assert!(
11404            !out.sql.contains("'test3'"),
11405            "test3 should not be projected, got:\n{}",
11406            out.sql
11407        );
11408    }
11409
11410    #[test]
11411    fn test_insert_multilink_remove_rejected() {
11412        let schema = make_schema_with_through_and_prop();
11413        let ast =
11414            crate::parse::parse("INSERT Product { name := $name, tags -= (SELECT Tag FILTER .id = $tid) }").unwrap();
11415        match crate::ir::compile(&ast, &schema) {
11416            Ok(_) => panic!("expected a compile error for `-=` on a multi-link at insert time"),
11417            Err(e) => assert!(
11418                e.to_string().contains("nothing to remove"),
11419                "expected a 'nothing to remove yet' error, got: {e}"
11420            ),
11421        }
11422    }
11423
11424    #[test]
11425    fn test_multilink_junction_info_disambiguates_self_referencing_through_type() {
11426        // Regression test for a self-referencing through-link (Person.friends
11427        // via PersonFriend, which declares two Person-typed links: "person"
11428        // and "friend"). Naively matching purely by target type resolves
11429        // *both* source_col and target_col to the same first-matching link
11430        // ("person"), silently collapsing the junction to a single column
11431        // and losing the other side entirely.
11432        let schema = make_schema_with_through();
11433        let out = compile_and_emit_with(
11434            "UPDATE Person FILTER .id = $id SET { friends += (SELECT Person FILTER .id = $fid) }",
11435            &schema,
11436        );
11437        assert!(
11438            out.sql.contains("(\"person\", \"friend\")"),
11439            "expected two distinct FK columns:\n{}",
11440            out.sql
11441        );
11442        assert!(
11443            !out.sql.contains("(\"person\", \"person\")"),
11444            "source/target collapsed to the same column:\n{}",
11445            out.sql
11446        );
11447    }
11448
11449    #[test]
11450    fn test_shape_descriptor_scalars() {
11451        let out = compile_and_emit("SELECT Person { name, age }");
11452        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11453            panic!()
11454        };
11455        assert_eq!(pointers.len(), 4); // __type__, the implicit id, name, age
11456        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, position: 0 } if name == "__type__"));
11457        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, position: 1 } if name == "id"));
11458        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, position: 2 } if name == "name"));
11459        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, position: 3 } if name == "age"));
11460    }
11461
11462    #[test]
11463    fn test_shape_descriptor_multi_link() {
11464        let out = compile_and_emit("SELECT Person { name, posts { title } }");
11465        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11466            panic!()
11467        };
11468        // pointers: [__type__, id, name, posts]
11469        assert_eq!(pointers.len(), 4);
11470        let ShapeNode::Array {
11471            name,
11472            position,
11473            element,
11474        } = &pointers[3]
11475        else {
11476            panic!()
11477        };
11478        assert_eq!(name, "posts");
11479        assert_eq!(*position, 3);
11480        let ShapeNode::Object {
11481            pointers: elem_pointers,
11482            ..
11483        } = element.as_ref()
11484        else {
11485            panic!()
11486        };
11487        // element pointers: [__type__, title] — `Post` has no pk property in
11488        // this fixture, so nothing is injected into its shape.
11489        assert_eq!(elem_pointers.len(), 2);
11490    }
11491
11492    #[test]
11493    fn test_select_order_by_limit() {
11494        let out = compile_and_emit("SELECT Person { name } ORDER BY .name ASC LIMIT 10");
11495        assert!(out.sql.contains("ORDER BY"));
11496        assert!(out.sql.contains("LIMIT 10"));
11497    }
11498
11499    #[test]
11500    fn test_insert_returning() {
11501        let out = compile_and_emit("INSERT Person { name := 'Alice', age := 30 }");
11502        assert!(out.sql.contains("INSERT INTO \"public\".\"Person\""));
11503        assert!(out.sql.contains("RETURNING"));
11504        assert!(out.sql.contains("'default::Person'::text"));
11505        assert!(out.sql.contains(") AS result"));
11506        // Bare INSERT returns pk only
11507        let ShapeNode::Object {
11508            cardinality, pointers, ..
11509        } = &out.shape.root
11510        else {
11511            panic!()
11512        };
11513        assert_eq!(*cardinality, Cardinality::Required);
11514        // Only __type__ and id — not name or age
11515        assert!(
11516            pointers
11517                .iter()
11518                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "id"))
11519        );
11520        assert!(
11521            !pointers
11522                .iter()
11523                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "name"))
11524        );
11525    }
11526
11527    #[test]
11528    fn test_update_returning() {
11529        let out = compile_and_emit("UPDATE Person FILTER .name = $name SET { age := 31 }");
11530        assert!(out.sql.contains("UPDATE \"public\".\"Person\""));
11531        assert!(out.sql.contains("SET"));
11532        // Bare UPDATE returns pk only
11533        assert!(out.sql.contains("RETURNING"));
11534        assert!(out.sql.contains("'default::Person'::text"));
11535        assert!(!out.sql.contains("\"name\"::text"), "bare UPDATE must not return name");
11536    }
11537
11538    #[test]
11539    fn test_update_set_tuple_param_cast_uses_direct_jsonb_cast_not_to_jsonb() {
11540        // Regression: to_jsonb($N) on a bare, still-untyped parameter fails at
11541        // execution time with "could not determine polymorphic type because
11542        // input has type unknown" — Postgres can't dispatch a polymorphic
11543        // function against an unknown-typed placeholder. A direct
11544        // ($N)::jsonb cast resolves the parameter's type from the cast
11545        // itself instead, matching how e.g. `$1::uuid` already works.
11546        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := <tuple<x: float64>>$val }");
11547        assert!(
11548            out.sql.contains(")::jsonb"),
11549            "expected a direct ::jsonb cast, got:\n{}",
11550            out.sql
11551        );
11552        assert!(
11553            !out.sql.contains("to_jsonb($"),
11554            "must not pass a bare param straight into to_jsonb(): got:\n{}",
11555            out.sql
11556        );
11557    }
11558
11559    #[test]
11560    fn test_empty_set_cast_to_object_type_clears_optional_link() {
11561        // Regression: unsetting an optional single-link generates
11562        // `<TargetType>{}` (a cast of the empty set, not the bare `{}` the
11563        // assignment-position special case in compile_assignments_inner
11564        // already handled) — that TypeCast wraps the empty set, so it fell
11565        // through to the generic Set/Shape rejection instead ("shapes and
11566        // set literals are not valid in expression context"). Must compile
11567        // to a plain NULL for the link's FK column: `<AnyType>{}` is always
11568        // NULL regardless of context.
11569        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { company := <default::Company>{} }");
11570        assert!(out.sql.contains("\"company_id\" = NULL"), "got:\n{}", out.sql);
11571    }
11572
11573    #[test]
11574    fn test_delete_returning() {
11575        let out = compile_and_emit("DELETE Person FILTER .id = $id");
11576        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""));
11577        // Bare DELETE returns pk only
11578        assert!(out.sql.contains("RETURNING"));
11579        assert!(out.sql.contains("'default::Person'::text"));
11580        assert!(!out.sql.contains("\"name\"::text"), "bare DELETE must not return name");
11581    }
11582
11583    #[test]
11584    fn test_select_over_insert() {
11585        let out = compile_and_emit("SELECT (INSERT Person { name := $name, age := $age }) { id, name }");
11586        // Must use a CTE
11587        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
11588        assert!(out.sql.contains("INSERT INTO"));
11589        assert!(out.sql.contains("RETURNING *"));
11590        // Outer SELECT shapes the result
11591        assert!(out.sql.contains("'default::Person'::text"));
11592        assert!(out.sql.contains("\"name\"::text"));
11593    }
11594
11595    #[test]
11596    fn test_select_over_update() {
11597        let out = compile_and_emit("SELECT (UPDATE Person FILTER .id = $id SET { name := $name }) { id, name }");
11598        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
11599        assert!(out.sql.contains("UPDATE"));
11600        assert!(out.sql.contains("RETURNING \"t1\".*"), "{}", out.sql);
11601        assert!(out.sql.contains("\"name\"::text"));
11602    }
11603
11604    #[test]
11605    fn test_insert_user_specified_id_denied_by_default() {
11606        let schema = make_schema();
11607        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
11608        match ir::compile_with_config(&ast, &schema, &ir::SessionConfig::default()) {
11609            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
11610            Ok(_) => panic!("expected id assignment to be denied by default"),
11611        }
11612    }
11613
11614    #[test]
11615    fn test_insert_user_specified_id_allowed_when_configured() {
11616        let schema = make_schema();
11617        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
11618        let config = ir::SessionConfig {
11619            allow_user_specified_id: true,
11620        };
11621        let ir_out = ir::compile_with_config(&ast, &schema, &config)
11622            .expect("expected id assignment to be allowed with allow_user_specified_id");
11623        let out = emit(&ir_out);
11624        assert!(out.sql.contains("INSERT INTO"));
11625    }
11626
11627    #[test]
11628    fn test_update_user_specified_id_denied_even_when_configured() {
11629        let schema = make_schema();
11630        let ast = parse::parse("UPDATE Person FILTER .name = $name SET { id := <uuid>$id }").unwrap();
11631        let config = ir::SessionConfig {
11632            allow_user_specified_id: true,
11633        };
11634        match ir::compile_with_config(&ast, &schema, &config) {
11635            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
11636            Ok(_) => panic!("expected UPDATE to always deny reassigning id"),
11637        }
11638    }
11639
11640    #[test]
11641    fn test_select_over_update_multilink_only() {
11642        // Regression test: an UPDATE bound to a single external name (here,
11643        // the implicit "_dml" wrapper for `SELECT (UPDATE ...)`) whose SET
11644        // clause is *only* a multi-link mutation, with no scalar/single-link
11645        // assignment. This used to either emit an empty `SET` clause
11646        // ("UPDATE ... SET  WHERE ..." — invalid SQL) or, in an earlier fix
11647        // attempt, nest the junction INSERT inside "_dml"'s own CTE body —
11648        // which Postgres rejects outright ("WITH clause containing a
11649        // data-modifying statement must be at the top level"). The junction
11650        // CTEs must instead be hoisted out as top-level siblings of "_dml".
11651        let out = compile_and_emit(
11652            "SELECT (UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $title) }) { id, name }",
11653        );
11654        assert!(
11655            out.sql.contains("\"_dml__ml_add_0\""),
11656            "missing junction-append CTE:\n{}",
11657            out.sql
11658        );
11659        assert!(out.sql.contains("INSERT INTO"), "missing junction INSERT:\n{}", out.sql);
11660        // No scalar changes -> the row-source CTE must be a SELECT, not an UPDATE
11661        // with an empty SET clause.
11662        assert!(
11663            out.sql.contains("\"_dml__ids\" AS (\nSELECT"),
11664            "expected SELECT-based _ids CTE:\n{}",
11665            out.sql
11666        );
11667        assert!(
11668            !out.sql.contains("SET\n\nWHERE") && !out.sql.contains("SET \nWHERE"),
11669            "empty SET clause regression:\n{}",
11670            out.sql
11671        );
11672        // The junction CTE must be a *sibling* at the top-level WITH, not
11673        // nested inside another CTE's body — only one "WITH" keyword total.
11674        assert_eq!(
11675            out.sql.matches("WITH\n").count(),
11676            1,
11677            "junction CTE must not be nested in a second WITH:\n{}",
11678            out.sql
11679        );
11680        // "_dml" itself must still resolve (as a passthrough) for the outer SELECT.
11681        assert!(
11682            out.sql.contains("\"_dml\" AS (\n    SELECT * FROM \"_dml__ids\"\n)"),
11683            "missing _dml passthrough:\n{}",
11684            out.sql
11685        );
11686    }
11687
11688    #[test]
11689    fn test_select_over_update_scalar_and_multilink() {
11690        // Mixed case: a scalar assignment alongside a multi-link mutation, both
11691        // bound to the same external name — the scalar SET must survive *and*
11692        // the junction mutation must still be emitted as its own top-level CTE.
11693        let out = compile_and_emit(
11694            "SELECT (UPDATE Person FILTER .id = $id SET { name := $name, posts += (SELECT Post FILTER .title = $title) }) { id, name }",
11695        );
11696        assert!(
11697            out.sql.contains("\"_dml__ml_add_0\""),
11698            "missing junction-append CTE:\n{}",
11699            out.sql
11700        );
11701        assert!(
11702            out.sql.contains("\"_dml__ids\" AS (\nUPDATE"),
11703            "expected UPDATE-based _ids CTE:\n{}",
11704            out.sql
11705        );
11706        assert!(
11707            out.sql.contains("\"name\" = "),
11708            "missing scalar SET assignment:\n{}",
11709            out.sql
11710        );
11711        assert_eq!(
11712            out.sql.matches("WITH\n").count(),
11713            1,
11714            "junction CTE must not be nested in a second WITH:\n{}",
11715            out.sql
11716        );
11717    }
11718
11719    #[test]
11720    fn test_with_bound_insert_and_multilink_update_forward_ref() {
11721        // The actual shape pylon-ui's Data Explorer generates for its
11722        // flagship "insert + link in one batch" scenario: a same-batch
11723        // forward-reference (Person.posts += a not-yet-existing Post, bound
11724        // to insert0) inside a multi-statement WITH. This is the exact query
11725        // that failed live against a real Postgres before this fix ("WITH
11726        // clause containing a data-modifying statement must be at the top
11727        // level"), so it's the most important regression to pin down.
11728        let out = compile_and_emit(
11729            "with insert0 := (insert Post { title := $title }), update0 := (update Person filter .id = $id set { posts += (select insert0) }) select { insert0, update0 }",
11730        );
11731        assert!(
11732            out.sql.contains("\"insert0\" AS (\n    INSERT INTO"),
11733            "missing insert0 CTE:\n{}",
11734            out.sql
11735        );
11736        assert!(
11737            out.sql.contains("\"update0__ml_add_0\""),
11738            "missing junction-append CTE for update0:\n{}",
11739            out.sql
11740        );
11741        assert!(
11742            out.sql.contains("\"update0__ids\" AS (\nSELECT"),
11743            "expected SELECT-based update0 ids CTE (no scalar changes):\n{}",
11744            out.sql
11745        );
11746        assert!(
11747            out.sql
11748                .contains("\"update0\" AS (\n    SELECT * FROM \"update0__ids\"\n)"),
11749            "missing update0 passthrough:\n{}",
11750            out.sql
11751        );
11752        // Exactly one WITH keyword — every CTE (insert0, update0__ids,
11753        // update0__ml_add_0, update0) must be a top-level sibling.
11754        assert_eq!(
11755            out.sql.matches("WITH\n").count(),
11756            1,
11757            "must be a single flat top-level WITH block:\n{}",
11758            out.sql
11759        );
11760    }
11761
11762    #[test]
11763    fn test_select_over_delete() {
11764        let out = compile_and_emit("SELECT (DELETE Person FILTER .id = $id) { id, name }");
11765        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
11766        assert!(out.sql.contains("DELETE FROM"));
11767        assert!(out.sql.contains("RETURNING *"));
11768        assert!(out.sql.contains("\"name\"::text"));
11769    }
11770
11771    fn make_schema_with_rewrite() -> SchemaDescriptor {
11772        use crate::schema::RewriteEntry;
11773        let mut schema = make_schema();
11774        // Add a `slug` property to Person with an INSERT+UPDATE rewrite: lower(.name)
11775        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
11776        person.properties.push(PropertyDescriptor {
11777            name: "slug".into(),
11778            pg_type: "text".into(),
11779            nullable: true,
11780            default_sql: None,
11781            default_pyql: None,
11782            description: None,
11783            check_constraints: vec![],
11784            is_exclusive: false,
11785            is_pk: false,
11786            is_readonly: false,
11787            rewrites: vec![
11788                RewriteEntry {
11789                    on: 1,
11790                    handler: "str_lower(.name)".into(),
11791                }, // INSERT
11792                RewriteEntry {
11793                    on: 2,
11794                    handler: "str_lower(.name)".into(),
11795                }, // UPDATE
11796            ],
11797            tuple_members: None,
11798            column_type: None,
11799        });
11800        schema
11801    }
11802
11803    #[test]
11804    fn a_link_assigned_from_a_select_keeps_its_clauses() {
11805        let out = compile_and_emit(
11806            "INSERT Person { name := 'a', age := 1, company := (SELECT Company ORDER BY .name LIMIT 1) }",
11807        );
11808        assert!(
11809            out.sql.contains("ORDER BY") && out.sql.contains("LIMIT 1"),
11810            "{}",
11811            out.sql
11812        );
11813    }
11814
11815    #[test]
11816    fn a_rewrite_leaves_the_statement_alone() {
11817        // It runs in the table's own `BEFORE` trigger, on the row as written.
11818        let schema = make_schema_with_rewrite();
11819        for query in [
11820            "INSERT Person { name := $name, age := 30 }",
11821            "UPDATE Person FILTER .id = $id SET { name := $name }",
11822        ] {
11823            let out = compile_and_emit_with(query, &schema);
11824            assert!(!out.sql.contains("\"slug\""), "{}", out.sql);
11825        }
11826    }
11827
11828    #[test]
11829    fn a_rewrite_reads_the_row_being_written() {
11830        let schema = make_schema_with_rewrite();
11831        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
11832        let [assignment] = assignments.as_slice() else {
11833            panic!("one rewrite on insert")
11834        };
11835        assert_eq!(assignment.column, "slug");
11836        assert!(assignment.sql.contains("NEW.\"name\""), "{}", assignment.sql);
11837    }
11838
11839    #[test]
11840    fn rewrites_become_before_triggers() {
11841        let ddl = crate::export::export_schema(&make_schema_with_rewrite()).unwrap();
11842        for event in ["BEFORE INSERT", "BEFORE UPDATE"] {
11843            assert!(
11844                ddl.contains(&format!("{event} ON \"public\".\"Person\"")),
11845                "{event}:\n{ddl}"
11846            );
11847        }
11848        assert!(ddl.contains("NEW.\"slug\" := _pylon_rewrites.\"v0\";"), "{ddl}");
11849    }
11850
11851    #[test]
11852    fn a_rewrite_walking_a_link_starts_from_the_new_row() {
11853        // The row is not in its table yet when a `BEFORE INSERT` trigger runs.
11854        use crate::schema::RewriteEntry;
11855        let mut schema = make_schema_with_rewrite();
11856        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
11857        let slug = person.properties.iter_mut().find(|p| p.name == "slug").unwrap();
11858        slug.rewrites = vec![RewriteEntry {
11859            on: 1,
11860            handler: "<str>(.company is Company) ++ .company.name".into(),
11861        }];
11862        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
11863        let sql = &assignments[0].sql;
11864        assert!(!sql.contains("\"public\".\"Person\""), "{sql}");
11865        assert!(
11866            sql.contains("(SELECT (NEW).*)") && sql.contains("NEW.\"company_id\""),
11867            "{sql}"
11868        );
11869    }
11870
11871    #[test]
11872    fn test_unless_conflict_do_nothing() {
11873        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT");
11874        assert!(out.sql.contains("ON CONFLICT DO NOTHING"));
11875    }
11876
11877    #[test]
11878    fn test_unless_conflict_on_do_nothing() {
11879        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT ON .name");
11880        assert!(out.sql.contains("ON CONFLICT (\"name\") DO NOTHING"));
11881    }
11882
11883    #[test]
11884    fn test_unless_conflict_do_update() {
11885        let out = compile_and_emit(
11886            "INSERT Person { name := $name, age := $age } \
11887             UNLESS CONFLICT ON .name \
11888             ELSE (UPDATE Person SET { age := $age })",
11889        );
11890        assert!(out.sql.contains("ON CONFLICT (\"name\") DO UPDATE SET"));
11891        assert!(out.sql.contains("\"age\" = $2"));
11892        // The param $age is shared — same index as in the INSERT VALUES
11893        assert!(!out.sql.contains("DO NOTHING"));
11894    }
11895
11896    #[test]
11897    fn test_unless_conflict_else_update_self_reference_is_qualified() {
11898        // Regression: a self-referencing ELSE-update assignment (`age :=
11899        // .age + 1`) used to compile its RHS with an empty (unqualified)
11900        // alias, emitting a bare `"age"` — ambiguous in Postgres's `ON
11901        // CONFLICT DO UPDATE SET` context between the existing row and the
11902        // `excluded` pseudo-row (confirmed live: "column reference ... is
11903        // ambiguous"). It must be qualified with the target table's own
11904        // bare name instead.
11905        let out = compile_and_emit(
11906            "INSERT Person { name := $name, age := 0 } \
11907             UNLESS CONFLICT ON .name \
11908             ELSE (UPDATE Person SET { age := .age + 1 })",
11909        );
11910        assert!(
11911            out.sql.contains("\"Person\".\"age\""),
11912            "self-reference must be qualified with the table's own name, got:\n{}",
11913            out.sql
11914        );
11915        assert!(
11916            !out.sql.contains("SET \"age\" = (\"age\""),
11917            "must not emit an unqualified (ambiguous) self-reference, got:\n{}",
11918            out.sql
11919        );
11920    }
11921
11922    #[test]
11923    fn test_unless_conflict_do_update_no_on() {
11924        let out = compile_and_emit(
11925            "INSERT Person { name := $name } \
11926             UNLESS CONFLICT \
11927             ELSE (UPDATE Person SET { age := 0 })",
11928        );
11929        assert!(out.sql.contains("ON CONFLICT DO UPDATE SET"));
11930        assert!(out.sql.contains("\"age\" = 0"));
11931    }
11932
11933    #[test]
11934    fn test_select_over_select() {
11935        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name }");
11936        // Must use a CTE
11937        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
11938        // CTE exposes raw columns via SELECT *
11939        assert!(out.sql.contains("SELECT *"));
11940        assert!(out.sql.contains("FROM \"public\".\"Person\""));
11941        // CTE carries the inner filter
11942        assert!(out.sql.contains("WHERE"));
11943        // Outer SELECT projects its own shape
11944        assert!(out.sql.contains("'default::Person'::text"));
11945        assert!(out.sql.contains("\"name\"::text"));
11946    }
11947
11948    #[test]
11949    fn test_select_over_select_with_outer_filter() {
11950        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name } FILTER .name = $name");
11951        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
11952        assert!(out.sql.contains("SELECT *"));
11953        // Both filters present: one inside CTE, one in outer SELECT
11954        assert_eq!(out.sql.matches("WHERE").count(), 2);
11955        assert!(out.sql.contains("$1"));
11956    }
11957
11958    #[test]
11959    fn test_insert_link_subquery() {
11960        let out = compile_and_emit("INSERT Person { name := $name, company := (SELECT Company FILTER .name = $co) }");
11961        // The company FK column should be assigned via a scalar subquery
11962        assert!(out.sql.contains("\"company_id\""));
11963        assert!(out.sql.contains("SELECT"));
11964        // The subquery must select the pk (id) of Company
11965        assert!(out.sql.contains("\"id\""));
11966        assert!(out.sql.contains("FROM \"public\".\"Company\""));
11967        // Filter param must appear
11968        assert!(out.sql.contains("$2")); // $1 = name, $2 = co
11969    }
11970
11971    #[test]
11972    fn test_update_link_subquery() {
11973        let out =
11974            compile_and_emit("UPDATE Person FILTER .id = $id SET { company := (SELECT Company FILTER .name = $co) }");
11975        assert!(out.sql.contains("\"company_id\""));
11976        assert!(out.sql.contains("SELECT"));
11977        assert!(out.sql.contains("FROM \"public\".\"Company\""));
11978    }
11979
11980    #[test]
11981    fn test_insert_link_value_from_nested_insert_hoists_a_with_cte() {
11982        // `company := (select (insert Company {...}) { id })` — Postgres has
11983        // no way to run a nested INSERT inside another statement's VALUES
11984        // list without hoisting it into its own WITH CTE first (confirmed
11985        // live against real Postgres — see live_execution_insert_nested.rs).
11986        // Regression: an earlier attempt at this compiled cleanly but
11987        // silently emitted a subquery that dropped the nested INSERT
11988        // entirely and selected an arbitrary unrelated row instead.
11989        let out = compile_and_emit(
11990            "INSERT Person { name := 'Alice', company := (select (insert Company { name := 'Acme' }) { id }) }",
11991        );
11992        assert!(
11993            out.sql.starts_with("WITH"),
11994            "expected a WITH-hoisted CTE, got:\n{}",
11995            out.sql
11996        );
11997        assert!(
11998            out.sql.contains("INSERT INTO \"public\".\"Company\""),
11999            "expected the nested insert to be its own CTE, got:\n{}",
12000            out.sql,
12001        );
12002        // The outer insert must reference the nested CTE's own id, not a
12003        // freestanding subquery against the Company table.
12004        assert!(
12005            out.sql.contains("\"company_id\") SELECT") && out.sql.contains(".\"id\" FROM"),
12006            "expected the outer insert to switch from VALUES to SELECT ... FROM <cte>, got:\n{}",
12007            out.sql,
12008        );
12009        assert!(
12010            !out.sql.contains("FROM \"public\".\"Company\" AS"),
12011            "must not select from the real Company table, got:\n{}",
12012            out.sql
12013        );
12014    }
12015
12016    #[test]
12017    fn test_update_link_value_from_nested_insert_hoists_a_with_cte_and_adds_from_clause() {
12018        let out = compile_and_emit(
12019            "UPDATE Person FILTER .id = $id SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
12020        );
12021        assert!(
12022            out.sql.starts_with("WITH"),
12023            "expected a WITH-hoisted CTE, got:\n{}",
12024            out.sql
12025        );
12026        assert!(
12027            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12028            "expected the nested insert to be its own CTE, got:\n{}",
12029            out.sql,
12030        );
12031        // Plain `UPDATE ... SET ...` has no FROM clause of its own — one
12032        // must be added so the SET clause can reference the CTE's column.
12033        assert!(
12034            out.sql.contains("\nFROM \""),
12035            "expected a FROM clause referencing the nested CTE, got:\n{}",
12036            out.sql
12037        );
12038        assert!(out.sql.contains("SET \"company_id\" = "), "got:\n{}", out.sql);
12039    }
12040
12041    #[test]
12042    fn test_update_link_value_from_nested_insert_combines_with_multilink_mutation() {
12043        // A nested-insert link value and a `+=` multi-link mutation in the
12044        // same UPDATE route through a completely different emitter branch
12045        // (the junction-CTE "_ids" path, not the plain-UPDATE path the
12046        // previous test covers) — both must still see the hoisted CTE.
12047        let out = compile_and_emit(
12048            "UPDATE Person FILTER .id = $id SET { \
12049                 company := (select (insert Company { name := 'Acme' }) { id }), \
12050                 posts += (SELECT Post FILTER .title = $t) \
12051             }",
12052        );
12053        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
12054        assert!(
12055            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12056            "expected the nested insert to be its own CTE, got:\n{}",
12057            out.sql,
12058        );
12059        assert!(
12060            out.sql.contains("\"_ids\" AS (\nUPDATE") && out.sql.contains("\nFROM \""),
12061            "expected the _ids UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
12062            out.sql,
12063        );
12064        assert!(
12065            out.sql.contains("\"_ml_add_0\""),
12066            "expected the junction-append CTE to still be present, got:\n{}",
12067            out.sql
12068        );
12069    }
12070
12071    #[test]
12072    fn test_update_link_value_from_nested_insert_combines_with_interface_target() {
12073        // A nested-insert link value on an UPDATE targeting an interface
12074        // (abstract + materialized) type routes through the poly fan-out
12075        // branch (emit_poly_update_stmt) — a third, independent emitter path
12076        // from the previous two tests. The hoisted CTE must be materialized
12077        // once and referenced from every per-implementor UPDATE branch.
12078        fn id_prop() -> PropertyDescriptor {
12079            PropertyDescriptor {
12080                name: "id".into(),
12081                pg_type: "uuid".into(),
12082                nullable: false,
12083                default_sql: Some("uuidv7()".into()),
12084                default_pyql: None,
12085                description: None,
12086                check_constraints: vec![],
12087                is_exclusive: true,
12088                is_pk: true,
12089                is_readonly: true,
12090                rewrites: vec![],
12091                tuple_members: None,
12092                column_type: None,
12093            }
12094        }
12095        fn text_prop(name: &str) -> PropertyDescriptor {
12096            PropertyDescriptor {
12097                name: name.into(),
12098                pg_type: "text".into(),
12099                nullable: false,
12100                default_sql: None,
12101                default_pyql: None,
12102                description: None,
12103                check_constraints: vec![],
12104                is_exclusive: false,
12105                is_pk: false,
12106                is_readonly: false,
12107                rewrites: vec![],
12108                tuple_members: None,
12109                column_type: None,
12110            }
12111        }
12112        fn company_link() -> LinkDescriptor {
12113            LinkDescriptor {
12114                name: "company".into(),
12115                target: "default::Company".into(),
12116                nullable: true,
12117                through: None,
12118                description: None,
12119                default_pyql: None,
12120                is_exclusive: false,
12121                is_readonly: false,
12122                rewrites: vec![],
12123                on_delete: vec![],
12124            }
12125        }
12126        let schema = SchemaDescriptor {
12127            types: vec![
12128                TypeDescriptor {
12129                    name: "Company".into(),
12130                    module: "default".into(),
12131                    table: "Company".into(),
12132                    abstract_: false,
12133                    materialized: true,
12134                    description: None,
12135                    parents: vec![],
12136                    interfaces: vec![],
12137                    bases: vec![],
12138                    properties: vec![id_prop(), text_prop("name")],
12139                    links: vec![],
12140                    multilinks: vec![],
12141                    computed: vec![],
12142                    constraints: vec![],
12143                    indexes: vec![],
12144                    partition: None,
12145                    vector_indexes: vec![],
12146                    search_indexes: vec![],
12147                    triggers: vec![],
12148                    junction: false,
12149                    signals: vec![],
12150                },
12151                TypeDescriptor {
12152                    name: "Account".into(),
12153                    module: "default".into(),
12154                    table: "Account".into(),
12155                    abstract_: true,
12156                    materialized: true,
12157                    description: None,
12158                    parents: vec![],
12159                    interfaces: vec![],
12160                    bases: vec![],
12161                    properties: vec![id_prop(), text_prop("email")],
12162                    links: vec![company_link()],
12163                    multilinks: vec![],
12164                    computed: vec![],
12165                    constraints: vec![],
12166                    indexes: vec![],
12167                    partition: None,
12168                    vector_indexes: vec![],
12169                    search_indexes: vec![],
12170                    triggers: vec![],
12171                    junction: false,
12172                    signals: vec![],
12173                },
12174                TypeDescriptor {
12175                    name: "Individual".into(),
12176                    module: "default".into(),
12177                    table: "Individual".into(),
12178                    abstract_: false,
12179                    materialized: true,
12180                    description: None,
12181                    parents: vec![],
12182                    interfaces: vec!["default::Account".into()],
12183                    bases: vec![],
12184                    properties: vec![id_prop(), text_prop("email"), text_prop("first_name")],
12185                    links: vec![company_link()],
12186                    multilinks: vec![],
12187                    computed: vec![],
12188                    constraints: vec![],
12189                    indexes: vec![],
12190                    partition: None,
12191                    vector_indexes: vec![],
12192                    search_indexes: vec![],
12193                    triggers: vec![],
12194                    junction: false,
12195                    signals: vec![],
12196                },
12197            ],
12198            scalars: vec![],
12199            enums: vec![],
12200            named_tuples: vec![],
12201            globals: vec![],
12202            functions: vec![],
12203            aliases: vec![],
12204            channels: vec![],
12205            ..Default::default()
12206        };
12207        let out = compile_and_emit_with(
12208            "UPDATE Account FILTER .email = $email \
12209             SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
12210            &schema,
12211        );
12212        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
12213        assert!(
12214            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12215            "expected the nested insert to be its own CTE, got:\n{}",
12216            out.sql,
12217        );
12218        assert!(
12219            out.sql.contains("UPDATE \"public\".\"Individual\"") && out.sql.contains("\nFROM \"_nested_dml_0\""),
12220            "expected the per-implementor UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
12221            out.sql,
12222        );
12223    }
12224
12225    #[test]
12226    fn test_a_backlink_narrowed_to_a_supertype() {
12227        // `.<company[is Account]` — the intersection narrows what comes back,
12228        // while the link itself is declared further down. Pylon looked for it
12229        // on the narrowed type alone and reported it had no such link.
12230        let mut schema = interface_link_schema();
12231        // Leave `company` declared only on the implementor, so the narrowed
12232        // type genuinely does not have it.
12233        schema
12234            .types
12235            .iter_mut()
12236            .find(|t| t.name == "Account")
12237            .expect("the helper declares Account")
12238            .links
12239            .clear();
12240        let out = compile_and_emit_with("SELECT Company { owners := .<company[is Account] { email } }", &schema);
12241        assert!(
12242            out.sql.contains("\"public\".\"Individual\""),
12243            "the backlink must read the type that declares it:\n{}",
12244            out.sql
12245        );
12246    }
12247
12248    #[test]
12249    fn a_path_through_a_backlink_narrowed_to_a_supertype() {
12250        let mut schema = interface_link_schema();
12251        schema
12252            .types
12253            .iter_mut()
12254            .find(|t| t.name == "Account")
12255            .expect("the helper declares Account")
12256            .links
12257            .clear();
12258        let out = compile_and_emit_with(
12259            "SELECT Company { name } FILTER EXISTS (SELECT .<company[is Account])",
12260            &schema,
12261        );
12262        assert!(out.sql.contains("\"public\".\"Individual\""), "{}", out.sql);
12263    }
12264
12265    /// Company, the Account interface, its Individual implementor and a
12266    /// Token linking to the interface -- enough to exercise reading and
12267    /// backlinking across one.
12268    fn interface_link_schema() -> SchemaDescriptor {
12269        // A link whose target is an interface is read by fanning the
12270        // implementors out inline. That union used to project only the
12271        // interface's *properties*, so a shape that then followed one of the
12272        // interface's own links failed with "column t1.company_id does not
12273        // exist".
12274        fn id_prop() -> PropertyDescriptor {
12275            PropertyDescriptor {
12276                name: "id".into(),
12277                pg_type: "uuid".into(),
12278                nullable: false,
12279                default_sql: None,
12280                default_pyql: None,
12281                description: None,
12282                check_constraints: vec![],
12283                is_exclusive: true,
12284                is_pk: true,
12285                is_readonly: true,
12286                rewrites: vec![],
12287                tuple_members: None,
12288                column_type: None,
12289            }
12290        }
12291        fn text_prop(name: &str) -> PropertyDescriptor {
12292            PropertyDescriptor {
12293                name: name.into(),
12294                pg_type: "text".into(),
12295                nullable: false,
12296                default_sql: None,
12297                default_pyql: None,
12298                description: None,
12299                check_constraints: vec![],
12300                is_exclusive: false,
12301                is_pk: false,
12302                is_readonly: false,
12303                rewrites: vec![],
12304                tuple_members: None,
12305                column_type: None,
12306            }
12307        }
12308        fn link(name: &str, target: &str) -> LinkDescriptor {
12309            LinkDescriptor {
12310                name: name.into(),
12311                target: target.into(),
12312                nullable: true,
12313                through: None,
12314                description: None,
12315                default_pyql: None,
12316                is_exclusive: false,
12317                is_readonly: false,
12318                rewrites: vec![],
12319                on_delete: vec![],
12320            }
12321        }
12322        fn ty(
12323            name: &str,
12324            abstract_: bool,
12325            interfaces: Vec<String>,
12326            properties: Vec<PropertyDescriptor>,
12327            links: Vec<LinkDescriptor>,
12328        ) -> TypeDescriptor {
12329            TypeDescriptor {
12330                name: name.into(),
12331                module: "default".into(),
12332                table: name.into(),
12333                abstract_,
12334                materialized: true,
12335                description: None,
12336                parents: vec![],
12337                interfaces,
12338                bases: vec![],
12339                properties,
12340                links,
12341                multilinks: vec![],
12342                computed: vec![],
12343                constraints: vec![],
12344                indexes: vec![],
12345                partition: None,
12346                vector_indexes: vec![],
12347                search_indexes: vec![],
12348                triggers: vec![],
12349                junction: false,
12350                signals: vec![],
12351            }
12352        }
12353        SchemaDescriptor {
12354            types: vec![
12355                ty("Company", false, vec![], vec![id_prop(), text_prop("name")], vec![]),
12356                ty(
12357                    "Account",
12358                    true,
12359                    vec![],
12360                    vec![id_prop(), text_prop("email")],
12361                    vec![link("company", "default::Company")],
12362                ),
12363                ty(
12364                    "Individual",
12365                    false,
12366                    vec!["default::Account".into()],
12367                    vec![id_prop(), text_prop("email")],
12368                    vec![link("company", "default::Company")],
12369                ),
12370                ty(
12371                    "Token",
12372                    false,
12373                    vec![],
12374                    vec![id_prop(), text_prop("value")],
12375                    vec![link("account", "default::Account")],
12376                ),
12377            ],
12378            scalars: vec![],
12379            enums: vec![],
12380            named_tuples: vec![],
12381            globals: vec![],
12382            functions: vec![],
12383            aliases: vec![],
12384            channels: vec![],
12385            ..Default::default()
12386        }
12387    }
12388
12389    /// `Token`, but reached through a second single link and narrowed to the
12390    /// interface's implementor -- conduit's trust-tier walk,
12391    /// `installation.connector.provider[is Individual].staff`.
12392    fn two_hop_interface_schema() -> SchemaDescriptor {
12393        let mut schema = interface_link_schema();
12394        let token = schema
12395            .types
12396            .iter()
12397            .find(|t| t.name == "Token")
12398            .expect("the helper declares Token")
12399            .clone();
12400        let mut connector = token.clone();
12401        connector.name = "Connector".into();
12402        connector.table = "Connector".into();
12403        connector.links[0].name = "provider".into();
12404        let mut installation = token;
12405        installation.name = "Installation".into();
12406        installation.table = "Installation".into();
12407        installation.links[0].name = "connector".into();
12408        installation.links[0].target = "default::Connector".into();
12409        schema.types.push(connector);
12410        schema.types.push(installation);
12411        schema
12412    }
12413
12414    #[test]
12415    fn a_narrowed_two_hop_walk_off_a_pinned_row_is_one_value() {
12416        // Every step is a forward single link and the filter pins the id, so
12417        // the `[is Individual]` narrowing does not make the walk set-valued.
12418        // Gathered as an array the `??` default became `array_remove(ARRAY[…])`
12419        // and the result decoded as a one-element set where Gel gives the
12420        // value itself.
12421        let out = compile_and_emit_with(
12422            "SELECT { s := ((SELECT Installation FILTER .id = <uuid>$0).connector.provider[is Individual].email) ?? 'y' }",
12423            &two_hop_interface_schema(),
12424        );
12425        assert!(
12426            !out.sql.contains("ARRAY(SELECT"),
12427            "expected a value, not a set:\n{}",
12428            out.sql
12429        );
12430        assert!(
12431            out.sql.contains("COALESCE("),
12432            "the default must stay a scalar one:\n{}",
12433            out.sql
12434        );
12435    }
12436
12437    #[test]
12438    fn a_narrowed_two_hop_walk_off_an_unpinned_row_stays_a_set() {
12439        let out = compile_and_emit_with(
12440            "SELECT { s := (SELECT Installation FILTER .value = 'x').connector.provider[is Individual].email }",
12441            &two_hop_interface_schema(),
12442        );
12443        assert!(
12444            out.sql.contains("ARRAY(SELECT"),
12445            "a walk off many rows is a set:\n{}",
12446            out.sql
12447        );
12448    }
12449
12450    /// `exists .account[is Individual]` — a walk narrowed to an implementor
12451    /// with no pointer read after it. The two-step form needs a trailing
12452    /// column to read, so it rejected the intersection outright ("type
12453    /// intersections are not valid in expression context") even though the
12454    /// same walk with a pointer on the end (`[is Individual].email`) has
12455    /// always compiled.
12456    #[test]
12457    fn a_walk_ending_in_a_type_intersection_is_valid_in_a_filter() {
12458        let out = compile_and_emit_with(
12459            "SELECT Token { id } FILTER EXISTS .account[is Individual]",
12460            &interface_link_schema(),
12461        );
12462        assert!(
12463            out.sql.contains("\"public\".\"Individual\""),
12464            "the narrowing must reach the implementor's table:\n{}",
12465            out.sql
12466        );
12467        assert!(
12468            out.sql.contains("IS NOT NULL"),
12469            "exists over the narrowed walk is a nullness test:\n{}",
12470            out.sql
12471        );
12472    }
12473
12474    #[test]
12475    fn a_walk_ending_in_a_type_intersection_is_valid_off_a_binding() {
12476        let out = compile_and_emit_with(
12477            "WITH t := (SELECT Token LIMIT 1) SELECT { e := EXISTS t.account[is Individual] }",
12478            &interface_link_schema(),
12479        );
12480        assert!(
12481            out.sql.contains("\"public\".\"Individual\""),
12482            "the narrowing must reach the implementor's table:\n{}",
12483            out.sql
12484        );
12485    }
12486
12487    #[test]
12488    fn test_reading_a_link_through_an_interface_target() {
12489        let schema = interface_link_schema();
12490        let out = compile_and_emit_with("SELECT Token { account: { email, company: { name } } }", &schema);
12491        assert!(
12492            out.sql
12493                .contains("\"email\", \"company_id\" FROM \"public\".\"Individual\""),
12494            "the fanned-out interface must carry its own link columns:\n{}",
12495            out.sql
12496        );
12497    }
12498
12499    #[test]
12500    fn test_computed_pointer_in_shape_emits_expression() {
12501        let mut schema = make_schema();
12502        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
12503            name: "upper_name".into(),
12504            expression: "str_upper(.name)".into(),
12505            return_type: Some("text".into()),
12506            link_target: None,
12507            link_multi: false,
12508        });
12509        let out = compile_and_emit_with("SELECT Person { upper_name }", &schema);
12510        assert!(
12511            out.sql.to_lowercase().contains("upper"),
12512            "expected upper() in SQL, got:\n{}",
12513            out.sql
12514        );
12515    }
12516
12517    /// Interface `Account` (non-materialized) with one implementor,
12518    /// `Individual`, carrying a stored property and a computed pointer.
12519    fn make_interface_schema() -> SchemaDescriptor {
12520        fn id_prop() -> PropertyDescriptor {
12521            PropertyDescriptor {
12522                name: "id".into(),
12523                pg_type: "uuid".into(),
12524                nullable: false,
12525                default_sql: Some("uuidv7()".into()),
12526                default_pyql: None,
12527                description: None,
12528                check_constraints: vec![],
12529                is_exclusive: true,
12530                is_pk: true,
12531                is_readonly: true,
12532                rewrites: vec![],
12533                tuple_members: None,
12534                column_type: None,
12535            }
12536        }
12537        SchemaDescriptor {
12538            types: vec![
12539                TypeDescriptor {
12540                    name: "Account".into(),
12541                    module: "default".into(),
12542                    table: "Account".into(),
12543                    abstract_: true,
12544                    materialized: false,
12545                    description: None,
12546                    parents: vec![],
12547                    interfaces: vec![],
12548                    bases: vec![],
12549                    properties: vec![
12550                        id_prop(),
12551                        PropertyDescriptor {
12552                            name: "email".into(),
12553                            pg_type: "text".into(),
12554                            nullable: false,
12555                            default_sql: None,
12556                            default_pyql: None,
12557                            description: None,
12558                            check_constraints: vec![],
12559                            is_exclusive: false,
12560                            is_pk: false,
12561                            is_readonly: false,
12562                            rewrites: vec![],
12563                            tuple_members: None,
12564                            column_type: None,
12565                        },
12566                    ],
12567                    links: vec![],
12568                    multilinks: vec![],
12569                    computed: vec![],
12570                    constraints: vec![],
12571                    indexes: vec![],
12572                    partition: None,
12573                    vector_indexes: vec![],
12574                    search_indexes: vec![],
12575                    triggers: vec![],
12576                    junction: false,
12577                    signals: vec![],
12578                },
12579                TypeDescriptor {
12580                    name: "Individual".into(),
12581                    module: "default".into(),
12582                    table: "Individual".into(),
12583                    abstract_: false,
12584                    materialized: true,
12585                    description: None,
12586                    parents: vec![],
12587                    interfaces: vec!["default::Account".into()],
12588                    bases: vec![],
12589                    properties: vec![
12590                        id_prop(),
12591                        PropertyDescriptor {
12592                            name: "first_name".into(),
12593                            pg_type: "text".into(),
12594                            nullable: false,
12595                            default_sql: None,
12596                            default_pyql: None,
12597                            description: None,
12598                            check_constraints: vec![],
12599                            is_exclusive: false,
12600                            is_pk: false,
12601                            is_readonly: false,
12602                            rewrites: vec![],
12603                            tuple_members: None,
12604                            column_type: None,
12605                        },
12606                    ],
12607                    links: vec![],
12608                    multilinks: vec![],
12609                    computed: vec![crate::schema::ComputedDescriptor {
12610                        name: "full_name".into(),
12611                        expression: "str_upper(.first_name)".into(),
12612                        return_type: Some("text".into()),
12613                        link_target: None,
12614                        link_multi: false,
12615                    }],
12616                    constraints: vec![],
12617                    indexes: vec![],
12618                    partition: None,
12619                    vector_indexes: vec![],
12620                    search_indexes: vec![],
12621                    triggers: vec![],
12622                    junction: false,
12623                    signals: vec![],
12624                },
12625            ],
12626            scalars: vec![],
12627            enums: vec![],
12628            named_tuples: vec![],
12629            globals: vec![],
12630            functions: vec![],
12631            aliases: vec![],
12632            channels: vec![],
12633            ..Default::default()
12634        }
12635    }
12636
12637    #[test]
12638    fn test_type_intersection_splat_includes_concrete_computed_pointers() {
12639        // `[is Concrete].*` must expand to the concrete type's own computed
12640        // pointers too (e.g. `full_name`), not just its stored properties —
12641        // querying `Individual` directly with `*` already included them, but
12642        // the interface-splat path never even looked at `td.computed`.
12643        let schema = make_interface_schema();
12644        let out = compile_and_emit_with("SELECT Account { *, [is Individual].* }", &schema);
12645        assert!(
12646            out.sql.to_lowercase().contains("upper"),
12647            "expected the concrete type's computed pointer (str_upper(...)) in the shape, got:\n{}",
12648            out.sql
12649        );
12650        assert!(
12651            out.sql.contains("\"first_name\""),
12652            "expected the concrete type's stored property too, got:\n{}",
12653            out.sql
12654        );
12655    }
12656
12657    #[test]
12658    fn test_enum_array_property_casts_to_text_array_and_shapes_as_a_list() {
12659        // Regression: an `array<enum>` property's pg_type is
12660        // `"module"."Type"[]`, which starts with a quote exactly as a scalar
12661        // enum's does. Read as a scalar it was cast to a single `::text`,
12662        // so `{MagicLink}` -- Postgres's text rendering of the whole array --
12663        // came back as one enum value, and the polymorphic path, which casts
12664        // nothing, failed outright with "no decoder for PostgreSQL type OID".
12665        let mut schema = make_schema();
12666        schema.enums.push(crate::schema::EnumDescriptor {
12667            name: "Gender".into(),
12668            module: "default".into(),
12669            members: vec!["Male".into(), "Female".into()],
12670        });
12671        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
12672            name: "genders".into(),
12673            pg_type: r#""default"."Gender"[]"#.into(),
12674            nullable: true,
12675            default_sql: None,
12676            default_pyql: None,
12677            description: None,
12678            check_constraints: vec![],
12679            is_exclusive: false,
12680            is_pk: false,
12681            is_readonly: false,
12682            rewrites: vec![],
12683            tuple_members: None,
12684            column_type: None,
12685        });
12686
12687        let out = compile_and_emit_with("SELECT Person { genders }", &schema);
12688        assert!(
12689            out.sql.contains(r#""genders"::text[]"#),
12690            "expected a text[] cast, got:\n{}",
12691            out.sql
12692        );
12693
12694        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
12695            panic!("expected Object shape, got {:?}", out.shape.root)
12696        };
12697        let genders = pointers
12698            .iter()
12699            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "genders"))
12700            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
12701        let crate::query::ShapeNode::Array { element, .. } = genders else {
12702            unreachable!()
12703        };
12704        assert!(
12705            matches!(
12706                element.as_ref(),
12707                crate::query::ShapeNode::Enum { enum_type, position: 0, .. }
12708                    if enum_type == "default::Gender"
12709            ),
12710            "expected the elements to be enum-shaped, got {element:?}",
12711        );
12712    }
12713
12714    #[test]
12715    fn test_scalar_enum_property_still_casts_to_a_single_text() {
12716        let mut schema = make_schema();
12717        schema.enums.push(crate::schema::EnumDescriptor {
12718            name: "Gender".into(),
12719            module: "default".into(),
12720            members: vec!["Male".into(), "Female".into()],
12721        });
12722        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
12723            name: "gender".into(),
12724            pg_type: r#""default"."Gender""#.into(),
12725            nullable: true,
12726            default_sql: None,
12727            default_pyql: None,
12728            description: None,
12729            check_constraints: vec![],
12730            is_exclusive: false,
12731            is_pk: false,
12732            is_readonly: false,
12733            rewrites: vec![],
12734            tuple_members: None,
12735            column_type: None,
12736        });
12737
12738        let out = compile_and_emit_with("SELECT Person { gender }", &schema);
12739        assert!(
12740            out.sql.contains(r#""gender"::text"#) && !out.sql.contains(r#""gender"::text[]"#),
12741            "expected a plain text cast, got:\n{}",
12742            out.sql
12743        );
12744        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
12745            panic!("expected Object shape")
12746        };
12747        assert!(
12748            pointers.iter().any(|node| matches!(
12749                node,
12750                crate::query::ShapeNode::Enum { name, enum_type, .. }
12751                    if name == "gender" && enum_type == "default::Gender"
12752            )),
12753            "expected an Enum-shaped pointer, got {pointers:?}",
12754        );
12755    }
12756
12757    #[test]
12758    fn test_type_intersection_splat_keeps_enum_pointers_enum_shaped() {
12759        // Regression: `[is Concrete].*` wraps each property in a correlated
12760        // subquery, so the outer shape saw an `IrExpr::Subquery` rather than a
12761        // column and fell back to a plain Scalar — handing back `'System'`
12762        // where a direct select gave `InterfaceAppearance.System`.
12763        let mut schema = make_interface_schema();
12764        schema.enums.push(crate::schema::EnumDescriptor {
12765            name: "Gender".into(),
12766            module: "default".into(),
12767            members: vec!["Male".into(), "Female".into()],
12768        });
12769        // An enum that lives only on the concrete type, so it can only be
12770        // reached through the intersection.
12771        let individual = schema
12772            .types
12773            .iter_mut()
12774            .find(|t| t.name == "Individual")
12775            .expect("the interface schema has an Individual type");
12776        individual.properties.push(crate::schema::PropertyDescriptor {
12777            name: "gender".into(),
12778            pg_type: r#""default"."Gender""#.into(),
12779            nullable: true,
12780            default_sql: None,
12781            default_pyql: None,
12782            description: None,
12783            check_constraints: vec![],
12784            is_exclusive: false,
12785            is_pk: false,
12786            is_readonly: false,
12787            rewrites: vec![],
12788            tuple_members: None,
12789            column_type: None,
12790        });
12791
12792        let out = compile_and_emit_with("SELECT Account { [is Individual].* }", &schema);
12793        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
12794            panic!("expected Object shape, got {:?}", out.shape.root)
12795        };
12796        assert!(
12797            pointers.iter().any(|node| matches!(
12798                node,
12799                crate::query::ShapeNode::Enum { name, enum_type, .. }
12800                    if name == "gender" && enum_type == "default::Gender"
12801            )),
12802            "expected the enum property to stay enum-shaped through the intersection, got {pointers:?}",
12803        );
12804    }
12805
12806    #[test]
12807    fn test_shallow_splat_leaves_object_valued_computeds_to_the_deep_form() {
12808        // Regression: `*` expanded every computed, link-valued ones included,
12809        // which Gel's `*` does not -- it is properties only, `**` adds links.
12810        // On jaldis's schema that pulled a computed multi-link
12811        // (`members := .memberships.member`) into every splat query.
12812        let mut schema = make_schema();
12813        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
12814            name: "authors".into(),
12815            expression: ".posts".into(),
12816            return_type: None,
12817            link_target: None,
12818            link_multi: false,
12819        });
12820        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
12821            name: "age_next".into(),
12822            expression: ".age + 1".into(),
12823            return_type: Some("int8".into()),
12824            link_target: None,
12825            link_multi: false,
12826        });
12827
12828        let shallow = compile_and_emit_with("SELECT Person { * }", &schema);
12829        let crate::query::ShapeNode::Object { pointers, .. } = &shallow.shape.root else {
12830            panic!("expected Object shape")
12831        };
12832        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
12833        assert!(
12834            names.contains(&"age_next"),
12835            "a computed property belongs in `*`: {names:?}"
12836        );
12837        assert!(!names.contains(&"authors"), "a computed link does not: {names:?}");
12838
12839        let deep = compile_and_emit_with("SELECT Person { ** }", &schema);
12840        let crate::query::ShapeNode::Object { pointers, .. } = &deep.shape.root else {
12841            panic!("expected Object shape")
12842        };
12843        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
12844        assert!(
12845            names.contains(&"authors"),
12846            "`**` includes links, computed ones included: {names:?}"
12847        );
12848    }
12849
12850    fn shape_pointer_name(node: &crate::query::ShapeNode) -> &str {
12851        use crate::query::ShapeNode;
12852        match node {
12853            ShapeNode::Scalar { name, .. }
12854            | ShapeNode::Enum { name, .. }
12855            | ShapeNode::NamedTuple { name, .. }
12856            | ShapeNode::Object { name, .. }
12857            | ShapeNode::Array { name, .. } => name,
12858            _ => "",
12859        }
12860    }
12861
12862    #[test]
12863    fn test_any_over_a_multilink_comparison_does_not_warn() {
12864        // Regression: the warning fired whether or not the comparison was
12865        // wrapped, so `any()` -- the very thing it asks for -- did not silence
12866        // it. The argument is compiled before the call is resolved, so the
12867        // suppression has to be in place while the argument is compiled.
12868        let schema = make_schema();
12869        let ast = parse::parse("SELECT Person FILTER any(.posts.title = 'x')").unwrap();
12870        let ir = ir::compile(&ast, &schema).unwrap();
12871        assert!(
12872            !ir.warnings.iter().any(|w| w.contains("FILTER clause")),
12873            "any() states the intent, so nothing should warn: {:?}",
12874            ir.warnings
12875        );
12876    }
12877
12878    #[test]
12879    fn test_bare_multilink_comparison_still_warns() {
12880        let schema = make_schema();
12881        let ast = parse::parse("SELECT Person FILTER .posts.title = 'x'").unwrap();
12882        let ir = ir::compile(&ast, &schema).unwrap();
12883        assert!(
12884            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
12885            "an unwrapped set-valued comparison still warns: {:?}",
12886            ir.warnings
12887        );
12888    }
12889
12890    #[test]
12891    fn test_link_to_an_interface_expands_over_its_implementors() {
12892        // Regression: an interface's view carries only the interface's own
12893        // columns, so reading a link through it tagged every row as the
12894        // interface and hydrated `Profile` where the row was an
12895        // `IndividualProfile`. The root of a query already expands inline;
12896        // a nested link did not.
12897        let mut schema = make_interface_schema();
12898        let account = schema
12899            .types
12900            .iter_mut()
12901            .find(|t| t.name == "Account")
12902            .expect("the interface schema has an Account type");
12903        account.materialized = true;
12904        // A second implementor, so the fan-out is a real union.
12905        let mut organization = schema
12906            .types
12907            .iter()
12908            .find(|t| t.name == "Individual")
12909            .expect("the interface schema has an Individual type")
12910            .clone();
12911        organization.name = "Organization".into();
12912        organization.table = "Organization".into();
12913        organization.computed.clear();
12914        schema.types.push(organization);
12915
12916        let owner = TypeDescriptor {
12917            name: "Note".into(),
12918            module: "default".into(),
12919            table: "Note".into(),
12920            abstract_: false,
12921            materialized: false,
12922            description: None,
12923            parents: vec![],
12924            interfaces: vec![],
12925            bases: vec![],
12926            properties: vec![],
12927            links: vec![LinkDescriptor {
12928                name: "owner".into(),
12929                target: "default::Account".into(),
12930                nullable: true,
12931                description: None,
12932                default_pyql: None,
12933                is_exclusive: false,
12934                is_readonly: false,
12935                rewrites: vec![],
12936                on_delete: vec![],
12937                through: None,
12938            }],
12939            multilinks: vec![],
12940            computed: vec![],
12941            constraints: vec![],
12942            indexes: vec![],
12943            partition: None,
12944            vector_indexes: vec![],
12945            search_indexes: vec![],
12946            triggers: vec![],
12947            junction: false,
12948            signals: vec![],
12949        };
12950        schema.types.push(owner);
12951
12952        let out = compile_and_emit_with("SELECT Note { owner: { id } }", &schema);
12953        assert!(
12954            out.sql.contains("UNION ALL"),
12955            "the link's target should expand over its implementors, got:\n{}",
12956            out.sql
12957        );
12958        assert!(
12959            out.sql.contains("'default::Individual'::text AS \"__type__\""),
12960            "each branch should carry its own discriminator, got:\n{}",
12961            out.sql
12962        );
12963        assert!(
12964            !out.sql.contains("FROM \"default\".\"Account\" AS"),
12965            "the interface's own view should no longer be read directly, got:\n{}",
12966            out.sql
12967        );
12968    }
12969
12970    /// `make_interface_schema` with a second implementor of `Account`, so a
12971    /// read of it is a real union of two concrete types.
12972    fn make_two_implementor_schema() -> SchemaDescriptor {
12973        let mut schema = make_interface_schema();
12974        let mut organization = schema
12975            .types
12976            .iter()
12977            .find(|t| t.name == "Individual")
12978            .expect("the interface schema has an Individual type")
12979            .clone();
12980        organization.name = "Organization".into();
12981        organization.table = "Organization".into();
12982        organization.computed.clear();
12983        schema.types.push(organization);
12984        schema
12985    }
12986
12987    /// Every row read of an interface names its own concrete type, so none may
12988    /// be labelled with the interface's.
12989    fn assert_rows_carry_their_concrete_type(query: &str) {
12990        let out = compile_and_emit_with(query, &make_two_implementor_schema());
12991        assert!(
12992            !out.sql.contains("'default::Account'::text"),
12993            "rows are labelled with the interface instead of their own type:\n{}",
12994            out.sql
12995        );
12996        assert!(
12997            out.sql.contains("'default::Individual'::text AS \"__type__\""),
12998            "the rows should come from the implementors, each tagging its own:\n{}",
12999            out.sql
13000        );
13001    }
13002
13003    #[test]
13004    fn an_aggregated_select_of_an_interface_carries_the_concrete_type() {
13005        assert_rows_carry_their_concrete_type("SELECT (xs := array_agg((SELECT Account { id })))");
13006    }
13007
13008    #[test]
13009    fn an_aggregated_binding_of_an_interface_carries_the_concrete_type() {
13010        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (xs := array_agg((SELECT a { id })))");
13011    }
13012
13013    #[test]
13014    fn a_single_object_read_off_a_binding_carries_the_concrete_type() {
13015        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (x := (SELECT a { id } LIMIT 1))");
13016    }
13017
13018    #[test]
13019    fn grouped_elements_of_an_interface_carry_the_concrete_type() {
13020        assert_rows_carry_their_concrete_type("group Account { id } by .email");
13021    }
13022
13023    /// `make_two_implementor_schema` with a `Note` that links to an `Account`,
13024    /// and an `Account` that links back to a `Note`.
13025    fn make_note_schema() -> SchemaDescriptor {
13026        fn link_to(name: &str, target: &str) -> LinkDescriptor {
13027            LinkDescriptor {
13028                name: name.into(),
13029                target: target.into(),
13030                nullable: true,
13031                description: None,
13032                default_pyql: None,
13033                is_exclusive: false,
13034                is_readonly: false,
13035                rewrites: vec![],
13036                on_delete: vec![],
13037                through: None,
13038            }
13039        }
13040        let mut schema = make_two_implementor_schema();
13041        for account in schema
13042            .types
13043            .iter_mut()
13044            .filter(|t| ["Account", "Individual", "Organization"].contains(&t.name.as_str()))
13045        {
13046            account.links.push(link_to("pinned", "default::Note"));
13047        }
13048        schema.types.push(TypeDescriptor {
13049            name: "Note".into(),
13050            module: "default".into(),
13051            table: "Note".into(),
13052            abstract_: false,
13053            materialized: false,
13054            description: None,
13055            parents: vec![],
13056            interfaces: vec![],
13057            bases: vec![],
13058            properties: vec![],
13059            links: vec![link_to("owner", "default::Account")],
13060            multilinks: vec![],
13061            computed: vec![],
13062            constraints: vec![],
13063            indexes: vec![],
13064            partition: None,
13065            vector_indexes: vec![],
13066            search_indexes: vec![],
13067            triggers: vec![],
13068            junction: false,
13069            signals: vec![],
13070        });
13071        schema
13072    }
13073
13074    #[test]
13075    fn a_backlink_to_an_interface_carries_the_concrete_type() {
13076        let out = compile_and_emit_with(
13077            "SELECT Note { pinners := .<pinned[is Account] { id } }",
13078            &make_note_schema(),
13079        );
13080        assert!(
13081            !out.sql.contains("'default::Account'::text"),
13082            "the backlink's rows are labelled with the interface instead of their own type:\n{}",
13083            out.sql
13084        );
13085    }
13086
13087    #[test]
13088    fn a_link_reached_through_a_deep_splat_carries_the_concrete_type() {
13089        let out = compile_and_emit_with("SELECT Note { ** }", &make_note_schema());
13090        assert!(
13091            !out.sql.contains("'default::Account'::text"),
13092            "the splatted link's rows are labelled with the interface instead of their own type:\n{}",
13093            out.sql
13094        );
13095    }
13096
13097    #[test]
13098    fn test_path_traversal_onto_an_interface_carries_the_concrete_type() {
13099        // The hole the chained-pointer fix landed on: a *path join* onto an
13100        // interface still read the interface's view, so the rows a traversal
13101        // returned were tagged with the interface and hydrated its class. Links
13102        // fan out; this is the traversal route they share with computed chains.
13103        let mut schema = make_interface_schema();
13104        let account = schema
13105            .types
13106            .iter_mut()
13107            .find(|t| t.name == "Account")
13108            .expect("the interface schema has an Account type");
13109        account.materialized = true;
13110        let mut organization = schema
13111            .types
13112            .iter()
13113            .find(|t| t.name == "Individual")
13114            .expect("the interface schema has an Individual type")
13115            .clone();
13116        organization.name = "Organization".into();
13117        organization.table = "Organization".into();
13118        organization.computed.clear();
13119        schema.types.push(organization);
13120        schema.types.push(TypeDescriptor {
13121            name: "Note".into(),
13122            module: "default".into(),
13123            table: "Note".into(),
13124            abstract_: false,
13125            materialized: false,
13126            description: None,
13127            parents: vec![],
13128            interfaces: vec![],
13129            bases: vec![],
13130            properties: vec![],
13131            links: vec![LinkDescriptor {
13132                name: "owner".into(),
13133                target: "default::Account".into(),
13134                nullable: true,
13135                description: None,
13136                default_pyql: None,
13137                is_exclusive: false,
13138                is_readonly: false,
13139                rewrites: vec![],
13140                on_delete: vec![],
13141                through: None,
13142            }],
13143            multilinks: vec![],
13144            computed: vec![],
13145            constraints: vec![],
13146            indexes: vec![],
13147            partition: None,
13148            vector_indexes: vec![],
13149            search_indexes: vec![],
13150            triggers: vec![],
13151            junction: false,
13152            signals: vec![],
13153        });
13154
13155        let out = compile_and_emit_with("SELECT Note.owner { id }", &schema);
13156        assert!(
13157            out.sql.contains("UNION ALL") && out.sql.contains("'default::Individual'::text AS \"__type__\""),
13158            "the traversal's target should fan out over implementors, got:\n{}",
13159            out.sql
13160        );
13161        assert!(
13162            !out.sql.contains("'default::Account'::text,"),
13163            "the row's type should be read off the row, not fixed to the interface, got:\n{}",
13164            out.sql
13165        );
13166    }
13167
13168    #[test]
13169    fn test_computed_multilink_chain_comes_back_as_rows() {
13170        // Regression: `members := .memberships.member` is a chain, and
13171        // try_compile_pointer_expr only recognised a single-step `.multilink`,
13172        // so it fell through to expression position and became a scalar
13173        // subquery — which Postgres rejects the moment a second row matches.
13174        let mut schema = make_schema();
13175        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13176            name: "coauthors".into(),
13177            expression: ".posts.author".into(),
13178            return_type: None,
13179            link_target: None,
13180            link_multi: false,
13181        });
13182        let post = schema
13183            .types
13184            .iter_mut()
13185            .find(|t| t.name == "Post")
13186            .expect("test schema has a Post type");
13187        post.links.push(LinkDescriptor {
13188            name: "author".into(),
13189            target: "default::Person".into(),
13190            nullable: true,
13191            description: None,
13192            default_pyql: None,
13193            is_exclusive: false,
13194            is_readonly: false,
13195            rewrites: vec![],
13196            on_delete: vec![],
13197            through: None,
13198        });
13199
13200        let out = compile_and_emit_with("SELECT Person { coauthors: { name } }", &schema);
13201        assert!(
13202            out.sql.contains("ARRAY(SELECT"),
13203            "the chain should aggregate into an array, got:\n{}",
13204            out.sql
13205        );
13206        assert!(
13207            out.sql.contains("\"name\""),
13208            "the requested sub-shape should survive, got:\n{}",
13209            out.sql
13210        );
13211
13212        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13213            panic!("expected Object shape, got {:?}", out.shape.root)
13214        };
13215        let coauthors = pointers
13216            .iter()
13217            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "coauthors"))
13218            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
13219        let crate::query::ShapeNode::Array { element, .. } = coauthors else {
13220            unreachable!()
13221        };
13222        assert!(
13223            matches!(
13224                element.as_ref(),
13225                crate::query::ShapeNode::Object { type_name: Some(t), .. } if t == "default::Person"
13226            ),
13227            "the elements should hydrate as objects, got {element:?}",
13228        );
13229    }
13230
13231    #[test]
13232    fn test_multi_sort_with_then_emits_two_order_keys() {
13233        let out = compile_and_emit("SELECT Person { name } ORDER BY .name THEN .age DESC");
13234        assert!(out.sql.contains("ORDER BY"), "expected ORDER BY");
13235        // Both columns should appear in the ORDER BY clause
13236        assert!(out.sql.contains("\"name\""));
13237        assert!(out.sql.contains("\"age\""));
13238        assert!(out.sql.contains("DESC"));
13239    }
13240
13241    #[test]
13242    fn test_string_index_emits_str_subscript() {
13243        let out = compile_and_emit("SELECT 'hello'[1]");
13244        assert!(
13245            out.sql.contains("_pylon.str_subscript"),
13246            "expected _pylon.str_subscript() for string index, got:\n{}",
13247            out.sql
13248        );
13249    }
13250
13251    /// PostgreSQL forbids a set-returning function inside `VALUES`, so a loop
13252    /// over `array_unpack` has to iterate a select instead.
13253    #[test]
13254    fn test_for_over_a_set_returning_call_is_not_a_values_clause() {
13255        let out = compile_and_emit("FOR n IN array_unpack(<array<int64>>$ns) UNION (SELECT n)");
13256        assert!(
13257            !out.sql.contains("VALUES (unnest"),
13258            "a set-returning iterator must not land in VALUES, got:\n{}",
13259            out.sql
13260        );
13261        assert!(
13262            out.sql.contains("SELECT unnest"),
13263            "expected the iterator in a select list, got:\n{}",
13264            out.sql
13265        );
13266    }
13267
13268    /// The loop variable's type is the unpacked array's element type, not
13269    /// whatever the generically-declared call says.
13270    #[test]
13271    fn test_for_over_array_unpack_binds_the_element_type() {
13272        let out = compile_and_emit("FOR j IN array_unpack(<array<json>>$rows) UNION (SELECT <str>j['k'])");
13273        assert!(
13274            out.sql.contains("::jsonb AS v"),
13275            "the loop variable should carry the element type, got:\n{}",
13276            out.sql
13277        );
13278    }
13279
13280    /// Indexing json reaches into it with `->`; neither string nor array
13281    /// subscripting applies.
13282    #[test]
13283    fn test_indexing_json_emits_a_jsonb_accessor() {
13284        let out = compile_and_emit("SELECT (<json>$payload)['label']");
13285        assert!(
13286            out.sql.contains("->'label'"),
13287            "expected a jsonb field access, got:\n{}",
13288            out.sql
13289        );
13290        assert!(
13291            !out.sql.contains("str_subscript"),
13292            "json must not be subscripted as a string, got:\n{}",
13293            out.sql
13294        );
13295    }
13296
13297    /// Casting json to a scalar yields the value, not its JSON spelling —
13298    /// `<str>` of a json string has no quotes around it.
13299    #[test]
13300    fn test_casting_json_to_a_scalar_unwraps_it() {
13301        let out = compile_and_emit("SELECT <str>to_json(<str>$s)");
13302        assert!(
13303            out.sql.contains("#>> '{}'"),
13304            "expected the json value to be extracted, got:\n{}",
13305            out.sql
13306        );
13307    }
13308
13309    /// A `with` binding that names the loop variable holds a different value
13310    /// each iteration. Evaluated once for the statement it reads an iterator
13311    /// that is not in its FROM; every read of it belongs to one iteration.
13312    #[test]
13313    fn test_a_binding_that_reads_the_loop_variable_is_keyed_by_iteration() {
13314        let out = compile_and_emit(
13315            "WITH made := (FOR p IN (SELECT Person) UNION ( \
13316               WITH mine := (SELECT p.posts LIMIT 1) \
13317               INSERT Company { name := mine.title } \
13318             )) SELECT count(made)",
13319        );
13320        assert!(
13321            out.sql.contains("\"mine\" AS (\nSELECT \"_for_p\".\"v\" AS \"_outer\""),
13322            "the binding must be evaluated per iteration, got:\n{}",
13323            out.sql
13324        );
13325        assert!(
13326            out.sql.contains("\"mine\".\"_outer\" = \"_for_p\".\"v\""),
13327            "a read of it must pin to the iteration in scope, got:\n{}",
13328            out.sql
13329        );
13330    }
13331
13332    /// A `for … union (insert …)` assigned to a multi link *inside another
13333    /// loop* writes rows per iteration. They used to be cross-joined onto
13334    /// every row the outer loop wrote; they belong to one iteration of it.
13335    #[test]
13336    fn test_a_loop_nested_in_a_loop_pairs_its_rows_by_iteration() {
13337        let out = compile_and_emit(
13338            "FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (FOR t IN {n} UNION (INSERT Post { title := t })) })",
13339        );
13340        assert!(
13341            out.sql.contains("\"_outer\""),
13342            "the nested rows must carry an iteration key, got:\n{}",
13343            out.sql
13344        );
13345        assert!(
13346            !out.sql.contains("CROSS JOIN \"_nested_dml_0\""),
13347            "they must not be cross-joined onto every row, got:\n{}",
13348            out.sql
13349        );
13350    }
13351
13352    /// A multi link assigned in a `for`-bodied insert used to be dropped: the
13353    /// rows went in, the junction rows never did, and nothing reported it.
13354    #[test]
13355    fn test_for_bodied_insert_writes_its_multi_link() {
13356        let out = compile_and_emit("FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (SELECT Post) })");
13357        assert!(
13358            out.sql.contains("INSERT INTO \"public\".\"Person.posts\""),
13359            "the junction rows must be written too, got:\n{}",
13360            out.sql
13361        );
13362    }
13363
13364    #[test]
13365    fn test_string_slice_emits_substr() {
13366        let out = compile_and_emit("SELECT 'hello'[1:3]");
13367        assert!(
13368            out.sql.contains("substr"),
13369            "expected substr() for string slice, got:\n{}",
13370            out.sql
13371        );
13372    }
13373
13374    #[test]
13375    fn test_array_index_emits_subscript() {
13376        let out = compile_and_emit("SELECT [1, 2, 3][1]");
13377        assert!(
13378            out.sql.contains("_pylon.array_subscript"),
13379            "expected _pylon.array_subscript() for array index, got:\n{}",
13380            out.sql
13381        );
13382    }
13383
13384    #[test]
13385    fn test_index_into_array_returning_function_emits_array_subscript() {
13386        let out = compile_and_emit("SELECT str_split('a::b', '::')[-1]");
13387        assert!(
13388            out.sql.contains("_pylon.array_subscript(string_to_array"),
13389            "expected _pylon.array_subscript() over str_split, got:\n{}",
13390            out.sql
13391        );
13392    }
13393
13394    #[test]
13395    fn test_array_slice_emits_subscript() {
13396        let out = compile_and_emit("SELECT [1, 2, 3][0:2]");
13397        assert!(
13398            !out.sql.contains("substr"),
13399            "should not use substr for array, got:\n{}",
13400            out.sql
13401        );
13402        assert!(out.sql.contains(")["), "expected array slice syntax, got:\n{}", out.sql);
13403    }
13404
13405    #[test]
13406    fn test_open_ended_string_slice_emits_substr_no_length() {
13407        let out = compile_and_emit("SELECT 'hello'[2:]");
13408        // substr(expr, start) without length argument
13409        assert!(out.sql.contains("substr"), "expected substr(), got:\n{}", out.sql);
13410        // Should NOT have a third argument (length)
13411        let substr_idx = out.sql.find("substr").unwrap();
13412        let after = &out.sql[substr_idx..];
13413        let commas = after.chars().take_while(|&c| c != ')').filter(|&c| c == ',').count();
13414        assert_eq!(commas, 1, "open-ended slice should use 2-arg substr, got:\n{}", out.sql);
13415    }
13416
13417    #[test]
13418    fn test_group_by_single_key() {
13419        let out = compile_and_emit("group Person { name } by .age");
13420        // GROUP BY clause present
13421        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
13422        // Key column referenced
13423        assert!(out.sql.contains("\"age\""), "expected age column, got:\n{}", out.sql);
13424        // array_agg for elements
13425        assert!(
13426            out.sql.contains("array_agg(ROW("),
13427            "expected array_agg, got:\n{}",
13428            out.sql
13429        );
13430        // grouping names array
13431        assert!(
13432            out.sql.contains("ARRAY['age']"),
13433            "expected grouping array, got:\n{}",
13434            out.sql
13435        );
13436        // shape node is Group
13437        assert!(matches!(out.shape.root, crate::query::ShapeNode::Group { .. }));
13438        if let crate::query::ShapeNode::Group {
13439            key_nodes,
13440            grouping_position,
13441            elements_position,
13442            ..
13443        } = &out.shape.root
13444        {
13445            assert_eq!(key_nodes.len(), 1);
13446            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "age"));
13447            assert_eq!(*grouping_position, 2);
13448            assert_eq!(*elements_position, 3);
13449        }
13450    }
13451
13452    #[test]
13453    fn test_group_using_alias() {
13454        let out = compile_and_emit("group Person using decade := .age // 10 by decade");
13455        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
13456        assert!(
13457            out.sql.contains("ARRAY['decade']"),
13458            "expected grouping array, got:\n{}",
13459            out.sql
13460        );
13461        if let crate::query::ShapeNode::Group { key_nodes, .. } = &out.shape.root {
13462            assert_eq!(key_nodes.len(), 1);
13463            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, .. } if name == "decade"));
13464        }
13465    }
13466
13467    #[test]
13468    fn test_group_orders_elements_within_each_group() {
13469        let out = compile_and_emit("group Person { name } by .age order by .name desc");
13470        assert!(
13471            out.sql.contains(")::record ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
13472            "{}",
13473            out.sql
13474        );
13475        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
13476    }
13477
13478    #[test]
13479    fn test_group_limit_trims_each_group_not_the_result() {
13480        // The newest row per key: a trailing LIMIT would drop whole groups,
13481        // so the rows are ranked within each key instead.
13482        let out = compile_and_emit("group Person { name } by .age order by .name desc limit 1");
13483        assert!(
13484            out.sql
13485                .contains("row_number() OVER (PARTITION BY \"t0\".\"age\" ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
13486            "{}",
13487            out.sql
13488        );
13489        assert!(
13490            out.sql.contains("WHERE \"__rk\" > 0 AND \"__rk\" <= 0 + 1"),
13491            "{}",
13492            out.sql
13493        );
13494        assert!(!out.sql.contains("\nLIMIT"), "{}", out.sql);
13495    }
13496
13497    #[test]
13498    fn test_group_by_two_keys_with_a_per_group_limit() {
13499        let out = compile_and_emit(
13500            "group Person { name } by .age, .name filter .age > 18 order by .name desc offset 1 limit 2",
13501        );
13502        assert!(
13503            out.sql.contains("PARTITION BY \"t0\".\"age\", \"t0\".\"name\""),
13504            "{}",
13505            out.sql
13506        );
13507        // The filter picks which rows are grouped, so it sits inside the
13508        // ranking subquery — ranking must not see rows the filter excluded.
13509        let where_pos = out.sql.find("WHERE (\"t0\".\"age\" > 18)").expect("filter");
13510        let rank_pos = out.sql.find("WHERE \"__rk\"").expect("rank filter");
13511        assert!(where_pos < rank_pos, "{}", out.sql);
13512        assert!(
13513            out.sql.contains("GROUP BY \"t0\".\"age\", \"t0\".\"name\""),
13514            "{}",
13515            out.sql
13516        );
13517    }
13518
13519    #[test]
13520    fn test_group_filter_without_a_limit_is_a_plain_where() {
13521        let out = compile_and_emit("group Person { name } by .age filter .age > 18");
13522        assert!(out.sql.contains("WHERE (\"t0\".\"age\" > 18)"), "{}", out.sql);
13523        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
13524    }
13525
13526    #[test]
13527    fn test_abs_path_concat_same_type() {
13528        let out = compile_and_emit("SELECT Person.name ++ ' ' ++ Person.name");
13529        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
13530        assert!(out.sql.contains("||"), "expected concat operator, got:\n{}", out.sql);
13531        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
13532    }
13533
13534    #[test]
13535    fn test_abs_path_single_property() {
13536        let out = compile_and_emit("SELECT Person.name");
13537        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
13538        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
13539    }
13540
13541    #[test]
13542    fn test_pgvector_cast_emits_vector_type() {
13543        let out = compile_and_emit("SELECT <pgvector::vector>[1.0, 2.0, 3.0]");
13544        assert!(
13545            out.sql.contains("::vector"),
13546            "expected ::vector cast, got:\n{}",
13547            out.sql
13548        );
13549        assert!(out.sql.contains("ARRAY["), "expected ARRAY literal, got:\n{}", out.sql);
13550    }
13551
13552    #[test]
13553    fn test_pgvector_euclidean_distance_emits_l2_operator() {
13554        let out = compile_and_emit(
13555            "SELECT pgvector::euclidean_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
13556        );
13557        assert!(out.sql.contains("<->"), "expected <-> operator, got:\n{}", out.sql);
13558    }
13559
13560    #[test]
13561    fn test_pgvector_cosine_distance_emits_cosine_operator() {
13562        let out = compile_and_emit(
13563            "SELECT pgvector::cosine_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
13564        );
13565        assert!(out.sql.contains("<=>"), "expected <=> operator, got:\n{}", out.sql);
13566    }
13567
13568    #[test]
13569    fn test_pgvector_neg_inner_product_emits_ip_operator() {
13570        let out = compile_and_emit(
13571            "SELECT pgvector::neg_inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
13572        );
13573        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
13574    }
13575
13576    #[test]
13577    fn test_pgvector_inner_product_negates_ip_operator() {
13578        let out = compile_and_emit(
13579            "SELECT pgvector::inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
13580        );
13581        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
13582        assert!(out.sql.contains("0.0"), "expected negation of <#>, got:\n{}", out.sql);
13583    }
13584
13585    #[test]
13586    fn test_crypto_digest_str_and_bytes_overloads_both_use_pgcrypto_digest() {
13587        let out = compile_and_emit("SELECT crypto::digest('hello', 'sha256')");
13588        assert!(
13589            out.sql.contains("digest("),
13590            "expected pgcrypto's digest(), got:\n{}",
13591            out.sql
13592        );
13593
13594        let out = compile_and_emit("SELECT crypto::digest(std::from_hex('68656c6c6f'), 'sha256')");
13595        assert!(
13596            out.sql.contains("digest("),
13597            "expected pgcrypto's digest(), got:\n{}",
13598            out.sql
13599        );
13600    }
13601
13602    #[test]
13603    fn test_crypto_hmac_str_and_bytes_overloads_both_use_pgcrypto_hmac() {
13604        let out = compile_and_emit("SELECT crypto::hmac('hello', 'key', 'sha256')");
13605        assert!(
13606            out.sql.contains("hmac("),
13607            "expected pgcrypto's hmac(), got:\n{}",
13608            out.sql
13609        );
13610
13611        let out =
13612            compile_and_emit("SELECT crypto::hmac(std::from_hex('68656c6c6f'), std::from_hex('6b6579'), 'sha256')");
13613        assert!(
13614            out.sql.contains("hmac("),
13615            "expected pgcrypto's hmac(), got:\n{}",
13616            out.sql
13617        );
13618    }
13619
13620    #[test]
13621    fn test_crypto_gen_salt_zero_arg_defaults_to_blowfish() {
13622        let out = compile_and_emit("SELECT crypto::gen_salt()");
13623        assert!(
13624            out.sql.contains("gen_salt('bf')"),
13625            "expected default 'bf' salt type, got:\n{}",
13626            out.sql
13627        );
13628    }
13629
13630    #[test]
13631    fn test_crypto_gen_salt_one_arg_passes_type_through() {
13632        let out = compile_and_emit("SELECT crypto::gen_salt('xdes')");
13633        assert!(
13634            out.sql.contains("gen_salt("),
13635            "expected gen_salt() call, got:\n{}",
13636            out.sql
13637        );
13638    }
13639
13640    #[test]
13641    fn test_crypto_gen_salt_iter_count_casts_to_int4() {
13642        let out = compile_and_emit("SELECT crypto::gen_salt('xdes', 5)");
13643        assert!(
13644            out.sql.contains("::int4"),
13645            "expected int8 -> int4 narrowing cast, got:\n{}",
13646            out.sql
13647        );
13648    }
13649
13650    #[test]
13651    fn test_crypto_crypt_uses_pgcrypto_crypt() {
13652        let out = compile_and_emit("SELECT crypto::crypt('hunter2', crypto::gen_salt())");
13653        assert!(
13654            out.sql.contains("crypt("),
13655            "expected pgcrypto's crypt(), got:\n{}",
13656            out.sql
13657        );
13658    }
13659
13660    #[test]
13661    fn test_postgis_cast_emits_geometry_type() {
13662        let out = compile_and_emit("SELECT <postgis::geometry>'POINT(1 2)'");
13663        assert!(
13664            out.sql.contains("::geometry"),
13665            "expected ::geometry cast, got:\n{}",
13666            out.sql
13667        );
13668    }
13669
13670    #[test]
13671    fn test_postgis_x_uses_st_x_builtin() {
13672        let out = compile_and_emit("SELECT postgis::x(<postgis::geometry>'POINT(1 2)')");
13673        assert!(out.sql.contains("st_x("), "expected st_x() call, got:\n{}", out.sql);
13674    }
13675
13676    #[test]
13677    fn test_postgis_area_geometry_and_geography_overloads() {
13678        let out = compile_and_emit("SELECT postgis::area(<postgis::geometry>'POINT(1 2)')");
13679        assert!(
13680            out.sql.contains("st_area("),
13681            "expected st_area() call, got:\n{}",
13682            out.sql
13683        );
13684
13685        let out = compile_and_emit("SELECT postgis::area(<postgis::geography>'POINT(1 2)', true)");
13686        assert!(
13687            out.sql.contains("st_area("),
13688            "expected st_area() call, got:\n{}",
13689            out.sql
13690        );
13691    }
13692
13693    #[test]
13694    fn test_postgis_setsrid_casts_int64_arg_to_int4() {
13695        let out = compile_and_emit("SELECT postgis::setsrid(<postgis::geometry>'POINT(1 2)', 4326)");
13696        assert!(
13697            out.sql.contains("st_setsrid("),
13698            "expected st_setsrid() call, got:\n{}",
13699            out.sql
13700        );
13701        assert!(
13702            out.sql.contains("::int4"),
13703            "expected int8 -> int4 narrowing cast, got:\n{}",
13704            out.sql
13705        );
13706    }
13707
13708    #[test]
13709    fn test_postgis_quantizecoordinates_default_arity_variants_compile() {
13710        // The reference binding documents this with 3 trailing optional
13711        // params; Pylon has no notion of default args, so each arity is
13712        // its own registered overload — confirm both the 2-arg and 4-arg
13713        // forms resolve.
13714        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5)");
13715        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
13716
13717        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5, 5, 5)");
13718        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
13719    }
13720
13721    #[test]
13722    fn test_postgis_op_contains_emits_infix_operator_not_function_call() {
13723        // Regression: ImplStrategy::SqlOperator was never actually consulted
13724        // by resolve_fn_call — it fell through to the generic "schema.name(args)"
13725        // FunctionCall path, which would have emitted a nonexistent
13726        // `"postgis".op_contains(...)` call instead of the `~` operator.
13727        let out = compile_and_emit(
13728            "SELECT postgis::op_contains(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
13729        );
13730        assert!(out.sql.contains(" ~ "), "expected infix ~ operator, got:\n{}", out.sql);
13731        assert!(
13732            !out.sql.contains("op_contains("),
13733            "must not call a literal op_contains function, got:\n{}",
13734            out.sql
13735        );
13736    }
13737
13738    #[test]
13739    fn test_postgis_op_overlaps_geometry_and_geography_overloads() {
13740        let out = compile_and_emit(
13741            "SELECT postgis::op_overlaps(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
13742        );
13743        assert!(
13744            out.sql.contains(" && "),
13745            "expected infix && operator, got:\n{}",
13746            out.sql
13747        );
13748
13749        let out = compile_and_emit(
13750            "SELECT postgis::op_overlaps(<postgis::geography>'POINT(1 2)', <postgis::geography>'POINT(3 4)')",
13751        );
13752        assert!(
13753            out.sql.contains(" && "),
13754            "expected infix && operator, got:\n{}",
13755            out.sql
13756        );
13757    }
13758
13759    // ── User-defined function tests ───────────────────────────────────────────
13760
13761    fn make_schema_with_fns() -> SchemaDescriptor {
13762        let mut s = make_schema();
13763        s.functions = vec![
13764            FunctionDescriptor {
13765                name: "mysum".into(),
13766                module: "default".into(),
13767                params: vec![
13768                    FunctionParamDescriptor {
13769                        name: "a".into(),
13770                        pg_type: "int8".into(),
13771                    },
13772                    FunctionParamDescriptor {
13773                        name: "b".into(),
13774                        pg_type: "int8".into(),
13775                    },
13776                ],
13777                return_pg_type: "int8".into(),
13778                return_is_object: false,
13779                return_is_set: false,
13780                return_is_polymorphic: false,
13781                volatility: "immutable".into(),
13782                body: "a + b".into(),
13783            },
13784            FunctionDescriptor {
13785                name: "adults".into(),
13786                module: "default".into(),
13787                params: vec![],
13788                return_pg_type: "default::Person".into(),
13789                return_is_object: true,
13790                return_is_set: true,
13791                return_is_polymorphic: false,
13792                volatility: "stable".into(),
13793                body: "select Person filter .age > 18".into(),
13794            },
13795            // A second, 3-arg overload of `mysum`, declared *before* the
13796            // 2-arg one is looked up by resolution below — regression for a
13797            // bug where call resolution grabbed the first name-matching
13798            // overload regardless of arity, instead of searching all
13799            // same-named overloads for one whose param count actually
13800            // matches the call site.
13801            FunctionDescriptor {
13802                name: "mysum".into(),
13803                module: "default".into(),
13804                params: vec![
13805                    FunctionParamDescriptor {
13806                        name: "a".into(),
13807                        pg_type: "int8".into(),
13808                    },
13809                    FunctionParamDescriptor {
13810                        name: "b".into(),
13811                        pg_type: "int8".into(),
13812                    },
13813                    FunctionParamDescriptor {
13814                        name: "c".into(),
13815                        pg_type: "int8".into(),
13816                    },
13817                ],
13818                return_pg_type: "int8".into(),
13819                return_is_object: false,
13820                return_is_set: false,
13821                return_is_polymorphic: false,
13822                volatility: "immutable".into(),
13823                body: "a + b + c".into(),
13824            },
13825        ];
13826        s
13827    }
13828
13829    #[test]
13830    fn test_user_fn_scalar_call() {
13831        let schema = make_schema_with_fns();
13832        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
13833        assert!(out.sql.contains("\"public\".\"mysum\""), "got:\n{}", out.sql);
13834    }
13835
13836    #[test]
13837    fn test_user_fn_overload_resolved_by_argument_count() {
13838        // Regression: with a 2-arg and a 3-arg `mysum` overload both
13839        // declared, a 3-arg call must resolve to the 3-arg overload rather
13840        // than erroring against whichever overload happens to be first in
13841        // `schema.functions`.
13842        let schema = make_schema_with_fns();
13843        let out = compile_and_emit_with("SELECT mysum(1, 2, 3)", &schema);
13844        assert!(
13845            out.sql
13846                .contains("\"public\".\"mysum\"((1)::int8, (2)::int8, (3)::int8)"),
13847            "got:\n{}",
13848            out.sql,
13849        );
13850
13851        // The 2-arg call must still resolve to the 2-arg overload.
13852        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
13853        assert!(
13854            out.sql.contains("\"public\".\"mysum\"((1)::int8, (2)::int8)"),
13855            "got:\n{}",
13856            out.sql,
13857        );
13858    }
13859
13860    #[test]
13861    fn test_user_fn_object_select_no_shape() {
13862        let schema = make_schema_with_fns();
13863        let out = compile_and_emit_with("SELECT adults()", &schema);
13864        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
13865        assert!(out.sql.contains("FROM"), "got:\n{}", out.sql);
13866    }
13867
13868    #[test]
13869    fn test_user_fn_object_select_with_shape() {
13870        let schema = make_schema_with_fns();
13871        let out = compile_and_emit_with("SELECT adults() { name }", &schema);
13872        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
13873        assert!(out.sql.contains("\"name\""), "got:\n{}", out.sql);
13874    }
13875
13876    #[test]
13877    fn test_object_fn_projected_to_a_property_inside_an_expression() {
13878        let schema = make_schema_with_fns();
13879        let out = compile_and_emit_with("SELECT Person { n := adults().name }", &schema);
13880        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
13881        assert!(out.sql.contains("\".\"name\"\n"), "got:\n{}", out.sql);
13882    }
13883
13884    #[test]
13885    fn test_object_fn_sub_select_carries_its_modifiers() {
13886        let schema = make_schema_with_fns();
13887        let out = compile_and_emit_with(
13888            "SELECT Person { n := (select adults() filter .age > 21 limit 1).name }",
13889            &schema,
13890        );
13891        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
13892        assert!(out.sql.contains("\"age\" > 21"), "got:\n{}", out.sql);
13893        assert!(out.sql.contains("LIMIT 1"), "got:\n{}", out.sql);
13894    }
13895
13896    #[test]
13897    fn test_object_fn_without_a_property_still_explains_the_restriction() {
13898        let schema = make_schema_with_fns();
13899        let ast = parse::parse("SELECT Person { n := adults() + 1 }").unwrap();
13900        let err = match ir::compile(&ast, &schema) {
13901            Ok(_) => panic!("expected a compile error"),
13902            Err(e) => format!("{e}"),
13903        };
13904        assert!(err.contains("returns objects"), "{err}");
13905    }
13906
13907    #[test]
13908    fn test_object_fn_projected_to_an_unknown_property_is_rejected() {
13909        let schema = make_schema_with_fns();
13910        let ast = parse::parse("SELECT Person { n := adults().nope }").unwrap();
13911        let err = match ir::compile(&ast, &schema) {
13912            Ok(_) => panic!("expected a compile error"),
13913            Err(e) => format!("{e}"),
13914        };
13915        assert!(err.contains("nope"), "{err}");
13916    }
13917
13918    #[test]
13919    fn test_user_fn_in_cte_exposes_raw_columns() {
13920        // Regression: FunctionSelect as a CTE source must emit SELECT * FROM fn()
13921        // so the outer query can reference raw columns like t1.age.
13922        let schema = make_schema_with_fns();
13923        let out = compile_and_emit_with("WITH persons := adults() SELECT persons FILTER .age > 25", &schema);
13924        assert!(
13925            out.sql.contains("SELECT * FROM \"public\".\"adults\"()"),
13926            "CTE source must be SELECT * FROM fn(), got:\n{}",
13927            out.sql,
13928        );
13929        assert!(
13930            out.sql.contains("\"age\""),
13931            "outer filter must reference raw column, got:\n{}",
13932            out.sql
13933        );
13934    }
13935
13936    // ── vector::search tests ──────────────────────────────────────────────────
13937
13938    fn make_schema_with_vector() -> SchemaDescriptor {
13939        use crate::schema::VectorIndexDescriptor;
13940        let mut s = make_schema();
13941        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
13942            td.vector_indexes.push(VectorIndexDescriptor {
13943                index_name: None,
13944                pointers: vec!["name".into()],
13945                model: "test-embed".into(),
13946                metric: "cosine".into(),
13947                dimensions: 4,
13948            });
13949        }
13950        s
13951    }
13952
13953    #[test]
13954    fn test_vector_search_bare_type_name() {
13955        let schema = make_schema_with_vector();
13956        let out = compile_and_emit_with(
13957            "WITH search := vector::search(Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
13958             SELECT search { object { name }, distance }",
13959            &schema,
13960        );
13961        assert!(
13962            out.sql.contains("\"Person\""),
13963            "expected Person table, got:\n{}",
13964            out.sql
13965        );
13966        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
13967    }
13968
13969    #[test]
13970    fn test_vector_search_qualified_type_name() {
13971        let schema = make_schema_with_vector();
13972        let out = compile_and_emit_with(
13973            "WITH search := vector::search(default::Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
13974             SELECT search { object { name }, distance }",
13975            &schema,
13976        );
13977        assert!(
13978            out.sql.contains("\"Person\""),
13979            "expected Person table, got:\n{}",
13980            out.sql
13981        );
13982        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
13983    }
13984
13985    #[test]
13986    fn test_vector_search_subquery_filter_included_in_where() {
13987        let schema = make_schema_with_vector();
13988        let out = compile_and_emit_with(
13989            "WITH search := vector::search((select Person filter .name = 'Alice'), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
13990             SELECT search { object { name }, distance }",
13991            &schema,
13992        );
13993        assert!(
13994            out.sql.contains("\"Person\""),
13995            "expected Person table, got:\n{}",
13996            out.sql
13997        );
13998        assert!(out.sql.contains("\"name\""), "expected name filter, got:\n{}", out.sql);
13999        assert!(out.sql.contains("Alice"), "expected filter value, got:\n{}", out.sql);
14000        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14001    }
14002
14003    #[test]
14004    fn test_vector_search_subquery_filter_combined_with_outer_property_filter() {
14005        let schema = make_schema_with_vector();
14006        let out = compile_and_emit_with(
14007            "WITH search := vector::search((select Person filter .age > 18), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14008             SELECT search { object { name }, distance }",
14009            &schema,
14010        );
14011        assert!(
14012            out.sql.contains("\"age\""),
14013            "expected age pre-filter, got:\n{}",
14014            out.sql
14015        );
14016        assert!(out.sql.contains("18"), "expected filter value 18, got:\n{}", out.sql);
14017        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14018    }
14019
14020    #[test]
14021    fn test_vector_search_text_overload_with_subquery_filter() {
14022        let schema = make_schema_with_vector();
14023        let out = compile_and_emit_with(
14024            "WITH search := vector::search((select Person filter .name = 'Alice'), query := $q) \
14025             SELECT search { object { name }, distance }",
14026            &schema,
14027        );
14028        assert!(
14029            out.sql.contains("\"Person\""),
14030            "expected Person table, got:\n{}",
14031            out.sql
14032        );
14033        assert!(
14034            out.sql.contains("Alice"),
14035            "expected pre-filter value, got:\n{}",
14036            out.sql
14037        );
14038        assert!(
14039            out.sql.contains("float8[]"),
14040            "expected float8[] cast for deferred vec param, got:\n{}",
14041            out.sql
14042        );
14043        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14044    }
14045
14046    #[test]
14047    fn test_count_type_ref_compiles_to_agg_over_query() {
14048        let out = compile_and_emit("SELECT count(Person)");
14049        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14050        assert!(
14051            out.sql.contains("\"Person\""),
14052            "expected Person table, got:\n{}",
14053            out.sql
14054        );
14055    }
14056
14057    #[test]
14058    fn test_count_qualified_type_ref_compiles_to_agg_over_query() {
14059        let out = compile_and_emit("SELECT count(default::Person)");
14060        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14061        assert!(
14062            out.sql.contains("\"Person\""),
14063            "expected Person table, got:\n{}",
14064            out.sql
14065        );
14066    }
14067
14068    #[test]
14069    fn test_count_subquery_compiles_to_agg_over_query() {
14070        let out = compile_and_emit("SELECT count((select Person))");
14071        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14072        assert!(
14073            out.sql.contains("\"Person\""),
14074            "expected Person table, got:\n{}",
14075            out.sql
14076        );
14077    }
14078
14079    #[test]
14080    fn test_count_subquery_with_filter() {
14081        let out = compile_and_emit("SELECT count((select Person filter .name = 'Alice'))");
14082        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14083        assert!(
14084            out.sql.contains("\"name\""),
14085            "expected filter on name, got:\n{}",
14086            out.sql
14087        );
14088    }
14089
14090    #[test]
14091    fn test_positional_param_compiles_to_dollar_n() {
14092        let out = compile_and_emit("SELECT Person FILTER .name = $0");
14093        assert!(out.sql.contains("$1"), "expected $1 placeholder, got:\n{}", out.sql);
14094    }
14095
14096    #[test]
14097    fn test_multiple_positional_params_compile_in_order() {
14098        let out = compile_and_emit("SELECT Person FILTER .name = $0 AND .age > $1");
14099        assert!(out.sql.contains("$1"), "expected $1, got:\n{}", out.sql);
14100        assert!(out.sql.contains("$2"), "expected $2, got:\n{}", out.sql);
14101    }
14102
14103    #[test]
14104    fn test_repeated_positional_param_reuses_slot() {
14105        let out = compile_and_emit("SELECT Person FILTER .name = $0 OR .name = $0");
14106        assert_eq!(
14107            out.sql.matches("$1").count(),
14108            2,
14109            "both uses must reference $1, got:\n{}",
14110            out.sql
14111        );
14112    }
14113
14114    #[test]
14115    fn test_cast_to_nonexistent_type_names_full_type() {
14116        let schema = make_schema();
14117        let ast = parse::parse("SELECT Person FILTER .name = <default::Ghost>$name").unwrap();
14118        match ir::compile(&ast, &schema) {
14119            Ok(_) => panic!("expected compile error for unknown type"),
14120            Err(e) => {
14121                let msg = e.to_string();
14122                assert!(
14123                    msg.contains("unknown type 'default::Ghost'"),
14124                    "expected full type name in error, got: {msg}",
14125                );
14126            }
14127        }
14128    }
14129
14130    #[test]
14131    fn test_cast_to_nonexistent_unqualified_type_names_type() {
14132        let schema = make_schema();
14133        let ast = parse::parse("SELECT Person FILTER .name = <Ghost>$name").unwrap();
14134        match ir::compile(&ast, &schema) {
14135            Ok(_) => panic!("expected compile error for unknown type"),
14136            Err(e) => {
14137                let msg = e.to_string();
14138                assert!(
14139                    msg.contains("unknown type 'Ghost'"),
14140                    "expected type name in error, got: {msg}",
14141                );
14142            }
14143        }
14144    }
14145
14146    #[test]
14147    fn test_top_level_cast_to_registered_scalar_uses_its_own_domain() {
14148        // A bare top-level `SELECT <module::Scalar>expr` must compile as a
14149        // plain scalar cast to that scalar's own DOMAIN — not get misrouted
14150        // to `compile_schema_cast_select`'s object-by-id lookup (which
14151        // treated any non-stdlib-module named cast target as an object type,
14152        // since it never checked `resolve_scalar` before falling through).
14153        use crate::schema::ScalarDescriptor;
14154        let mut schema = make_schema();
14155        schema.scalars.push(ScalarDescriptor {
14156            name: "Email".into(),
14157            module: "default".into(),
14158            base: "Str".into(),
14159            pg_type: "text".into(),
14160            check_constraints: vec!["value ~ '^[^@]+@[^@]+\\.[^@]+$'".into()],
14161            is_sequence: false,
14162        });
14163        let out = compile_and_emit_with("SELECT <default::Email>'test@test.de'", &schema);
14164        assert!(
14165            out.sql.contains("\"public\".\"Email\""),
14166            "expected a cast to the scalar's own domain, got:\n{}",
14167            out.sql
14168        );
14169        assert!(
14170            !out.sql.to_lowercase().contains("\"person\""),
14171            "must not be misrouted to an object-type lookup, got:\n{}",
14172            out.sql
14173        );
14174    }
14175
14176    #[test]
14177    fn test_top_level_cast_to_unknown_type_names_full_type() {
14178        // The object-cast fallback (`compile_schema_cast_select`) used to
14179        // discard the module before building its synthetic path, so an
14180        // unknown top-level cast target reported a bare name even when
14181        // written with an explicit module.
14182        let schema = make_schema();
14183        let ast = parse::parse("SELECT <default::Ghost>$name").unwrap();
14184        match ir::compile(&ast, &schema) {
14185            Ok(_) => panic!("expected compile error for unknown type"),
14186            Err(e) => {
14187                let msg = e.to_string();
14188                assert!(
14189                    msg.contains("unknown type 'default::Ghost'"),
14190                    "expected full type name in error, got: {msg}",
14191                );
14192            }
14193        }
14194    }
14195
14196    #[test]
14197    fn test_structural_tuple_cast_unnamed_resolves_to_jsonb() {
14198        let out = compile_and_emit("SELECT <tuple<str, bool>>$p");
14199        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14200    }
14201
14202    #[test]
14203    fn test_jsonb_to_uuid_cast_extracts_via_text() {
14204        // PostgreSQL has no native jsonb -> uuid cast; extract raw text then cast.
14205        let out = compile_and_emit("SELECT <uuid>(<json>$p)");
14206        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14207        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
14208    }
14209
14210    #[test]
14211    fn test_jsonb_to_datetime_cast_extracts_via_text() {
14212        let out = compile_and_emit("SELECT <datetime>(<json>$p)");
14213        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14214        assert!(out.sql.contains("::timestamptz"), "got:\n{}", out.sql);
14215    }
14216
14217    #[test]
14218    fn test_jsonb_to_duration_cast_extracts_via_text() {
14219        let out = compile_and_emit("SELECT <duration>(<json>$p)");
14220        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14221        assert!(out.sql.contains("::interval"), "got:\n{}", out.sql);
14222    }
14223
14224    #[test]
14225    fn test_jsonb_to_array_cast_unpacks_each_element() {
14226        let out = compile_and_emit("SELECT <array<int64>>(<json>$p)");
14227        assert!(out.sql.contains("jsonb_array_elements("), "got:\n{}", out.sql);
14228        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14229        assert!(out.sql.contains("::int8"), "got:\n{}", out.sql);
14230    }
14231
14232    #[test]
14233    fn test_non_jsonb_cast_is_unaffected_by_jsonb_extraction() {
14234        // A plain str -> uuid cast (source isn't jsonb) must still use the
14235        // ordinary `::pg_type` path, not the jsonb-extraction template.
14236        let out = compile_and_emit("SELECT <uuid>$p");
14237        assert!(!out.sql.contains("#>>"), "got:\n{}", out.sql);
14238        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
14239    }
14240
14241    #[test]
14242    fn test_datetime_plus_duration_type_checks() {
14243        // Regression: types_compatible's bucket-matching (same type, or
14244        // both-int, or both-float) rejected this outright, even though
14245        // Postgres supports `timestamptz + interval` natively and this is
14246        // a legitimate operator overload.
14247        let out = compile_and_emit("SELECT <datetime>$p + <duration>$d");
14248        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14249    }
14250
14251    #[test]
14252    fn test_duration_plus_datetime_type_checks_either_order() {
14253        let out = compile_and_emit("SELECT <duration>$d + <datetime>$p");
14254        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14255    }
14256
14257    #[test]
14258    fn test_local_date_plus_duration_type_checks() {
14259        let out = compile_and_emit("SELECT <cal::local_date>$p + <duration>$d");
14260        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14261    }
14262
14263    #[test]
14264    fn test_datetime_minus_duration_type_checks() {
14265        let out = compile_and_emit("SELECT <datetime>$p - <duration>$d");
14266        assert!(out.sql.contains(" - "), "got:\n{}", out.sql);
14267    }
14268
14269    #[test]
14270    fn test_local_time_plus_duration_type_checks() {
14271        let out = compile_and_emit("SELECT <cal::local_time>$p + <duration>$d");
14272        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14273    }
14274
14275    #[test]
14276    fn test_datetime_plus_duration_only_allowed_for_add_and_sub() {
14277        // Comparing (or otherwise combining) a datetime and a duration with
14278        // anything other than +/- still isn't meaningful and must still error.
14279        let schema = make_schema();
14280        let ast = parse::parse("SELECT <datetime>$p = <duration>$d").unwrap();
14281        match ir::compile(&ast, &schema) {
14282            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
14283            Ok(_) => panic!("expected a compile error"),
14284        }
14285    }
14286
14287    #[test]
14288    fn test_int_and_float_mix_is_allowed() {
14289        // Every int width implicitly casts to every float width, so mixed
14290        // int/float arithmetic type-checks without an explicit cast —
14291        // Postgres's own operator resolution handles the actual promotion.
14292        let out = compile_and_emit("SELECT <int16>1 + <float32>2.0");
14293        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14294    }
14295
14296    #[test]
14297    fn test_int_and_decimal_mix_is_allowed() {
14298        // int64 -> bigint -> decimal is a separate implicit-cast branch
14299        // from int64 -> float32 -> float64, but every int width still
14300        // mixes with numeric/decimal directly.
14301        let out = compile_and_emit("SELECT <int64>1 + <decimal>2.0");
14302        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
14303    }
14304
14305    #[test]
14306    fn test_float_and_decimal_mix_still_rejected() {
14307        // float and numeric/decimal are separate branches past int64 in
14308        // the implicit-cast graph and don't cast to each other — this
14309        // still needs an explicit cast.
14310        let schema = make_schema();
14311        let ast = parse::parse("SELECT <decimal>$p + <float64>$n").unwrap();
14312        match ir::compile(&ast, &schema) {
14313            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
14314            Ok(_) => panic!("expected a compile error"),
14315        }
14316    }
14317
14318    #[test]
14319    fn test_unknown_pointer_suggests_a_close_match() {
14320        // `Person` has a `name` property (see make_schema) — `nam` is close
14321        // enough (missing one trailing character) that a "Did you mean"
14322        // suggestion should fire.
14323        let schema = make_schema();
14324        let ast = parse::parse("SELECT Person { nam }").unwrap();
14325        match ir::compile(&ast, &schema) {
14326            Err(err) => assert_eq!(
14327                format!("{err}"),
14328                "object type 'default::Person' has no link or property 'nam'. Did you mean 'name'?"
14329            ),
14330            Ok(_) => panic!("expected a compile error"),
14331        }
14332    }
14333
14334    #[test]
14335    fn test_unrelated_unknown_pointer_gets_no_suggestion() {
14336        // A typo with no plausible match on the type shouldn't force a
14337        // confusing, unrelated suggestion onto the user.
14338        let schema = make_schema();
14339        let ast = parse::parse("SELECT Person { xyzxyzxyz }").unwrap();
14340        match ir::compile(&ast, &schema) {
14341            Err(err) => {
14342                let msg = format!("{err}");
14343                assert!(msg.contains("has no link or property 'xyzxyzxyz'"), "got: {msg}");
14344                assert!(!msg.contains("Did you mean"), "got: {msg}");
14345            }
14346            Ok(_) => panic!("expected a compile error"),
14347        }
14348    }
14349
14350    #[test]
14351    fn test_multilink_sourced_backlink_exists_filter_compiles() {
14352        // Regression for a gap live-execution testing caught: `filter exists
14353        // .<multilink[is Type]` (a backlink whose source pointer on the
14354        // target type is a multi-link, e.g. `Person.posts`, not a single
14355        // FK link) used to fail to compile with "has no link ... pointing
14356        // to" — `compile_backlink_as_exists` only ever checked
14357        // `target_td.links`, never `target_td.multilinks`. Fast SQL-text
14358        // companion to the live-execution test in
14359        // `tests/live_execution_backlinks.rs`, which additionally confirms
14360        // the emitted SQL returns the right rows.
14361        let out = compile_and_emit("SELECT Post filter exists .<posts[is Person]");
14362        assert!(out.sql.contains("EXISTS"), "got:\n{}", out.sql);
14363    }
14364
14365    #[test]
14366    fn test_backlink_as_computed_shape_pointer_compiles() {
14367        // Regression for the bigger gap live-execution testing caught: a
14368        // backlink could only be used in `filter exists .<...>` — using one
14369        // to select/project actual objects as a computed pointer's value
14370        // (`authors := .<posts[is Person] { name }`) failed to compile at
14371        // all ("shapes and set literals are not valid in expression
14372        // context"). `compile_shape_element`'s computed-pointer dispatch
14373        // now recognizes a backlink-rooted `Expr::Shape`/`Expr::Path` RHS
14374        // and routes it through `compile_backlink_pointer`, mirroring
14375        // `compile_multilink_pointer` for the forward direction. Fast
14376        // SQL-text companion to the live-execution tests in
14377        // `tests/live_execution_backlinks.rs`, which additionally confirm
14378        // the emitted SQL returns the right rows, nested 2 levels deep.
14379        let out = compile_and_emit("SELECT Post { title, authors := .<posts[is Person] { name } }");
14380        assert!(out.sql.contains("array_agg(ROW("), "got:\n{}", out.sql);
14381    }
14382
14383    fn schema_with_exclusive_posts() -> SchemaDescriptor {
14384        let mut schema = make_schema();
14385        for td in schema.types.iter_mut().filter(|td| td.name == "Person") {
14386            for ml in td.multilinks.iter_mut().filter(|ml| ml.name == "posts") {
14387                ml.is_exclusive = true;
14388            }
14389        }
14390        schema
14391    }
14392
14393    #[test]
14394    fn test_backlink_through_an_exclusive_multilink_is_single() {
14395        let out = compile_and_emit_with(
14396            "SELECT Post { title, author := .<posts[is Person] { name } }",
14397            &schema_with_exclusive_posts(),
14398        );
14399        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
14400            panic!("expected an object root, got {:?}", out.shape.root)
14401        };
14402        assert!(
14403            matches!(pointers.last(), Some(ShapeNode::Object { name, .. }) if name == "author"),
14404            "got {pointers:?}"
14405        );
14406    }
14407
14408    #[test]
14409    fn test_a_path_through_an_exclusive_backlink_is_single() {
14410        let out = compile_and_emit_with(
14411            "SELECT Post { title, author_name := .<posts[is Person].name }",
14412            &schema_with_exclusive_posts(),
14413        );
14414        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
14415            panic!("expected an object root, got {:?}", out.shape.root)
14416        };
14417        assert!(
14418            matches!(pointers.last(), Some(ShapeNode::Scalar { name, .. }) if name == "author_name"),
14419            "got {pointers:?}"
14420        );
14421        assert!(!out.sql.contains("ARRAY("), "got:\n{}", out.sql);
14422    }
14423
14424    #[test]
14425    fn test_structural_tuple_cast_named_resolves_to_jsonb() {
14426        let out = compile_and_emit("SELECT <tuple<x: float64, y: float64>>$p");
14427        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14428    }
14429
14430    #[test]
14431    fn test_structural_tuple_cast_nested_resolves_to_jsonb() {
14432        let out = compile_and_emit("SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>$p");
14433        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14434    }
14435
14436    #[test]
14437    fn test_nominal_named_tuple_cast_resolves_to_jsonb() {
14438        let mut schema = make_schema();
14439        schema.named_tuples.push(NamedTupleDescriptor {
14440            name: "Point".into(),
14441            module: "default".into(),
14442            members: vec![],
14443        });
14444        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
14445        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14446    }
14447
14448    #[test]
14449    fn test_array_literal_cast_resolves_to_native_pg_array_not_jsonb() {
14450        // The exact query reported as failing — must compile now, and must
14451        // resolve to a real Postgres array (text[]), never jsonb (arrays
14452        // decode natively, unlike tuples).
14453        let out = compile_and_emit("SELECT <array<str>>['foo', 'bar']");
14454        assert!(
14455            out.sql.contains("::text[]") || out.sql.contains("ARRAY["),
14456            "got:\n{}",
14457            out.sql
14458        );
14459        assert!(
14460            !out.sql.contains("jsonb"),
14461            "arrays must not use jsonb, got:\n{}",
14462            out.sql
14463        );
14464    }
14465
14466    #[test]
14467    fn test_array_literal_cast_applies_per_element_cast() {
14468        // Each element gets its own real cast, not a raw untyped ARRAY[...] —
14469        // '1' and '2' must actually coerce to int8 (mirrors the equivalent
14470        // tuple test).
14471        let out = compile_and_emit("SELECT <array<int64>>['1', '2']");
14472        assert!(out.sql.contains("ARRAY[('1')::int8, ('2')::int8]"), "got:\n{}", out.sql);
14473    }
14474
14475    #[test]
14476    fn test_array_param_cast_uses_direct_suffix_cast() {
14477        let out = compile_and_emit("SELECT <array<int64>>$p");
14478        assert!(out.sql.contains("::int8[]"), "got:\n{}", out.sql);
14479    }
14480
14481    #[test]
14482    fn test_array_of_named_tuple_element_casts_to_jsonb_array() {
14483        // An array's element type can be anything except another array —
14484        // including a structural tuple, which still resolves that one
14485        // element to jsonb while the array itself stays a native pg array.
14486        let out = compile_and_emit("SELECT <array<tuple<x: float64, y: float64>>>$p");
14487        assert!(out.sql.contains("::jsonb[]"), "got:\n{}", out.sql);
14488    }
14489
14490    #[test]
14491    fn test_contains_on_array_literal_cast_uses_array_overload_not_strpos() {
14492        // Regression: contains()'s array<Any> overload only matched a bare
14493        // IrExpr::Array node, never a TypeCast-wrapped one — which is what
14494        // every `<array<T>>[...]` cast actually compiles to. Overload
14495        // resolution silently fell back to the *first* registered `contains`
14496        // overload (str, str), emitting a bogus `strpos(text[], text)` call.
14497        let out = compile_and_emit("SELECT contains(<array<str>>[1, 2], '2')");
14498        assert!(out.sql.contains("@> ARRAY["), "got:\n{}", out.sql);
14499        assert!(
14500            !out.sql.contains("strpos"),
14501            "must not fall back to the str/str overload, got:\n{}",
14502            out.sql
14503        );
14504    }
14505
14506    #[test]
14507    fn test_nested_array_type_rejected_at_parse_time() {
14508        match parse::parse("SELECT <array<array<str>>>$p") {
14509            Ok(_) => panic!("expected parse error for nested array type"),
14510            Err(e) => assert!(e.to_string().contains("nested arrays are not supported"), "got: {}", e),
14511        }
14512    }
14513
14514    #[test]
14515    fn test_array_cast_in_computed_shape_field_schema_bound_context() {
14516        // Schema-bound counterpart (compile_expr, not compile_free_expr) — a
14517        // computed shape field casting an array literal.
14518        let out = compile_and_emit("SELECT Person { name, tags := <array<str>>['a', 'b'] }");
14519        assert!(out.sql.contains("ARRAY[('a')::text, ('b')::text]"), "got:\n{}", out.sql);
14520    }
14521
14522    #[test]
14523    fn test_nominal_named_tuple_cast_shape_carries_real_members() {
14524        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
14525        let mut schema = make_schema();
14526        schema.named_tuples.push(NamedTupleDescriptor {
14527            name: "Point".into(),
14528            module: "default".into(),
14529            members: vec![
14530                TupleMemberDescriptor {
14531                    name: Some("x".into()),
14532                    kind: TupleMemberKind::Scalar {
14533                        pg_type: "float8".into(),
14534                    },
14535                },
14536                TupleMemberDescriptor {
14537                    name: Some("y".into()),
14538                    kind: TupleMemberKind::Scalar {
14539                        pg_type: "float8".into(),
14540                    },
14541                },
14542            ],
14543        });
14544        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
14545        match &out.shape.root {
14546            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
14547                assert_eq!(type_name.as_deref(), Some("default::Point"));
14548                let members = members.as_ref().expect("expected resolved members");
14549                assert_eq!(members.len(), 2);
14550                assert_eq!(members[0].key.as_deref(), Some("x"));
14551                assert_eq!(members[1].key.as_deref(), Some("y"));
14552            }
14553            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
14554        }
14555    }
14556
14557    #[test]
14558    fn test_structural_tuple_property_read_shape_carries_real_members() {
14559        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
14560        let schema = SchemaDescriptor {
14561            types: vec![TypeDescriptor {
14562                name: "Person".into(),
14563                module: "default".into(),
14564                table: "Person".into(),
14565                abstract_: false,
14566                materialized: false,
14567                description: None,
14568                parents: vec![],
14569                interfaces: vec![],
14570                bases: vec![],
14571                properties: vec![PropertyDescriptor {
14572                    name: "address".into(),
14573                    pg_type: "jsonb".into(),
14574                    nullable: true,
14575                    default_sql: None,
14576                    default_pyql: None,
14577                    description: None,
14578                    check_constraints: vec![],
14579                    is_exclusive: false,
14580                    is_pk: false,
14581                    is_readonly: false,
14582                    rewrites: vec![],
14583                    tuple_members: Some(vec![
14584                        TupleMemberDescriptor {
14585                            name: Some("street".into()),
14586                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14587                        },
14588                        TupleMemberDescriptor {
14589                            name: Some("zip".into()),
14590                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14591                        },
14592                    ]),
14593                    column_type: None,
14594                }],
14595                links: vec![],
14596                multilinks: vec![],
14597                computed: vec![],
14598                constraints: vec![],
14599                indexes: vec![],
14600                partition: None,
14601                vector_indexes: vec![],
14602                search_indexes: vec![],
14603                triggers: vec![],
14604                junction: false,
14605                signals: vec![],
14606            }],
14607            scalars: vec![],
14608            enums: vec![],
14609            named_tuples: vec![],
14610            globals: vec![],
14611            functions: vec![],
14612            aliases: vec![],
14613            channels: vec![],
14614            ..Default::default()
14615        };
14616        let out = compile_and_emit_with("SELECT Person { address }", &schema);
14617        assert!(out.sql.contains("::jsonb"), "got:\n{}", out.sql);
14618        match &out.shape.root {
14619            crate::query::ShapeNode::Object { pointers, .. } => {
14620                let address = pointers
14621                    .iter()
14622                    .find(|p| matches!(p, crate::query::ShapeNode::NamedTuple { name, .. } if name == "address"))
14623                    .expect("expected address pointer in shape");
14624                match address {
14625                    crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
14626                        assert_eq!(*type_name, None);
14627                        let members = members.as_ref().expect("expected resolved members");
14628                        assert_eq!(members.len(), 2);
14629                        assert_eq!(members[0].key.as_deref(), Some("street"));
14630                        assert_eq!(members[1].key.as_deref(), Some("zip"));
14631                    }
14632                    other => panic!("expected NamedTuple, got {other:?}"),
14633                }
14634            }
14635            other => panic!("expected ShapeNode::Object, got {other:?}"),
14636        }
14637    }
14638
14639    #[test]
14640    fn test_bare_path_select_structural_tuple_property_shape_carries_real_members() {
14641        // A bare `select Type.property` path traversal (no `{ }` shape) used
14642        // to fall back to `ShapeNode::Scalar` for a *structural* tuple
14643        // property — `emit_path_select`'s IrPathResult::Scalar branch only
14644        // detected a *nominal* named tuple (via the "__nt__:" pg_type
14645        // marker), never consulting the property's own `tuple_members`. This
14646        // decoded every row as an opaque blob instead of a proper tuple
14647        // literal (reported as `select default::Person.address` rendering
14648        // wrong in both pylon-ui and the CLI REPL, while a `{ address }`
14649        // shape query — the test above — already worked).
14650        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
14651        let schema = SchemaDescriptor {
14652            types: vec![TypeDescriptor {
14653                name: "Person".into(),
14654                module: "default".into(),
14655                table: "Person".into(),
14656                abstract_: false,
14657                materialized: false,
14658                description: None,
14659                parents: vec![],
14660                interfaces: vec![],
14661                bases: vec![],
14662                properties: vec![PropertyDescriptor {
14663                    name: "address".into(),
14664                    pg_type: "jsonb".into(),
14665                    nullable: true,
14666                    default_sql: None,
14667                    default_pyql: None,
14668                    description: None,
14669                    check_constraints: vec![],
14670                    is_exclusive: false,
14671                    is_pk: false,
14672                    is_readonly: false,
14673                    rewrites: vec![],
14674                    tuple_members: Some(vec![
14675                        TupleMemberDescriptor {
14676                            name: Some("street".into()),
14677                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14678                        },
14679                        TupleMemberDescriptor {
14680                            name: Some("zip".into()),
14681                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14682                        },
14683                    ]),
14684                    column_type: None,
14685                }],
14686                links: vec![],
14687                multilinks: vec![],
14688                computed: vec![],
14689                constraints: vec![],
14690                indexes: vec![],
14691                partition: None,
14692                vector_indexes: vec![],
14693                search_indexes: vec![],
14694                triggers: vec![],
14695                junction: false,
14696                signals: vec![],
14697            }],
14698            scalars: vec![],
14699            enums: vec![],
14700            named_tuples: vec![],
14701            globals: vec![],
14702            functions: vec![],
14703            aliases: vec![],
14704            channels: vec![],
14705            ..Default::default()
14706        };
14707        let out = compile_and_emit_with("SELECT Person.address", &schema);
14708        match &out.shape.root {
14709            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
14710                assert_eq!(*type_name, None);
14711                let members = members.as_ref().expect("expected resolved members");
14712                assert_eq!(members.len(), 2);
14713                assert_eq!(members[0].key.as_deref(), Some("street"));
14714                assert_eq!(members[1].key.as_deref(), Some("zip"));
14715            }
14716            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
14717        }
14718    }
14719
14720    #[test]
14721    fn test_path_traversal_into_structural_tuple_property() {
14722        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
14723        let schema = SchemaDescriptor {
14724            types: vec![TypeDescriptor {
14725                name: "Person".into(),
14726                module: "default".into(),
14727                table: "Person".into(),
14728                abstract_: false,
14729                materialized: false,
14730                description: None,
14731                parents: vec![],
14732                interfaces: vec![],
14733                bases: vec![],
14734                properties: vec![PropertyDescriptor {
14735                    name: "address".into(),
14736                    pg_type: "jsonb".into(),
14737                    nullable: true,
14738                    default_sql: None,
14739                    default_pyql: None,
14740                    description: None,
14741                    check_constraints: vec![],
14742                    is_exclusive: false,
14743                    is_pk: false,
14744                    is_readonly: false,
14745                    rewrites: vec![],
14746                    tuple_members: Some(vec![
14747                        TupleMemberDescriptor {
14748                            name: Some("street".into()),
14749                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14750                        },
14751                        TupleMemberDescriptor {
14752                            name: Some("zip".into()),
14753                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
14754                        },
14755                    ]),
14756                    column_type: None,
14757                }],
14758                links: vec![],
14759                multilinks: vec![],
14760                computed: vec![],
14761                constraints: vec![],
14762                indexes: vec![],
14763                partition: None,
14764                vector_indexes: vec![],
14765                search_indexes: vec![],
14766                triggers: vec![],
14767                junction: false,
14768                signals: vec![],
14769            }],
14770            scalars: vec![],
14771            enums: vec![],
14772            named_tuples: vec![],
14773            globals: vec![],
14774            functions: vec![],
14775            aliases: vec![],
14776            channels: vec![],
14777            ..Default::default()
14778        };
14779        // Regression: a structural (unnamed) tuple property previously failed
14780        // path traversal with "'address' is a scalar property, not a link —
14781        // cannot traverse further" because compile_path_select only allowed
14782        // further traversal for the nominal `__nt__:` named-tuple marker,
14783        // ignoring `tuple_members` on plain jsonb-typed properties.
14784        let out = compile_and_emit_with("SELECT default::Person.address.street", &schema);
14785        assert!(out.sql.contains("\"address\"->'street'"), "got:\n{}", out.sql);
14786    }
14787
14788    #[test]
14789    fn test_structural_tuple_cast_shape_carries_real_members() {
14790        let out = compile_and_emit("SELECT <tuple<street: str, zip: str>>$p");
14791        match &out.shape.root {
14792            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
14793                assert_eq!(*type_name, None);
14794                let members = members.as_ref().expect("expected resolved members");
14795                assert_eq!(members.len(), 2);
14796                assert_eq!(members[0].key.as_deref(), Some("street"));
14797                assert_eq!(members[1].key.as_deref(), Some("zip"));
14798            }
14799            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
14800        }
14801    }
14802
14803    #[test]
14804    fn test_tuple_cast_mixed_named_and_unnamed_elements_rejected() {
14805        match parse::parse("SELECT <tuple<x: float64, bool>>$p") {
14806            Ok(_) => panic!("expected parse error for mixed named/unnamed tuple elements"),
14807            Err(e) => assert!(e.to_string().contains("all named or all unnamed"), "got: {}", e),
14808        }
14809    }
14810
14811    #[test]
14812    fn test_is_with_tuple_type_rejected() {
14813        let schema = make_schema();
14814        let ast = parse::parse("SELECT Person FILTER Person is tuple<x: float64, y: float64>").unwrap();
14815        match ir::compile(&ast, &schema) {
14816            Ok(_) => panic!("expected error for IS with a tuple type"),
14817            Err(e) => {
14818                assert!(
14819                    e.to_string().contains("cannot use IS with a tuple or array type"),
14820                    "got: {}",
14821                    e
14822                );
14823            }
14824        }
14825    }
14826
14827    #[test]
14828    fn test_is_with_array_type_rejected() {
14829        let schema = make_schema();
14830        let ast = parse::parse("SELECT Person FILTER Person is array<str>").unwrap();
14831        match ir::compile(&ast, &schema) {
14832            Ok(_) => panic!("expected error for IS with an array type"),
14833            Err(e) => {
14834                assert!(
14835                    e.to_string().contains("cannot use IS with a tuple or array type"),
14836                    "got: {}",
14837                    e
14838                );
14839            }
14840        }
14841    }
14842
14843    #[test]
14844    fn test_tuple_index_on_non_literal_falls_back_to_jsonb_index() {
14845        // `.1` only constant-folds against a literal tuple; anything else
14846        // (a $param, a cast result, …) needs a genuine runtime jsonb index
14847        // instead of erroring "only supported on tuple literals".
14848        let out = compile_and_emit("SELECT (<tuple<int64, str>>('1', 3)).1");
14849        assert!(out.sql.contains("->1"), "got:\n{}", out.sql);
14850    }
14851
14852    #[test]
14853    fn test_tuple_index_out_of_bounds_on_cast_target_errors_at_compile_time() {
14854        // Regression: `.2` on a 2-element tuple cast silently fell back to a
14855        // runtime jsonb index (returning null) instead of failing, because
14856        // the source is a TypeCast, not a literal Tuple/NamedTuple. The cast's
14857        // target type is statically known here, so this must bounds-check
14858        // and error instead.
14859        let ast = parse::parse("SELECT (<tuple<int64, str>>('1', 3)).2").unwrap();
14860        let schema = make_schema();
14861        match ir::compile(&ast, &schema) {
14862            Ok(_) => panic!("expected out-of-bounds tuple index error"),
14863            Err(e) => {
14864                assert!(
14865                    e.to_string()
14866                        .contains("2 is not a member of tuple<std::int64, std::str>"),
14867                    "got: {}",
14868                    e
14869                );
14870            }
14871        }
14872    }
14873
14874    #[test]
14875    fn test_positional_tuple_literal_cast_to_tuple_type_compiles() {
14876        // Each element must be cast to its own declared type — '1' isn't
14877        // silently jsonb-wrapped unchanged as a string; it's coerced to
14878        // int8.
14879        let out = compile_and_emit("SELECT <tuple<int64, str>>(1, 'x')");
14880        assert!(
14881            out.sql.contains("jsonb_build_array((1)::int8, ('x')::text)"),
14882            "got:\n{}",
14883            out.sql
14884        );
14885    }
14886
14887    #[test]
14888    fn test_positional_tuple_literal_cast_coerces_mismatched_literal_types() {
14889        // The exact case that was silently wrong: casting a string literal to
14890        // int64 and an int literal to str must actually coerce each one, not
14891        // just pass the raw literal through untouched.
14892        let out = compile_and_emit("SELECT <tuple<int64, str>>('1', 3)");
14893        assert!(
14894            out.sql.contains("jsonb_build_array(('1')::int8, (3)::text)"),
14895            "got:\n{}",
14896            out.sql
14897        );
14898    }
14899
14900    #[test]
14901    fn test_nested_tuple_literal_cast_applies_casts_recursively() {
14902        let out = compile_and_emit(
14903            "SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>(point := ('1', 2), label := 5)",
14904        );
14905        assert!(
14906            out.sql.contains(
14907                "jsonb_build_object('point', jsonb_build_object('x', ('1')::float8, 'y', (2)::float8), 'label', (5)::text)"
14908            ),
14909            "got:\n{}",
14910            out.sql
14911        );
14912    }
14913
14914    #[test]
14915    fn test_positional_tuple_literal_nested_inside_named_tuple_compiles() {
14916        let out = compile_and_emit("SELECT (point := (1, 2), label := 'origin')");
14917        assert!(out.sql.contains("jsonb_build_array(1, 2)"), "got:\n{}", out.sql);
14918        assert!(out.sql.contains("jsonb_build_object("), "got:\n{}", out.sql);
14919    }
14920
14921    #[test]
14922    fn test_positional_tuple_literal_in_schema_bound_shape_field_compiles() {
14923        let out = compile_and_emit("SELECT Person { name, pair := (1, 2) }");
14924        assert!(out.sql.contains("jsonb_build_array(1, 2)"), "got:\n{}", out.sql);
14925    }
14926
14927    // ── search index outbox enqueue tests ─────────────────────────────────────
14928    //
14929    // Regression coverage for a real bug: `collect_search_enqueue` used to
14930    // filter for `SearchBackend::OpenSearch` only, so a Meilisearch-backed
14931    // SearchIndex never got an outbox row on insert/update/delete — the
14932    // Meilisearch worker (Rust or the old Python one) never received real
14933    // traffic from normal DML, only from a manually-inserted outbox row.
14934
14935    fn make_schema_with_search_index(backend: crate::schema::SearchBackend) -> SchemaDescriptor {
14936        use crate::schema::{SearchIndexDescriptor, SearchPointerDescriptor, SearchWeight};
14937        let mut s = make_schema();
14938        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
14939            td.search_indexes.push(SearchIndexDescriptor {
14940                index_name: None,
14941                backend,
14942                pointers: vec![SearchPointerDescriptor {
14943                    name: "name".into(),
14944                    weight: SearchWeight::A,
14945                }],
14946            });
14947        }
14948        s
14949    }
14950
14951    #[test]
14952    fn test_insert_enqueues_a_meilisearch_outbox_row() {
14953        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
14954        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
14955        assert!(
14956            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
14957            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
14958            out.sql,
14959        );
14960        assert!(
14961            out.sql.contains("INSERT INTO _pylon.\"IndexOutbox\""),
14962            "got:\n{}",
14963            out.sql
14964        );
14965    }
14966
14967    #[test]
14968    fn test_insert_enqueues_an_opensearch_outbox_row() {
14969        let schema = make_schema_with_search_index(crate::schema::SearchBackend::OpenSearch);
14970        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
14971        assert!(
14972            out.sql.contains("'OpenSearch'::_pylon.\"IndexKind\""),
14973            "expected an OpenSearch outbox enqueue CTE, got:\n{}",
14974            out.sql,
14975        );
14976    }
14977
14978    #[test]
14979    fn test_insert_does_not_enqueue_an_outbox_row_for_a_postgres_backed_search_index() {
14980        // Postgres-backed search indexes are maintained synchronously by a
14981        // trigger-updated tsvector column — no async worker involved.
14982        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Postgres);
14983        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
14984        assert!(
14985            !out.sql.contains("_pylon.\"IndexOutbox\""),
14986            "did not expect an outbox enqueue, got:\n{}",
14987            out.sql
14988        );
14989    }
14990
14991    #[test]
14992    fn test_update_enqueues_a_meilisearch_outbox_row() {
14993        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
14994        let out = compile_and_emit_with("UPDATE Person FILTER .name = 'Alice' SET { age := 31 }", &schema);
14995        assert!(
14996            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
14997            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
14998            out.sql,
14999        );
15000    }
15001
15002    #[test]
15003    fn test_delete_enqueues_a_meilisearch_outbox_delete_job() {
15004        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
15005        let out = compile_and_emit_with("DELETE Person FILTER .name = 'Alice'", &schema);
15006        assert!(
15007            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
15008            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
15009            out.sql,
15010        );
15011        assert!(
15012            out.sql.contains("'delete'"),
15013            "expected the delete operation literal, got:\n{}",
15014            out.sql
15015        );
15016    }
15017
15018    #[test]
15019    fn test_range_intrinsic_resolves_int_literals_to_int8range() {
15020        // Regression: std::range's ImplStrategy::TranspilerIntrinsic had no
15021        // actual substitution anywhere — it fell through resolve_fn_call's
15022        // catch-all, which emitted a literal (nonexistent) `"std"."range"(...)`
15023        // call instead of a real PostgreSQL range constructor.
15024        let out = compile_and_emit("SELECT std::overlaps(std::range(1, 3), std::range(2, 5))");
15025        assert!(out.sql.contains("int8range(1, 3)"), "got:\n{}", out.sql);
15026        assert!(out.sql.contains("int8range(2, 5)"), "got:\n{}", out.sql);
15027        assert!(
15028            out.sql.contains(" && "),
15029            "expected infix && for overlaps, got:\n{}",
15030            out.sql
15031        );
15032        assert!(
15033            !out.sql.contains("\"std\""),
15034            "must not emit a literal std schema call, got:\n{}",
15035            out.sql
15036        );
15037    }
15038
15039    #[test]
15040    fn test_range_intrinsic_resolves_datetime_to_tstzrange() {
15041        let out =
15042            compile_and_emit("SELECT std::range(<datetime>'2024-01-01T00:00:00Z', <datetime>'2024-06-01T00:00:00Z')");
15043        assert!(out.sql.contains("tstzrange("), "got:\n{}", out.sql);
15044    }
15045
15046    #[test]
15047    fn test_range_intrinsic_four_arg_form_computes_bounds_string() {
15048        let out = compile_and_emit("SELECT std::range(1, 3, true, false)");
15049        assert!(out.sql.contains("int8range(1, 3,"), "got:\n{}", out.sql);
15050        assert!(
15051            out.sql.contains("CASE WHEN"),
15052            "expected a dynamic bounds-string CASE, got:\n{}",
15053            out.sql
15054        );
15055    }
15056
15057    #[test]
15058    fn test_multirange_intrinsic_resolves_from_range_element() {
15059        let out = compile_and_emit("SELECT std::multirange([std::range(1, 3), std::range(5, 7)])");
15060        assert!(out.sql.contains("int8multirange(VARIADIC "), "got:\n{}", out.sql);
15061        assert!(
15062            !out.sql.contains("\"std\""),
15063            "must not emit a literal std schema call, got:\n{}",
15064            out.sql
15065        );
15066    }
15067}