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(&binding_outer_key(cte_name)),
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(IrPathJoin::target)
450        .find(|target| target.alias == alias)
451}
452
453/// The type discriminator for a row from `src`: the fanned-out column when the
454/// source reads from implementors, else the one type its table holds.
455fn source_type_disc(src: &IrSource) -> String {
456    match source_fanout(src) {
457        Some(_) => format!("{}.\"__type__\"", qi(&src.alias)),
458        None => type_disc(&src.type_name),
459    }
460}
461
462// ── Polymorphic UNION ALL ───────────────────────────────────────────────────
463
464fn emit_poly_union(implementors: &[IrPolyImplementor], columns: &[String]) -> String {
465    let col_list = columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
466    implementors
467        .iter()
468        .map(|imp| {
469            format!(
470                "    SELECT {}::text AS \"__type__\", {} FROM {}",
471                sql_str(&imp.type_name),
472                col_list,
473                qn(&imp.module, &imp.table),
474            )
475        })
476        .collect::<Vec<_>>()
477        .join("\n    UNION ALL\n")
478}
479
480// ── SELECT statement ────────────────────────────────────────────────────────
481
482fn emit_select_stmt(sel: &IrSelect, ctes: &[IrCteDef]) -> SqlOutput {
483    match sel.rows.as_slice() {
484        [IrRowSource::Bound { source, shape }] => emit_bound_select(sel, source, shape),
485        rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
486            emit_bound_union_select(sel, rows)
487        }
488        rows => emit_free_rows(sel, rows, ctes),
489    }
490}
491
492/// The branches of `select (a union b)` all carry the same object type, so
493/// they share a column set: union them in the FROM clause and project, filter
494/// and order the combined set once, under the shape's single alias.
495/// `common_columns`, when set, are the columns of the ancestor branches of
496/// different types share: each branch projects those and its rows' concrete
497/// type, rather than columns the others do not have.
498fn bound_union_from_clause(rows: &[IrRowSource], common_columns: &[String]) -> String {
499    let columns = common_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
500    rows.iter()
501        .map(|row| match row {
502            IrRowSource::Bound { source, .. } if !common_columns.is_empty() => {
503                let own_type = match source_fanout(source) {
504                    Some(_) => "\"__type__\"".to_string(),
505                    None => format!("{} AS \"__type__\"", type_disc(&source.type_name)),
506                };
507                format!("    SELECT {own_type}, {columns} FROM {}", source_ref(source))
508            }
509            IrRowSource::Bound { source, .. } => format!("    SELECT * FROM {}", source_ref(source)),
510            IrRowSource::Free(_) => unreachable!("caller checked every row is bound"),
511        })
512        .collect::<Vec<_>>()
513        .join("\n    UNION ALL\n")
514}
515
516fn emit_bound_union_select(sel: &IrSelect, rows: &[IrRowSource]) -> SqlOutput {
517    let IrRowSource::Bound { source, shape } = &rows[0] else {
518        unreachable!("caller checked every row is bound")
519    };
520    let alias = &source.alias;
521    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
522    let row_type = if sel.poly_columns.is_empty() {
523        type_disc(&source.type_name)
524    } else {
525        format!("{}.\"__type__\"", qi(alias))
526    };
527    let mut parts = vec![row_type];
528    parts.extend(pointer_exprs);
529
530    let mut sql = format!(
531        "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
532        if sel.distinct { "DISTINCT " } else { "" },
533        parts.join(",\n    "),
534        bound_union_from_clause(rows, &sel.poly_columns),
535        qi(alias),
536    );
537    append_filter(&mut sql, &sel.filter);
538    append_order_by(&mut sql, &sel.order_by);
539    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
540
541    SqlOutput {
542        sql,
543        shape: ShapeDescriptor {
544            root: ShapeNode::Object {
545                name: String::new(),
546                type_name: Some(source.type_name.clone()),
547                position: 0,
548                cardinality: Cardinality::Many,
549                pointers: prepend_type(shape_pointers),
550                has_implicit_id: shape_has_implicit_id(shape),
551            },
552        },
553        inference_plan: None,
554    }
555}
556
557/// Schema-bound SELECT — has a FROM clause, projected column shape, and
558/// (optionally) a DML source / polymorphic fan-out. `source`/`shape` are the
559/// single `IrRowSource::Bound` entry destructured by `emit_select_stmt`;
560/// everything else (filter/order_by/offset/limit/distinct/dml_source/
561/// polymorphic/poly_*) stays on the outer `IrSelect`.
562fn emit_bound_select(sel: &IrSelect, source: &IrSource, shape: &[IrShapePointer]) -> SqlOutput {
563    let alias = &source.alias;
564    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
565
566    let type_expr = if sel.polymorphic {
567        format!("{}.\"__type__\"", qi(alias))
568    } else {
569        type_disc(&source.type_name)
570    };
571    let mut parts = vec![type_expr];
572    parts.extend(pointer_exprs);
573    let tuple = parts.join(",\n    ");
574
575    let distinct = if sel.distinct { "DISTINCT " } else { "" };
576
577    // SELECT-over-DML: wrap inner statement in a CTE, select from it.
578    let from_clause = if let Some(dml) = &sel.dml_source {
579        let mut cte_parts = match dml.as_ref() {
580            IrStmt::Update(upd) if update_has_any_multilink(upd) => emit_update_multilink_ctes(upd, DML_CTE),
581            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => emit_insert_multilink_ctes(ins, DML_CTE),
582            // Nested DML the row's values read from: hoisted ahead of the row
583            // itself, since a data-modifying WITH only works at the top level.
584            IrStmt::Insert(ins) if !ins.nested_ctes.is_empty() => {
585                let mut parts = emit_user_cte_parts(&ins.nested_ctes);
586                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
587                parts
588            }
589            IrStmt::Update(upd) if !upd.nested_ctes.is_empty() => {
590                let mut parts = emit_user_cte_parts(&upd.nested_ctes);
591                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
592                parts
593            }
594            IrStmt::Update(upd) if !upd.poly_implementors.is_empty() => emit_poly_update_dml_ctes(upd, "_dml"),
595            IrStmt::Delete(del) if !del.poly_implementors.is_empty() => emit_poly_delete_dml_ctes(del, "_dml"),
596            _ => vec![format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml))],
597        };
598        let (enqueue_v, enqueue_s) = match dml.as_ref() {
599            IrStmt::Insert(ins) => (ins.enqueue_vector.as_slice(), ins.enqueue_search.as_slice()),
600            IrStmt::Update(upd) => (upd.enqueue_vector.as_slice(), upd.enqueue_search.as_slice()),
601            _ => (&[][..], &[][..]),
602        };
603        cte_parts.extend(enqueue_ctes(enqueue_v, "_dml"));
604        cte_parts.extend(enqueue_search_ctes(enqueue_s, "_dml", enqueue_v.len()));
605        format!(
606            "WITH\n{}\nSELECT {}(\n    {}\n) AS result\nFROM \"_dml\" AS {}",
607            cte_parts.join(",\n"),
608            distinct,
609            tuple,
610            qi(alias)
611        )
612    } else if sel.polymorphic && !source.table.starts_with("@cte:") {
613        // Polymorphic interface with no CTE indirection: fan out to implementor tables.
614        let union_sql = emit_poly_union(&sel.poly_implementors, &sel.poly_columns);
615        format!(
616            "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
617            distinct,
618            tuple,
619            union_sql,
620            qi(alias)
621        )
622    } else {
623        // Concrete table or CTE (pre-filtered): query directly.
624        // For CTE-backed polymorphic sources, __type__ is already present in the CTE result.
625        format!(
626            "SELECT {}(\n    {}\n) AS result\nFROM {} AS {}",
627            distinct,
628            tuple,
629            source_ref(source),
630            qi(alias)
631        )
632    };
633
634    let mut sql = from_clause;
635    append_filter(&mut sql, &sel.filter);
636    append_order_by(&mut sql, &sel.order_by);
637    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
638    append_lock_clause(&mut sql, &sel.lock);
639
640    let root_pointers = prepend_type(shape_pointers);
641    SqlOutput {
642        sql,
643        shape: ShapeDescriptor {
644            root: ShapeNode::Object {
645                name: String::new(),
646                type_name: Some(source.type_name.clone()),
647                position: 0,
648                cardinality: Cardinality::Many,
649                pointers: root_pointers,
650                has_implicit_id: shape_has_implicit_id(shape),
651            },
652        },
653        inference_plan: None,
654    }
655}
656
657/// Emit a DML statement for use as a CTE source, using `RETURNING *` to expose
658/// all columns to the outer SELECT.  The DML's own returning shape is ignored.
659/// `FROM` over the nested-DML CTEs `reading` takes a column of, prefixed by
660/// `lead`; empty when it takes none. One read through a scalar subquery
661/// (`(SELECT "id" FROM cte)`) needs no join, and joining it anyway drops every
662/// row the moment that CTE is empty -- which a guarded branch is.
663fn nested_cte_from(ctes: &[IrCteDef], reading: &str, lead: &str) -> String {
664    let joined: Vec<String> = ctes
665        .iter()
666        .map(|c| qi(&c.name))
667        .filter(|name| reading.contains(&format!("{name}.")))
668        .collect();
669    if joined.is_empty() {
670        return String::new();
671    }
672    format!("{lead}FROM {}", joined.join(", "))
673}
674
675/// Each operand's rows read through the bare `v` every scalar statement
676/// exposes as a CTE source, so operands of different kinds line up.
677fn emit_scalar_union(branches: &[IrStmt]) -> String {
678    let operands: Vec<String> = branches
679        .iter()
680        .enumerate()
681        .map(|(i, branch)| {
682            format!(
683                "SELECT \"v\" FROM (\n{}\n) AS \"_u{i}\"",
684                emit_dml_as_cte_source(branch)
685            )
686        })
687        .collect();
688    format!(
689        "SELECT ROW(v) AS result, v FROM (\n{}\n) AS _scalar",
690        operands.join("\nUNION ALL\n")
691    )
692}
693
694fn emit_dml_as_cte_source(stmt: &IrStmt) -> String {
695    match stmt {
696        IrStmt::ScalarUnion(branches) => format!("    {}", emit_scalar_union(branches)),
697        IrStmt::Insert(ins) => {
698            let rewrite_cols: std::collections::HashSet<&str> =
699                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
700            let cols: Vec<String> = ins
701                .assignments
702                .iter()
703                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
704                .map(|(c, _)| format!("    {}", qi(c)))
705                .chain(ins.rewrites.iter().map(|r| format!("    {}", qi(&r.column))))
706                .collect();
707            let vals: Vec<String> = ins
708                .assignments
709                .iter()
710                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
711                .map(|(_, e)| format!("    {}", emit_expr(e)))
712                .chain(ins.rewrites.iter().map(|r| format!("    {}", emit_expr(&r.expr))))
713                .collect();
714            let mut sql = if !ins.nested_ctes.is_empty() || ins.guard.is_some() {
715                // The row reads columns out of hoisted nested-DML CTEs, so it
716                // selects from them rather than listing literal values. Those
717                // CTEs are emitted by the caller: Postgres only allows a
718                // data-modifying WITH at the top level, never inside a CTE.
719                // A guarded insert likewise has to be a `SELECT … WHERE`, so
720                // that it writes nothing when the condition is false.
721                format!("    {}", emit_insert_row_sql(ins))
722            } else if cols.is_empty() {
723                // See `emit_insert_row_sql`: a row of nothing but defaults.
724                format!("    INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
725            } else {
726                format!(
727                    "    INSERT INTO {} (\n{}\n    ) VALUES (\n{}\n    )",
728                    target_ref(&ins.target),
729                    cols.join(",\n"),
730                    vals.join(",\n"),
731                )
732            };
733            if let Some(conflict) = &ins.unless_conflict {
734                emit_conflict(&mut sql, conflict);
735            }
736            sql.push_str("\n    RETURNING *");
737            sql
738        }
739        IrStmt::Update(upd) => {
740            // Callers must route an update with any multi-link (junction
741            // table) mutation through emit_update_multilink_ctes instead —
742            // junction INSERT/DELETE CTEs are data-modifying and Postgres
743            // requires those to sit at the *top level* of the query, so they
744            // can't be nested inside this function's single-CTE-body return
745            // value. See emit_cte_prefix and emit_select_stmt's dml_source
746            // branch, both of which check has_any_multilink before calling
747            // this function at all.
748            let alias = &upd.target.alias;
749            let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "    ");
750            let mut sql = format!(
751                "    UPDATE {} AS {}\n    SET\n{}",
752                target_ref(&upd.target),
753                qi(alias),
754                sets.join(",\n"),
755            );
756            // The nested CTEs are emitted alongside this body (see
757            // `emit_user_cte_parts`), so the reference to them needs a FROM.
758            sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
759            append_filter(&mut sql, &upd.filter);
760            // Qualified: past a FROM, a bare `*` carries the joined relation's
761            // columns too, and a reader of this CTE then finds two `id`s.
762            sql.push_str(&format!("\n    RETURNING {}.*", qi(alias)));
763            sql
764        }
765        IrStmt::Delete(del) => {
766            let alias = &del.target.alias;
767            let mut sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
768            append_filter(&mut sql, &del.filter);
769            sql.push_str("\n    RETURNING *");
770            sql
771        }
772        IrStmt::Select(inner) => match inner.rows.as_slice() {
773            // `x := (select (select T filter …) filter …)` — the inner select is
774            // what this one reads, the same `_dml` the top-level emitter reads
775            // it from. Read from the table instead, it returned every row of
776            // the type, with neither filter applied.
777            [IrRowSource::Bound { source, .. }]
778                if let Some(read) = inner.dml_source.as_deref()
779                    && !matches!(read, IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::Delete(_)) =>
780            {
781                // A subquery, not a `WITH`: this may already sit inside one.
782                let mut sql = format!(
783                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
784                    if inner.distinct { "DISTINCT " } else { "" },
785                    emit_dml_as_cte_source(read),
786                    qi(&source.alias),
787                );
788                append_filter(&mut sql, &inner.filter);
789                append_order_by(&mut sql, &inner.order_by);
790                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
791                sql
792            }
793            [IrRowSource::Bound { source, .. }] => {
794                // SELECT-over-SELECT: expose raw columns so the outer SELECT can
795                // project its own shape from them, mirroring DML's RETURNING *.
796                let from = if inner.polymorphic && !source.table.starts_with("@cte:") {
797                    format!(
798                        "(\n{}\n    ) AS {}",
799                        emit_poly_union(&inner.poly_implementors, &inner.poly_columns),
800                        qi(&source.alias),
801                    )
802                } else {
803                    format!("{} AS {}", source_ref(source), qi(&source.alias))
804                };
805                let mut sql = format!(
806                    "    SELECT {}* FROM {}",
807                    if inner.distinct { "DISTINCT " } else { "" },
808                    from
809                );
810                append_filter(&mut sql, &inner.filter);
811                append_order_by(&mut sql, &inner.order_by);
812                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
813                sql
814            }
815            // Object union: same FROM-clause union as `emit_bound_union_select`,
816            // but exposing raw columns like every other arm here.
817            rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
818                let IrRowSource::Bound { source, .. } = &rows[0] else {
819                    unreachable!("checked by the guard")
820                };
821                let mut sql = format!(
822                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
823                    if inner.distinct { "DISTINCT " } else { "" },
824                    bound_union_from_clause(rows, &inner.poly_columns),
825                    qi(&source.alias),
826                );
827                append_filter(&mut sql, &inner.filter);
828                append_order_by(&mut sql, &inner.order_by);
829                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
830                sql
831            }
832            // Free rows: full UNION-ALL emission, same as before the merge
833            // (this arm used to be a separate IrStmt::FreeSelect match). Only
834            // `.sql` is used here, and CtePassthrough's SQL text doesn't
835            // depend on the ctes list (only its *shape* resolution does), so
836            // an empty slice is fine.
837            _ => emit_select_stmt(inner, &[]).sql,
838        },
839        IrStmt::FunctionSelect(sel) => {
840            // Expose raw columns so the outer SELECT can project its own shape,
841            // mirroring how IrStmt::Select works as a CTE source.
842            let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
843            let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
844            let mut sql = format!("    SELECT * FROM {} AS {}", fn_call, qi(&sel.alias));
845            append_filter(&mut sql, &sel.filter);
846            append_order_by(&mut sql, &sel.order_by);
847            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
848            sql
849        }
850        IrStmt::For(f) => {
851            // `for x in S union (...)` as a set source: iterate in the FROM
852            // clause and expose the body's raw columns, the same contract the
853            // other arms honour. An empty literal iterator yields no rows, but
854            // still has to name a column for the outer SELECT to project.
855            if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
856                return "    SELECT NULL AS \"id\" WHERE FALSE".to_string();
857            }
858            let (values_from, _) = emit_for_iterator(&f.iterator, &format!("_for_{}", f.var_name));
859            let body =
860                prefix_body_ctes(emit_dml_as_cte_source(&f.body), &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
861            format!(
862                "    SELECT \"_body\".*\n    FROM {}\n    CROSS JOIN LATERAL (\n    {}\n    ) AS \"_body\"",
863                values_from.replace('\n', "\n    "),
864                body,
865            )
866        }
867        IrStmt::Group(grp) if matches!(grp.output, IrGroupOutput::Elements) => emit_group_elements_source(grp),
868        IrStmt::Group(_) | IrStmt::VectorSearch(_) | IrStmt::FtsSearch(_) => {
869            unreachable!("cannot appear as a CTE source")
870        }
871        IrStmt::PathSelect(ps) => {
872            // A path traversal as a CTE body. What it projects depends on
873            // where the path ends: a scalar becomes a `result` column (which
874            // is what `IrExpr::CteRef { scalar: true }` reads back), an
875            // object exposes its raw columns so the outer query can both
876            // traverse it (`@cte:` source) and read its `id`.
877            let distinct = if ps.distinct { "DISTINCT " } else { "" };
878            let projection = match &ps.result {
879                // Same two-column convention a free scalar binding uses:
880                // `result` is the ROW() composite top-level decoding wants,
881                // `v` the bare value `IrExpr::CteRef { scalar: true }` reads.
882                IrPathResult::Scalar(e, _) => format!("{}{} AS v", distinct, emit_expr(e)),
883                IrPathResult::Object { alias, .. } => format!("{}{}.*", distinct, qi(alias)),
884            };
885            let mut sql = format!(
886                "SELECT {}\n    FROM {}",
887                projection,
888                emit_path_joins(&ps.root, &ps.joins)
889            );
890            append_filter(&mut sql, &ps.filter);
891            append_order_by(&mut sql, &ps.order_by);
892            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
893            match &ps.result {
894                IrPathResult::Scalar(e, _) => {
895                    let row_value = if enum_type_of_expr(e).is_some() { "v::text" } else { "v" };
896                    format!("    SELECT ROW({row_value}) AS result, v FROM ({sql}) AS _scalar")
897                }
898                IrPathResult::Object { .. } => format!("    {sql}"),
899            }
900        }
901    }
902}
903
904// ── Polymorphic DML-as-CTE fan-out ──────────────────────────────────────────
905//
906// A DML statement targeting an interface/abstract type can't be a single
907// `UPDATE`/`DELETE ... RETURNING *` the way emit_dml_as_cte_source handles a
908// concrete-type target — Postgres has no single physical relation backing
909// the interface (each concrete implementor has its own table), and a bare
910// `RETURNING *` off whichever one table `source_ref` happened to resolve
911// would only ever see that one implementor's rows and never carry a
912// `__type__` discriminator column at all (confirmed live: querying such a
913// CTE from an outer polymorphic SELECT raised "column t1.__type__ does not
914// exist", since the outer SELECT — see emit_select_stmt and the "CTE-backed
915// polymorphic sources" comment there — assumes any CTE tied to a polymorphic
916// type already exposes one).
917//
918// Mirrors emit_poly_update_stmt/emit_poly_delete_stmt's per-implementor
919// UNION ALL fan-out (one CTE per concrete table, each RETURNING-ing rows,
920// unioned together with a compile-time-known type literal per branch — the
921// type is known per branch even though it can vary per row across the whole
922// statement, since each branch only ever touches one implementor's table).
923// The difference here: those two functions only need `id` back (a
924// standalone DML's own default RETURNING shape), while a DML-as-CTE source
925// must expose whatever columns the *outer* SELECT might project — so this
926// returns the interface's full common column set (poly_columns: every
927// property + link FK the type declares, guaranteed present on every
928// implementor table) instead of just id.
929//
930// Not handled: a multi-link mutation (+=/-=/:=) on the interface's own
931// pointer combined with poly_implementors on the same statement — that
932// would need per-implementor junction-table CTEs too, which
933// emit_update_multilink_ctes doesn't do either today. Callers check
934// update_has_any_multilink first and keep routing that (rarer) combination
935// through the existing (equally not-poly-aware) path rather than silently
936// mishandling it here.
937
938fn emit_poly_update_dml_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
939    let alias = &upd.target.alias;
940    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
941    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
942
943    let mut cte_parts = vec![];
944    let mut union_parts = vec![];
945    for (i, imp) in upd.poly_implementors.iter().enumerate() {
946        let cte_name = format!("{}__u{}", name, i);
947        let mut upd_sql = format!(
948            "UPDATE {} AS {}\nSET {}",
949            qn(&imp.module, &imp.table),
950            qi(alias),
951            sets.join(", "),
952        );
953        append_filter(&mut upd_sql, &upd.filter);
954        upd_sql.push_str(&format!("\nRETURNING {}", col_list));
955        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
956
957        union_parts.push(format!(
958            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
959            sql_str(&imp.type_name),
960            col_list,
961            cte_name,
962        ));
963    }
964    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
965    cte_parts
966}
967
968/// One implementor's own copy of a polymorphic update: it writes to that
969/// implementor's table, and to that implementor's own junction tables — an
970/// interface's junction is a view over them, which Postgres refuses to
971/// insert into.
972fn concrete_poly_update(upd: &IrUpdate, imp: &IrPolyImplementor) -> IrUpdate {
973    let interface_prefix = format!("{}.", upd.target.table);
974    let own_prefix = format!("{}.", imp.table);
975    let own_junction = |table: &mut String, module: &mut String| {
976        if let Some(link) = table.strip_prefix(&interface_prefix) {
977            *table = format!("{own_prefix}{link}");
978            module.clone_from(&imp.module);
979        }
980    };
981    let mut concrete = upd.clone();
982    concrete.poly_implementors = vec![];
983    concrete.target.table = imp.table.clone();
984    concrete.target.type_name = imp.type_name.clone();
985    concrete.target.poly = None;
986    concrete
987        .multi_link_clears
988        .iter_mut()
989        .for_each(|c| own_junction(&mut c.junction_table, &mut c.module));
990    concrete
991        .multi_link_replaces
992        .iter_mut()
993        .chain(concrete.multi_link_appends.iter_mut())
994        .chain(concrete.multi_link_removals.iter_mut())
995        .for_each(|m| own_junction(&mut m.junction_table, &mut m.module));
996    concrete
997}
998
999/// `emit_poly_update_dml_ctes` for an update that also mutates a multi-link:
1000/// each implementor gets the whole sibling set `emit_update_multilink_ctes`
1001/// builds, against its own tables.
1002fn emit_poly_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1003    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1004    let mut cte_parts = emit_user_cte_parts(&upd.nested_ctes);
1005    let mut union_parts = vec![];
1006    for (i, imp) in upd.poly_implementors.iter().enumerate() {
1007        let cte_name = format!("{}__u{}", name, i);
1008        cte_parts.extend(emit_update_multilink_cte_parts(
1009            &concrete_poly_update(upd, imp),
1010            &cte_name,
1011            false,
1012        ));
1013        union_parts.push(format!(
1014            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1015            sql_str(&imp.type_name),
1016            col_list,
1017            cte_name,
1018        ));
1019    }
1020    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1021    cte_parts
1022}
1023
1024fn emit_poly_delete_dml_ctes(del: &IrDelete, name: &str) -> Vec<String> {
1025    let alias = &del.target.alias;
1026    let col_list = del.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1027
1028    let mut cte_parts = vec![];
1029    let mut union_parts = vec![];
1030    for (i, imp) in del.poly_implementors.iter().enumerate() {
1031        let cte_name = format!("{}__d{}", name, i);
1032        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
1033        append_filter(&mut del_sql, &del.filter);
1034        del_sql.push_str(&format!("\nRETURNING {}", col_list));
1035        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
1036
1037        union_parts.push(format!(
1038            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1039            sql_str(&imp.type_name),
1040            col_list,
1041            cte_name,
1042        ));
1043    }
1044    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1045    cte_parts
1046}
1047
1048// ── User CTE helpers ────────────────────────────────────────────────────────
1049
1050fn update_has_any_multilink(upd: &IrUpdate) -> bool {
1051    !upd.multi_link_clears.is_empty()
1052        || !upd.multi_link_replaces.is_empty()
1053        || !upd.multi_link_appends.is_empty()
1054        || !upd.multi_link_removals.is_empty()
1055}
1056
1057/// Extra `AND target NOT IN (...)` clause for a junction clear paired with
1058/// a replace at the same index (`friends := expr` — see `IrUpdate.
1059/// multi_link_clears`'s own doc comment on the shared-index pairing).
1060///
1061/// Without this, a target present in *both* the old and new sets gets
1062/// deleted here and then silently fails to come back: the paired INSERT
1063/// (`emit_ml_append_cte`) runs its `ON CONFLICT` check against the same
1064/// statement-wide snapshot every CTE in one `WITH` shares, which still
1065/// shows the row this DELETE is *about to* remove as present — Postgres
1066/// treats that as a real conflict and skips the insert (`ON CONFLICT DO
1067/// NOTHING`) or updates the row this DELETE is also deleting (`DO UPDATE`,
1068/// the link-properties case), not "insert the row back." Confirmed live:
1069/// reassigning a junction-backed single link to its own already-set value,
1070/// or replacing a multi-link with a set overlapping its current one,
1071/// silently dropped every overlapping member. Excluding still-wanted
1072/// targets from the DELETE instead means they're never touched at all, so
1073/// there's nothing for the INSERT to conflict with.
1074fn ml_clear_exclusion(rep: Option<&IrMultiLinkMutation>) -> String {
1075    match rep {
1076        Some(rep) => {
1077            let vals_ref = emit_multilink_values_subquery(&rep.values, &[]);
1078            format!(
1079                " AND {} NOT IN (SELECT \"_v\".\"id\" FROM {} AS \"_v\")",
1080                qi(&rep.target_col),
1081                vals_ref
1082            )
1083        }
1084        None => String::new(),
1085    }
1086}
1087
1088/// Builds the CTE chain for an UPDATE with multi-link (junction table)
1089/// mutations, bound to a single external `name` (a user WITH-binding, or the
1090/// implicit "_dml" wrapper for `SELECT (UPDATE ...)`). Junction INSERT/DELETE
1091/// statements are themselves data-modifying, and Postgres requires
1092/// data-modifying CTEs to sit at the *top level* of the query — they can't
1093/// be nested inside another CTE's own body (confirmed live: nesting them
1094/// raises "WITH clause containing a data-modifying statement must be at the
1095/// top level"). So this returns a flat list of sibling CTE parts, prefixed
1096/// by `name` to stay collision-free alongside any other bound statements in
1097/// the same WITH block, ending in a `"{name}" AS (SELECT * FROM
1098/// "{name}__ids")` passthrough — every other reference to `name` keeps
1099/// seeing the updated row's full columns exactly as `RETURNING *` would have
1100/// exposed them.
1101fn emit_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1102    emit_update_multilink_cte_parts(upd, name, true)
1103}
1104
1105/// `emit_nested` is false for a caller that has already emitted
1106/// `upd.nested_ctes` beside this one: a polymorphic update emits one branch
1107/// per implementor off the same `IrUpdate`, and a `WITH` may name each
1108/// hoisted statement only once.
1109fn emit_update_multilink_cte_parts(upd: &IrUpdate, name: &str, emit_nested: bool) -> Vec<String> {
1110    let alias = &upd.target.alias;
1111    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
1112    let ids_name = format!("{}__ids", name);
1113    // The nested statements its values read from, ahead of it.
1114    let mut parts: Vec<String> = if emit_nested {
1115        emit_user_cte_parts(&upd.nested_ctes)
1116    } else {
1117        vec![]
1118    };
1119
1120    if has_scalar_changes {
1121        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1122        let mut upd_sql = format!(
1123            "UPDATE {} AS {}\nSET {}",
1124            target_ref(&upd.target),
1125            qi(alias),
1126            sets.join(", "),
1127        );
1128        // A nested statement's CTE sits beside this one, so reading its id
1129        // needs a FROM — the same reason the plain update path has one.
1130        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
1131        append_filter(&mut upd_sql, &upd.filter);
1132        // See `emit_dml_as_cte_source`: past a FROM, a bare `*` would carry the
1133        // joined relation's columns into this CTE beside the target's.
1134        upd_sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1135        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, upd_sql));
1136    } else {
1137        let mut sel = format!(
1138            "SELECT {}.* FROM {} AS {}",
1139            qi(alias),
1140            target_ref(&upd.target),
1141            qi(alias),
1142        );
1143        append_filter(&mut sel, &upd.filter);
1144        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sel));
1145    }
1146
1147    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
1148        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
1149        let del = format!(
1150            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"{}\"){}",
1151            qn(&clr.module, &clr.junction_table),
1152            qi(&clr.source_col),
1153            ids_name,
1154            exclude,
1155        );
1156        parts.push(format!("\"{}__clr_{}\" AS (\n{}\n)", name, i, del));
1157    }
1158    for (i, app) in upd.multi_link_appends.iter().enumerate() {
1159        parts.push(emit_ml_append_cte(
1160            app,
1161            &ids_name,
1162            &upd.target.alias,
1163            &format!("{}__ml_add_{}", name, i),
1164        ));
1165    }
1166    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
1167        parts.push(emit_ml_remove_cte(
1168            rem,
1169            &ids_name,
1170            &upd.target.alias,
1171            &format!("{}__ml_rm_{}", name, i),
1172        ));
1173    }
1174    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
1175        parts.push(emit_ml_append_cte(
1176            rep,
1177            &ids_name,
1178            &upd.target.alias,
1179            &format!("{}__ml_rep_{}", name, i),
1180        ));
1181    }
1182
1183    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1184    parts
1185}
1186
1187fn insert_has_any_multilink(ins: &IrInsert) -> bool {
1188    !ins.multi_link_appends.is_empty()
1189}
1190
1191/// Builds the CTE chain for an INSERT with multi-link (junction table)
1192/// assignments at creation time — same top-level-CTE constraint as
1193/// `emit_update_multilink_ctes` (junction INSERTs are themselves
1194/// data-modifying). The row itself is inserted first (`{name}__ids`, via
1195/// `RETURNING *`) so the junction CTEs can reference its freshly-generated id.
1196fn emit_insert_multilink_ctes(ins: &IrInsert, name: &str) -> Vec<String> {
1197    let ids_name = format!("{}__ids", name);
1198    let mut parts: Vec<String> = emit_user_cte_parts(&ins.nested_ctes);
1199
1200    let mut insert_sql = emit_insert_row_sql(ins);
1201    if let Some(conflict) = &ins.unless_conflict {
1202        emit_conflict(&mut insert_sql, conflict);
1203    }
1204    insert_sql.push_str("\nRETURNING *");
1205    parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, insert_sql));
1206
1207    for (i, app) in ins.multi_link_appends.iter().enumerate() {
1208        parts.push(emit_ml_append_cte(
1209            app,
1210            &ids_name,
1211            &ins.target.alias,
1212            &format!("{}__ml_add_{}", name, i),
1213        ));
1214    }
1215
1216    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1217    parts
1218}
1219
1220/// A `for` whose body mutates, bound to a WITH name: the same iteration-driven
1221/// shapes `emit_for_insert`/`emit_for_update` build at the top level, but split
1222/// into sibling CTE parts. A data-modifying statement can neither sit inside
1223/// another CTE's body nor in a LATERAL, so the loop cannot be emitted as the
1224/// binding's own subquery the way a select-bodied one is.
1225/// A `for` whose body mutates, bound to a WITH name. `outer_alias` names an
1226/// enclosing loop's iteration when this loop reads it, which makes every row
1227/// this one writes belong to one iteration of it rather than to all of them. `outer_alias` names
1228/// that loop's iteration when this loop reads it, which makes every row this
1229/// one writes belong to one iteration of it rather than to all of them.
1230/// A guarded insert writes only where its condition holds. The rows it is
1231/// driven from are filtered rather than the statement skipped, so an
1232/// iteration-driven insert stays one statement.
1233fn append_insert_guard(sql: &mut String, ins: &IrInsert) {
1234    if let Some(guard) = &ins.guard {
1235        sql.push_str(&format!("\nWHERE {}", emit_expr(guard)));
1236    }
1237}
1238
1239fn emit_for_dml_ctes_within(f: &IrFor, name: &str, outer_alias: Option<&str>) -> Vec<String> {
1240    let iter_alias = format!("_for_{}", f.var_name);
1241    let (_, plain_iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
1242    let correlated = outer_alias;
1243    let iter_cte = match correlated {
1244        Some(outer) => emit_nested_for_iterator(&f.iterator, &iter_alias, outer),
1245        None => plain_iter_cte,
1246    };
1247    // The iterator first: a body CTE may read the loop variable, and a WITH
1248    // name is only in scope for the bindings that follow it.
1249    let mut parts: Vec<String> = vec![iter_cte];
1250    parts.extend(emit_user_cte_parts(&f.body_ctes));
1251    let ids_name = format!("{}__ids", name);
1252
1253    // Rows written under a correlated loop carry their iteration key, so the
1254    // enclosing statement can pair them with the row that iteration wrote.
1255    if correlated.is_some()
1256        && let IrStmt::Insert(ins) = f.body.as_ref()
1257        && ins.multi_link_appends.is_empty()
1258    {
1259        let rows_name = format!("{}__rows", name);
1260        let outer_source = format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY));
1261        parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1262        parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1263        parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, rows_name));
1264        return parts;
1265    }
1266
1267    match f.body.as_ref() {
1268        IrStmt::Insert(ins) => {
1269            let rewrite_cols: std::collections::HashSet<&str> =
1270                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1271            let cols: Vec<String> = ins
1272                .assignments
1273                .iter()
1274                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1275                .map(|(c, _)| qi(c))
1276                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1277                .collect();
1278            let values: Vec<String> = ins
1279                .assignments
1280                .iter()
1281                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1282                .map(|(_, e)| emit_expr(e))
1283                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1284                .collect();
1285            // A nested loop that carried the iteration key back means these
1286            // rows need one too, to pair with.
1287            let rows_name = format!("{}__rows", name);
1288            let correlated_targets =
1289                correlated_append_indices(&ins.multi_link_appends, &[&f.body_ctes, &ins.nested_ctes]);
1290            if correlated_targets.is_empty() {
1291                let mut sql = format!(
1292                    "INSERT INTO {} ({})\nSELECT {} FROM {}",
1293                    target_ref(&ins.target),
1294                    cols.join(", "),
1295                    values.join(", "),
1296                    qi(&iter_alias),
1297                );
1298                append_insert_guard(&mut sql, ins);
1299                if let Some(conflict) = &ins.unless_conflict {
1300                    emit_conflict(&mut sql, conflict);
1301                }
1302                sql.push_str("\nRETURNING *");
1303                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1304            } else {
1305                let outer_source = match correlated {
1306                    Some(_) => format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY)),
1307                    None => format!("{}.{}", qi(&iter_alias), qi("v")),
1308                };
1309                parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1310                parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1311            }
1312            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1313                let cte_name = format!("{}__ml_add_{}", name, i);
1314                if correlated_targets.contains(&i) {
1315                    let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
1316                        unreachable!("only a CTE-backed value can carry an iteration key")
1317                    };
1318                    parts.push(emit_correlated_ml_append_cte(append, &rows_name, target, &cte_name));
1319                } else {
1320                    parts.push(emit_ml_append_cte(append, &ids_name, &ins.target.alias, &cte_name));
1321                }
1322            }
1323        }
1324        IrStmt::Update(upd) => {
1325            let alias = &upd.target.alias;
1326            if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
1327                const ITER_COL: &str = "_iter";
1328                // A nested insert gets its id generated here rather than by the
1329                // column default, so the junction below can name the row this
1330                // iteration inserted. `RETURNING` offers no order to pair on.
1331                let generated: Vec<Option<&IrInsert>> = upd
1332                    .multi_link_appends
1333                    .iter()
1334                    .map(|a| per_iteration_insert(a, &upd.nested_ctes))
1335                    .collect();
1336                let new_cols: String = generated
1337                    .iter()
1338                    .enumerate()
1339                    .filter_map(|(i, ins)| {
1340                        ins.map(|ins| {
1341                            format!(
1342                                ", {} AS {}",
1343                                ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
1344                                qi(&format!("_new_{i}"))
1345                            )
1346                        })
1347                    })
1348                    .collect();
1349                let mut ids_sql = format!(
1350                    "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
1351                    ids_name,
1352                    qi(alias),
1353                    qi(&iter_alias),
1354                    qi(ITER_COL),
1355                    new_cols,
1356                    target_ref(&upd.target),
1357                    qi(alias),
1358                    qi(&iter_alias),
1359                );
1360                append_filter(&mut ids_sql, &upd.filter);
1361                ids_sql.push_str("\n)");
1362                parts.push(ids_sql);
1363                for (i, append) in upd.multi_link_appends.iter().enumerate() {
1364                    match generated[i] {
1365                        Some(ins) => {
1366                            parts.push(emit_for_nested_insert_cte(
1367                                ins,
1368                                &ids_name,
1369                                &iter_alias,
1370                                &format!("{}__ml_ins_{}", name, i),
1371                                &format!("_new_{i}"),
1372                            ));
1373                            parts.push(emit_for_ml_append_cte(
1374                                append,
1375                                &ids_name,
1376                                &format!("{}__ml_add_{}", name, i),
1377                                &format!("_new_{i}"),
1378                            ));
1379                        }
1380                        None => parts.push(emit_for_ml_append_cte(
1381                            append,
1382                            &ids_name,
1383                            &format!("{}__ml_add_{}", name, i),
1384                            ITER_COL,
1385                        )),
1386                    }
1387                }
1388            } else if !upd.poly_implementors.is_empty() {
1389                // An interface has no table to update: each implementor is
1390                // driven from the iteration on its own, and the rows unioned
1391                // back under the interface's common columns.
1392                let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1393                let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1394                // Qualified: the iterated set is in scope here too, so a bare
1395                // column name could name either side.
1396                let ret_list = upd
1397                    .poly_columns
1398                    .iter()
1399                    .map(|c| format!("{}.{}", qi(alias), qi(c)))
1400                    .collect::<Vec<_>>()
1401                    .join(", ");
1402                let mut union_parts = vec![];
1403                for (i, imp) in upd.poly_implementors.iter().enumerate() {
1404                    let cte_name = format!("{}__u{}", ids_name, i);
1405                    let mut upd_sql = format!(
1406                        "UPDATE {} AS {}\nSET {}\nFROM {}",
1407                        qn(&imp.module, &imp.table),
1408                        qi(alias),
1409                        sets.join(", "),
1410                        qi(&iter_alias),
1411                    );
1412                    append_filter(&mut upd_sql, &upd.filter);
1413                    upd_sql.push_str(&format!("\nRETURNING {}", ret_list));
1414                    parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
1415                    union_parts.push(format!(
1416                        "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1417                        sql_str(&imp.type_name),
1418                        col_list,
1419                        cte_name,
1420                    ));
1421                }
1422                parts.push(format!(
1423                    "\"{}\" AS (\n{}\n)",
1424                    ids_name,
1425                    union_parts.join("\nUNION ALL\n")
1426                ));
1427            } else {
1428                let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1429                let mut sql = format!(
1430                    "UPDATE {} AS {}\nSET {}\nFROM {}",
1431                    target_ref(&upd.target),
1432                    qi(alias),
1433                    sets.join(", "),
1434                    qi(&iter_alias),
1435                );
1436                append_filter(&mut sql, &upd.filter);
1437                sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1438                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1439            }
1440        }
1441        // `for line in … union (for component in … union (insert …))` — one
1442        // iterator CTE per loop, the inner carrying the outer's key, and the
1443        // insert driven from the two joined on it.
1444        IrStmt::For(inner) => {
1445            let inner_alias = format!("_for_{}", inner.var_name);
1446            parts.push(emit_nested_for_iterator(&inner.iterator, &inner_alias, &iter_alias));
1447            parts.extend(emit_user_cte_parts(&inner.body_ctes));
1448            let IrStmt::Insert(ins) = inner.body.as_ref() else {
1449                unreachable!("a nested for-loop's own body is an insert: {:?}", inner.body)
1450            };
1451            let rewrite_cols: std::collections::HashSet<&str> =
1452                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1453            let cols: Vec<String> = ins
1454                .assignments
1455                .iter()
1456                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1457                .map(|(c, _)| qi(c))
1458                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1459                .collect();
1460            let values: Vec<String> = ins
1461                .assignments
1462                .iter()
1463                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1464                .map(|(_, e)| emit_expr(e))
1465                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1466                .collect();
1467            let mut sql = format!(
1468                "INSERT INTO {} ({})\nSELECT {} FROM {}",
1469                target_ref(&ins.target),
1470                cols.join(", "),
1471                values.join(", "),
1472                nested_for_from(&inner_alias, &iter_alias),
1473            );
1474            append_insert_guard(&mut sql, ins);
1475            if let Some(conflict) = &ins.unless_conflict {
1476                emit_conflict(&mut sql, conflict);
1477            }
1478            sql.push_str("\nRETURNING *");
1479            parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1480            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1481                parts.push(emit_ml_append_cte(
1482                    append,
1483                    &ids_name,
1484                    &ins.target.alias,
1485                    &format!("{}__ml_add_{}", name, i),
1486                ));
1487            }
1488        }
1489        // Unreachable: only a mutating body is routed here.
1490        other => unreachable!("for-loop body is not a mutation: {other:?}"),
1491    }
1492
1493    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1494    parts
1495}
1496
1497/// Expands a list of user-bound WITH names into their top-level CTE parts —
1498/// usually one part per name, except an UPDATE or INSERT with any multi-link
1499/// mutation expands into several sibling parts (see
1500/// emit_update_multilink_ctes / emit_insert_multilink_ctes), and an UPDATE or
1501/// DELETE targeting an interface/abstract type similarly fans out into one
1502/// part per concrete implementor (see emit_poly_update_dml_ctes /
1503/// emit_poly_delete_dml_ctes) — neither can be nested inside a single name's
1504/// own CTE body.
1505fn emit_user_cte_parts(ctes: &[IrCteDef]) -> Vec<String> {
1506    let mut parts: Vec<String> = vec![];
1507    for c in ctes {
1508        // A hoisted statement may have hoisted one of its own —
1509        // `credentials := (insert access::Credentials { … })` inside an update.
1510        // Those CTEs are emitted beside it rather than inside its body, which
1511        // is where its own SQL expects to read them from.
1512        // The multi-link builders emit a statement's own, ahead of it.
1513        let nested: &[IrCteDef] = match &c.stmt {
1514            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => &[],
1515            IrStmt::Update(upd) if update_has_any_multilink(upd) => &[],
1516            IrStmt::Insert(ins) => &ins.nested_ctes,
1517            IrStmt::Update(upd) => &upd.nested_ctes,
1518            _ => &[],
1519        };
1520        if !nested.is_empty() {
1521            parts.extend(emit_user_cte_parts(nested));
1522        }
1523        if let IrStmt::Update(upd) = &c.stmt {
1524            if !upd.poly_implementors.is_empty() {
1525                parts.extend(if update_has_any_multilink(upd) {
1526                    emit_poly_update_multilink_ctes(upd, &c.name)
1527                } else {
1528                    emit_poly_update_dml_ctes(upd, &c.name)
1529                });
1530                continue;
1531            }
1532            if update_has_any_multilink(upd) {
1533                parts.extend(emit_update_multilink_ctes(upd, &c.name));
1534                continue;
1535            }
1536        }
1537        if let IrStmt::Insert(ins) = &c.stmt
1538            && insert_has_any_multilink(ins)
1539        {
1540            parts.extend(emit_insert_multilink_ctes(ins, &c.name));
1541            continue;
1542        }
1543        if let IrStmt::Delete(del) = &c.stmt
1544            && !del.poly_implementors.is_empty()
1545        {
1546            parts.extend(emit_poly_delete_dml_ctes(del, &c.name));
1547            continue;
1548        }
1549        if let IrStmt::For(f) = &c.stmt
1550            && matches!(f.body.as_ref(), IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::For(_))
1551        {
1552            parts.extend(emit_for_dml_ctes_within(f, &c.name, c.correlated_to.as_deref()));
1553            continue;
1554        }
1555        let body = emit_dml_as_cte_source(&c.stmt);
1556        parts.push(match &c.correlated_to {
1557            // Evaluated once per iteration and keyed by it, since a CTE is
1558            // evaluated once for the whole statement whatever it reads.
1559            Some(iterator) => format!(
1560                "\"{}\" AS (\nSELECT {}.\"v\" AS {}, \"_row\".*\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_row\"\n)",
1561                c.name,
1562                qi(iterator),
1563                qi(&binding_outer_key(&c.name)),
1564                qi(iterator),
1565                body,
1566            ),
1567            None => format!("\"{}\" AS (\n{}\n)", c.name, body),
1568        });
1569    }
1570    parts
1571}
1572
1573/// Emit `WITH "name" AS (...), ...` prefix (WITH keyword + trailing newline included).
1574fn emit_cte_prefix(ctes: &[IrCteDef]) -> String {
1575    format!("WITH\n{}\n", emit_user_cte_parts(ctes).join(",\n"))
1576}
1577
1578/// Collects every link-property name assigned anywhere in a multilink value
1579/// tree (both sides of any nested `union`), in first-seen order — the SQL
1580/// layer needs one consistent column list across every unioned branch, even
1581/// when different targets set different (or no) properties.
1582pub(crate) fn collect_link_prop_names(vals: &IrMultiLinkValues, names: &mut Vec<String>) {
1583    for (name, _) in &vals.link_props {
1584        if !names.contains(name) {
1585            names.push(name.clone());
1586        }
1587    }
1588    match &vals.source {
1589        IrMultiLinkValueSource::Union(a, b) => {
1590            collect_link_prop_names(a, names);
1591            collect_link_prop_names(b, names);
1592        }
1593        IrMultiLinkValueSource::Asserted { inner, .. } => collect_link_prop_names(inner, names),
1594        _ => {}
1595    }
1596}
1597
1598/// `, <expr> AS "name"` for each entry in `prop_names` — the value assigned to
1599/// *this* node if any, else a bare `NULL` (Postgres infers its type from the
1600/// other union branch's typed value in the same column position; if no
1601/// branch ever sets it, the INSERT's target column type coerces it).
1602fn emit_link_prop_cols(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1603    prop_names
1604        .iter()
1605        .map(|name| match vals.link_props.iter().find(|(n, _)| n == name) {
1606            Some((_, expr)) => format!(", {} AS {}", emit_expr(expr), qi(name)),
1607            None => format!(", NULL AS {}", qi(name)),
1608        })
1609        .collect()
1610}
1611
1612/// Emit `(SELECT id[, prop, ...] FROM ...)` sub-expression for a multi-link
1613/// values source. `prop_names` is the full set of link-property names used
1614/// anywhere in the enclosing mutation (see `collect_link_prop_names`) — every
1615/// leaf projects all of them so a `union` of heterogeneous branches has a
1616/// consistent column list.
1617fn emit_multilink_values_subquery(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1618    emit_multilink_values_inner(vals, prop_names, true)
1619}
1620
1621/// `bare_cte_ok` is false wherever the result has to be a SELECT in its own
1622/// right — a `UNION ALL` operand, or the body of an assert. A CTE reference
1623/// otherwise emits just its quoted name, which only works where the caller
1624/// aliases it into a FROM.
1625fn emit_multilink_values_inner(vals: &IrMultiLinkValues, prop_names: &[String], bare_cte_ok: bool) -> String {
1626    if let IrMultiLinkValueSource::Union(a, b) = &vals.source {
1627        return format!(
1628            "({}\nUNION ALL\n{})",
1629            emit_multilink_values_inner(a, prop_names, false),
1630            emit_multilink_values_inner(b, prop_names, false),
1631        );
1632    }
1633
1634    // The assert wraps the whole value, so the ids are collected once, passed
1635    // through the check, and unnested back into rows. Reading the inner value
1636    // twice — once for the check, once for the rows — would run any `insert`
1637    // inside it twice.
1638    if let IrMultiLinkValueSource::Asserted {
1639        fn_name,
1640        inner,
1641        message,
1642    } = &vals.source
1643    {
1644        let inner_sql = emit_multilink_values_inner(inner, &[], false);
1645        let prop_cols = emit_link_prop_cols(vals, prop_names);
1646        return format!(
1647            "(SELECT unnest(\"_pylon\".{}(ARRAY(SELECT \"_v\".\"id\" FROM {} AS \"_v\"){})) AS \"id\"{})",
1648            qi(fn_name),
1649            inner_sql,
1650            assert_message_arg(message),
1651            prop_cols,
1652        );
1653    }
1654
1655    let prop_cols = emit_link_prop_cols(vals, prop_names);
1656
1657    match &vals.source {
1658        IrMultiLinkValueSource::CteRef(name) => {
1659            if prop_cols.is_empty() && bare_cte_ok {
1660                // Just the CTE name; will be aliased at the call site.
1661                format!("\"{}\"", name)
1662            } else {
1663                format!("(SELECT \"_s\".\"id\"{} FROM \"{}\" AS \"_s\")", prop_cols, name)
1664            }
1665        }
1666        IrMultiLinkValueSource::Select(s) => {
1667            // Compiler only ever constructs this variant for a single
1668            // schema-bound row (compile_subquery_exists's callers reject
1669            // free rows before wrapping) — see compiler.rs's multilink-
1670            // value-source sites.
1671            let [IrRowSource::Bound { source, .. }] = s.rows.as_slice() else {
1672                unreachable!("IrMultiLinkValueSource::Select is always schema-bound")
1673            };
1674            let alias = &source.alias;
1675            let mut sql = format!(
1676                "(SELECT {}.\"id\"{} FROM {} AS {}",
1677                qi(alias),
1678                prop_cols,
1679                source_ref(source),
1680                qi(alias)
1681            );
1682            append_filter(&mut sql, &s.filter);
1683            sql.push(')');
1684            sql
1685        }
1686        IrMultiLinkValueSource::PathSelect(ps) => {
1687            let root_alias = &ps.root.alias;
1688            // The targets are the rows the walk lands on, not the one it
1689            // starts from.
1690            let target_alias = match &ps.result {
1691                IrPathResult::Object { alias, .. } => alias,
1692                IrPathResult::Scalar(..) => root_alias,
1693            };
1694            let mut sql = format!(
1695                "(SELECT {}.\"id\"{} FROM {} AS {}",
1696                qi(target_alias),
1697                prop_cols,
1698                source_ref(&ps.root),
1699                qi(root_alias)
1700            );
1701            for join in &ps.joins {
1702                sql.push_str(&emit_path_join_sql(join));
1703            }
1704            append_filter(&mut sql, &ps.filter);
1705            sql.push(')');
1706            sql
1707        }
1708        IrMultiLinkValueSource::Function(fs) => {
1709            let args = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1710            let mut sql = format!(
1711                "(SELECT {}.\"id\"{} FROM {}.{}({}) AS {}",
1712                qi(&fs.alias),
1713                prop_cols,
1714                pg_schema(&fs.fn_module),
1715                qi(&fs.fn_name),
1716                args,
1717                qi(&fs.alias),
1718            );
1719            append_filter(&mut sql, &fs.filter);
1720            sql.push(')');
1721            sql
1722        }
1723        IrMultiLinkValueSource::Union(..) | IrMultiLinkValueSource::Asserted { .. } => {
1724            unreachable!("handled above")
1725        }
1726    }
1727}
1728
1729/// SQL fragment for a single path join (used in multilink values emission).
1730fn emit_path_join_sql(join: &IrPathJoin) -> String {
1731    match join {
1732        IrPathJoin::Lateral { inner, target } => {
1733            let projected = match &inner.result {
1734                IrPathResult::Object { alias, .. } => alias.clone(),
1735                IrPathResult::Scalar(..) => unreachable!("a lateral step is only built for an object-valued computed"),
1736            };
1737            let mut body = format!(
1738                "SELECT {}.* FROM {}",
1739                qi(&projected),
1740                emit_path_joins(&inner.root, &inner.joins)
1741            );
1742            append_filter(&mut body, &inner.filter);
1743            append_order_by(&mut body, &inner.order_by);
1744            append_offset_limit(&mut body, &inner.offset, &inner.limit);
1745            format!(" JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias))
1746        }
1747        IrPathJoin::Function {
1748            fn_module,
1749            fn_name,
1750            args,
1751            target,
1752        } => {
1753            let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1754            format!(
1755                " CROSS JOIN LATERAL {}.{}({}) AS {}",
1756                pg_schema(fn_module),
1757                qi(fn_name),
1758                args_sql,
1759                qi(&target.alias),
1760            )
1761        }
1762        IrPathJoin::Single {
1763            source_alias,
1764            fk_col,
1765            target,
1766        } => {
1767            format!(
1768                " JOIN {} AS {} ON {}.\"id\" = {}.{}",
1769                source_ref(target),
1770                qi(&target.alias),
1771                qi(&target.alias),
1772                qi(source_alias),
1773                qi(fk_col)
1774            )
1775        }
1776        IrPathJoin::Multi {
1777            source_alias,
1778            junction_alias,
1779            join: ml_join,
1780            target,
1781        } => {
1782            let (jt_ref, src_col, tgt_col) = match ml_join {
1783                IrMultiLinkJoin::Standard { junction_table, module } => (
1784                    junction_ref(module, junction_table),
1785                    "source".to_string(),
1786                    "target".to_string(),
1787                ),
1788                IrMultiLinkJoin::Through {
1789                    junction_table,
1790                    module,
1791                    source_col,
1792                    target_col,
1793                } => (
1794                    junction_ref(module, junction_table),
1795                    source_col.clone(),
1796                    target_col.clone(),
1797                ),
1798                // A forward `IrPathJoin::Multi` step is always built from a
1799                // real multi-link (`compile_path_select`'s middle-step
1800                // handling), never a backlink — the Backlink* variants only
1801                // ever appear inside `IrShapePointer::MultiLink.join`.
1802                IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
1803                    unreachable!("a forward multi-link path step never uses a backlink join variant")
1804                }
1805            };
1806            format!(
1807                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.{} = {}.\"id\"",
1808                jt_ref,
1809                qi(junction_alias),
1810                qi(junction_alias),
1811                qi(&src_col),
1812                qi(source_alias),
1813                source_ref(target),
1814                qi(&target.alias),
1815                qi(junction_alias),
1816                qi(&tgt_col),
1817                qi(&target.alias),
1818            )
1819        }
1820        IrPathJoin::BacklinkSingle {
1821            source_alias,
1822            fk_col,
1823            target,
1824        } => {
1825            format!(
1826                " JOIN {} AS {} ON {}.{} = {}.\"id\"",
1827                source_ref(target),
1828                qi(&target.alias),
1829                qi(&target.alias),
1830                qi(fk_col),
1831                qi(source_alias),
1832            )
1833        }
1834        IrPathJoin::BacklinkMulti {
1835            source_alias,
1836            junction_alias,
1837            junction_table,
1838            module,
1839            owner_col,
1840            current_col,
1841            target,
1842        } => {
1843            format!(
1844                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.\"id\" = {}.{}",
1845                junction_ref(module, junction_table),
1846                qi(junction_alias),
1847                qi(junction_alias),
1848                qi(current_col),
1849                qi(source_alias),
1850                source_ref(target),
1851                qi(&target.alias),
1852                qi(&target.alias),
1853                qi(junction_alias),
1854                qi(owner_col),
1855            )
1856        }
1857    }
1858}
1859
1860/// Emit the CTE clause for a junction table INSERT (append / replace-insert).
1861/// `ids_name` is the row-source CTE to join against (usually "_ids", but
1862/// callers hoisting this into a shared top-level WITH block alongside other
1863/// user-bound statements pass a name prefixed for collision-safety instead).
1864/// The loop variable an append's value stands for, when the value is exactly
1865/// that variable (`memberships += membership`).
1866///
1867/// Such a value is compiled as a select over the target table narrowed to the
1868/// key the variable holds. Inside a `for` that key is already carried on the
1869/// driving rows, so the junction insert can read it from there instead of
1870/// correlating to a CTE it has no range-table entry for.
1871pub(crate) fn append_value_is_the_loop_variable(values: &IrMultiLinkValues, var: &str) -> bool {
1872    let IrMultiLinkValueSource::Select(sel) = &values.source else {
1873        return false;
1874    };
1875    let Some(IrExpr::BinOp(cmp)) = sel.filter.as_ref() else {
1876        return false;
1877    };
1878    matches!(
1879        (&cmp.left, &cmp.op, &cmp.right),
1880        (
1881            IrExpr::ColumnRef { column, .. },
1882            crate::parse::ast::BinOpKind::Eq,
1883            IrExpr::ForVar { name, .. },
1884        ) if column == "id" && name == var
1885    ) && values.link_props.is_empty()
1886}
1887
1888/// `emit_ml_append_cte` for a `for` body: the target comes from the column the
1889/// driving rows carry rather than from a correlated subquery.
1890/// The nested insert a multi-link append's value names, when it is one of this
1891/// update's own hoisted CTEs. Inside a `for` body such an insert runs once per
1892/// iteration, so it cannot stay a standalone CTE.
1893pub(crate) fn per_iteration_insert<'c>(mutation: &IrMultiLinkMutation, nested: &'c [IrCteDef]) -> Option<&'c IrInsert> {
1894    let IrMultiLinkValueSource::CteRef(name) = &mutation.values.source else {
1895        return None;
1896    };
1897    if !mutation.values.link_props.is_empty() {
1898        return None;
1899    }
1900    nested.iter().find(|c| &c.name == name).and_then(|c| match &c.stmt {
1901        IrStmt::Insert(ins) if ins.id_default_sql.is_some() && ins.multi_link_appends.is_empty() => Some(ins),
1902        _ => None,
1903    })
1904}
1905
1906/// A nested insert inside a `for` body: one row per driving row, with its id
1907/// taken from the column the driving CTE generated so the junction can pair
1908/// with it. The iterator CTE is joined in because the inserted values are
1909/// correlated subqueries that read the loop variable.
1910fn emit_for_nested_insert_cte(
1911    ins: &IrInsert,
1912    ids_name: &str,
1913    iter_alias: &str,
1914    cte_name: &str,
1915    id_col: &str,
1916) -> String {
1917    let mut cols = vec![qi("id")];
1918    let mut values = vec![format!("\"{}\".{}", ids_name, qi(id_col))];
1919    for (col, expr) in &ins.assignments {
1920        cols.push(qi(col));
1921        values.push(emit_expr(expr));
1922    }
1923    format!(
1924        "\"{}\" AS (\nINSERT INTO {} ({})\nSELECT {} FROM \"{}\", {}\nWHERE \"{}\".{} = {}.\"v\"\nRETURNING {}\n)",
1925        cte_name,
1926        target_ref(&ins.target),
1927        cols.join(", "),
1928        values.join(", "),
1929        ids_name,
1930        qi(iter_alias),
1931        ids_name,
1932        qi("_iter"),
1933        qi(iter_alias),
1934        qi("id"),
1935    )
1936}
1937
1938fn emit_for_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, cte_name: &str, iter_col: &str) -> String {
1939    format!(
1940        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"{}\".\"id\", \"{}\".{} FROM \"{}\"\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
1941        cte_name,
1942        qn(&mutation.module, &mutation.junction_table),
1943        qi(&mutation.source_col),
1944        qi(&mutation.target_col),
1945        ids_name,
1946        ids_name,
1947        qi(iter_col),
1948        ids_name,
1949        qi(&mutation.source_col),
1950        qi(&mutation.target_col),
1951    )
1952}
1953
1954/// `ids_alias` is the name the rows being written go by inside the value:
1955/// a walk off the row (`+= (select .notifications …)`) is correlated to it.
1956fn emit_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
1957    let mut prop_names = vec![];
1958    collect_link_prop_names(&mutation.values, &mut prop_names);
1959    let vals_ref = emit_multilink_values_subquery(&mutation.values, &prop_names);
1960
1961    let extra_cols: String = prop_names.iter().map(|n| format!(", {}", qi(n))).collect();
1962    let extra_select: String = prop_names.iter().map(|n| format!(", \"_v\".{}", qi(n))).collect();
1963
1964    // Re-checking an already-linked target with a new property value must
1965    // update in place rather than error — a bare `DO NOTHING` (the no-
1966    // properties case) would silently keep the old value instead.
1967    let conflict_clause = if mutation.single {
1968        // A junction-backed single link's table has `PRIMARY KEY (source)`
1969        // alone (D3, cardinality-one) — the paired `_clr_i` DELETE (see
1970        // `ml_clear_exclusion`) removes any existing row for this source in
1971        // a sibling CTE that shares this whole statement's one snapshot, so
1972        // this INSERT still "sees" that old row and treats it as a
1973        // same-key conflict regardless of what the new target is — a plain
1974        // `DO NOTHING` would then silently drop a genuine reassignment.
1975        // `source` alone is always this table's conflict target, so this
1976        // must always be an upsert, not just when link properties exist.
1977        let mut sets = vec![format!(
1978            "{} = EXCLUDED.{}",
1979            qi(&mutation.target_col),
1980            qi(&mutation.target_col)
1981        )];
1982        sets.extend(prop_names.iter().map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n))));
1983        format!(
1984            "ON CONFLICT ({}) DO UPDATE SET {}",
1985            qi(&mutation.source_col),
1986            sets.join(", ")
1987        )
1988    } else if prop_names.is_empty() {
1989        "ON CONFLICT DO NOTHING".to_string()
1990    } else {
1991        let sets: Vec<String> = prop_names
1992            .iter()
1993            .map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n)))
1994            .collect();
1995        format!(
1996            "ON CONFLICT ({}, {}) DO UPDATE SET {}",
1997            qi(&mutation.source_col),
1998            qi(&mutation.target_col),
1999            sets.join(", "),
2000        )
2001    };
2002
2003    // A subquery value may read the row it is written for, so it joins
2004    // laterally; a bare CTE name cannot take LATERAL.
2005    let lateral = if vals_ref.starts_with('(') { "LATERAL " } else { "" };
2006    let ins = format!(
2007        "INSERT INTO {} ({}, {}{})\nSELECT {}.\"id\", \"_v\".\"id\"{} FROM \"{}\" AS {} CROSS JOIN {}{} AS \"_v\"\n{}\nRETURNING {}, {}",
2008        qn(&mutation.module, &mutation.junction_table),
2009        qi(&mutation.source_col),
2010        qi(&mutation.target_col),
2011        extra_cols,
2012        qi(ids_alias),
2013        extra_select,
2014        ids_name,
2015        qi(ids_alias),
2016        lateral,
2017        vals_ref,
2018        conflict_clause,
2019        qi(&mutation.source_col),
2020        qi(&mutation.target_col),
2021    );
2022    format!("\"{}\" AS (\n{}\n)", cte_name, ins)
2023}
2024
2025/// Emit the CTE clause for a junction table DELETE (remove). See
2026/// emit_ml_append_cte re: `ids_name` and `ids_alias`.
2027fn emit_ml_remove_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
2028    // Removal never carries link-property assignments (rejected at compile
2029    // time in ir/compiler.rs), so no extra columns are ever needed here.
2030    let vals_ref = emit_multilink_values_subquery(&mutation.values, &[]);
2031    // A walk off the row (`-= (select .line_items …)`) is correlated to
2032    // `ids_alias`, so the rows being written are joined in under that name
2033    // rather than read through an uncorrelated `IN`.
2034    let del = format!(
2035        "DELETE FROM {} AS \"_jn\"\nUSING \"{}\" AS {}\nWHERE \"_jn\".{} = {}.\"id\"\n  AND \"_jn\".{} IN (SELECT \"id\" FROM {})\nRETURNING \"_jn\".{}, \"_jn\".{}",
2036        qn(&mutation.module, &mutation.junction_table),
2037        ids_name,
2038        qi(ids_alias),
2039        qi(&mutation.source_col),
2040        qi(ids_alias),
2041        qi(&mutation.target_col),
2042        vals_ref,
2043        qi(&mutation.source_col),
2044        qi(&mutation.target_col),
2045    );
2046    format!("\"{}\" AS (\n{}\n)", cte_name, del)
2047}
2048
2049// ── Conflict helper ─────────────────────────────────────────────────────────
2050
2051use crate::ir::IrConflict;
2052
2053fn emit_conflict(sql: &mut String, conflict: &IrConflict) {
2054    // `unless conflict on (.email, .invitee)` names several columns, not one
2055    // composite value: Postgres infers the arbiter index from a column list,
2056    // and would look for an expression index on the tuple instead.
2057    let on_sql = conflict.on.as_ref().map(|e| match e {
2058        IrExpr::Tuple(elements) => format!("({})", elements.iter().map(emit_expr).collect::<Vec<_>>().join(", ")),
2059        other => format!("({})", emit_expr(other)),
2060    });
2061    match (&on_sql, &conflict.do_update) {
2062        (None, None) => sql.push_str(" ON CONFLICT DO NOTHING"),
2063        (Some(on), None) => sql.push_str(&format!(" ON CONFLICT {} DO NOTHING", on)),
2064        (None, Some(updates)) => {
2065            sql.push_str(&format!(" ON CONFLICT DO UPDATE SET {}", do_update_sets(updates)));
2066        }
2067        (Some(on), Some(updates)) => {
2068            sql.push_str(&format!(
2069                " ON CONFLICT {} DO UPDATE SET {}",
2070                on,
2071                do_update_sets(updates),
2072            ));
2073        }
2074    }
2075    if conflict.do_update.is_some()
2076        && let Some(predicate) = &conflict.do_update_where
2077    {
2078        sql.push_str(&format!(" WHERE {}", emit_expr(predicate)));
2079    }
2080}
2081
2082fn do_update_sets(updates: &[(String, IrExpr)]) -> String {
2083    updates
2084        .iter()
2085        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
2086        .collect::<Vec<_>>()
2087        .join(", ")
2088}
2089
2090// ── FREE SELECT ─────────────────────────────────────────────────────────────
2091
2092/// Types returned as plain top-level columns rather than wrapped in the
2093/// `result` ROW() composite, so each keeps its own natural top-level shape.
2094fn is_integer_expr(expr: &IrExpr) -> bool {
2095    match expr {
2096        IrExpr::ColumnRef { pg_type, .. } => matches!(
2097            pg_type.as_str(),
2098            "int2" | "int4" | "int8" | "integer" | "bigint" | "smallint"
2099        ),
2100        IrExpr::Literal(crate::ir::IrLiteral::Int(_)) => true,
2101        IrExpr::BinOp(op) => is_integer_expr(&op.left) && is_integer_expr(&op.right),
2102        _ => false,
2103    }
2104}
2105
2106fn is_raw_scalar(expr: &IrExpr) -> bool {
2107    matches!(expr, IrExpr::Array(_))
2108        || matches!(expr, IrExpr::TypeCast(c) if c.pg_type == "jsonb")
2109        || matches!(expr, IrExpr::NamedTuple { .. })
2110        || matches!(expr, IrExpr::Tuple(_))
2111        || matches!(expr, IrExpr::JsonbField { .. })
2112        || matches!(expr, IrExpr::JsonbIndex { .. })
2113}
2114
2115/// Free (non-schema-bound) SELECT rows — one or more UNION ALL branches with
2116/// a ROW(…) wrapper. `rows` is `sel.rows` with any leading `Bound` entries
2117/// already ruled out by `emit_select_stmt`'s dispatch (a schema object and a
2118/// free literal can never appear in the same UNION — `check_union_type_compat`
2119/// rejects that at compile time), so every entry here is `IrRowSource::Free`.
2120fn emit_free_rows(sel: &IrSelect, rows: &[IrRowSource], ctes: &[IrCteDef]) -> SqlOutput {
2121    use crate::query::ShapeNode;
2122
2123    let items: Vec<&IrFreeExpr> = rows
2124        .iter()
2125        .map(|r| match r {
2126            IrRowSource::Free(item) => item,
2127            IrRowSource::Bound { .. } => unreachable!("mixed Bound/Free rows rejected at compile time"),
2128        })
2129        .collect();
2130
2131    if items.is_empty() {
2132        return SqlOutput {
2133            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
2134            shape: ShapeDescriptor {
2135                root: ShapeNode::Scalar {
2136                    name: String::new(),
2137                    position: 0,
2138                },
2139            },
2140            inference_plan: None,
2141        };
2142    }
2143
2144    // assert_exists / assert_distinct: set-returning — emit as unnest, not UNION ALL
2145    if items.len() == 1
2146        && let IrFreeExpr::AssertSet {
2147            fn_name,
2148            inner,
2149            message,
2150        } = items[0]
2151    {
2152        let array_sql = emit_array_source(inner) + &assert_message_arg(message);
2153        let mut sql = format!(
2154            "SELECT ROW(v) AS result FROM unnest(\"_pylon\".{}({})) AS _assert(v)",
2155            fn_name, array_sql,
2156        );
2157        if sel.distinct {
2158            sql = format!("SELECT DISTINCT * FROM ({}) AS \"_distinct\"", sql);
2159        }
2160        append_order_by(&mut sql, &sel.order_by);
2161        append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2162        return SqlOutput {
2163            sql,
2164            shape: ShapeDescriptor {
2165                root: ShapeNode::Scalar {
2166                    name: String::new(),
2167                    position: 0,
2168                },
2169            },
2170            inference_plan: None,
2171        };
2172    }
2173
2174    let shape_root = free_item_shape(items.first().unwrap(), ctes);
2175
2176    let branches: Vec<String> = items
2177        .iter()
2178        .map(|item| match item {
2179            IrFreeExpr::Scalar(expr) => {
2180                // An object standing as the whole result is already the
2181                // composite row `result` is meant to be — wrapping it in
2182                // another ROW() would bury its columns one level down from
2183                // where its shape node says they are.
2184                if matches!(expr, IrExpr::ObjectSubquery(_)) {
2185                    return format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _obj", emit_expr(expr));
2186                }
2187                // Arrays and jsonb are returned as plain top-level columns
2188                // rather than wrapped, so they keep their own shape node.
2189                if is_raw_scalar(expr) {
2190                    // `v` alongside it for the same reason the wrapped form
2191                    // has one: a `with` binding of an array is read by name.
2192                    format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _raw", emit_expr(expr))
2193                } else {
2194                    // `result` is a ROW() composite for top-level decoding.
2195                    // `v` is the unwrapped scalar for use in CteRef expression context.
2196                    // Wrap in a subquery so volatile functions (nextval, etc.) are called once.
2197                    // Enum values also need a ::text cast inside the ROW() — same
2198                    // unregistered-OID problem as arrays/jsonb — but the bare `v`
2199                    // column stays natively typed for CteRef expression use.
2200                    let e = emit_expr(expr);
2201                    let row_value = if enum_type_of_expr(expr).is_some() {
2202                        "v::text"
2203                    } else {
2204                        "v"
2205                    };
2206                    // In a set of several, an element that is empty is not
2207                    // in the set at all (`{a.id, b.id}` with no `a` is just
2208                    // `b.id`) — where SQL would carry it as a NULL row.
2209                    let present = if items.len() > 1 { " WHERE v IS NOT NULL" } else { "" };
2210                    format!("SELECT ROW({row_value}) AS result, v FROM (SELECT {e} AS v) AS _scalar{present}")
2211                }
2212            }
2213            IrFreeExpr::FreeObject(fields) => {
2214                // Each field is computed once in an inner subquery (so a
2215                // volatile expression like nextval() isn't evaluated twice) and
2216                // exposed both packed into the `result` composite (for whole-
2217                // object passthrough/decoding) and as its own named column (for
2218                // `IrExpr::CteFieldRef` — `with x := {a := ...} select x.a`).
2219                let inner_cols: Vec<String> = fields
2220                    .iter()
2221                    .enumerate()
2222                    .map(|(i, (_, e))| format!("{} AS \"_f{}\"", emit_expr(e), i))
2223                    .collect();
2224                let row_items: Vec<String> = fields
2225                    .iter()
2226                    .enumerate()
2227                    .map(|(i, (_, e))| {
2228                        if enum_type_of_expr(e).is_some() {
2229                            format!("\"_f{}\"::text", i)
2230                        } else {
2231                            format!("\"_f{}\"", i)
2232                        }
2233                    })
2234                    .collect();
2235                let named_cols: Vec<String> = fields
2236                    .iter()
2237                    .enumerate()
2238                    .map(|(i, (name, _))| format!("\"_f{}\" AS {}", i, qi(name)))
2239                    .collect();
2240                format!(
2241                    "SELECT ROW({}) AS result, {} FROM (SELECT {}) AS _obj",
2242                    row_items.join(", "),
2243                    named_cols.join(", "),
2244                    inner_cols.join(", "),
2245                )
2246            }
2247            IrFreeExpr::Tuple(exprs) => {
2248                if exprs.len() == 1 {
2249                    format!("SELECT ROW({}) AS result", emit_free_field_expr(&exprs[0]))
2250                } else {
2251                    let parts: Vec<String> = exprs.iter().map(emit_free_field_expr).collect();
2252                    format!("SELECT ({}) AS result", parts.join(", "))
2253                }
2254            }
2255            IrFreeExpr::NamedTupleRow(fields) => {
2256                let parts: Vec<String> = fields.iter().map(|(_, e)| emit_free_field_expr(e)).collect();
2257                if parts.len() == 1 {
2258                    format!("SELECT ROW({}) AS result", parts[0])
2259                } else {
2260                    format!("SELECT ({}) AS result", parts.join(", "))
2261                }
2262            }
2263            IrFreeExpr::AssertSet { .. } => unreachable!("AssertSet is handled by early return above"),
2264            IrFreeExpr::CtePassthrough(name) => format!("SELECT \"result\" FROM {}", qi(name)),
2265        })
2266        .collect();
2267
2268    let union_sql = branches.join("\nUNION ALL\n");
2269
2270    let mut sql = if sel.distinct {
2271        // Wrap UNION ALL in an outer SELECT DISTINCT to deduplicate.
2272        format!("SELECT DISTINCT * FROM (\n{}\n) AS \"_distinct\"", union_sql)
2273    } else {
2274        union_sql
2275    };
2276    // A free SELECT's FILTER has no row of its own to sit beside, so it gates
2277    // the result the branches already produced rather than joining their own
2278    // WHERE clauses.
2279    if let Some(filter) = &sel.filter {
2280        sql = format!(
2281            "SELECT * FROM (\n{}\n) AS \"_filtered\"\nWHERE {}",
2282            sql,
2283            emit_expr(filter)
2284        );
2285    }
2286    append_order_by(&mut sql, &sel.order_by);
2287    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2288
2289    SqlOutput {
2290        sql,
2291        shape: ShapeDescriptor { root: shape_root },
2292        inference_plan: None,
2293    }
2294}
2295
2296/// Determine whether `expr`'s runtime SQL type is a custom/enum type whose
2297/// OID is assigned by the database rather than fixed — and
2298/// if so, the Postgres-schema-qualified enum type name for `ShapeNode::Enum`
2299/// tagging (same convention `pg_quoted_to_pylon` expects). Covers both a
2300/// column reference to an enum-typed property (quoted pg_type) and a bare
2301/// enum literal (`default::Gender.Male`) — the two ways an enum value can
2302/// appear as a free scalar/object/tuple field, which (unlike a schema
2303/// object's `emit_scalar`) has no dedicated per-pointer descriptor to carry
2304/// this, so it must be recovered from the expression itself.
2305fn enum_type_of_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2306    match expr {
2307        IrExpr::ColumnRef { pg_type, .. } => QualifiedPgType::of(pg_type),
2308        IrExpr::EnumLiteral { pg_type, .. } => Some(QualifiedPgType {
2309            name: pg_quoted_to_pylon(pg_type),
2310            is_array: false,
2311        }),
2312        _ => None,
2313    }
2314}
2315
2316/// The enum type a shape position decodes as — wider than `enum_type_of_expr`,
2317/// which only answers for the values that need a `::text` cast emitting.
2318///
2319/// `[is Concrete].*` wraps each of the concrete type's properties in a
2320/// correlated subquery of its own, so the enum sits a level down and the
2321/// subquery's own emission has already cast it. Without looking through to it
2322/// the outer shape stays a plain `Scalar` and the label is handed back as a
2323/// bare string rather than the enum member.
2324fn enum_type_of_shape_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2325    if let Some(qualified) = enum_type_of_expr(expr) {
2326        return Some(qualified);
2327    }
2328    let IrExpr::Subquery(select) = expr else {
2329        return None;
2330    };
2331    let [IrRowSource::Bound { shape, .. }] = select.rows.as_slice() else {
2332        return None;
2333    };
2334    let [IrShapePointer::Scalar(scalar)] = shape.as_slice() else {
2335        return None;
2336    };
2337    QualifiedPgType::of(&scalar.pg_type)
2338}
2339
2340/// Emit `expr` for use as a free object/tuple field value — casts to
2341/// `::text` when it's enum-typed (see `enum_type_of_expr`), which keeps the
2342/// value's shape independent of the enum's database-assigned type OID.
2343/// (`pylon-pgcon` also discovers enum OIDs at connect time now, so this cast
2344/// is belt-and-braces rather than load-bearing for decoding.)
2345fn emit_free_field_expr(expr: &IrExpr) -> String {
2346    match enum_type_of_expr(expr) {
2347        Some(qualified) => format!("{}{}", emit_expr(expr), qualified.text_cast()),
2348        None => emit_expr(expr),
2349    }
2350}
2351
2352/// One member of a tuple literal: its own name, plus what that member
2353/// holds — a nested literal recurses, an enum keeps the type its labels
2354/// decode against, and anything else is read as the jsonb scalar it is.
2355fn literal_tuple_member(key: &str, value: &IrExpr) -> crate::query::JsonMember {
2356    use crate::query::{JsonMember, JsonMemberKind};
2357
2358    let kind = match value {
2359        IrExpr::NamedTuple { fields, .. } => JsonMemberKind::Tuple {
2360            type_name: None,
2361            members: fields
2362                .iter()
2363                .map(|(key, value)| literal_tuple_member(key, value))
2364                .collect(),
2365        },
2366        _ => match enum_type_of_expr(value) {
2367            Some(qualified) => JsonMemberKind::Enum {
2368                // A member's enum type is Pylon-qualified, unlike
2369                // `ShapeNode::Enum`'s own Postgres-schema-qualified one.
2370                enum_type: pg_schema_qualified_to_pylon(&qualified.name),
2371            },
2372            None if crate::ir::infer_ir_type(value) == Some("numeric") => JsonMemberKind::Decimal,
2373            None => JsonMemberKind::Scalar,
2374        },
2375    };
2376    JsonMember {
2377        key: Some(key.to_string()),
2378        kind,
2379    }
2380}
2381
2382/// `public::Gender` -> `default::Gender` — only the `default` module is ever
2383/// renamed on the way into Postgres, so it is the only one to undo.
2384fn pg_schema_qualified_to_pylon(qualified: &str) -> String {
2385    match qualified.split_once("::") {
2386        Some(("public", name)) => format!("default::{name}"),
2387        _ => qualified.to_string(),
2388    }
2389}
2390
2391/// Shape node for one free scalar/object/tuple field — `Enum` when the
2392/// value is enum-typed (see `enum_type_of_expr`), else a plain `Scalar`.
2393fn free_field_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2394    use crate::query::{Cardinality, ShapeNode};
2395    if let IrExpr::ObjectSubquery(sel) = expr {
2396        let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2397            unreachable!("an object subquery is always schema-bound")
2398        };
2399        let (_, nodes) = build_shape(shape, &source.alias);
2400        return ShapeNode::Object {
2401            name: name.to_string(),
2402            type_name: Some(source.type_name.clone()),
2403            position,
2404            cardinality: Cardinality::Optional,
2405            pointers: prepend_type(nodes),
2406            has_implicit_id: shape_has_implicit_id(shape),
2407        };
2408    }
2409    // A walk landing on one object: `(select … limit 1).latest_data { … }`.
2410    if matches!(expr, IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { .. }) {
2411        return expr_shape_node(name, position, expr);
2412    }
2413    // A set of objects keeps its rows' own shape, as it does on a schema
2414    // shape; described as a scalar, each row hydrates as a bare tuple.
2415    if let IrExpr::ArrayFromSelect(source) = expr
2416        && matches!(
2417            source.as_ref(),
2418            IrArraySource::ObjectSelect(_)
2419                | IrArraySource::ObjectFunction(_)
2420                | IrArraySource::PathSelect(_)
2421                | IrArraySource::Group(_)
2422        )
2423    {
2424        return expr_shape_node(name, position, expr);
2425    }
2426    match enum_type_of_shape_expr(expr) {
2427        Some(qualified) => qualified.shape_node(name.to_string(), position),
2428        None => ShapeNode::Scalar {
2429            name: name.to_string(),
2430            position,
2431        },
2432    }
2433}
2434
2435/// Shape node for an arbitrary compiled expression appearing at a shape
2436/// position (a computed pointer, or a free scalar/tuple/object field) —
2437/// `NamedTuple`/`JsonScalar` when the expression produces jsonb (tuple
2438/// literals, nested free objects, tuple-typed casts), `RawScalar` when
2439/// it is returned as a bare top-level column, `Enum` when
2440/// enum-typed, else a plain `Scalar`.
2441fn expr_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2442    use crate::query::{Cardinality, ShapeNode};
2443    match expr {
2444        IrExpr::ObjectPathUnion { branches, multi, .. } => {
2445            let first = branches.first().expect("a union has at least one branch");
2446            let IrPathResult::Object {
2447                alias,
2448                type_name,
2449                shape,
2450            } = &first.result
2451            else {
2452                unreachable!("an object path union's branches always land on objects")
2453            };
2454            let (_, pointer_nodes) = build_shape(shape, alias);
2455            let object = ShapeNode::Object {
2456                name: if *multi { String::new() } else { name.to_string() },
2457                type_name: Some(type_name.clone()),
2458                position: if *multi { 0 } else { position },
2459                cardinality: if *multi {
2460                    Cardinality::Many
2461                } else {
2462                    Cardinality::Optional
2463                },
2464                pointers: prepend_type(pointer_nodes),
2465                has_implicit_id: shape_has_implicit_id(shape),
2466            };
2467            if *multi {
2468                ShapeNode::Array {
2469                    name: name.to_string(),
2470                    position,
2471                    element: Box::new(object),
2472                }
2473            } else {
2474                object
2475            }
2476        }
2477        IrExpr::ObjectPathSubquery(ps) => {
2478            let IrPathResult::Object {
2479                alias,
2480                type_name,
2481                shape,
2482            } = &ps.result
2483            else {
2484                unreachable!("an object path subquery always lands on an object")
2485            };
2486            let (_, pointer_nodes) = build_shape(shape, alias);
2487            ShapeNode::Object {
2488                name: name.to_string(),
2489                type_name: Some(type_name.clone()),
2490                position,
2491                cardinality: Cardinality::Optional,
2492                pointers: prepend_type(pointer_nodes),
2493                has_implicit_id: shape_has_implicit_id(shape),
2494            }
2495        }
2496        IrExpr::TypeCast(c) if c.tuple_shape.is_some() => {
2497            let shape = c.tuple_shape.as_ref().unwrap();
2498            // `<array<tuple<…>>>`: the shape resolved above is one element's,
2499            // each sitting at position 0 of its own jsonb value.
2500            let is_array = c.pg_type.ends_with("[]");
2501            let tuple = ShapeNode::NamedTuple {
2502                name: if is_array { String::new() } else { name.to_string() },
2503                position: if is_array { 0 } else { position },
2504                type_name: shape.type_name.clone(),
2505                members: Some(shape.members.clone()),
2506                is_free_object: false,
2507            };
2508            if is_array {
2509                ShapeNode::Array {
2510                    name: name.to_string(),
2511                    position,
2512                    element: Box::new(tuple),
2513                }
2514            } else {
2515                tuple
2516            }
2517        }
2518        // A `<json>` cast at a shape position is an ordinary column of the row
2519        // tuple, so it needs the pointer's own name and position like any
2520        // other leaf. `JsonScalar` is the *root-only* form, where the result
2521        // column itself is the value and there is no pointer to name — it
2522        // carries neither field, and `free_item_shape` is what selects it.
2523        IrExpr::TypeCast(c) if c.pg_type == "jsonb" => ShapeNode::Scalar {
2524            name: name.to_string(),
2525            position,
2526        },
2527        IrExpr::NamedTuple { fields, is_free_object } => ShapeNode::NamedTuple {
2528            name: name.to_string(),
2529            position,
2530            type_name: None,
2531            // A literal names its own members — `(amount := 9.99, note :=
2532            // 'x')` says what they are called and what each one holds. With
2533            // no member plan a reader has only the raw jsonb to go on, so
2534            // the value hydrated as a plain dict rather than a named tuple,
2535            // and the web UI, which renders `(key := value, …)` from this
2536            // list, had nothing to render and showed `()`.
2537            //
2538            // A free object (`{ a := 1 }`) is left without one: it is not a
2539            // tuple to its reader, and a member plan would hydrate it as a
2540            // named-tuple value instead of the mapping it reads as today.
2541            members: (!*is_free_object).then(|| {
2542                fields
2543                    .iter()
2544                    .map(|(key, value)| literal_tuple_member(key, value))
2545                    .collect()
2546            }),
2547            is_free_object: *is_free_object,
2548        },
2549        // A path select aggregated into an array keeps its rows' own shape, so
2550        // the elements hydrate as objects rather than as opaque scalars.
2551        IrExpr::ArrayFromSelect(source) => match source.as_ref() {
2552            IrArraySource::ObjectFunction(fs) => {
2553                let (_, nodes) = build_shape(&fs.shape, &fs.alias);
2554                ShapeNode::Array {
2555                    name: name.to_string(),
2556                    position,
2557                    element: Box::new(ShapeNode::Object {
2558                        name: String::new(),
2559                        type_name: Some(fs.type_name.clone()),
2560                        position: 0,
2561                        cardinality: Cardinality::Many,
2562                        pointers: prepend_type(nodes),
2563                        has_implicit_id: shape_has_implicit_id(&fs.shape),
2564                    }),
2565                }
2566            }
2567            IrArraySource::Group(grp) => ShapeNode::Array {
2568                name: name.to_string(),
2569                position,
2570                element: Box::new(emit_group(grp).shape.root),
2571            },
2572            IrArraySource::ObjectSelect(sel) => {
2573                let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2574                    unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2575                };
2576                let (_, nodes) = build_shape(shape, &source.alias);
2577                ShapeNode::Array {
2578                    name: name.to_string(),
2579                    position,
2580                    element: Box::new(ShapeNode::Object {
2581                        name: String::new(),
2582                        type_name: Some(source.type_name.clone()),
2583                        position: 0,
2584                        cardinality: Cardinality::Many,
2585                        pointers: prepend_type(nodes),
2586                        has_implicit_id: shape_has_implicit_id(shape),
2587                    }),
2588                }
2589            }
2590            IrArraySource::PathSelect(ps) => match &ps.result {
2591                IrPathResult::Object {
2592                    alias,
2593                    type_name,
2594                    shape,
2595                } => {
2596                    let (_, pointer_nodes) = build_shape(shape, alias);
2597                    ShapeNode::Array {
2598                        name: name.to_string(),
2599                        position,
2600                        element: Box::new(ShapeNode::Object {
2601                            name: String::new(),
2602                            type_name: Some(type_name.clone()),
2603                            position: 0,
2604                            cardinality: Cardinality::Many,
2605                            pointers: prepend_type(pointer_nodes),
2606                            has_implicit_id: shape_has_implicit_id(shape),
2607                        }),
2608                    }
2609                }
2610                IrPathResult::Scalar(e, tuple_shape) if yields_array(e) => ShapeNode::Array {
2611                    name: name.to_string(),
2612                    position,
2613                    element: Box::new(aggregated_array_element(e, tuple_shape.as_ref())),
2614                },
2615                IrPathResult::Scalar(..) => ShapeNode::Scalar {
2616                    name: name.to_string(),
2617                    position,
2618                },
2619            },
2620            _ => ShapeNode::Scalar {
2621                name: name.to_string(),
2622                position,
2623            },
2624        },
2625        e if is_raw_scalar(e) => ShapeNode::RawScalar,
2626        e => free_field_shape_node(name, position, e),
2627    }
2628}
2629
2630fn free_item_shape(item: &IrFreeExpr, ctes: &[IrCteDef]) -> crate::query::ShapeNode {
2631    use crate::query::{Cardinality, ShapeNode};
2632    match item {
2633        IrFreeExpr::Scalar(e) => match e {
2634            // Top level: the result column *is* the jsonb value rather than a
2635            // field inside a row tuple. A tuple-shaped cast is excluded because
2636            // `expr_shape_node` decodes that as a `NamedTuple`.
2637            IrExpr::TypeCast(c) if c.pg_type == "jsonb" && c.tuple_shape.is_none() => ShapeNode::JsonScalar,
2638            _ => expr_shape_node("", 0, e),
2639        },
2640        IrFreeExpr::FreeObject(fields) => ShapeNode::Object {
2641            name: String::new(),
2642            type_name: None,
2643            position: 0,
2644            cardinality: Cardinality::Many,
2645            pointers: fields
2646                .iter()
2647                .enumerate()
2648                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2649                .collect(),
2650            has_implicit_id: false,
2651        },
2652        IrFreeExpr::Tuple(exprs) => ShapeNode::Tuple {
2653            position: 0,
2654            elements: exprs
2655                .iter()
2656                .enumerate()
2657                .map(|(i, e)| free_field_shape_node("", i, e))
2658                .collect(),
2659            names: None,
2660        },
2661        IrFreeExpr::NamedTupleRow(fields) => ShapeNode::Tuple {
2662            position: 0,
2663            elements: fields
2664                .iter()
2665                .enumerate()
2666                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2667                .collect(),
2668            names: Some(fields.iter().map(|(name, _)| name.clone()).collect()),
2669        },
2670        IrFreeExpr::AssertSet { .. } => ShapeNode::Scalar {
2671            name: String::new(),
2672            position: 0,
2673        },
2674        // The referenced CTE's own `result` column carries whatever shape its
2675        // defining free row has (a bare scalar, a multi-field free object, a
2676        // tuple, or even another passthrough) — resolve it by looking the CTE
2677        // up rather than assuming Scalar, otherwise a multi-field free object
2678        // bound in a `WITH` clause decodes as just its first field's value.
2679        IrFreeExpr::CtePassthrough(name) => ctes
2680            .iter()
2681            .find(|c| &c.name == name)
2682            .and_then(|c| match &c.stmt {
2683                IrStmt::Select(sel) => match sel.rows.first() {
2684                    Some(IrRowSource::Free(inner)) => Some(free_item_shape(inner, ctes)),
2685                    _ => None,
2686                },
2687                // `with entries := Webhook.headers select entries` reads the
2688                // CTE's own `result` column, so it decodes exactly as the
2689                // path select itself does — which for an enum or a tuple is
2690                // not the plain scalar the fallback below assumes.
2691                IrStmt::PathSelect(ps) => Some(emit_path_select(ps).shape.root),
2692                _ => None,
2693            })
2694            .unwrap_or(ShapeNode::Scalar {
2695                name: String::new(),
2696                position: 0,
2697            }),
2698    }
2699}
2700
2701// ── PATH SELECT ─────────────────────────────────────────────────────────────
2702
2703fn emit_path_joins(root: &IrSource, joins: &[IrPathJoin]) -> String {
2704    let mut parts = vec![format!("{} AS {}", source_ref(root), qi(&root.alias))];
2705    for join in joins {
2706        match join {
2707            IrPathJoin::Single {
2708                source_alias,
2709                fk_col,
2710                target,
2711            } => {
2712                parts.push(format!(
2713                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2714                    source_ref(target),
2715                    qi(&target.alias),
2716                    qi(source_alias),
2717                    qi(fk_col),
2718                    qi(&target.alias),
2719                ));
2720            }
2721            IrPathJoin::Multi {
2722                source_alias,
2723                junction_alias,
2724                join,
2725                target,
2726            } => {
2727                match join {
2728                    IrMultiLinkJoin::Standard { junction_table, module } => {
2729                        parts.push(format!(
2730                            "JOIN {} AS {} ON {}.\"source\" = {}.\"id\"",
2731                            junction_ref(module, junction_table),
2732                            qi(junction_alias),
2733                            qi(junction_alias),
2734                            qi(source_alias),
2735                        ));
2736                        parts.push(format!(
2737                            "JOIN {} AS {} ON {}.\"id\" = {}.\"target\"",
2738                            source_ref(target),
2739                            qi(&target.alias),
2740                            qi(&target.alias),
2741                            qi(junction_alias),
2742                        ));
2743                    }
2744                    IrMultiLinkJoin::Through {
2745                        junction_table,
2746                        module,
2747                        source_col,
2748                        target_col,
2749                    } => {
2750                        parts.push(format!(
2751                            "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2752                            junction_ref(module, junction_table),
2753                            qi(junction_alias),
2754                            qi(junction_alias),
2755                            qi(source_col),
2756                            qi(source_alias),
2757                        ));
2758                        parts.push(format!(
2759                            "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2760                            source_ref(target),
2761                            qi(&target.alias),
2762                            qi(&target.alias),
2763                            qi(junction_alias),
2764                            qi(target_col),
2765                        ));
2766                    }
2767                    // See the identical comment in `emit_path_join_sql` above.
2768                    IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
2769                        unreachable!("a forward multi-link path step never uses a backlink join variant")
2770                    }
2771                }
2772            }
2773            IrPathJoin::BacklinkSingle {
2774                source_alias,
2775                fk_col,
2776                target,
2777            } => {
2778                parts.push(format!(
2779                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2780                    source_ref(target),
2781                    qi(&target.alias),
2782                    qi(&target.alias),
2783                    qi(fk_col),
2784                    qi(source_alias),
2785                ));
2786            }
2787            // LATERAL, not a plain join: the arguments read from the alias
2788            // the traversal has reached, which a non-lateral function call
2789            // in FROM cannot see.
2790            IrPathJoin::Function {
2791                fn_module,
2792                fn_name,
2793                args,
2794                target,
2795            } => {
2796                let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2797                parts.push(format!(
2798                    "CROSS JOIN LATERAL {}.{}({}) AS {}",
2799                    pg_schema(fn_module),
2800                    qi(fn_name),
2801                    args_sql,
2802                    qi(&target.alias),
2803                ));
2804            }
2805            IrPathJoin::Lateral { inner, target } => {
2806                let projected = match &inner.result {
2807                    IrPathResult::Object { alias, .. } => alias.clone(),
2808                    IrPathResult::Scalar(..) => {
2809                        unreachable!("a lateral step is only built for an object-valued computed")
2810                    }
2811                };
2812                let mut body = format!(
2813                    "SELECT {}.* FROM {}",
2814                    qi(&projected),
2815                    emit_path_joins(&inner.root, &inner.joins)
2816                );
2817                append_filter(&mut body, &inner.filter);
2818                append_order_by(&mut body, &inner.order_by);
2819                append_offset_limit(&mut body, &inner.offset, &inner.limit);
2820                parts.push(format!("JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias)));
2821            }
2822            IrPathJoin::BacklinkMulti {
2823                source_alias,
2824                junction_alias,
2825                junction_table,
2826                module,
2827                owner_col,
2828                current_col,
2829                target,
2830            } => {
2831                parts.push(format!(
2832                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2833                    junction_ref(module, junction_table),
2834                    qi(junction_alias),
2835                    qi(junction_alias),
2836                    qi(current_col),
2837                    qi(source_alias),
2838                ));
2839                parts.push(format!(
2840                    "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2841                    source_ref(target),
2842                    qi(&target.alias),
2843                    qi(&target.alias),
2844                    qi(junction_alias),
2845                    qi(owner_col),
2846                ));
2847            }
2848        }
2849    }
2850    parts.join("\n")
2851}
2852
2853/// Emit `ARRAY(SELECT scalar FROM source [JOINs] [WHERE filter])`.
2854fn emit_array_source(src: &IrArraySource) -> String {
2855    match src {
2856        IrArraySource::Select(s) => {
2857            // compile_subquery_to_array_source only ever constructs this
2858            // variant for a single schema-bound row.
2859            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2860                unreachable!("IrArraySource::Select is always schema-bound")
2861            };
2862            let scalar = match shape.first() {
2863                Some(IrShapePointer::Scalar(sf)) => format!("{}.{}", qi(&source.alias), qi(&sf.column)),
2864                _ => format!("{}.\"id\"", qi(&source.alias)),
2865            };
2866            let mut sql = format!("SELECT {} FROM {} AS {}", scalar, source_ref(source), qi(&source.alias));
2867            append_filter(&mut sql, &s.filter);
2868            format!("ARRAY({})", sql)
2869        }
2870        IrArraySource::ObjectFunction(fs) => {
2871            let (exprs, _) = build_shape(&fs.shape, &fs.alias);
2872            let mut parts = vec![sql_str(&fs.type_name) + "::text"];
2873            parts.extend(exprs);
2874            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2875            let mut sql = format!(
2876                "SELECT (\n    {}\n) FROM {}.{}({}) AS {}",
2877                parts.join(",\n    "),
2878                pg_schema(&fs.fn_module),
2879                qi(&fs.fn_name),
2880                args_sql,
2881                qi(&fs.alias),
2882            );
2883            append_filter(&mut sql, &fs.filter);
2884            append_order_by(&mut sql, &fs.order_by);
2885            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
2886            format!("ARRAY({})", sql)
2887        }
2888        IrArraySource::ObjectSelect(s) => {
2889            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2890                unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2891            };
2892            let (exprs, _) = build_shape(shape, &source.alias);
2893            let mut parts = vec![source_type_disc(source)];
2894            parts.extend(exprs);
2895            let mut sql = format!(
2896                "SELECT (\n    {}\n) FROM {} AS {}",
2897                parts.join(",\n    "),
2898                source_ref(source),
2899                qi(&source.alias)
2900            );
2901            append_filter(&mut sql, &s.filter);
2902            append_order_by(&mut sql, &s.order_by);
2903            append_offset_limit(&mut sql, &s.offset, &s.limit);
2904            format!("ARRAY({})", sql)
2905        }
2906        IrArraySource::Group(grp) => format!("ARRAY({})", emit_group(grp).sql),
2907        IrArraySource::StmtColumn { stmt, column } => format!(
2908            "ARRAY(SELECT {} FROM (\n{}\n) AS \"_rows\")",
2909            qi(column),
2910            emit_dml_as_cte_source(stmt),
2911        ),
2912        IrArraySource::PathSelect(ps) => {
2913            let scalar = match &ps.result {
2914                // PostgreSQL has no array of arrays, so a set of
2915                // array-valued properties cannot be accumulated into one
2916                // ("cannot accumulate arrays of different dimensionality").
2917                // Each element travels as a record of one field instead —
2918                // the same way a set of objects already does, and with every
2919                // element keeping its own column type rather than being
2920                // flattened through jsonb.
2921                IrPathResult::Scalar(e, _) if yields_array(e) => {
2922                    // Same reason `emit_scalar` casts one: a custom type's
2923                    // OID is assigned by the database and means nothing to a
2924                    // static record decode.
2925                    let cast = match e {
2926                        IrExpr::ColumnRef { pg_type, .. } => {
2927                            QualifiedPgType::of(pg_type).map(|qualified| qualified.text_cast())
2928                        }
2929                        _ => None,
2930                    };
2931                    format!("ROW({}{})", emit_expr(e), cast.unwrap_or(""))
2932                }
2933                IrPathResult::Scalar(e, _) => emit_expr(e),
2934                // Aggregating objects keeps the whole row, not the id: this is
2935                // how a computed pointer that walks through a multi-link comes
2936                // back as hydrated rows rather than a list of uuids.
2937                IrPathResult::Object {
2938                    alias,
2939                    type_name,
2940                    shape,
2941                } => {
2942                    let (pointer_exprs, _) = build_shape(shape, alias);
2943                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
2944                    parts.extend(pointer_exprs);
2945                    format!("(\n    {}\n)", parts.join(",\n    "))
2946                }
2947            };
2948            let from_sql = emit_path_joins(&ps.root, &ps.joins);
2949            let mut sql = format!(
2950                "SELECT {}{} FROM {}",
2951                if ps.distinct { "DISTINCT " } else { "" },
2952                scalar,
2953                from_sql
2954            );
2955            append_filter(&mut sql, &ps.filter);
2956            append_order_by(&mut sql, &ps.order_by);
2957            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
2958            format!("ARRAY({})", sql)
2959        }
2960        IrArraySource::RawExpr {
2961            source,
2962            poly_implementors,
2963            poly_columns,
2964            expr,
2965        } => {
2966            let from_sql = if !poly_implementors.is_empty() {
2967                format!(
2968                    "(\n{}\n) AS {}",
2969                    emit_poly_union(poly_implementors, poly_columns),
2970                    qi(&source.alias)
2971                )
2972            } else {
2973                format!("{} AS {}", source_ref(source), qi(&source.alias))
2974            };
2975            format!("ARRAY(SELECT {} FROM {})", emit_expr(expr), from_sql)
2976        }
2977    }
2978}
2979
2980/// Emit a group-key expression, casting schema-qualified enum ColumnRefs to `::text`
2981/// so they decode outside of a typed composite.
2982fn emit_key_expr(expr: &IrExpr) -> String {
2983    if let IrExpr::ColumnRef { alias, column, pg_type } = expr
2984        && let Some(qualified) = QualifiedPgType::of(pg_type)
2985    {
2986        let col_ref = if alias.is_empty() {
2987            qi(column)
2988        } else {
2989            format!("{}.{}", qi(alias), qi(column))
2990        };
2991        return format!("{}{}", col_ref, qualified.text_cast());
2992    }
2993    emit_expr(expr)
2994}
2995
2996/// The rows a group reads, as `(FROM, WHERE)`. A per-group OFFSET/LIMIT
2997/// can't be a plain LIMIT — that would cut whole groups — so the rows are
2998/// ranked within each key first and only the wanted slice kept.
2999fn group_rows(grp: &IrGroup) -> (String, Option<String>) {
3000    let alias = &grp.source.alias;
3001    if grp.limit.is_none() && grp.offset.is_none() {
3002        return (
3003            format!("{} AS {}", source_ref(&grp.source), qi(alias)),
3004            grp.filter.as_ref().map(emit_expr),
3005        );
3006    }
3007    let ranked = format!(
3008        "(SELECT {}.*, row_number() OVER (PARTITION BY {}{}) AS \"__rk\"\n    FROM {} AS {}{}) AS {}",
3009        qi(alias),
3010        group_by_sql(grp),
3011        group_element_order(grp),
3012        source_ref(&grp.source),
3013        qi(alias),
3014        grp.filter
3015            .as_ref()
3016            .map(|f| format!("\n    WHERE {}", emit_expr(f)))
3017            .unwrap_or_default(),
3018        qi(alias),
3019    );
3020    let lower = grp.offset.as_ref().map(emit_expr).unwrap_or_else(|| "0".to_string());
3021    let mut conds = vec![format!("\"__rk\" > {}", lower)];
3022    if let Some(l) = &grp.limit {
3023        conds.push(format!("\"__rk\" <= {} + {}", lower, emit_expr(l)));
3024    }
3025    (ranked, Some(conds.join(" AND ")))
3026}
3027
3028fn group_by_sql(grp: &IrGroup) -> String {
3029    grp.keys
3030        .iter()
3031        .map(|(_, key_expr)| emit_expr(key_expr))
3032        .collect::<Vec<_>>()
3033        .join(", ")
3034}
3035
3036fn group_element_order(grp: &IrGroup) -> String {
3037    if grp.order_by.is_empty() {
3038        return String::new();
3039    }
3040    let s: Vec<_> = grp.order_by.iter().map(emit_sort_clause).collect();
3041    format!(" ORDER BY {}", s.join(", "))
3042}
3043
3044/// A `for` over a group's elements used as a CTE body: the rows themselves,
3045/// so the outer query can traverse them like any object binding.
3046fn emit_group_elements_source(grp: &IrGroup) -> String {
3047    let (from_sql, cond) = group_rows(grp);
3048    let mut sql = format!("SELECT {}.*\n    FROM {}", qi(&grp.source.alias), from_sql);
3049    if let Some(cond) = cond {
3050        sql.push_str(&format!("\n    WHERE {}", cond));
3051    }
3052    sql
3053}
3054
3055fn emit_group(grp: &IrGroup) -> SqlOutput {
3056    match &grp.output {
3057        IrGroupOutput::Groups => emit_group_rows(grp),
3058        IrGroupOutput::Projection(projection) => emit_group_projection(
3059            grp,
3060            &projection.pointers,
3061            &projection.order_by,
3062            &projection.offset,
3063            &projection.limit,
3064        ),
3065        IrGroupOutput::Elements => emit_group_elements(grp),
3066    }
3067}
3068
3069fn emit_group_projection(
3070    grp: &IrGroup,
3071    pointers: &[IrShapePointer],
3072    order_by: &[IrSort],
3073    offset: &Option<IrExpr>,
3074    limit: &Option<IrExpr>,
3075) -> SqlOutput {
3076    let (exprs, nodes) = build_shape(pointers, &grp.source.alias);
3077    let mut parts = vec!["NULL::text".to_string()];
3078    parts.extend(exprs);
3079    let (from_sql, cond) = group_rows(grp);
3080    let mut sql = format!(
3081        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
3082        parts.join(",\n    "),
3083        from_sql
3084    );
3085    if let Some(cond) = cond {
3086        sql.push_str(&format!("\nWHERE {}", cond));
3087    }
3088    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
3089    if !order_by.is_empty() {
3090        let sorts: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
3091        sql.push_str(&format!("\nORDER BY {}", sorts.join(", ")));
3092    }
3093    append_offset_limit(&mut sql, offset, limit);
3094    SqlOutput {
3095        sql,
3096        shape: ShapeDescriptor {
3097            root: ShapeNode::Object {
3098                name: String::new(),
3099                type_name: None,
3100                position: 0,
3101                cardinality: Cardinality::Many,
3102                pointers: nodes,
3103                has_implicit_id: false,
3104            },
3105        },
3106        inference_plan: None,
3107    }
3108}
3109
3110fn emit_group_elements(grp: &IrGroup) -> SqlOutput {
3111    let (exprs, nodes) = build_shape(&grp.shape, &grp.source.alias);
3112    let mut parts = vec![source_type_disc(&grp.source)];
3113    parts.extend(exprs);
3114    let (from_sql, cond) = group_rows(grp);
3115    let mut sql = format!(
3116        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
3117        parts.join(",\n    "),
3118        from_sql
3119    );
3120    if let Some(cond) = cond {
3121        sql.push_str(&format!("\nWHERE {}", cond));
3122    }
3123    SqlOutput {
3124        sql,
3125        shape: ShapeDescriptor {
3126            root: ShapeNode::Object {
3127                name: String::new(),
3128                type_name: Some(grp.source.type_name.clone()),
3129                position: 0,
3130                cardinality: Cardinality::Many,
3131                pointers: prepend_type(nodes),
3132                has_implicit_id: shape_has_implicit_id(&grp.shape),
3133            },
3134        },
3135        inference_plan: None,
3136    }
3137}
3138
3139fn emit_group_rows(grp: &IrGroup) -> SqlOutput {
3140    let alias = &grp.source.alias;
3141    let (shape_exprs, shape_nodes) = build_shape(&grp.shape, alias);
3142
3143    // Build the elements ROW: type discriminator at pos 0, then shape pointers.
3144    let mut elem_row_parts = vec![source_type_disc(&grp.source)];
3145    elem_row_parts.extend(shape_exprs);
3146    let elem_row = elem_row_parts.join(",\n            ");
3147
3148    // Key positions: NULL at pos 0 (type slot), keys at 1..=N, grouping at N+1, elements at N+2.
3149    let n_keys = grp.keys.len();
3150    let grouping_pos = n_keys + 1;
3151    let elements_pos = n_keys + 2;
3152
3153    // Build key SQL expressions and ShapeNodes.
3154    let mut key_exprs_sql: Vec<String> = vec![];
3155    let mut key_nodes: Vec<ShapeNode> = vec![];
3156    for (i, (key_name, key_expr)) in grp.keys.iter().enumerate() {
3157        let pos = i + 1;
3158        if let IrExpr::ColumnRef { pg_type, .. } = key_expr
3159            && let Some(qualified) = QualifiedPgType::of(pg_type)
3160        {
3161            key_exprs_sql.push(emit_key_expr(key_expr));
3162            key_nodes.push(qualified.shape_node(key_name.clone(), pos));
3163            continue;
3164        }
3165        key_exprs_sql.push(emit_expr(key_expr));
3166        key_nodes.push(ShapeNode::Scalar {
3167            name: key_name.clone(),
3168            position: pos,
3169        });
3170    }
3171
3172    // Build the outer SELECT tuple.
3173    let mut outer_parts = vec!["NULL::text".to_string()];
3174    outer_parts.extend(key_exprs_sql.clone());
3175    let key_names_sql = grp
3176        .keys
3177        .iter()
3178        .map(|(name, _)| format!("'{}'", name))
3179        .collect::<Vec<_>>()
3180        .join(", ");
3181    outer_parts.push(format!("ARRAY[{}]::text[]", key_names_sql));
3182    outer_parts.push(format!(
3183        "array_agg(ROW(\n            {}\n        )::record{})",
3184        elem_row,
3185        group_element_order(grp)
3186    ));
3187
3188    let outer_tuple = outer_parts.join(",\n    ");
3189    let (from_sql, cond) = group_rows(grp);
3190    let mut sql = format!("SELECT (\n    {}\n) AS \"result\"\nFROM {}", outer_tuple, from_sql,);
3191    if let Some(cond) = cond {
3192        sql.push_str(&format!("\nWHERE {}", cond));
3193    }
3194    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
3195
3196    // ShapeNode for each element (Object with the selected pointers).
3197    let element_node = ShapeNode::Object {
3198        name: String::new(),
3199        type_name: Some(grp.source.type_name.clone()),
3200        position: 0,
3201        cardinality: Cardinality::Many,
3202        pointers: prepend_type(shape_nodes),
3203        has_implicit_id: shape_has_implicit_id(&grp.shape),
3204    };
3205
3206    let root = ShapeNode::Group {
3207        key_nodes,
3208        grouping_position: grouping_pos,
3209        elements_position: elements_pos,
3210        element: Box::new(element_node),
3211    };
3212
3213    SqlOutput {
3214        sql,
3215        shape: ShapeDescriptor { root },
3216        inference_plan: None,
3217    }
3218}
3219
3220fn emit_poly_union_type_only(implementors: &[IrPolyImplementor]) -> String {
3221    implementors
3222        .iter()
3223        .map(|imp| {
3224            format!(
3225                "    SELECT {}::text AS \"__type__\" FROM {}",
3226                sql_str(&imp.type_name),
3227                qn(&imp.module, &imp.table),
3228            )
3229        })
3230        .collect::<Vec<_>>()
3231        .join("\n    UNION ALL\n")
3232}
3233
3234fn emit_path_select(sel: &IrPathSelect) -> SqlOutput {
3235    let distinct = if sel.distinct { "DISTINCT " } else { "" };
3236    let from_sql = if !sel.poly_implementors.is_empty() {
3237        format!(
3238            "(\n{}\n) AS {}",
3239            emit_poly_union_type_only(&sel.poly_implementors),
3240            qi(&sel.root.alias),
3241        )
3242    } else {
3243        emit_path_joins(&sel.root, &sel.joins)
3244    };
3245
3246    let (result_expr, shape_root) = match &sel.result {
3247        IrPathResult::Scalar(ir_expr, tuple_shape) => {
3248            // Named tuples / jsonb field accesses can't be decoded inside ROW() — emit raw.
3249            let is_nt = matches!(ir_expr, IrExpr::NamedTuple { .. })
3250                || matches!(ir_expr, IrExpr::Tuple(_))
3251                || matches!(ir_expr, IrExpr::JsonbField { .. })
3252                || matches!(ir_expr, IrExpr::JsonbIndex { .. })
3253                || matches!(ir_expr, IrExpr::ColumnRef { pg_type, .. } if pg_type.starts_with("__nt__:"))
3254                || tuple_shape.is_some();
3255            // An `array<tuple<…>>` property is the one tuple-shaped value that
3256            // keeps the ROW() wrapper: the array itself is read at a position
3257            // inside the row, and only then is each element a jsonb tuple.
3258            let array_of_tuples = matches!(ir_expr, IrExpr::ColumnRef { pg_type, .. } if pg_type.ends_with("[]"));
3259            if let Some(shape) = tuple_shape.as_ref().filter(|_| array_of_tuples) {
3260                let node = ShapeNode::Array {
3261                    name: String::new(),
3262                    position: 0,
3263                    element: Box::new(ShapeNode::NamedTuple {
3264                        name: String::new(),
3265                        position: 0,
3266                        type_name: shape.type_name.clone(),
3267                        members: Some(shape.members.clone()),
3268                        is_free_object: false,
3269                    }),
3270                };
3271                (format!("ROW({}) AS result", emit_expr(ir_expr)), node)
3272            } else if is_nt {
3273                let expr_sql = format!("{} AS result", emit_expr(ir_expr));
3274                let shape = if matches!(
3275                    ir_expr,
3276                    IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::Tuple(_)
3277                ) {
3278                    ShapeNode::RawScalar
3279                } else if let Some(shape) = tuple_shape {
3280                    // A bare tuple-typed property reference (nominal or
3281                    // structural) — real member shape already resolved at
3282                    // compile time (see resolve_property_tuple_shape), same
3283                    // as a `Type { tuple_property }` shape query gets via
3284                    // emit_scalar, instead of falling back to `members: None`.
3285                    ShapeNode::NamedTuple {
3286                        name: String::new(),
3287                        position: 0,
3288                        type_name: shape.type_name.clone(),
3289                        members: Some(shape.members.clone()),
3290                        is_free_object: false,
3291                    }
3292                } else {
3293                    let type_name = match ir_expr {
3294                        IrExpr::ColumnRef { pg_type, .. } => pg_type.strip_prefix("__nt__:").map(|s| s.to_string()),
3295                        _ => None,
3296                    };
3297                    ShapeNode::NamedTuple {
3298                        name: String::new(),
3299                        position: 0,
3300                        type_name,
3301                        members: None,
3302                        is_free_object: false,
3303                    }
3304                };
3305                (expr_sql, shape)
3306            } else {
3307                // Schema-qualified types (enums, domains) have unknown OIDs inside ROW() —
3308                // cast to text so the value's shape doesn't depend on a runtime OID.
3309                if let IrExpr::ColumnRef { pg_type, .. } = ir_expr {
3310                    if let Some(qualified) = QualifiedPgType::of(pg_type) {
3311                        let expr = format!("ROW({}{}) AS result", emit_expr(ir_expr), qualified.text_cast());
3312                        let shape = qualified.shape_node(String::new(), 0);
3313                        (expr, shape)
3314                    } else {
3315                        let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3316                        (
3317                            expr,
3318                            ShapeNode::Scalar {
3319                                name: String::new(),
3320                                position: 0,
3321                            },
3322                        )
3323                    }
3324                } else {
3325                    let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3326                    (
3327                        expr,
3328                        ShapeNode::Scalar {
3329                            name: String::new(),
3330                            position: 0,
3331                        },
3332                    )
3333                }
3334            }
3335        }
3336        IrPathResult::Object {
3337            alias,
3338            type_name,
3339            shape,
3340        } => {
3341            let (pointer_exprs, pointer_nodes) = build_shape(shape, alias);
3342            let mut parts = vec![result_type_disc(sel, alias, type_name)];
3343            parts.extend(pointer_exprs);
3344            let expr = format!("(\n    {}\n) AS result", parts.join(",\n    "));
3345            let shape_root = ShapeNode::Object {
3346                name: String::new(),
3347                type_name: Some(type_name.clone()),
3348                position: 0,
3349                cardinality: Cardinality::Many,
3350                pointers: prepend_type(pointer_nodes),
3351                has_implicit_id: shape_has_implicit_id(shape),
3352            };
3353            (expr, shape_root)
3354        }
3355    };
3356
3357    let mut sql = format!("SELECT {}{}\nFROM {}", distinct, result_expr, from_sql);
3358    append_filter(&mut sql, &sel.filter);
3359    append_order_by(&mut sql, &sel.order_by);
3360    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
3361
3362    SqlOutput {
3363        sql,
3364        shape: ShapeDescriptor { root: shape_root },
3365        inference_plan: None,
3366    }
3367}
3368
3369// ── FOR LOOP ─────────────────────────────────────────────────────────────────
3370
3371/// The loop's iterator as a relation with a single column named `v`, in both
3372/// the forms the two body kinds need: one for a `FROM` clause, one for a CTE.
3373/// True for an expression PostgreSQL hands back as `jsonb`, whose value a
3374/// scalar cast has to reach into rather than render.
3375fn yields_jsonb(expr: &IrExpr) -> bool {
3376    match expr {
3377        IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::NamedTuple { .. } => true,
3378        IrExpr::TypeCast(c) => c.pg_type == "jsonb",
3379        IrExpr::ColumnRef { pg_type, .. } | IrExpr::FnParam { pg_type, .. } => pg_type == "jsonb",
3380        IrExpr::CteRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => pg_type.as_deref() == Some("jsonb"),
3381        // A call that recorded a return type answers from it, which is the
3382        // only thing that speaks for one reached through `_pylon` or a schema
3383        // module — the registry scan below only recognises an inline builtin.
3384        IrExpr::FunctionCall(f) if f.return_pg_type.is_some() => f.return_pg_type.as_deref() == Some("jsonb"),
3385        IrExpr::FunctionCall(f) if f.schema.is_none() => {
3386            let mut overloads = crate::stdlib::registry().iter().filter(|d| d.name == f.name).peekable();
3387            overloads.peek().is_some() && overloads.all(|d| matches!(d.return_type, crate::stdlib::PylonType::Json))
3388        }
3389        _ => false,
3390    }
3391}
3392
3393/// True for an expression whose value is a PostgreSQL array — mirrors
3394/// `yields_jsonb` for the other type the emitter has to know by sight.
3395fn yields_array(expr: &IrExpr) -> bool {
3396    match expr {
3397        IrExpr::Array(_) | IrExpr::ArrayFromSelect(_) => true,
3398        IrExpr::ColumnRef { pg_type, .. } | IrExpr::FnParam { pg_type, .. } => pg_type.ends_with("[]"),
3399        IrExpr::CteRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => {
3400            pg_type.as_deref().is_some_and(|ty| ty.ends_with("[]"))
3401        }
3402        IrExpr::TypeCast(cast) => cast.pg_type.ends_with("[]"),
3403        _ => false,
3404    }
3405}
3406
3407/// One element of an aggregated set of array-valued properties: the array
3408/// sits at position 0 of the one-field record it travels in (see
3409/// `IrArraySource::PathSelect`'s emission), described the same way the
3410/// property is inside an object's own row — a named tuple per element where
3411/// the declaration says so, an enum label per element for an enum array,
3412/// else the array as the driver decodes it.
3413fn aggregated_array_element(expr: &IrExpr, tuple_shape: Option<&crate::ir::TupleCastShape>) -> ShapeNode {
3414    if let Some(shape) = tuple_shape {
3415        return ShapeNode::Array {
3416            name: String::new(),
3417            position: 0,
3418            element: Box::new(ShapeNode::NamedTuple {
3419                name: String::new(),
3420                position: 0,
3421                type_name: shape.type_name.clone(),
3422                members: Some(shape.members.clone()),
3423                is_free_object: false,
3424            }),
3425        };
3426    }
3427    if let IrExpr::ColumnRef { pg_type, .. } = expr
3428        && let Some(qualified) = QualifiedPgType::of(pg_type)
3429    {
3430        return qualified.shape_node(String::new(), 0);
3431    }
3432    ShapeNode::Scalar {
3433        name: String::new(),
3434        position: 0,
3435    }
3436}
3437
3438fn emit_for_iterator(it: &IrForIterator, iter_alias: &str) -> (String, String) {
3439    match it {
3440        IrForIterator::Values { exprs, pg_type } => {
3441            let rows: Vec<String> = exprs
3442                .iter()
3443                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3444                .collect();
3445            (
3446                format!("(VALUES {}) AS {}(\"v\")", rows.join(", "), qi(iter_alias)),
3447                format!("{}(\"v\") AS (VALUES {})", qi(iter_alias), rows.join(", ")),
3448            )
3449        }
3450        IrForIterator::Query { stmt, scalar } => {
3451            let inner = emit_dml_as_cte_source(stmt);
3452            // A scalar-yielding select already exposes `v` (the same
3453            // convention a scalar `with` binding uses); an object-yielding
3454            // one exposes its raw columns, so the loop variable binds `id`.
3455            let body = if *scalar {
3456                inner
3457            } else {
3458                format!("    SELECT \"id\" AS v FROM (\n{}\n    ) AS _src", inner)
3459            };
3460            (
3461                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3462                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3463            )
3464        }
3465        IrForIterator::SetReturning { expr, pg_type } => {
3466            let body = format!("    SELECT {}::{} AS v", emit_expr(expr), pg_type);
3467            (
3468                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3469                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3470            )
3471        }
3472    }
3473}
3474
3475/// The iterator CTE of a `for` nested in another one. The enclosing iterator
3476/// goes in its FROM, so the walk it iterates can read the outer loop variable,
3477/// and its key rides along as `_outer` for the body to join back on — the bond
3478/// that holds a chain of iterator CTEs together.
3479fn emit_nested_for_iterator(it: &IrForIterator, iter_alias: &str, outer_alias: &str) -> String {
3480    const OUTER: &str = "_outer";
3481    match it {
3482        IrForIterator::Values { exprs, pg_type } => {
3483            let rows: Vec<String> = exprs
3484                .iter()
3485                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3486                .collect();
3487            format!(
3488                "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, (VALUES {}) AS \"_vals\"(\"v\")\n)",
3489                qi(iter_alias),
3490                qi(outer_alias),
3491                qi(OUTER),
3492                qi(outer_alias),
3493                rows.join(", "),
3494            )
3495        }
3496        IrForIterator::Query { stmt, scalar } => {
3497            let inner = emit_dml_as_cte_source(stmt);
3498            let projected = if *scalar { "\"_src\".\"v\"" } else { "\"_src\".\"id\"" };
3499            format!(
3500                "{} AS (\nSELECT {}.\"v\" AS {}, {} AS v\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_src\"\n)",
3501                qi(iter_alias),
3502                qi(outer_alias),
3503                qi(OUTER),
3504                projected,
3505                qi(outer_alias),
3506                inner,
3507            )
3508        }
3509        IrForIterator::SetReturning { expr, pg_type } => format!(
3510            "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, LATERAL (SELECT {}::{} AS v) AS \"_vals\"\n)",
3511            qi(iter_alias),
3512            qi(outer_alias),
3513            qi(OUTER),
3514            qi(outer_alias),
3515            emit_expr(expr),
3516            pg_type,
3517        ),
3518    }
3519}
3520
3521/// `FROM <inner iterator> JOIN <outer iterator> ON …` — both loop variables in
3522/// scope for the body, each still read as `"_for_<name>"."v"`.
3523fn nested_for_from(inner_alias: &str, outer_alias: &str) -> String {
3524    format!(
3525        "{} JOIN {} ON {}.\"v\" = {}.\"_outer\"",
3526        qi(inner_alias),
3527        qi(outer_alias),
3528        qi(outer_alias),
3529        qi(inner_alias),
3530    )
3531}
3532
3533/// Put the bindings a `for` body declared in front of the body itself: they may
3534/// read the loop variable, so they cannot sit in the enclosing WITH clause.
3535/// A `for` body's CTEs as they must read inside the LATERAL that runs the body.
3536///
3537/// `emit_user_cte_parts` keys a binding that reads the loop variable by the
3538/// iteration, joining `_for_<slot>` to carry its key — right where the binding
3539/// sits in the statement's own WITH beside the iterator CTE, as the DML forms
3540/// emit it. Inside the LATERAL there is no such relation: `_for_<slot>` is the
3541/// outer FROM item's alias, so `FROM "_for_<slot>"` is SQL PostgreSQL rejects
3542/// ("relation does not exist"), and spelling it legally would widen the binding
3543/// to every iteration's rows at once. The LATERAL has already pinned the
3544/// iteration to one row, and the correlated column reference the body carries
3545/// reads it, so the key is neither needed nor correct here.
3546fn uncorrelated_body_ctes(f: &IrFor) -> Vec<IrCteDef> {
3547    f.body_ctes
3548        .iter()
3549        .map(|cte| IrCteDef {
3550            correlated_to: None,
3551            ..cte.clone()
3552        })
3553        .collect()
3554}
3555
3556fn prefix_body_ctes(sql: String, body_ctes: &[IrCteDef]) -> String {
3557    if body_ctes.is_empty() {
3558        return sql;
3559    }
3560    merge_into_existing_with(&sql, &emit_user_cte_parts(body_ctes))
3561        .unwrap_or_else(|| format!("{}{}", emit_cte_prefix(body_ctes), sql))
3562}
3563
3564fn emit_for_stmt(f: &IrFor, user_ctes: &[IrCteDef]) -> SqlOutput {
3565    let iter_alias = format!("_for_{}", f.var_name);
3566
3567    if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
3568        let empty = SqlOutput {
3569            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
3570            shape: ShapeDescriptor {
3571                root: ShapeNode::Scalar {
3572                    name: String::new(),
3573                    position: 0,
3574                },
3575            },
3576            inference_plan: None,
3577        };
3578        return empty;
3579    }
3580
3581    let (values_from, iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
3582
3583    match f.body.as_ref() {
3584        IrStmt::Insert(ins) => emit_for_insert(ins, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3585        IrStmt::Update(upd) => emit_for_update(upd, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3586        body => {
3587            let body_out = match body {
3588                IrStmt::Select(sel) => emit_select_stmt(sel, user_ctes),
3589                IrStmt::PathSelect(sel) => emit_path_select(sel),
3590                // Unreachable: `Compiler::compile_for` rejects every other
3591                // body kind with a PyQL error before an `IrFor` is built.
3592                other => unreachable!("for-loop body should have been rejected at compile time: {other:?}"),
3593            };
3594            let indent_body = prefix_body_ctes(body_out.sql, &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
3595            let cte_prefix = if !user_ctes.is_empty() {
3596                emit_cte_prefix(user_ctes)
3597            } else {
3598                String::new()
3599            };
3600            let sql = format!(
3601                "{}SELECT \"_body\".result\nFROM {}\nCROSS JOIN LATERAL (\n    {}\n) AS \"_body\"",
3602                cte_prefix, values_from, indent_body,
3603            );
3604            SqlOutput {
3605                sql,
3606                shape: body_out.shape,
3607                inference_plan: None,
3608            }
3609        }
3610    }
3611}
3612
3613/// A `for` whose body updates: driven from the iteration with `UPDATE … FROM`,
3614/// since a LATERAL -- what the select-bodied form uses -- cannot hold DML.
3615/// Both the SET clause and the filter may read the loop variable, and both are
3616/// already compiled against the iteration alias.
3617fn emit_for_update(
3618    upd: &IrUpdate,
3619    iter_alias: &str,
3620    iter_cte: &str,
3621    user_ctes: &[IrCteDef],
3622    body_ctes: &[IrCteDef],
3623) -> SqlOutput {
3624    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3625    // The iterator first: a body CTE may read the loop variable, and a WITH
3626    // name is only in scope for the bindings that follow it.
3627    cte_parts.push(iter_cte.to_string());
3628    cte_parts.extend(emit_user_cte_parts(body_ctes));
3629
3630    let alias = &upd.target.alias;
3631    // With only junction rows to write there is nothing to SET, so the driving
3632    // rows are selected rather than updated -- the same shape a multi-link-only
3633    // update takes outside a loop.
3634    if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
3635        const ITER_COL: &str = "_iter";
3636        let ids_name = "_ids";
3637        // A nested insert gets its id generated here rather than by the column
3638        // default, so the junction below can name the row *this* iteration
3639        // inserted — `RETURNING` offers no order to pair on.
3640        let generated: Vec<Option<&IrInsert>> = upd
3641            .multi_link_appends
3642            .iter()
3643            .map(|a| per_iteration_insert(a, &upd.nested_ctes))
3644            .collect();
3645        let new_cols: String = generated
3646            .iter()
3647            .enumerate()
3648            .filter_map(|(i, ins)| {
3649                ins.map(|ins| {
3650                    format!(
3651                        ", {} AS {}",
3652                        ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3653                        qi(&format!("_new_{i}"))
3654                    )
3655                })
3656            })
3657            .collect();
3658        let mut ids_sql = format!(
3659            "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
3660            ids_name,
3661            qi(alias),
3662            qi(iter_alias),
3663            qi(ITER_COL),
3664            new_cols,
3665            target_ref(&upd.target),
3666            qi(alias),
3667            qi(iter_alias),
3668        );
3669        append_filter(&mut ids_sql, &upd.filter);
3670        ids_sql.push_str("\n)");
3671        cte_parts.push(ids_sql);
3672        for (i, append) in upd.multi_link_appends.iter().enumerate() {
3673            match generated[i] {
3674                Some(ins) => {
3675                    cte_parts.push(emit_for_nested_insert_cte(
3676                        ins,
3677                        ids_name,
3678                        iter_alias,
3679                        &format!("_ml_ins_{i}"),
3680                        &format!("_new_{i}"),
3681                    ));
3682                    cte_parts.push(emit_for_ml_append_cte(
3683                        append,
3684                        ids_name,
3685                        &format!("_ml_add_{i}"),
3686                        &format!("_new_{i}"),
3687                    ));
3688                }
3689                None => cte_parts.push(emit_for_ml_append_cte(
3690                    append,
3691                    ids_name,
3692                    &format!("_ml_add_{i}"),
3693                    ITER_COL,
3694                )),
3695            }
3696        }
3697        let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, ids_name);
3698        let mut parts = vec![type_disc(&upd.target.type_name)];
3699        parts.extend(pointer_exprs);
3700        let sql = format!(
3701            "WITH {}\nSELECT (\n    {}\n) AS result\nFROM \"{}\"",
3702            cte_parts.join(",\n"),
3703            parts.join(",\n    "),
3704            ids_name,
3705        );
3706        return SqlOutput {
3707            sql,
3708            shape: crate::query::ShapeDescriptor {
3709                root: ShapeNode::Object {
3710                    name: String::new(),
3711                    type_name: Some(upd.target.type_name.clone()),
3712                    position: 0,
3713                    cardinality: Cardinality::Many,
3714                    pointers: prepend_type(shape_nodes),
3715                    has_implicit_id: shape_has_implicit_id(&upd.returning),
3716                },
3717            },
3718            inference_plan: None,
3719        };
3720    }
3721    let sets: Vec<String> = upd
3722        .assignments
3723        .iter()
3724        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
3725        .chain(
3726            upd.rewrites
3727                .iter()
3728                .map(|rw| format!("{} = {}", qi(&rw.column), emit_expr(&rw.expr))),
3729        )
3730        .collect();
3731    if !upd.poly_implementors.is_empty() {
3732        return emit_for_poly_update(upd, iter_alias, &sets, cte_parts);
3733    }
3734    let mut sql = format!(
3735        "WITH {}\nUPDATE {} AS {}\nSET {}\nFROM {}",
3736        cte_parts.join(",\n"),
3737        target_ref(&upd.target),
3738        qi(alias),
3739        sets.join(", "),
3740        qi(iter_alias),
3741    );
3742    append_filter(&mut sql, &upd.filter);
3743    let mut parts = vec![type_disc(&upd.target.type_name)];
3744    let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, alias);
3745    parts.extend(pointer_exprs);
3746    sql.push_str(&format!("\nRETURNING (\n    {}\n) AS result", parts.join(",\n    ")));
3747    SqlOutput {
3748        sql,
3749        shape: crate::query::ShapeDescriptor {
3750            root: ShapeNode::Object {
3751                name: String::new(),
3752                type_name: Some(upd.target.type_name.clone()),
3753                position: 0,
3754                cardinality: Cardinality::Many,
3755                pointers: prepend_type(shape_nodes),
3756                has_implicit_id: shape_has_implicit_id(&upd.returning),
3757            },
3758        },
3759        inference_plan: None,
3760    }
3761}
3762
3763/// The iteration key a correlated nested CTE carries, so rows written per
3764/// iteration can be paired back with the row that iteration wrote. `RETURNING`
3765/// can only name the inserted row's own columns, so the pairing is computed in
3766/// a staging CTE beside the insert rather than read back out of it.
3767const OUTER_KEY: &str = "_outer";
3768
3769/// The iteration key a correlated `with` binding carries, named after the
3770/// binding itself. A binding that reads another one re-exports that one's key
3771/// alongside its own, so a single shared name would leave two columns under
3772/// it and make every read of either ambiguous.
3773fn binding_outer_key(cte_name: &str) -> String {
3774    format!("_outer__{cte_name}")
3775}
3776
3777/// `SELECT <id> AS "id", <iter>."v" AS "_outer", <value> AS "<column>", …` —
3778/// the rows one iteration-driven insert will write, with their ids generated
3779/// here rather than by the column default so a junction can name them before
3780/// the insert has run.
3781fn emit_iteration_rows_cte(ins: &IrInsert, cte_name: &str, iter_alias: &str, outer_source: &str) -> String {
3782    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3783    let mut selected = vec![
3784        format!(
3785            "{} AS {}",
3786            ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3787            qi("id")
3788        ),
3789        format!("{} AS {}", outer_source, qi(OUTER_KEY)),
3790    ];
3791    selected.extend(
3792        ins.assignments
3793            .iter()
3794            .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3795            .map(|(c, e)| format!("{} AS {}", emit_expr(e), qi(c)))
3796            .chain(
3797                ins.rewrites
3798                    .iter()
3799                    .map(|r| format!("{} AS {}", emit_expr(&r.expr), qi(&r.column))),
3800            ),
3801    );
3802    let mut sql = format!(
3803        "\"{}\" AS (\nSELECT {}\nFROM {}",
3804        cte_name,
3805        selected.join(", "),
3806        qi(iter_alias),
3807    );
3808    append_insert_guard(&mut sql, ins);
3809    sql.push_str("\n)");
3810    sql
3811}
3812
3813/// The insert that reads its rows back out of the staging CTE above.
3814fn emit_insert_from_rows_cte(ins: &IrInsert, cte_name: &str, rows_name: &str) -> String {
3815    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3816    let cols: Vec<String> = std::iter::once(qi("id"))
3817        .chain(
3818            ins.assignments
3819                .iter()
3820                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3821                .map(|(c, _)| qi(c)),
3822        )
3823        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3824        .collect();
3825    let mut sql = format!(
3826        "INSERT INTO {} ({})\nSELECT {} FROM \"{}\"",
3827        target_ref(&ins.target),
3828        cols.join(", "),
3829        cols.join(", "),
3830        rows_name,
3831    );
3832    if let Some(conflict) = &ins.unless_conflict {
3833        emit_conflict(&mut sql, conflict);
3834    }
3835    sql.push_str("\nRETURNING *");
3836    format!("\"{}\" AS (\n{}\n)", cte_name, sql)
3837}
3838
3839/// A junction append whose targets were written per iteration: pair the two
3840/// staging CTEs on the iteration key rather than cross-joining every row
3841/// written against every row that wrote one.
3842fn emit_correlated_ml_append_cte(
3843    mutation: &IrMultiLinkMutation,
3844    rows_name: &str,
3845    targets_name: &str,
3846    cte_name: &str,
3847) -> String {
3848    format!(
3849        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"_src\".{}, \"_tgt\".{} FROM \"{}\" AS \"_src\" JOIN \"{}\" AS \"_tgt\" ON \"_tgt\".{} = \"_src\".{}\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
3850        cte_name,
3851        qn(&mutation.module, &mutation.junction_table),
3852        qi(&mutation.source_col),
3853        qi(&mutation.target_col),
3854        qi("id"),
3855        qi("id"),
3856        rows_name,
3857        targets_name,
3858        qi(OUTER_KEY),
3859        qi(OUTER_KEY),
3860        qi(&mutation.source_col),
3861        qi(&mutation.target_col),
3862    )
3863}
3864
3865/// Which of `appends` name a CTE that wrote its rows per iteration, and so
3866/// pair on the iteration key instead of cross-joining.
3867fn correlated_append_indices(appends: &[IrMultiLinkMutation], defs: &[&[IrCteDef]]) -> Vec<usize> {
3868    appends
3869        .iter()
3870        .enumerate()
3871        .filter(|(_, append)| match &append.values.source {
3872            IrMultiLinkValueSource::CteRef(target) => defs
3873                .iter()
3874                .flat_map(|group| group.iter())
3875                .any(|c| &c.name == target && c.correlated_to.is_some()),
3876            _ => false,
3877        })
3878        .map(|(i, _)| i)
3879        .collect()
3880}
3881
3882/// `emit_for_update` for an interface target: one UPDATE per implementor,
3883/// each driven from the iteration, unioned back under a per-branch type
3884/// literal the way a standalone polymorphic update is.
3885fn emit_for_poly_update(upd: &IrUpdate, iter_alias: &str, sets: &[String], mut cte_parts: Vec<String>) -> SqlOutput {
3886    let alias = &upd.target.alias;
3887    let mut union_parts = vec![];
3888    for (i, imp) in upd.poly_implementors.iter().enumerate() {
3889        let cte_name = format!("_u{}", i);
3890        let mut upd_sql = format!(
3891            "UPDATE {} AS {}\nSET {}\nFROM {}",
3892            qn(&imp.module, &imp.table),
3893            qi(alias),
3894            sets.join(", "),
3895            qi(iter_alias),
3896        );
3897        append_filter(&mut upd_sql, &upd.filter);
3898        upd_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
3899        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
3900
3901        let r_alias = format!("_r{}", i);
3902        union_parts.push(format!(
3903            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
3904            sql_str(&imp.type_name),
3905            qi(&r_alias),
3906            cte_name,
3907            qi(&r_alias),
3908        ));
3909    }
3910    let sql = format!("WITH {}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"));
3911    let (shape, _) = emit_returning_shape(&upd.target, &upd.returning, true);
3912    SqlOutput {
3913        sql,
3914        shape,
3915        inference_plan: None,
3916    }
3917}
3918
3919fn emit_for_insert(
3920    ins: &IrInsert,
3921    iter_alias: &str,
3922    iter_cte: &str,
3923    user_ctes: &[IrCteDef],
3924    body_ctes: &[IrCteDef],
3925) -> SqlOutput {
3926    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3927
3928    let cols: Vec<String> = ins
3929        .assignments
3930        .iter()
3931        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3932        .map(|(c, _)| qi(c))
3933        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3934        .collect();
3935    let sel_exprs: Vec<String> = ins
3936        .assignments
3937        .iter()
3938        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3939        .map(|(_, e)| emit_expr(e))
3940        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
3941        .collect();
3942
3943    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3944    // The iterator first: a body CTE, and any DML nested in the row's values,
3945    // may read the loop variable, and a WITH name is only in scope for the
3946    // bindings that follow it.
3947    cte_parts.push(iter_cte.to_string());
3948    cte_parts.extend(emit_user_cte_parts(body_ctes));
3949    cte_parts.extend(emit_user_cte_parts(&ins.nested_ctes));
3950    // A nested loop that carried the iteration key back means this insert's
3951    // own rows need one too, to pair with.
3952    let correlated_targets = correlated_append_indices(&ins.multi_link_appends, &[body_ctes, &ins.nested_ctes]);
3953
3954    let mut insert_sql = format!(
3955        "INSERT INTO {} ({})\nSELECT {} FROM {}",
3956        target_ref(&ins.target),
3957        cols.join(", "),
3958        sel_exprs.join(", "),
3959        qi(iter_alias),
3960    );
3961    append_insert_guard(&mut insert_sql, ins);
3962    if let Some(conflict) = &ins.unless_conflict {
3963        emit_conflict(&mut insert_sql, conflict);
3964    }
3965    let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
3966
3967    if ins.multi_link_appends.is_empty() {
3968        let mut sql = format!("WITH {}\n{}", cte_parts.join(",\n"), insert_sql);
3969        if let Some(r) = returning_sql {
3970            sql.push_str(&r);
3971        }
3972        return SqlOutput {
3973            sql,
3974            shape,
3975            inference_plan: None,
3976        };
3977    }
3978
3979    // A junction row needs the id of the row this iteration just wrote, so
3980    // the insert becomes a CTE the junction inserts read back from.
3981    const IDS: &str = "_for_dml__ids";
3982    const ROWS: &str = "_for_dml__rows";
3983    if correlated_targets.is_empty() {
3984        insert_sql.push_str("\nRETURNING *");
3985        cte_parts.push(format!("\"{}\" AS (\n{}\n)", IDS, insert_sql));
3986    } else {
3987        let outer_source = format!("{}.{}", qi(iter_alias), qi("v"));
3988        cte_parts.push(emit_iteration_rows_cte(ins, ROWS, iter_alias, &outer_source));
3989        cte_parts.push(emit_insert_from_rows_cte(ins, IDS, ROWS));
3990    }
3991    for (i, append) in ins.multi_link_appends.iter().enumerate() {
3992        let cte_name = format!("_for_dml__ml_add_{}", i);
3993        if correlated_targets.contains(&i) {
3994            let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
3995                unreachable!("only a CTE-backed value can carry an iteration key")
3996            };
3997            cte_parts.push(emit_correlated_ml_append_cte(append, ROWS, target, &cte_name));
3998        } else {
3999            cte_parts.push(emit_ml_append_cte(append, IDS, &ins.target.alias, &cte_name));
4000        }
4001    }
4002
4003    let projection = match &returning_sql {
4004        // `emit_returning_shape` spells the projection as a RETURNING clause;
4005        // read off a CTE it is the same tuple in a SELECT list.
4006        Some(r) => r.trim_start_matches('\n').replacen("RETURNING ", "SELECT ", 1),
4007        None => "SELECT 1".to_string(),
4008    };
4009    SqlOutput {
4010        sql: format!("WITH {}\n{}\nFROM \"{}\"", cte_parts.join(",\n"), projection, IDS),
4011        shape,
4012        inference_plan: None,
4013    }
4014}
4015
4016// ── INSERT ──────────────────────────────────────────────────────────────────
4017
4018// ── Vector index enqueue helpers ─────────────────────────────────────────────
4019
4020/// Build one `"_eqN" AS (INSERT INTO _pylon."IndexOutbox" ...)` CTE string.
4021fn enqueue_cte_sql(eq: &VectorEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
4022    let index_name_sql = match &eq.index_name {
4023        None => "NULL".to_string(),
4024        Some(name) => sql_str(name),
4025    };
4026    format!(
4027        concat!(
4028            "\"{}\" AS (\n",
4029            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
4030            "        (object_id, type_name, index_kind, index_name)\n",
4031            "    SELECT \"id\", {}, 'Vector'::_pylon.\"IndexKind\", {}\n",
4032            "    FROM \"{}\"\n",
4033            "    ON CONFLICT (object_id, index_kind, index_name)\n",
4034            "    DO UPDATE SET status = 'Pending', enqueued_at = now()\n",
4035            ")",
4036        ),
4037        cte_name,
4038        sql_str(&eq.type_name),
4039        index_name_sql,
4040        source_cte,
4041    )
4042}
4043
4044/// Build the full list of enqueue CTE strings for a set of vector indexes.
4045fn enqueue_ctes(enqueue: &[VectorEnqueueInfo], source_cte: &str) -> Vec<String> {
4046    enqueue
4047        .iter()
4048        .enumerate()
4049        .map(|(i, eq)| enqueue_cte_sql(eq, source_cte, &format!("_eq{}", i)))
4050        .collect()
4051}
4052
4053/// The `_pylon."IndexOutbox".index_kind` enum label for a search backend —
4054/// `Postgres`-backed indexes never reach this (see `collect_search_enqueue`),
4055/// so there's no corresponding `IndexKind` value for that variant.
4056fn search_backend_index_kind(backend: &crate::schema::SearchBackend) -> &'static str {
4057    match backend {
4058        crate::schema::SearchBackend::OpenSearch => "OpenSearch",
4059        crate::schema::SearchBackend::Meilisearch => "Meilisearch",
4060        crate::schema::SearchBackend::Postgres => {
4061            unreachable!("Postgres-backed search indexes are never collected into SearchEnqueueInfo")
4062        }
4063    }
4064}
4065
4066/// Build one OpenSearch/Meilisearch outbox CTE string.
4067fn enqueue_search_cte_sql(eq: &SearchEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
4068    let index_name_sql = match &eq.index_name {
4069        None => "NULL".to_string(),
4070        Some(name) => sql_str(name),
4071    };
4072    format!(
4073        concat!(
4074            "\"{}\" AS (\n",
4075            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
4076            "        (object_id, type_name, index_kind, index_name, operation)\n",
4077            "    SELECT \"id\", {}, '{}'::_pylon.\"IndexKind\", {}, {}\n",
4078            "    FROM \"{}\"\n",
4079            "    ON CONFLICT (object_id, index_kind, index_name)\n",
4080            "    DO UPDATE SET status = 'Pending', operation = EXCLUDED.operation, enqueued_at = now()\n",
4081            ")",
4082        ),
4083        cte_name,
4084        sql_str(&eq.type_name),
4085        search_backend_index_kind(&eq.backend),
4086        index_name_sql,
4087        sql_str(eq.operation),
4088        source_cte,
4089    )
4090}
4091
4092/// Build the full list of OpenSearch/Meilisearch enqueue CTE strings.
4093fn enqueue_search_ctes(enqueue: &[SearchEnqueueInfo], source_cte: &str, offset: usize) -> Vec<String> {
4094    enqueue
4095        .iter()
4096        .enumerate()
4097        .map(|(i, eq)| enqueue_search_cte_sql(eq, source_cte, &format!("_es{}", offset + i)))
4098        .collect()
4099}
4100
4101/// Build `SELECT (...) AS result FROM "cte_name"` plus its ShapeDescriptor,
4102/// mirroring `emit_returning_shape` but for the CTE-wrapper SELECT path.
4103fn shape_select_from_cte(
4104    target: &IrSource,
4105    returning: &[IrShapePointer],
4106    cte_name: &str,
4107) -> (ShapeDescriptor, Option<String>) {
4108    if returning.is_empty() {
4109        return (
4110            ShapeDescriptor {
4111                root: ShapeNode::Scalar {
4112                    name: String::new(),
4113                    position: 0,
4114                },
4115            },
4116            None,
4117        );
4118    }
4119    let (pointer_exprs, shape_pointers) = build_shape(returning, "");
4120    let mut parts = vec![type_disc(&target.type_name)];
4121    parts.extend(pointer_exprs);
4122    let tuple = parts.join(",\n    ");
4123    let sql = format!("SELECT (\n    {}\n) AS result\nFROM {}", tuple, qi(cte_name));
4124    let root_pointers = prepend_type(shape_pointers);
4125    let shape = ShapeDescriptor {
4126        root: ShapeNode::Object {
4127            name: String::new(),
4128            type_name: Some(target.type_name.clone()),
4129            position: 0,
4130            cardinality: Cardinality::Required,
4131            pointers: root_pointers,
4132            has_implicit_id: shape_has_implicit_id(returning),
4133        },
4134    };
4135    (shape, Some(sql))
4136}
4137
4138/// Builds the base `INSERT INTO t (cols) VALUES (...)` row source — or,
4139/// when `ins.nested_ctes` is non-empty, `INSERT INTO t (cols) SELECT ...
4140/// FROM cte1, cte2, ...` instead, since a VALUES list can't reference a
4141/// CTE's columns (needed when a link's value is sourced from a hoisted
4142/// nested INSERT/UPDATE/DELETE — see `IrInsert::nested_ctes`). Each nested
4143/// CTE is guaranteed exactly one row, so an implicit cross join is safe.
4144/// Shared by the plain wrap path and the multi-link junction wrap path
4145/// below, both of which need this same VALUES/SELECT choice.
4146fn emit_insert_row_sql(ins: &IrInsert) -> String {
4147    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
4148    let cols: Vec<String> = ins
4149        .assignments
4150        .iter()
4151        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
4152        .map(|(c, _)| qi(c))
4153        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
4154        .collect();
4155    let vals: Vec<String> = ins
4156        .assignments
4157        .iter()
4158        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
4159        .map(|(_, e)| emit_expr(e))
4160        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
4161        .collect();
4162    // A guarded insert writes nothing when its condition is false, so the row
4163    // comes from a `SELECT` that returns none -- `VALUES` always yields one.
4164    if let Some(guard) = &ins.guard {
4165        let from_ctes = nested_cte_from(&ins.nested_ctes, &vals.join(","), " ");
4166        let projection = if cols.is_empty() {
4167            String::new()
4168        } else {
4169            format!(" {}", vals.join(", "))
4170        };
4171        let column_list = if cols.is_empty() {
4172            String::new()
4173        } else {
4174            format!(" ({})", cols.join(", "))
4175        };
4176        return format!(
4177            "INSERT INTO {}{} SELECT{}{} WHERE {}",
4178            target_ref(&ins.target),
4179            column_list,
4180            projection,
4181            from_ctes,
4182            emit_expr(guard),
4183        );
4184    }
4185    if cols.is_empty() && ins.nested_ctes.is_empty() {
4186        // `insert Preferences {}` — a row made entirely of its own defaults.
4187        // An empty column list is not SQL; `DEFAULT VALUES` is how it is said.
4188        format!("INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
4189    } else if ins.nested_ctes.is_empty() {
4190        format!(
4191            "INSERT INTO {} ({}) VALUES ({})",
4192            target_ref(&ins.target),
4193            cols.join(", "),
4194            vals.join(", ")
4195        )
4196    } else {
4197        format!(
4198            "INSERT INTO {} ({}) SELECT {}{}",
4199            target_ref(&ins.target),
4200            cols.join(", "),
4201            vals.join(", "),
4202            nested_cte_from(&ins.nested_ctes, &vals.join(","), " "),
4203        )
4204    }
4205}
4206
4207fn emit_insert_stmt(ins: &IrInsert) -> SqlOutput {
4208    if ins.enqueue_vector.is_empty()
4209        && ins.enqueue_search.is_empty()
4210        && !insert_has_any_multilink(ins)
4211        && ins.nested_ctes.is_empty()
4212    {
4213        let mut sql = emit_insert_row_sql(ins);
4214        if let Some(conflict) = &ins.unless_conflict {
4215            emit_conflict(&mut sql, conflict);
4216        }
4217        let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
4218        if let Some(r) = returning_sql {
4219            sql.push_str(&r);
4220        }
4221        return SqlOutput {
4222            sql,
4223            shape,
4224            inference_plan: None,
4225        };
4226    }
4227
4228    // Wrap path: needed for outbox enqueue CTEs, junction-table population
4229    // (see emit_insert_multilink_ctes), and/or hoisted nested-DML CTEs (see
4230    // IrInsert::nested_ctes) — all of which require either the row's own id
4231    // or a WITH prefix, so the plain single-statement INSERT above can't be
4232    // used.
4233    let mut cte_parts = if insert_has_any_multilink(ins) {
4234        emit_insert_multilink_ctes(ins, "_w")
4235    } else {
4236        let mut cte_parts = emit_user_cte_parts(&ins.nested_ctes);
4237        let mut insert_sql = emit_insert_row_sql(ins);
4238        if let Some(conflict) = &ins.unless_conflict {
4239            emit_conflict(&mut insert_sql, conflict);
4240        }
4241        insert_sql.push_str("\nRETURNING \"id\"");
4242        cte_parts.push(format!("\"_w\" AS (\n{}\n)", insert_sql));
4243        cte_parts
4244    };
4245    cte_parts.extend(enqueue_ctes(&ins.enqueue_vector, "_w"));
4246    cte_parts.extend(enqueue_search_ctes(&ins.enqueue_search, "_w", ins.enqueue_vector.len()));
4247
4248    let (shape, select_sql) = shape_select_from_cte(&ins.target, &ins.returning, "_w");
4249    let sql = format!(
4250        "WITH\n{}\n{}",
4251        cte_parts.join(",\n"),
4252        select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4253    );
4254    SqlOutput {
4255        sql,
4256        shape,
4257        inference_plan: None,
4258    }
4259}
4260
4261// ── UPDATE ──────────────────────────────────────────────────────────────────
4262
4263fn emit_poly_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4264    let alias = &upd.target.alias;
4265    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4266
4267    // Nested-DML CTEs first — a single hoisted CTE (e.g. a nested INSERT)
4268    // is materialized once and safely referenced from every per-implementor
4269    // branch below via its own FROM clause; at most one branch's filter
4270    // will ever actually match a row, since each row belongs to exactly
4271    // one concrete implementor table.
4272    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4273    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4274    let mut union_parts = vec![];
4275    let from_ctes = nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n");
4276
4277    let has_any_multilink = !upd.multi_link_clears.is_empty()
4278        || !upd.multi_link_replaces.is_empty()
4279        || !upd.multi_link_appends.is_empty()
4280        || !upd.multi_link_removals.is_empty();
4281    for (i, imp) in upd.poly_implementors.iter().enumerate() {
4282        let cte_name = format!("_u{}", i);
4283        // Junction rows live in each implementor's own junction table, so
4284        // with any to write each implementor is updated as the concrete type
4285        // it is.
4286        if has_any_multilink {
4287            cte_parts.extend(emit_update_multilink_cte_parts(
4288                &concrete_poly_update(upd, imp),
4289                &cte_name,
4290                false,
4291            ));
4292        } else {
4293            let mut upd_sql = format!(
4294                "UPDATE {} AS {}\nSET {}{}",
4295                qn(&imp.module, &imp.table),
4296                qi(alias),
4297                sets.join(", "),
4298                from_ctes,
4299            );
4300            append_filter(&mut upd_sql, &upd.filter);
4301            upd_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4302            cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
4303        }
4304
4305        let r_alias = format!("_r{}", i);
4306        union_parts.push(format!(
4307            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4308            sql_str(&imp.type_name),
4309            qi(&r_alias),
4310            cte_name,
4311            qi(&r_alias),
4312        ));
4313    }
4314
4315    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4316
4317    let (shape, _) = emit_returning_shape(&upd.target, &upd.returning, true);
4318    SqlOutput {
4319        sql,
4320        shape,
4321        inference_plan: None,
4322    }
4323}
4324
4325fn emit_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4326    if !upd.poly_implementors.is_empty() {
4327        return emit_poly_update_stmt(upd, user_ctes);
4328    }
4329    let alias = &upd.target.alias;
4330    let (shape, returning_sql) = emit_returning_shape(&upd.target, &upd.returning, true);
4331
4332    let has_any_multilink = !upd.multi_link_clears.is_empty()
4333        || !upd.multi_link_replaces.is_empty()
4334        || !upd.multi_link_appends.is_empty()
4335        || !upd.multi_link_removals.is_empty();
4336
4337    if !has_any_multilink && upd.enqueue_vector.is_empty() && upd.enqueue_search.is_empty() {
4338        // No junction changes, no enqueue — plain UPDATE (possibly with a
4339        // user CTE prefix and/or hoisted nested-DML CTEs from a link value
4340        // like `author := (select (insert Person {...}) { id })` — see
4341        // IrUpdate::nested_ctes). A nested CTE's `id` column is referenced
4342        // from the SET clause, which needs a `FROM` clause to see it —
4343        // ordinary `UPDATE ... SET ...` has no FROM of its own otherwise.
4344        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4345        let mut sql = format!(
4346            "UPDATE {} AS {}\nSET {}",
4347            target_ref(&upd.target),
4348            qi(alias),
4349            sets.join(", "),
4350        );
4351        sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4352        append_filter(&mut sql, &upd.filter);
4353        if let Some(r) = returning_sql {
4354            sql.push_str(&r);
4355        }
4356        // The user's bindings first: a nested statement may read them, and a
4357        // CTE only sees the ones before it.
4358        let combined_ctes: Vec<IrCteDef> = user_ctes
4359            .iter()
4360            .cloned()
4361            .chain(upd.nested_ctes.iter().cloned())
4362            .collect();
4363        if !combined_ctes.is_empty() {
4364            sql = format!("{}{}", emit_cte_prefix(&combined_ctes), sql);
4365        }
4366        return SqlOutput {
4367            sql,
4368            shape,
4369            inference_plan: None,
4370        };
4371    }
4372
4373    if !has_any_multilink && (!upd.enqueue_vector.is_empty() || !upd.enqueue_search.is_empty()) {
4374        // No junction changes but need to enqueue — wrap UPDATE in a CTE.
4375        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4376        let mut upd_sql = format!(
4377            "    UPDATE {} AS {}\n    SET {}",
4378            target_ref(&upd.target),
4379            qi(alias),
4380            sets.join(", "),
4381        );
4382        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
4383        append_filter(&mut upd_sql, &upd.filter);
4384        upd_sql.push_str("\n    RETURNING \"id\"");
4385
4386        let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4387        cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4388        cte_parts.push(format!("\"_w\" AS (\n{}\n)", upd_sql));
4389        cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_w"));
4390        cte_parts.extend(enqueue_search_ctes(&upd.enqueue_search, "_w", upd.enqueue_vector.len()));
4391
4392        let (shape2, select_sql) = shape_select_from_cte(&upd.target, &upd.returning, "_w");
4393        let sql = format!(
4394            "WITH\n{}\n{}",
4395            cte_parts.join(",\n"),
4396            select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4397        );
4398        return SqlOutput {
4399            sql,
4400            shape: shape2,
4401            inference_plan: None,
4402        };
4403    }
4404
4405    // CTE-based UPDATE for junction table mutations.
4406    let result_expr = if !upd.returning.is_empty() {
4407        let (pointer_exprs, _) = build_shape(&upd.returning, alias);
4408        let mut parts = vec![type_disc(&upd.target.type_name)];
4409        parts.extend(pointer_exprs);
4410        parts.join(",\n    ")
4411    } else {
4412        format!("{}.id", qi(alias))
4413    };
4414
4415    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
4416
4417    // Nested-DML CTEs first (see IrUpdate::nested_ctes) — only relevant when
4418    // has_scalar_changes, since nested_ctes is only ever populated while
4419    // compiling scalar assignments, which is exactly the condition below.
4420    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4421    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4422
4423    // _ids: the target rows (updated or selected).
4424    if has_scalar_changes {
4425        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4426        let mut upd_sql = format!(
4427            "UPDATE {} AS {}\nSET {}",
4428            target_ref(&upd.target),
4429            qi(alias),
4430            sets.join(", "),
4431        );
4432        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4433        append_filter(&mut upd_sql, &upd.filter);
4434        upd_sql.push_str("\nRETURNING *");
4435        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", upd_sql));
4436    } else {
4437        let mut sel = format!(
4438            "SELECT {}.* FROM {} AS {}",
4439            qi(alias),
4440            target_ref(&upd.target),
4441            qi(alias),
4442        );
4443        append_filter(&mut sel, &upd.filter);
4444        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", sel));
4445    }
4446
4447    // Junction clears (`:= {}` and `:= expr` — the clear part of replace).
4448    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
4449        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
4450        let del = format!(
4451            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"_ids\"){}",
4452            qn(&clr.module, &clr.junction_table),
4453            qi(&clr.source_col),
4454            exclude,
4455        );
4456        cte_parts.push(format!("\"_clr_{}\" AS (\n{}\n)", i, del));
4457    }
4458
4459    // Junction appends (`+=`).
4460    for (i, app) in upd.multi_link_appends.iter().enumerate() {
4461        cte_parts.push(emit_ml_append_cte(
4462            app,
4463            "_ids",
4464            &upd.target.alias,
4465            &format!("_ml_add_{}", i),
4466        ));
4467    }
4468
4469    // Junction removals (`-=`).
4470    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
4471        cte_parts.push(emit_ml_remove_cte(
4472            rem,
4473            "_ids",
4474            &upd.target.alias,
4475            &format!("_ml_rm_{}", i),
4476        ));
4477    }
4478
4479    // Junction inserts for replace (`:= expr` — insert after the clear).
4480    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
4481        cte_parts.push(emit_ml_append_cte(
4482            rep,
4483            "_ids",
4484            &upd.target.alias,
4485            &format!("_ml_rep_{}", i),
4486        ));
4487    }
4488
4489    // Enqueue CTEs (source is _ids which has all columns including id).
4490    cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_ids"));
4491    cte_parts.extend(enqueue_search_ctes(
4492        &upd.enqueue_search,
4493        "_ids",
4494        upd.enqueue_vector.len(),
4495    ));
4496
4497    let sql = format!(
4498        "WITH\n{}\nSELECT (\n    {}\n) AS result\nFROM \"_ids\" AS {}",
4499        cte_parts.join(",\n"),
4500        result_expr,
4501        qi(alias),
4502    );
4503    SqlOutput {
4504        sql,
4505        shape,
4506        inference_plan: None,
4507    }
4508}
4509
4510// ── DELETE ──────────────────────────────────────────────────────────────────
4511
4512fn emit_delete_stmt(del: &IrDelete) -> SqlOutput {
4513    if !del.poly_implementors.is_empty() {
4514        return emit_poly_delete_stmt(del);
4515    }
4516    let alias = &del.target.alias;
4517
4518    if del.enqueue_search.is_empty() {
4519        let mut sql = format!("DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4520        append_filter(&mut sql, &del.filter);
4521        let (shape, returning_sql) = emit_returning_shape(&del.target, &del.returning, true);
4522        if let Some(r) = returning_sql {
4523            sql.push_str(&r);
4524        }
4525        return SqlOutput {
4526            sql,
4527            shape,
4528            inference_plan: None,
4529        };
4530    }
4531
4532    // Wrap DELETE in a CTE to enqueue OpenSearch delete jobs.
4533    let mut del_sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4534    append_filter(&mut del_sql, &del.filter);
4535    del_sql.push_str("\n    RETURNING \"id\"");
4536
4537    let mut cte_parts = vec![format!("\"_del\" AS (\n{}\n)", del_sql)];
4538    cte_parts.extend(enqueue_search_ctes(&del.enqueue_search, "_del", 0));
4539
4540    let (shape, select_sql) = shape_select_from_cte(&del.target, &del.returning, "_del");
4541    let sql = format!(
4542        "WITH\n{}\n{}",
4543        cte_parts.join(",\n"),
4544        select_sql.unwrap_or_else(|| "SELECT * FROM \"_del\"".to_string()),
4545    );
4546    SqlOutput {
4547        sql,
4548        shape,
4549        inference_plan: None,
4550    }
4551}
4552
4553fn emit_poly_delete_stmt(del: &IrDelete) -> SqlOutput {
4554    let alias = &del.target.alias;
4555    let mut cte_parts = vec![];
4556    let mut union_parts = vec![];
4557
4558    for (i, imp) in del.poly_implementors.iter().enumerate() {
4559        let cte_name = format!("_d{}", i);
4560        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
4561        append_filter(&mut del_sql, &del.filter);
4562        del_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4563        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
4564
4565        let r_alias = format!("_r{}", i);
4566        union_parts.push(format!(
4567            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4568            sql_str(&imp.type_name),
4569            qi(&r_alias),
4570            cte_name,
4571            qi(&r_alias),
4572        ));
4573    }
4574
4575    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4576
4577    let (shape, _) = emit_returning_shape(&del.target, &del.returning, true);
4578    SqlOutput {
4579        sql,
4580        shape,
4581        inference_plan: None,
4582    }
4583}
4584
4585// ── RETURNING helper ─────────────────────────────────────────────────────────
4586
4587/// Builds the RETURNING clause and ShapeDescriptor for DML.
4588/// `with_alias`: UPDATE/DELETE can use the table alias; INSERT cannot.
4589fn emit_returning_shape(
4590    target: &IrSource,
4591    returning: &[IrShapePointer],
4592    with_alias: bool,
4593) -> (ShapeDescriptor, Option<String>) {
4594    if returning.is_empty() {
4595        return (
4596            ShapeDescriptor {
4597                root: ShapeNode::Scalar {
4598                    name: String::new(),
4599                    position: 0,
4600                },
4601            },
4602            None,
4603        );
4604    }
4605
4606    let alias = if with_alias { target.alias.as_str() } else { "" };
4607    let (pointer_exprs, shape_pointers) = build_shape(returning, alias);
4608
4609    let mut parts = vec![type_disc(&target.type_name)];
4610    parts.extend(pointer_exprs);
4611    let tuple = parts.join(",\n    ");
4612    let sql = format!("\nRETURNING (\n    {}\n) AS result", tuple);
4613
4614    let root_pointers = prepend_type(shape_pointers);
4615    let shape = ShapeDescriptor {
4616        root: ShapeNode::Object {
4617            name: String::new(),
4618            type_name: Some(target.type_name.clone()),
4619            position: 0,
4620            cardinality: Cardinality::Required,
4621            pointers: root_pointers,
4622            has_implicit_id: shape_has_implicit_id(returning),
4623        },
4624    };
4625    (shape, Some(sql))
4626}
4627
4628// ── Shape emission ───────────────────────────────────────────────────────────
4629
4630fn emit_scalar_set(f: &IrScalarSetPointer, pos: usize) -> (String, ShapeNode) {
4631    let from_sql = if !f.poly_implementors.is_empty() {
4632        format!(
4633            "(\n{}\n) AS {}",
4634            emit_poly_union(&f.poly_implementors, &f.poly_columns),
4635            qi(&f.source.alias)
4636        )
4637    } else {
4638        format!("{} AS {}", source_ref(&f.source), qi(&f.source.alias))
4639    };
4640    let sql = format!(
4641        "(SELECT COALESCE(array_agg(ROW({})::record), ARRAY[]::record[]) FROM {})",
4642        emit_expr(&f.bool_expr),
4643        from_sql,
4644    );
4645    let node = ShapeNode::Array {
4646        name: f.alias.clone(),
4647        position: pos,
4648        element: Box::new(ShapeNode::Scalar {
4649            name: String::new(),
4650            position: 0,
4651        }),
4652    };
4653    (sql, node)
4654}
4655
4656/// Build SQL expressions and ShapeNodes for `pointers`, starting at position 1
4657/// (position 0 is always the type discriminator, added by the caller).
4658/// True when the compiler put an `id` at the front of this shape that the
4659/// query never asked for — see `IrScalarPointer::implicit_id`.
4660fn shape_has_implicit_id(pointers: &[IrShapePointer]) -> bool {
4661    matches!(pointers.first(), Some(IrShapePointer::Scalar(p)) if p.implicit_id)
4662}
4663
4664fn build_shape(pointers: &[IrShapePointer], table_alias: &str) -> (Vec<String>, Vec<ShapeNode>) {
4665    let mut exprs = Vec::new();
4666    let mut nodes = Vec::new();
4667
4668    for (i, pointer) in pointers.iter().enumerate() {
4669        let (sql, node) = emit_shape_pointer(pointer, table_alias, i + 1);
4670        exprs.push(sql);
4671        nodes.push(node);
4672    }
4673
4674    (exprs, nodes)
4675}
4676
4677/// A `$1`, `$2`, … template with each placeholder replaced by its argument's
4678/// SQL, in one pass over the template alone. Replacing one number at a time
4679/// over the growing string would rewrite a query parameter an argument
4680/// carries (`$5` inside the first argument, replaced again as the fifth), and
4681/// `$1` also matches the start of `$10`.
4682fn fill_template(template: &str, args: &[String]) -> String {
4683    let mut out = String::with_capacity(template.len());
4684    let mut chars = template.char_indices().peekable();
4685    while let Some((start, c)) = chars.next() {
4686        if c != '$' {
4687            out.push(c);
4688            continue;
4689        }
4690        let mut end = start + 1;
4691        while let Some((i, d)) = chars.peek().copied()
4692            && d.is_ascii_digit()
4693        {
4694            end = i + d.len_utf8();
4695            chars.next();
4696        }
4697        match template[start + 1..end].parse::<usize>() {
4698            Ok(n) if (1..=args.len()).contains(&n) => out.push_str(&args[n - 1]),
4699            _ => out.push_str(&template[start..end]),
4700        }
4701    }
4702    out
4703}
4704
4705/// `, message` for an assert given `message := …`, else nothing.
4706fn assert_message_arg(message: &Option<IrExpr>) -> String {
4707    message
4708        .as_ref()
4709        .map(|m| format!(", ({})::text", emit_expr(m)))
4710        .unwrap_or_default()
4711}
4712
4713fn emit_shape_pointer(pointer: &IrShapePointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4714    match pointer {
4715        IrShapePointer::Scalar(f) => emit_scalar(f, table_alias, pos),
4716        IrShapePointer::SingleLink(f) => emit_single_link(f, table_alias, pos),
4717        IrShapePointer::MultiLink(f) => emit_multi_link(f, table_alias, pos),
4718        IrShapePointer::Computed(f) => (emit_expr(&f.expr), expr_shape_node(&f.alias, pos, &f.expr)),
4719        IrShapePointer::ScalarSet(f) => emit_scalar_set(f, pos),
4720        IrShapePointer::Asserted(a) => {
4721            let (sql, node) = emit_shape_pointer(&a.inner, table_alias, pos);
4722            // The inner pointer aggregates rows to `record[]`, which a PL/pgSQL
4723            // function cannot take as a parameter at all -- hence the cast to
4724            // `text[]`, whose rendering compares element-for-element the same
4725            // way. Bound in a sub-select so the pointer's own subquery is
4726            // evaluated once rather than in both the check and the result, and
4727            // read through `cardinality` because the assert hands back the
4728            // array it was given.
4729            // A pointer landing on one object reads as a lone record, not an
4730            // array, so its set is re-read through `ARRAY(…)`; the unlimited
4731            // check form would otherwise fail on more than one row.
4732            let checked_set = match &a.check {
4733                Some(check) if emits_one_object(check) => {
4734                    format!("ARRAY{}", emit_shape_pointer(check, table_alias, pos).0)
4735                }
4736                Some(check) => emit_shape_pointer(check, table_alias, pos).0,
4737                None if emits_one_object(&a.inner) => format!("ARRAY{sql}"),
4738                None => "\"_a\".\"v\"".to_string(),
4739            };
4740            let checked = format!(
4741                "(SELECT \"_a\".\"v\" FROM (SELECT {sql} AS \"v\") AS \"_a\"\n                     WHERE cardinality(\"_pylon\".{}({checked_set}::text[]{})) >= 0)",
4742                qi(&a.fn_name),
4743                assert_message_arg(&a.message),
4744            );
4745            (checked, node)
4746        }
4747    }
4748}
4749
4750/// True when the pointer is emitted as a single `(SELECT …)` record rather
4751/// than an aggregated array of them.
4752fn emits_one_object(pointer: &IrShapePointer) -> bool {
4753    match pointer {
4754        IrShapePointer::SingleLink(_) => true,
4755        IrShapePointer::Computed(c) => matches!(
4756            c.expr,
4757            IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { multi: false, .. }
4758        ),
4759        IrShapePointer::Asserted(a) => emits_one_object(&a.inner),
4760        _ => false,
4761    }
4762}
4763
4764/// Convert a PostgreSQL schema-qualified type name (`"module"."TypeName"`) to
4765/// a Pylon-qualified name (`module::TypeName`).
4766fn pg_quoted_to_pylon(pg_type: &str) -> String {
4767    let inner = pg_type.trim_start_matches('"');
4768    if let Some(idx) = inner.find(r#""."#) {
4769        let module = &inner[..idx];
4770        let type_name = inner[idx + 3..].trim_end_matches('"');
4771        format!("{}::{}", module, type_name)
4772    } else {
4773        pg_type.to_string()
4774    }
4775}
4776
4777/// A schema-qualified custom type (an enum or a domain) named in a property's
4778/// `pg_type`, together with whether the column holds an array of it.
4779///
4780/// Both forms are emitted as text -- `::text` for a scalar, `::text[]` for an
4781/// array -- so a value's shape never depends on an OID the database assigns
4782/// when the migration creating the type runs. The array case has to be told
4783/// apart from the scalar one: `"access"."AuthenticationMethod"[]` starts with a
4784/// quote just as `"account"."InterfaceAppearance"` does, and reading it as a
4785/// scalar enum casts a whole array to a single `text` (`'{MagicLink}'`) and
4786/// shapes it as one enum value.
4787struct QualifiedPgType {
4788    /// Pylon-qualified name of the element type, e.g. `access::AuthenticationMethod`.
4789    name: String,
4790    is_array: bool,
4791}
4792
4793impl QualifiedPgType {
4794    fn of(pg_type: &str) -> Option<Self> {
4795        let (element, is_array) = match pg_type.strip_suffix("[]") {
4796            Some(element) => (element, true),
4797            None => (pg_type, false),
4798        };
4799        element.starts_with('"').then(|| Self {
4800            name: pg_quoted_to_pylon(element),
4801            is_array,
4802        })
4803    }
4804
4805    /// The cast that carries this value back as text.
4806    fn text_cast(&self) -> &'static str {
4807        if self.is_array { "::text[]" } else { "::text" }
4808    }
4809
4810    /// How the decoded value is shaped: a single enum, or a list of them.
4811    fn shape_node(&self, name: String, position: usize) -> ShapeNode {
4812        let element = ShapeNode::Enum {
4813            name: name.clone(),
4814            position,
4815            enum_type: self.name.clone(),
4816        };
4817        if !self.is_array {
4818            return element;
4819        }
4820        ShapeNode::Array {
4821            name,
4822            // Each element arrives as the label itself rather than as a field
4823            // of a record, which the decoders read at position 0.
4824            element: Box::new(ShapeNode::Enum {
4825                name: String::new(),
4826                position: 0,
4827                enum_type: self.name.clone(),
4828            }),
4829            position,
4830        }
4831    }
4832}
4833
4834fn emit_scalar(f: &IrScalarPointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4835    // A tuple-typed property — nominal (the `__nt__:module::Name` marker,
4836    // hydrating to the registered dataclass) or structural (`tuple_shape`,
4837    // with no registered type to name). Either way a jsonb column, or a
4838    // `jsonb[]` one holding a tuple per element for `array<tuple<…>>`.
4839    let nominal_tuple = f
4840        .pg_type
4841        .strip_suffix("[]")
4842        .unwrap_or(&f.pg_type)
4843        .strip_prefix("__nt__:");
4844    if nominal_tuple.is_some() || f.tuple_shape.is_some() {
4845        let is_array = f.pg_type.ends_with("[]");
4846        let cast = if is_array { "::jsonb[]" } else { "::jsonb" };
4847        let sql = if table_alias.is_empty() {
4848            format!("{}{}", qi(&f.column), cast)
4849        } else {
4850            format!("{}.{}{}", qi(table_alias), qi(&f.column), cast)
4851        };
4852        let tuple = ShapeNode::NamedTuple {
4853            // Inside an array the element is read at position 0 of its own
4854            // jsonb value and carries no name of its own, the way an array
4855            // of objects does.
4856            name: if is_array { String::new() } else { f.alias.clone() },
4857            position: if is_array { 0 } else { pos },
4858            type_name: nominal_tuple
4859                .map(str::to_string)
4860                .or_else(|| f.tuple_shape.as_ref().and_then(|s| s.type_name.clone())),
4861            members: f.tuple_shape.as_ref().map(|s| s.members.clone()),
4862            is_free_object: false,
4863        };
4864        let node = if is_array {
4865            ShapeNode::Array {
4866                name: f.alias.clone(),
4867                position: pos,
4868                element: Box::new(tuple),
4869            }
4870        } else {
4871            tuple
4872        };
4873        return (sql, node);
4874    }
4875    // Schema-qualified custom types (enums, domains) have runtime OIDs unknown to a static
4876    // anonymous_record_decode. Cast to text — the string label is all the decoder needs.
4877    if let Some(qualified) = QualifiedPgType::of(&f.pg_type) {
4878        let cast = qualified.text_cast();
4879        let sql = if table_alias.is_empty() {
4880            format!("{}{}", qi(&f.column), cast)
4881        } else {
4882            format!("{}.{}{}", qi(table_alias), qi(&f.column), cast)
4883        };
4884        return (sql, qualified.shape_node(f.alias.clone(), pos));
4885    }
4886    let sql = if table_alias.is_empty() {
4887        format!("{}::{}", qi(&f.column), f.pg_type)
4888    } else {
4889        format!("{}.{}::{}", qi(table_alias), qi(&f.column), f.pg_type)
4890    };
4891    (
4892        sql,
4893        ShapeNode::Scalar {
4894            name: f.alias.clone(),
4895            position: pos,
4896        },
4897    )
4898}
4899
4900fn emit_single_link(f: &IrSingleLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
4901    let sub = &f.subquery;
4902    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
4903        unreachable!("single-link subquery is always schema-bound")
4904    };
4905    let sub_alias = &source.alias;
4906
4907    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
4908    let mut parts = vec![source_type_disc(source)];
4909    parts.extend(sub_exprs);
4910
4911    // Link properties: read from the junction table alias "jt" — only ever
4912    // populated for a junction-backed link (see `IrSingleLinkPointer`'s doc
4913    // comment), which is exactly when `f.correlation` below puts "jt" in
4914    // scope.
4915    for lp in &f.link_properties {
4916        parts.push(format!("\"jt\".{}", qi(&lp.name)));
4917        let pos = sub_nodes.len() + 1;
4918        sub_nodes.push(ShapeNode::Scalar {
4919            name: format!("@{}", lp.name),
4920            position: pos,
4921        });
4922    }
4923
4924    let tuple = parts.join(",\n        ");
4925
4926    let (from_sql, mut where_parts) = match &f.correlation {
4927        IrSingleLinkCorrelation::Fk { fk_column, target_pk } => {
4928            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
4929            let cond = format!(
4930                "{}.{} = {}.{}",
4931                qi(parent_alias),
4932                qi(fk_column),
4933                qi(sub_alias),
4934                qi(target_pk),
4935            );
4936            (from, vec![cond])
4937        }
4938        // Junction-backed — same join shape a multi-link's correlated
4939        // subquery uses; cardinality-one is a schema-level invariant on the
4940        // junction table, not something this query needs to enforce itself.
4941        IrSingleLinkCorrelation::Junction { join, target_pk: _ } => match join {
4942            IrMultiLinkJoin::Standard { junction_table, module } => {
4943                let from = format!(
4944                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
4945                    junction_ref(module, junction_table),
4946                    source_ref(source),
4947                    qi(sub_alias),
4948                    qi(sub_alias),
4949                );
4950                let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
4951                (from, vec![cond])
4952            }
4953            IrMultiLinkJoin::Through {
4954                junction_table,
4955                module,
4956                source_col,
4957                target_col,
4958            } => {
4959                let from = format!(
4960                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4961                    junction_ref(module, junction_table),
4962                    source_ref(source),
4963                    qi(sub_alias),
4964                    qi(sub_alias),
4965                    qi(target_col),
4966                );
4967                let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
4968                (from, vec![cond])
4969            }
4970            IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
4971                unreachable!("a junction-backed single link's own forward join is always Standard or Through")
4972            }
4973        },
4974    };
4975    if let Some(filter) = &sub.filter {
4976        where_parts.push(emit_expr(filter));
4977    }
4978
4979    let mut sql = format!(
4980        "(SELECT (\n        {}\n    )\n    {}\n    WHERE {}",
4981        tuple,
4982        from_sql,
4983        where_parts.join(" AND "),
4984    );
4985    if !sub.order_by.is_empty() {
4986        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
4987        sql.push_str(&format!("\n    ORDER BY {}", s.join(", ")));
4988    }
4989    sql.push(')');
4990
4991    let node = ShapeNode::Object {
4992        name: f.alias.clone(),
4993        type_name: Some(source.type_name.clone()),
4994        position: pos,
4995        cardinality: Cardinality::Optional,
4996        pointers: prepend_type(sub_nodes),
4997        has_implicit_id: shape_has_implicit_id(shape),
4998    };
4999    (sql, node)
5000}
5001
5002fn emit_multi_link(f: &IrMultiLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
5003    let sub = &f.subquery;
5004    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
5005        unreachable!("multi-link subquery is always schema-bound")
5006    };
5007    let sub_alias = &source.alias;
5008
5009    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
5010    let mut row_parts = vec![source_type_disc(source)];
5011    row_parts.extend(sub_exprs);
5012
5013    // Link properties: read from the junction table alias "jt".
5014    // The ShapeNode name carries the `@` prefix so hydration stores it as
5015    // `@prop` in the object's __dict__ and the REPL displays it with `@`.
5016    for lp in &f.link_properties {
5017        row_parts.push(format!("\"jt\".{}", qi(&lp.name)));
5018        // Positions are 1-based (0 = type discriminator). sub_nodes.len() gives
5019        // the count of already-assigned positions, so the next position is len+1.
5020        let pos = sub_nodes.len() + 1;
5021        sub_nodes.push(ShapeNode::Scalar {
5022            name: format!("@{}", lp.name),
5023            position: pos,
5024        });
5025    }
5026
5027    let row = row_parts.join(",\n            ");
5028
5029    // ORDER BY inside array_agg
5030    let order_sql = if !sub.order_by.is_empty() {
5031        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
5032        format!(" ORDER BY {}", s.join(", "))
5033    } else {
5034        String::new()
5035    };
5036
5037    let (from_sql, source_cond) = match &f.join {
5038        IrMultiLinkJoin::Standard { junction_table, module } => {
5039            let from = format!(
5040                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
5041                junction_ref(module, junction_table),
5042                source_ref(source),
5043                qi(sub_alias),
5044                qi(sub_alias),
5045            );
5046            let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
5047            (from, cond)
5048        }
5049        IrMultiLinkJoin::Through {
5050            junction_table,
5051            module,
5052            source_col,
5053            target_col,
5054        } => {
5055            let from = format!(
5056                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
5057                junction_ref(module, junction_table),
5058                source_ref(source),
5059                qi(sub_alias),
5060                qi(sub_alias),
5061                qi(target_col),
5062            );
5063            let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
5064            (from, cond)
5065        }
5066        // Reverse of a single link: no junction table, the owner
5067        // (sub-select) rows are correlated directly by their own FK column.
5068        IrMultiLinkJoin::BacklinkFk { fk_col } => {
5069            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
5070            let cond = format!("{}.{} = {}.id", qi(sub_alias), qi(fk_col), qi(parent_alias));
5071            (from, cond)
5072        }
5073        // Reverse of a multi-link: same junction table shape as `Standard`/
5074        // `Through`, with the owner/current column roles swapped — the
5075        // sub-select's own rows join via `owner_col`, the outer (current)
5076        // row correlates via `current_col`.
5077        IrMultiLinkJoin::BacklinkJunction {
5078            junction_table,
5079            module,
5080            owner_col,
5081            current_col,
5082        } => {
5083            let from = format!(
5084                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
5085                junction_ref(module, junction_table),
5086                source_ref(source),
5087                qi(sub_alias),
5088                qi(sub_alias),
5089                qi(owner_col),
5090            );
5091            let cond = format!("\"jt\".{} = {}.id", qi(current_col), qi(parent_alias));
5092            (from, cond)
5093        }
5094    };
5095
5096    let mut where_parts = vec![source_cond];
5097    if let Some(filter) = &sub.filter {
5098        where_parts.push(emit_expr(filter));
5099    }
5100
5101    if f.single {
5102        let mut sql = format!(
5103            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
5104            row,
5105            from_sql,
5106            where_parts.join(" AND "),
5107        );
5108        append_order_by(&mut sql, &sub.order_by);
5109        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
5110        sql.push(')');
5111        let node = ShapeNode::Object {
5112            name: f.alias.clone(),
5113            type_name: Some(source.type_name.clone()),
5114            position: pos,
5115            cardinality: Cardinality::Optional,
5116            pointers: prepend_type(sub_nodes),
5117            has_implicit_id: shape_has_implicit_id(shape),
5118        };
5119        return (sql, node);
5120    }
5121
5122    if f.single {
5123        let mut sql = format!(
5124            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
5125            row,
5126            from_sql,
5127            where_parts.join(" AND "),
5128        );
5129        append_order_by(&mut sql, &sub.order_by);
5130        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
5131        sql.push(')');
5132        let node = ShapeNode::Object {
5133            name: f.alias.clone(),
5134            type_name: Some(source.type_name.clone()),
5135            position: pos,
5136            cardinality: Cardinality::Optional,
5137            pointers: prepend_type(sub_nodes),
5138            has_implicit_id: shape_has_implicit_id(shape),
5139        };
5140        return (sql, node);
5141    }
5142
5143    let sql = if sub.limit.is_some() || sub.offset.is_some() {
5144        // OFFSET/LIMIT cut the *rows* that go into the array, so they can't
5145        // sit next to the aggregate — the aggregate collapses them to one
5146        // row first. Select the row's parts (and any sort keys) in a derived
5147        // table that carries the modifiers, then aggregate over that.
5148        let mut cols: Vec<String> = row_parts
5149            .iter()
5150            .enumerate()
5151            .map(|(i, part)| format!("{} AS \"c{}\"", part, i))
5152            .collect();
5153        let agg_row: Vec<String> = (0..row_parts.len()).map(|i| format!("\"__lim\".\"c{}\"", i)).collect();
5154        let mut agg_order: Vec<String> = vec![];
5155        for (i, s) in sub.order_by.iter().enumerate() {
5156            cols.push(format!("{} AS \"s{}\"", emit_expr(&s.expr), i));
5157            agg_order.push(emit_sort_clause(&IrSort {
5158                expr: IrExpr::ColumnRef {
5159                    alias: "__lim".to_string(),
5160                    column: format!("s{}", i),
5161                    pg_type: String::new(),
5162                },
5163                direction: s.direction.clone(),
5164                nulls: s.nulls.clone(),
5165            }));
5166        }
5167        let mut inner = format!(
5168            "SELECT {}\n    {}\n    WHERE {}",
5169            cols.join(",\n        "),
5170            from_sql,
5171            where_parts.join(" AND "),
5172        );
5173        append_order_by(&mut inner, &sub.order_by);
5174        append_offset_limit(&mut inner, &sub.offset, &sub.limit);
5175        let agg_order_sql = if agg_order.is_empty() {
5176            String::new()
5177        } else {
5178            format!(" ORDER BY {}", agg_order.join(", "))
5179        };
5180        format!(
5181            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    FROM ({}) AS \"__lim\")",
5182            agg_row.join(",\n            "),
5183            agg_order_sql,
5184            inner,
5185        )
5186    } else {
5187        format!(
5188            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    {}\n    WHERE {})",
5189            row,
5190            order_sql,
5191            from_sql,
5192            where_parts.join(" AND "),
5193        )
5194    };
5195
5196    let node = ShapeNode::Array {
5197        name: f.alias.clone(),
5198        position: pos,
5199        element: Box::new(ShapeNode::Object {
5200            name: String::new(),
5201            type_name: Some(source.type_name.clone()),
5202            position: 0,
5203            cardinality: Cardinality::Required,
5204            pointers: prepend_type(sub_nodes),
5205            has_implicit_id: shape_has_implicit_id(shape),
5206        }),
5207    };
5208    (sql, node)
5209}
5210
5211/// Prepend `ShapeNode::Scalar { name: "__type__", position: 0 }` and shift
5212/// existing nodes' positions by 1.
5213fn prepend_type(nodes: Vec<ShapeNode>) -> Vec<ShapeNode> {
5214    let mut out = vec![ShapeNode::Scalar {
5215        name: "__type__".into(),
5216        position: 0,
5217    }];
5218    out.extend(nodes);
5219    out
5220}
5221
5222// ── SQL clause helpers ──────────────────────────────────────────────────────
5223
5224fn append_filter(sql: &mut String, filter: &Option<IrExpr>) {
5225    if let Some(f) = filter {
5226        sql.push_str(&format!("\nWHERE {}", emit_expr(f)));
5227    }
5228}
5229
5230fn append_order_by(sql: &mut String, order_by: &[IrSort]) {
5231    if !order_by.is_empty() {
5232        let s: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
5233        sql.push_str(&format!("\nORDER BY {}", s.join(", ")));
5234    }
5235}
5236
5237fn append_offset_limit(sql: &mut String, offset: &Option<IrExpr>, limit: &Option<IrExpr>) {
5238    if let Some(o) = offset {
5239        sql.push_str(&format!("\nOFFSET {}", emit_expr(o)));
5240    }
5241    if let Some(l) = limit {
5242        sql.push_str(&format!("\nLIMIT {}", emit_expr(l)));
5243    }
5244}
5245
5246/// `FOR UPDATE`/`FOR SHARE`/... — Postgres's own grammar places this last,
5247/// after `ORDER BY`/`LIMIT`/`OFFSET`, so this must be the final thing
5248/// appended to a bound SELECT's SQL. No `OF table_name` — the outer
5249/// SELECT's own aliased table is always the only lockable target (nested
5250/// shape pointers are correlated subqueries, never part of the outer FROM),
5251/// so a bare `FOR UPDATE` already targets exactly the right row(s).
5252fn append_lock_clause(sql: &mut String, lock: &Option<IrLockClause>) {
5253    let Some(lock) = lock else { return };
5254    let strength = match lock.strength {
5255        IrLockStrength::Update => "UPDATE",
5256        IrLockStrength::NoKeyUpdate => "NO KEY UPDATE",
5257        IrLockStrength::Share => "SHARE",
5258        IrLockStrength::KeyShare => "KEY SHARE",
5259    };
5260    sql.push_str(&format!("\nFOR {}", strength));
5261    match lock.wait {
5262        IrLockWait::Block => {}
5263        IrLockWait::NoWait => sql.push_str(" NOWAIT"),
5264        IrLockWait::SkipLocked => sql.push_str(" SKIP LOCKED"),
5265    }
5266}
5267
5268fn emit_sort_clause(s: &IrSort) -> String {
5269    let dir = match s.direction {
5270        IrSortDir::Asc => "ASC",
5271        IrSortDir::Desc => "DESC",
5272    };
5273    let nulls = match s.nulls {
5274        IrNulls::First => "NULLS FIRST",
5275        IrNulls::Last => "NULLS LAST",
5276    };
5277    format!("{} {} {}", emit_expr(&s.expr), dir, nulls)
5278}
5279
5280// ── Expression emission ─────────────────────────────────────────────────────
5281
5282/// One side of `a intersect b` as the rows of the set it stands for: an
5283/// array's elements, a binding's rows, else the single value.
5284fn set_operand(expr: &IrExpr) -> String {
5285    match expr {
5286        IrExpr::ArrayFromSelect(_) => format!("SELECT unnest({})", emit_expr(expr)),
5287        IrExpr::CteRef { name, scalar, .. } => {
5288            format!("SELECT {} FROM {}", if *scalar { "\"v\"" } else { "\"id\"" }, qi(name))
5289        }
5290        _ => format!("SELECT {}", emit_expr(expr)),
5291    }
5292}
5293
5294/// `emit_expr`, reading the types with subtypes through `fanouts` the way
5295/// `emit` does — see `IrOutput::subtype_fanouts`.
5296pub fn emit_expr_with_fanouts(expr: &IrExpr, fanouts: &HashMap<(String, String), IrPolyFanout>) -> String {
5297    with_subtype_fanouts(fanouts, || emit_expr(expr))
5298}
5299
5300pub fn emit_expr(expr: &IrExpr) -> String {
5301    match expr {
5302        IrExpr::ColumnRef { alias, column, .. } => {
5303            if alias.is_empty() {
5304                qi(column)
5305            } else if alias == "NEW" || alias == "OLD" {
5306                // plpgsql's trigger row variables (`ir::compile_trigger_handler`'s
5307                // `__new__`/`__old__` anchors) are recognized by the plpgsql
5308                // parser as bare, unquoted identifiers — quoting them like an
5309                // ordinary table alias (`"NEW"."col"`) turns this into an
5310                // *ordinary* SQL column reference instead, which Postgres then
5311                // rejects with "missing FROM-clause entry for table NEW" the
5312                // moment it appears inside a nested SQL command (e.g. the
5313                // VALUES list of an `INSERT` the trigger handler runs) — there's
5314                // no real `NEW` table to resolve it against there, only
5315                // plpgsql's own row-variable substitution recognizes it.
5316                format!("{alias}.{}", qi(column))
5317            } else {
5318                format!("{}.{}", qi(alias), qi(column))
5319            }
5320        }
5321        IrExpr::Param { index } => format!("${}", index + 1),
5322        IrExpr::Literal(lit) => emit_literal(lit),
5323        IrExpr::BinOp(op) => {
5324            let l = emit_expr(&op.left);
5325            let r = emit_expr(&op.right);
5326            match op.op {
5327                BinOpKind::Add => format!("({} + {})", l, r),
5328                BinOpKind::Sub => format!("({} - {})", l, r),
5329                BinOpKind::Mul => format!("({} * {})", l, r),
5330                BinOpKind::Div => format!("({} / {})", l, r),
5331                BinOpKind::FloorDiv => {
5332                    if is_integer_expr(&op.left) && is_integer_expr(&op.right) {
5333                        format!("({} / {})", l, r)
5334                    } else {
5335                        format!("floor(({}) / ({}))", l, r)
5336                    }
5337                }
5338                BinOpKind::Mod => format!("({} % {})", l, r),
5339                BinOpKind::Pow => format!("power({}, {})", l, r),
5340                BinOpKind::Eq => format!("({} = {})", l, r),
5341                BinOpKind::Ne => format!("({} <> {})", l, r),
5342                BinOpKind::Lt => format!("({} < {})", l, r),
5343                BinOpKind::Le => format!("({} <= {})", l, r),
5344                BinOpKind::Gt => format!("({} > {})", l, r),
5345                BinOpKind::Ge => format!("({} >= {})", l, r),
5346                BinOpKind::And => format!("({} AND {})", l, r),
5347                BinOpKind::Or => format!("({} OR {})", l, r),
5348                BinOpKind::Like => format!("({} LIKE {})", l, r),
5349                BinOpKind::Ilike => format!("({} ILIKE {})", l, r),
5350                BinOpKind::NotLike => format!("({} NOT LIKE {})", l, r),
5351                BinOpKind::NotIlike => format!("({} NOT ILIKE {})", l, r),
5352                // `ANY`/`ALL` take the array directly, so unwrap a
5353                // right-hand `std::array_unpack` rather than emitting its
5354                // `unnest()` — a set-returning function is not allowed in
5355                // `WHERE`, and PostgreSQL rejects it at execution time even
5356                // though the PyQL and the SQL both look fine.
5357                BinOpKind::In => format!("({} = ANY({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5358                BinOpKind::NotIn => format!("({} <> ALL({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5359                BinOpKind::Coalesce => format!("COALESCE({}, {})", l, r),
5360                BinOpKind::CoalesceEq => format!("({} IS NOT DISTINCT FROM {})", l, r),
5361                BinOpKind::CoalesceNe => format!("({} IS DISTINCT FROM {})", l, r),
5362                BinOpKind::Concat => format!("({} || {})", l, r),
5363            }
5364        }
5365        IrExpr::UnaryOp(op) => {
5366            let inner = emit_expr(&op.operand);
5367            match op.op {
5368                UnaryOpKind::Not => format!("(NOT {})", inner),
5369                UnaryOpKind::Minus => format!("(-{})", inner),
5370                UnaryOpKind::Exists => format!("EXISTS({})", inner),
5371                // A value that reached expression position is a single one,
5372                // and a single value is already distinct — `DISTINCT x` is not
5373                // an SQL expression. Set-valued operands carry their own
5374                // DISTINCT, applied where the set is built.
5375                UnaryOpKind::Distinct => inner,
5376            }
5377        }
5378        IrExpr::FunctionCall(f) => {
5379            let args: Vec<_> = f.args.iter().map(emit_expr).collect();
5380            if let Some(tmpl) = &f.sql_template {
5381                return fill_template(tmpl, &args);
5382            }
5383            let name = match &f.schema {
5384                Some(s) => format!("{}.{}", pg_schema(s), qi(&f.name)),
5385                None => f.name.clone(),
5386            };
5387            format!("{}({})", name, args.join(", "))
5388        }
5389        IrExpr::TypeCast(c) => {
5390            // PostgreSQL doesn't support arbitrary_type::jsonb; to_jsonb() accepts any input.
5391            // String literals have type "unknown" in PG, so cast to text first.
5392            if c.pg_type == "jsonb" {
5393                match &c.expr {
5394                    // A bare `$N` parameter has no PG type at all until told
5395                    // otherwise. to_jsonb($N) can't resolve it — it's a
5396                    // polymorphic function, which needs a *concrete* input
5397                    // type to dispatch on, and an unknown-typed placeholder
5398                    // gives it nothing to work with ("could not determine
5399                    // polymorphic type because input has type unknown").
5400                    // A direct ($N)::jsonb cast works because PG specially
5401                    // resolves an unknown-typed parameter against an
5402                    // explicit cast — the same reason `$1::uuid` works fine
5403                    // elsewhere in this file.
5404                    IrExpr::Param { .. } => format!("({})::jsonb", emit_expr(&c.expr)),
5405                    IrExpr::Literal(IrLiteral::Str(_)) => {
5406                        format!("to_jsonb({}::text)", emit_expr(&c.expr))
5407                    }
5408                    _ => format!("to_jsonb({})", emit_expr(&c.expr)),
5409                }
5410            } else if yields_jsonb(&c.expr) {
5411                // Casting json to a scalar yields the value, not its JSON
5412                // spelling — `<str>` of a json string is the string, with no
5413                // quotes around it. `::text` would keep them.
5414                format!("(({}) #>> '{{}}')::{}", emit_expr(&c.expr), c.pg_type)
5415            } else {
5416                format!("({})::{}", emit_expr(&c.expr), c.pg_type)
5417            }
5418        }
5419        IrExpr::IfElse(ie) => format!(
5420            "CASE WHEN {} THEN {} ELSE {} END",
5421            emit_expr(&ie.condition),
5422            emit_expr(&ie.if_),
5423            emit_expr(&ie.else_),
5424        ),
5425        IrExpr::Array(elems) => {
5426            if elems.is_empty() {
5427                "ARRAY[]::text[]".to_string()
5428            } else {
5429                let parts: Vec<String> = elems.iter().map(emit_expr).collect();
5430                format!("ARRAY[{}]", parts.join(", "))
5431            }
5432        }
5433        IrExpr::Null => "NULL".to_string(),
5434        IrExpr::AggOverSet {
5435            fn_name,
5436            schema: _,
5437            elems,
5438        } => {
5439            let union_all = elems
5440                .iter()
5441                .map(|e| format!("SELECT {}", emit_expr(e)))
5442                .collect::<Vec<_>>()
5443                .join(" UNION ALL ");
5444            format!("(SELECT {}(v) FROM ({}) AS _set(v))", fn_name, union_all)
5445        }
5446        IrExpr::AggOverCte { fn_name, cte, column } => format!(
5447            "(SELECT {}({}) FROM {})",
5448            fn_name,
5449            column.as_deref().map(qi).unwrap_or_else(|| "*".to_string()),
5450            qi(cte),
5451        ),
5452        IrExpr::ExistsOverCte { cte, column } => match column {
5453            Some(column) => format!("EXISTS(SELECT 1 FROM {} WHERE {} IS NOT NULL)", qi(cte), qi(column)),
5454            None => format!("EXISTS(SELECT 1 FROM {})", qi(cte)),
5455        },
5456        IrExpr::AggOverQuery { fn_name, inner } => {
5457            let inner_sql = emit_select_stmt(inner, &[]).sql;
5458            format!("(SELECT {}(*) FROM ({}) _agg)", fn_name, inner_sql)
5459        }
5460        IrExpr::ArrayFromSelect(src) => emit_array_source(src),
5461        IrExpr::ScalarSubquery(sel) => {
5462            format!(
5463                "(SELECT \"v\" FROM (\n{}\n) AS \"_scalar_sub\")",
5464                emit_select_stmt(sel, &[]).sql
5465            )
5466        }
5467
5468        IrExpr::SetOp { op, left, right, mode } => {
5469            let set = format!("({}) {} ({})", set_operand(left), op.sql(), set_operand(right));
5470            match mode {
5471                crate::ir::SetOpMode::Exists => format!("EXISTS({set})"),
5472                crate::ir::SetOpMode::Array => format!("ARRAY({set})"),
5473                crate::ir::SetOpMode::Aggregate(f) => {
5474                    format!("(SELECT {f}(\"_s\".\"v\") FROM ({set}) AS \"_s\"(\"v\"))")
5475                }
5476            }
5477        }
5478
5479        IrExpr::CteRef { name, scalar, .. } => {
5480            // scalar CTEs emit `ROW(expr) AS result, expr AS v`; use `v` for
5481            // expression context so we get the plain scalar type, not record.
5482            let col = if *scalar { "v" } else { "id" };
5483            format!("(SELECT \"{}\" FROM \"{}\")", col, name)
5484        }
5485
5486        IrExpr::CteFieldRef { name, field, .. } => {
5487            format!("(SELECT {} FROM {})", qi(field), qi(name))
5488        }
5489
5490        IrExpr::ForVar { name, .. } => format!("\"_for_{}\".\"v\"", name),
5491
5492        IrExpr::EnumLiteral { pg_type, variant } => {
5493            format!("'{}'::{}", variant.replace('\'', "''"), pg_type)
5494        }
5495
5496        IrExpr::GlobalParam { index, pg_type } => {
5497            format!("(${}::{})", index + 1, pg_type)
5498        }
5499
5500        IrExpr::GlobalRef { cte_name } => {
5501            format!("(SELECT \"value\" FROM \"{}\")", cte_name)
5502        }
5503
5504        IrExpr::NamedTuple { fields, .. } => {
5505            let pairs: Vec<String> = fields
5506                .iter()
5507                .flat_map(|(k, v)| [format!("'{}'", k.replace('\'', "''")), emit_expr(v)])
5508                .collect();
5509            format!("jsonb_build_object({})", pairs.join(", "))
5510        }
5511
5512        IrExpr::Tuple(elems) => {
5513            let items: Vec<String> = elems.iter().map(emit_expr).collect();
5514            format!("jsonb_build_array({})", items.join(", "))
5515        }
5516
5517        IrExpr::Subscript { expr, index, is_array } => {
5518            let e = emit_expr(expr);
5519            let i = emit_expr(index);
5520            if *is_array {
5521                format!("_pylon.array_subscript({}, ({})::bigint)", e, i)
5522            } else {
5523                format!("_pylon.str_subscript({}, ({})::bigint)", e, i)
5524            }
5525        }
5526
5527        IrExpr::Slice {
5528            expr,
5529            lower,
5530            upper,
5531            is_array,
5532        } => {
5533            let e = emit_expr(expr);
5534            if *is_array {
5535                let lo = lower
5536                    .as_deref()
5537                    .map(|x| format!("({}) + 1", emit_expr(x)))
5538                    .unwrap_or_else(|| "1".to_string());
5539                let hi = upper.as_deref().map(emit_expr).unwrap_or_default();
5540                if hi.is_empty() {
5541                    format!("({})[{}:]", e, lo)
5542                } else {
5543                    format!("({})[{}:{}]", e, lo, hi)
5544                }
5545            } else {
5546                // substr(expr, start, length) for text/bytea.
5547                let start = lower
5548                    .as_deref()
5549                    .map(|x| format!("({}) + 1", emit_expr(x)))
5550                    .unwrap_or_else(|| "1".to_string());
5551                match upper.as_deref() {
5552                    Some(hi_expr) => {
5553                        let lo_val = lower.as_deref().map(emit_expr).unwrap_or_else(|| "0".to_string());
5554                        // GREATEST(0, ...) so reversed bounds yield '' instead of a PG error.
5555                        format!(
5556                            "substr({}, {}, GREATEST(0, ({}) - ({})))",
5557                            e,
5558                            start,
5559                            emit_expr(hi_expr),
5560                            lo_val
5561                        )
5562                    }
5563                    None => format!("substr({}, {})", e, start),
5564                }
5565            }
5566        }
5567
5568        IrExpr::JsonbField { expr, field } => {
5569            format!("({}->{})", emit_expr(expr), sql_str(field))
5570        }
5571
5572        IrExpr::JsonbIndex { expr, index } => {
5573            format!("({}->{})", emit_expr(expr), index)
5574        }
5575
5576        IrExpr::FnParam { name, .. } => qi(name),
5577
5578        IrExpr::FnSubquery(fs) => {
5579            let scalar = match fs.shape.as_slice() {
5580                [IrShapePointer::Computed(c)] => emit_expr(&c.expr),
5581                _ => unreachable!("a function scalar subquery always projects exactly one computed pointer"),
5582            };
5583            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
5584            let mut sql = format!(
5585                "(SELECT {}{}\nFROM {}.{}({}) AS {}",
5586                if fs.distinct { "DISTINCT " } else { "" },
5587                scalar,
5588                pg_schema(&fs.fn_module),
5589                qi(&fs.fn_name),
5590                args_sql,
5591                qi(&fs.alias),
5592            );
5593            append_filter(&mut sql, &fs.filter);
5594            append_order_by(&mut sql, &fs.order_by);
5595            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5596            sql.push(')');
5597            sql
5598        }
5599
5600        IrExpr::PathSubquery(ps) => {
5601            let scalar = match &ps.result {
5602                IrPathResult::Scalar(e, _) => emit_expr(e),
5603                IrPathResult::Object { alias, .. } => format!("{}.\"id\"", qi(alias)),
5604            };
5605            let from_sql = emit_path_joins(&ps.root, &ps.joins);
5606            let mut sql = format!(
5607                "(SELECT {}{}\nFROM {}",
5608                if ps.distinct { "DISTINCT " } else { "" },
5609                scalar,
5610                from_sql
5611            );
5612            append_filter(&mut sql, &ps.filter);
5613            append_order_by(&mut sql, &ps.order_by);
5614            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5615            sql.push(')');
5616            sql
5617        }
5618
5619        IrExpr::ObjectPathUnion { branches, limit, multi } => {
5620            let arms: Vec<String> = branches
5621                .iter()
5622                .map(|ps| {
5623                    let IrPathResult::Object {
5624                        alias,
5625                        type_name,
5626                        shape,
5627                    } = &ps.result
5628                    else {
5629                        unreachable!("an object path union's branches always land on objects")
5630                    };
5631                    let (pointer_exprs, _) = build_shape(shape, alias);
5632                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
5633                    parts.extend(pointer_exprs);
5634                    let mut sql = format!(
5635                        "SELECT (\n    {}\n) AS \"r\"\nFROM {}",
5636                        parts.join(",\n    "),
5637                        emit_path_joins(&ps.root, &ps.joins),
5638                    );
5639                    append_filter(&mut sql, &ps.filter);
5640                    sql
5641                })
5642                .collect();
5643            let mut sql = format!(
5644                "{}(SELECT \"r\" FROM (\n{}\n) AS \"_u\"",
5645                if *multi { "ARRAY" } else { "" },
5646                arms.join("\nUNION ALL\n")
5647            );
5648            if let Some(limit) = limit {
5649                sql.push_str(&format!("\nLIMIT {}", emit_expr(limit)));
5650            }
5651            sql.push(')');
5652            sql
5653        }
5654
5655        IrExpr::ObjectPathSubquery(ps) => {
5656            let IrPathResult::Object {
5657                alias,
5658                type_name,
5659                shape,
5660            } = &ps.result
5661            else {
5662                unreachable!("an object path subquery always lands on an object")
5663            };
5664            let (pointer_exprs, _) = build_shape(shape, alias);
5665            let mut parts = vec![result_type_disc(ps, alias, type_name)];
5666            parts.extend(pointer_exprs);
5667            let mut sql = format!(
5668                "(SELECT (\n    {}\n)\nFROM {}",
5669                parts.join(",\n    "),
5670                emit_path_joins(&ps.root, &ps.joins),
5671            );
5672            append_filter(&mut sql, &ps.filter);
5673            append_order_by(&mut sql, &ps.order_by);
5674            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5675            sql.push(')');
5676            sql
5677        }
5678
5679        IrExpr::ObjectSubquery(sel) => {
5680            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5681                unreachable!("an object subquery is always schema-bound")
5682            };
5683            let alias = &source.alias;
5684            let (sub_exprs, _) = build_shape(shape, alias);
5685            let mut row_parts = vec![source_type_disc(source)];
5686            row_parts.extend(sub_exprs);
5687            let mut sql = format!(
5688                "(SELECT (\n        {}\n    )\n    FROM {} AS {}",
5689                row_parts.join(",\n        "),
5690                source_ref(source),
5691                qi(alias),
5692            );
5693            append_filter(&mut sql, &sel.filter);
5694            append_order_by(&mut sql, &sel.order_by);
5695            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5696            sql.push(')');
5697            sql
5698        }
5699
5700        IrExpr::Subquery(sel) => {
5701            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5702                unreachable!("scalar/exists subquery is always schema-bound")
5703            };
5704            let alias = &source.alias;
5705            let mut sql = if shape.is_empty() {
5706                // EXISTS inner: SELECT 1 FROM …
5707                format!("(SELECT 1\nFROM {} AS {}", source_ref(source), qi(alias))
5708            } else if let Some(c) = shape.iter().find_map(|f| {
5709                if let IrShapePointer::Computed(c) = f {
5710                    Some(c)
5711                } else {
5712                    None
5713                }
5714            }) {
5715                // Computed subquery (e.g. a type-intersection splat's
5716                // computed pointer): select the compiled expression itself,
5717                // not a bare column — the expression already references
5718                // `alias`'s own columns via ColumnRef.
5719                format!(
5720                    "(SELECT {}\nFROM {} AS {}",
5721                    emit_expr(&c.expr),
5722                    source_ref(source),
5723                    qi(alias)
5724                )
5725            } else {
5726                // Scalar subquery: SELECT alias.col FROM …
5727                let pk_col = shape
5728                    .iter()
5729                    .find_map(|f| {
5730                        if let IrShapePointer::Scalar(s) = f {
5731                            Some(s.column.as_str())
5732                        } else {
5733                            None
5734                        }
5735                    })
5736                    .unwrap_or("id");
5737                format!(
5738                    "(SELECT {}.{}\nFROM {} AS {}",
5739                    qi(alias),
5740                    qi(pk_col),
5741                    source_ref(source),
5742                    qi(alias),
5743                )
5744            };
5745            append_filter(&mut sql, &sel.filter);
5746            append_order_by(&mut sql, &sel.order_by);
5747            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5748            sql.push(')');
5749            sql
5750        }
5751        IrExpr::RawSql(s) => format!("({s})"),
5752    }
5753}
5754
5755// ── Vector search ────────────────────────────────────────────────────────────
5756
5757fn emit_vector_search(vs: &IrVectorSearch) -> SqlOutput {
5758    let alias = &vs.source.alias;
5759    let dist_sql = format!(
5760        "{}.{} {} {}",
5761        qi(alias),
5762        qi(&vs.vector_col),
5763        vs.distance_op,
5764        emit_expr(&vs.query_expr),
5765    );
5766
5767    // Build the object sub-tuple.  If object_shape is empty, include all properties
5768    // (type disc + id implicitly come from build_shape when no elements provided;
5769    //  with no shape elements the shape is empty, so we fall back to "just id").
5770    // Use build_shape when there are explicit shape pointers; otherwise emit a minimal tuple.
5771    let (obj_tuple, object_shape_nodes) = if vs.object_shape.is_empty() {
5772        // No explicit shape: produce (type_disc, id) as minimum.
5773        let type_expr = type_disc(&vs.source.type_name);
5774        let id_expr = format!("{}.\"id\"", qi(alias));
5775        let tuple = format!("{},\n    {}", type_expr, id_expr);
5776        let id_node = ShapeNode::Scalar {
5777            name: "id".to_string(),
5778            position: 1,
5779        };
5780        (tuple, vec![id_node])
5781    } else {
5782        let (pointer_exprs, shape_pointers) = build_shape(&vs.object_shape, alias);
5783        let mut parts = vec![type_disc(&vs.source.type_name)];
5784        parts.extend(pointer_exprs);
5785        (parts.join(",\n    "), prepend_type(shape_pointers))
5786    };
5787
5788    // Outer tuple: NULL (type slot), object sub-tuple at pos 1, distance at pos 2.
5789    let outer = format!(
5790        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5791        obj_tuple, dist_sql,
5792    );
5793    let mut sql = format!(
5794        "SELECT (\n    {}\n) AS result\nFROM {} AS {}",
5795        outer,
5796        source_ref(&vs.source),
5797        qi(alias),
5798    );
5799    append_filter(&mut sql, &vs.filter);
5800
5801    // ORDER BY distance if requested.
5802    if let Some(dir) = &vs.order_by_distance {
5803        let dir_sql = match dir {
5804            IrSortDir::Asc => "ASC",
5805            IrSortDir::Desc => "DESC",
5806        };
5807        sql.push_str(&format!("\nORDER BY {} {}", dist_sql, dir_sql));
5808    }
5809    append_offset_limit(&mut sql, &vs.offset, &vs.limit);
5810
5811    let object_node = ShapeNode::Object {
5812        name: "object".to_string(),
5813        type_name: Some(vs.source.type_name.clone()),
5814        position: 1,
5815        cardinality: Cardinality::Many,
5816        pointers: object_shape_nodes,
5817        has_implicit_id: shape_has_implicit_id(&vs.object_shape),
5818    };
5819    let shape = ShapeDescriptor {
5820        root: ShapeNode::VectorSearch {
5821            object_position: 1,
5822            distance_position: 2,
5823            object_node: Box::new(object_node),
5824        },
5825    };
5826    let inference_plan = vs.inference_model.as_ref().map(|model_name| InferencePlan::Embedding {
5827        model_name: model_name.clone(),
5828        type_name: vs.inference_type_name.clone().unwrap_or_default(),
5829        index_name: vs.inference_index_name.clone().unwrap_or(None),
5830        query_param_name: vs.inference_query_param_name.clone().unwrap_or_default(),
5831        query_literal: vs.inference_query_literal.clone(),
5832    });
5833    SqlOutput {
5834        sql,
5835        shape,
5836        inference_plan,
5837    }
5838}
5839
5840// ── FTS search ───────────────────────────────────────────────────────────────
5841
5842fn emit_fts_search(fs: &IrFtsSearch) -> SqlOutput {
5843    use crate::schema::SearchBackend;
5844    if fs.backend != SearchBackend::Postgres {
5845        return emit_fts_search_deferred(fs);
5846    }
5847
5848    let alias = &fs.source.alias;
5849    let search_col = format!("{}.{}", qi(alias), qi(&fs.search_col));
5850    let query_sql = emit_expr(&fs.query_expr);
5851    let tsquery = format!("{}('english', {})", fs.tsquery_fn, query_sql);
5852    let rank_sql = format!("ts_rank({}, {})", search_col, tsquery);
5853
5854    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5855        let type_expr = type_disc(&fs.source.type_name);
5856        let id_expr = format!("{}.\"id\"", qi(alias));
5857        let tuple = format!("{},\n    {}", type_expr, id_expr);
5858        let id_node = ShapeNode::Scalar {
5859            name: "id".to_string(),
5860            position: 1,
5861        };
5862        (tuple, vec![id_node])
5863    } else {
5864        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5865        let mut parts = vec![type_disc(&fs.source.type_name)];
5866        parts.extend(pointer_exprs);
5867        (parts.join(",\n    "), prepend_type(shape_pointers))
5868    };
5869
5870    let outer = format!(
5871        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5872        obj_tuple, rank_sql,
5873    );
5874    let mut sql = format!(
5875        "SELECT (\n    {}\n) AS result\nFROM {} AS {}\nWHERE {} @@ {}",
5876        outer,
5877        source_ref(&fs.source),
5878        qi(alias),
5879        search_col,
5880        tsquery,
5881    );
5882    if let Some(f) = &fs.filter {
5883        sql.push_str(&format!(" AND ({})", emit_expr(f)));
5884    }
5885    if let Some(dir) = &fs.order_by_rank {
5886        let dir_sql = match dir {
5887            IrSortDir::Asc => "ASC",
5888            IrSortDir::Desc => "DESC",
5889        };
5890        sql.push_str(&format!("\nORDER BY {} {}", rank_sql, dir_sql));
5891    }
5892    append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5893
5894    let object_node = ShapeNode::Object {
5895        name: "object".to_string(),
5896        type_name: Some(fs.source.type_name.clone()),
5897        position: 1,
5898        cardinality: Cardinality::Many,
5899        pointers: object_shape_nodes,
5900        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
5901    };
5902    let shape = ShapeDescriptor {
5903        root: ShapeNode::FtsSearch {
5904            object_position: 1,
5905            rank_position: 2,
5906            object_node: Box::new(object_node),
5907        },
5908    };
5909    SqlOutput {
5910        sql,
5911        shape,
5912        inference_plan: None,
5913    }
5914}
5915
5916fn emit_fts_search_deferred(fs: &IrFtsSearch) -> SqlOutput {
5917    let alias = &fs.source.alias;
5918    let ids_idx = fs
5919        .deferred_ids_param
5920        .expect("deferred_ids_param must be set for deferred backend");
5921    let scores_idx = fs
5922        .deferred_scores_param
5923        .expect("deferred_scores_param must be set for deferred backend");
5924    let ids_param = format!("${}", ids_idx + 1);
5925    let scores_param = format!("${}", scores_idx + 1);
5926
5927    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5928        let type_expr = type_disc(&fs.source.type_name);
5929        let id_expr = format!("{}.\"id\"", qi(alias));
5930        let tuple = format!("{},\n    {}", type_expr, id_expr);
5931        let id_node = ShapeNode::Scalar {
5932            name: "id".to_string(),
5933            position: 1,
5934        };
5935        (tuple, vec![id_node])
5936    } else {
5937        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5938        let mut parts = vec![type_disc(&fs.source.type_name)];
5939        parts.extend(pointer_exprs);
5940        (parts.join(",\n    "), prepend_type(shape_pointers))
5941    };
5942
5943    let outer = format!(
5944        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    \"_os\".\"score\"",
5945        obj_tuple,
5946    );
5947    let mut sql = format!(
5948        concat!(
5949            "SELECT (\n    {}\n) AS result\n",
5950            "FROM {} AS {}\n",
5951            "JOIN UNNEST({}::uuid[], {}::float8[]) AS \"_os\"(\"id\", \"score\")\n",
5952            "    ON \"_os\".\"id\" = {}.\"id\"",
5953        ),
5954        outer,
5955        source_ref(&fs.source),
5956        qi(alias),
5957        ids_param,
5958        scores_param,
5959        qi(alias),
5960    );
5961    if let Some(f) = &fs.filter {
5962        sql.push_str(&format!("\nWHERE ({})", emit_expr(f)));
5963    }
5964    if let Some(dir) = &fs.order_by_rank {
5965        let dir_sql = match dir {
5966            IrSortDir::Asc => "ASC",
5967            IrSortDir::Desc => "DESC",
5968        };
5969        sql.push_str(&format!("\nORDER BY \"_os\".\"score\" {}", dir_sql));
5970    }
5971    // limit/offset are passed to OpenSearch as size/from, not emitted in Postgres SQL.
5972    let size = fs.limit.as_ref().and_then(|lim| {
5973        if let IrExpr::Literal(IrLiteral::Int(n)) = lim {
5974            Some(*n as usize)
5975        } else {
5976            None
5977        }
5978    });
5979
5980    let object_node = ShapeNode::Object {
5981        name: "object".to_string(),
5982        type_name: Some(fs.source.type_name.clone()),
5983        position: 1,
5984        cardinality: Cardinality::Many,
5985        pointers: object_shape_nodes,
5986        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
5987    };
5988    let shape = ShapeDescriptor {
5989        root: ShapeNode::FtsSearch {
5990            object_position: 1,
5991            rank_position: 2,
5992            object_node: Box::new(object_node),
5993        },
5994    };
5995    let backend_str = match fs.backend {
5996        crate::schema::SearchBackend::Meilisearch => "meilisearch",
5997        _ => "opensearch",
5998    };
5999    let inference_plan = Some(InferencePlan::Search {
6000        backend: backend_str.to_string(),
6001        index_name: fs.deferred_index_name.clone().unwrap_or_default(),
6002        query_param_name: fs.deferred_query_param_name.clone().unwrap_or_default(),
6003        query_literal: fs.deferred_query_literal.clone(),
6004        size,
6005    });
6006    SqlOutput {
6007        sql,
6008        shape,
6009        inference_plan,
6010    }
6011}
6012
6013// ── Function select ──────────────────────────────────────────────────────────
6014
6015fn emit_function_select(sel: &IrFunctionSelect) -> SqlOutput {
6016    let alias = &sel.alias;
6017    let (pointer_exprs, shape_pointers) = build_shape(&sel.shape, alias);
6018
6019    let type_expr = if sel.polymorphic {
6020        format!("{}.\"__type__\"", qi(alias))
6021    } else {
6022        type_disc(&sel.type_name)
6023    };
6024    let mut parts = vec![type_expr];
6025    parts.extend(pointer_exprs);
6026    let tuple = parts.join(",\n    ");
6027    let distinct = if sel.distinct { "DISTINCT " } else { "" };
6028
6029    let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
6030    let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
6031
6032    let from_clause = if sel.polymorphic {
6033        // Polymorphic: we can't peek inside the function — assume it returns
6034        // a __type__ column since we control the DDL.  Use the fn call directly.
6035        format!("{} AS {}", fn_call, qi(alias))
6036    } else {
6037        format!("{} AS {}", fn_call, qi(alias))
6038    };
6039
6040    let mut sql = format!("SELECT {}(\n    {}\n) AS result\nFROM {}", distinct, tuple, from_clause,);
6041    append_filter(&mut sql, &sel.filter);
6042    append_order_by(&mut sql, &sel.order_by);
6043    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
6044
6045    let root_pointers = prepend_type(shape_pointers);
6046    SqlOutput {
6047        sql,
6048        shape: ShapeDescriptor {
6049            root: ShapeNode::Object {
6050                name: String::new(),
6051                type_name: Some(sel.type_name.clone()),
6052                position: 0,
6053                cardinality: Cardinality::Many,
6054                pointers: root_pointers,
6055                has_implicit_id: shape_has_implicit_id(&sel.shape),
6056            },
6057        },
6058        inference_plan: None,
6059    }
6060}
6061
6062/// Emit the SQL body expression for a user-defined function DDL.
6063///
6064/// For a bare scalar free select emits just the expression — e.g. `"a" + "b"`.
6065/// For everything else (object functions, non-scalar free selects) emits a
6066/// full `SELECT … FROM …` via `emit_dml_as_cte_source`.
6067pub fn emit_fn_body(ir: &crate::ir::IrOutput) -> String {
6068    with_subtype_fanouts(&ir.subtype_fanouts, || emit_output_fn_body(ir))
6069}
6070
6071fn emit_output_fn_body(ir: &crate::ir::IrOutput) -> String {
6072    let body = match &ir.stmt {
6073        IrStmt::Select(sel) if matches!(sel.rows.as_slice(), [IrRowSource::Free(IrFreeExpr::Scalar(_))]) => {
6074            let IrRowSource::Free(IrFreeExpr::Scalar(e)) = &sel.rows[0] else {
6075                unreachable!()
6076            };
6077            format!("SELECT {}", emit_expr(e))
6078        }
6079        other => emit_dml_as_cte_source(other),
6080    };
6081    if ir.ctes.is_empty() {
6082        body
6083    } else {
6084        let cte_prefix = emit_cte_prefix(&ir.ctes);
6085        format!("{}{}", cte_prefix, body)
6086    }
6087}
6088
6089fn emit_literal(lit: &IrLiteral) -> String {
6090    match lit {
6091        IrLiteral::Str(s) => sql_str(s),
6092        IrLiteral::Int(i) => i.to_string(),
6093        IrLiteral::Float(f) => {
6094            // Explicit ::float8 cast — a bare untyped numeral like `1.0`
6095            // defaults to Postgres `numeric`, but an un-cast PyQL float
6096            // literal means `float64`; only an explicit `<decimal>`/`123n`
6097            // literal should ever produce a real decimal. Without this,
6098            // `numeric`'s different wire OID
6099            // also broke decoding inside ROW() composites (see
6100            // _pg_decode_numeric in pylon/client.py, still needed for
6101            // genuine decimal casts).
6102            let s = f.to_string();
6103            let s = if s.contains('.') || s.contains('e') {
6104                s
6105            } else {
6106                format!("{}.0", s)
6107            };
6108            format!("({}::float8)", s)
6109        }
6110        IrLiteral::Bool(b) => {
6111            if *b {
6112                "TRUE".into()
6113            } else {
6114                "FALSE".into()
6115            }
6116        }
6117    }
6118}
6119
6120// ── Tests ────────────────────────────────────────────────────────────────────
6121
6122#[cfg(test)]
6123mod tests {
6124    use super::*;
6125    use crate::ir;
6126    use crate::parse;
6127    use crate::schema::{
6128        FunctionDescriptor, FunctionParamDescriptor, GlobalDescriptor, LinkDescriptor, MultiLinkDescriptor,
6129        NamedTupleDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor,
6130    };
6131
6132    #[test]
6133    fn a_template_argument_keeps_the_parameters_it_carries() {
6134        // The first argument carries query parameter `$2`; filled one number
6135        // at a time, it would be rewritten into the second argument.
6136        let filled = fill_template("f($1, $2, $10)", &["($2)::int".to_string(), "b".to_string()]);
6137        assert_eq!(filled, "f(($2)::int, b, $10)");
6138    }
6139
6140    fn make_schema() -> SchemaDescriptor {
6141        SchemaDescriptor {
6142            types: vec![
6143                TypeDescriptor {
6144                    name: "Person".into(),
6145                    module: "default".into(),
6146                    table: "Person".into(),
6147                    abstract_: false,
6148                    materialized: false,
6149                    description: None,
6150                    parents: vec![],
6151                    interfaces: vec![],
6152                    bases: vec![],
6153                    properties: vec![
6154                        PropertyDescriptor {
6155                            name: "id".into(),
6156                            pg_type: "uuid".into(),
6157                            nullable: false,
6158                            default_sql: Some("uuidv7()".into()),
6159                            default_pyql: None,
6160                            description: None,
6161                            check_constraints: vec![],
6162                            is_exclusive: true,
6163                            is_pk: true,
6164                            is_readonly: true,
6165                            rewrites: vec![],
6166                            tuple_members: None,
6167                            column_type: None,
6168                        },
6169                        PropertyDescriptor {
6170                            name: "name".into(),
6171                            pg_type: "text".into(),
6172                            nullable: false,
6173                            default_sql: None,
6174                            default_pyql: None,
6175                            description: None,
6176                            check_constraints: vec![],
6177                            is_exclusive: false,
6178                            is_pk: false,
6179                            is_readonly: false,
6180                            rewrites: vec![],
6181                            tuple_members: None,
6182                            column_type: None,
6183                        },
6184                        PropertyDescriptor {
6185                            name: "age".into(),
6186                            pg_type: "int8".into(),
6187                            nullable: true,
6188                            default_sql: None,
6189                            default_pyql: None,
6190                            description: None,
6191                            check_constraints: vec![],
6192                            is_exclusive: false,
6193                            is_pk: false,
6194                            is_readonly: false,
6195                            rewrites: vec![],
6196                            tuple_members: None,
6197                            column_type: None,
6198                        },
6199                    ],
6200                    links: vec![LinkDescriptor {
6201                        name: "company".into(),
6202                        target: "default::Company".into(),
6203                        nullable: true,
6204                        through: None,
6205                        description: None,
6206                        default_pyql: None,
6207                        is_exclusive: false,
6208                        is_readonly: false,
6209                        rewrites: vec![],
6210                        on_delete: vec![],
6211                    }],
6212                    multilinks: vec![MultiLinkDescriptor {
6213                        name: "posts".into(),
6214                        target: "default::Post".into(),
6215                        through: None,
6216                        nullable: false,
6217                        description: None,
6218                        default_pyql: None,
6219                        on_delete: vec![],
6220                        is_exclusive: false,
6221                    }],
6222                    computed: vec![],
6223                    constraints: vec![],
6224                    indexes: vec![],
6225                    partition: None,
6226                    vector_indexes: vec![],
6227                    search_indexes: vec![],
6228                    triggers: vec![],
6229                    junction: false,
6230                    signals: vec![],
6231                },
6232                TypeDescriptor {
6233                    name: "Company".into(),
6234                    module: "default".into(),
6235                    table: "Company".into(),
6236                    abstract_: false,
6237                    materialized: false,
6238                    description: None,
6239                    parents: vec![],
6240                    interfaces: vec![],
6241                    bases: vec![],
6242                    properties: vec![PropertyDescriptor {
6243                        name: "name".into(),
6244                        pg_type: "text".into(),
6245                        nullable: false,
6246                        default_sql: None,
6247                        default_pyql: None,
6248                        description: None,
6249                        check_constraints: vec![],
6250                        is_exclusive: false,
6251                        is_pk: false,
6252                        is_readonly: false,
6253                        rewrites: vec![],
6254                        tuple_members: None,
6255                        column_type: None,
6256                    }],
6257                    links: vec![],
6258                    multilinks: vec![],
6259                    computed: vec![],
6260                    constraints: vec![],
6261                    indexes: vec![],
6262                    partition: None,
6263                    vector_indexes: vec![],
6264                    search_indexes: vec![],
6265                    triggers: vec![],
6266                    junction: false,
6267                    signals: vec![],
6268                },
6269                TypeDescriptor {
6270                    name: "Post".into(),
6271                    module: "default".into(),
6272                    table: "Post".into(),
6273                    abstract_: false,
6274                    materialized: false,
6275                    description: None,
6276                    parents: vec![],
6277                    interfaces: vec![],
6278                    bases: vec![],
6279                    properties: vec![PropertyDescriptor {
6280                        name: "title".into(),
6281                        pg_type: "text".into(),
6282                        nullable: false,
6283                        default_sql: None,
6284                        default_pyql: None,
6285                        description: None,
6286                        check_constraints: vec![],
6287                        is_exclusive: false,
6288                        is_pk: false,
6289                        is_readonly: false,
6290                        rewrites: vec![],
6291                        tuple_members: None,
6292                        column_type: None,
6293                    }],
6294                    links: vec![],
6295                    multilinks: vec![],
6296                    computed: vec![],
6297                    constraints: vec![],
6298                    indexes: vec![],
6299                    partition: None,
6300                    vector_indexes: vec![],
6301                    search_indexes: vec![],
6302                    triggers: vec![],
6303                    junction: false,
6304                    signals: vec![],
6305                },
6306            ],
6307            scalars: vec![],
6308            enums: vec![],
6309            named_tuples: vec![],
6310            globals: vec![],
6311            functions: vec![],
6312            aliases: vec![],
6313            channels: vec![],
6314            ..Default::default()
6315        }
6316    }
6317
6318    fn compile_and_emit(query: &str) -> SqlOutput {
6319        let schema = make_schema();
6320        compile_and_emit_with(query, &schema)
6321    }
6322
6323    fn compile_and_emit_with(query: &str, schema: &SchemaDescriptor) -> SqlOutput {
6324        let ast = parse::parse(query).expect("parse failed");
6325        let ir = ir::compile(&ast, schema).expect("IR compile failed");
6326        emit(&ir)
6327    }
6328
6329    /// A set literal is the haystack `in` reads, so a walk that crosses a
6330    /// multi-link can be tested against one. Compiled as an ordinary operand
6331    /// it was refused outright — the walk reaches the EXISTS builder before
6332    /// the arm that knows what a set literal on the right of `in` means.
6333    #[test]
6334    fn a_multilink_walk_can_be_tested_against_a_set_literal() {
6335        let out = compile_and_emit("SELECT Person FILTER .posts.title IN {'a', 'b'}");
6336        assert!(out.sql.contains("= ANY(ARRAY['a', 'b'])"), "{}", out.sql);
6337    }
6338
6339    /// `all` is not `any`. Both used to fold into the same bare EXISTS over
6340    /// the junction, which answers "some element matches" — so `all` silently
6341    /// agreed with `any` on every one of these.
6342    #[test]
6343    fn all_over_a_multilink_is_not_the_same_question_as_any() {
6344        for condition in [
6345            ".posts.title IN {'a', 'b'}",
6346            ".posts IN ps",
6347            ".posts = ps",
6348            ".posts.title = 'x'",
6349        ] {
6350            let sql = |quantifier: &str| {
6351                compile_and_emit(&format!(
6352                    "WITH ps := (SELECT Post) SELECT Person FILTER {quantifier}({condition})"
6353                ))
6354                .sql
6355            };
6356            assert_ne!(
6357                sql("all"),
6358                sql("any"),
6359                "all({condition}) must not compile to what any({condition}) does"
6360            );
6361        }
6362    }
6363
6364    /// The two shapes `all` takes: a walk with a tail negates the test around
6365    /// each element inside one EXISTS, and a walk that *is* the link gathers
6366    /// the answers and reduces them.
6367    #[test]
6368    fn all_over_a_multilink_answers_for_every_element() {
6369        let out = compile_and_emit("SELECT Person FILTER all(.posts.title IN {'a', 'b'})");
6370        assert!(
6371            out.sql.contains("NOT EXISTS(") && out.sql.contains("NOT (\"t2\".\"title\" = ANY("),
6372            "no element fails the test:\n{}",
6373            out.sql
6374        );
6375        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER all(.posts IN ps)");
6376        assert!(
6377            out.sql
6378                .contains("coalesce(bool_and(\"_s\".\"v\"), true) FROM unnest(ARRAY(SELECT ("),
6379            "every answer, reduced:\n{}",
6380            out.sql
6381        );
6382    }
6383
6384    /// `any` keeps the EXISTS fold, which is exactly what it means and reads
6385    /// more directly than gathering the answers to reduce them.
6386    #[test]
6387    fn any_over_a_multilink_keeps_its_exists() {
6388        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER any(.posts IN ps)");
6389        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
6390        assert!(!out.sql.contains("bool_or"), "{}", out.sql);
6391    }
6392
6393    /// Comparing a multi-link to a binding of more than one row is the same
6394    /// membership question `in` asks — the reading PyQL gives `=` against a
6395    /// set everywhere else. Compared against the binding whole it became
6396    /// `= (SELECT id FROM ps)`, which Postgres aborts on as soon as the
6397    /// binding holds two rows.
6398    #[test]
6399    fn comparing_a_multilink_to_a_multi_row_binding_is_membership() {
6400        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts = ps");
6401        assert!(
6402            out.sql.contains("\"target\" = ANY((SELECT \"id\" FROM \"ps\"))"),
6403            "{}",
6404            out.sql
6405        );
6406        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts != ps");
6407        assert!(
6408            out.sql
6409                .contains("NOT (\"t2\".\"target\" = ANY((SELECT \"id\" FROM \"ps\")))"),
6410            "{}",
6411            out.sql
6412        );
6413    }
6414
6415    /// A binding of one row is still one value, so the comparison stays one.
6416    #[test]
6417    fn comparing_a_multilink_to_a_single_row_binding_stays_an_equality() {
6418        let out = compile_and_emit("WITH p := (SELECT Post LIMIT 1) SELECT Person FILTER .posts = p");
6419        assert!(
6420            out.sql.contains("\"target\" = (SELECT \"id\" FROM \"p\")"),
6421            "{}",
6422            out.sql
6423        );
6424    }
6425
6426    /// `select a in b` over two bindings asks the question once per element of
6427    /// `a`, so `a` is the row source. Read as a plain value the left side
6428    /// became a scalar subquery over the whole binding, which Postgres aborts
6429    /// on the moment it holds more than one row ("more than one row returned
6430    /// by a subquery used as an expression").
6431    #[test]
6432    fn membership_of_one_set_in_another_answers_per_element() {
6433        let out = compile_and_emit("WITH a := (SELECT Person), b := (SELECT Person) SELECT a IN b");
6434        assert!(
6435            out.sql.contains("FROM \"a\""),
6436            "the left set is the source:\n{}",
6437            out.sql
6438        );
6439        assert!(
6440            out.sql.contains("\"id\" = ANY((SELECT \"id\" FROM \"b\"))"),
6441            "each row tests its own id:\n{}",
6442            out.sql
6443        );
6444        assert!(
6445            !out.sql.contains("(SELECT \"id\" FROM \"a\") = ANY"),
6446            "the left set must not collapse to one value:\n{}",
6447            out.sql
6448        );
6449    }
6450
6451    /// The same question asked of a type rather than a binding.
6452    #[test]
6453    fn membership_of_a_type_in_a_set_answers_per_row() {
6454        let out = compile_and_emit("WITH b := (SELECT Person) SELECT Person NOT IN b");
6455        assert!(
6456            out.sql.contains("FROM \"public\".\"Person\""),
6457            "the type is the source:\n{}",
6458            out.sql
6459        );
6460        assert!(
6461            out.sql.contains("\"id\" <> ALL((SELECT \"id\" FROM \"b\"))"),
6462            "{}",
6463            out.sql
6464        );
6465    }
6466
6467    /// A quantifier reduces those per-element answers to one, so the set it
6468    /// reduces still has to be built per element: the left binding must not
6469    /// collapse into the one value a scalar subquery reads.
6470    #[test]
6471    fn quantifying_membership_of_one_set_in_another_aggregates_the_elements() {
6472        for (query, aggregate) in [("SELECT all(a IN b)", "bool_and"), ("SELECT any(a IN b)", "bool_or")] {
6473            let out = compile_and_emit(&format!("WITH a := (SELECT Person), b := (SELECT Person) {query}"));
6474            assert!(
6475                out.sql.contains(&format!("{aggregate}(\"_s\".\"v\")")),
6476                "{query}:\n{}",
6477                out.sql
6478            );
6479            assert!(
6480                out.sql.contains("FROM unnest(ARRAY(SELECT ("),
6481                "one answer per element, then reduced — {query}:\n{}",
6482                out.sql
6483            );
6484            assert!(
6485                !out.sql.contains("(SELECT \"id\" FROM \"a\") = ANY"),
6486                "the left set must not collapse to one value — {query}:\n{}",
6487                out.sql
6488            );
6489        }
6490    }
6491
6492    /// `t := .posts in ps` is a set of booleans, one per post — the same array
6493    /// any other multi-valued computed comes back as. Collapsed to an EXISTS it
6494    /// answered once for the whole link, which is what `any(…)` spells.
6495    #[test]
6496    fn membership_of_a_multilink_is_a_boolean_per_element() {
6497        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := .posts IN ps }");
6498        assert!(
6499            out.sql
6500                .contains("ARRAY(SELECT (\"t3\".\"id\" = ANY((SELECT \"id\" FROM \"ps\")))"),
6501            "one answer per post:\n{}",
6502            out.sql
6503        );
6504        assert!(
6505            !out.sql.contains("EXISTS("),
6506            "not one answer for the link:\n{}",
6507            out.sql
6508        );
6509    }
6510
6511    /// A FILTER wants a single boolean out of the whole set, which is the
6512    /// EXISTS the element-wise reading above must not take away.
6513    #[test]
6514    fn membership_of_a_multilink_in_a_filter_stays_one_answer() {
6515        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts IN ps");
6516        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
6517    }
6518
6519    /// And `all(…)`/`any(…)` are how a computed asks for that single boolean:
6520    /// the quantifier ranges over the set's elements, not over the rows of the
6521    /// query the array was built in.
6522    #[test]
6523    fn quantifying_membership_of_a_multilink_aggregates_its_elements() {
6524        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := all(.posts IN ps) }");
6525        assert!(
6526            out.sql
6527                .contains("coalesce(bool_and(\"_s\".\"v\"), true) FROM unnest(ARRAY(SELECT ("),
6528            "{}",
6529            out.sql
6530        );
6531        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := any(.posts IN ps) }");
6532        assert!(
6533            out.sql
6534                .contains("coalesce(bool_or(\"_s\".\"v\"), false) FROM unnest(ARRAY(SELECT ("),
6535            "{}",
6536            out.sql
6537        );
6538    }
6539
6540    /// A set *literal* on the right is still the array `= ANY(…)` wants, even
6541    /// once the left is walked element by element.
6542    #[test]
6543    fn membership_in_a_set_literal_answers_per_element() {
6544        let out = compile_and_emit("SELECT Person { t := .posts.title IN {'a', 'b'} }");
6545        assert!(
6546            out.sql
6547                .contains("ARRAY(SELECT (\"t2\".\"title\" = ANY(ARRAY['a', 'b']))"),
6548            "{}",
6549            out.sql
6550        );
6551    }
6552
6553    /// `select (for x in S union (insert T { … })) { … }` — a shape, or any
6554    /// clause of the select's own, stops the loop from simply *being* the
6555    /// statement, so the loop becomes this select's source. Emitted as one it
6556    /// landed in a `CROSS JOIN LATERAL`, where Postgres rejects a
6557    /// data-modifying statement outright ("syntax error at or near INSERT").
6558    /// It is bound to a CTE instead, exactly as the `with c := (for … union
6559    /// (insert …)) select c { … }` spelling of the same query already was.
6560    #[test]
6561    fn test_a_mutating_loop_as_a_select_source_is_bound_to_a_cte() {
6562        let out =
6563            compile_and_emit("SELECT (FOR p IN (SELECT Person) UNION (INSERT Post { title := p.name })) { title }");
6564        assert!(
6565            !out.sql.contains("LATERAL"),
6566            "the insert must not sit in a LATERAL, got:\n{}",
6567            out.sql
6568        );
6569        assert!(
6570            out.sql
6571                .contains("\"_nested_dml_0__ids\" AS (\nINSERT INTO \"public\".\"Post\""),
6572            "the loop's insert belongs in a top-level CTE, got:\n{}",
6573            out.sql
6574        );
6575        assert!(
6576            out.sql.contains("FROM \"_nested_dml_0\" AS "),
6577            "the select must read the bound loop back, got:\n{}",
6578            out.sql
6579        );
6580    }
6581
6582    /// The same binding also puts the loop variable in scope before the body's
6583    /// subject is resolved — `update p` named no type until then, so this
6584    /// spelling used to fail to compile at all while its `with` form worked.
6585    #[test]
6586    fn test_a_loop_updating_its_own_variable_compiles_as_a_select_source() {
6587        let out = compile_and_emit("SELECT (FOR p IN (SELECT Person) UNION (UPDATE p SET { age := 1 })) { name }");
6588        assert!(
6589            out.sql
6590                .contains("\"_nested_dml_0__ids\" AS (\nUPDATE \"public\".\"Person\""),
6591            "got:\n{}",
6592            out.sql
6593        );
6594        assert!(!out.sql.contains("LATERAL"), "got:\n{}", out.sql);
6595    }
6596
6597    /// `(insert Outer { link := (insert Inner …) }) if cond else {}` — the
6598    /// guard belongs to the insert the conditional names. The nested insert
6599    /// compiles first and used to claim it, leaving the outer one to write a
6600    /// row whatever the condition said. `compile_update`/`compile_delete`
6601    /// take theirs up front for the same reason.
6602    /// A loop's own bindings mutate too — an order line's attributes are
6603    /// bound inside the loop that writes the line, two levels under the
6604    /// insert the condition is on.
6605    #[test]
6606    fn test_a_guarded_insert_reaches_a_binding_inside_a_nested_loop() {
6607        let out = compile_and_emit(
6608            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6609                  made := (INSERT Person { name := 'a', \
6610                             posts := (FOR s IN {'p','q'} UNION ( \
6611                               WITH extra := (INSERT Company { name := s }) \
6612                               INSERT Post { title := s })) }) \
6613                          IF NOT EXISTS existing ELSE {} \
6614             SELECT made",
6615        );
6616        let at = out
6617            .sql
6618            .find("INSERT INTO \"public\".\"Company\"")
6619            .unwrap_or_else(|| panic!("no Company insert in:\n{}", out.sql));
6620        let tail = &out.sql[at..];
6621        let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6622        assert!(
6623            tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6624            "a binding inside the loop must carry the condition too, got:\n{}",
6625            out.sql
6626        );
6627    }
6628
6629    /// Everything a guarded insert writes is guarded with it: the row, the
6630    /// values nested in it, and a multi-link's rows — which are hoisted
6631    /// beside the statement rather than inside it. Otherwise a false
6632    /// condition still leaves those behind, owned by nothing.
6633    #[test]
6634    fn test_a_guarded_insert_carries_its_condition_into_what_it_nests() {
6635        let out = compile_and_emit(
6636            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6637                  made := (INSERT Person { name := 'a', company := (INSERT Company { name := 'b' }), \
6638                                           posts := (FOR s IN {'p','q'} UNION (INSERT Post { title := s })) }) \
6639                          IF NOT EXISTS existing ELSE {} \
6640             SELECT made",
6641        );
6642        for table in ["\"Person\"", "\"Company\" (", "\"Post\""] {
6643            let at = out
6644                .sql
6645                .find(&format!("INSERT INTO \"public\".{}", table))
6646                .unwrap_or_else(|| panic!("no insert into {} in:\n{}", table, out.sql));
6647            let tail = &out.sql[at..];
6648            let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6649            assert!(
6650                tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6651                "insert into {} writes whatever the condition says, got:\n{}",
6652                table,
6653                out.sql
6654            );
6655        }
6656    }
6657
6658    #[test]
6659    fn test_a_guarded_insert_keeps_its_guard_when_it_nests_another() {
6660        let out = compile_and_emit(
6661            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6662                  made := (INSERT Person { name := 'a', company := (INSERT Company { name := 'b' }) }) \
6663                          IF NOT EXISTS existing ELSE {} \
6664             SELECT made",
6665        );
6666        let outer = out
6667            .sql
6668            .find("INSERT INTO \"public\".\"Person\"")
6669            .unwrap_or_else(|| panic!("no Person insert in:\n{}", out.sql));
6670        let tail = &out.sql[outer..];
6671        let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6672        assert!(
6673            tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6674            "the insert the conditional names must carry the condition, got:\n{}",
6675            out.sql
6676        );
6677    }
6678
6679    #[test]
6680    fn a_nested_read_inside_a_with_opens_no_second_with() {
6681        let out = compile_and_emit_with(
6682            "WITH a := (SELECT Person FILTER .age > 1), b := (SELECT Person LIMIT 1) SELECT (SELECT (a UNION b))",
6683            &make_schema(),
6684        );
6685        assert_eq!(out.sql.matches("WITH").count(), 1, "{}", out.sql);
6686    }
6687
6688    #[test]
6689    fn a_comparison_against_a_multi_row_with_binding_tests_membership() {
6690        // Read as a scalar subquery, the binding aborts the query on its
6691        // second row: "more than one row returned by a subquery used as an
6692        // expression".
6693        let out = compile_and_emit_with(
6694            "WITH companies := (SELECT Company FILTER .name = 'x') SELECT Person FILTER .company = companies",
6695            &make_schema(),
6696        );
6697        assert!(
6698            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6699            "{}",
6700            out.sql
6701        );
6702
6703        let out = compile_and_emit_with(
6704            "WITH companies := (SELECT Company) SELECT Person FILTER any(.company = companies)",
6705            &make_schema(),
6706        );
6707        assert!(
6708            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6709            "{}",
6710            out.sql
6711        );
6712
6713        let out = compile_and_emit_with(
6714            "WITH companies := (SELECT Company) SELECT Person FILTER .company != companies",
6715            &make_schema(),
6716        );
6717        assert!(out.sql.contains("NOT (\"t1\".\"company_id\" = ANY("), "{}", out.sql);
6718    }
6719
6720    #[test]
6721    fn a_comparison_against_a_single_row_with_binding_stays_a_scalar_read() {
6722        let out = compile_and_emit_with(
6723            "WITH company := (SELECT Company LIMIT 1) SELECT Person FILTER .company = company",
6724            &make_schema(),
6725        );
6726        assert!(out.sql.contains("= (SELECT \"id\" FROM \"company\")"), "{}", out.sql);
6727    }
6728
6729    #[test]
6730    fn an_aggregate_over_a_backlink_counts_inside_a_subquery() {
6731        let out = compile_and_emit_with("SELECT Post { n := count(.<posts) }", &make_schema());
6732        assert!(
6733            !out.sql.contains("count((SELECT"),
6734            "an aggregate over a scalar subquery:\n{}",
6735            out.sql
6736        );
6737        assert!(out.sql.contains("count("), "{}", out.sql);
6738    }
6739
6740    #[test]
6741    fn an_aggregate_over_a_relative_subselect_counts_inside_a_subquery() {
6742        let out = compile_and_emit_with(
6743            "SELECT Post { n := count((SELECT .<posts FILTER .age > 1)) }",
6744            &make_schema(),
6745        );
6746        assert!(
6747            !out.sql.contains("count((SELECT"),
6748            "an aggregate over a scalar subquery:\n{}",
6749            out.sql
6750        );
6751        assert!(
6752            out.sql.contains(r#"(SELECT count("_s"."v") FROM unnest(ARRAY("#),
6753            "{}",
6754            out.sql
6755        );
6756    }
6757
6758    #[test]
6759    fn any_over_a_link_type_check_tests_each_element() {
6760        let out = compile_and_emit_with(
6761            "SELECT Company { name } FILTER any(.<company.posts IS Post)",
6762            &make_schema(),
6763        );
6764        assert!(
6765            !out.sql.contains("bool_or(ARRAY"),
6766            "an aggregate over outer rows:\n{}",
6767            out.sql
6768        );
6769        assert!(
6770            out.sql
6771                .contains("FROM unnest(ARRAY(SELECT ('default::Post' = 'default::Post')"),
6772            "{}",
6773            out.sql
6774        );
6775    }
6776
6777    #[test]
6778    fn a_link_type_check_reads_the_linked_object() {
6779        let out = compile_and_emit_with("SELECT Person { name } FILTER .company IS Company", &make_schema());
6780        assert!(!out.sql.contains("WHERE FALSE"), "{}", out.sql);
6781        assert!(out.sql.contains("'default::Company'"), "{}", out.sql);
6782    }
6783
6784    /// `make_schema` with an `Admin` extending `Person`, in a table of its own.
6785    fn schema_with_a_subtype() -> SchemaDescriptor {
6786        let mut schema = make_schema();
6787        let mut admin = schema.types[0].clone();
6788        admin.name = "Admin".into();
6789        admin.table = "Admin".into();
6790        admin.bases = vec!["default::Person".into()];
6791        schema.types.push(admin);
6792        schema
6793    }
6794
6795    #[test]
6796    fn a_type_with_subtypes_reads_their_rows_too() {
6797        let out = compile_and_emit_with("SELECT Person { name }", &schema_with_a_subtype());
6798        assert!(
6799            out.sql.contains("'default::Admin'::text AS \"__type__\"") && out.sql.contains("FROM \"public\".\"Admin\""),
6800            "expected the subtype's table in the read:\n{}",
6801            out.sql
6802        );
6803    }
6804
6805    #[test]
6806    fn a_path_from_a_type_with_subtypes_reads_their_rows_too() {
6807        let out = compile_and_emit_with("SELECT Person.name", &schema_with_a_subtype());
6808        assert!(out.sql.contains("FROM \"public\".\"Admin\""), "{}", out.sql);
6809    }
6810
6811    #[test]
6812    fn a_multilink_of_a_type_with_subtypes_reads_their_junctions_too() {
6813        let out = compile_and_emit_with("SELECT Person { posts: { title } }", &schema_with_a_subtype());
6814        assert!(
6815            out.sql.contains(
6816                "(SELECT \"source\", \"target\" FROM \"public\".\"Person.posts\" UNION ALL \
6817                 SELECT \"source\", \"target\" FROM \"public\".\"Admin.posts\")"
6818            ),
6819            "expected the junctions unioned:\n{}",
6820            out.sql
6821        );
6822    }
6823
6824    #[test]
6825    fn is_a_supertype_matches_its_subtypes() {
6826        let out = compile_and_emit_with(
6827            "SELECT Person { name } FILTER Person IS Admin",
6828            &schema_with_a_subtype(),
6829        );
6830        assert!(out.sql.contains("\"__type__\" = 'default::Admin'"), "{}", out.sql);
6831    }
6832
6833    #[test]
6834    fn a_write_to_a_type_with_subtypes_reaches_their_tables() {
6835        let out = compile_and_emit_with("UPDATE Person SET { age := 1 }", &schema_with_a_subtype());
6836        assert!(
6837            out.sql.contains("UPDATE \"public\".\"Person\"") && out.sql.contains("UPDATE \"public\".\"Admin\""),
6838            "expected both tables updated:\n{}",
6839            out.sql
6840        );
6841        assert!(
6842            !out.sql.contains("UPDATE (\n"),
6843            "a write never targets the union:\n{}",
6844            out.sql
6845        );
6846    }
6847
6848    #[test]
6849    fn test_comparing_a_multilink_itself_to_an_object() {
6850        // `filter any(.posts = post)` — the multi-link with no tail step. It
6851        // used to reach `compile_path`, which only knows properties, links
6852        // and computeds, and reported "has no link or property 'posts'".
6853        let out = compile_and_emit_with(
6854            "WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER any(.posts = p)",
6855            &make_schema(),
6856        );
6857        assert!(
6858            out.sql.contains("EXISTS((SELECT 1\nFROM \"public\".\"Person.posts\""),
6859            "expected a junction EXISTS:\n{}",
6860            out.sql
6861        );
6862        assert!(
6863            out.sql.contains("\"target\" = (SELECT \"id\" FROM \"p\")"),
6864            "expected the junction target compared to the bound object's id:\n{}",
6865            out.sql
6866        );
6867    }
6868
6869    #[test]
6870    fn test_comparing_a_multilink_itself_warns_without_any() {
6871        let ast = parse::parse("WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER .posts = p").unwrap();
6872        let ir = ir::compile(&ast, &make_schema()).unwrap();
6873        assert!(
6874            ir.warnings.iter().any(|w| w.contains("multi-link '.posts'")),
6875            "expected the set-valued FILTER warning, got: {:?}",
6876            ir.warnings
6877        );
6878    }
6879
6880    #[test]
6881    fn test_detached_names_the_type_of_a_nested_select() {
6882        // `detached T` as an insert's link value: the prefix used to hide the
6883        // type name from the nested select's subject resolution, which then
6884        // reported "expected a type name as SELECT subject".
6885        let out = compile_and_emit_with(
6886            "INSERT Person { name := $n, company := (SELECT detached Company FILTER .name = $c LIMIT 1) }",
6887            &make_schema(),
6888        );
6889        assert!(
6890            out.sql.contains("INSERT INTO \"public\".\"Person\""),
6891            "expected the insert to compile:\n{}",
6892            out.sql
6893        );
6894        assert!(
6895            out.sql.contains("FROM \"public\".\"Company\""),
6896            "expected the detached select to read the Company table:\n{}",
6897            out.sql
6898        );
6899    }
6900
6901    #[test]
6902    fn test_select_over_an_update_on_a_with_binding() {
6903        // A bare `with` binding as an update's subject names rows, not a
6904        // type. The top-level form already knew that; wrapping it in a select
6905        // asked what type the statement yields and got the binding's name,
6906        // reported as "unknown type 'p'".
6907        let out = compile_and_emit_with(
6908            "WITH p := (SELECT Person FILTER .id = $a LIMIT 1) SELECT (UPDATE p SET { name := $n }) { id }",
6909            &make_schema(),
6910        );
6911        assert!(
6912            out.sql.contains("UPDATE \"public\".\"Person\""),
6913            "expected the update to resolve to the bound type's table:\n{}",
6914            out.sql
6915        );
6916    }
6917
6918    #[test]
6919    fn test_a_mutation_as_a_free_object_field() {
6920        // Already allowed as an item of a free *set*; a named field of a free
6921        // object is the same question and used to answer "an update cannot
6922        // stand in for a value".
6923        let out = compile_and_emit_with(
6924            "SELECT { renamed := (UPDATE Person FILTER .id = $a SET { name := $n }), n := 1 }",
6925            &make_schema(),
6926        );
6927        assert!(
6928            out.sql.contains("UPDATE \"public\".\"Person\""),
6929            "expected the mutation to become a data-modifying CTE:\n{}",
6930            out.sql
6931        );
6932        assert!(
6933            out.sql
6934                .contains("(SELECT \"t1\".\"id\"\nFROM \"_nested_dml_0\" AS \"t1\") AS \"_f0\""),
6935            "expected the field to carry the rows the mutation touched:\n{}",
6936            out.sql
6937        );
6938    }
6939
6940    #[test]
6941    fn test_a_sub_selects_filter_stays_on_its_own_subject_when_a_field_chain_projects_off_it() {
6942        // `(select Person filter .id = $a).company.name` filters People, not
6943        // Companies. The merge that splices the field chain onto the inner
6944        // select used to leave the filter to bind against the last step.
6945        let out = compile_and_emit_with("SELECT (SELECT Person FILTER .name = $n).company.name", &make_schema());
6946        assert!(
6947            out.sql.contains("WHERE (\"t0\".\"name\" = $1)"),
6948            "the inner filter belongs to the Person alias:\n{}",
6949            out.sql
6950        );
6951    }
6952
6953    #[test]
6954    fn test_reading_back_a_nested_insert_through_its_own_statement() {
6955        let out = compile_and_emit_with(
6956            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) { id, posts: { title } }",
6957            &make_schema(),
6958        );
6959        assert!(out.sql.contains("__ml_add_0"), "got:\n{}", out.sql);
6960    }
6961
6962    #[test]
6963    fn test_reading_back_a_nested_insert_within_its_own_statement() {
6964        // Postgres does not show one statement's CTE writes to the rest of
6965        // that statement, so reading the link back off the base tables found
6966        // nothing and it hydrated as empty/None (confirmed live).
6967        let out = compile_and_emit_with(
6968            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) \
6969             { id, posts: { title } }",
6970            &make_schema(),
6971        );
6972        assert!(
6973            out.sql.contains("FROM \"_dml__ml_add_0\" AS \"jt\""),
6974            "the junction rows must come from the CTE that wrote them:\n{}",
6975            out.sql
6976        );
6977        assert!(
6978            out.sql.contains("\"_nested_dml_0\""),
6979            "and the targets from the nested insert's own CTE:\n{}",
6980            out.sql
6981        );
6982    }
6983
6984    #[test]
6985    fn test_a_nested_single_link_insert_reads_back_from_its_cte() {
6986        let out = compile_and_emit_with(
6987            "SELECT (INSERT Person { name := $n, company := (INSERT Company { name := $c }) }) \
6988             { id, company: { name } }",
6989            &make_schema(),
6990        );
6991        assert!(
6992            out.sql.contains("FROM \"_nested_dml_0\""),
6993            "the linked row must be read from the CTE that inserted it:\n{}",
6994            out.sql
6995        );
6996    }
6997
6998    #[test]
6999    fn test_a_free_object_field_holding_an_object_keeps_it_an_object() {
7000        // A free shape is a real object type whose fields are real pointers,
7001        // so an object field stays an object. Pylon used to build the free
7002        // object as jsonb, which flattened it to the bare id.
7003        let out = compile_and_emit_with(
7004            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }",
7005            &make_schema(),
7006        );
7007        assert!(
7008            out.sql.contains("'default::Company'::text"),
7009            "the field must carry the object's own row:\n{}",
7010            out.sql
7011        );
7012    }
7013
7014    #[test]
7015    fn test_projecting_an_object_off_a_free_object() {
7016        // `.co` is a path step through a pointer, not jsonb extraction, so
7017        // what comes back is the object with its shape.
7018        let out = compile_and_emit_with(
7019            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }.co",
7020            &make_schema(),
7021        );
7022        assert!(
7023            !out.sql.contains("jsonb_build_object"),
7024            "projecting a pointer must not go through jsonb:\n{}",
7025            out.sql
7026        );
7027        assert!(
7028            out.sql.contains("SELECT v AS result, v FROM"),
7029            "the object row is already the result row:\n{}",
7030            out.sql
7031        );
7032    }
7033
7034    #[test]
7035    fn test_a_shape_on_a_single_valued_object_path() {
7036        // `c := .company { name }` — an object route existed only for walks
7037        // that crossed a multi step, so a single-valued one fell through to
7038        // expression position and reported "shapes and set literals are not
7039        // valid in expression context".
7040        let out = compile_and_emit_with("SELECT Person { c := .company { name } }", &make_schema());
7041        assert!(
7042            out.sql.contains("'default::Company'::text"),
7043            "the pointer must carry the object's own row:\n{}",
7044            out.sql
7045        );
7046        assert!(
7047            !out.sql.contains("ARRAY(SELECT"),
7048            "a single-valued walk is one object, not an array of one:\n{}",
7049            out.sql
7050        );
7051    }
7052
7053    #[test]
7054    fn test_a_shapeless_single_link_still_reads_as_its_value() {
7055        // The object route is taken only when a shape says an object was
7056        // meant; without one this stays what it always was.
7057        let out = compile_and_emit_with("SELECT Person { c := .company }", &make_schema());
7058        assert!(
7059            !out.sql.contains("'default::Company'::text"),
7060            "a bare link reference must not grow an object row:\n{}",
7061            out.sql
7062        );
7063    }
7064
7065    #[test]
7066    fn test_a_tuple_element_holding_a_shaped_object() {
7067        // `select (c { name }, 1)` — an element is the same question a free
7068        // object's field asks, and used to report "shapes and set literals
7069        // are not valid in expression context".
7070        let out = compile_and_emit_with(
7071            "WITH c := (SELECT Company LIMIT 1) SELECT (c { name }, 1)",
7072            &make_schema(),
7073        );
7074        assert!(
7075            out.sql.contains("'default::Company'::text"),
7076            "the element must carry the object's own row:\n{}",
7077            out.sql
7078        );
7079    }
7080
7081    #[test]
7082    fn test_an_object_returning_function_as_a_link_value() {
7083        // `created_by := account_of_transaction()` — the function names the
7084        // row to link to, so its key is what gets stored. The schema itself
7085        // writes this as a link default; written out in a query it used to be
7086        // refused as "part of a larger expression".
7087        let mut schema = make_schema();
7088        // make_schema's Company carries only a name; a link's value is its key.
7089        let company = schema
7090            .types
7091            .iter_mut()
7092            .find(|t| t.name == "Company")
7093            .expect("make_schema declares Company");
7094        company.properties.push(PropertyDescriptor {
7095            name: "id".into(),
7096            pg_type: "uuid".into(),
7097            nullable: false,
7098            default_sql: None,
7099            default_pyql: None,
7100            description: None,
7101            check_constraints: vec![],
7102            is_exclusive: true,
7103            is_pk: true,
7104            is_readonly: true,
7105            rewrites: vec![],
7106            tuple_members: None,
7107            column_type: None,
7108        });
7109        schema.functions.push(crate::schema::FunctionDescriptor {
7110            name: "current_company".into(),
7111            module: "default".into(),
7112            params: vec![],
7113            return_pg_type: "default::Company".into(),
7114            return_is_object: true,
7115            return_is_set: false,
7116            return_is_polymorphic: false,
7117            volatility: "stable".into(),
7118            body: "select Company limit 1".into(),
7119        });
7120        let out = compile_and_emit_with("INSERT Person { name := $n, company := current_company() }", &schema);
7121        assert!(
7122            out.sql.contains("FROM \"public\".\"current_company\"()"),
7123            "the function must supply the foreign key:\n{}",
7124            out.sql
7125        );
7126    }
7127
7128    /// `make_schema` plus a `Company`-returning function and a `Person.company`
7129    /// link defaulting to it — jaldis's `Creatable.created_by :=
7130    /// account_of_transaction()` in miniature.
7131    fn make_schema_with_an_object_returning_default() -> SchemaDescriptor {
7132        let mut schema = make_schema();
7133        let company = schema
7134            .types
7135            .iter_mut()
7136            .find(|t| t.name == "Company")
7137            .expect("make_schema declares Company");
7138        company.properties.push(PropertyDescriptor {
7139            name: "id".into(),
7140            pg_type: "uuid".into(),
7141            nullable: false,
7142            default_sql: None,
7143            default_pyql: None,
7144            description: None,
7145            check_constraints: vec![],
7146            is_exclusive: true,
7147            is_pk: true,
7148            is_readonly: true,
7149            rewrites: vec![],
7150            tuple_members: None,
7151            column_type: None,
7152        });
7153        schema.functions.push(crate::schema::FunctionDescriptor {
7154            name: "current_company".into(),
7155            module: "default".into(),
7156            params: vec![],
7157            return_pg_type: "default::Company".into(),
7158            return_is_object: true,
7159            return_is_set: false,
7160            return_is_polymorphic: false,
7161            volatility: "stable".into(),
7162            body: "select Company limit 1".into(),
7163        });
7164        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7165        let company_link = person.links.iter_mut().find(|l| l.name == "company").unwrap();
7166        company_link.default_pyql = Some("current_company()".into());
7167        schema
7168    }
7169
7170    #[test]
7171    fn an_object_returning_link_default_is_applied_by_the_insert() {
7172        // A column DEFAULT cannot hold this one — PostgreSQL evaluates it with
7173        // no query in scope — so the insert has to carry it, expanded into
7174        // the shape.
7175        let schema = make_schema_with_an_object_returning_default();
7176        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
7177        assert!(
7178            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
7179            "the default must supply the foreign key:\n{}",
7180            out.sql
7181        );
7182    }
7183
7184    #[test]
7185    fn an_explicit_value_wins_over_an_inlined_default() {
7186        let schema = make_schema_with_an_object_returning_default();
7187        let out = compile_and_emit_with("INSERT Person { name := $n, company := {} }", &schema);
7188        assert!(
7189            !out.sql.contains("current_company"),
7190            "the shape named the pointer, so the default does not apply:\n{}",
7191            out.sql
7192        );
7193    }
7194
7195    #[test]
7196    fn an_object_returning_link_default_gets_no_column_default() {
7197        // Emitting one produces DDL PostgreSQL refuses to run; it used to be
7198        // dropped silently here, leaving the pointer with no default at all.
7199        let schema = make_schema_with_an_object_returning_default();
7200        let ddl = crate::export::export_schema(&schema).unwrap();
7201        assert!(
7202            !ddl.contains("\"company_id\" uuid DEFAULT"),
7203            "the column must carry no default:\n{ddl}"
7204        );
7205        crate::validate::validate_schema_types(&schema).expect("an inlined default is not an error");
7206    }
7207
7208    #[test]
7209    fn a_column_expressible_default_stays_in_the_ddl() {
7210        // Only what a column DEFAULT cannot hold moves into the insert.
7211        let mut schema = make_schema();
7212        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7213        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
7214        age.default_pyql = Some("21".into());
7215        assert!(
7216            crate::ir::inlined_pointer_defaults(schema.types.iter().find(|t| t.name == "Person").unwrap(), &schema)
7217                .is_empty()
7218        );
7219        let ddl = crate::export::export_schema(&schema).unwrap();
7220        assert!(ddl.contains("DEFAULT 21"), "{ddl}");
7221        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
7222        assert!(
7223            !out.sql.contains("21"),
7224            "the column DEFAULT still applies it:\n{}",
7225            out.sql
7226        );
7227    }
7228
7229    /// `make_schema` plus an `Employee` extending `Person`, where `Person`
7230    /// declares a computed naming itself — jaldis's
7231    /// `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)` in miniature.
7232    fn make_schema_with_an_inherited_self_naming_computed() -> SchemaDescriptor {
7233        let mut schema = make_schema();
7234        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7235        person.computed.push(crate::schema::ComputedDescriptor {
7236            name: "own_name".into(),
7237            expression: "default::Person.name".into(),
7238            return_type: Some("text".into()),
7239            link_target: None,
7240            link_multi: false,
7241        });
7242        let mut employee = person.clone();
7243        employee.name = "Employee".into();
7244        employee.table = "Employee".into();
7245        employee.bases = vec!["default::Person".into()];
7246        schema.types.push(employee);
7247        schema
7248    }
7249
7250    #[test]
7251    fn an_inherited_computed_naming_its_declaring_type_still_means_the_row() {
7252        // A computed is compiled once, against the type that declares it, so
7253        // the name reaches the subject on every subtype that inherits it.
7254        // Pylon compiles it again per subtype, where the name matched nothing
7255        // and the reference read the whole table instead.
7256        let schema = make_schema_with_an_inherited_self_naming_computed();
7257        let out = compile_and_emit_with("SELECT Employee { own_name }", &schema);
7258        assert!(
7259            !out.sql.contains("\"Person\""),
7260            "the computed must read the Employee row, not Person's table:\n{}",
7261            out.sql
7262        );
7263        crate::validate::validate_schema_types(&schema).expect("an inherited computed is single-valued");
7264    }
7265
7266    #[test]
7267    fn an_inherited_type_check_computed_tests_the_row_it_is_read_on() {
7268        // jaldis's own: `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)`,
7269        // read on `BrandAddonBundle`. `is` had a same-scope check of its own
7270        // that only knew the two spellings of the type being compiled, so on
7271        // the subtype it read `BrandAddon` as a separate set — many values
7272        // behind a declared `bool`.
7273        let mut schema = make_schema();
7274        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7275        person.computed.push(crate::schema::ComputedDescriptor {
7276            name: "employed".into(),
7277            expression: "(default::Person is default::Employee)".into(),
7278            return_type: Some("boolean".into()),
7279            link_target: None,
7280            link_multi: false,
7281        });
7282        let mut employee = person.clone();
7283        employee.name = "Employee".into();
7284        employee.table = "Employee".into();
7285        employee.bases = vec!["default::Person".into()];
7286        schema.types.push(employee);
7287
7288        crate::validate::validate_schema_types(&schema).expect("the check is single-valued on both types");
7289        let out = compile_and_emit_with("SELECT Employee { employed }", &schema);
7290        assert!(
7291            !out.sql.contains("\"Person\""),
7292            "the check must read the Employee row's own type:\n{}",
7293            out.sql
7294        );
7295    }
7296
7297    /// The interface schema plus a type outside its hierarchy, so a query
7298    /// over that type can name the interface and get one answer per row of
7299    /// it rather than a same-scope check. `Account` also gains a second
7300    /// implementor: with only `Individual` under it, `Account is Individual`
7301    /// holds for every row and is settled without reading any, which is the
7302    /// answer but not the case these tests are about.
7303    fn make_interface_schema_with_an_outsider() -> SchemaDescriptor {
7304        let mut schema = make_interface_schema();
7305        let individual = schema
7306            .types
7307            .iter()
7308            .find(|t| t.name == "Individual")
7309            .expect("Individual is in the interface schema")
7310            .clone();
7311        let mut company = individual.clone();
7312        company.name = "Company".into();
7313        company.table = "Company".into();
7314        company.interfaces = vec![];
7315        company.computed = vec![];
7316        schema.types.push(company);
7317        let mut organisation = individual;
7318        organisation.name = "Organisation".into();
7319        organisation.table = "Organisation".into();
7320        organisation.computed = vec![];
7321        schema.types.push(organisation);
7322        schema
7323    }
7324
7325    #[test]
7326    fn a_type_check_naming_another_type_reads_as_one_value_in_a_filter() {
7327        // jaldis's own: `filter (… if brand::Brand is brand::CustomBrief else
7328        // true)` inside `select brand::Brief`. Naming a type other than the
7329        // one being selected asks the question once per row of that type,
7330        // which came out as `ARRAY(SELECT …)` — a `boolean[]`, which Postgres
7331        // refuses outright as a CASE/WHEN condition. `Company` is not
7332        // polymorphic, so every row answers the same and the check is the
7333        // constant it reads as.
7334        let out = compile_and_emit(
7335            "SELECT Person { name } FILTER (.name = 'a' if default::Company is default::Post else FALSE)",
7336        );
7337        assert!(
7338            !out.sql.contains("ARRAY(SELECT"),
7339            "an answer that is the same for every row has nothing to iterate:\n{}",
7340            out.sql
7341        );
7342        assert!(
7343            out.sql.contains("CASE WHEN FALSE"),
7344            "a Company is never a Post:\n{}",
7345            out.sql
7346        );
7347    }
7348
7349    #[test]
7350    fn a_polymorphic_type_is_never_one_from_a_hierarchy_it_shares_nothing_with() {
7351        // jaldis's own again, and the half the first fix missed: their
7352        // `brand::Brand` is itself an interface, so it is polymorphic and the
7353        // check did *not* fold — it became one answer per Brand row, and with
7354        // three of them the filter failed the single-value check at runtime.
7355        // No row of one hierarchy can ever carry a `__type__` from a disjoint
7356        // one, so the answer is false without reading any.
7357        let schema = make_interface_schema_with_an_outsider();
7358        let out = compile_and_emit_with(
7359            "SELECT Company { id } FILTER (.first_name = 'a' if default::Account is default::Company else FALSE)",
7360            &schema,
7361        );
7362        assert!(
7363            !out.sql.contains("assert_single") && !out.sql.contains("ARRAY(SELECT"),
7364            "nothing to ask per row when the hierarchies are disjoint:\n{}",
7365            out.sql
7366        );
7367        assert!(
7368            out.sql.contains("CASE WHEN FALSE"),
7369            "an Account is never a Company:\n{}",
7370            out.sql
7371        );
7372    }
7373
7374    #[test]
7375    fn a_check_covering_every_implementor_holds_without_reading_a_row() {
7376        // The mirror of the disjoint case: `Individual` and `Organisation`
7377        // are all there is under `Account`, so naming both leaves nothing
7378        // that could answer differently.
7379        let mut schema = make_interface_schema_with_an_outsider();
7380        schema.types.retain(|t| t.name != "Organisation");
7381        let out = compile_and_emit_with(
7382            "SELECT Company { id } FILTER (default::Account is default::Individual)",
7383            &schema,
7384        );
7385        assert!(
7386            !out.sql.contains("assert_single"),
7387            "every Account is an Individual when it is the only implementor:\n{}",
7388            out.sql
7389        );
7390        assert!(out.sql.contains("WHERE TRUE"), "{}", out.sql);
7391    }
7392
7393    #[test]
7394    fn a_type_check_over_a_polymorphic_outsider_collapses_to_one_value() {
7395        // What is left after both folds: `Account` has two implementors, so
7396        // `is Individual` really does differ per row. A filter still takes one
7397        // value, and the message names the check rather than the internal
7398        // helper enforcing it.
7399        let schema = make_interface_schema_with_an_outsider();
7400        let out = compile_and_emit_with(
7401            "SELECT Company { id } FILTER (default::Account is default::Individual)",
7402            &schema,
7403        );
7404        assert!(
7405            out.sql.contains("\"_pylon\".\"assert_single\"(ARRAY(SELECT"),
7406            "one answer per Account row, read as a single value:\n{}",
7407            out.sql
7408        );
7409        assert!(
7410            out.sql
7411                .contains("is default::Individual'' is asked once for every default::Account object"),
7412            "the error has to name the check, not assert_single:\n{}",
7413            out.sql
7414        );
7415    }
7416
7417    #[test]
7418    fn a_type_check_naming_another_type_keeps_its_whole_set_in_a_shape() {
7419        // The other half of the same rule: a shape pointer holds a set, so
7420        // there the per-row answers stay a set rather than collapsing.
7421        let schema = make_interface_schema_with_an_outsider();
7422        let out = compile_and_emit_with(
7423            "SELECT Company { id, each := default::Account is default::Individual }",
7424            &schema,
7425        );
7426        assert!(
7427            out.sql.contains("array_agg") && !out.sql.contains("assert_single"),
7428            "a shape keeps every answer:\n{}",
7429            out.sql
7430        );
7431    }
7432
7433    #[test]
7434    fn a_shape_writing_the_computed_out_itself_does_not_get_the_anchor() {
7435        // The rule is narrow: only a *declared* computed is compiled against
7436        // its declaring type. Naming a supertype anywhere else is that
7437        // supertype's own set —
7438        // `select BrandAddonBundle { n := count(BrandAddon) }` is the full
7439        // count while the same shape naming `BrandAddonBundle` is 1. So the
7440        // same expression written out in a shape keeps resolving on its own,
7441        // even where it shadows the inherited computed's name.
7442        let schema = make_schema_with_an_inherited_self_naming_computed();
7443        let ast = parse::parse("SELECT Employee { own_name := default::Person.name }").expect("parse failed");
7444        let Err(error) = ir::compile(&ast, &schema) else {
7445            panic!("an absolute path has nothing to anchor to here")
7446        };
7447        assert!(
7448            format!("{error:?}").contains("absolute paths are not valid"),
7449            "{error:?}"
7450        );
7451    }
7452
7453    #[test]
7454    fn a_default_that_compiles_nowhere_is_still_an_error() {
7455        // jaldis carried `sequence_next(INTROSPECT marketplace::OrderNo)`,
7456        // which PyQL cannot parse. Inlining must not turn that into silence.
7457        let mut schema = make_schema();
7458        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7459        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
7460        age.default_pyql = Some("sequence_next(INTROSPECT default::AgeSeq)".into());
7461        let errs = crate::validate::validate_schema_types(&schema).unwrap_err();
7462        let (_, msg, _) = errs[0].class_name_message_position();
7463        assert!(msg.contains("default"), "{msg}");
7464    }
7465
7466    #[test]
7467    fn test_comparing_a_link_with_an_object_returning_function() {
7468        // Comparing objects compares identity, so the call stands for the id
7469        // of the row it returns. Left whole it is a function call with nowhere
7470        // to go but the subject of a select.
7471        let mut schema = make_schema();
7472        // make_schema's Company carries only a name; identity needs its key.
7473        let company = schema
7474            .types
7475            .iter_mut()
7476            .find(|t| t.name == "Company")
7477            .expect("make_schema declares Company");
7478        company.properties.push(PropertyDescriptor {
7479            name: "id".into(),
7480            pg_type: "uuid".into(),
7481            nullable: false,
7482            default_sql: None,
7483            default_pyql: None,
7484            description: None,
7485            check_constraints: vec![],
7486            is_exclusive: true,
7487            is_pk: true,
7488            is_readonly: true,
7489            rewrites: vec![],
7490            tuple_members: None,
7491            column_type: None,
7492        });
7493        schema.functions.push(crate::schema::FunctionDescriptor {
7494            name: "current_company".into(),
7495            module: "default".into(),
7496            params: vec![],
7497            return_pg_type: "default::Company".into(),
7498            return_is_object: true,
7499            return_is_set: false,
7500            return_is_polymorphic: false,
7501            volatility: "stable".into(),
7502            body: "select Company limit 1".into(),
7503        });
7504        let out = compile_and_emit_with("SELECT Person FILTER .company = current_company()", &schema);
7505        assert!(
7506            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
7507            "the link's key against the function's row:\n{}",
7508            out.sql
7509        );
7510    }
7511
7512    #[test]
7513    fn test_choosing_between_two_object_sets() {
7514        // `A if cond else B` over objects is not the scalar CASE an if/else
7515        // over values gives: it stands for A's rows when the condition holds
7516        // and B's when it does not.
7517        let out = compile_and_emit_with(
7518            "WITH a := (SELECT Person FILTER .name = $n), b := (SELECT Person) \
7519             SELECT (a IF EXISTS a ELSE b) { name }",
7520            &make_schema(),
7521        );
7522        assert!(
7523            out.sql.contains("UNION ALL"),
7524            "expected one branch per side:\n{}",
7525            out.sql
7526        );
7527    }
7528
7529    #[test]
7530    fn test_a_union_operand_written_inline() {
7531        // A union reads one relation per branch, so an operand that names no
7532        // relation yet -- a sub-select, or an insert in a get-or-create -- is
7533        // hoisted into a CTE of its own first.
7534        let out = compile_and_emit_with(
7535            "SELECT ((SELECT Person FILTER .name = $n) UNION (SELECT Person)) { name }",
7536            &make_schema(),
7537        );
7538        assert!(
7539            out.sql.starts_with("WITH"),
7540            "expected the operands hoisted:\n{}",
7541            out.sql
7542        );
7543        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7544    }
7545
7546    #[test]
7547    fn test_a_for_loop_variable_over_objects() {
7548        // The variable holds the row's key, so a path off it reads that row
7549        // back. It used to be treated as a scalar throughout, and any path
7550        // rooted at one reported "unknown type".
7551        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p.name)", &make_schema());
7552        assert!(
7553            out.sql.contains("FROM \"public\".\"Person\"") && out.sql.contains("\"_for_p\".\"v\""),
7554            "the body must read the row the variable names:\n{}",
7555            out.sql
7556        );
7557    }
7558
7559    #[test]
7560    fn test_a_shape_on_a_for_loop_variable() {
7561        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p { name })", &make_schema());
7562        assert!(
7563            out.sql.contains("'default::Person'::text"),
7564            "a shape on the variable yields its object:\n{}",
7565            out.sql
7566        );
7567    }
7568
7569    #[test]
7570    fn test_a_for_loop_variable_over_values_is_still_a_scalar() {
7571        let out = compile_and_emit_with("FOR n IN {1, 2} UNION (SELECT n)", &make_schema());
7572        assert!(out.sql.contains("VALUES"), "{}", out.sql);
7573    }
7574
7575    #[test]
7576    fn test_exists_over_a_sub_select_on_a_relative_path() {
7577        // `exists ((select .posts filter …))` — the sub-select is relative to
7578        // the enclosing object, so it needs the same rooting and correlation
7579        // `count((select .posts))` gets. Compiled without them it resolved in
7580        // free context and called the pointer unknown.
7581        let out = compile_and_emit_with(
7582            "SELECT Person { id } FILTER EXISTS ((SELECT .posts FILTER .title = $t))",
7583            &make_schema(),
7584        );
7585        assert!(out.sql.contains("EXISTS("), "{}", out.sql);
7586        assert!(
7587            out.sql.contains("\"Person.posts\""),
7588            "the junction join must survive into the EXISTS:\n{}",
7589            out.sql
7590        );
7591        assert!(
7592            out.sql.contains("\"t0\".\"id\""),
7593            "and it must correlate to the enclosing row:\n{}",
7594            out.sql
7595        );
7596    }
7597
7598    #[test]
7599    fn test_counting_the_rows_a_mutation_touched() {
7600        // `count((delete X filter …))` — the mutation runs as its own
7601        // data-modifying CTE and the aggregate reads it back, rather than
7602        // being refused as "a delete cannot stand in for a value".
7603        let out = compile_and_emit_with("SELECT count((DELETE Person FILTER .name = $n))", &make_schema());
7604        assert!(out.sql.starts_with("WITH"), "{}", out.sql);
7605        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""), "{}", out.sql);
7606        assert!(out.sql.contains("count(*)"), "{}", out.sql);
7607    }
7608
7609    #[test]
7610    fn test_a_conflict_target_naming_several_columns() {
7611        // `unless conflict on (.a, .b)` names columns for Postgres to infer
7612        // the arbiter index from; emitted as one composite value it would
7613        // look for an expression index on the tuple instead.
7614        let out = compile_and_emit_with(
7615            "INSERT Person { name := $n } UNLESS CONFLICT ON (.name, .id) ELSE (UPDATE Person SET { name := $n })",
7616            &make_schema(),
7617        );
7618        assert!(
7619            out.sql.contains("ON CONFLICT (\"name\", \"id\")"),
7620            "expected a column list:\n{}",
7621            out.sql
7622        );
7623    }
7624
7625    #[test]
7626    fn test_a_for_loop_over_a_path() {
7627        // `for post in p.posts union (…)` — a walk off a binding names a set
7628        // to iterate just as a sub-select does, only written without the
7629        // parentheses.
7630        let out = compile_and_emit_with(
7631            "WITH p := (SELECT Person LIMIT 1) FOR post IN p.posts UNION (SELECT post.title)",
7632            &make_schema(),
7633        );
7634        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
7635    }
7636
7637    #[test]
7638    fn test_a_relative_sub_select_keeps_its_enclosing_object() {
7639        // `assert_exists((select .posts { title }))` and `posts := (select
7640        // .posts filter …)` in an update both compiled their sub-select with
7641        // no enclosing object, so the relative path resolved in free context
7642        // and the pointer came back unknown.
7643        let out = compile_and_emit_with(
7644            "SELECT Person { ps := assert_exists((SELECT .posts { title })) }",
7645            &make_schema(),
7646        );
7647        assert!(out.sql.contains("assert_exists"), "{}", out.sql);
7648        assert!(
7649            out.sql.contains("\"Person.posts\""),
7650            "the walk must reach the junction:\n{}",
7651            out.sql
7652        );
7653    }
7654
7655    #[test]
7656    fn test_a_relative_sub_select_as_a_multi_link_value() {
7657        let out = compile_and_emit_with(
7658            "UPDATE Person FILTER .name = $n SET { posts := (SELECT .posts FILTER .title = $t) }",
7659            &make_schema(),
7660        );
7661        assert!(
7662            out.sql.contains("\"Person.posts\""),
7663            "the value must read the row's own posts:\n{}",
7664            out.sql
7665        );
7666    }
7667
7668    #[test]
7669    fn test_a_shape_written_after_a_sub_select() {
7670        // `(select Post filter …) { title }` means the same as putting the
7671        // shape inside the parentheses, so the shape is pushed onto the inner
7672        // statement's own result rather than refused.
7673        let out = compile_and_emit_with(
7674            "SELECT Person { ps := (SELECT Post FILTER .title = $t) { title } }",
7675            &make_schema(),
7676        );
7677        assert!(
7678            out.sql.contains("'default::Post'::text"),
7679            "the pointer must carry the object rows:\n{}",
7680            out.sql
7681        );
7682    }
7683
7684    #[test]
7685    fn test_a_shape_after_a_sub_select_with_a_with_block() {
7686        let out = compile_and_emit_with(
7687            "SELECT Person { ps := (WITH t := $t SELECT Post FILTER .title = t) { title } }",
7688            &make_schema(),
7689        );
7690        assert!(out.sql.contains("'default::Post'::text"), "{}", out.sql);
7691    }
7692
7693    #[test]
7694    fn test_a_computed_declared_as_an_object_returning_call() {
7695        // `recent := recent()` — the rows the function yields are the
7696        // pointer's objects. Read as a plain expression the call was "part of
7697        // a larger expression", which such a function refuses, so a splat over
7698        // a type carrying one could not compile at all.
7699        let mut schema = make_schema();
7700        schema.functions.push(crate::schema::FunctionDescriptor {
7701            name: "recent".into(),
7702            module: "default".into(),
7703            params: vec![],
7704            return_pg_type: "default::Post".into(),
7705            return_is_object: true,
7706            return_is_set: true,
7707            return_is_polymorphic: false,
7708            volatility: "stable".into(),
7709            body: "select Post".into(),
7710        });
7711        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
7712            name: "recent".into(),
7713            expression: "default::recent()".into(),
7714            return_type: None,
7715            link_target: None,
7716            link_multi: false,
7717        });
7718        let out = compile_and_emit_with("SELECT Person { recent }", &schema);
7719        assert!(
7720            out.sql.contains("ARRAY(SELECT") && out.sql.contains("'default::Post'::text"),
7721            "the pointer must carry the function's rows:\n{}",
7722            out.sql
7723        );
7724    }
7725
7726    #[test]
7727    fn test_one_branch_of_an_object_if_else_is_empty() {
7728        // `{}` contributes no rows, so the full rewrite (`SELECT A WHERE
7729        // Cond UNION ALL SELECT B WHERE NOT Cond`) reduces to the other
7730        // branch under its guard.
7731        let out = compile_and_emit_with(
7732            "WITH p := (SELECT Person LIMIT 1) SELECT p { name } IF EXISTS p ELSE {}",
7733            &make_schema(),
7734        );
7735        assert!(!out.sql.contains("UNION ALL"), "nothing to union with:\n{}", out.sql);
7736        assert!(out.sql.contains("'default::Person'::text"), "{}", out.sql);
7737    }
7738
7739    #[test]
7740    fn test_a_conditional_insert_is_guarded_by_its_condition() {
7741        // `(insert …) if cond else {}` — guarding a reader would not do: the
7742        // insert is a data-modifying CTE that Postgres runs regardless, so
7743        // the condition goes into the insert itself and it writes nothing
7744        // when false. Verified live: running it twice leaves one row.
7745        let out = compile_and_emit_with("SELECT (INSERT Person { name := $n }) IF FALSE ELSE {}", &make_schema());
7746        assert!(
7747            out.sql.contains("SELECT") && out.sql.contains("WHERE"),
7748            "a guarded insert selects its row rather than listing values:\n{}",
7749            out.sql
7750        );
7751        assert!(!out.sql.contains("VALUES"), "VALUES always yields a row:\n{}", out.sql);
7752    }
7753
7754    #[test]
7755    fn test_a_conditional_update_carries_its_condition() {
7756        // Guarding the branch only filters what is read back -- the mutation
7757        // is its own CTE and Postgres runs it regardless. An update has a
7758        // WHERE of its own to fold the condition into, which is what keeps it
7759        // from running when the condition is false.
7760        let out = compile_and_emit(
7761            "SELECT (UPDATE Person FILTER .name = $n SET { name := $m }) IF EXISTS (SELECT Company) ELSE {}",
7762        );
7763        let update = out.sql.find("UPDATE").expect("an update is emitted");
7764        let where_clause = out.sql[update..].find("WHERE").expect("the update is filtered");
7765        assert!(
7766            out.sql[update + where_clause..].contains("EXISTS"),
7767            "the condition narrows the rows the update touches:\n{}",
7768            out.sql
7769        );
7770    }
7771
7772    #[test]
7773    fn test_a_conditional_delete_carries_its_condition() {
7774        // Guarding the branch would only filter what is read back — Postgres
7775        // runs a data-modifying CTE regardless — so the condition has to be
7776        // ANDed into the DELETE's own WHERE, as it already is for an update.
7777        let out = compile_and_emit("SELECT (DELETE Person FILTER .name = $n) IF FALSE ELSE {}");
7778        let delete = out
7779            .sql
7780            .split("DELETE FROM")
7781            .nth(1)
7782            .expect("the delete should still be emitted");
7783        let where_clause = delete.split("WHERE").nth(1).expect("the delete should be filtered");
7784        assert!(
7785            where_clause.contains("FALSE") || where_clause.contains("false"),
7786            "the condition must narrow the delete itself:\n{}",
7787            out.sql
7788        );
7789    }
7790
7791    #[test]
7792    fn test_a_pointer_a_binding_declared_in_its_own_shape() {
7793        // `p := (select Person { boss := … })` then `p { boss }` — the
7794        // pointer is on no type, so reading it back has to find it through
7795        // the binding it was written on, and compile it against that row.
7796        let out = compile_and_emit_with(
7797            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT p { name, co: { name } }",
7798            &make_schema(),
7799        );
7800        assert!(
7801            out.sql.contains("'default::Company'::text"),
7802            "the declared pointer must be inlined:\n{}",
7803            out.sql
7804        );
7805    }
7806
7807    #[test]
7808    fn test_a_binding_declared_pointer_read_from_a_tuple_element() {
7809        let out = compile_and_emit_with(
7810            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT (p { co: { name } }, 1)",
7811            &make_schema(),
7812        );
7813        assert!(out.sql.contains("'default::Company'::text"), "{}", out.sql);
7814    }
7815
7816    #[test]
7817    fn test_a_for_loop_variable_shaped_inside_a_tuple() {
7818        // The variable holds one row's key, so the element has to be narrowed
7819        // to it. Unfiltered it read the whole table and answered with rows
7820        // the loop never named -- wrong, and silently so.
7821        let out = compile_and_emit_with(
7822            "WITH p := (SELECT Person LIMIT 1) FOR q IN p.posts UNION (SELECT (a := q { title }, b := 1))",
7823            &make_schema(),
7824        );
7825        assert!(
7826            out.sql.contains("\"_for_q\".\"v\""),
7827            "the element must be narrowed to the loop's row:\n{}",
7828            out.sql
7829        );
7830    }
7831
7832    #[test]
7833    fn test_choosing_between_two_walks() {
7834        // `(select p.posts …) if cond else (select p.posts …)` — a walk names
7835        // no type of its own, so neither branch could be resolved.
7836        let out = compile_and_emit_with(
7837            "WITH p := (SELECT Person LIMIT 1), q := (SELECT p.posts LIMIT 1) IF TRUE ELSE (SELECT p.posts) \
7838             SELECT q { title }",
7839            &make_schema(),
7840        );
7841        assert!(out.sql.contains("UNION ALL"), "one branch per side:\n{}", out.sql);
7842    }
7843
7844    #[test]
7845    fn test_a_bare_coalesce_of_correlated_walks_reaching_many_objects() {
7846        // Without a shape the walks stand for the ids they reach, which is
7847        // still a set -- SQL's COALESCE takes one value per side.
7848        let out = compile_and_emit_with(
7849            "SELECT Post { owners := .<posts[is Person] ?? .<posts[is Person] }",
7850            &make_schema(),
7851        );
7852        assert!(
7853            !out.sql.contains("COALESCE("),
7854            "not a value-by-value choice:\n{}",
7855            out.sql
7856        );
7857        assert!(
7858            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7859            "the arms are aggregated:\n{}",
7860            out.sql
7861        );
7862    }
7863
7864    #[test]
7865    fn test_a_union_of_correlated_walks_reaching_many() {
7866        // Without the LIMIT the union stands for every object both walks
7867        // reach, so its arms have to be aggregated. Read as a lone record it
7868        // compiled fine and failed in PostgreSQL on the second row, with
7869        // "more than one row returned by a subquery used as an expression".
7870        let out = compile_and_emit_with(
7871            "SELECT Post { owners := (.<posts[is Person] UNION .<posts[is Person]) { name } }",
7872            &make_schema(),
7873        );
7874        assert!(
7875            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7876            "the arms are aggregated:\n{}",
7877            out.sql
7878        );
7879    }
7880
7881    #[test]
7882    fn test_a_coalesce_of_correlated_walks_reaching_many() {
7883        // `([is A].links ?? [is B].links) { … }` — the same set a union of
7884        // them gives, so it aggregates the same way.
7885        let out = compile_and_emit_with(
7886            "SELECT Post { owners := (.<posts[is Person] ?? .<posts[is Person]) { name } }",
7887            &make_schema(),
7888        );
7889        assert!(
7890            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7891            "the arms are aggregated:\n{}",
7892            out.sql
7893        );
7894    }
7895
7896    #[test]
7897    fn test_a_union_of_correlated_walks() {
7898        // `(.<a[is T] union .<b[is T]) { id }` — each operand hangs off the
7899        // enclosing row, so none can be hoisted into a CTE the way a
7900        // standalone union's operands are; they are read as one set instead.
7901        let out = compile_and_emit_with(
7902            "SELECT Post { owners := (SELECT (.<posts[is Person] UNION .<posts[is Person]) { name } LIMIT 1) }",
7903            &make_schema(),
7904        );
7905        assert!(out.sql.contains("UNION ALL"), "one arm per operand:\n{}", out.sql);
7906        assert!(
7907            out.sql.contains("AS \"_u\""),
7908            "the arms are read as one set:\n{}",
7909            out.sql
7910        );
7911    }
7912
7913    #[test]
7914    fn test_a_for_loop_whose_body_updates() {
7915        // Driven from the iteration with `UPDATE … FROM`: the select-bodied
7916        // form uses a LATERAL, which cannot hold DML. Verified live -- three
7917        // rows iterated, three rows updated.
7918        let out = compile_and_emit_with(
7919            "FOR p IN (SELECT Person) UNION (UPDATE Person FILTER .id = p.id SET { name := $n })",
7920            &make_schema(),
7921        );
7922        assert!(out.sql.contains("UPDATE \"public\".\"Person\""), "{}", out.sql);
7923        assert!(
7924            out.sql.contains("FROM \"_for_p\""),
7925            "driven from the iteration:\n{}",
7926            out.sql
7927        );
7928        assert!(
7929            !out.sql.contains("LATERAL"),
7930            "DML cannot sit in a LATERAL:\n{}",
7931            out.sql
7932        );
7933    }
7934
7935    #[test]
7936    fn test_a_for_loop_body_appends_the_row_it_is_iterating() {
7937        let out = compile_and_emit(
7938            "WITH t := (SELECT Post) FOR q IN t UNION (UPDATE Person FILTER .id = $i SET { posts += q })",
7939        );
7940        // One junction row per iteration, taken from the iteration itself --
7941        // not every Post, which an uncorrelated value subquery would append.
7942        assert!(
7943            out.sql.contains("\"_iter\"") && out.sql.contains("FROM \"_ids\""),
7944            "junction rows driven from the iteration:\n{}",
7945            out.sql
7946        );
7947        assert!(
7948            !out.sql.contains("LATERAL"),
7949            "DML cannot sit in a LATERAL:\n{}",
7950            out.sql
7951        );
7952    }
7953
7954    #[test]
7955    fn test_select_on_a_for_loop_variable_reads_only_its_row() {
7956        let out = compile_and_emit("FOR p IN (SELECT Person) UNION (SELECT (SELECT p) { name })");
7957        assert!(
7958            out.sql.contains("= \"_for_p\".\"v\""),
7959            "narrowed to the row the variable holds:\n{}",
7960            out.sql
7961        );
7962    }
7963
7964    #[test]
7965    fn test_a_walk_off_a_for_loop_variable_reads_the_binding() {
7966        // The rows a sibling CTE just wrote are not in the base table yet, so
7967        // a walk that re-reads the table finds nothing.
7968        let out = compile_and_emit("WITH ps := (SELECT Post) FOR q IN ps UNION (SELECT Person FILTER .name = q.title)");
7969        assert!(
7970            out.sql.contains("FROM \"ps\""),
7971            "the walk reads the binding:\n{}",
7972            out.sql
7973        );
7974    }
7975
7976    #[test]
7977    fn test_a_mutating_for_loop_binding_becomes_sibling_ctes() {
7978        let out = compile_and_emit(
7979            "WITH made := (FOR n IN {'a', 'b'} UNION (INSERT Post { title := n })) SELECT count(made)",
7980        );
7981        assert!(
7982            out.sql.contains("\"made__ids\" AS (\nINSERT INTO"),
7983            "the insert is its own top-level CTE:\n{}",
7984            out.sql
7985        );
7986        assert!(
7987            !out.sql.contains("LATERAL"),
7988            "DML cannot sit in a LATERAL:\n{}",
7989            out.sql
7990        );
7991    }
7992
7993    #[test]
7994    fn test_count_of_a_binding_counts_its_rows() {
7995        // Read as an ordinary expression a binding becomes a scalar subquery,
7996        // which aborts on the second row.
7997        let out = compile_and_emit("WITH people := (SELECT Person) SELECT count(people)");
7998        assert!(out.sql.contains("(SELECT count(*) FROM \"people\")"), "{}", out.sql);
7999    }
8000
8001    #[test]
8002    fn test_dividing_integers_yields_a_float() {
8003        for (query, expected) in [
8004            ("SELECT 100 / 133", "((100)::float8 / 133)"),
8005            ("SELECT Person { a := .age / 2 }", "::float8 / 2)"),
8006            ("SELECT Person.age / 2", "::float8 / 2)"),
8007            (
8008                "WITH total := max({133}), current := 38 - 1 SELECT 100 / total * current",
8009                "(((100)::float8 / (SELECT \"v\" FROM \"total\")) * (SELECT \"v\" FROM \"current\"))",
8010            ),
8011            (
8012                "WITH p := (SELECT Person LIMIT 1), index := p.age - 1, total := max(Person.age) SELECT 100 / total * index",
8013                "((100)::float8 / (SELECT \"v\" FROM \"total\"))",
8014            ),
8015        ] {
8016            let out = compile_and_emit(query);
8017            assert!(out.sql.contains(expected), "{query}:\n{}", out.sql);
8018        }
8019    }
8020
8021    #[test]
8022    fn test_dividing_anything_but_two_integers_is_left_to_postgres() {
8023        for query in [
8024            "SELECT 100 // 133",
8025            "SELECT 100 / 1.5",
8026            "SELECT <decimal>100 / <decimal>3",
8027            "SELECT 100 / <decimal>3",
8028        ] {
8029            let out = compile_and_emit(query);
8030            assert!(!out.sql.contains("::float8 /"), "{query}:\n{}", out.sql);
8031        }
8032    }
8033
8034    #[test]
8035    fn test_a_free_object_under_a_condition_stays_an_object() {
8036        for query in [
8037            "SELECT { a := 1 } IF true ELSE {}",
8038            "SELECT {} IF true ELSE { a := 1 }",
8039            "SELECT { a := 1 } IF true ELSE {} LIMIT 1",
8040        ] {
8041            let out = compile_and_emit(query);
8042            assert!(
8043                matches!(&out.shape.root, ShapeNode::Object { type_name: None, pointers, .. } if pointers.len() == 1),
8044                "{query}: {:?}\n{}",
8045                out.shape.root,
8046                out.sql
8047            );
8048        }
8049    }
8050
8051    #[test]
8052    fn test_exists_of_a_binding_asks_for_any_row() {
8053        let out = compile_and_emit("WITH people := (SELECT Person) SELECT exists people");
8054        assert!(out.sql.contains("EXISTS(SELECT 1 FROM \"people\")"), "{}", out.sql);
8055
8056        let out = compile_and_emit("WITH names := {'a', 'b'} SELECT exists names");
8057        assert!(
8058            out.sql
8059                .contains("EXISTS(SELECT 1 FROM \"names\" WHERE \"v\" IS NOT NULL)"),
8060            "{}",
8061            out.sql
8062        );
8063    }
8064
8065    #[test]
8066    fn test_a_conditional_insert_beside_a_read_branch_unions_both() {
8067        let out = compile_and_emit(
8068            "WITH existing := (SELECT Person FILTER .name = $n LIMIT 1) \
8069             SELECT (existing IF EXISTS existing ELSE (INSERT Person { name := $n })) { name }",
8070        );
8071        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
8072        assert!(
8073            out.sql.contains("INSERT INTO") && out.sql.contains("WHERE (NOT EXISTS(SELECT 1 FROM \"existing\"))"),
8074            "the insert carries the condition itself:\n{}",
8075            out.sql
8076        );
8077    }
8078
8079    /// `@pylon.abstract` — unlike an interface it is materialised as nothing
8080    /// at all, so nothing can read a relation under its name. Modelled on
8081    /// `Person`, the one test type carrying the `id` every implementor of a
8082    /// real abstract has.
8083    fn make_schema_with_a_plain_abstract() -> SchemaDescriptor {
8084        let mut schema = make_schema();
8085        let mut archivable = schema
8086            .types
8087            .iter()
8088            .find(|t| t.name == "Person")
8089            .expect("Person is in the test schema")
8090            .clone();
8091        archivable.name = "Archivable".into();
8092        archivable.table = "Archivable".into();
8093        archivable.abstract_ = true;
8094        archivable.materialized = false;
8095        archivable.links = vec![];
8096        archivable.multilinks = vec![];
8097        schema.types.push(archivable);
8098        let person = schema
8099            .types
8100            .iter_mut()
8101            .find(|t| t.name == "Person")
8102            .expect("just found");
8103        person.parents.push("default::Archivable".into());
8104        schema
8105    }
8106
8107    #[test]
8108    fn test_an_intersection_with_a_plain_abstract_fans_out() {
8109        let schema = make_schema_with_a_plain_abstract();
8110        let out = compile_and_emit_with("SELECT Post { who := [is default::Archivable].name }", &schema);
8111        assert!(
8112            !out.sql.contains("\"Archivable\""),
8113            "a plain abstract backs no relation:\n{}",
8114            out.sql
8115        );
8116        assert!(
8117            out.sql.contains("\"public\".\"Person\""),
8118            "read from the type that carries the column:\n{}",
8119            out.sql
8120        );
8121    }
8122
8123    /// The same plain abstract, but keeping `Person`'s `company` link so a
8124    /// backlink can be narrowed to it the way jaldis narrows one to
8125    /// `default::Creatable`.
8126    fn make_schema_with_a_linking_plain_abstract() -> SchemaDescriptor {
8127        let mut schema = make_schema_with_a_plain_abstract();
8128        let person_links = schema
8129            .types
8130            .iter()
8131            .find(|t| t.name == "Person")
8132            .expect("Person is in the test schema")
8133            .links
8134            .clone();
8135        let archivable = schema
8136            .types
8137            .iter_mut()
8138            .find(|t| t.name == "Archivable")
8139            .expect("just added");
8140        archivable.links = person_links;
8141        schema
8142    }
8143
8144    #[test]
8145    fn test_a_plain_abstract_in_a_subquery_fans_out() {
8146        let schema = make_schema_with_a_plain_abstract();
8147        let out = compile_and_emit_with(
8148            "SELECT Person FILTER .id IN (SELECT default::Archivable FILTER .age > 1).id",
8149            &schema,
8150        );
8151        assert!(
8152            !out.sql.contains("\"Archivable\""),
8153            "a plain abstract backs no relation:\n{}",
8154            out.sql
8155        );
8156    }
8157
8158    #[test]
8159    fn test_a_backlink_narrowed_to_a_plain_abstract_fans_out() {
8160        let schema = make_schema_with_a_linking_plain_abstract();
8161        let out = compile_and_emit_with(
8162            "SELECT Company FILTER .<company[is default::Archivable].name = 'a'",
8163            &schema,
8164        );
8165        assert!(
8166            !out.sql.contains("\"Archivable\""),
8167            "a plain abstract backs no relation:\n{}",
8168            out.sql
8169        );
8170    }
8171
8172    /// `Account` as a real interface (a view), with a plain abstract only its
8173    /// implementor carries — jaldis reads `[is default::Auditable].*` off
8174    /// `marketplace::IdentifiableListing` exactly this way.
8175    fn make_interface_schema_with_a_plain_abstract() -> SchemaDescriptor {
8176        let mut schema = make_interface_schema();
8177        let mut archivable = schema
8178            .types
8179            .iter()
8180            .find(|t| t.name == "Individual")
8181            .expect("Individual is in the interface schema")
8182            .clone();
8183        archivable.name = "Archivable".into();
8184        archivable.table = "Archivable".into();
8185        archivable.abstract_ = true;
8186        archivable.materialized = false;
8187        archivable.interfaces = vec![];
8188        archivable.links = vec![];
8189        archivable.multilinks = vec![];
8190        archivable.computed = vec![];
8191        schema.types.push(archivable);
8192        for t in schema.types.iter_mut() {
8193            match t.name.as_str() {
8194                // A view to read, so the splat cannot fall back to it.
8195                "Account" => t.materialized = true,
8196                "Individual" => t.parents.push("default::Archivable".into()),
8197                _ => {}
8198            }
8199        }
8200        schema
8201    }
8202
8203    #[test]
8204    fn test_a_splat_over_a_plain_abstract_fans_out() {
8205        let schema = make_interface_schema_with_a_plain_abstract();
8206        let out = compile_and_emit_with("SELECT Account { id, [is default::Archivable].* }", &schema);
8207        assert!(
8208            !out.sql.contains("\"Archivable\""),
8209            "a plain abstract backs no relation:\n{}",
8210            out.sql
8211        );
8212        assert!(
8213            out.sql.contains("first_name"),
8214            "the splat still reaches the mixin's own pointers:\n{}",
8215            out.sql
8216        );
8217    }
8218
8219    #[test]
8220    fn test_an_aggregate_over_a_relative_walk_runs_inside_the_subquery() {
8221        // Compiled as an expression the walk stands for the array of its
8222        // elements, leaving the aggregate with an array argument.
8223        let out = compile_and_emit("SELECT Person { latest := max(.posts.title) }");
8224        assert!(out.sql.contains("(SELECT max("), "{}", out.sql);
8225        assert!(!out.sql.contains("max(ARRAY("), "{}", out.sql);
8226    }
8227
8228    /// `Company.staff` — a computed standing for a narrowed backlink, the
8229    /// shape jaldis reaches for wherever one type owns rows of another.
8230    fn make_schema_with_a_computed_backlink() -> SchemaDescriptor {
8231        use crate::schema::ComputedDescriptor;
8232        let mut schema = make_schema();
8233        let company = schema
8234            .types
8235            .iter_mut()
8236            .find(|t| t.name == "Company")
8237            .expect("Company is in the test schema");
8238        company.computed = vec![ComputedDescriptor {
8239            name: "staff".into(),
8240            expression: ".<company[is default::Person]".into(),
8241            return_type: None,
8242            link_target: None,
8243            link_multi: false,
8244        }];
8245        schema
8246    }
8247
8248    #[test]
8249    fn test_a_computed_backlink_reads_the_objects_it_lands_on() {
8250        let schema = make_schema_with_a_computed_backlink();
8251        let out = compile_and_emit_with("SELECT Company { staff: { name } }", &schema);
8252        assert!(
8253            out.sql.contains("\"public\".\"Person\""),
8254            "the walk reaches Person:\n{}",
8255            out.sql
8256        );
8257    }
8258
8259    #[test]
8260    fn test_a_computed_backlinks_modifiers_belong_to_what_it_lands_on() {
8261        // Bound to the declaring type instead, `.name` is a field Company has
8262        // not got and the query is rejected outright.
8263        let schema = make_schema_with_a_computed_backlink();
8264        let out = compile_and_emit_with("SELECT Company.staff { name } FILTER .name = 'ada'", &schema);
8265        assert!(out.sql.contains("'ada'"), "{}", out.sql);
8266        assert!(
8267            out.sql.contains("\"public\".\"Person\""),
8268            "filtered on the Person the walk lands on:\n{}",
8269            out.sql
8270        );
8271    }
8272
8273    #[test]
8274    fn test_an_unreserved_keyword_names_a_free_shape_field() {
8275        // `last`, `first` and `order` are unreserved keywords, so a shape can
8276        // carry them. Read as a set literal, the `:=` has nowhere to go.
8277        let out = compile_and_emit("SELECT { last := 2, first := 1, order := 3 }");
8278        assert!(out.sql.contains("\"last\""), "{}", out.sql);
8279        assert!(out.sql.contains("\"first\""), "{}", out.sql);
8280        assert!(out.sql.contains("\"order\""), "{}", out.sql);
8281    }
8282
8283    #[test]
8284    fn test_a_shaped_sub_select_over_a_type_is_an_object() {
8285        // Shapeless it stands for the row's key; with a shape it is the object
8286        // the shape reads, which used to be refused outright.
8287        let out = compile_and_emit("SELECT Person { latest := (SELECT Post { title } LIMIT 1) }");
8288        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8289    }
8290
8291    #[test]
8292    fn test_a_walk_inside_a_path_selects_shape_finds_its_row() {
8293        // The shape is compiled against the type the walk landed on, so a
8294        // relative path inside it has that row to resolve against.
8295        let out = compile_and_emit("SELECT Person.posts { mine := (SELECT Post FILTER .title = .title LIMIT 1) }");
8296        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
8297    }
8298
8299    #[test]
8300    fn test_a_walk_off_a_binding_in_a_free_select() {
8301        // A free select has no row of its own, so the walk is read back as one
8302        // subquery; resolved as a type name the binding is simply unknown.
8303        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := p.company.name }");
8304        assert!(out.sql.contains("\"p\""), "{}", out.sql);
8305        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
8306    }
8307
8308    #[test]
8309    fn test_a_free_selects_filter_can_root_at_a_binding() {
8310        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := 1 } FILTER p.company.name = 'Acme'");
8311        assert!(out.sql.contains("'Acme'"), "{}", out.sql);
8312    }
8313
8314    #[test]
8315    fn test_a_trailing_shape_on_a_field_access_select() {
8316        let out = compile_and_emit("SELECT (SELECT Person).company { name }");
8317        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8318        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
8319    }
8320
8321    #[test]
8322    fn test_a_union_of_walks_names_each_branch() {
8323        // A walk names no relation, and a union is emitted one relation per
8324        // branch — so each is hoisted into a CTE of its own first.
8325        let out = compile_and_emit(
8326            "WITH p := (SELECT Person FILTER .name = $a), q := (SELECT Person FILTER .name = $b), \
8327             t := (SELECT p.posts UNION q.posts) SELECT t { title }",
8328        );
8329        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
8330        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8331    }
8332
8333    #[test]
8334    fn test_sibling_for_loops_each_get_their_own_iterator() {
8335        // One WITH clause cannot hold `_for_p` twice, and two loops written
8336        // over the same variable name are ordinary.
8337        let out = compile_and_emit(
8338            "WITH a := (SELECT Post), b := (SELECT Post), \
8339             x := (FOR p IN a UNION (INSERT Person { name := p.title, age := 1 })), \
8340             y := (FOR p IN b UNION (INSERT Person { name := p.title, age := 2 })) \
8341             SELECT x",
8342        );
8343        assert!(out.sql.contains("\"_for_p\" AS"), "{}", out.sql);
8344        assert!(out.sql.contains("\"_for_p__1\" AS"), "{}", out.sql);
8345    }
8346
8347    #[test]
8348    fn test_a_nested_for_carries_the_outer_loops_key() {
8349        // The inner iterator has the outer one in its FROM, and the insert
8350        // joins the two back on the key it carries.
8351        let out = compile_and_emit(
8352            "WITH made := (FOR p IN (SELECT Person) UNION ( \
8353               FOR q IN p.posts UNION (INSERT Company { name := q.title }) \
8354             )) SELECT count(made)",
8355        );
8356        assert!(
8357            out.sql.contains("\"_outer\""),
8358            "the inner iterator carries the bond:\n{}",
8359            out.sql
8360        );
8361        assert!(
8362            out.sql.contains("JOIN \"_for_p\" ON"),
8363            "the insert reads both loop variables:\n{}",
8364            out.sql
8365        );
8366        assert!(
8367            !out.sql.contains("LATERAL (\nINSERT"),
8368            "DML cannot sit in a LATERAL:\n{}",
8369            out.sql
8370        );
8371    }
8372
8373    #[test]
8374    fn test_a_link_value_can_be_a_walk_off_a_binding() {
8375        // `(select placed.line_items limit 1)` — the walk names no type of its
8376        // own, so reading one off it used to report the path as no type at all.
8377        let mut schema = make_schema();
8378        let company = schema
8379            .types
8380            .iter_mut()
8381            .find(|t| t.name == "Company")
8382            .expect("make_schema declares Company");
8383        company.properties.push(PropertyDescriptor {
8384            name: "id".into(),
8385            pg_type: "uuid".into(),
8386            nullable: false,
8387            default_sql: None,
8388            default_pyql: None,
8389            description: None,
8390            check_constraints: vec![],
8391            is_exclusive: true,
8392            is_pk: true,
8393            is_readonly: true,
8394            rewrites: vec![],
8395            tuple_members: None,
8396            column_type: None,
8397        });
8398        let out = compile_and_emit_with(
8399            "WITH others := (SELECT Person) \
8400             SELECT (INSERT Person { name := $n, age := 1, company := (SELECT others.company LIMIT 1) })",
8401            &schema,
8402        );
8403        assert!(out.sql.contains("\"company_id\""), "{}", out.sql);
8404    }
8405
8406    #[test]
8407    fn test_a_shape_over_a_coalesce_of_walks() {
8408        // Each operand is a walk off the enclosing row, and every one after the
8409        // first only stands in when the ones before it are empty.
8410        let schema = make_schema_with_computed_links();
8411        let out = compile_and_emit_with("SELECT Person { x := (.published ?? .plain) { title } }", &schema);
8412        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
8413        assert!(
8414            out.sql.contains("NOT EXISTS"),
8415            "the later branch only stands in when the first is empty:\n{}",
8416            out.sql
8417        );
8418    }
8419
8420    #[test]
8421    fn test_an_aggregate_over_a_walk_onto_objects_counts_its_rows() {
8422        // Falling through instead put the aggregate back through the very
8423        // expression route that sent it here, and the recursion took the
8424        // process down with it.
8425        let out = compile_and_emit("WITH c := (SELECT Company) SELECT c { n := count(.<company[is default::Person]) }");
8426        assert!(out.sql.contains("count("), "{}", out.sql);
8427        assert!(out.sql.contains("\"public\".\"Person\""), "{}", out.sql);
8428    }
8429
8430    #[test]
8431    fn an_aggregate_over_a_computed_set_counts_inside_a_subquery() {
8432        // `count(.plain)` is one step, so the junction-only count a stored
8433        // multi-link gets cannot apply — it has no junction to read. Taking
8434        // the walk as a value instead made the whole select an aggregate
8435        // query, which Postgres rejects ("column must appear in the GROUP BY
8436        // clause").
8437        let schema = make_schema_with_computed_links();
8438        let out = compile_and_emit_with("SELECT Person { n := count(.plain) }", &schema);
8439        assert!(
8440            !out.sql.contains("count((SELECT"),
8441            "the aggregate belongs inside the walk's subquery, not around it:\n{}",
8442            out.sql
8443        );
8444        assert!(
8445            out.sql.contains("(SELECT count(") && out.sql.contains("\"public\".\"Post\""),
8446            "it counts the rows the computed's own path lands on:\n{}",
8447            out.sql
8448        );
8449    }
8450
8451    /// The specialized count for a stored multi-link reads its junction table
8452    /// alone — the computed case above must not have pulled it off that route.
8453    #[test]
8454    fn an_aggregate_over_a_stored_multi_link_still_counts_its_junction_rows() {
8455        let schema = make_schema_with_computed_links();
8456        let out = compile_and_emit_with("SELECT Person { n := count(.posts) }", &schema);
8457        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
8458        assert!(
8459            !out.sql.contains("JOIN \"public\".\"Post\""),
8460            "counting junction rows needs no join onto the targets:\n{}",
8461            out.sql
8462        );
8463    }
8464
8465    #[test]
8466    fn test_a_select_can_name_its_own_result() {
8467        // A select may name its own result: the alias names it and the
8468        // select's own clauses may read it by that name.
8469        let out = compile_and_emit("SELECT oldest := max(Person.age)");
8470        assert!(out.sql.contains("max("), "{}", out.sql);
8471        let filtered = compile_and_emit("SELECT n := Person.age FILTER n > 18");
8472        assert!(
8473            filtered.sql.contains("18"),
8474            "the alias resolves to the result:\n{}",
8475            filtered.sql
8476        );
8477    }
8478
8479    #[test]
8480    fn test_exists_sees_a_computed_pointer() {
8481        // The suggester could see the name the resolver had just reported as
8482        // unknown — `has no link or property 'published'. Did you mean
8483        // 'published'?`
8484        let schema = make_schema_with_computed_links();
8485        let out = compile_and_emit_with("SELECT Person FILTER EXISTS .published", &schema);
8486        assert!(out.sql.contains("EXISTS"), "{}", out.sql);
8487        assert!(
8488            out.sql.contains("\"public\".\"Post\""),
8489            "the computed's own path:\n{}",
8490            out.sql
8491        );
8492    }
8493
8494    #[test]
8495    fn test_walking_through_a_computed_field_access() {
8496        use crate::schema::ComputedDescriptor;
8497        // `parent := (select .parents … limit 1).parent` — the modifiers belong
8498        // to the step before the field access, not to what it lands on.
8499        let mut schema = make_schema_with_computed_links();
8500        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
8501        person.computed.push(ComputedDescriptor {
8502            name: "latest_title".into(),
8503            expression: "(select .posts order by .title desc limit 1).title".into(),
8504            return_type: None,
8505            link_target: None,
8506            link_multi: false,
8507        });
8508        let out = compile_and_emit_with("SELECT Person { x := .latest_title }", &schema);
8509        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8510        assert!(
8511            out.sql.contains("LIMIT"),
8512            "the computed's own limit survives:\n{}",
8513            out.sql
8514        );
8515    }
8516
8517    #[test]
8518    fn test_a_computed_field_access_onto_an_object_takes_a_shape() {
8519        use crate::schema::ComputedDescriptor;
8520        // Compiled as an expression, the pointer came back as the bare id of
8521        // the object it lands on, whatever shape was written on it.
8522        let mut schema = make_schema_with_computed_links();
8523        let post = schema.types.iter_mut().find(|t| t.name == "Post").expect("Post");
8524        post.links.push(LinkDescriptor {
8525            name: "author".into(),
8526            target: "default::Person".into(),
8527            nullable: true,
8528            through: None,
8529            description: None,
8530            default_pyql: None,
8531            is_exclusive: false,
8532            is_readonly: false,
8533            rewrites: vec![],
8534            on_delete: vec![],
8535        });
8536        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
8537        person.computed.retain(|c| c.name != "looper");
8538        person.computed.push(ComputedDescriptor {
8539            name: "latest_author".into(),
8540            expression: "((select .posts order by .title desc limit 1)).author".into(),
8541            return_type: None,
8542            link_target: None,
8543            link_multi: false,
8544        });
8545        let out = compile_and_emit_with("SELECT Person { latest_author: { name } }", &schema);
8546        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8547        assert!(
8548            out.sql.contains("\"title\" DESC"),
8549            "the order stays on the posts:\n{}",
8550            out.sql
8551        );
8552        assert!(
8553            out.sql.contains("LIMIT"),
8554            "the computed's own limit survives:\n{}",
8555            out.sql
8556        );
8557        let splat = compile_and_emit_with("SELECT Person { * }", &schema);
8558        assert!(
8559            !splat.sql.contains("latest_author"),
8560            "`*` leaves links out:\n{}",
8561            splat.sql
8562        );
8563    }
8564
8565    #[test]
8566    fn test_a_narrowed_pointer_can_carry_a_shape() {
8567        // `[is T].posts: { title }` — the parser stopped at the `:`, and the
8568        // route behind it resolved a stored property and nothing else, so a
8569        // link reported itself as the name it had just been asked for.
8570        let out = compile_and_emit("SELECT Company { x := 1, [is default::Person].posts: { title } }");
8571        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8572        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
8573    }
8574
8575    #[test]
8576    fn test_select_type_name_as_a_path_step() {
8577        let out = compile_and_emit("SELECT Person.__type__");
8578        assert!(out.sql.contains("ROW('default::Person')"), "{}", out.sql);
8579        assert!(out.sql.contains("\"Person\" AS \"t0\""), "{}", out.sql);
8580    }
8581
8582    #[test]
8583    fn test_aggregate_over_a_with_bound_object_set() {
8584        let out = compile_and_emit("WITH people := (SELECT Person) SELECT max(people.age)");
8585        assert!(out.sql.contains("\"people\" AS ("));
8586        assert!(out.sql.contains("max("));
8587        // Reads the binding, not the base table again.
8588        assert!(!out.sql.contains("FROM \"default\".\"Person\" AS \"t1\""));
8589    }
8590
8591    #[test]
8592    fn test_select_union_of_two_object_bindings() {
8593        let out = compile_and_emit("WITH a := (SELECT Person LIMIT 1), b := (SELECT Person) SELECT (a UNION b)");
8594        assert!(out.sql.contains("SELECT * FROM \"a\""));
8595        assert!(out.sql.contains("UNION ALL"));
8596        assert!(out.sql.contains("SELECT * FROM \"b\""));
8597    }
8598
8599    #[test]
8600    fn test_select_union_of_different_object_types_is_rejected() {
8601        let schema = make_schema();
8602        let ast = parse::parse("WITH a := (SELECT Person), b := (SELECT Company) SELECT (a UNION b)").unwrap();
8603        let Err(err) = ir::compile(&ast, &schema) else {
8604            panic!("union of unrelated types must be rejected")
8605        };
8606        assert!(format!("{err}").contains("UNION"));
8607    }
8608
8609    #[test]
8610    fn test_for_union_as_a_function_body() {
8611        let schema = make_schema();
8612        let descriptor = FunctionDescriptor {
8613            name: "recent".into(),
8614            module: "default".into(),
8615            params: vec![],
8616            return_pg_type: "default::Person".into(),
8617            return_is_object: true,
8618            return_is_set: true,
8619            return_is_polymorphic: false,
8620            volatility: "stable".into(),
8621            body: "for n in {1, 2} union (select Person filter .age = n)".into(),
8622        };
8623        let ir = ir::compile_fn_body(&descriptor, &schema).expect("function body must compile");
8624        let body = emit_fn_body(&ir);
8625        assert!(body.contains("VALUES"), "{body}");
8626        assert!(body.contains("CROSS JOIN LATERAL"), "{body}");
8627        assert!(body.contains("\"_body\".*"), "{body}");
8628    }
8629
8630    #[test]
8631    fn test_with_binding_in_a_computed_reads_the_enclosing_object() {
8632        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
8633        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8634    }
8635
8636    #[test]
8637    fn test_subject_path_update_names_its_own_alias() {
8638        let mut schema = make_schema();
8639        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
8640        company.properties.insert(
8641            0,
8642            PropertyDescriptor {
8643                name: "id".into(),
8644                pg_type: "uuid".into(),
8645                nullable: false,
8646                default_sql: Some("uuidv7()".into()),
8647                default_pyql: None,
8648                description: None,
8649                check_constraints: vec![],
8650                is_exclusive: true,
8651                is_pk: true,
8652                is_readonly: true,
8653                rewrites: vec![],
8654                tuple_members: None,
8655                column_type: None,
8656            },
8657        );
8658        // The row set narrowing the update compares against *that update's*
8659        // alias; a bare `id` would be ambiguous once a nested statement's CTE
8660        // joins the FROM.
8661        let out = compile_and_emit_with("SELECT (UPDATE Person.company SET { name := 'x' }) { name }", &schema);
8662        assert!(out.sql.contains("\"t1\".\"id\" = ANY("), "{}", out.sql);
8663    }
8664
8665    #[test]
8666    fn test_an_update_past_a_from_returns_only_its_own_columns() {
8667        // `UPDATE … FROM cte … RETURNING *` returns the joined relation's
8668        // columns as well, so a CTE built that way carries two `id`s and any
8669        // later reference to one is ambiguous.
8670        let out = compile_and_emit(
8671            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
8672        );
8673        assert!(out.sql.contains("FROM \"_nested_dml_0\""), "{}", out.sql);
8674        assert!(
8675            out.sql.contains("RETURNING \"t0\".*"),
8676            "the update names its own target:\n{}",
8677            out.sql
8678        );
8679    }
8680
8681    #[test]
8682    fn test_nested_insert_in_a_mutation_written_as_a_free_set() {
8683        let out = compile_and_emit(
8684            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
8685        );
8686        assert!(
8687            out.sql.contains("INSERT INTO \"public\".\"Company\""),
8688            "the hoisted insert is defined, not just referenced:\n{}",
8689            out.sql
8690        );
8691    }
8692
8693    #[test]
8694    fn test_nested_insert_as_a_link_value_in_an_update() {
8695        // The hoisted insert sits beside the update rather than inside it, so
8696        // the update needs a FROM to read its id, and its own `id` has to name
8697        // the update's alias — a bare one could mean either relation.
8698        let out = compile_and_emit(
8699            "SELECT (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) { name }",
8700        );
8701        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8702        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
8703        assert!(
8704            out.sql.contains("FROM \"_nested_dml_0\""),
8705            "the update reads the hoisted CTE:\n{}",
8706            out.sql
8707        );
8708    }
8709
8710    #[test]
8711    fn test_nested_insert_as_a_link_value() {
8712        let out = compile_and_emit(
8713            "SELECT (INSERT Person { name := 'a', company := (INSERT Company { name := 'c' }) }) { name }",
8714        );
8715        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
8716        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8717    }
8718
8719    #[test]
8720    fn test_nested_insert_in_a_one_element_set_is_that_insert() {
8721        let out = compile_and_emit(
8722            "SELECT (INSERT Person { name := 'a', company := { (INSERT Company { name := 'c' }) } }) { name }",
8723        );
8724        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8725    }
8726
8727    #[test]
8728    fn test_insert_with_no_assignments_uses_default_values() {
8729        let out = compile_and_emit("SELECT (INSERT Person { name := 'a', company := (INSERT Company {}) }) { name }");
8730        assert!(out.sql.contains("DEFAULT VALUES"), "{}", out.sql);
8731    }
8732
8733    #[test]
8734    fn test_backlink_narrowed_to_an_interface_its_implementor_declares() {
8735        // `notes` is declared on Individual; `[is Account]` is satisfied by an
8736        // Individual row, so the backlink resolves through the implementor.
8737        use crate::schema::{LinkDescriptor, TypeDescriptor};
8738        let mut schema = make_interface_schema();
8739        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
8740        schema.types.push(TypeDescriptor {
8741            name: "Note".into(),
8742            module: "default".into(),
8743            table: "Note".into(),
8744            abstract_: false,
8745            materialized: true,
8746            description: None,
8747            parents: vec![],
8748            interfaces: vec![],
8749            bases: vec![],
8750            properties: individual.properties[..1].to_vec(),
8751            links: vec![],
8752            multilinks: vec![],
8753            computed: vec![],
8754            constraints: vec![],
8755            indexes: vec![],
8756            partition: None,
8757            vector_indexes: vec![],
8758            search_indexes: vec![],
8759            triggers: vec![],
8760            junction: false,
8761            signals: vec![],
8762        });
8763        schema
8764            .types
8765            .iter_mut()
8766            .find(|t| t.name == "Individual")
8767            .unwrap()
8768            .links
8769            .push(LinkDescriptor {
8770                name: "note".into(),
8771                target: "default::Note".into(),
8772                nullable: true,
8773                description: None,
8774                default_pyql: None,
8775                is_exclusive: false,
8776                is_readonly: false,
8777                rewrites: vec![],
8778                on_delete: vec![],
8779                through: None,
8780            });
8781        let out = compile_and_emit_with("SELECT Note FILTER EXISTS .<note[is Account]", &schema);
8782        assert!(out.sql.contains("\"Individual\""), "{}", out.sql);
8783    }
8784
8785    #[test]
8786    fn test_subject_reads_the_row_a_constraint_checks() {
8787        let schema = make_schema();
8788        let sql = ir::compile_constraint_expr("__subject__.age > 18", "default::Person", &schema)
8789            .expect("__subject__ names the row under check");
8790        assert!(sql.contains("\"age\""), "{sql}");
8791    }
8792
8793    #[test]
8794    fn test_free_select_filter_gates_the_result_and_warns() {
8795        let schema = make_schema();
8796        let ast = parse::parse("SELECT count(Person) FILTER (Person.age > 18)").unwrap();
8797        let ir = ir::compile(&ast, &schema).unwrap();
8798        let out = emit(&ir);
8799        assert!(
8800            out.sql.contains("EXISTS("),
8801            "the condition gates the count:\n{}",
8802            out.sql
8803        );
8804        assert!(
8805            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
8806            "a set-valued filter warns: {:?}",
8807            ir.warnings
8808        );
8809    }
8810
8811    #[test]
8812    fn test_free_sub_select_with_modifiers_reads_inline() {
8813        // The inner statement reads the enclosing row, so it cannot become a
8814        // CTE ahead of the FROM clause that defines it.
8815        let out = compile_and_emit(
8816            "SELECT Person { n := (WITH mine := .name SELECT count(Post) FILTER (Post.title = mine)) }",
8817        );
8818        assert!(out.sql.contains("_scalar_sub"), "{}", out.sql);
8819        assert!(!out.sql.contains("WITH"), "nothing is hoisted:\n{}", out.sql);
8820    }
8821
8822    #[test]
8823    fn test_correlated_with_binding_is_not_hoisted() {
8824        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
8825        assert!(
8826            !out.sql.contains("\"own\" AS ("),
8827            "the binding is inlined:\n{}",
8828            out.sql
8829        );
8830        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8831    }
8832
8833    #[test]
8834    fn test_array_literal_binding_is_readable_and_typed() {
8835        let out = compile_and_emit("WITH order := ['a', 'b'] SELECT std::find(order, 'b')");
8836        assert!(out.sql.contains("array_position"), "its type is known:\n{}", out.sql);
8837        assert!(
8838            out.sql.contains("AS v"),
8839            "the binding exposes a value column:\n{}",
8840            out.sql
8841        );
8842    }
8843
8844    #[test]
8845    fn test_any_over_a_single_boolean_is_that_boolean() {
8846        let out = compile_and_emit("SELECT Person FILTER std::any(.age > 18)");
8847        assert!(!out.sql.contains("bool_or"), "no aggregate in WHERE:\n{}", out.sql);
8848        assert!(out.sql.contains("> 18"), "{}", out.sql);
8849    }
8850
8851    #[test]
8852    fn test_comparing_a_value_to_a_set_is_membership() {
8853        let schema = make_schema_with_computed_links();
8854        let out = compile_and_emit_with("SELECT Person FILTER .name = Person.posts.title", &schema);
8855        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8856    }
8857
8858    #[test]
8859    fn test_for_over_a_with_binding_iterates_every_row() {
8860        let out = compile_and_emit(
8861            "WITH names := (SELECT Person.name) FOR n IN names UNION (SELECT Person FILTER .name = n)",
8862        );
8863        assert!(out.sql.contains("FROM \"names\""), "{}", out.sql);
8864        assert!(!out.sql.contains("VALUES"), "the whole set is iterated:\n{}", out.sql);
8865    }
8866
8867    #[test]
8868    fn test_for_body_bindings_stay_inside_the_body() {
8869        // `matching` reads the loop variable, so it cannot be hoisted to the
8870        // statement's own WITH — nothing there can see `_for_n`.
8871        let out = compile_and_emit(
8872            "WITH names := (SELECT Person.name) \
8873             FOR n IN names UNION (WITH matching := (SELECT Person FILTER .name = n) SELECT matching)",
8874        );
8875        let lateral = out.sql.find("CROSS JOIN LATERAL").expect("a lateral body");
8876        let binding = out.sql.find("\"matching\" AS (").expect("the body's binding");
8877        assert!(binding > lateral, "the binding sits inside the body:\n{}", out.sql);
8878    }
8879
8880    #[test]
8881    fn test_array_valued_binding_picks_the_array_overload() {
8882        // The binding carries `text[]`, so the call resolves to the array
8883        // overload even though the value itself is no longer an array literal.
8884        let out = compile_and_emit("WITH l := <array<std::str>>$0 SELECT std::find(l, 'a')");
8885        assert!(out.sql.contains("array_position"), "{}", out.sql);
8886        assert!(!out.sql.contains("strpos"), "{}", out.sql);
8887    }
8888
8889    #[test]
8890    fn test_aggregate_over_a_path_in_a_filter_is_a_subquery() {
8891        let out = compile_and_emit("SELECT Person FILTER .age = std::max(Person.age)");
8892        assert!(
8893            out.sql.contains("(SELECT max("),
8894            "an aggregate in WHERE needs its own query:\n{}",
8895            out.sql
8896        );
8897    }
8898
8899    #[test]
8900    fn test_distinct_over_a_single_value_is_that_value() {
8901        let out = compile_and_emit("SELECT Person { n := (DISTINCT .name) }");
8902        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8903        assert!(!out.sql.contains("DISTINCT \""), "{}", out.sql);
8904    }
8905
8906    #[test]
8907    fn test_narrowing_an_interface_joins_the_implementor_table() {
8908        // `first_name` lives on Individual's own table; the Account view has
8909        // only the columns every implementor shares.
8910        let mut schema = make_interface_schema();
8911        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
8912        account.materialized = true;
8913        let out = compile_and_emit_with("SELECT Account[is Individual].first_name", &schema);
8914        assert!(
8915            out.sql.contains("JOIN \"public\".\"Individual\""),
8916            "the implementor's own table is joined:\n{}",
8917            out.sql
8918        );
8919        assert!(out.sql.contains("\"first_name\""), "{}", out.sql);
8920    }
8921
8922    #[test]
8923    fn test_sub_select_shape_declares_names_its_filter_and_projection_read() {
8924        let out = compile_and_emit("SELECT Person { n := (SELECT .posts { t := .title } FILTER .t = 'x' LIMIT 1).t }");
8925        assert!(out.sql.contains("'x'"), "{}", out.sql);
8926        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8927    }
8928
8929    #[test]
8930    fn test_assert_over_an_object_set_returns_rows() {
8931        let out = compile_and_emit("SELECT std::assert_distinct((SELECT Person))");
8932        assert!(
8933            out.sql.contains("\"assert_distinct\"(ARRAY(SELECT \"id\""),
8934            "{}",
8935            out.sql
8936        );
8937        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8938        assert!(out.sql.contains("\"Person\" AS \"t1\""), "{}", out.sql);
8939        assert!(!out.sql.contains("unnest("), "{}", out.sql);
8940    }
8941
8942    #[test]
8943    fn test_assert_over_a_for_union_reads_its_rows() {
8944        let out =
8945            compile_and_emit("SELECT std::assert_distinct((FOR n IN {1, 2} UNION (SELECT Person FILTER .age = n)))");
8946        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
8947        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8948    }
8949
8950    #[test]
8951    fn test_declared_computed_with_binding_reads_its_own_object() {
8952        // Validation compiles a declared computed on its own, with no
8953        // enclosing SELECT — `.name` in the binding still means this object's.
8954        use crate::schema::ComputedDescriptor;
8955        let schema = make_schema();
8956        let cd = ComputedDescriptor {
8957            name: "shouted".into(),
8958            expression: "(with own := .name select std::str_upper(own))".into(),
8959            return_type: None,
8960            link_target: None,
8961            link_multi: false,
8962        };
8963        let ir = ir::compile_computed_in_type(&cd, "default::Person", &schema)
8964            .expect("a computed may read the object it is declared on");
8965        assert!(ir.is_some());
8966    }
8967
8968    #[test]
8969    fn test_sub_select_modifiers_scope_to_its_own_subject() {
8970        // `.title` belongs to Post, the sub-select's subject — not to Person,
8971        // where the projection `.author.name` lands.
8972        let mut schema = make_schema();
8973        let post = schema
8974            .types
8975            .iter_mut()
8976            .find(|t| t.name == "Post")
8977            .expect("Post is in the test schema");
8978        post.links.push(LinkDescriptor {
8979            name: "author".into(),
8980            target: "default::Person".into(),
8981            nullable: true,
8982            description: None,
8983            default_pyql: None,
8984            is_exclusive: false,
8985            is_readonly: false,
8986            rewrites: vec![],
8987            on_delete: vec![],
8988            through: None,
8989        });
8990        let out = compile_and_emit_with(
8991            "SELECT Person { a := (SELECT .posts FILTER .title = 'x' LIMIT 1).author.name }",
8992            &schema,
8993        );
8994        assert!(out.sql.contains("'x'"), "{}", out.sql);
8995    }
8996
8997    #[test]
8998    fn test_free_select_set_literal() {
8999        let schema = make_schema();
9000        let ast = parse::parse("SELECT {1, 2, 3}").unwrap();
9001        let ir = ir::compile(&ast, &schema).unwrap();
9002        let out = emit(&ir);
9003        // Three UNION ALL branches
9004        assert_eq!(out.sql.matches("UNION ALL").count(), 2);
9005        assert!(out.sql.contains("1 AS v"));
9006        assert!(out.sql.contains("2 AS v"));
9007        assert!(out.sql.contains("3 AS v"));
9008        assert!(out.sql.contains("ROW(v) AS result"));
9009        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
9010    }
9011
9012    #[test]
9013    fn test_free_select_free_object() {
9014        let schema = make_schema();
9015        let ast = parse::parse("SELECT { foo := 'bar', n := 42 }").unwrap();
9016        let ir = ir::compile(&ast, &schema).unwrap();
9017        let out = emit(&ir);
9018        assert!(out.sql.contains("'bar'"));
9019        assert!(out.sql.contains("42"));
9020        assert!(out.sql.contains("AS result"));
9021        // Shape should describe an object with pointers foo and n
9022        let crate::query::ShapeNode::Object {
9023            pointers, type_name, ..
9024        } = &out.shape.root
9025        else {
9026            panic!("expected Object shape")
9027        };
9028        assert!(type_name.is_none());
9029        assert_eq!(pointers.len(), 2);
9030        assert!(matches!(&pointers[0], crate::query::ShapeNode::Scalar { name, position: 0 } if name == "foo"));
9031        assert!(matches!(&pointers[1], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "n"));
9032    }
9033
9034    #[test]
9035    fn test_free_select_object_with_enum_field_casts_to_text_and_tags_shape() {
9036        // Regression: `select { gender := default::Gender.Male }` failed at
9037        // runtime with "no decoder for composite type element ... " —
9038        // A custom enum's OID is database-assigned, so inside an anonymous ROW()
9039        // composite. free_item_shape/emit_free_select always treated every
9040        // free-object field as a plain untyped Scalar, never casting an
9041        // enum-valued field to ::text (unlike a schema object's emit_scalar,
9042        // which already does this for a real enum-typed property column).
9043        let mut schema = make_schema();
9044        schema.enums.push(crate::schema::EnumDescriptor {
9045            name: "Gender".into(),
9046            module: "default".into(),
9047            members: vec!["Male".into(), "Female".into()],
9048        });
9049        let out = compile_and_emit_with("select { gender := default::Gender.Male }", &schema);
9050        // The enum value is computed once (`'Male'::"public"."Gender" AS
9051        // "_f0"`) and reused for both the `result` composite (cast to
9052        // `::text` there, since an enum's OID is database-assigned inside an
9053        // anonymous ROW()) and the plain per-field column exposed for
9054        // `IrExpr::CteFieldRef` access — so the `::text` cast now applies
9055        // to that computed column, not inline on the enum literal itself.
9056        assert!(
9057            out.sql.contains("'Male'::\"public\".\"Gender\""),
9058            "expected the enum literal, got:\n{}",
9059            out.sql
9060        );
9061        assert!(
9062            out.sql.contains("ROW(\"_f0\"::text) AS result"),
9063            "expected the ROW composite to cast the enum field to text, got:\n{}",
9064            out.sql
9065        );
9066        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
9067            panic!("expected Object shape")
9068        };
9069        assert_eq!(pointers.len(), 1);
9070        assert!(
9071            matches!(
9072                &pointers[0],
9073                crate::query::ShapeNode::Enum { name, position: 0, enum_type }
9074                    if name == "gender" && enum_type == "public::Gender"
9075            ),
9076            "expected Enum-tagged shape, got: {:?}",
9077            pointers[0]
9078        );
9079    }
9080
9081    #[test]
9082    fn test_free_select_bare_enum_literal_casts_to_text_inside_row() {
9083        // Same bug, bare scalar form: `select default::Gender.Male;` (no
9084        // shape/object wrapper) also wraps the value in ROW() for top-level
9085        // top-level decoding.
9086        let mut schema = make_schema();
9087        schema.enums.push(crate::schema::EnumDescriptor {
9088            name: "Gender".into(),
9089            module: "default".into(),
9090            members: vec!["Male".into(), "Female".into()],
9091        });
9092        let out = compile_and_emit_with("select default::Gender.Male", &schema);
9093        assert!(
9094            out.sql.contains("ROW(v::text) AS result"),
9095            "expected ROW(v::text), got:\n{}",
9096            out.sql
9097        );
9098        assert!(
9099            matches!(
9100                &out.shape.root,
9101                crate::query::ShapeNode::Enum { enum_type, .. } if enum_type == "public::Gender"
9102            ),
9103            "expected Enum-tagged shape, got: {:?}",
9104            out.shape.root
9105        );
9106    }
9107
9108    // ── FOR UPDATE / FOR SHARE row-locking clause ──────────────────────────────
9109
9110    #[test]
9111    fn test_for_update_defaults_to_blocking() {
9112        let out = compile_and_emit("SELECT Person FOR UPDATE");
9113        assert!(out.sql.trim_end().ends_with("FOR UPDATE"), "got:\n{}", out.sql);
9114    }
9115
9116    #[test]
9117    fn test_for_update_skip_locked() {
9118        let out = compile_and_emit("SELECT Person FOR UPDATE SKIP LOCKED");
9119        assert!(
9120            out.sql.trim_end().ends_with("FOR UPDATE SKIP LOCKED"),
9121            "got:\n{}",
9122            out.sql
9123        );
9124    }
9125
9126    #[test]
9127    fn test_for_update_nowait() {
9128        let out = compile_and_emit("SELECT Person FOR UPDATE NOWAIT");
9129        assert!(out.sql.trim_end().ends_with("FOR UPDATE NOWAIT"), "got:\n{}", out.sql);
9130    }
9131
9132    #[test]
9133    fn test_for_no_key_update_skip_locked() {
9134        let out = compile_and_emit("SELECT Person FOR NO KEY UPDATE SKIP LOCKED");
9135        assert!(
9136            out.sql.trim_end().ends_with("FOR NO KEY UPDATE SKIP LOCKED"),
9137            "got:\n{}",
9138            out.sql
9139        );
9140    }
9141
9142    #[test]
9143    fn test_for_share() {
9144        let out = compile_and_emit("SELECT Person FOR SHARE");
9145        assert!(out.sql.trim_end().ends_with("FOR SHARE"), "got:\n{}", out.sql);
9146    }
9147
9148    #[test]
9149    fn test_for_key_share_nowait() {
9150        let out = compile_and_emit("SELECT Person FOR KEY SHARE NOWAIT");
9151        assert!(
9152            out.sql.trim_end().ends_with("FOR KEY SHARE NOWAIT"),
9153            "got:\n{}",
9154            out.sql
9155        );
9156    }
9157
9158    #[test]
9159    fn test_for_update_comes_after_order_by_limit_offset_in_emitted_sql() {
9160        // Postgres's own grammar places the locking clause last — confirm
9161        // the emitter matches, not just that all the pieces are present.
9162        let out = compile_and_emit("SELECT Person { name } ORDER BY .name OFFSET 1 LIMIT 5 FOR UPDATE SKIP LOCKED");
9163        let order_pos = out.sql.find("ORDER BY").unwrap();
9164        let offset_pos = out.sql.find("OFFSET").unwrap();
9165        let limit_pos = out.sql.find("LIMIT").unwrap();
9166        let for_pos = out.sql.find("FOR UPDATE").unwrap();
9167        assert!(
9168            order_pos < offset_pos && offset_pos < limit_pos && limit_pos < for_pos,
9169            "got:\n{}",
9170            out.sql
9171        );
9172    }
9173
9174    #[test]
9175    fn test_for_update_combined_with_distinct_is_rejected() {
9176        let ast = parse::parse("SELECT DISTINCT Person FOR UPDATE").expect("parse failed");
9177        let err = ir::compile(&ast, &make_schema())
9178            .err()
9179            .expect("expected a compile error");
9180        assert!(err.to_string().contains("DISTINCT"), "unexpected: {err}");
9181    }
9182
9183    #[test]
9184    fn test_for_update_combined_with_select_over_insert_is_rejected() {
9185        let ast = parse::parse("SELECT (INSERT Person { name := 'Alice' }) { name } FOR UPDATE").expect("parse failed");
9186        let err = ir::compile(&ast, &make_schema())
9187            .err()
9188            .expect("expected a compile error");
9189        assert!(err.to_string().contains("INSERT"), "unexpected: {err}");
9190    }
9191
9192    #[test]
9193    fn test_for_update_on_an_interface_type_is_rejected() {
9194        fn id_prop() -> PropertyDescriptor {
9195            PropertyDescriptor {
9196                name: "id".into(),
9197                pg_type: "uuid".into(),
9198                nullable: false,
9199                default_sql: Some("uuidv7()".into()),
9200                default_pyql: None,
9201                description: None,
9202                check_constraints: vec![],
9203                is_exclusive: true,
9204                is_pk: true,
9205                is_readonly: true,
9206                rewrites: vec![],
9207                tuple_members: None,
9208                column_type: None,
9209            }
9210        }
9211        let schema = SchemaDescriptor {
9212            types: vec![
9213                TypeDescriptor {
9214                    name: "Account".into(),
9215                    module: "default".into(),
9216                    table: "Account".into(),
9217                    abstract_: true,
9218                    materialized: true,
9219                    description: None,
9220                    parents: vec![],
9221                    interfaces: vec![],
9222                    bases: vec![],
9223                    properties: vec![id_prop()],
9224                    links: vec![],
9225                    multilinks: vec![],
9226                    computed: vec![],
9227                    constraints: vec![],
9228                    indexes: vec![],
9229                    partition: None,
9230                    vector_indexes: vec![],
9231                    search_indexes: vec![],
9232                    triggers: vec![],
9233                    junction: false,
9234                    signals: vec![],
9235                },
9236                TypeDescriptor {
9237                    name: "Individual".into(),
9238                    module: "default".into(),
9239                    table: "Individual".into(),
9240                    abstract_: false,
9241                    materialized: true,
9242                    description: None,
9243                    parents: vec![],
9244                    interfaces: vec!["default::Account".into()],
9245                    bases: vec![],
9246                    properties: vec![id_prop()],
9247                    links: vec![],
9248                    multilinks: vec![],
9249                    computed: vec![],
9250                    constraints: vec![],
9251                    indexes: vec![],
9252                    partition: None,
9253                    vector_indexes: vec![],
9254                    search_indexes: vec![],
9255                    triggers: vec![],
9256                    junction: false,
9257                    signals: vec![],
9258                },
9259            ],
9260            scalars: vec![],
9261            enums: vec![],
9262            named_tuples: vec![],
9263            globals: vec![],
9264            functions: vec![],
9265            aliases: vec![],
9266            channels: vec![],
9267            ..Default::default()
9268        };
9269        let ast = parse::parse("SELECT Account FOR UPDATE").expect("parse failed");
9270        let err = ir::compile(&ast, &schema).err().expect("expected a compile error");
9271        assert!(err.to_string().contains("interface"), "unexpected: {err}");
9272    }
9273
9274    #[test]
9275    fn test_schema_select_distinct_emits_distinct_keyword() {
9276        // Regression: compile_stmt's Distinct/Detached unwrap and
9277        // compile_select's own re-derivation of the same unwrap were merged
9278        // into a single pass-through (Phase 2 of the compile_expr merge) —
9279        // confirm `select distinct` still compiles and emits DISTINCT.
9280        let out = compile_and_emit("SELECT DISTINCT Person { name }");
9281        assert!(out.sql.contains("DISTINCT"), "expected DISTINCT in SQL:\n{}", out.sql);
9282    }
9283
9284    #[test]
9285    fn test_schema_select_detached_compiles_as_ordinary_select() {
9286        // Same merge — `detached` at the top level of a schema select is a
9287        // no-op (already independent); confirm it still compiles cleanly
9288        // instead of erroring or double-unwrapping.
9289        let out = compile_and_emit("SELECT DETACHED Person { name }");
9290        assert!(
9291            out.sql.contains("\"name\""),
9292            "expected name column in SQL:\n{}",
9293            out.sql
9294        );
9295    }
9296
9297    #[test]
9298    fn test_detached_subquery_correlates_against_the_outer_row() {
9299        // The anti-join idiom: "the Person with no other Person of the same
9300        // name that is younger". `detached` is what makes the inner `Person` a
9301        // different row from the outer one, so the bare `Person.name` inside
9302        // has to resolve to the *outer* alias. It used to be stripped as a
9303        // no-op, which compiled to `t1.name = t1.name` — a tautology that
9304        // quietly matched every row instead of erroring.
9305        let out = compile_and_emit(
9306            "SELECT Person { name } FILTER NOT EXISTS (\
9307             SELECT DETACHED Person FILTER .name = Person.name AND .age > Person.age)",
9308        );
9309        assert!(
9310            out.sql.contains("\"t1\".\"name\" = \"t0\".\"name\""),
9311            "the inner row should be compared against the outer one:\n{}",
9312            out.sql
9313        );
9314        assert!(
9315            !out.sql.contains("\"t1\".\"name\" = \"t1\".\"name\""),
9316            "self-comparison means the correlation was lost:\n{}",
9317            out.sql
9318        );
9319    }
9320
9321    #[test]
9322    fn test_an_assert_on_a_pointer_checks_its_rows() {
9323        // The pointer's own subquery is bound once and read back through the
9324        // check, rather than appearing in both — and the cast is what makes
9325        // the call legal at all, since PL/pgSQL cannot take a `record[]`.
9326        let out = compile_and_emit("SELECT Person { name, p := assert_exists(.posts { title }) }");
9327        assert!(
9328            out.sql
9329                .contains(r#"cardinality("_pylon"."assert_exists"("_a"."v"::text[]))"#),
9330            "the assert should read the aggregated rows:\n{}",
9331            out.sql
9332        );
9333        assert_eq!(
9334            out.sql.matches("array_agg(ROW(").count(),
9335            1,
9336            "the pointer's subquery should be evaluated once, not once per use:\n{}",
9337            out.sql
9338        );
9339    }
9340
9341    #[test]
9342    fn test_an_assert_on_a_scalar_stays_an_ordinary_call() {
9343        // Only an object pointer takes the wrapper; `assert_exists(.name)` is
9344        // a value expression and must keep compiling as the function call it is.
9345        let out = compile_and_emit("SELECT Person { n := assert_exists(.name) }");
9346        assert!(
9347            !out.sql.contains(r#""_a"."v""#),
9348            "a scalar assert should not be wrapped as a pointer check:\n{}",
9349            out.sql
9350        );
9351    }
9352
9353    #[test]
9354    fn test_a_backlink_tail_walks_past_the_first_step() {
9355        // `.<link[is T].multilink.prop` — the tail after a backlink was read
9356        // one arity at a time (property, or link-then-property), so anything
9357        // crossing a multi-link had no single column to read and was refused.
9358        // Counted against the migrated data the answer is 4 and 4.
9359        let out = compile_and_emit("SELECT Post FILTER any(.<posts[is Person].posts.title = $t)");
9360        assert!(
9361            out.sql.contains(r#""public"."Person.posts""#),
9362            "the tail should walk the multi-link's junction:\n{}",
9363            out.sql
9364        );
9365    }
9366
9367    #[test]
9368    fn test_an_intersection_narrows_the_rest_of_a_multilink_walk() {
9369        // `.posts[is Post].title` — the narrowed type's own table is what the
9370        // remaining steps join to, so a row of any other type drops out
9371        // without a separate check. On real data the answer is 1 and 1.
9372        let out = compile_and_emit("SELECT Person FILTER any(.posts[is Post].title = $t)");
9373        assert!(
9374            out.sql.contains(r#""public"."Post""#),
9375            "the walk should continue from the narrowed type:\n{}",
9376            out.sql
9377        );
9378    }
9379
9380    #[test]
9381    fn test_a_union_of_bindings_emits_selects_not_bare_names() {
9382        // A CTE reference normally emits just its quoted name, which works
9383        // where the caller aliases it into a FROM. As a `UNION ALL` operand it
9384        // has to be a SELECT of its own — `("a" UNION ALL "b")` is not SQL.
9385        let out = compile_and_emit_with(
9386            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
9387             SELECT (INSERT Person { name := $n, posts := (a UNION b) })",
9388            &make_schema(),
9389        );
9390        assert!(
9391            out.sql.contains(r#"(SELECT "_s"."id" FROM "a" AS "_s")"#),
9392            "each union operand should be a select:\n{}",
9393            out.sql
9394        );
9395    }
9396
9397    #[test]
9398    fn test_a_bare_narrowing_reads_as_a_type_check_and_as_an_object() {
9399        // `exists [is Sub]` is whether the row *is* one; `x := [is Sub] { … }`
9400        // is that row read as it. Both used to fall off the end of the path
9401        // walk ("invalid path step" / "empty path traversal").
9402        for query in [
9403            "SELECT Person FILTER EXISTS [is Person]",
9404            "SELECT Person { p := [is Person] { name } }",
9405        ] {
9406            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query}: {e}"));
9407            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
9408        }
9409    }
9410
9411    #[test]
9412    fn test_a_multilink_takes_a_set_wrapped_in_a_select() {
9413        // `translations := (select { a, b })` — a select with no clauses of its
9414        // own is the expression it wraps. Left wrapped, a free row source (a
9415        // set literal, a union) reached the generic subquery arm and was
9416        // refused, while the identical unwrapped `{ a, b }` compiled.
9417        let out = compile_and_emit_with(
9418            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
9419             SELECT (INSERT Person { name := $n, posts := (SELECT { a, b }) })",
9420            &make_schema(),
9421        );
9422        assert!(
9423            out.sql.contains(r#""public"."Person.posts""#),
9424            "both targets should reach the junction:\n{}",
9425            out.sql
9426        );
9427    }
9428
9429    #[test]
9430    fn test_an_aggregate_over_a_subselected_path() {
9431        // `array_agg((select Person.name))` — the sub-select walks to a scalar,
9432        // which has no type name to be a schema select's subject. It means the
9433        // same as `array_agg(Person.name)` with the select's own modifiers on
9434        // the walk, which the path-select builder already handles. Checked
9435        // element for element against the unwrapped spelling.
9436        let out = compile_and_emit("SELECT array_agg((SELECT Person.name))");
9437        assert!(
9438            out.sql.contains("array_agg") && out.sql.contains(r#""public"."Person""#),
9439            "the aggregate should wrap the walk's column:\n{}",
9440            out.sql
9441        );
9442    }
9443
9444    #[test]
9445    fn test_a_distinct_aggregate_argument_reaches_the_aggregate() {
9446        // `array_agg(distinct x)` used to emit `array_agg(x)`: the unary
9447        // `distinct` emits its operand unchanged, because it is normally
9448        // applied where the set is built — which an aggregate argument is not.
9449        // Both spellings mean the same thing, and both have to return one
9450        // element where the old output returned two.
9451        for query in [
9452            "SELECT array_agg(DISTINCT Person.name)",
9453            "SELECT array_agg((SELECT DISTINCT Person.name))",
9454        ] {
9455            let out = compile_and_emit(query);
9456            assert!(
9457                out.sql.contains("array_agg(DISTINCT "),
9458                "{query} should deduplicate inside the aggregate:\n{}",
9459                out.sql
9460            );
9461        }
9462    }
9463
9464    #[test]
9465    fn test_is_not_negates_the_type_check() {
9466        // `x IS NOT T` is `Expr IS NOT TypeExpr` — the negation of the check,
9467        // not a comparison against some type `not T`. Counted against the
9468        // migrated data it came out as 3 of 3.
9469        let positive = compile_and_emit("SELECT Person FILTER Person IS Person");
9470        let negative = compile_and_emit("SELECT Person FILTER Person IS NOT Person");
9471        assert!(
9472            negative.sql.contains("NOT") && !positive.sql.contains("NOT"),
9473            "the negation should reach the SQL:\npositive:\n{}\nnegative:\n{}",
9474            positive.sql,
9475            negative.sql
9476        );
9477    }
9478
9479    #[test]
9480    fn test_a_cast_takes_a_prefix_operator_as_its_operand() {
9481        // `<bool>exists x` is legal — a cast takes a whole `Expr` at CAST
9482        // precedence. Pylon's cast went straight to a postfix expression, so
9483        // any prefix operator after one failed to parse.
9484        for query in ["SELECT <bool>EXISTS (SELECT Person LIMIT 1)", "SELECT <int64>-1"] {
9485            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query} should parse: {e}"));
9486            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
9487        }
9488    }
9489
9490    #[test]
9491    fn test_a_bare_multilink_reads_as_a_set() {
9492        // `p in .posts` — a multi-link read as a value, not traversed through.
9493        // The single-step resolver knew properties, links and computeds but
9494        // never multi-links, so the pointer reported itself as unknown while
9495        // the suggester, which does know them, offered the name straight back.
9496        let out =
9497            compile_and_emit("WITH p := (SELECT DETACHED Post LIMIT 1) SELECT Person { name } FILTER p IN .posts");
9498        assert!(
9499            out.sql.contains(r#""public"."Person.posts""#),
9500            "the membership test should read the link's junction:\n{}",
9501            out.sql
9502        );
9503    }
9504
9505    #[test]
9506    fn test_a_select_reads_its_own_shape_computed_in_its_clauses() {
9507        // `select T { x := … } order by .x` — a shape's computeds are in scope
9508        // for the select's own clauses, but not for a sibling pointer, so the
9509        // scope stops at the clauses.
9510        let out = compile_and_emit("SELECT Person { n := .name } ORDER BY .n ASC");
9511        assert!(
9512            out.sql.contains("ORDER BY"),
9513            "the order by should compile against the declared pointer:\n{}",
9514            out.sql
9515        );
9516    }
9517
9518    #[test]
9519    fn test_a_declared_pointer_can_be_walked_through() {
9520        // `p { boss := … }` read back as `.boss.name` — the walk builder
9521        // resolved a *schema* computed as a step but not one a shape in scope
9522        // declared, so anything past the first step reported it as unknown.
9523        let out = compile_and_emit_with(
9524            "WITH p := (SELECT Person { boss := .company } LIMIT 1) SELECT p { name, boss_name := .boss.name }",
9525            &make_schema(),
9526        );
9527        assert!(
9528            out.sql.contains(r#""public"."Company""#),
9529            "the walk should reach the declared pointer's own target table:\n{}",
9530            out.sql
9531        );
9532    }
9533
9534    #[test]
9535    fn test_a_replaced_subject_shape_stays_in_scope() {
9536        // `(select .company { n := .name }) { n }` — a shape at the point of
9537        // use replaces the subject's own, so what that one declared has to
9538        // stay readable or the replacement cannot see it. Before, this was an
9539        // outright compile error.
9540        let out = compile_and_emit("SELECT Person { c := (SELECT .company { n := .name }) { n } }");
9541        assert!(
9542            out.sql.contains(r#"'default::Company'::text"#) && out.sql.contains(r#""t2"."name""#),
9543            "`n` should resolve to the company's own column:\n{}",
9544            out.sql
9545        );
9546    }
9547
9548    #[test]
9549    fn test_a_sibling_shape_pointer_is_not_in_scope() {
9550        // One shape pointer reading another is refused, which is what keeps
9551        // four graph queries reported as the application bugs they are.
9552        let schema = make_schema();
9553        let ast = parse::parse("SELECT Person { n := .name, copy := .n }").unwrap();
9554        assert!(ir::compile(&ast, &schema).is_err());
9555    }
9556
9557    #[test]
9558    fn test_shape_over_a_with_binding_reads_the_binding() {
9559        // `metadata := metadata { … }` — a binding names a row source, so a
9560        // shape on it projects that source. The pointer route only took
9561        // relative paths, so this fell through to the expression route and was
9562        // rejected as "shapes are not valid in expression context".
9563        let out = compile_and_emit("WITH c := (SELECT Company LIMIT 1) SELECT Person { name, employer := c { name } }");
9564        assert!(
9565            out.sql.contains("\"c\""),
9566            "the pointer should read the binding's CTE:\n{}",
9567            out.sql
9568        );
9569    }
9570
9571    #[test]
9572    fn test_shape_over_a_bare_type_name_is_still_rejected() {
9573        // Unlike a binding, a type name in an enclosing scope is a factored
9574        // prefix, so a shape on one does not mean "every row of the table".
9575        let schema = make_schema();
9576        let ast = parse::parse("SELECT Person { name, other := Company { name } }").unwrap();
9577        assert!(ir::compile(&ast, &schema).is_err());
9578    }
9579
9580    #[test]
9581    fn test_nested_shape_reads_a_prefix_bound_by_the_outer_select() {
9582        // `Person.name` inside the `posts` shape names a type the enclosing
9583        // select binds, so it reads the outer row — not every Person. The
9584        // prefix used to be rejected outright because only the *innermost*
9585        // subject was checked.
9586        let out = compile_and_emit("SELECT Person { name, posts: { title, who := Person.name } }");
9587        assert!(
9588            out.sql.contains(r#""t0"."name""#),
9589            "the nested computed should read the outer Person alias:\n{}",
9590            out.sql
9591        );
9592        assert!(
9593            !out.sql.contains(r#"FROM "public"."Person" "t2""#),
9594            "the prefix should not open a second Person source:\n{}",
9595            out.sql
9596        );
9597    }
9598
9599    #[test]
9600    fn test_prefix_naming_a_type_out_of_scope_is_rejected() {
9601        // Nothing binds `Company` here, so the prefix stays an absolute path —
9602        // Pylon must not silently read the whole table.
9603        let schema = make_schema();
9604        let ast = parse::parse("SELECT Person { name, who := Company.name }").unwrap();
9605        assert!(ir::compile(&ast, &schema).is_err());
9606    }
9607
9608    #[test]
9609    fn test_free_select_tuple() {
9610        let schema = make_schema();
9611        let ast = parse::parse("SELECT (1, 2)").unwrap();
9612        let ir = ir::compile(&ast, &schema).unwrap();
9613        let out = emit(&ir);
9614        assert!(out.sql.contains("1"));
9615        assert!(out.sql.contains("2"));
9616        assert!(out.sql.contains("AS result"));
9617        assert!(matches!(out.shape.root, crate::query::ShapeNode::Tuple { .. }));
9618    }
9619
9620    #[test]
9621    fn test_free_select_scalar_literal() {
9622        let schema = make_schema();
9623        let ast = parse::parse("SELECT 'hello'").unwrap();
9624        let ir = ir::compile(&ast, &schema).unwrap();
9625        let out = emit(&ir);
9626        assert!(out.sql.contains("SELECT 'hello' AS v"));
9627        assert!(out.sql.contains("ROW(v) AS result"));
9628        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
9629    }
9630
9631    #[test]
9632    fn test_float_literal_casts_to_float8() {
9633        // Regression: an un-cast float literal like `1.0` is untyped in
9634        // Postgres and defaults to `numeric`, not `float64` — silently
9635        // changing PyQL's semantics (only an explicit `<decimal>`/`123n`
9636        // literal should ever produce a real decimal) and breaking decode
9637        // inside ROW() composites (numeric's wire format differs from
9638        // float8's). An explicit ::float8 cast keeps it a real float.
9639        let out = compile_and_emit("SELECT 1.0");
9640        assert!(
9641            out.sql.contains("(1.0::float8)"),
9642            "expected explicit float8 cast, got:\n{}",
9643            out.sql
9644        );
9645    }
9646
9647    #[test]
9648    fn test_free_select_array_literal() {
9649        let schema = make_schema();
9650        let ast = parse::parse("SELECT [1, 2, 3]").unwrap();
9651        let ir = ir::compile(&ast, &schema).unwrap();
9652        let out = emit(&ir);
9653        assert!(out.sql.contains("SELECT ARRAY[1, 2, 3] AS v"), "{}", out.sql);
9654        assert!(out.sql.contains("SELECT v AS result, v FROM"), "{}", out.sql);
9655        assert!(matches!(out.shape.root, crate::query::ShapeNode::RawScalar));
9656    }
9657
9658    #[test]
9659    fn test_select_scalars() {
9660        let out = compile_and_emit("SELECT Person { name, age }");
9661        assert!(out.sql.contains("'default::Person'::text"));
9662        assert!(out.sql.contains("\"name\"::text"));
9663        assert!(out.sql.contains("\"age\"::int8"));
9664        assert!(out.sql.contains("FROM \"public\".\"Person\""));
9665        assert!(out.sql.contains(") AS result"));
9666    }
9667
9668    #[test]
9669    fn test_select_filter_param() {
9670        let out = compile_and_emit("SELECT Person { name } FILTER .name = $name");
9671        assert!(out.sql.contains("WHERE"));
9672        assert!(out.sql.contains("$1"));
9673    }
9674
9675    #[test]
9676    fn test_filter_in_set_literal_compiles_to_any_array() {
9677        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol'}");
9678        assert!(out.sql.contains("= ANY(ARRAY['Carol'])"), "got:\n{}", out.sql);
9679    }
9680
9681    #[test]
9682    fn test_filter_in_multi_element_set_literal_compiles_to_any_array() {
9683        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol', 'Bob'}");
9684        assert!(out.sql.contains("= ANY(ARRAY['Carol', 'Bob'])"), "got:\n{}", out.sql);
9685    }
9686
9687    /// `x IN std::array_unpack(arr)` is the documented way to test membership
9688    /// of an array parameter. It used to emit `= ANY(unnest(arr))` — a
9689    /// set-returning function inside `WHERE`, which PostgreSQL rejects at
9690    /// execution time (`set-returning functions are not allowed in WHERE`)
9691    /// even though both the PyQL and the SQL parse cleanly. `ANY` takes the
9692    /// array directly, so the `unnest` has to go.
9693    #[test]
9694    fn test_filter_in_array_unpack_passes_the_array_straight_to_any() {
9695        let out = compile_and_emit("SELECT Person { name } FILTER .name IN std::array_unpack(<array<str>>$names)");
9696        assert!(
9697            !out.sql.contains("ANY(unnest("),
9698            "unnest inside ANY is invalid in WHERE:\n{}",
9699            out.sql
9700        );
9701        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9702    }
9703
9704    #[test]
9705    fn test_filter_not_in_array_unpack_passes_the_array_straight_to_all() {
9706        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN std::array_unpack(<array<str>>$names)");
9707        assert!(
9708            !out.sql.contains("ALL(unnest("),
9709            "unnest inside ALL is invalid in WHERE:\n{}",
9710            out.sql
9711        );
9712        assert!(out.sql.contains("<> ALL("), "got:\n{}", out.sql);
9713    }
9714
9715    /// `all(array_unpack($t) in .posts.title)` quantifies over the unpacked
9716    /// array, not over `.posts`. Read as a condition on one `.posts` element
9717    /// it would both ask the opposite question and leave the `unnest()` in a
9718    /// `WHERE`, which PostgreSQL rejects at execution time.
9719    #[test]
9720    fn test_all_over_unpacked_array_aggregates_the_array_not_the_multilink() {
9721        let out = compile_and_emit(
9722            "SELECT Person { name } FILTER all(std::array_unpack(<array<str>>$titles) IN .posts.title)",
9723        );
9724        assert!(out.sql.contains("bool_and("), "got:\n{}", out.sql);
9725        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
9726    }
9727
9728    /// The mirror of the above: the multilink is what is quantified over and
9729    /// the array is only the haystack, so `IN` still folds into `= ANY(array)`
9730    /// and nothing unnests.
9731    #[test]
9732    fn test_any_over_a_multilink_against_an_unpacked_array_still_folds_into_any() {
9733        let out = compile_and_emit(
9734            "SELECT Person { name } FILTER any(.posts.title IN std::array_unpack(<array<str>>$titles))",
9735        );
9736        assert!(!out.sql.contains("unnest("), "got:\n{}", out.sql);
9737        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9738    }
9739
9740    #[test]
9741    fn test_any_over_unpacked_array_aggregates_the_array_not_the_multilink() {
9742        let out = compile_and_emit(
9743            "SELECT Person { name } FILTER any(std::array_unpack(<array<str>>$titles) IN .posts.title)",
9744        );
9745        assert!(out.sql.contains("bool_or("), "got:\n{}", out.sql);
9746        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
9747    }
9748
9749    /// A schema with a `text[]` property, which `make_schema` has none of.
9750    fn schema_with_array_property() -> SchemaDescriptor {
9751        let mut schema = make_schema();
9752        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
9753            name: "perms".into(),
9754            pg_type: "text[]".into(),
9755            nullable: true,
9756            default_sql: None,
9757            default_pyql: None,
9758            description: None,
9759            check_constraints: vec![],
9760            is_exclusive: false,
9761            is_pk: false,
9762            is_readonly: false,
9763            rewrites: vec![],
9764            tuple_members: None,
9765            column_type: None,
9766        });
9767        schema
9768    }
9769
9770    /// `array_agg(array_unpack(teams.perms))` flattens every row's array into
9771    /// one. Compiled as written it emitted `array_agg(unnest(...))`, which
9772    /// PostgreSQL rejects at execution time ("aggregate function calls cannot
9773    /// contain set-returning function calls"), so the unpacking has to happen
9774    /// in a row source the aggregate reads from outside.
9775    #[test]
9776    fn test_aggregate_over_an_unpacked_walk_unnests_in_a_row_source() {
9777        let out = compile_and_emit_with(
9778            "WITH teams := (SELECT Person { perms } FILTER .age > 1) SELECT std::array_agg(std::array_unpack(teams.perms))",
9779            &schema_with_array_property(),
9780        );
9781        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9782        assert!(
9783            out.sql.contains(
9784                r#"coalesce(array_agg("_s"."v"), '{}') FROM unnest(ARRAY(SELECT unnest("t1"."perms") FROM "teams" AS "t1"#
9785            ),
9786            "got:\n{}",
9787            out.sql
9788        );
9789    }
9790
9791    #[test]
9792    fn test_aggregate_over_an_unpacked_type_rooted_path_unnests_in_a_row_source() {
9793        let out = compile_and_emit_with(
9794            "SELECT std::count(std::array_unpack(Person.perms))",
9795            &schema_with_array_property(),
9796        );
9797        assert!(!out.sql.contains("count(unnest("), "got:\n{}", out.sql);
9798        assert!(
9799            out.sql
9800                .contains(r#"FROM unnest(ARRAY(SELECT unnest("t0"."perms") FROM "public"."Person" AS "t0"#),
9801            "got:\n{}",
9802            out.sql
9803        );
9804    }
9805
9806    /// One array standing on its own — a parameter, or one row's property —
9807    /// is already the row source, so it needs no walk gathered around it.
9808    #[test]
9809    fn test_aggregate_over_an_unpacked_parameter_unnests_the_array_itself() {
9810        let out = compile_and_emit("SELECT std::array_agg(std::array_unpack(<array<str>>$names))");
9811        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9812        assert!(
9813            out.sql.contains(r#"FROM unnest(($1)::text[]) AS "_s"("v")"#),
9814            "got:\n{}",
9815            out.sql
9816        );
9817    }
9818
9819    #[test]
9820    fn test_aggregate_over_an_unpacked_property_unnests_that_row_s_array() {
9821        let out = compile_and_emit_with(
9822            "SELECT Person { n := std::array_agg(std::array_unpack(.perms)) }",
9823            &schema_with_array_property(),
9824        );
9825        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9826        assert!(
9827            out.sql.contains(r#"FROM unnest("t0"."perms") AS "_s"("v")"#),
9828            "got:\n{}",
9829            out.sql
9830        );
9831    }
9832
9833    /// `array_agg` over no rows is `[]` in PyQL and NULL in SQL, which reaches
9834    /// a caller as a missing value where a list was promised.
9835    #[test]
9836    fn test_array_agg_over_nothing_is_an_empty_array_not_null() {
9837        for query in [
9838            "SELECT std::array_agg(Person.name)",
9839            "SELECT std::array_agg((SELECT Person).name)",
9840        ] {
9841            let out = compile_and_emit(query);
9842            assert!(
9843                out.sql.contains("coalesce(") && out.sql.contains("'{}'"),
9844                "{query} got:\n{}",
9845                out.sql
9846            );
9847        }
9848    }
9849
9850    /// The `coalesce` that empty array wears must not hide what the binding
9851    /// holds: `contains(ids, .id)` picks its overload off the binding's type,
9852    /// and an array read as text silently becomes a substring search.
9853    #[test]
9854    fn test_an_array_agg_binding_stays_an_array_through_its_coalesce() {
9855        let out = compile_and_emit(
9856            "WITH ids := std::array_agg(Person.id) SELECT Person { name } FILTER std::contains(ids, .id)",
9857        );
9858        assert!(
9859            out.sql.contains("@> ARRAY["),
9860            "expected the array overload, got:\n{}",
9861            out.sql
9862        );
9863        assert!(
9864            !out.sql.contains("strpos("),
9865            "the string overload must not win, got:\n{}",
9866            out.sql
9867        );
9868    }
9869
9870    /// An aggregate over a walk that is *not* unpacked keeps its own row
9871    /// source — the rewrite above must not reach it.
9872    #[test]
9873    fn test_aggregate_over_a_plain_walk_keeps_its_row_source() {
9874        let out = compile_and_emit("SELECT std::array_agg(Person.name)");
9875        assert!(
9876            out.sql.contains(r#"array_agg("t0"."name")"#) && out.sql.contains(r#"FROM "public"."Person""#),
9877            "got:\n{}",
9878            out.sql
9879        );
9880    }
9881
9882    /// The order check is a safety net for every emitter that assembles a
9883    /// WITH clause, so it has to actually catch one.
9884    #[test]
9885    fn test_the_cte_order_check_catches_a_forward_reference() {
9886        let bad = "WITH \"b\" AS (\n SELECT * FROM \"a\"\n),\n\"a\" AS (\n SELECT 1\n)\nSELECT 1";
9887        assert!(
9888            super::forward_cte_reference(bad).is_some(),
9889            "expected a forward reference"
9890        );
9891        let good = "WITH \"a\" AS (\n SELECT 1\n),\n\"b\" AS (\n SELECT * FROM \"a\"\n)\nSELECT 1";
9892        assert_eq!(super::forward_cte_reference(good), None, "ordered CTEs must pass");
9893    }
9894
9895    /// `.status IN <Enum>array_unpack($1)` — the cast sits between `IN` and
9896    /// the unpack, and used to hide it, leaving a set-returning function in a
9897    /// `WHERE` that PostgreSQL rejects at execution time.
9898    #[test]
9899    fn test_a_cast_unpacked_array_still_folds_into_any() {
9900        let out = compile_and_emit("SELECT Person { name } FILTER .name IN <str>std::array_unpack(<array<str>>$names)");
9901        assert!(
9902            !out.sql.contains("unnest("),
9903            "the unpack must fold into ANY, got:\n{}",
9904            out.sql
9905        );
9906        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9907    }
9908
9909    /// A bare `array_unpack` outside `IN` still has to unnest — the unwrap is
9910    /// specific to `ANY`/`ALL`, which take an array rather than a set.
9911    #[test]
9912    fn test_array_unpack_outside_in_still_unnests() {
9913        let out = compile_and_emit("SELECT std::array_unpack(<array<str>>$names)");
9914        assert!(out.sql.contains("unnest("), "got:\n{}", out.sql);
9915    }
9916
9917    #[test]
9918    fn test_filter_not_in_set_literal_compiles_to_all_array() {
9919        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN {'Carol'}");
9920        assert!(out.sql.contains("<> ALL(ARRAY['Carol'])"), "got:\n{}", out.sql);
9921    }
9922
9923    #[test]
9924    fn test_deep_splat_fetches_single_link_target_properties_not_just_id() {
9925        // `**` on a single link must expand to the target type's own
9926        // properties (one level of `*`, not `**` again — recursing into
9927        // the target's own links would never terminate for a cyclic link
9928        // graph), not just an implicit `{ id }`.
9929        let out = compile_and_emit("SELECT Person { ** }");
9930        assert!(
9931            out.sql.contains("\"name\""),
9932            "expected Company.name pulled in via .company's ** expansion, got:\n{}",
9933            out.sql
9934        );
9935    }
9936
9937    #[test]
9938    fn test_deep_splat_fetches_multilink_target_properties_not_just_id() {
9939        let out = compile_and_emit("SELECT Person { ** }");
9940        assert!(
9941            out.sql.contains("\"title\""),
9942            "expected Post.title pulled in via .posts' ** expansion, got:\n{}",
9943            out.sql
9944        );
9945    }
9946
9947    #[test]
9948    fn test_schema_type_cast_select() {
9949        let out = compile_and_emit("SELECT <default::Person><uuid>'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'");
9950        assert!(out.sql.contains("FROM \"public\".\"Person\""));
9951        assert!(out.sql.contains("WHERE"));
9952        assert!(out.sql.contains("'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'"));
9953    }
9954
9955    #[test]
9956    fn test_select_single_link() {
9957        let out = compile_and_emit("SELECT Person { name, company { name } }");
9958        assert!(out.sql.contains("'default::Company'::text"));
9959        assert!(out.sql.contains("FROM \"public\".\"Company\""));
9960        // join condition: parent FK column = target PK
9961        assert!(out.sql.contains("\"company_id\" = "));
9962    }
9963
9964    #[test]
9965    fn test_select_multi_link() {
9966        let out = compile_and_emit("SELECT Person { name, posts { title } }");
9967        assert!(out.sql.contains("array_agg(ROW("));
9968        assert!(out.sql.contains("ARRAY[]::record[]"));
9969        assert!(out.sql.contains("'default::Post'::text"));
9970        assert!(out.sql.contains("\"Person.posts\""));
9971    }
9972
9973    fn compile_err(query: &str) -> String {
9974        let schema = make_schema();
9975        let ast = parse::parse(query).expect("parse failed");
9976        match ir::compile(&ast, &schema) {
9977            Ok(_) => panic!("expected a compile error"),
9978            Err(e) => format!("{e}"),
9979        }
9980    }
9981
9982    #[test]
9983    fn test_multi_link_limit_cuts_rows_before_aggregating() {
9984        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' limit 1 }");
9985        // The LIMIT has to sit on the row source, not next to array_agg —
9986        // the aggregate has already collapsed the rows by then.
9987        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9988        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
9989        let agg = out.sql.find("array_agg").unwrap();
9990        let limit = out.sql.find("LIMIT 1").unwrap();
9991        assert!(agg < limit, "{}", out.sql);
9992        assert!(out.sql.contains("\"t1\".\"title\"::text AS \"c1\""), "{}", out.sql);
9993    }
9994
9995    #[test]
9996    fn test_multi_link_order_by_with_limit_orders_the_aggregate_too() {
9997        let out = compile_and_emit("SELECT Person { posts: { title } order by .title desc offset 1 limit 2 }");
9998        // The sort key rides along as its own column so the array keeps the
9999        // derived table's order rather than relying on aggregation order.
10000        assert!(out.sql.contains("\"t1\".\"title\" AS \"s0\""), "{}", out.sql);
10001        assert!(
10002            out.sql.contains("ORDER BY \"__lim\".\"s0\" DESC NULLS LAST"),
10003            "{}",
10004            out.sql
10005        );
10006        assert!(out.sql.contains("OFFSET 1"), "{}", out.sql);
10007        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
10008    }
10009
10010    #[test]
10011    fn test_multi_link_without_limit_keeps_the_flat_aggregate() {
10012        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' }");
10013        assert!(!out.sql.contains("__lim"), "{}", out.sql);
10014        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
10015    }
10016
10017    #[test]
10018    fn test_sub_select_as_computed_pointer_is_a_link_pointer() {
10019        let out = compile_and_emit("SELECT Person { recent := (select .posts filter .title = 'x' limit 1) { title } }");
10020        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10021            panic!()
10022        };
10023        // Named after the pointer, not after the link it selects from, and
10024        // one object: `limit 1` makes the sub-select at most one row.
10025        let ShapeNode::Object {
10026            name,
10027            pointers: elem_pointers,
10028            ..
10029        } = &pointers[2]
10030        else {
10031            panic!("{:?}", pointers[2])
10032        };
10033        assert_eq!(name, "recent");
10034        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
10035        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
10036        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10037    }
10038
10039    #[test]
10040    fn test_sub_select_shape_inside_the_parens_is_equivalent() {
10041        let outer = compile_and_emit("SELECT Person { recent := (select .posts limit 1) { title } }");
10042        let inner = compile_and_emit("SELECT Person { recent := (select .posts { title } limit 1) }");
10043        assert_eq!(outer.sql, inner.sql);
10044    }
10045
10046    #[test]
10047    fn test_sub_select_field_access_is_a_correlated_scalar_subquery() {
10048        let out = compile_and_emit("SELECT Person { t := (select .posts filter .title = 'x' limit 1).title }");
10049        // Rooted at the enclosing type and correlated back to its row, so
10050        // the subquery only ever sees this Person's posts.
10051        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10052        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
10053        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10054        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10055            panic!()
10056        };
10057        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, .. } if name == "t"));
10058    }
10059
10060    #[test]
10061    fn test_sub_select_field_access_over_a_single_link() {
10062        let out = compile_and_emit("SELECT Person { c := (select .company).name }");
10063        assert!(out.sql.contains("\"t1\".\"company_id\" = \"t2\".\"id\""), "{}", out.sql);
10064        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10065    }
10066
10067    #[test]
10068    fn test_sub_select_field_access_in_a_filter() {
10069        let out = compile_and_emit("SELECT Person { name } filter (select .posts limit 1).title = 'x'");
10070        assert!(out.sql.contains("WHERE ((SELECT \"t2\".\"title\""), "{}", out.sql);
10071        assert!(out.sql.contains("LIMIT 1) = 'x')"), "{}", out.sql);
10072    }
10073
10074    #[test]
10075    fn test_sub_select_over_an_absolute_path_is_not_correlated() {
10076        let out = compile_and_emit("SELECT Person { n := (select Company.name limit 1) }");
10077        assert!(out.sql.contains("FROM \"public\".\"Company\""), "{}", out.sql);
10078        assert!(!out.sql.contains("= \"t0\".\"id\""), "{}", out.sql);
10079    }
10080
10081    #[test]
10082    fn test_backlink_sub_select_carries_its_modifiers() {
10083        let out = compile_and_emit("SELECT Post { authors := (select .<posts[is Person] { name } limit 2) }");
10084        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
10085        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
10086        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10087            panic!()
10088        };
10089        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
10090    }
10091
10092    #[test]
10093    fn test_with_bound_scalar_is_typed_for_overload_resolution() {
10094        // A `with`-bound value had no inferred type, so a call over one fell
10095        // back to the first registered overload and needed an explicit cast.
10096        let out = compile_and_emit("WITH xs := (select Person.name) SELECT contains(xs, 'a')");
10097        assert!(
10098            out.sql.contains("strpos((SELECT \"v\" FROM \"xs\"), 'a')"),
10099            "{}",
10100            out.sql
10101        );
10102    }
10103
10104    /// `Person` with a translation-style computed model: one computed
10105    /// resolves a filtered subset of a multi-link, others read through it.
10106    fn make_schema_with_computed_links() -> SchemaDescriptor {
10107        use crate::schema::ComputedDescriptor;
10108        let mut schema = make_schema();
10109        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10110        person.computed = vec![
10111            ComputedDescriptor {
10112                name: "published".into(),
10113                expression: "(select .posts filter .title != '')".into(),
10114                return_type: None,
10115                link_target: None,
10116                link_multi: false,
10117            },
10118            ComputedDescriptor {
10119                name: "plain".into(),
10120                expression: ".posts".into(),
10121                return_type: None,
10122                link_target: None,
10123                link_multi: false,
10124            },
10125            ComputedDescriptor {
10126                name: "featured".into(),
10127                expression: "(select .published filter .title != 'draft')".into(),
10128                return_type: None,
10129                link_target: None,
10130                link_multi: false,
10131            },
10132            ComputedDescriptor {
10133                name: "looper".into(),
10134                expression: "(select .looper)".into(),
10135                return_type: None,
10136                link_target: None,
10137                link_multi: false,
10138            },
10139            ComputedDescriptor {
10140                name: "capped".into(),
10141                expression: "(select .posts limit 1)".into(),
10142                return_type: None,
10143                link_target: None,
10144                link_multi: false,
10145            },
10146        ];
10147        schema
10148    }
10149
10150    /// `Person.recent` computed from an object-returning function call, the
10151    /// shape jaldis's translation model uses.
10152    fn make_schema_with_object_fn_computed() -> SchemaDescriptor {
10153        use crate::schema::{ComputedDescriptor, FunctionDescriptor, FunctionParamDescriptor};
10154        let mut schema = make_schema();
10155        schema.functions = vec![FunctionDescriptor {
10156            name: "latest".into(),
10157            module: "default".into(),
10158            params: vec![FunctionParamDescriptor {
10159                name: "owner".into(),
10160                pg_type: "uuid".into(),
10161            }],
10162            return_pg_type: "default::Post".into(),
10163            return_is_object: true,
10164            return_is_set: true,
10165            return_is_polymorphic: false,
10166            volatility: "stable".into(),
10167            body: String::new(),
10168        }];
10169        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10170        person.computed = vec![
10171            ComputedDescriptor {
10172                name: "recent".into(),
10173                expression: "latest(.id)".into(),
10174                return_type: None,
10175                link_target: None,
10176                link_multi: false,
10177            },
10178            ComputedDescriptor {
10179                name: "vetted".into(),
10180                expression: "(select latest(.id) filter .title != '')".into(),
10181                return_type: None,
10182                link_target: None,
10183                link_multi: false,
10184            },
10185        ];
10186        schema
10187    }
10188
10189    #[test]
10190    fn test_path_traverses_through_a_function_backed_computed() {
10191        // `recent := latest(.id)` has no path to splice in, so the call
10192        // itself becomes the next row source — LATERAL, because its
10193        // arguments read the alias the traversal has reached.
10194        let schema = make_schema_with_object_fn_computed();
10195        let out = compile_and_emit_with("SELECT Person { t := .recent.title }", &schema);
10196        assert!(
10197            out.sql
10198                .contains("CROSS JOIN LATERAL \"public\".\"latest\"(\"t1\".\"id\") AS \"t2\""),
10199            "{}",
10200            out.sql
10201        );
10202        // The function returns a set, so the traversal is multi-valued.
10203        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10204    }
10205
10206    #[test]
10207    fn test_sub_select_over_a_function_backed_computed() {
10208        // The jaldis translation model, minimal: a function-backed computed
10209        // resolves the set, a scalar computed picks an attribute out of it.
10210        let schema = make_schema_with_object_fn_computed();
10211        let out = compile_and_emit_with(
10212            "SELECT Person { t := (select .recent filter .title = 'x' limit 1).title }",
10213            &schema,
10214        );
10215        assert!(
10216            out.sql.contains("CROSS JOIN LATERAL \"public\".\"latest\""),
10217            "{}",
10218            out.sql
10219        );
10220        assert!(out.sql.contains("\"t2\".\"title\" = 'x'"), "{}", out.sql);
10221        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10222        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10223    }
10224
10225    #[test]
10226    fn test_function_backed_computed_carries_its_own_filter() {
10227        let schema = make_schema_with_object_fn_computed();
10228        let out = compile_and_emit_with(
10229            "SELECT Person { t := (select .vetted filter .title = 'x' limit 1).title }",
10230            &schema,
10231        );
10232        assert!(
10233            out.sql
10234                .contains("(\"t2\".\"title\" = 'x') AND (\"t2\".\"title\" <> '')"),
10235            "{}",
10236            out.sql
10237        );
10238    }
10239
10240    #[test]
10241    fn test_function_backed_computed_checks_its_argument_count() {
10242        use crate::schema::ComputedDescriptor;
10243        let mut schema = make_schema_with_object_fn_computed();
10244        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10245        person.computed = vec![ComputedDescriptor {
10246            name: "recent".into(),
10247            expression: "latest()".into(),
10248            return_type: None,
10249            link_target: None,
10250            link_multi: false,
10251        }];
10252        let ast = parse::parse("SELECT Person { t := .recent.title }").unwrap();
10253        let err = match ir::compile(&ast, &schema) {
10254            Ok(_) => panic!("expected a compile error"),
10255            Err(e) => format!("{e}"),
10256        };
10257        assert!(err.contains("expects 1 argument(s), got 0"), "{err}");
10258    }
10259
10260    #[test]
10261    fn test_path_traverses_through_a_computed_pointer() {
10262        // `.published` is itself computed, so it has no column to join on —
10263        // its own path takes the step's place and its filter rides along.
10264        let schema = make_schema_with_computed_links();
10265        let out = compile_and_emit_with("SELECT Person { t := .published.title }", &schema);
10266        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
10267        assert!(
10268            out.sql
10269                .contains("(\"t1\".\"id\" = \"t0\".\"id\") AND (\"t2\".\"title\" <> '')"),
10270            "{}",
10271            out.sql
10272        );
10273    }
10274
10275    #[test]
10276    fn test_sub_select_over_a_computed_pointer_projects_a_property() {
10277        // The shape the jaldis translation model is built on:
10278        // `(select .translation filter .attribute = X limit 1).value`.
10279        let schema = make_schema_with_computed_links();
10280        let out = compile_and_emit_with(
10281            "SELECT Person { t := (select .published filter .title = 'a' limit 1).title }",
10282            &schema,
10283        );
10284        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10285        // Both the computed's own filter and the use-site one apply.
10286        assert!(out.sql.contains("\"t2\".\"title\" = 'a'"), "{}", out.sql);
10287        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
10288    }
10289
10290    #[test]
10291    fn test_chained_computed_pointers_each_contribute_their_filter() {
10292        let schema = make_schema_with_computed_links();
10293        let out = compile_and_emit_with("SELECT Person { t := .featured.title }", &schema);
10294        assert_eq!(out.sql.matches("\"public\".\"Person.posts\"").count(), 1, "{}", out.sql);
10295        assert!(out.sql.contains("\"t2\".\"title\" <> 'draft'"), "{}", out.sql);
10296        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
10297    }
10298
10299    #[test]
10300    fn test_computed_pointer_naming_a_bare_link_is_traversable() {
10301        let schema = make_schema_with_computed_links();
10302        let out = compile_and_emit_with("SELECT Person { t := .plain.title }", &schema);
10303        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10304    }
10305
10306    #[test]
10307    fn test_a_cycle_of_computed_pointers_is_reported_not_hung_on() {
10308        let schema = make_schema_with_computed_links();
10309        let ast = parse::parse("SELECT Person { t := .looper.title }").unwrap();
10310        let err = match ir::compile(&ast, &schema) {
10311            Ok(_) => panic!("expected a compile error"),
10312            Err(e) => format!("{e}"),
10313        };
10314        assert!(err.contains("expands into itself"), "{err}");
10315    }
10316
10317    #[test]
10318    fn test_a_cycle_of_computed_pointers_read_as_a_value_is_reported_not_hung_on() {
10319        // Read as the last step, the computed was compiled as an expression,
10320        // which walked the same path again with no count of how often.
10321        let schema = make_schema_with_computed_links();
10322        for query in ["SELECT Person { looper }", "SELECT Person { * }"] {
10323            let ast = parse::parse(query).unwrap();
10324            let err = match ir::compile(&ast, &schema) {
10325                Ok(_) => panic!("expected a compile error for {query}"),
10326                Err(e) => format!("{e}"),
10327            };
10328            assert!(err.contains("expands into itself"), "{query}: {err}");
10329        }
10330    }
10331
10332    #[test]
10333    fn test_traversing_through_a_limited_computed_uses_a_lateral() {
10334        // `capped` picks one row per Person, so the traversal correlates
10335        // through a LATERAL rather than a plain join — and its `limit 1`
10336        // means the result is one title, not an array of them.
10337        let schema = make_schema_with_computed_links();
10338        let out = compile_and_emit_with("SELECT Person { t := .capped.title }", &schema);
10339        assert!(out.sql.contains("JOIN LATERAL ("), "{}", out.sql);
10340        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10341        assert!(!out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
10342    }
10343
10344    #[test]
10345    fn test_backlink_from_a_type_whose_interface_the_link_targets() {
10346        use crate::schema::{LinkDescriptor, TypeDescriptor};
10347        let mut schema = make_interface_schema();
10348        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
10349        schema.types.push(TypeDescriptor {
10350            name: "Note".into(),
10351            module: "default".into(),
10352            table: "Note".into(),
10353            abstract_: false,
10354            materialized: true,
10355            description: None,
10356            parents: vec![],
10357            interfaces: vec![],
10358            bases: vec![],
10359            properties: individual.properties[..1].to_vec(),
10360            // Targets the *interface*, which `Individual` implements.
10361            links: vec![LinkDescriptor {
10362                name: "owner".into(),
10363                target: "default::Account".into(),
10364                nullable: true,
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            multilinks: vec![],
10374            computed: vec![],
10375            constraints: vec![],
10376            indexes: vec![],
10377            partition: None,
10378            vector_indexes: vec![],
10379            search_indexes: vec![],
10380            triggers: vec![],
10381            junction: false,
10382            signals: vec![],
10383        });
10384        // Reported "link 'Note::owner' does not target 'Individual'" — but a
10385        // link to an interface accepts every implementor, and they share ids.
10386        let out = compile_and_emit_with("SELECT Individual { notes := .<owner[is Note] }", &schema);
10387        assert!(out.sql.contains("\"public\".\"Note\""), "{}", out.sql);
10388        assert!(out.sql.contains("\"owner_id\" = \"t0\".id"), "{}", out.sql);
10389    }
10390
10391    #[test]
10392    fn test_with_block_in_expression_position_hoists_its_bindings() {
10393        // A `with` has nowhere to put its CTEs mid-expression, so they move
10394        // to the enclosing statement's own WITH clause.
10395        let out = compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select c) }");
10396        assert!(out.sql.starts_with("WITH\n\"c\" AS ("), "{}", out.sql);
10397        assert!(out.sql.contains("(SELECT \"v\" FROM \"c\")"), "{}", out.sql);
10398    }
10399
10400    #[test]
10401    fn test_hoisted_binding_is_usable_in_the_expression_around_it() {
10402        let out =
10403            compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select .name ++ c) }");
10404        assert!(
10405            out.sql.contains("(\"t0\".\"name\" || (SELECT \"v\" FROM \"c\"))"),
10406            "{}",
10407            out.sql
10408        );
10409    }
10410
10411    #[test]
10412    fn test_hoisted_binding_joins_the_statements_own_with_clause() {
10413        let out = compile_and_emit(
10414            "WITH top := (select Person.name limit 1) \
10415             SELECT Person { t := (with c := (select Company.name limit 1) select c) }",
10416        );
10417        let top = out.sql.find("\"top\" AS (").expect("top-level binding");
10418        let inner = out.sql.find("\"c\" AS (").expect("hoisted binding");
10419        assert!(top < inner, "the statement's own bindings come first:\n{}", out.sql);
10420    }
10421
10422    #[test]
10423    fn test_a_binding_named_after_a_keyword_is_usable() {
10424        // The lexer is case-insensitive, so `order` arrives as the ORDER
10425        // token and read as "expected an expression, found 'order'" — and the
10426        // binding keeps the casing it was written with, not the keyword
10427        // table's.
10428        let out = compile_and_emit("WITH order := (select Person.name) SELECT Person { name } FILTER .name = order");
10429        assert!(out.sql.contains("(SELECT \"v\" FROM \"order\")"), "{}", out.sql);
10430    }
10431
10432    #[test]
10433    fn test_computed_declared_on_an_interface_is_visible_from_an_implementor() {
10434        use crate::schema::ComputedDescriptor;
10435        let mut schema = make_interface_schema();
10436        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
10437        account.computed = vec![ComputedDescriptor {
10438            name: "tier".into(),
10439            expression: "'gold'".into(),
10440            return_type: None,
10441            link_target: None,
10442            link_multi: false,
10443        }];
10444        let individual = schema.types.iter_mut().find(|t| t.name == "Individual").unwrap();
10445        individual.computed = vec![];
10446        // Reported as "has no link or property 'tier'. Did you mean 'tier'?"
10447        // — the suggester searched interfaces, the resolver didn't.
10448        for q in ["SELECT Individual { tier }", "SELECT Individual { t := .tier }"] {
10449            let out = compile_and_emit_with(q, &schema);
10450            assert!(out.sql.contains("'gold'"), "{q}: {}", out.sql);
10451        }
10452    }
10453
10454    #[test]
10455    fn test_computed_may_carry_trailing_modifiers_without_a_select() {
10456        use crate::schema::ComputedDescriptor;
10457        let mut schema = make_schema();
10458        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10459        person.computed = vec![ComputedDescriptor {
10460            name: "recent".into(),
10461            expression: ".posts order by .title desc limit 5".into(),
10462            return_type: None,
10463            link_target: None,
10464            link_multi: false,
10465        }];
10466        // A schema fragment has no statement around it to hang modifiers
10467        // off, so this used to be "expected an expression, found 'order'".
10468        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
10469        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
10470        assert!(out.sql.contains("ORDER BY \"t1\".\"title\" DESC"), "{}", out.sql);
10471    }
10472
10473    #[test]
10474    fn test_computed_may_lead_with_a_bare_select() {
10475        use crate::schema::ComputedDescriptor;
10476        let mut schema = make_schema();
10477        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10478        person.computed = vec![ComputedDescriptor {
10479            name: "recent".into(),
10480            expression: "select .posts order by .title desc limit 5".into(),
10481            return_type: None,
10482            link_target: None,
10483            link_multi: false,
10484        }];
10485        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
10486        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
10487    }
10488
10489    #[test]
10490    fn test_sub_select_with_a_shape_may_project_a_property_off_it() {
10491        let out = compile_and_emit("SELECT Person { t := (select .posts { title } limit 1).title }");
10492        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
10493        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10494    }
10495
10496    #[test]
10497    fn test_sub_statement_error_names_what_it_got() {
10498        let err = compile_err("SELECT Person { t := (insert Company { name := 'a' }) }");
10499        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
10500    }
10501
10502    #[test]
10503    fn test_with_bound_scalar_path_select_emits_a_real_select() {
10504        // The CTE body used to be just the FROM clause — `"xs" AS (
10505        // "public"."Person" AS "t0" )` — which is not SQL at all.
10506        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name }");
10507        assert!(
10508            out.sql
10509                .contains("SELECT ROW(v) AS result, v FROM (SELECT \"t0\".\"name\" AS v"),
10510            "{}",
10511            out.sql
10512        );
10513    }
10514
10515    #[test]
10516    fn test_with_bound_scalar_path_select_is_typed_as_its_result() {
10517        // Binding `Person.name` binds text, not `default::Person`, so a
10518        // reference to it reads the CTE's value column rather than an `id`.
10519        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name } FILTER .name IN xs");
10520        assert!(out.sql.contains("ANY((SELECT \"v\" FROM \"xs\"))"), "{}", out.sql);
10521    }
10522
10523    #[test]
10524    fn test_distinct_survives_a_with_binding() {
10525        let out = compile_and_emit("WITH xs := (select distinct Person.name) SELECT Person { name }");
10526        assert!(out.sql.contains("SELECT DISTINCT \"t0\".\"name\" AS v"), "{}", out.sql);
10527    }
10528
10529    #[test]
10530    fn test_for_over_a_derived_set_iterates_every_row() {
10531        // A non-literal iterator used to be wrapped in a one-row VALUES, so
10532        // the body ran once against a scalar subquery instead of per row.
10533        let out = compile_and_emit("FOR x IN (select Person.name) UNION (SELECT Person { name } FILTER .name = x)");
10534        assert!(!out.sql.contains("VALUES"), "{}", out.sql);
10535        assert!(out.sql.contains(") AS \"_for_x\""), "{}", out.sql);
10536        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
10537        assert!(out.sql.contains("\"t1\".\"name\" = \"_for_x\".\"v\""), "{}", out.sql);
10538    }
10539
10540    #[test]
10541    fn test_for_insert_over_a_derived_set_uses_it_as_the_cte() {
10542        let out = compile_and_emit("FOR x IN (select Person.name) UNION (INSERT Company { name := x })");
10543        assert!(out.sql.starts_with("WITH \"_for_x\" AS ("), "{}", out.sql);
10544        assert!(
10545            out.sql.contains("SELECT \"_for_x\".\"v\" FROM \"_for_x\""),
10546            "{}",
10547            out.sql
10548        );
10549    }
10550
10551    #[test]
10552    fn test_for_over_objects_binds_their_id() {
10553        let out = compile_and_emit("FOR p IN (select Person) UNION (INSERT Company { name := <str>p })");
10554        assert!(out.sql.contains("SELECT \"id\" AS v FROM ("), "{}", out.sql);
10555    }
10556
10557    #[test]
10558    fn test_for_over_a_non_select_statement_is_rejected() {
10559        let ast = parse::parse("FOR x IN (INSERT Company { name := 'a' }) UNION (SELECT Person { name })").unwrap();
10560        let schema = make_schema();
10561        let err = match ir::compile(&ast, &schema) {
10562            Ok(_) => panic!("expected a compile error"),
10563            Err(e) => format!("{e}"),
10564        };
10565        assert!(err.contains("only a select can be iterated"), "{err}");
10566    }
10567
10568    #[test]
10569    fn test_multi_link_path_outside_a_comparison_is_an_array() {
10570        // It stands for a set of values — as a scalar subquery Postgres
10571        // would reject it the moment a Person had two posts.
10572        let out = compile_and_emit("SELECT Person { t := .posts.title }");
10573        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10574        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10575    }
10576
10577    #[test]
10578    fn test_multi_link_path_inside_a_comparison_is_still_exists() {
10579        let out = compile_and_emit("SELECT Person { name } filter .posts.title = 'x'");
10580        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
10581        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10582    }
10583
10584    #[test]
10585    fn test_path_traversal_deeper_than_two_steps() {
10586        let schema = make_schema_with_through();
10587        let out = compile_and_emit_with("SELECT Person { t := .friends.friends.name }", &schema);
10588        // Two junction hops, then the property — one subquery, not an error.
10589        assert_eq!(out.sql.matches("\"public\".\"PersonFriend\"").count(), 2, "{}", out.sql);
10590        assert!(out.sql.contains("ARRAY(SELECT \"t4\".\"name\""), "{}", out.sql);
10591    }
10592
10593    #[test]
10594    fn test_coalesce_over_backlinked_values_is_a_set_choice() {
10595        // The backlink was taken for a comparison and rewritten to an EXISTS,
10596        // with a COALESCE of a value and an array inside it.
10597        let out = compile_and_emit("SELECT Post ORDER BY max(.<posts[is Person].age ?? .<posts[is Person].age)");
10598        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
10599        assert!(!out.sql.contains("COALESCE("), "{}", out.sql);
10600        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
10601        assert!(
10602            out.sql
10603                .contains("(SELECT coalesce(max(\"_s\".\"v\"), NULL) FROM unnest("),
10604            "{}",
10605            out.sql
10606        );
10607    }
10608
10609    #[test]
10610    fn test_coalesce_over_multilink_values_is_a_set_choice() {
10611        let out = compile_and_emit("SELECT Person { t := .posts.title ?? .posts.title }");
10612        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
10613        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
10614    }
10615
10616    #[test]
10617    fn test_coalesce_of_a_set_with_a_single_value_falls_back_to_it() {
10618        let out = compile_and_emit("SELECT Post { a := .<posts[is Person].age ?? 0 }");
10619        assert!(out.sql.contains("ELSE array_remove(ARRAY[0], NULL) END"), "{}", out.sql);
10620    }
10621
10622    #[test]
10623    fn test_traversal_after_a_backlink() {
10624        let out = compile_and_emit("SELECT Post { t := .<posts[is Person].name }");
10625        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"name\""), "{}", out.sql);
10626        assert!(out.sql.contains("\"t3\".\"target\" = \"t1\".\"id\""), "{}", out.sql);
10627    }
10628
10629    #[test]
10630    fn test_computed_pointer_on_a_linked_type_is_reachable() {
10631        use crate::schema::ComputedDescriptor;
10632        let mut schema = make_schema();
10633        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
10634        company.computed = vec![ComputedDescriptor {
10635            name: "shout".into(),
10636            expression: ".name ++ '!'".into(),
10637            return_type: Some("text".into()),
10638            link_target: None,
10639            link_multi: false,
10640        }];
10641        // `.company.shout` used to report "has no link or property 'shout'"
10642        // while helpfully suggesting 'shout' — path traversal never looked
10643        // at the target type's computed pointers.
10644        let out = compile_and_emit_with("SELECT Person { t := .company.shout }", &schema);
10645        assert!(out.sql.contains("(\"t2\".\"name\" || '!')"), "{}", out.sql);
10646    }
10647
10648    #[test]
10649    fn test_computed_pointer_behind_a_type_intersection() {
10650        let schema = make_interface_schema();
10651        let out = compile_and_emit_with("SELECT Account { n := [is Individual].full_name }", &schema);
10652        assert!(out.sql.contains("upper(\"t1\".\"first_name\")"), "{}", out.sql);
10653        assert!(out.sql.contains("FROM \"public\".\"Individual\""), "{}", out.sql);
10654        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10655    }
10656
10657    #[test]
10658    fn test_computed_pointer_cannot_be_traversed_through() {
10659        use crate::schema::ComputedDescriptor;
10660        let mut schema = make_schema();
10661        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
10662        company.computed = vec![ComputedDescriptor {
10663            name: "shout".into(),
10664            expression: ".name ++ '!'".into(),
10665            return_type: Some("text".into()),
10666            link_target: None,
10667            link_multi: false,
10668        }];
10669        let ast = parse::parse("SELECT Person { t := .company.shout.nope }").unwrap();
10670        let err = match ir::compile(&ast, &schema) {
10671            Ok(_) => panic!("expected a compile error"),
10672            Err(e) => format!("{e}"),
10673        };
10674        assert!(err.contains("is a computed pointer"), "{err}");
10675    }
10676
10677    #[test]
10678    fn test_sub_select_over_a_multi_link_path_keeps_its_modifiers() {
10679        let out = compile_and_emit("SELECT Person { t := (select .posts.title order by .title desc limit 2) }");
10680        assert!(out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
10681        assert!(out.sql.contains("ORDER BY \"t2\".\"title\" DESC"), "{}", out.sql);
10682        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
10683    }
10684
10685    #[test]
10686    fn test_limit_one_over_a_multi_link_path_stays_a_scalar() {
10687        // `limit 1` is what makes the set single-valued, so this one is a
10688        // scalar subquery rather than a one-element array.
10689        let out = compile_and_emit("SELECT Person { t := (select .posts.title limit 1) }");
10690        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10691        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10692    }
10693
10694    #[test]
10695    fn test_computed_multilink_can_carry_a_nested_shape() {
10696        let out = compile_and_emit("SELECT Person { p := .posts { title } }");
10697        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10698            panic!()
10699        };
10700        let ShapeNode::Array { name, element, .. } = &pointers[2] else {
10701            panic!("{:?}", pointers[2])
10702        };
10703        assert_eq!(name, "p");
10704        let ShapeNode::Object {
10705            pointers: elem_pointers,
10706            ..
10707        } = element.as_ref()
10708        else {
10709            panic!()
10710        };
10711        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
10712    }
10713
10714    #[test]
10715    fn test_schema_declared_backlink_computed_is_an_object_pointer() {
10716        use crate::schema::ComputedDescriptor;
10717        let mut schema = make_schema();
10718        let post = schema.types.iter_mut().find(|t| t.name == "Post").unwrap();
10719        post.computed = vec![ComputedDescriptor {
10720            name: "authors".into(),
10721            expression: ".<posts[is Person]".into(),
10722            return_type: None,
10723            link_target: None,
10724            link_multi: false,
10725        }];
10726        // A backlink computed used to compile as an EXISTS boolean here —
10727        // the pointer builders were only reachable from an inline `:=`.
10728        let out = compile_and_emit_with("SELECT Post { authors { name } }", &schema);
10729        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
10730        assert!(out.sql.contains("\"t1\".\"name\"::text"), "{}", out.sql);
10731        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10732            panic!()
10733        };
10734        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
10735    }
10736
10737    #[test]
10738    fn test_schema_declared_multilink_computed_defaults_to_ids_without_a_shape() {
10739        use crate::schema::ComputedDescriptor;
10740        let mut schema = make_schema();
10741        schema.types[0].computed = vec![ComputedDescriptor {
10742            name: "everything".into(),
10743            expression: ".posts".into(),
10744            return_type: None,
10745            link_target: None,
10746            link_multi: false,
10747        }];
10748        let out = compile_and_emit_with("SELECT Person { everything }", &schema);
10749        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10750            panic!()
10751        };
10752        assert!(matches!(&pointers[2], ShapeNode::Array { name, .. } if name == "everything"));
10753    }
10754
10755    #[test]
10756    fn test_schema_declared_computed_sub_select_is_a_link_pointer() {
10757        use crate::schema::ComputedDescriptor;
10758        let mut schema = make_schema();
10759        schema.types[0].computed = vec![
10760            ComputedDescriptor {
10761                name: "recent".into(),
10762                expression: "(select .posts order by .title desc limit 1)".into(),
10763                return_type: None,
10764                link_target: None,
10765                link_multi: false,
10766            },
10767            ComputedDescriptor {
10768                name: "recent_title".into(),
10769                expression: "(select .posts order by .title desc limit 1).title".into(),
10770                return_type: Some("text".into()),
10771                link_target: None,
10772                link_multi: false,
10773            },
10774        ];
10775        let out = compile_and_emit_with("SELECT Person { recent { title }, recent_title }", &schema);
10776        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10777            panic!()
10778        };
10779        // A declared computed gets the same treatment as one written inline:
10780        // object-valued when it selects a link, scalar when it projects one.
10781        assert!(matches!(&pointers[2], ShapeNode::Object { name, .. } if name == "recent"));
10782        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, .. } if name == "recent_title"));
10783        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
10784        assert_eq!(out.sql.matches("LIMIT 1").count(), 2, "{}", out.sql);
10785        crate::validate::validate_schema_types(&schema).expect("schema should validate");
10786    }
10787
10788    /// `select (insert T { … }).id` — the mutation is hoisted into the
10789    /// statement's own WITH and the field read off it, which is what the
10790    /// `with x := (insert …) select x.id` spelling already produced. Without
10791    /// the CTE the projection would read the whole table instead.
10792    #[test]
10793    fn test_a_mutation_read_through_a_path_runs_as_a_hoisted_cte() {
10794        let out = compile_and_emit("SELECT (INSERT Person { name := 'x' }).name");
10795        assert!(
10796            out.sql.contains("\"_nested_dml_0\" AS (") && out.sql.contains("INSERT INTO"),
10797            "the mutation must run as a CTE:\n{}",
10798            out.sql
10799        );
10800        assert!(
10801            out.sql.contains("FROM \"_nested_dml_0\""),
10802            "the projection must read the mutation's own rows:\n{}",
10803            out.sql
10804        );
10805    }
10806
10807    #[test]
10808    fn test_dml_sub_statement_in_expression_position_still_rejected() {
10809        let err = compile_err("SELECT Person { x := (insert Company { name := 'c' }).name }");
10810        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
10811    }
10812
10813    #[test]
10814    fn test_sub_select_with_a_shape_in_expression_position_is_rejected() {
10815        // Only when the shape *is* the result — projecting a property off
10816        // it is fine, since the shape then says nothing the projection
10817        // doesn't (see test_sub_select_with_a_shape_may_project_a_property_off_it).
10818        let err = compile_err("SELECT Person { name } filter (select .posts { title }) = 1");
10819        assert!(err.contains("sub-select with a shape"), "{err}");
10820    }
10821
10822    fn make_schema_with_through() -> SchemaDescriptor {
10823        let id_prop = || PropertyDescriptor {
10824            name: "id".into(),
10825            pg_type: "uuid".into(),
10826            nullable: false,
10827            default_sql: Some("gen_random_uuid()".into()),
10828            description: None,
10829            default_pyql: None,
10830            check_constraints: vec![],
10831            is_exclusive: true,
10832            is_pk: true,
10833            is_readonly: true,
10834            rewrites: vec![],
10835            tuple_members: None,
10836            column_type: None,
10837        };
10838        let name_prop = || PropertyDescriptor {
10839            name: "name".into(),
10840            pg_type: "text".into(),
10841            nullable: false,
10842            default_sql: None,
10843            description: None,
10844            check_constraints: vec![],
10845            default_pyql: None,
10846            is_exclusive: false,
10847            is_pk: false,
10848            is_readonly: false,
10849            rewrites: vec![],
10850            tuple_members: None,
10851            column_type: None,
10852        };
10853        SchemaDescriptor {
10854            types: vec![
10855                TypeDescriptor {
10856                    name: "Person".into(),
10857                    module: "default".into(),
10858                    table: "Person".into(),
10859                    abstract_: false,
10860                    materialized: false,
10861                    description: None,
10862                    parents: vec![],
10863                    interfaces: vec![],
10864                    bases: vec![],
10865                    properties: vec![id_prop(), name_prop()],
10866                    links: vec![],
10867                    multilinks: vec![MultiLinkDescriptor {
10868                        name: "friends".into(),
10869                        target: "default::Person".into(),
10870                        through: Some("default::PersonFriend".into()),
10871                        nullable: false,
10872                        description: None,
10873                        default_pyql: None,
10874                        on_delete: vec![],
10875                        is_exclusive: false,
10876                    }],
10877                    computed: vec![],
10878                    constraints: vec![],
10879                    indexes: vec![],
10880                    partition: None,
10881                    vector_indexes: vec![],
10882                    search_indexes: vec![],
10883                    triggers: vec![],
10884                    junction: false,
10885                    signals: vec![],
10886                },
10887                TypeDescriptor {
10888                    name: "PersonFriend".into(),
10889                    module: "default".into(),
10890                    table: "PersonFriend".into(),
10891                    abstract_: false,
10892                    materialized: false,
10893                    description: None,
10894                    parents: vec![],
10895                    interfaces: vec![],
10896                    bases: vec![],
10897                    properties: vec![id_prop()],
10898                    links: vec![
10899                        LinkDescriptor {
10900                            name: "person".into(),
10901                            target: "default::Person".into(),
10902                            nullable: false,
10903                            through: None,
10904                            description: None,
10905                            default_pyql: None,
10906                            is_exclusive: false,
10907                            is_readonly: false,
10908                            rewrites: vec![],
10909                            on_delete: vec![],
10910                        },
10911                        LinkDescriptor {
10912                            name: "friend".into(),
10913                            target: "default::Person".into(),
10914                            nullable: false,
10915                            through: None,
10916                            description: None,
10917                            default_pyql: None,
10918                            is_exclusive: false,
10919                            is_readonly: false,
10920                            rewrites: vec![],
10921                            on_delete: vec![],
10922                        },
10923                    ],
10924                    multilinks: vec![],
10925                    computed: vec![],
10926                    constraints: vec![],
10927                    indexes: vec![],
10928                    partition: None,
10929                    vector_indexes: vec![],
10930                    search_indexes: vec![],
10931                    triggers: vec![],
10932                    junction: false,
10933                    signals: vec![],
10934                },
10935            ],
10936            scalars: vec![],
10937            enums: vec![],
10938            named_tuples: vec![],
10939            globals: vec![],
10940            functions: vec![],
10941            aliases: vec![],
10942            channels: vec![],
10943            ..Default::default()
10944        }
10945    }
10946
10947    #[test]
10948    fn test_select_through_multi_link() {
10949        let schema = make_schema_with_through();
10950        let ast = crate::parse::parse("SELECT Person { name, friends { name } }").unwrap();
10951        let ir = crate::ir::compile(&ast, &schema).unwrap();
10952        let out = emit(&ir);
10953        // Junction table is the PersonFriend table, not the standard dotted name
10954        assert!(out.sql.contains("\"public\".\"PersonFriend\""));
10955        // Source FK column (person → Person) and target FK column (friend → Person)
10956        assert!(out.sql.contains("\"friend\""));
10957        assert!(out.sql.contains("\"person\""));
10958        // Still emits array_agg pattern
10959        assert!(out.sql.contains("array_agg(ROW("));
10960    }
10961
10962    /// Person/Org — a junction-backed single link (`Person.spouse: Link[Org,
10963    /// through(Marriage)]`, exclusive). Deliberately targets a different
10964    /// type (Org) rather than a self-link, for the same reason
10965    /// `test_select_through_multi_link` avoids Person-to-Person — see the
10966    /// comment on `make_schema_with_through` above.
10967    #[test]
10968    fn test_limited_multi_link_keeps_a_nested_multi_link_in_scope() {
10969        // The nested pointer's own correlated subquery moves inside the
10970        // derived table the LIMIT sits on, where its alias is still bound.
10971        let schema = make_schema_with_through();
10972        let ast = crate::parse::parse("SELECT Person { friends: { name, friends { name } } limit 1 }").unwrap();
10973        let ir = crate::ir::compile(&ast, &schema).unwrap();
10974        let out = emit(&ir);
10975        assert_eq!(out.sql.matches("array_agg(ROW(").count(), 2, "{}", out.sql);
10976        let derived = out.sql.find("AS \"__lim\"").unwrap();
10977        let nested = out.sql.rfind("array_agg(ROW(").unwrap();
10978        assert!(
10979            nested < derived,
10980            "nested aggregate must sit inside the derived table:\n{}",
10981            out.sql
10982        );
10983    }
10984
10985    fn make_schema_with_junction_backed_link() -> SchemaDescriptor {
10986        let id_prop = || PropertyDescriptor {
10987            name: "id".into(),
10988            pg_type: "uuid".into(),
10989            nullable: false,
10990            default_sql: Some("gen_random_uuid()".into()),
10991            description: None,
10992            default_pyql: None,
10993            check_constraints: vec![],
10994            is_exclusive: true,
10995            is_pk: true,
10996            is_readonly: true,
10997            rewrites: vec![],
10998            tuple_members: None,
10999            column_type: None,
11000        };
11001        let name_prop = || PropertyDescriptor {
11002            name: "name".into(),
11003            pg_type: "text".into(),
11004            nullable: false,
11005            default_sql: None,
11006            description: None,
11007            check_constraints: vec![],
11008            default_pyql: None,
11009            is_exclusive: false,
11010            is_pk: false,
11011            is_readonly: false,
11012            rewrites: vec![],
11013            tuple_members: None,
11014            column_type: None,
11015        };
11016        SchemaDescriptor {
11017            types: vec![
11018                TypeDescriptor {
11019                    name: "Person".into(),
11020                    module: "default".into(),
11021                    table: "Person".into(),
11022                    abstract_: false,
11023                    materialized: false,
11024                    description: None,
11025                    parents: vec![],
11026                    interfaces: vec![],
11027                    bases: vec![],
11028                    properties: vec![id_prop(), name_prop()],
11029                    links: vec![LinkDescriptor {
11030                        name: "spouse".into(),
11031                        target: "default::Org".into(),
11032                        nullable: true,
11033                        through: Some("default::Marriage".into()),
11034                        description: None,
11035                        default_pyql: None,
11036                        is_exclusive: true,
11037                        is_readonly: false,
11038                        rewrites: vec![],
11039                        on_delete: vec![],
11040                    }],
11041                    multilinks: vec![],
11042                    computed: vec![],
11043                    constraints: vec![],
11044                    indexes: vec![],
11045                    partition: None,
11046                    vector_indexes: vec![],
11047                    search_indexes: vec![],
11048                    triggers: vec![],
11049                    junction: false,
11050                    signals: vec![],
11051                },
11052                TypeDescriptor {
11053                    name: "Org".into(),
11054                    module: "default".into(),
11055                    table: "Org".into(),
11056                    abstract_: false,
11057                    materialized: false,
11058                    description: None,
11059                    parents: vec![],
11060                    interfaces: vec![],
11061                    bases: vec![],
11062                    properties: vec![id_prop(), name_prop()],
11063                    links: vec![],
11064                    multilinks: vec![],
11065                    computed: vec![],
11066                    constraints: vec![],
11067                    indexes: vec![],
11068                    partition: None,
11069                    vector_indexes: vec![],
11070                    search_indexes: vec![],
11071                    triggers: vec![],
11072                    junction: false,
11073                    signals: vec![],
11074                },
11075                TypeDescriptor {
11076                    name: "Marriage".into(),
11077                    module: "default".into(),
11078                    table: "Person.spouse".into(),
11079                    abstract_: false,
11080                    materialized: false,
11081                    description: None,
11082                    parents: vec![],
11083                    interfaces: vec![],
11084                    bases: vec![],
11085                    properties: vec![
11086                        id_prop(),
11087                        PropertyDescriptor {
11088                            name: "since".into(),
11089                            pg_type: "int8".into(),
11090                            nullable: true,
11091                            default_sql: None,
11092                            description: None,
11093                            check_constraints: vec![],
11094                            default_pyql: None,
11095                            is_exclusive: false,
11096                            is_pk: false,
11097                            is_readonly: false,
11098                            rewrites: vec![],
11099                            tuple_members: None,
11100                            column_type: None,
11101                        },
11102                    ],
11103                    links: vec![
11104                        LinkDescriptor {
11105                            name: "source".into(),
11106                            target: "default::Person".into(),
11107                            nullable: false,
11108                            through: None,
11109                            description: None,
11110                            default_pyql: None,
11111                            is_exclusive: false,
11112                            is_readonly: false,
11113                            rewrites: vec![],
11114                            on_delete: vec![],
11115                        },
11116                        LinkDescriptor {
11117                            name: "target".into(),
11118                            target: "default::Org".into(),
11119                            nullable: false,
11120                            through: None,
11121                            description: None,
11122                            default_pyql: None,
11123                            is_exclusive: false,
11124                            is_readonly: false,
11125                            rewrites: vec![],
11126                            on_delete: vec![],
11127                        },
11128                    ],
11129                    multilinks: vec![],
11130                    computed: vec![],
11131                    constraints: vec![],
11132                    indexes: vec![],
11133                    partition: None,
11134                    vector_indexes: vec![],
11135                    search_indexes: vec![],
11136                    triggers: vec![],
11137                    junction: true,
11138                    signals: vec![],
11139                },
11140            ],
11141            scalars: vec![],
11142            enums: vec![],
11143            named_tuples: vec![],
11144            globals: vec![],
11145            functions: vec![],
11146            aliases: vec![],
11147            channels: vec![],
11148            ..Default::default()
11149        }
11150    }
11151
11152    #[test]
11153    fn test_select_shape_over_junction_backed_single_link() {
11154        let schema = make_schema_with_junction_backed_link();
11155        let ast = crate::parse::parse("SELECT Person { name, spouse { name } }").unwrap();
11156        let ir = crate::ir::compile(&ast, &schema).unwrap();
11157        let out = emit(&ir);
11158        // Junction table is the through type's own table, joined like a multi-link's.
11159        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11160        assert!(out.sql.contains("\"jt\".source"), "got:\n{}", out.sql);
11161        assert!(out.sql.contains("\"jt\".target"), "got:\n{}", out.sql);
11162        // Cardinality-one: a scalar correlated subquery, never array_agg.
11163        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
11164        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11165    }
11166
11167    #[test]
11168    fn test_select_shape_over_junction_backed_single_link_with_link_property() {
11169        // A junction-backed single link can read its own link properties
11170        // (`@since`) in a nested shape, same as a multi-link's `@weight` —
11171        // this is the one read site with no reusable FK-based form
11172        // (`IrSingleLinkPointer` needed its own `link_properties`, mirroring
11173        // `IrMultiLinkPointer`'s).
11174        let schema = make_schema_with_junction_backed_link();
11175        let ast = crate::parse::parse("SELECT Person { name, spouse { name, @since } }").unwrap();
11176        let ir = crate::ir::compile(&ast, &schema).unwrap();
11177        let out = emit(&ir);
11178        assert!(out.sql.contains("\"jt\".\"since\""), "got:\n{}", out.sql);
11179        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
11180    }
11181
11182    #[test]
11183    fn test_select_path_over_junction_backed_single_link() {
11184        let schema = make_schema_with_junction_backed_link();
11185        let ast = crate::parse::parse("SELECT Person.spouse { name }").unwrap();
11186        let ir = crate::ir::compile(&ast, &schema).unwrap();
11187        let out = emit(&ir);
11188        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11189        assert!(out.sql.contains("\"source\""), "got:\n{}", out.sql);
11190        assert!(out.sql.contains("\"target\""), "got:\n{}", out.sql);
11191        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11192    }
11193
11194    #[test]
11195    fn test_insert_with_junction_backed_single_link() {
11196        let schema = make_schema_with_junction_backed_link();
11197        let out = compile_and_emit_with(
11198            "INSERT Person { name := $name, \
11199                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
11200            &schema,
11201        );
11202        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11203        assert!(out.sql.contains("\"since\""), "got:\n{}", out.sql);
11204        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11205        // A fresh row has no prior junction entry, but the emitted ON
11206        // CONFLICT target must still name only `source` — no `(source,
11207        // target)` composite unique constraint exists on this table (D3).
11208        // The conflict action is always an upsert (not DO NOTHING) since a
11209        // same-statement reassignment's paired DELETE shares this
11210        // statement's snapshot and can still "conflict" with the old row.
11211        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
11212    }
11213
11214    #[test]
11215    fn test_update_replace_junction_backed_single_link() {
11216        let schema = make_schema_with_junction_backed_link();
11217        let out = compile_and_emit_with(
11218            "UPDATE Person FILTER .id = $id SET { \
11219                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
11220            &schema,
11221        );
11222        // Replace = clear the existing junction row, then insert the new one.
11223        assert!(
11224            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11225            "got:\n{}",
11226            out.sql
11227        );
11228        assert!(
11229            out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11230            "got:\n{}",
11231            out.sql
11232        );
11233        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
11234        // The paired DELETE must not race the new INSERT for a target that's
11235        // being kept, but for a single link (PK is `source` alone) ANY
11236        // existing row conflicts regardless of target, so the upsert above
11237        // is what actually makes reassignment work, not the exclusion here.
11238        assert!(out.sql.contains("NOT IN"), "got:\n{}", out.sql);
11239    }
11240
11241    /// `unless conflict on .k else (update T filter … set { … })` — the ON
11242    /// CONFLICT target says which row conflicts, the filter says whether to
11243    /// touch it. Dropping the filter turned automator's advisory lock into an
11244    /// unconditional steal of a live holder's row.
11245    #[test]
11246    fn test_unless_conflict_else_update_keeps_its_filter() {
11247        let out = compile_and_emit(
11248            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name \
11249             ELSE (UPDATE Person FILTER .age < 30 SET { name := 'b' })",
11250        );
11251        assert!(
11252            out.sql.contains("DO UPDATE SET") && out.sql.contains("WHERE"),
11253            "the ELSE UPDATE's filter must survive as a DO UPDATE predicate:\n{}",
11254            out.sql
11255        );
11256        assert!(
11257            out.sql.contains("\"Person\".\"age\""),
11258            "the predicate must read the existing row, not `excluded`:\n{}",
11259            out.sql
11260        );
11261    }
11262
11263    /// No filter, no predicate — an unconditional ELSE UPDATE still updates.
11264    #[test]
11265    fn test_unless_conflict_else_update_without_a_filter_has_no_predicate() {
11266        let out = compile_and_emit(
11267            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { name := 'b' })",
11268        );
11269        let after_set = out.sql.split("DO UPDATE SET").nth(1).unwrap_or_default();
11270        assert!(
11271            !after_set.contains("WHERE"),
11272            "got a predicate we never asked for:\n{}",
11273            out.sql
11274        );
11275    }
11276
11277    #[test]
11278    fn test_junction_backed_single_link_rejected_in_unless_conflict_else() {
11279        // `compile_conflict_else` calls `compile_assignments_for_update`
11280        // directly on the raw ELSE-clause shape, bypassing the
11281        // shape-classification loop `compile_insert`/`compile_update` use to
11282        // intercept a junction-backed link before it reaches the plain
11283        // FK-column assignment path — must fail loudly here instead of
11284        // emitting a `spouse_id` column that doesn't exist.
11285        let schema = make_schema_with_junction_backed_link();
11286        let ast = parse::parse(
11287            "INSERT Person { name := $name } \
11288             UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { \
11289                spouse := (SELECT Org FILTER .id = $oid) })",
11290        )
11291        .unwrap();
11292        assert!(ir::compile(&ast, &schema).is_err());
11293    }
11294
11295    #[test]
11296    fn test_update_clear_junction_backed_single_link() {
11297        let schema = make_schema_with_junction_backed_link();
11298        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := {} }", &schema);
11299        assert!(
11300            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11301            "got:\n{}",
11302            out.sql
11303        );
11304        assert!(
11305            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11306            "clearing must not also insert:\n{}",
11307            out.sql
11308        );
11309    }
11310
11311    #[test]
11312    fn test_update_clear_junction_backed_single_link_with_cast_empty_set() {
11313        // Regression: the frontend (generateStatements.ts's `setNull`
11314        // handling) always emits the cast form `<Type>{}`, never the bare
11315        // `{}` — the junction-backed clear path originally only recognized
11316        // bare `Expr::Set([])`, so this form fell through to
11317        // `compile_multilink_values` and errored with "multilink value must
11318        // be a CTE reference, parenthesised subquery, or type path" instead
11319        // of clearing (confirmed live via the Data Explorer).
11320        let schema = make_schema_with_junction_backed_link();
11321        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := <Org>{} }", &schema);
11322        assert!(
11323            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11324            "got:\n{}",
11325            out.sql
11326        );
11327        assert!(
11328            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11329            "clearing must not also insert:\n{}",
11330            out.sql
11331        );
11332    }
11333
11334    /// Product/Tag/ProductTag — the actual real-world shape link properties
11335    /// were built for (mirrors pylon-demo). Deliberately *not* built off
11336    /// `make_schema_with_through` (Person self-linking to Person): a
11337    /// same-type through-link hits a pre-existing, unrelated column-
11338    /// resolution bug in `multilink_junction_info` (both source_col and
11339    /// target_col resolve to the same link name when source type == target
11340    /// type) — tracked separately, not fixed here.
11341    fn make_schema_with_through_and_prop() -> SchemaDescriptor {
11342        let id_prop = || PropertyDescriptor {
11343            name: "id".into(),
11344            pg_type: "uuid".into(),
11345            nullable: false,
11346            default_sql: Some("gen_random_uuid()".into()),
11347            description: None,
11348            default_pyql: None,
11349            check_constraints: vec![],
11350            is_exclusive: true,
11351            is_pk: true,
11352            is_readonly: true,
11353            rewrites: vec![],
11354            tuple_members: None,
11355            column_type: None,
11356        };
11357        let name_prop = || PropertyDescriptor {
11358            name: "name".into(),
11359            pg_type: "text".into(),
11360            nullable: false,
11361            default_sql: None,
11362            description: None,
11363            check_constraints: vec![],
11364            default_pyql: None,
11365            is_exclusive: false,
11366            is_pk: false,
11367            is_readonly: false,
11368            rewrites: vec![],
11369            tuple_members: None,
11370            column_type: None,
11371        };
11372        SchemaDescriptor {
11373            types: vec![
11374                TypeDescriptor {
11375                    name: "Product".into(),
11376                    module: "default".into(),
11377                    table: "Product".into(),
11378                    abstract_: false,
11379                    materialized: false,
11380                    description: None,
11381                    parents: vec![],
11382                    interfaces: vec![],
11383                    bases: vec![],
11384                    properties: vec![id_prop(), name_prop()],
11385                    links: vec![],
11386                    multilinks: vec![MultiLinkDescriptor {
11387                        name: "tags".into(),
11388                        target: "default::Tag".into(),
11389                        through: Some("default::ProductTag".into()),
11390                        nullable: false,
11391                        description: None,
11392                        default_pyql: None,
11393                        on_delete: vec![],
11394                        is_exclusive: false,
11395                    }],
11396                    computed: vec![],
11397                    constraints: vec![],
11398                    indexes: vec![],
11399                    partition: None,
11400                    vector_indexes: vec![],
11401                    search_indexes: vec![],
11402                    triggers: vec![],
11403                    junction: false,
11404                    signals: vec![],
11405                },
11406                TypeDescriptor {
11407                    name: "Tag".into(),
11408                    module: "default".into(),
11409                    table: "Tag".into(),
11410                    abstract_: false,
11411                    materialized: false,
11412                    description: None,
11413                    parents: vec![],
11414                    interfaces: vec![],
11415                    bases: vec![],
11416                    properties: vec![id_prop(), name_prop()],
11417                    links: vec![],
11418                    multilinks: vec![],
11419                    computed: vec![],
11420                    constraints: vec![],
11421                    indexes: vec![],
11422                    partition: None,
11423                    vector_indexes: vec![],
11424                    search_indexes: vec![],
11425                    triggers: vec![],
11426                    junction: false,
11427                    signals: vec![],
11428                },
11429                TypeDescriptor {
11430                    name: "ProductTag".into(),
11431                    module: "default".into(),
11432                    table: "Product.tags".into(),
11433                    abstract_: false,
11434                    materialized: false,
11435                    description: None,
11436                    parents: vec![],
11437                    interfaces: vec![],
11438                    bases: vec![],
11439                    properties: vec![
11440                        id_prop(),
11441                        PropertyDescriptor {
11442                            name: "weight".into(),
11443                            pg_type: "float8".into(),
11444                            nullable: false,
11445                            default_sql: None,
11446                            default_pyql: None,
11447                            description: None,
11448                            check_constraints: vec![],
11449                            is_exclusive: false,
11450                            is_pk: false,
11451                            is_readonly: false,
11452                            rewrites: vec![],
11453                            tuple_members: None,
11454                            column_type: None,
11455                        },
11456                    ],
11457                    // No declared Link pointers — matches pylon-demo's actual
11458                    // ProductTag, which relies on multilink_junction_info's
11459                    // "source"/"target" defaults.
11460                    links: vec![],
11461                    multilinks: vec![],
11462                    computed: vec![],
11463                    constraints: vec![],
11464                    indexes: vec![],
11465                    partition: None,
11466                    vector_indexes: vec![],
11467                    search_indexes: vec![],
11468                    triggers: vec![],
11469                    junction: true,
11470                    signals: vec![],
11471                },
11472            ],
11473            scalars: vec![],
11474            enums: vec![],
11475            named_tuples: vec![],
11476            globals: vec![],
11477            functions: vec![],
11478            aliases: vec![],
11479            channels: vec![],
11480            ..Default::default()
11481        }
11482    }
11483
11484    #[test]
11485    fn test_link_property_in_a_links_own_filter() {
11486        // `filter @weight > x` reads the junction row of the link being
11487        // filtered — `@prop` used to parse only as a postfix on a named
11488        // path, so a bare one was "expected an expression, found '@'".
11489        let schema = make_schema_with_through_and_prop();
11490        let out = compile_and_emit_with("SELECT Product { tags: { } filter @weight > 0.5 }", &schema);
11491        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11492    }
11493
11494    #[test]
11495    fn test_exists_on_a_link_property() {
11496        // `exists @weight` — the junction column the link's own modifiers are
11497        // compiled against. `exists` only knew how to take a pointer name.
11498        let schema = make_schema_with_through_and_prop();
11499        let out = compile_and_emit_with("SELECT Product { tags: { } filter exists @weight }", &schema);
11500        assert!(out.sql.contains("\"jt\".\"weight\" IS NOT NULL"), "{}", out.sql);
11501    }
11502
11503    #[test]
11504    fn test_link_property_in_a_computed_link_filter() {
11505        let schema = make_schema_with_through_and_prop();
11506        let out = compile_and_emit_with(
11507            "SELECT Product { best := (select .tags filter @weight > 0.5 limit 1) { @weight } }",
11508            &schema,
11509        );
11510        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11511        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
11512    }
11513
11514    #[test]
11515    fn test_link_property_in_a_projected_sub_select_reads_the_junction() {
11516        // The flat-join form names the junction with an ordinary alias
11517        // rather than "jt", so the scope has to carry the alias too.
11518        let schema = make_schema_with_through_and_prop();
11519        let out = compile_and_emit_with(
11520            "SELECT Product { t := (select .tags filter @weight > 0.5 limit 1).id }",
11521            &schema,
11522        );
11523        assert!(out.sql.contains("\"t3\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11524    }
11525
11526    #[test]
11527    fn test_unknown_link_property_names_the_through_type() {
11528        let schema = make_schema_with_through_and_prop();
11529        let ast = parse::parse("SELECT Product { tags: { } filter @nope > 0.5 }").unwrap();
11530        let err = match ir::compile(&ast, &schema) {
11531            Ok(_) => panic!("expected a compile error"),
11532            Err(e) => format!("{e}"),
11533        };
11534        assert!(err.contains("default::ProductTag") && err.contains("nope"), "{err}");
11535    }
11536
11537    #[test]
11538    fn test_link_property_outside_a_link_says_where_it_belongs() {
11539        let schema = make_schema_with_through_and_prop();
11540        let ast = parse::parse("SELECT Product { t := @weight }").unwrap();
11541        let err = match ir::compile(&ast, &schema) {
11542            Ok(_) => panic!("expected a compile error"),
11543            Err(e) => format!("{e}"),
11544        };
11545        assert!(err.contains("is a link property"), "{err}");
11546    }
11547
11548    #[test]
11549    fn test_multilink_append_with_link_property() {
11550        let schema = make_schema_with_through_and_prop();
11551        let out = compile_and_emit_with(
11552            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11553            &schema,
11554        );
11555        // Extra junction column present in both the INSERT column list and the SELECT list.
11556        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
11557        // Re-linking an existing pair with a new weight must update in place,
11558        // not silently keep the old value (a bare `DO NOTHING` would).
11559        assert!(
11560            out.sql
11561                .contains("ON CONFLICT (\"source\", \"target\") DO UPDATE SET \"weight\" = EXCLUDED.\"weight\""),
11562            "missing upsert conflict clause:\n{}",
11563            out.sql
11564        );
11565    }
11566
11567    #[test]
11568    fn test_multilink_append_union_with_different_link_property_values() {
11569        // The realistic Data Explorer scenario: multiple checked targets in
11570        // one `+=`, each with its own distinct property value — expressed as
11571        // a `union` of individually-shaped target selects.
11572        let schema = make_schema_with_through_and_prop();
11573        let out = compile_and_emit_with(
11574            "UPDATE Product FILTER .id = $id SET { \
11575                tags += (SELECT Tag FILTER .id = $aid) { @weight := <float64>$w1 } \
11576                    union (SELECT Tag FILTER .id = $bid) { @weight := <float64>$w2 } \
11577            }",
11578            &schema,
11579        );
11580        assert!(
11581            out.sql.contains("UNION ALL"),
11582            "expected a UNION ALL between the two shaped targets:\n{}",
11583            out.sql
11584        );
11585        // Both branches must select the same "weight" column (with their own
11586        // value) so the union has a consistent column list.
11587        assert_eq!(
11588            out.sql.matches("AS \"weight\"").count(),
11589            2,
11590            "each union branch must project its own weight:\n{}",
11591            out.sql
11592        );
11593    }
11594
11595    #[test]
11596    fn test_multilink_append_without_link_property_keeps_do_nothing() {
11597        // No `@prop := ...` anywhere — must keep the original DO NOTHING
11598        // behavior (no spurious upsert/extra columns for the common case).
11599        let schema = make_schema_with_through_and_prop();
11600        let out = compile_and_emit_with(
11601            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) }",
11602            &schema,
11603        );
11604        assert!(
11605            out.sql.contains("ON CONFLICT DO NOTHING"),
11606            "expected plain DO NOTHING when no link properties are set:\n{}",
11607            out.sql
11608        );
11609        assert!(
11610            !out.sql.contains("\"weight\""),
11611            "unexpected weight column with no link property assignment:\n{}",
11612            out.sql
11613        );
11614    }
11615
11616    #[test]
11617    fn test_multilink_link_property_rejected_on_standard_junction() {
11618        // Person.posts is a Standard (implicit) junction — no user-declared
11619        // properties, so `@prop := ...` must be rejected at compile time.
11620        let ast = crate::parse::parse(
11621            "UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $t) { @weight := <float64>$w } }",
11622        ).unwrap();
11623        match crate::ir::compile(&ast, &make_schema()) {
11624            Ok(_) => panic!("expected a compile error for link property on a Standard junction"),
11625            Err(e) => assert!(
11626                e.to_string().contains("Through"),
11627                "expected a Through[...]-related error, got: {e}"
11628            ),
11629        }
11630    }
11631
11632    #[test]
11633    fn test_multilink_link_property_rejected_on_remove() {
11634        let schema = make_schema_with_through_and_prop();
11635        let ast = crate::parse::parse(
11636            "UPDATE Product FILTER .id = $id SET { tags -= (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11637        )
11638        .unwrap();
11639        match crate::ir::compile(&ast, &schema) {
11640            Ok(_) => panic!("expected a compile error for link property on a remove (-=)"),
11641            Err(e) => assert!(
11642                e.to_string().contains("removing"),
11643                "expected a remove-related error, got: {e}"
11644            ),
11645        }
11646    }
11647
11648    #[test]
11649    fn test_insert_with_multilink_assignment() {
11650        // Regression test: `insert Product { tags := ... }` used to fail with
11651        // "object type 'default::Product' has no link or property 'tags'" —
11652        // compile_assignments_inner never checked resolve_multilink, and
11653        // IrInsert had no multi-link handling at all.
11654        let schema = make_schema_with_through_and_prop();
11655        let out = compile_and_emit_with(
11656            "INSERT Product { name := $name, tags := (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11657            &schema,
11658        );
11659        // The row insert must happen before the junction insert references its id.
11660        assert!(
11661            out.sql.contains("\"_w__ids\" AS (\nINSERT INTO"),
11662            "missing row-insert CTE:\n{}",
11663            out.sql
11664        );
11665        assert!(
11666            out.sql.contains("\"_w__ml_add_0\" AS ("),
11667            "missing junction-append CTE:\n{}",
11668            out.sql
11669        );
11670        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
11671        assert!(
11672            out.sql.contains("\"_w\" AS (\n    SELECT * FROM \"_w__ids\"\n)"),
11673            "missing _w passthrough:\n{}",
11674            out.sql
11675        );
11676        assert_eq!(
11677            out.sql.matches("WITH\n").count(),
11678            1,
11679            "must be a single flat top-level WITH block:\n{}",
11680            out.sql
11681        );
11682    }
11683
11684    /// A user `with` binding *consumed by* a statement that builds CTEs of
11685    /// its own. The binding is emitted by the generic `ir.ctes` path while
11686    /// the multi-link/junction machinery has already opened its own `WITH`;
11687    /// prefixing a second one produced `WITH ... WITH ...`, which compiles
11688    /// cleanly and then fails at execution with `syntax error at or near
11689    /// "WITH"`. Covers both link kinds, since each reaches the junction
11690    /// emitter by a different route.
11691    #[test]
11692    fn test_user_with_binding_merges_into_a_statements_own_ctes() {
11693        let schema = make_schema_with_through_and_prop();
11694        for query in [
11695            "with t := (select Tag filter .id = $tid) insert Product { name := $name, tags := t }",
11696            "with t := (select Tag filter .id = $tid) \
11697             update Product filter .name = $name set { tags += t }",
11698        ] {
11699            let out = compile_and_emit_with(query, &schema);
11700            let flat: String = out.sql.split_whitespace().collect::<Vec<_>>().join(" ");
11701            assert!(
11702                !flat.contains(") WITH "),
11703                "a second WITH clause is invalid SQL:\n{}",
11704                out.sql
11705            );
11706            assert_eq!(
11707                out.sql.matches("WITH").count(),
11708                1,
11709                "exactly one WITH keyword expected:\n{}",
11710                out.sql
11711            );
11712        }
11713    }
11714
11715    #[test]
11716    fn test_with_bound_insert_with_multilink_assignment() {
11717        // Same as above, but bound via WITH (the Data Explorer's actual
11718        // shape) — exercises emit_user_cte_parts's insert_has_any_multilink
11719        // branch instead of emit_insert_stmt's bare-statement path.
11720        let schema = make_schema_with_through_and_prop();
11721        let out = compile_and_emit_with(
11722            "with insert0 := (insert Product { name := $name, tags := (select Tag filter .id = $tid) }) select insert0",
11723            &schema,
11724        );
11725        assert!(
11726            out.sql.contains("\"insert0__ids\" AS (\nINSERT INTO"),
11727            "missing row-insert CTE:\n{}",
11728            out.sql
11729        );
11730        assert!(
11731            out.sql.contains("\"insert0__ml_add_0\" AS ("),
11732            "missing junction-append CTE:\n{}",
11733            out.sql
11734        );
11735        assert!(
11736            out.sql
11737                .contains("\"insert0\" AS (\n    SELECT * FROM \"insert0__ids\"\n)"),
11738            "missing insert0 passthrough:\n{}",
11739            out.sql
11740        );
11741        assert_eq!(
11742            out.sql.matches("WITH\n").count(),
11743            1,
11744            "must be a single flat top-level WITH block:\n{}",
11745            out.sql
11746        );
11747    }
11748
11749    #[test]
11750    fn test_with_block_cte_over_computed_global_merges_into_single_with_clause() {
11751        // Regression: `with user := (select global current_user) select user;`
11752        // produced two separate top-level `WITH` keywords ("syntax error at
11753        // or near WITH" from Postgres) whenever the computed global's own
11754        // expression referenced a session global (registering a global CTE
11755        // in addition to the user-defined "user" CTE). The merge check in
11756        // emit() only recognized the "WITH " (space) prefix convention, not
11757        // emit_cte_prefix's "WITH\n" (newline) convention used here, so it
11758        // prepended a second WITH block instead of merging into the first.
11759        let mut schema = make_schema();
11760        schema.globals.push(GlobalDescriptor {
11761            name: "current_user_id".into(),
11762            module: "default".into(),
11763            scalar_type: "std::uuid".into(),
11764            required: false,
11765            default_expr: None,
11766            computed_expr: None,
11767        });
11768        schema.globals.push(GlobalDescriptor {
11769            name: "current_user".into(),
11770            module: "default".into(),
11771            scalar_type: "Person".into(),
11772            required: false,
11773            default_expr: None,
11774            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11775        });
11776        let out = compile_and_emit_with("with\n  user := (select global current_user)\nselect user;", &schema);
11777        assert_eq!(
11778            out.sql.matches("WITH").count(),
11779            1,
11780            "must be a single WITH clause, got:\n{}",
11781            out.sql
11782        );
11783    }
11784
11785    #[test]
11786    fn test_several_mutations_in_a_free_set_each_become_a_cte() {
11787        let schema = make_schema();
11788        let out = compile_and_emit_with(
11789            "select { (update Person filter .name = 'a' set { age := 1 }), \
11790                      (update Company filter .name = 'b' set { name := 'c' }) }",
11791            &schema,
11792        );
11793        assert_eq!(
11794            out.sql.matches("UPDATE").count(),
11795            2,
11796            "both mutations have to run, got:\n{}",
11797            out.sql
11798        );
11799        assert!(out.sql.contains("WITH"), "each becomes a CTE, got:\n{}", out.sql);
11800    }
11801
11802    #[test]
11803    fn test_updating_a_binding_touches_only_its_own_rows() {
11804        // The binding names the rows to update, so resolving it to its type
11805        // without narrowing would rewrite every row in the table.
11806        let schema = make_schema();
11807        let out = compile_and_emit_with(
11808            "with mine := (select Person filter .name = 'a') select { (update mine set { age := 1 }) }",
11809            &schema,
11810        );
11811        assert!(
11812            out.sql.contains("UPDATE") && out.sql.contains("= ANY(ARRAY(SELECT"),
11813            "expected the update narrowed to the binding's rows, got:\n{}",
11814            out.sql
11815        );
11816    }
11817
11818    #[test]
11819    fn test_updating_a_traversal_touches_only_the_rows_it_lands_on() {
11820        let mut schema = make_schema();
11821        // The traversal's target needs an id to narrow against; every real
11822        // schema declares one, this one leaves it off Company.
11823        let company = schema
11824            .types
11825            .iter_mut()
11826            .find(|t| t.name == "Company")
11827            .expect("test schema has a Company type");
11828        company.properties.push(crate::schema::PropertyDescriptor {
11829            name: "id".into(),
11830            pg_type: "uuid".into(),
11831            nullable: false,
11832            default_sql: Some("uuidv7()".into()),
11833            default_pyql: None,
11834            description: None,
11835            check_constraints: vec![],
11836            is_exclusive: true,
11837            is_pk: true,
11838            is_readonly: true,
11839            rewrites: vec![],
11840            tuple_members: None,
11841            column_type: None,
11842        });
11843        let out = compile_and_emit_with(
11844            "with mine := (select Person filter .name = 'a') select { (update mine.company set { name := 'x' }) }",
11845            &schema,
11846        );
11847        assert!(
11848            out.sql.contains("= ANY(ARRAY(SELECT"),
11849            "expected the update narrowed to the traversal's rows, got:\n{}",
11850            out.sql
11851        );
11852        assert!(
11853            out.sql.contains("\"Company\""),
11854            "expected the table the traversal ends on, got:\n{}",
11855            out.sql
11856        );
11857    }
11858
11859    #[test]
11860    fn test_aggregate_over_a_multi_valued_path_keeps_the_set_flat() {
11861        // Regression: `array_agg(a.posts.title)` compiled the path as an
11862        // expression, where a multi-valued path stands for the array of its
11863        // elements — so the aggregate wrapped that array and the result came
11864        // back one level deep, `[[t1, t2]]` rather than `[t1, t2]`.
11865        let schema = make_schema();
11866        let out = compile_and_emit_with(
11867            "with a := (select Person limit 1) select { titles := array_agg(a.posts.title) }",
11868            &schema,
11869        );
11870        assert!(
11871            !out.sql.contains("array_agg(ARRAY("),
11872            "the aggregate must take the set, not the array standing for it, got:\n{}",
11873            out.sql
11874        );
11875        assert!(
11876            out.sql.contains("array_agg(") && out.sql.contains("\"title\""),
11877            "expected the aggregate over the traversal's own column, got:\n{}",
11878            out.sql
11879        );
11880        // The free object keeps its composite emission, so values stay typed
11881        // rather than passing through jsonb as strings.
11882        assert!(
11883            out.sql.contains("ROW(") && !out.sql.contains("jsonb_build_object"),
11884            "expected a composite row, got:\n{}",
11885            out.sql
11886        );
11887    }
11888
11889    #[test]
11890    fn test_with_bound_root_read_by_name_inside_a_nested_select() {
11891        // Regression: `compile_path_select` resolved a `with`-bound root
11892        // correctly, but the multi-valued check straight after it re-resolved
11893        // the same root as a plain type, so reading the binding by name from a
11894        // nested sub-select failed with "unknown type 'owner'".
11895        let schema = make_schema();
11896        let out = compile_and_emit_with(
11897            "with
11898  owner := (select Person limit 1)
11899select owner { posts := (select owner.posts.title) };",
11900            &schema,
11901        );
11902        assert!(
11903            out.sql.contains("WITH") && out.sql.contains("\"title\""),
11904            "expected the nested select to traverse the binding, got:\n{}",
11905            out.sql
11906        );
11907    }
11908
11909    #[test]
11910    fn test_path_traversal_into_with_bound_cte_of_object_type() {
11911        // Regression: `with user := (select global current_user) select
11912        // user.name;` failed with "unknown type 'user'" — compile_path_select
11913        // (used for multi-step absolute paths like `user.name`) only ever
11914        // tried resolve_type(root_name), never checking whether the root
11915        // name is a WITH-block CTE bound to an object type.
11916        let mut schema = make_schema();
11917        schema.globals.push(GlobalDescriptor {
11918            name: "current_user_id".into(),
11919            module: "default".into(),
11920            scalar_type: "std::uuid".into(),
11921            required: false,
11922            default_expr: None,
11923            computed_expr: None,
11924        });
11925        schema.globals.push(GlobalDescriptor {
11926            name: "current_user".into(),
11927            module: "default".into(),
11928            scalar_type: "Person".into(),
11929            required: false,
11930            default_expr: None,
11931            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11932        });
11933        let out = compile_and_emit_with(
11934            "with\n  user := (select global current_user)\nselect user.name;",
11935            &schema,
11936        );
11937        assert!(
11938            out.sql.contains("FROM \"user\""),
11939            "expected path traversal from the CTE, got:\n{}",
11940            out.sql
11941        );
11942        assert_eq!(
11943            out.sql.matches("WITH").count(),
11944            1,
11945            "must be a single WITH clause, got:\n{}",
11946            out.sql
11947        );
11948    }
11949
11950    #[test]
11951    fn test_with_bound_schema_object_property_access_in_filter_expression() {
11952        // Regression: `with person := (select detached Person filter ...)
11953        // select Person filter .id not in person.id;` failed with "absolute
11954        // paths are not valid in expression context" — compile_path's
11955        // schema-bound-CTE-in-expression-position case only ever handled a
11956        // WITH-bound *free object* (resolve_cte_path), never a WITH-bound
11957        // *schema object* CTE, even though its own SELECT * CTE source
11958        // (emit_dml_as_cte_source) always exposes every raw column.
11959        let schema = make_schema();
11960        let out = compile_and_emit_with(
11961            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11962             select Person filter .id not in person.id;",
11963            &schema,
11964        );
11965        assert!(
11966            out.sql.contains("<> ALL((SELECT") && out.sql.contains(".\"id\"") && out.sql.contains("FROM \"person\""),
11967            "got:\n{}",
11968            out.sql
11969        );
11970    }
11971
11972    #[test]
11973    fn test_with_bound_schema_object_scalar_property_access() {
11974        // Not just `.id` — any scalar property of the bound type is
11975        // reachable, since the CTE source is `SELECT *` regardless of the
11976        // binding's own declared shape.
11977        let schema = make_schema();
11978        let out = compile_and_emit_with(
11979            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11980             select Person filter .name = person.name;",
11981            &schema,
11982        );
11983        assert!(
11984            out.sql.contains("(SELECT") && out.sql.contains(".\"name\"") && out.sql.contains("FROM \"person\""),
11985            "got:\n{}",
11986            out.sql
11987        );
11988    }
11989
11990    #[test]
11991    fn test_with_bound_schema_object_link_traversal_reaches_nested_property() {
11992        // Generalization: a with-bound schema object's CTE source is
11993        // `SELECT *`, so it's traversable exactly like a real type name —
11994        // not just its own scalar properties, but a *link's* properties too
11995        // (`person.company.name`), via the same path-traversal machinery
11996        // `TypeName.a.b.c` already uses (compile_path_select), wrapped as a
11997        // correlated subquery (IrExpr::PathSubquery).
11998        let schema = make_schema();
11999        let out = compile_and_emit_with(
12000            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
12001             select Company filter .name = person.company.name;",
12002            &schema,
12003        );
12004        assert!(
12005            out.sql.contains("(SELECT") && out.sql.contains("\"name\"") && out.sql.contains("\"Company\""),
12006            "got:\n{}",
12007            out.sql
12008        );
12009    }
12010
12011    #[test]
12012    fn test_with_bound_schema_object_bare_link_reduces_to_id() {
12013        // A bare link at the end of the chain (`person.company`, no further
12014        // traversal) reduces to the linked object's id, matching the
12015        // existing bare-CTE-reference convention (IrExpr::CteRef).
12016        let schema = make_schema();
12017        let out = compile_and_emit_with(
12018            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
12019             select Person filter .id = person.company;",
12020            &schema,
12021        );
12022        assert!(
12023            out.sql.contains("(SELECT") && out.sql.contains("\"id\""),
12024            "got:\n{}",
12025            out.sql
12026        );
12027    }
12028
12029    #[test]
12030    fn test_with_bound_schema_object_unknown_property_suggests_a_close_match() {
12031        let schema = make_schema();
12032        let ast = parse::parse(
12033            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
12034             select Person filter .name = person.nam;",
12035        )
12036        .unwrap();
12037        match ir::compile(&ast, &schema) {
12038            Err(err) => assert!(format!("{err}").contains("Did you mean 'name'"), "got: {err}"),
12039            Ok(_) => panic!("expected a compile error"),
12040        }
12041    }
12042
12043    #[test]
12044    fn test_with_bound_free_object_passthrough_preserves_all_fields() {
12045        // Regression: `with test := { test2 := 1.0, test3 := 'str' } select
12046        // test;` decoded as just `1.0` (the CTE's first field) — a bare
12047        // reference to a free-object CTE (`IrFreeExpr::CtePassthrough`) had
12048        // its shape hardcoded to `ShapeNode::Scalar` regardless of what the
12049        // CTE actually held.
12050        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test;");
12051        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12052            panic!("expected Object shape, got {:?}", out.shape.root)
12053        };
12054        assert_eq!(pointers.len(), 2);
12055        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, .. } if name == "test2"));
12056        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, .. } if name == "test3"));
12057    }
12058
12059    #[test]
12060    fn test_with_bound_free_object_field_access() {
12061        // Regression: `with test := {...} select test.test2;` failed with
12062        // "unknown type 'test'" — any absolute path with 2+ steps
12063        // unconditionally routed to compile_path_select, which only knows
12064        // how to resolve real schema types, never a free-value CTE.
12065        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test.test2;");
12066        assert!(out.sql.contains("\"test2\" FROM \"test\""), "got:\n{}", out.sql);
12067    }
12068
12069    #[test]
12070    fn test_with_bound_free_object_nested_field_access_chain() {
12071        // A field-access chain through a *nested* free object literal
12072        // (`test.test3.foo`) must resolve the first hop via the CTE's own
12073        // materialized column, then extract `foo` as jsonb from that value
12074        // — the fix must not be hardcoded to exactly 2 path steps.
12075        let out =
12076            compile_and_emit("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.foo;");
12077        assert!(out.sql.contains("\"test3\" FROM \"test\""), "got:\n{}", out.sql);
12078        assert!(
12079            out.sql.contains("->'foo'"),
12080            "expected jsonb field extraction, got:\n{}",
12081            out.sql
12082        );
12083    }
12084
12085    #[test]
12086    fn test_with_bound_free_object_nested_field_access_wrong_field_errors() {
12087        // A typo'd field name anywhere in the chain must still be a compile
12088        // error, not silently emit SQL that returns NULL at runtime.
12089        let schema = make_schema();
12090        let ast = parse::parse("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.nope;")
12091            .unwrap();
12092        assert!(ir::compile(&ast, &schema).is_err());
12093    }
12094
12095    #[test]
12096    fn test_nested_free_object_literal_in_computed_shape_element() {
12097        // Regression: a free object literal (`{ foo := 'bar' }`) nested
12098        // inside a computed shape element — not the top-level SELECT
12099        // result — hit "shapes and set literals are not valid in
12100        // expression context"; free object literals were only ever handled
12101        // at the statement level.
12102        let out = compile_and_emit("select default::Person { id, test := { foo := 'bar' } };");
12103        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12104            panic!("expected Object shape")
12105        };
12106        let test_node = pointers
12107            .iter()
12108            .find(|p| matches!(p, ShapeNode::NamedTuple { name, .. } if name == "test"))
12109            .unwrap_or_else(|| panic!("expected a NamedTuple shape node for 'test', got {:?}", pointers));
12110        assert!(matches!(
12111            test_node,
12112            ShapeNode::NamedTuple {
12113                is_free_object: true,
12114                ..
12115            }
12116        ));
12117    }
12118
12119    #[test]
12120    fn test_bare_free_cte_reference_in_computed_shape_collapses_to_empty() {
12121        // A free-object CTE referenced bare (no shape) as a computed shape
12122        // element's value has nothing to project — a free object needs an
12123        // explicit shape to know what to expose (unlike a tuple, which is
12124        // a plain value with no such requirement).
12125        let out = compile_and_emit(
12126            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
12127             select default::Person { id, test := test };",
12128        );
12129        assert!(
12130            out.sql.contains("jsonb_build_object()"),
12131            "expected an empty free object, got:\n{}",
12132            out.sql
12133        );
12134    }
12135
12136    #[test]
12137    fn test_shaped_free_cte_reference_projects_fields() {
12138        // `test := test { test2 }` must project just the named field out
12139        // of the underlying free object — neither collapsing to empty nor
12140        // returning every field.
12141        let out = compile_and_emit(
12142            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
12143             select default::Person { id, test := test { test2 } };",
12144        );
12145        assert!(out.sql.contains("jsonb_build_object('test2'"), "got:\n{}", out.sql);
12146        assert!(
12147            !out.sql.contains("'test3'"),
12148            "test3 should not be projected, got:\n{}",
12149            out.sql
12150        );
12151    }
12152
12153    #[test]
12154    fn test_insert_multilink_remove_rejected() {
12155        let schema = make_schema_with_through_and_prop();
12156        let ast =
12157            crate::parse::parse("INSERT Product { name := $name, tags -= (SELECT Tag FILTER .id = $tid) }").unwrap();
12158        match crate::ir::compile(&ast, &schema) {
12159            Ok(_) => panic!("expected a compile error for `-=` on a multi-link at insert time"),
12160            Err(e) => assert!(
12161                e.to_string().contains("nothing to remove"),
12162                "expected a 'nothing to remove yet' error, got: {e}"
12163            ),
12164        }
12165    }
12166
12167    #[test]
12168    fn test_multilink_junction_info_disambiguates_self_referencing_through_type() {
12169        // Regression test for a self-referencing through-link (Person.friends
12170        // via PersonFriend, which declares two Person-typed links: "person"
12171        // and "friend"). Naively matching purely by target type resolves
12172        // *both* source_col and target_col to the same first-matching link
12173        // ("person"), silently collapsing the junction to a single column
12174        // and losing the other side entirely.
12175        let schema = make_schema_with_through();
12176        let out = compile_and_emit_with(
12177            "UPDATE Person FILTER .id = $id SET { friends += (SELECT Person FILTER .id = $fid) }",
12178            &schema,
12179        );
12180        assert!(
12181            out.sql.contains("(\"person\", \"friend\")"),
12182            "expected two distinct FK columns:\n{}",
12183            out.sql
12184        );
12185        assert!(
12186            !out.sql.contains("(\"person\", \"person\")"),
12187            "source/target collapsed to the same column:\n{}",
12188            out.sql
12189        );
12190    }
12191
12192    #[test]
12193    fn test_shape_descriptor_scalars() {
12194        let out = compile_and_emit("SELECT Person { name, age }");
12195        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12196            panic!()
12197        };
12198        assert_eq!(pointers.len(), 4); // __type__, the implicit id, name, age
12199        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, position: 0 } if name == "__type__"));
12200        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, position: 1 } if name == "id"));
12201        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, position: 2 } if name == "name"));
12202        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, position: 3 } if name == "age"));
12203    }
12204
12205    #[test]
12206    fn test_shape_descriptor_multi_link() {
12207        let out = compile_and_emit("SELECT Person { name, posts { title } }");
12208        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12209            panic!()
12210        };
12211        // pointers: [__type__, id, name, posts]
12212        assert_eq!(pointers.len(), 4);
12213        let ShapeNode::Array {
12214            name,
12215            position,
12216            element,
12217        } = &pointers[3]
12218        else {
12219            panic!()
12220        };
12221        assert_eq!(name, "posts");
12222        assert_eq!(*position, 3);
12223        let ShapeNode::Object {
12224            pointers: elem_pointers,
12225            ..
12226        } = element.as_ref()
12227        else {
12228            panic!()
12229        };
12230        // element pointers: [__type__, title] — `Post` has no pk property in
12231        // this fixture, so nothing is injected into its shape.
12232        assert_eq!(elem_pointers.len(), 2);
12233    }
12234
12235    #[test]
12236    fn test_select_order_by_limit() {
12237        let out = compile_and_emit("SELECT Person { name } ORDER BY .name ASC LIMIT 10");
12238        assert!(out.sql.contains("ORDER BY"));
12239        assert!(out.sql.contains("LIMIT 10"));
12240    }
12241
12242    #[test]
12243    fn test_insert_returning() {
12244        let out = compile_and_emit("INSERT Person { name := 'Alice', age := 30 }");
12245        assert!(out.sql.contains("INSERT INTO \"public\".\"Person\""));
12246        assert!(out.sql.contains("RETURNING"));
12247        assert!(out.sql.contains("'default::Person'::text"));
12248        assert!(out.sql.contains(") AS result"));
12249        // Bare INSERT returns pk only
12250        let ShapeNode::Object {
12251            cardinality, pointers, ..
12252        } = &out.shape.root
12253        else {
12254            panic!()
12255        };
12256        assert_eq!(*cardinality, Cardinality::Required);
12257        // Only __type__ and id — not name or age
12258        assert!(
12259            pointers
12260                .iter()
12261                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "id"))
12262        );
12263        assert!(
12264            !pointers
12265                .iter()
12266                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "name"))
12267        );
12268    }
12269
12270    #[test]
12271    fn test_update_returning() {
12272        let out = compile_and_emit("UPDATE Person FILTER .name = $name SET { age := 31 }");
12273        assert!(out.sql.contains("UPDATE \"public\".\"Person\""));
12274        assert!(out.sql.contains("SET"));
12275        // Bare UPDATE returns pk only
12276        assert!(out.sql.contains("RETURNING"));
12277        assert!(out.sql.contains("'default::Person'::text"));
12278        assert!(!out.sql.contains("\"name\"::text"), "bare UPDATE must not return name");
12279    }
12280
12281    #[test]
12282    fn test_update_set_tuple_param_cast_uses_direct_jsonb_cast_not_to_jsonb() {
12283        // Regression: to_jsonb($N) on a bare, still-untyped parameter fails at
12284        // execution time with "could not determine polymorphic type because
12285        // input has type unknown" — Postgres can't dispatch a polymorphic
12286        // function against an unknown-typed placeholder. A direct
12287        // ($N)::jsonb cast resolves the parameter's type from the cast
12288        // itself instead, matching how e.g. `$1::uuid` already works.
12289        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := <tuple<x: float64>>$val }");
12290        assert!(
12291            out.sql.contains(")::jsonb"),
12292            "expected a direct ::jsonb cast, got:\n{}",
12293            out.sql
12294        );
12295        assert!(
12296            !out.sql.contains("to_jsonb($"),
12297            "must not pass a bare param straight into to_jsonb(): got:\n{}",
12298            out.sql
12299        );
12300    }
12301
12302    #[test]
12303    fn test_empty_set_cast_to_object_type_clears_optional_link() {
12304        // Regression: unsetting an optional single-link generates
12305        // `<TargetType>{}` (a cast of the empty set, not the bare `{}` the
12306        // assignment-position special case in compile_assignments_inner
12307        // already handled) — that TypeCast wraps the empty set, so it fell
12308        // through to the generic Set/Shape rejection instead ("shapes and
12309        // set literals are not valid in expression context"). Must compile
12310        // to a plain NULL for the link's FK column: `<AnyType>{}` is always
12311        // NULL regardless of context.
12312        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { company := <default::Company>{} }");
12313        assert!(out.sql.contains("\"company_id\" = NULL"), "got:\n{}", out.sql);
12314    }
12315
12316    #[test]
12317    fn test_an_aggregate_over_a_set_literal_inside_a_shape() {
12318        // `min({cap, .age})` is one value read from a two-element set, and the
12319        // set is the aggregate's argument wherever the call is written. In a
12320        // schema-bound shape it used to report "shapes and set literals are not
12321        // valid in expression context" while the same call compiled at the top
12322        // level, so a saturating cap had no spelling here at all.
12323        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := min({<int64>100, .age}) }");
12324        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12325        assert!(
12326            out.sql.contains("UNION ALL"),
12327            "both operands must reach the aggregate:\n{}",
12328            out.sql
12329        );
12330    }
12331
12332    #[test]
12333    fn test_an_aggregate_over_a_set_literal_reads_the_subject() {
12334        // The operands compile with the shape's own context, so a relative path
12335        // among them still resolves to the subject's column.
12336        let out = compile_and_emit("SELECT Person { capped := min({<int64>100, .age}) }");
12337        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12338        assert!(
12339            out.sql.contains("\"age\""),
12340            "the relative path must reach the column:\n{}",
12341            out.sql
12342        );
12343    }
12344
12345    #[test]
12346    fn test_an_aggregate_over_a_union_inside_a_shape() {
12347        // `min((a union b))` says the same thing as `min({a, b})`, and used to
12348        // report "union is not valid in expression context".
12349        let out = compile_and_emit("SELECT Person { capped := min((<int64>100 union .age)) }");
12350        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12351        assert!(
12352            out.sql.contains("UNION ALL"),
12353            "both arms must reach the aggregate:\n{}",
12354            out.sql
12355        );
12356    }
12357
12358    #[test]
12359    fn test_an_aggregate_over_a_union_of_three_arms_flattens() {
12360        let out = compile_and_emit("SELECT Person { capped := min((<int64>1 union <int64>2 union .age)) }");
12361        assert_eq!(
12362            out.sql.matches("UNION ALL").count(),
12363            2,
12364            "three arms means two joins:\n{}",
12365            out.sql
12366        );
12367    }
12368
12369    #[test]
12370    fn test_an_aggregate_over_an_empty_set_emits_no_row_source() {
12371        // An empty set has nothing to aggregate, and the row source it would
12372        // emit (`FROM () AS _set(v)`) is not valid SQL -- so `{}` must not be
12373        // taken for an aggregate's operands, in either context.
12374        for query in ["SELECT Person { capped := min({}) }", "SELECT min({})"] {
12375            let ast = parse::parse(query).expect("parse failed");
12376            let sql = ir::compile(&ast, &make_schema())
12377                .map(|ir| emit(&ir).sql)
12378                .unwrap_or_default();
12379            assert!(
12380                !sql.contains("FROM () AS"),
12381                "{query} emitted an empty row source:\n{sql}"
12382            );
12383        }
12384    }
12385
12386    #[test]
12387    fn test_delete_returning() {
12388        let out = compile_and_emit("DELETE Person FILTER .id = $id");
12389        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""));
12390        // Bare DELETE returns pk only
12391        assert!(out.sql.contains("RETURNING"));
12392        assert!(out.sql.contains("'default::Person'::text"));
12393        assert!(!out.sql.contains("\"name\"::text"), "bare DELETE must not return name");
12394    }
12395
12396    #[test]
12397    fn test_select_over_insert() {
12398        let out = compile_and_emit("SELECT (INSERT Person { name := $name, age := $age }) { id, name }");
12399        // Must use a CTE
12400        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12401        assert!(out.sql.contains("INSERT INTO"));
12402        assert!(out.sql.contains("RETURNING *"));
12403        // Outer SELECT shapes the result
12404        assert!(out.sql.contains("'default::Person'::text"));
12405        assert!(out.sql.contains("\"name\"::text"));
12406    }
12407
12408    #[test]
12409    fn test_select_over_update() {
12410        let out = compile_and_emit("SELECT (UPDATE Person FILTER .id = $id SET { name := $name }) { id, name }");
12411        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12412        assert!(out.sql.contains("UPDATE"));
12413        assert!(out.sql.contains("RETURNING \"t1\".*"), "{}", out.sql);
12414        assert!(out.sql.contains("\"name\"::text"));
12415    }
12416
12417    #[test]
12418    fn test_insert_user_specified_id_denied_by_default() {
12419        let schema = make_schema();
12420        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
12421        match ir::compile_with_config(&ast, &schema, &ir::SessionConfig::default()) {
12422            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
12423            Ok(_) => panic!("expected id assignment to be denied by default"),
12424        }
12425    }
12426
12427    #[test]
12428    fn test_insert_user_specified_id_allowed_when_configured() {
12429        let schema = make_schema();
12430        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
12431        let config = ir::SessionConfig {
12432            allow_user_specified_id: true,
12433        };
12434        let ir_out = ir::compile_with_config(&ast, &schema, &config)
12435            .expect("expected id assignment to be allowed with allow_user_specified_id");
12436        let out = emit(&ir_out);
12437        assert!(out.sql.contains("INSERT INTO"));
12438    }
12439
12440    #[test]
12441    fn test_update_user_specified_id_denied_even_when_configured() {
12442        let schema = make_schema();
12443        let ast = parse::parse("UPDATE Person FILTER .name = $name SET { id := <uuid>$id }").unwrap();
12444        let config = ir::SessionConfig {
12445            allow_user_specified_id: true,
12446        };
12447        match ir::compile_with_config(&ast, &schema, &config) {
12448            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
12449            Ok(_) => panic!("expected UPDATE to always deny reassigning id"),
12450        }
12451    }
12452
12453    #[test]
12454    fn test_select_over_update_multilink_only() {
12455        // Regression test: an UPDATE bound to a single external name (here,
12456        // the implicit "_dml" wrapper for `SELECT (UPDATE ...)`) whose SET
12457        // clause is *only* a multi-link mutation, with no scalar/single-link
12458        // assignment. This used to either emit an empty `SET` clause
12459        // ("UPDATE ... SET  WHERE ..." — invalid SQL) or, in an earlier fix
12460        // attempt, nest the junction INSERT inside "_dml"'s own CTE body —
12461        // which Postgres rejects outright ("WITH clause containing a
12462        // data-modifying statement must be at the top level"). The junction
12463        // CTEs must instead be hoisted out as top-level siblings of "_dml".
12464        let out = compile_and_emit(
12465            "SELECT (UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $title) }) { id, name }",
12466        );
12467        assert!(
12468            out.sql.contains("\"_dml__ml_add_0\""),
12469            "missing junction-append CTE:\n{}",
12470            out.sql
12471        );
12472        assert!(out.sql.contains("INSERT INTO"), "missing junction INSERT:\n{}", out.sql);
12473        // No scalar changes -> the row-source CTE must be a SELECT, not an UPDATE
12474        // with an empty SET clause.
12475        assert!(
12476            out.sql.contains("\"_dml__ids\" AS (\nSELECT"),
12477            "expected SELECT-based _ids CTE:\n{}",
12478            out.sql
12479        );
12480        assert!(
12481            !out.sql.contains("SET\n\nWHERE") && !out.sql.contains("SET \nWHERE"),
12482            "empty SET clause regression:\n{}",
12483            out.sql
12484        );
12485        // The junction CTE must be a *sibling* at the top-level WITH, not
12486        // nested inside another CTE's body — only one "WITH" keyword total.
12487        assert_eq!(
12488            out.sql.matches("WITH\n").count(),
12489            1,
12490            "junction CTE must not be nested in a second WITH:\n{}",
12491            out.sql
12492        );
12493        // "_dml" itself must still resolve (as a passthrough) for the outer SELECT.
12494        assert!(
12495            out.sql.contains("\"_dml\" AS (\n    SELECT * FROM \"_dml__ids\"\n)"),
12496            "missing _dml passthrough:\n{}",
12497            out.sql
12498        );
12499    }
12500
12501    #[test]
12502    fn test_select_over_update_scalar_and_multilink() {
12503        // Mixed case: a scalar assignment alongside a multi-link mutation, both
12504        // bound to the same external name — the scalar SET must survive *and*
12505        // the junction mutation must still be emitted as its own top-level CTE.
12506        let out = compile_and_emit(
12507            "SELECT (UPDATE Person FILTER .id = $id SET { name := $name, posts += (SELECT Post FILTER .title = $title) }) { id, name }",
12508        );
12509        assert!(
12510            out.sql.contains("\"_dml__ml_add_0\""),
12511            "missing junction-append CTE:\n{}",
12512            out.sql
12513        );
12514        assert!(
12515            out.sql.contains("\"_dml__ids\" AS (\nUPDATE"),
12516            "expected UPDATE-based _ids CTE:\n{}",
12517            out.sql
12518        );
12519        assert!(
12520            out.sql.contains("\"name\" = "),
12521            "missing scalar SET assignment:\n{}",
12522            out.sql
12523        );
12524        assert_eq!(
12525            out.sql.matches("WITH\n").count(),
12526            1,
12527            "junction CTE must not be nested in a second WITH:\n{}",
12528            out.sql
12529        );
12530    }
12531
12532    #[test]
12533    fn test_with_bound_insert_and_multilink_update_forward_ref() {
12534        // The actual shape pylon-ui's Data Explorer generates for its
12535        // flagship "insert + link in one batch" scenario: a same-batch
12536        // forward-reference (Person.posts += a not-yet-existing Post, bound
12537        // to insert0) inside a multi-statement WITH. This is the exact query
12538        // that failed live against a real Postgres before this fix ("WITH
12539        // clause containing a data-modifying statement must be at the top
12540        // level"), so it's the most important regression to pin down.
12541        let out = compile_and_emit(
12542            "with insert0 := (insert Post { title := $title }), update0 := (update Person filter .id = $id set { posts += (select insert0) }) select { insert0, update0 }",
12543        );
12544        assert!(
12545            out.sql.contains("\"insert0\" AS (\n    INSERT INTO"),
12546            "missing insert0 CTE:\n{}",
12547            out.sql
12548        );
12549        assert!(
12550            out.sql.contains("\"update0__ml_add_0\""),
12551            "missing junction-append CTE for update0:\n{}",
12552            out.sql
12553        );
12554        assert!(
12555            out.sql.contains("\"update0__ids\" AS (\nSELECT"),
12556            "expected SELECT-based update0 ids CTE (no scalar changes):\n{}",
12557            out.sql
12558        );
12559        assert!(
12560            out.sql
12561                .contains("\"update0\" AS (\n    SELECT * FROM \"update0__ids\"\n)"),
12562            "missing update0 passthrough:\n{}",
12563            out.sql
12564        );
12565        // Exactly one WITH keyword — every CTE (insert0, update0__ids,
12566        // update0__ml_add_0, update0) must be a top-level sibling.
12567        assert_eq!(
12568            out.sql.matches("WITH\n").count(),
12569            1,
12570            "must be a single flat top-level WITH block:\n{}",
12571            out.sql
12572        );
12573    }
12574
12575    #[test]
12576    fn test_select_over_delete() {
12577        let out = compile_and_emit("SELECT (DELETE Person FILTER .id = $id) { id, name }");
12578        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12579        assert!(out.sql.contains("DELETE FROM"));
12580        assert!(out.sql.contains("RETURNING *"));
12581        assert!(out.sql.contains("\"name\"::text"));
12582    }
12583
12584    fn make_schema_with_rewrite() -> SchemaDescriptor {
12585        use crate::schema::RewriteEntry;
12586        let mut schema = make_schema();
12587        // Add a `slug` property to Person with an INSERT+UPDATE rewrite: lower(.name)
12588        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
12589        person.properties.push(PropertyDescriptor {
12590            name: "slug".into(),
12591            pg_type: "text".into(),
12592            nullable: true,
12593            default_sql: None,
12594            default_pyql: None,
12595            description: None,
12596            check_constraints: vec![],
12597            is_exclusive: false,
12598            is_pk: false,
12599            is_readonly: false,
12600            rewrites: vec![
12601                RewriteEntry {
12602                    on: 1,
12603                    handler: "str_lower(.name)".into(),
12604                }, // INSERT
12605                RewriteEntry {
12606                    on: 2,
12607                    handler: "str_lower(.name)".into(),
12608                }, // UPDATE
12609            ],
12610            tuple_members: None,
12611            column_type: None,
12612        });
12613        schema
12614    }
12615
12616    #[test]
12617    fn a_link_assigned_from_a_select_keeps_its_clauses() {
12618        let out = compile_and_emit(
12619            "INSERT Person { name := 'a', age := 1, company := (SELECT Company ORDER BY .name LIMIT 1) }",
12620        );
12621        assert!(
12622            out.sql.contains("ORDER BY") && out.sql.contains("LIMIT 1"),
12623            "{}",
12624            out.sql
12625        );
12626    }
12627
12628    #[test]
12629    fn a_rewrite_leaves_the_statement_alone() {
12630        // It runs in the table's own `BEFORE` trigger, on the row as written.
12631        let schema = make_schema_with_rewrite();
12632        for query in [
12633            "INSERT Person { name := $name, age := 30 }",
12634            "UPDATE Person FILTER .id = $id SET { name := $name }",
12635        ] {
12636            let out = compile_and_emit_with(query, &schema);
12637            assert!(!out.sql.contains("\"slug\""), "{}", out.sql);
12638        }
12639    }
12640
12641    #[test]
12642    fn a_rewrite_reads_the_row_being_written() {
12643        let schema = make_schema_with_rewrite();
12644        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
12645        let [assignment] = assignments.as_slice() else {
12646            panic!("one rewrite on insert")
12647        };
12648        assert_eq!(assignment.column, "slug");
12649        assert!(assignment.sql.contains("NEW.\"name\""), "{}", assignment.sql);
12650    }
12651
12652    #[test]
12653    fn rewrites_become_before_triggers() {
12654        let ddl = crate::export::export_schema(&make_schema_with_rewrite()).unwrap();
12655        for event in ["BEFORE INSERT", "BEFORE UPDATE"] {
12656            assert!(
12657                ddl.contains(&format!("{event} ON \"public\".\"Person\"")),
12658                "{event}:\n{ddl}"
12659            );
12660        }
12661        assert!(ddl.contains("NEW.\"slug\" := _pylon_rewrites.\"v0\";"), "{ddl}");
12662    }
12663
12664    #[test]
12665    fn a_rewrite_walking_a_link_starts_from_the_new_row() {
12666        // The row is not in its table yet when a `BEFORE INSERT` trigger runs.
12667        use crate::schema::RewriteEntry;
12668        let mut schema = make_schema_with_rewrite();
12669        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
12670        let slug = person.properties.iter_mut().find(|p| p.name == "slug").unwrap();
12671        slug.rewrites = vec![RewriteEntry {
12672            on: 1,
12673            handler: "<str>(.company is Company) ++ .company.name".into(),
12674        }];
12675        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
12676        let sql = &assignments[0].sql;
12677        assert!(!sql.contains("\"public\".\"Person\""), "{sql}");
12678        assert!(
12679            sql.contains("(SELECT (NEW).*)") && sql.contains("NEW.\"company_id\""),
12680            "{sql}"
12681        );
12682    }
12683
12684    #[test]
12685    fn test_unless_conflict_do_nothing() {
12686        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT");
12687        assert!(out.sql.contains("ON CONFLICT DO NOTHING"));
12688    }
12689
12690    #[test]
12691    fn test_unless_conflict_on_do_nothing() {
12692        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT ON .name");
12693        assert!(out.sql.contains("ON CONFLICT (\"name\") DO NOTHING"));
12694    }
12695
12696    #[test]
12697    fn test_unless_conflict_do_update() {
12698        let out = compile_and_emit(
12699            "INSERT Person { name := $name, age := $age } \
12700             UNLESS CONFLICT ON .name \
12701             ELSE (UPDATE Person SET { age := $age })",
12702        );
12703        assert!(out.sql.contains("ON CONFLICT (\"name\") DO UPDATE SET"));
12704        assert!(out.sql.contains("\"age\" = $2"));
12705        // The param $age is shared — same index as in the INSERT VALUES
12706        assert!(!out.sql.contains("DO NOTHING"));
12707    }
12708
12709    #[test]
12710    fn test_unless_conflict_else_update_self_reference_is_qualified() {
12711        // Regression: a self-referencing ELSE-update assignment (`age :=
12712        // .age + 1`) used to compile its RHS with an empty (unqualified)
12713        // alias, emitting a bare `"age"` — ambiguous in Postgres's `ON
12714        // CONFLICT DO UPDATE SET` context between the existing row and the
12715        // `excluded` pseudo-row (confirmed live: "column reference ... is
12716        // ambiguous"). It must be qualified with the target table's own
12717        // bare name instead.
12718        let out = compile_and_emit(
12719            "INSERT Person { name := $name, age := 0 } \
12720             UNLESS CONFLICT ON .name \
12721             ELSE (UPDATE Person SET { age := .age + 1 })",
12722        );
12723        assert!(
12724            out.sql.contains("\"Person\".\"age\""),
12725            "self-reference must be qualified with the table's own name, got:\n{}",
12726            out.sql
12727        );
12728        assert!(
12729            !out.sql.contains("SET \"age\" = (\"age\""),
12730            "must not emit an unqualified (ambiguous) self-reference, got:\n{}",
12731            out.sql
12732        );
12733    }
12734
12735    #[test]
12736    fn test_unless_conflict_do_update_no_on() {
12737        let out = compile_and_emit(
12738            "INSERT Person { name := $name } \
12739             UNLESS CONFLICT \
12740             ELSE (UPDATE Person SET { age := 0 })",
12741        );
12742        assert!(out.sql.contains("ON CONFLICT DO UPDATE SET"));
12743        assert!(out.sql.contains("\"age\" = 0"));
12744    }
12745
12746    #[test]
12747    fn test_select_over_select() {
12748        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name }");
12749        // Must use a CTE
12750        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12751        // CTE exposes raw columns via SELECT *
12752        assert!(out.sql.contains("SELECT *"));
12753        assert!(out.sql.contains("FROM \"public\".\"Person\""));
12754        // CTE carries the inner filter
12755        assert!(out.sql.contains("WHERE"));
12756        // Outer SELECT projects its own shape
12757        assert!(out.sql.contains("'default::Person'::text"));
12758        assert!(out.sql.contains("\"name\"::text"));
12759    }
12760
12761    #[test]
12762    fn test_select_over_select_with_outer_filter() {
12763        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name } FILTER .name = $name");
12764        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12765        assert!(out.sql.contains("SELECT *"));
12766        // Both filters present: one inside CTE, one in outer SELECT
12767        assert_eq!(out.sql.matches("WHERE").count(), 2);
12768        assert!(out.sql.contains("$1"));
12769    }
12770
12771    #[test]
12772    fn test_insert_link_subquery() {
12773        let out = compile_and_emit("INSERT Person { name := $name, company := (SELECT Company FILTER .name = $co) }");
12774        // The company FK column should be assigned via a scalar subquery
12775        assert!(out.sql.contains("\"company_id\""));
12776        assert!(out.sql.contains("SELECT"));
12777        // The subquery must select the pk (id) of Company
12778        assert!(out.sql.contains("\"id\""));
12779        assert!(out.sql.contains("FROM \"public\".\"Company\""));
12780        // Filter param must appear
12781        assert!(out.sql.contains("$2")); // $1 = name, $2 = co
12782    }
12783
12784    #[test]
12785    fn test_update_link_subquery() {
12786        let out =
12787            compile_and_emit("UPDATE Person FILTER .id = $id SET { company := (SELECT Company FILTER .name = $co) }");
12788        assert!(out.sql.contains("\"company_id\""));
12789        assert!(out.sql.contains("SELECT"));
12790        assert!(out.sql.contains("FROM \"public\".\"Company\""));
12791    }
12792
12793    #[test]
12794    fn test_insert_link_value_from_nested_insert_hoists_a_with_cte() {
12795        // `company := (select (insert Company {...}) { id })` — Postgres has
12796        // no way to run a nested INSERT inside another statement's VALUES
12797        // list without hoisting it into its own WITH CTE first (confirmed
12798        // live against real Postgres — see live_execution_insert_nested.rs).
12799        // Regression: an earlier attempt at this compiled cleanly but
12800        // silently emitted a subquery that dropped the nested INSERT
12801        // entirely and selected an arbitrary unrelated row instead.
12802        let out = compile_and_emit(
12803            "INSERT Person { name := 'Alice', company := (select (insert Company { name := 'Acme' }) { id }) }",
12804        );
12805        assert!(
12806            out.sql.starts_with("WITH"),
12807            "expected a WITH-hoisted CTE, got:\n{}",
12808            out.sql
12809        );
12810        assert!(
12811            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12812            "expected the nested insert to be its own CTE, got:\n{}",
12813            out.sql,
12814        );
12815        // The outer insert must reference the nested CTE's own id, not a
12816        // freestanding subquery against the Company table.
12817        assert!(
12818            out.sql.contains("\"company_id\") SELECT") && out.sql.contains(".\"id\" FROM"),
12819            "expected the outer insert to switch from VALUES to SELECT ... FROM <cte>, got:\n{}",
12820            out.sql,
12821        );
12822        assert!(
12823            !out.sql.contains("FROM \"public\".\"Company\" AS"),
12824            "must not select from the real Company table, got:\n{}",
12825            out.sql
12826        );
12827    }
12828
12829    #[test]
12830    fn test_update_link_value_from_nested_insert_hoists_a_with_cte_and_adds_from_clause() {
12831        let out = compile_and_emit(
12832            "UPDATE Person FILTER .id = $id SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
12833        );
12834        assert!(
12835            out.sql.starts_with("WITH"),
12836            "expected a WITH-hoisted CTE, got:\n{}",
12837            out.sql
12838        );
12839        assert!(
12840            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12841            "expected the nested insert to be its own CTE, got:\n{}",
12842            out.sql,
12843        );
12844        // Plain `UPDATE ... SET ...` has no FROM clause of its own — one
12845        // must be added so the SET clause can reference the CTE's column.
12846        assert!(
12847            out.sql.contains("\nFROM \""),
12848            "expected a FROM clause referencing the nested CTE, got:\n{}",
12849            out.sql
12850        );
12851        assert!(out.sql.contains("SET \"company_id\" = "), "got:\n{}", out.sql);
12852    }
12853
12854    #[test]
12855    fn test_update_link_value_from_nested_insert_combines_with_multilink_mutation() {
12856        // A nested-insert link value and a `+=` multi-link mutation in the
12857        // same UPDATE route through a completely different emitter branch
12858        // (the junction-CTE "_ids" path, not the plain-UPDATE path the
12859        // previous test covers) — both must still see the hoisted CTE.
12860        let out = compile_and_emit(
12861            "UPDATE Person FILTER .id = $id SET { \
12862                 company := (select (insert Company { name := 'Acme' }) { id }), \
12863                 posts += (SELECT Post FILTER .title = $t) \
12864             }",
12865        );
12866        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
12867        assert!(
12868            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12869            "expected the nested insert to be its own CTE, got:\n{}",
12870            out.sql,
12871        );
12872        assert!(
12873            out.sql.contains("\"_ids\" AS (\nUPDATE") && out.sql.contains("\nFROM \""),
12874            "expected the _ids UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
12875            out.sql,
12876        );
12877        assert!(
12878            out.sql.contains("\"_ml_add_0\""),
12879            "expected the junction-append CTE to still be present, got:\n{}",
12880            out.sql
12881        );
12882    }
12883
12884    #[test]
12885    fn test_update_link_value_from_nested_insert_combines_with_interface_target() {
12886        // A nested-insert link value on an UPDATE targeting an interface
12887        // (abstract + materialized) type routes through the poly fan-out
12888        // branch (emit_poly_update_stmt) — a third, independent emitter path
12889        // from the previous two tests. The hoisted CTE must be materialized
12890        // once and referenced from every per-implementor UPDATE branch.
12891        fn id_prop() -> PropertyDescriptor {
12892            PropertyDescriptor {
12893                name: "id".into(),
12894                pg_type: "uuid".into(),
12895                nullable: false,
12896                default_sql: Some("uuidv7()".into()),
12897                default_pyql: None,
12898                description: None,
12899                check_constraints: vec![],
12900                is_exclusive: true,
12901                is_pk: true,
12902                is_readonly: true,
12903                rewrites: vec![],
12904                tuple_members: None,
12905                column_type: None,
12906            }
12907        }
12908        fn text_prop(name: &str) -> PropertyDescriptor {
12909            PropertyDescriptor {
12910                name: name.into(),
12911                pg_type: "text".into(),
12912                nullable: false,
12913                default_sql: None,
12914                default_pyql: None,
12915                description: None,
12916                check_constraints: vec![],
12917                is_exclusive: false,
12918                is_pk: false,
12919                is_readonly: false,
12920                rewrites: vec![],
12921                tuple_members: None,
12922                column_type: None,
12923            }
12924        }
12925        fn company_link() -> LinkDescriptor {
12926            LinkDescriptor {
12927                name: "company".into(),
12928                target: "default::Company".into(),
12929                nullable: true,
12930                through: None,
12931                description: None,
12932                default_pyql: None,
12933                is_exclusive: false,
12934                is_readonly: false,
12935                rewrites: vec![],
12936                on_delete: vec![],
12937            }
12938        }
12939        let schema = SchemaDescriptor {
12940            types: vec![
12941                TypeDescriptor {
12942                    name: "Company".into(),
12943                    module: "default".into(),
12944                    table: "Company".into(),
12945                    abstract_: false,
12946                    materialized: true,
12947                    description: None,
12948                    parents: vec![],
12949                    interfaces: vec![],
12950                    bases: vec![],
12951                    properties: vec![id_prop(), text_prop("name")],
12952                    links: vec![],
12953                    multilinks: vec![],
12954                    computed: vec![],
12955                    constraints: vec![],
12956                    indexes: vec![],
12957                    partition: None,
12958                    vector_indexes: vec![],
12959                    search_indexes: vec![],
12960                    triggers: vec![],
12961                    junction: false,
12962                    signals: vec![],
12963                },
12964                TypeDescriptor {
12965                    name: "Account".into(),
12966                    module: "default".into(),
12967                    table: "Account".into(),
12968                    abstract_: true,
12969                    materialized: true,
12970                    description: None,
12971                    parents: vec![],
12972                    interfaces: vec![],
12973                    bases: vec![],
12974                    properties: vec![id_prop(), text_prop("email")],
12975                    links: vec![company_link()],
12976                    multilinks: vec![],
12977                    computed: vec![],
12978                    constraints: vec![],
12979                    indexes: vec![],
12980                    partition: None,
12981                    vector_indexes: vec![],
12982                    search_indexes: vec![],
12983                    triggers: vec![],
12984                    junction: false,
12985                    signals: vec![],
12986                },
12987                TypeDescriptor {
12988                    name: "Individual".into(),
12989                    module: "default".into(),
12990                    table: "Individual".into(),
12991                    abstract_: false,
12992                    materialized: true,
12993                    description: None,
12994                    parents: vec![],
12995                    interfaces: vec!["default::Account".into()],
12996                    bases: vec![],
12997                    properties: vec![id_prop(), text_prop("email"), text_prop("first_name")],
12998                    links: vec![company_link()],
12999                    multilinks: vec![],
13000                    computed: vec![],
13001                    constraints: vec![],
13002                    indexes: vec![],
13003                    partition: None,
13004                    vector_indexes: vec![],
13005                    search_indexes: vec![],
13006                    triggers: vec![],
13007                    junction: false,
13008                    signals: vec![],
13009                },
13010            ],
13011            scalars: vec![],
13012            enums: vec![],
13013            named_tuples: vec![],
13014            globals: vec![],
13015            functions: vec![],
13016            aliases: vec![],
13017            channels: vec![],
13018            ..Default::default()
13019        };
13020        let out = compile_and_emit_with(
13021            "UPDATE Account FILTER .email = $email \
13022             SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
13023            &schema,
13024        );
13025        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
13026        assert!(
13027            out.sql.contains("INSERT INTO \"public\".\"Company\""),
13028            "expected the nested insert to be its own CTE, got:\n{}",
13029            out.sql,
13030        );
13031        assert!(
13032            out.sql.contains("UPDATE \"public\".\"Individual\"") && out.sql.contains("\nFROM \"_nested_dml_0\""),
13033            "expected the per-implementor UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
13034            out.sql,
13035        );
13036    }
13037
13038    #[test]
13039    fn test_a_backlink_narrowed_to_a_supertype() {
13040        // `.<company[is Account]` — the intersection narrows what comes back,
13041        // while the link itself is declared further down. Pylon looked for it
13042        // on the narrowed type alone and reported it had no such link.
13043        let mut schema = interface_link_schema();
13044        // Leave `company` declared only on the implementor, so the narrowed
13045        // type genuinely does not have it.
13046        schema
13047            .types
13048            .iter_mut()
13049            .find(|t| t.name == "Account")
13050            .expect("the helper declares Account")
13051            .links
13052            .clear();
13053        let out = compile_and_emit_with("SELECT Company { owners := .<company[is Account] { email } }", &schema);
13054        assert!(
13055            out.sql.contains("\"public\".\"Individual\""),
13056            "the backlink must read the type that declares it:\n{}",
13057            out.sql
13058        );
13059    }
13060
13061    #[test]
13062    fn a_path_through_a_backlink_narrowed_to_a_supertype() {
13063        let mut schema = interface_link_schema();
13064        schema
13065            .types
13066            .iter_mut()
13067            .find(|t| t.name == "Account")
13068            .expect("the helper declares Account")
13069            .links
13070            .clear();
13071        let out = compile_and_emit_with(
13072            "SELECT Company { name } FILTER EXISTS (SELECT .<company[is Account])",
13073            &schema,
13074        );
13075        assert!(out.sql.contains("\"public\".\"Individual\""), "{}", out.sql);
13076    }
13077
13078    /// Company, the Account interface, its Individual implementor and a
13079    /// Token linking to the interface -- enough to exercise reading and
13080    /// backlinking across one.
13081    fn interface_link_schema() -> SchemaDescriptor {
13082        // A link whose target is an interface is read by fanning the
13083        // implementors out inline. That union used to project only the
13084        // interface's *properties*, so a shape that then followed one of the
13085        // interface's own links failed with "column t1.company_id does not
13086        // exist".
13087        fn id_prop() -> PropertyDescriptor {
13088            PropertyDescriptor {
13089                name: "id".into(),
13090                pg_type: "uuid".into(),
13091                nullable: false,
13092                default_sql: None,
13093                default_pyql: None,
13094                description: None,
13095                check_constraints: vec![],
13096                is_exclusive: true,
13097                is_pk: true,
13098                is_readonly: true,
13099                rewrites: vec![],
13100                tuple_members: None,
13101                column_type: None,
13102            }
13103        }
13104        fn text_prop(name: &str) -> PropertyDescriptor {
13105            PropertyDescriptor {
13106                name: name.into(),
13107                pg_type: "text".into(),
13108                nullable: false,
13109                default_sql: None,
13110                default_pyql: None,
13111                description: None,
13112                check_constraints: vec![],
13113                is_exclusive: false,
13114                is_pk: false,
13115                is_readonly: false,
13116                rewrites: vec![],
13117                tuple_members: None,
13118                column_type: None,
13119            }
13120        }
13121        fn link(name: &str, target: &str) -> LinkDescriptor {
13122            LinkDescriptor {
13123                name: name.into(),
13124                target: target.into(),
13125                nullable: true,
13126                through: None,
13127                description: None,
13128                default_pyql: None,
13129                is_exclusive: false,
13130                is_readonly: false,
13131                rewrites: vec![],
13132                on_delete: vec![],
13133            }
13134        }
13135        fn ty(
13136            name: &str,
13137            abstract_: bool,
13138            interfaces: Vec<String>,
13139            properties: Vec<PropertyDescriptor>,
13140            links: Vec<LinkDescriptor>,
13141        ) -> TypeDescriptor {
13142            TypeDescriptor {
13143                name: name.into(),
13144                module: "default".into(),
13145                table: name.into(),
13146                abstract_,
13147                materialized: true,
13148                description: None,
13149                parents: vec![],
13150                interfaces,
13151                bases: vec![],
13152                properties,
13153                links,
13154                multilinks: vec![],
13155                computed: vec![],
13156                constraints: vec![],
13157                indexes: vec![],
13158                partition: None,
13159                vector_indexes: vec![],
13160                search_indexes: vec![],
13161                triggers: vec![],
13162                junction: false,
13163                signals: vec![],
13164            }
13165        }
13166        SchemaDescriptor {
13167            types: vec![
13168                ty("Company", false, vec![], vec![id_prop(), text_prop("name")], vec![]),
13169                ty(
13170                    "Account",
13171                    true,
13172                    vec![],
13173                    vec![id_prop(), text_prop("email")],
13174                    vec![link("company", "default::Company")],
13175                ),
13176                ty(
13177                    "Individual",
13178                    false,
13179                    vec!["default::Account".into()],
13180                    vec![id_prop(), text_prop("email")],
13181                    vec![link("company", "default::Company")],
13182                ),
13183                ty(
13184                    "Token",
13185                    false,
13186                    vec![],
13187                    vec![id_prop(), text_prop("value")],
13188                    vec![link("account", "default::Account")],
13189                ),
13190            ],
13191            scalars: vec![],
13192            enums: vec![],
13193            named_tuples: vec![],
13194            globals: vec![],
13195            functions: vec![],
13196            aliases: vec![],
13197            channels: vec![],
13198            ..Default::default()
13199        }
13200    }
13201
13202    /// `Token`, but reached through a second single link and narrowed to the
13203    /// interface's implementor -- conduit's trust-tier walk,
13204    /// `installation.connector.provider[is Individual].staff`.
13205    fn two_hop_interface_schema() -> SchemaDescriptor {
13206        let mut schema = interface_link_schema();
13207        let token = schema
13208            .types
13209            .iter()
13210            .find(|t| t.name == "Token")
13211            .expect("the helper declares Token")
13212            .clone();
13213        let mut connector = token.clone();
13214        connector.name = "Connector".into();
13215        connector.table = "Connector".into();
13216        connector.links[0].name = "provider".into();
13217        let mut installation = token;
13218        installation.name = "Installation".into();
13219        installation.table = "Installation".into();
13220        installation.links[0].name = "connector".into();
13221        installation.links[0].target = "default::Connector".into();
13222        schema.types.push(connector);
13223        schema.types.push(installation);
13224        schema
13225    }
13226
13227    #[test]
13228    fn a_narrowed_two_hop_walk_off_a_pinned_row_is_one_value() {
13229        // Every step is a forward single link and the filter pins the id, so
13230        // the `[is Individual]` narrowing does not make the walk set-valued.
13231        // Gathered as an array the `??` default became `array_remove(ARRAY[…])`
13232        // and the result decoded as a one-element set rather than as the value
13233        // itself.
13234        let out = compile_and_emit_with(
13235            "SELECT { s := ((SELECT Installation FILTER .id = <uuid>$0).connector.provider[is Individual].email) ?? 'y' }",
13236            &two_hop_interface_schema(),
13237        );
13238        assert!(
13239            !out.sql.contains("ARRAY(SELECT"),
13240            "expected a value, not a set:\n{}",
13241            out.sql
13242        );
13243        assert!(
13244            out.sql.contains("COALESCE("),
13245            "the default must stay a scalar one:\n{}",
13246            out.sql
13247        );
13248    }
13249
13250    #[test]
13251    fn a_narrowed_two_hop_walk_off_an_unpinned_row_stays_a_set() {
13252        let out = compile_and_emit_with(
13253            "SELECT { s := (SELECT Installation FILTER .value = 'x').connector.provider[is Individual].email }",
13254            &two_hop_interface_schema(),
13255        );
13256        assert!(
13257            out.sql.contains("ARRAY(SELECT"),
13258            "a walk off many rows is a set:\n{}",
13259            out.sql
13260        );
13261    }
13262
13263    /// `exists .account[is Individual]` — a walk narrowed to an implementor
13264    /// with no pointer read after it. The two-step form needs a trailing
13265    /// column to read, so it rejected the intersection outright ("type
13266    /// intersections are not valid in expression context") even though the
13267    /// same walk with a pointer on the end (`[is Individual].email`) has
13268    /// always compiled.
13269    #[test]
13270    fn a_walk_ending_in_a_type_intersection_is_valid_in_a_filter() {
13271        let out = compile_and_emit_with(
13272            "SELECT Token { id } FILTER EXISTS .account[is Individual]",
13273            &interface_link_schema(),
13274        );
13275        assert!(
13276            out.sql.contains("\"public\".\"Individual\""),
13277            "the narrowing must reach the implementor's table:\n{}",
13278            out.sql
13279        );
13280        assert!(
13281            out.sql.contains("IS NOT NULL"),
13282            "exists over the narrowed walk is a nullness test:\n{}",
13283            out.sql
13284        );
13285    }
13286
13287    #[test]
13288    fn a_walk_ending_in_a_type_intersection_is_valid_off_a_binding() {
13289        let out = compile_and_emit_with(
13290            "WITH t := (SELECT Token LIMIT 1) SELECT { e := EXISTS t.account[is Individual] }",
13291            &interface_link_schema(),
13292        );
13293        assert!(
13294            out.sql.contains("\"public\".\"Individual\""),
13295            "the narrowing must reach the implementor's table:\n{}",
13296            out.sql
13297        );
13298    }
13299
13300    #[test]
13301    fn test_reading_a_link_through_an_interface_target() {
13302        let schema = interface_link_schema();
13303        let out = compile_and_emit_with("SELECT Token { account: { email, company: { name } } }", &schema);
13304        assert!(
13305            out.sql
13306                .contains("\"email\", \"company_id\" FROM \"public\".\"Individual\""),
13307            "the fanned-out interface must carry its own link columns:\n{}",
13308            out.sql
13309        );
13310    }
13311
13312    #[test]
13313    fn test_computed_pointer_in_shape_emits_expression() {
13314        let mut schema = make_schema();
13315        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13316            name: "upper_name".into(),
13317            expression: "str_upper(.name)".into(),
13318            return_type: Some("text".into()),
13319            link_target: None,
13320            link_multi: false,
13321        });
13322        let out = compile_and_emit_with("SELECT Person { upper_name }", &schema);
13323        assert!(
13324            out.sql.to_lowercase().contains("upper"),
13325            "expected upper() in SQL, got:\n{}",
13326            out.sql
13327        );
13328    }
13329
13330    /// Interface `Account` (non-materialized) with one implementor,
13331    /// `Individual`, carrying a stored property and a computed pointer.
13332    fn make_interface_schema() -> SchemaDescriptor {
13333        fn id_prop() -> PropertyDescriptor {
13334            PropertyDescriptor {
13335                name: "id".into(),
13336                pg_type: "uuid".into(),
13337                nullable: false,
13338                default_sql: Some("uuidv7()".into()),
13339                default_pyql: None,
13340                description: None,
13341                check_constraints: vec![],
13342                is_exclusive: true,
13343                is_pk: true,
13344                is_readonly: true,
13345                rewrites: vec![],
13346                tuple_members: None,
13347                column_type: None,
13348            }
13349        }
13350        SchemaDescriptor {
13351            types: vec![
13352                TypeDescriptor {
13353                    name: "Account".into(),
13354                    module: "default".into(),
13355                    table: "Account".into(),
13356                    abstract_: true,
13357                    materialized: false,
13358                    description: None,
13359                    parents: vec![],
13360                    interfaces: vec![],
13361                    bases: vec![],
13362                    properties: vec![
13363                        id_prop(),
13364                        PropertyDescriptor {
13365                            name: "email".into(),
13366                            pg_type: "text".into(),
13367                            nullable: false,
13368                            default_sql: None,
13369                            default_pyql: None,
13370                            description: None,
13371                            check_constraints: vec![],
13372                            is_exclusive: false,
13373                            is_pk: false,
13374                            is_readonly: false,
13375                            rewrites: vec![],
13376                            tuple_members: None,
13377                            column_type: None,
13378                        },
13379                    ],
13380                    links: vec![],
13381                    multilinks: vec![],
13382                    computed: vec![],
13383                    constraints: vec![],
13384                    indexes: vec![],
13385                    partition: None,
13386                    vector_indexes: vec![],
13387                    search_indexes: vec![],
13388                    triggers: vec![],
13389                    junction: false,
13390                    signals: vec![],
13391                },
13392                TypeDescriptor {
13393                    name: "Individual".into(),
13394                    module: "default".into(),
13395                    table: "Individual".into(),
13396                    abstract_: false,
13397                    materialized: true,
13398                    description: None,
13399                    parents: vec![],
13400                    interfaces: vec!["default::Account".into()],
13401                    bases: vec![],
13402                    properties: vec![
13403                        id_prop(),
13404                        PropertyDescriptor {
13405                            name: "first_name".into(),
13406                            pg_type: "text".into(),
13407                            nullable: false,
13408                            default_sql: None,
13409                            default_pyql: None,
13410                            description: None,
13411                            check_constraints: vec![],
13412                            is_exclusive: false,
13413                            is_pk: false,
13414                            is_readonly: false,
13415                            rewrites: vec![],
13416                            tuple_members: None,
13417                            column_type: None,
13418                        },
13419                    ],
13420                    links: vec![],
13421                    multilinks: vec![],
13422                    computed: vec![crate::schema::ComputedDescriptor {
13423                        name: "full_name".into(),
13424                        expression: "str_upper(.first_name)".into(),
13425                        return_type: Some("text".into()),
13426                        link_target: None,
13427                        link_multi: false,
13428                    }],
13429                    constraints: vec![],
13430                    indexes: vec![],
13431                    partition: None,
13432                    vector_indexes: vec![],
13433                    search_indexes: vec![],
13434                    triggers: vec![],
13435                    junction: false,
13436                    signals: vec![],
13437                },
13438            ],
13439            scalars: vec![],
13440            enums: vec![],
13441            named_tuples: vec![],
13442            globals: vec![],
13443            functions: vec![],
13444            aliases: vec![],
13445            channels: vec![],
13446            ..Default::default()
13447        }
13448    }
13449
13450    #[test]
13451    fn test_type_intersection_splat_includes_concrete_computed_pointers() {
13452        // `[is Concrete].*` must expand to the concrete type's own computed
13453        // pointers too (e.g. `full_name`), not just its stored properties —
13454        // querying `Individual` directly with `*` already included them, but
13455        // the interface-splat path never even looked at `td.computed`.
13456        let schema = make_interface_schema();
13457        let out = compile_and_emit_with("SELECT Account { *, [is Individual].* }", &schema);
13458        assert!(
13459            out.sql.to_lowercase().contains("upper"),
13460            "expected the concrete type's computed pointer (str_upper(...)) in the shape, got:\n{}",
13461            out.sql
13462        );
13463        assert!(
13464            out.sql.contains("\"first_name\""),
13465            "expected the concrete type's stored property too, got:\n{}",
13466            out.sql
13467        );
13468    }
13469
13470    #[test]
13471    fn test_enum_array_property_casts_to_text_array_and_shapes_as_a_list() {
13472        // Regression: an `array<enum>` property's pg_type is
13473        // `"module"."Type"[]`, which starts with a quote exactly as a scalar
13474        // enum's does. Read as a scalar it was cast to a single `::text`,
13475        // so `{MagicLink}` -- Postgres's text rendering of the whole array --
13476        // came back as one enum value, and the polymorphic path, which casts
13477        // nothing, failed outright with "no decoder for PostgreSQL type OID".
13478        let mut schema = make_schema();
13479        schema.enums.push(crate::schema::EnumDescriptor {
13480            name: "Gender".into(),
13481            module: "default".into(),
13482            members: vec!["Male".into(), "Female".into()],
13483        });
13484        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
13485            name: "genders".into(),
13486            pg_type: r#""default"."Gender"[]"#.into(),
13487            nullable: true,
13488            default_sql: None,
13489            default_pyql: None,
13490            description: None,
13491            check_constraints: vec![],
13492            is_exclusive: false,
13493            is_pk: false,
13494            is_readonly: false,
13495            rewrites: vec![],
13496            tuple_members: None,
13497            column_type: None,
13498        });
13499
13500        let out = compile_and_emit_with("SELECT Person { genders }", &schema);
13501        assert!(
13502            out.sql.contains(r#""genders"::text[]"#),
13503            "expected a text[] cast, got:\n{}",
13504            out.sql
13505        );
13506
13507        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13508            panic!("expected Object shape, got {:?}", out.shape.root)
13509        };
13510        let genders = pointers
13511            .iter()
13512            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "genders"))
13513            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
13514        let crate::query::ShapeNode::Array { element, .. } = genders else {
13515            unreachable!()
13516        };
13517        assert!(
13518            matches!(
13519                element.as_ref(),
13520                crate::query::ShapeNode::Enum { enum_type, position: 0, .. }
13521                    if enum_type == "default::Gender"
13522            ),
13523            "expected the elements to be enum-shaped, got {element:?}",
13524        );
13525    }
13526
13527    #[test]
13528    fn test_scalar_enum_property_still_casts_to_a_single_text() {
13529        let mut schema = make_schema();
13530        schema.enums.push(crate::schema::EnumDescriptor {
13531            name: "Gender".into(),
13532            module: "default".into(),
13533            members: vec!["Male".into(), "Female".into()],
13534        });
13535        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
13536            name: "gender".into(),
13537            pg_type: r#""default"."Gender""#.into(),
13538            nullable: true,
13539            default_sql: None,
13540            default_pyql: None,
13541            description: None,
13542            check_constraints: vec![],
13543            is_exclusive: false,
13544            is_pk: false,
13545            is_readonly: false,
13546            rewrites: vec![],
13547            tuple_members: None,
13548            column_type: None,
13549        });
13550
13551        let out = compile_and_emit_with("SELECT Person { gender }", &schema);
13552        assert!(
13553            out.sql.contains(r#""gender"::text"#) && !out.sql.contains(r#""gender"::text[]"#),
13554            "expected a plain text cast, got:\n{}",
13555            out.sql
13556        );
13557        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13558            panic!("expected Object shape")
13559        };
13560        assert!(
13561            pointers.iter().any(|node| matches!(
13562                node,
13563                crate::query::ShapeNode::Enum { name, enum_type, .. }
13564                    if name == "gender" && enum_type == "default::Gender"
13565            )),
13566            "expected an Enum-shaped pointer, got {pointers:?}",
13567        );
13568    }
13569
13570    #[test]
13571    fn test_type_intersection_splat_keeps_enum_pointers_enum_shaped() {
13572        // Regression: `[is Concrete].*` wraps each property in a correlated
13573        // subquery, so the outer shape saw an `IrExpr::Subquery` rather than a
13574        // column and fell back to a plain Scalar — handing back `'System'`
13575        // where a direct select gave `InterfaceAppearance.System`.
13576        let mut schema = make_interface_schema();
13577        schema.enums.push(crate::schema::EnumDescriptor {
13578            name: "Gender".into(),
13579            module: "default".into(),
13580            members: vec!["Male".into(), "Female".into()],
13581        });
13582        // An enum that lives only on the concrete type, so it can only be
13583        // reached through the intersection.
13584        let individual = schema
13585            .types
13586            .iter_mut()
13587            .find(|t| t.name == "Individual")
13588            .expect("the interface schema has an Individual type");
13589        individual.properties.push(crate::schema::PropertyDescriptor {
13590            name: "gender".into(),
13591            pg_type: r#""default"."Gender""#.into(),
13592            nullable: true,
13593            default_sql: None,
13594            default_pyql: None,
13595            description: None,
13596            check_constraints: vec![],
13597            is_exclusive: false,
13598            is_pk: false,
13599            is_readonly: false,
13600            rewrites: vec![],
13601            tuple_members: None,
13602            column_type: None,
13603        });
13604
13605        let out = compile_and_emit_with("SELECT Account { [is Individual].* }", &schema);
13606        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13607            panic!("expected Object shape, got {:?}", out.shape.root)
13608        };
13609        assert!(
13610            pointers.iter().any(|node| matches!(
13611                node,
13612                crate::query::ShapeNode::Enum { name, enum_type, .. }
13613                    if name == "gender" && enum_type == "default::Gender"
13614            )),
13615            "expected the enum property to stay enum-shaped through the intersection, got {pointers:?}",
13616        );
13617    }
13618
13619    #[test]
13620    fn test_shallow_splat_leaves_object_valued_computeds_to_the_deep_form() {
13621        // Regression: `*` expanded every computed, link-valued ones included.
13622        // `*` is properties only; `**` adds links.
13623        // On jaldis's schema that pulled a computed multi-link
13624        // (`members := .memberships.member`) into every splat query.
13625        let mut schema = make_schema();
13626        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13627            name: "authors".into(),
13628            expression: ".posts".into(),
13629            return_type: None,
13630            link_target: None,
13631            link_multi: false,
13632        });
13633        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13634            name: "age_next".into(),
13635            expression: ".age + 1".into(),
13636            return_type: Some("int8".into()),
13637            link_target: None,
13638            link_multi: false,
13639        });
13640
13641        let shallow = compile_and_emit_with("SELECT Person { * }", &schema);
13642        let crate::query::ShapeNode::Object { pointers, .. } = &shallow.shape.root else {
13643            panic!("expected Object shape")
13644        };
13645        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
13646        assert!(
13647            names.contains(&"age_next"),
13648            "a computed property belongs in `*`: {names:?}"
13649        );
13650        assert!(!names.contains(&"authors"), "a computed link does not: {names:?}");
13651
13652        let deep = compile_and_emit_with("SELECT Person { ** }", &schema);
13653        let crate::query::ShapeNode::Object { pointers, .. } = &deep.shape.root else {
13654            panic!("expected Object shape")
13655        };
13656        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
13657        assert!(
13658            names.contains(&"authors"),
13659            "`**` includes links, computed ones included: {names:?}"
13660        );
13661    }
13662
13663    fn shape_pointer_name(node: &crate::query::ShapeNode) -> &str {
13664        use crate::query::ShapeNode;
13665        match node {
13666            ShapeNode::Scalar { name, .. }
13667            | ShapeNode::Enum { name, .. }
13668            | ShapeNode::NamedTuple { name, .. }
13669            | ShapeNode::Object { name, .. }
13670            | ShapeNode::Array { name, .. } => name,
13671            _ => "",
13672        }
13673    }
13674
13675    #[test]
13676    fn test_any_over_a_multilink_comparison_does_not_warn() {
13677        // Regression: the warning fired whether or not the comparison was
13678        // wrapped, so `any()` -- the very thing it asks for -- did not silence
13679        // it. The argument is compiled before the call is resolved, so the
13680        // suppression has to be in place while the argument is compiled.
13681        let schema = make_schema();
13682        let ast = parse::parse("SELECT Person FILTER any(.posts.title = 'x')").unwrap();
13683        let ir = ir::compile(&ast, &schema).unwrap();
13684        assert!(
13685            !ir.warnings.iter().any(|w| w.contains("FILTER clause")),
13686            "any() states the intent, so nothing should warn: {:?}",
13687            ir.warnings
13688        );
13689    }
13690
13691    #[test]
13692    fn test_bare_multilink_comparison_still_warns() {
13693        let schema = make_schema();
13694        let ast = parse::parse("SELECT Person FILTER .posts.title = 'x'").unwrap();
13695        let ir = ir::compile(&ast, &schema).unwrap();
13696        assert!(
13697            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
13698            "an unwrapped set-valued comparison still warns: {:?}",
13699            ir.warnings
13700        );
13701    }
13702
13703    #[test]
13704    fn test_link_to_an_interface_expands_over_its_implementors() {
13705        // Regression: an interface's view carries only the interface's own
13706        // columns, so reading a link through it tagged every row as the
13707        // interface and hydrated `Profile` where the row was an
13708        // `IndividualProfile`. The root of a query already expands inline;
13709        // a nested link did not.
13710        let mut schema = make_interface_schema();
13711        let account = schema
13712            .types
13713            .iter_mut()
13714            .find(|t| t.name == "Account")
13715            .expect("the interface schema has an Account type");
13716        account.materialized = true;
13717        // A second implementor, so the fan-out is a real union.
13718        let mut organization = schema
13719            .types
13720            .iter()
13721            .find(|t| t.name == "Individual")
13722            .expect("the interface schema has an Individual type")
13723            .clone();
13724        organization.name = "Organization".into();
13725        organization.table = "Organization".into();
13726        organization.computed.clear();
13727        schema.types.push(organization);
13728
13729        let owner = TypeDescriptor {
13730            name: "Note".into(),
13731            module: "default".into(),
13732            table: "Note".into(),
13733            abstract_: false,
13734            materialized: false,
13735            description: None,
13736            parents: vec![],
13737            interfaces: vec![],
13738            bases: vec![],
13739            properties: vec![],
13740            links: vec![LinkDescriptor {
13741                name: "owner".into(),
13742                target: "default::Account".into(),
13743                nullable: true,
13744                description: None,
13745                default_pyql: None,
13746                is_exclusive: false,
13747                is_readonly: false,
13748                rewrites: vec![],
13749                on_delete: vec![],
13750                through: None,
13751            }],
13752            multilinks: vec![],
13753            computed: vec![],
13754            constraints: vec![],
13755            indexes: vec![],
13756            partition: None,
13757            vector_indexes: vec![],
13758            search_indexes: vec![],
13759            triggers: vec![],
13760            junction: false,
13761            signals: vec![],
13762        };
13763        schema.types.push(owner);
13764
13765        let out = compile_and_emit_with("SELECT Note { owner: { id } }", &schema);
13766        assert!(
13767            out.sql.contains("UNION ALL"),
13768            "the link's target should expand over its implementors, got:\n{}",
13769            out.sql
13770        );
13771        assert!(
13772            out.sql.contains("'default::Individual'::text AS \"__type__\""),
13773            "each branch should carry its own discriminator, got:\n{}",
13774            out.sql
13775        );
13776        assert!(
13777            !out.sql.contains("FROM \"default\".\"Account\" AS"),
13778            "the interface's own view should no longer be read directly, got:\n{}",
13779            out.sql
13780        );
13781    }
13782
13783    /// `make_interface_schema` with a second implementor of `Account`, so a
13784    /// read of it is a real union of two concrete types.
13785    fn make_two_implementor_schema() -> SchemaDescriptor {
13786        let mut schema = make_interface_schema();
13787        let mut organization = schema
13788            .types
13789            .iter()
13790            .find(|t| t.name == "Individual")
13791            .expect("the interface schema has an Individual type")
13792            .clone();
13793        organization.name = "Organization".into();
13794        organization.table = "Organization".into();
13795        organization.computed.clear();
13796        schema.types.push(organization);
13797        schema
13798    }
13799
13800    /// Every row read of an interface names its own concrete type, so none may
13801    /// be labelled with the interface's.
13802    fn assert_rows_carry_their_concrete_type(query: &str) {
13803        let out = compile_and_emit_with(query, &make_two_implementor_schema());
13804        assert!(
13805            !out.sql.contains("'default::Account'::text"),
13806            "rows are labelled with the interface instead of their own type:\n{}",
13807            out.sql
13808        );
13809        assert!(
13810            out.sql.contains("'default::Individual'::text AS \"__type__\""),
13811            "the rows should come from the implementors, each tagging its own:\n{}",
13812            out.sql
13813        );
13814    }
13815
13816    #[test]
13817    fn an_aggregated_select_of_an_interface_carries_the_concrete_type() {
13818        assert_rows_carry_their_concrete_type("SELECT (xs := array_agg((SELECT Account { id })))");
13819    }
13820
13821    #[test]
13822    fn an_aggregated_binding_of_an_interface_carries_the_concrete_type() {
13823        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (xs := array_agg((SELECT a { id })))");
13824    }
13825
13826    #[test]
13827    fn a_single_object_read_off_a_binding_carries_the_concrete_type() {
13828        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (x := (SELECT a { id } LIMIT 1))");
13829    }
13830
13831    #[test]
13832    fn grouped_elements_of_an_interface_carry_the_concrete_type() {
13833        assert_rows_carry_their_concrete_type("group Account { id } by .email");
13834    }
13835
13836    /// `make_two_implementor_schema` with a `Note` that links to an `Account`,
13837    /// and an `Account` that links back to a `Note`.
13838    fn make_note_schema() -> SchemaDescriptor {
13839        fn link_to(name: &str, target: &str) -> LinkDescriptor {
13840            LinkDescriptor {
13841                name: name.into(),
13842                target: target.into(),
13843                nullable: true,
13844                description: None,
13845                default_pyql: None,
13846                is_exclusive: false,
13847                is_readonly: false,
13848                rewrites: vec![],
13849                on_delete: vec![],
13850                through: None,
13851            }
13852        }
13853        let mut schema = make_two_implementor_schema();
13854        for account in schema
13855            .types
13856            .iter_mut()
13857            .filter(|t| ["Account", "Individual", "Organization"].contains(&t.name.as_str()))
13858        {
13859            account.links.push(link_to("pinned", "default::Note"));
13860        }
13861        schema.types.push(TypeDescriptor {
13862            name: "Note".into(),
13863            module: "default".into(),
13864            table: "Note".into(),
13865            abstract_: false,
13866            materialized: false,
13867            description: None,
13868            parents: vec![],
13869            interfaces: vec![],
13870            bases: vec![],
13871            properties: vec![],
13872            links: vec![link_to("owner", "default::Account")],
13873            multilinks: vec![],
13874            computed: vec![],
13875            constraints: vec![],
13876            indexes: vec![],
13877            partition: None,
13878            vector_indexes: vec![],
13879            search_indexes: vec![],
13880            triggers: vec![],
13881            junction: false,
13882            signals: vec![],
13883        });
13884        schema
13885    }
13886
13887    #[test]
13888    fn a_backlink_to_an_interface_carries_the_concrete_type() {
13889        let out = compile_and_emit_with(
13890            "SELECT Note { pinners := .<pinned[is Account] { id } }",
13891            &make_note_schema(),
13892        );
13893        assert!(
13894            !out.sql.contains("'default::Account'::text"),
13895            "the backlink's rows are labelled with the interface instead of their own type:\n{}",
13896            out.sql
13897        );
13898    }
13899
13900    #[test]
13901    fn a_link_reached_through_a_deep_splat_carries_the_concrete_type() {
13902        let out = compile_and_emit_with("SELECT Note { ** }", &make_note_schema());
13903        assert!(
13904            !out.sql.contains("'default::Account'::text"),
13905            "the splatted link's rows are labelled with the interface instead of their own type:\n{}",
13906            out.sql
13907        );
13908    }
13909
13910    #[test]
13911    fn test_path_traversal_onto_an_interface_carries_the_concrete_type() {
13912        // The hole the chained-pointer fix landed on: a *path join* onto an
13913        // interface still read the interface's view, so the rows a traversal
13914        // returned were tagged with the interface and hydrated its class. Links
13915        // fan out; this is the traversal route they share with computed chains.
13916        let mut schema = make_interface_schema();
13917        let account = schema
13918            .types
13919            .iter_mut()
13920            .find(|t| t.name == "Account")
13921            .expect("the interface schema has an Account type");
13922        account.materialized = true;
13923        let mut organization = schema
13924            .types
13925            .iter()
13926            .find(|t| t.name == "Individual")
13927            .expect("the interface schema has an Individual type")
13928            .clone();
13929        organization.name = "Organization".into();
13930        organization.table = "Organization".into();
13931        organization.computed.clear();
13932        schema.types.push(organization);
13933        schema.types.push(TypeDescriptor {
13934            name: "Note".into(),
13935            module: "default".into(),
13936            table: "Note".into(),
13937            abstract_: false,
13938            materialized: false,
13939            description: None,
13940            parents: vec![],
13941            interfaces: vec![],
13942            bases: vec![],
13943            properties: vec![],
13944            links: vec![LinkDescriptor {
13945                name: "owner".into(),
13946                target: "default::Account".into(),
13947                nullable: true,
13948                description: None,
13949                default_pyql: None,
13950                is_exclusive: false,
13951                is_readonly: false,
13952                rewrites: vec![],
13953                on_delete: vec![],
13954                through: None,
13955            }],
13956            multilinks: vec![],
13957            computed: vec![],
13958            constraints: vec![],
13959            indexes: vec![],
13960            partition: None,
13961            vector_indexes: vec![],
13962            search_indexes: vec![],
13963            triggers: vec![],
13964            junction: false,
13965            signals: vec![],
13966        });
13967
13968        let out = compile_and_emit_with("SELECT Note.owner { id }", &schema);
13969        assert!(
13970            out.sql.contains("UNION ALL") && out.sql.contains("'default::Individual'::text AS \"__type__\""),
13971            "the traversal's target should fan out over implementors, got:\n{}",
13972            out.sql
13973        );
13974        assert!(
13975            !out.sql.contains("'default::Account'::text,"),
13976            "the row's type should be read off the row, not fixed to the interface, got:\n{}",
13977            out.sql
13978        );
13979    }
13980
13981    #[test]
13982    fn test_computed_multilink_chain_comes_back_as_rows() {
13983        // Regression: `members := .memberships.member` is a chain, and
13984        // try_compile_pointer_expr only recognised a single-step `.multilink`,
13985        // so it fell through to expression position and became a scalar
13986        // subquery — which Postgres rejects the moment a second row matches.
13987        let mut schema = make_schema();
13988        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13989            name: "coauthors".into(),
13990            expression: ".posts.author".into(),
13991            return_type: None,
13992            link_target: None,
13993            link_multi: false,
13994        });
13995        let post = schema
13996            .types
13997            .iter_mut()
13998            .find(|t| t.name == "Post")
13999            .expect("test schema has a Post type");
14000        post.links.push(LinkDescriptor {
14001            name: "author".into(),
14002            target: "default::Person".into(),
14003            nullable: true,
14004            description: None,
14005            default_pyql: None,
14006            is_exclusive: false,
14007            is_readonly: false,
14008            rewrites: vec![],
14009            on_delete: vec![],
14010            through: None,
14011        });
14012
14013        let out = compile_and_emit_with("SELECT Person { coauthors: { name } }", &schema);
14014        assert!(
14015            out.sql.contains("ARRAY(SELECT"),
14016            "the chain should aggregate into an array, got:\n{}",
14017            out.sql
14018        );
14019        assert!(
14020            out.sql.contains("\"name\""),
14021            "the requested sub-shape should survive, got:\n{}",
14022            out.sql
14023        );
14024
14025        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
14026            panic!("expected Object shape, got {:?}", out.shape.root)
14027        };
14028        let coauthors = pointers
14029            .iter()
14030            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "coauthors"))
14031            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
14032        let crate::query::ShapeNode::Array { element, .. } = coauthors else {
14033            unreachable!()
14034        };
14035        assert!(
14036            matches!(
14037                element.as_ref(),
14038                crate::query::ShapeNode::Object { type_name: Some(t), .. } if t == "default::Person"
14039            ),
14040            "the elements should hydrate as objects, got {element:?}",
14041        );
14042    }
14043
14044    #[test]
14045    fn test_multi_sort_with_then_emits_two_order_keys() {
14046        let out = compile_and_emit("SELECT Person { name } ORDER BY .name THEN .age DESC");
14047        assert!(out.sql.contains("ORDER BY"), "expected ORDER BY");
14048        // Both columns should appear in the ORDER BY clause
14049        assert!(out.sql.contains("\"name\""));
14050        assert!(out.sql.contains("\"age\""));
14051        assert!(out.sql.contains("DESC"));
14052    }
14053
14054    #[test]
14055    fn test_string_index_emits_str_subscript() {
14056        let out = compile_and_emit("SELECT 'hello'[1]");
14057        assert!(
14058            out.sql.contains("_pylon.str_subscript"),
14059            "expected _pylon.str_subscript() for string index, got:\n{}",
14060            out.sql
14061        );
14062    }
14063
14064    /// PostgreSQL forbids a set-returning function inside `VALUES`, so a loop
14065    /// over `array_unpack` has to iterate a select instead.
14066    #[test]
14067    fn test_for_over_a_set_returning_call_is_not_a_values_clause() {
14068        let out = compile_and_emit("FOR n IN array_unpack(<array<int64>>$ns) UNION (SELECT n)");
14069        assert!(
14070            !out.sql.contains("VALUES (unnest"),
14071            "a set-returning iterator must not land in VALUES, got:\n{}",
14072            out.sql
14073        );
14074        assert!(
14075            out.sql.contains("SELECT unnest"),
14076            "expected the iterator in a select list, got:\n{}",
14077            out.sql
14078        );
14079    }
14080
14081    /// The loop variable's type is the unpacked array's element type, not
14082    /// whatever the generically-declared call says.
14083    #[test]
14084    fn test_for_over_array_unpack_binds_the_element_type() {
14085        let out = compile_and_emit("FOR j IN array_unpack(<array<json>>$rows) UNION (SELECT <str>j['k'])");
14086        assert!(
14087            out.sql.contains("::jsonb AS v"),
14088            "the loop variable should carry the element type, got:\n{}",
14089            out.sql
14090        );
14091    }
14092
14093    /// A binding that holds an array has to say so, or no overload of
14094    /// `contains` accepts it and the call is reported as one nobody wrote.
14095    #[test]
14096    fn test_a_binding_keeps_its_array_type_through_an_aggregate_and_a_condition() {
14097        for query in [
14098            "WITH ids := ([<uuid>$a] IF true ELSE [<uuid>$b]) SELECT Person FILTER contains(ids, .id)",
14099            "WITH ns := array_agg((SELECT Person.name)) SELECT Person FILTER contains(ns, .name)",
14100        ] {
14101            let out = compile_and_emit(query);
14102            assert!(
14103                out.sql.contains("@> ARRAY["),
14104                "expected the array overload of contains for `{query}`, got:\n{}",
14105                out.sql
14106            );
14107        }
14108    }
14109
14110    /// Each element of an array literal is one value. A walk gathered into an
14111    /// array of its own leaves `ARRAY[uuid, uuid[]]`, which PostgreSQL
14112    /// refuses: "ARRAY types uuid and uuid[] cannot be matched".
14113    #[test]
14114    fn test_an_array_literals_walk_element_is_read_as_one_value() {
14115        let out = compile_and_emit("WITH p := (SELECT Person LIMIT 1) SELECT [<uuid>$a, p.id]");
14116        assert!(
14117            !out.sql.contains("ARRAY(SELECT"),
14118            "the element should be a scalar subquery, not an array of its own:\n{}",
14119            out.sql
14120        );
14121    }
14122
14123    /// A sub-select's own filter speaks about its subject however the outer
14124    /// select is written: ordering the walk must not re-read `.name` off what
14125    /// the walk lands on, which silently filtered the wrong table.
14126    #[test]
14127    fn test_ordering_a_walk_leaves_the_inner_filter_on_its_own_subject() {
14128        let out = compile_and_emit("SELECT (SELECT Person FILTER .name = 'x' LIMIT 1).posts { title } ORDER BY .title");
14129        assert!(
14130            out.sql.contains("WHERE (\"t0\".\"name\" = 'x')"),
14131            "the filter belongs to the Person the walk starts from, got:\n{}",
14132            out.sql
14133        );
14134        assert!(
14135            out.sql.contains("\"title\" ASC"),
14136            "the ordering belongs to the Post the walk lands on, got:\n{}",
14137            out.sql
14138        );
14139    }
14140
14141    /// A sub-select's `limit` counts its own rows. Left on the joined result
14142    /// it took one row of the walk instead — one promotion of a cart that has
14143    /// two, with nothing to say it had dropped any.
14144    #[test]
14145    fn test_a_walks_head_keeps_its_own_limit() {
14146        let out = compile_and_emit("SELECT (SELECT Person FILTER .name = 'x' LIMIT 1).posts { title }");
14147        let (head, walk) = out.sql.split_once(")\nSELECT").expect("expected a bound head");
14148        assert!(
14149            head.contains("LIMIT 1"),
14150            "the head keeps its own row count, got:\n{}",
14151            out.sql
14152        );
14153        assert!(
14154            !walk.contains("LIMIT"),
14155            "the walk keeps every row it reaches, got:\n{}",
14156            out.sql
14157        );
14158    }
14159
14160    /// An overload that declares its element type carries it whatever it was
14161    /// handed, so the body reads the loop variable as json rather than as the
14162    /// text `jsonb_array_elements` would be cast to by default.
14163    #[test]
14164    fn test_for_over_json_array_unpack_binds_json() {
14165        let out = compile_and_emit(
14166            "FOR entry IN json_array_unpack(to_json(<str>$rows)) UNION (SELECT <str>json_get(entry, 'k'))",
14167        );
14168        assert!(
14169            out.sql.contains("::jsonb AS v"),
14170            "the loop variable should carry json, got:\n{}",
14171            out.sql
14172        );
14173    }
14174
14175    /// `json_get` yields json, so casting its result to a scalar reads the
14176    /// value out of it. `::text` on its own would keep a json string's quotes.
14177    #[test]
14178    fn test_casting_json_get_reads_the_value_not_its_json_spelling() {
14179        let out = compile_and_emit("SELECT <uuid><str>json_get(<json>$p, 'id')");
14180        assert!(
14181            out.sql.contains("#>> '{}'"),
14182            "expected the json value to be read out, got:\n{}",
14183            out.sql
14184        );
14185    }
14186
14187    /// Indexing json reaches into it with `->`; neither string nor array
14188    /// subscripting applies.
14189    #[test]
14190    fn test_indexing_json_emits_a_jsonb_accessor() {
14191        let out = compile_and_emit("SELECT (<json>$payload)['label']");
14192        assert!(
14193            out.sql.contains("->'label'"),
14194            "expected a jsonb field access, got:\n{}",
14195            out.sql
14196        );
14197        assert!(
14198            !out.sql.contains("str_subscript"),
14199            "json must not be subscripted as a string, got:\n{}",
14200            out.sql
14201        );
14202    }
14203
14204    /// Casting json to a scalar yields the value, not its JSON spelling —
14205    /// `<str>` of a json string has no quotes around it.
14206    #[test]
14207    fn test_casting_json_to_a_scalar_unwraps_it() {
14208        let out = compile_and_emit("SELECT <str>to_json(<str>$s)");
14209        assert!(
14210            out.sql.contains("#>> '{}'"),
14211            "expected the json value to be extracted, got:\n{}",
14212            out.sql
14213        );
14214    }
14215
14216    /// A `with` binding that names the loop variable holds a different value
14217    /// each iteration. Evaluated once for the statement it reads an iterator
14218    /// that is not in its FROM; every read of it belongs to one iteration.
14219    #[test]
14220    fn test_a_binding_that_reads_the_loop_variable_is_keyed_by_iteration() {
14221        let out = compile_and_emit(
14222            "WITH made := (FOR p IN (SELECT Person) UNION ( \
14223               WITH mine := (SELECT p.posts LIMIT 1) \
14224               INSERT Company { name := mine.title } \
14225             )) SELECT count(made)",
14226        );
14227        assert!(
14228            out.sql
14229                .contains("\"mine\" AS (\nSELECT \"_for_p\".\"v\" AS \"_outer__mine\""),
14230            "the binding must be evaluated per iteration, got:\n{}",
14231            out.sql
14232        );
14233        assert!(
14234            out.sql.contains("\"mine\".\"_outer__mine\" = \"_for_p\".\"v\""),
14235            "a read of it must pin to the iteration in scope, got:\n{}",
14236            out.sql
14237        );
14238    }
14239
14240    /// A `for … union (insert …)` assigned to a multi link *inside another
14241    /// loop* writes rows per iteration. They used to be cross-joined onto
14242    /// every row the outer loop wrote; they belong to one iteration of it.
14243    #[test]
14244    fn test_a_loop_nested_in_a_loop_pairs_its_rows_by_iteration() {
14245        let out = compile_and_emit(
14246            "FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (FOR t IN {n} UNION (INSERT Post { title := t })) })",
14247        );
14248        assert!(
14249            out.sql.contains("\"_outer\""),
14250            "the nested rows must carry an iteration key, got:\n{}",
14251            out.sql
14252        );
14253        assert!(
14254            !out.sql.contains("CROSS JOIN \"_nested_dml_0\""),
14255            "they must not be cross-joined onto every row, got:\n{}",
14256            out.sql
14257        );
14258    }
14259
14260    /// A multi link assigned in a `for`-bodied insert used to be dropped: the
14261    /// rows went in, the junction rows never did, and nothing reported it.
14262    #[test]
14263    fn test_for_bodied_insert_writes_its_multi_link() {
14264        let out = compile_and_emit("FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (SELECT Post) })");
14265        assert!(
14266            out.sql.contains("INSERT INTO \"public\".\"Person.posts\""),
14267            "the junction rows must be written too, got:\n{}",
14268            out.sql
14269        );
14270    }
14271
14272    #[test]
14273    fn test_string_slice_emits_substr() {
14274        let out = compile_and_emit("SELECT 'hello'[1:3]");
14275        assert!(
14276            out.sql.contains("substr"),
14277            "expected substr() for string slice, got:\n{}",
14278            out.sql
14279        );
14280    }
14281
14282    #[test]
14283    fn test_array_index_emits_subscript() {
14284        let out = compile_and_emit("SELECT [1, 2, 3][1]");
14285        assert!(
14286            out.sql.contains("_pylon.array_subscript"),
14287            "expected _pylon.array_subscript() for array index, got:\n{}",
14288            out.sql
14289        );
14290    }
14291
14292    #[test]
14293    fn test_index_into_array_returning_function_emits_array_subscript() {
14294        let out = compile_and_emit("SELECT str_split('a::b', '::')[-1]");
14295        assert!(
14296            out.sql.contains("_pylon.array_subscript(string_to_array"),
14297            "expected _pylon.array_subscript() over str_split, got:\n{}",
14298            out.sql
14299        );
14300    }
14301
14302    #[test]
14303    fn test_array_slice_emits_subscript() {
14304        let out = compile_and_emit("SELECT [1, 2, 3][0:2]");
14305        assert!(
14306            !out.sql.contains("substr"),
14307            "should not use substr for array, got:\n{}",
14308            out.sql
14309        );
14310        assert!(out.sql.contains(")["), "expected array slice syntax, got:\n{}", out.sql);
14311    }
14312
14313    #[test]
14314    fn test_open_ended_string_slice_emits_substr_no_length() {
14315        let out = compile_and_emit("SELECT 'hello'[2:]");
14316        // substr(expr, start) without length argument
14317        assert!(out.sql.contains("substr"), "expected substr(), got:\n{}", out.sql);
14318        // Should NOT have a third argument (length)
14319        let substr_idx = out.sql.find("substr").unwrap();
14320        let after = &out.sql[substr_idx..];
14321        let commas = after.chars().take_while(|&c| c != ')').filter(|&c| c == ',').count();
14322        assert_eq!(commas, 1, "open-ended slice should use 2-arg substr, got:\n{}", out.sql);
14323    }
14324
14325    #[test]
14326    fn test_group_by_single_key() {
14327        let out = compile_and_emit("group Person { name } by .age");
14328        // GROUP BY clause present
14329        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
14330        // Key column referenced
14331        assert!(out.sql.contains("\"age\""), "expected age column, got:\n{}", out.sql);
14332        // array_agg for elements
14333        assert!(
14334            out.sql.contains("array_agg(ROW("),
14335            "expected array_agg, got:\n{}",
14336            out.sql
14337        );
14338        // grouping names array
14339        assert!(
14340            out.sql.contains("ARRAY['age']"),
14341            "expected grouping array, got:\n{}",
14342            out.sql
14343        );
14344        // shape node is Group
14345        assert!(matches!(out.shape.root, crate::query::ShapeNode::Group { .. }));
14346        if let crate::query::ShapeNode::Group {
14347            key_nodes,
14348            grouping_position,
14349            elements_position,
14350            ..
14351        } = &out.shape.root
14352        {
14353            assert_eq!(key_nodes.len(), 1);
14354            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "age"));
14355            assert_eq!(*grouping_position, 2);
14356            assert_eq!(*elements_position, 3);
14357        }
14358    }
14359
14360    #[test]
14361    fn test_group_using_alias() {
14362        let out = compile_and_emit("group Person using decade := .age // 10 by decade");
14363        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
14364        assert!(
14365            out.sql.contains("ARRAY['decade']"),
14366            "expected grouping array, got:\n{}",
14367            out.sql
14368        );
14369        if let crate::query::ShapeNode::Group { key_nodes, .. } = &out.shape.root {
14370            assert_eq!(key_nodes.len(), 1);
14371            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, .. } if name == "decade"));
14372        }
14373    }
14374
14375    #[test]
14376    fn test_group_orders_elements_within_each_group() {
14377        let out = compile_and_emit("group Person { name } by .age order by .name desc");
14378        assert!(
14379            out.sql.contains(")::record ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
14380            "{}",
14381            out.sql
14382        );
14383        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
14384    }
14385
14386    #[test]
14387    fn test_group_limit_trims_each_group_not_the_result() {
14388        // The newest row per key: a trailing LIMIT would drop whole groups,
14389        // so the rows are ranked within each key instead.
14390        let out = compile_and_emit("group Person { name } by .age order by .name desc limit 1");
14391        assert!(
14392            out.sql
14393                .contains("row_number() OVER (PARTITION BY \"t0\".\"age\" ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
14394            "{}",
14395            out.sql
14396        );
14397        assert!(
14398            out.sql.contains("WHERE \"__rk\" > 0 AND \"__rk\" <= 0 + 1"),
14399            "{}",
14400            out.sql
14401        );
14402        assert!(!out.sql.contains("\nLIMIT"), "{}", out.sql);
14403    }
14404
14405    #[test]
14406    fn test_group_by_two_keys_with_a_per_group_limit() {
14407        let out = compile_and_emit(
14408            "group Person { name } by .age, .name filter .age > 18 order by .name desc offset 1 limit 2",
14409        );
14410        assert!(
14411            out.sql.contains("PARTITION BY \"t0\".\"age\", \"t0\".\"name\""),
14412            "{}",
14413            out.sql
14414        );
14415        // The filter picks which rows are grouped, so it sits inside the
14416        // ranking subquery — ranking must not see rows the filter excluded.
14417        let where_pos = out.sql.find("WHERE (\"t0\".\"age\" > 18)").expect("filter");
14418        let rank_pos = out.sql.find("WHERE \"__rk\"").expect("rank filter");
14419        assert!(where_pos < rank_pos, "{}", out.sql);
14420        assert!(
14421            out.sql.contains("GROUP BY \"t0\".\"age\", \"t0\".\"name\""),
14422            "{}",
14423            out.sql
14424        );
14425    }
14426
14427    #[test]
14428    fn test_group_filter_without_a_limit_is_a_plain_where() {
14429        let out = compile_and_emit("group Person { name } by .age filter .age > 18");
14430        assert!(out.sql.contains("WHERE (\"t0\".\"age\" > 18)"), "{}", out.sql);
14431        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
14432    }
14433
14434    #[test]
14435    fn test_abs_path_concat_same_type() {
14436        let out = compile_and_emit("SELECT Person.name ++ ' ' ++ Person.name");
14437        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
14438        assert!(out.sql.contains("||"), "expected concat operator, got:\n{}", out.sql);
14439        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
14440    }
14441
14442    #[test]
14443    fn test_abs_path_single_property() {
14444        let out = compile_and_emit("SELECT Person.name");
14445        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
14446        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
14447    }
14448
14449    #[test]
14450    fn test_pgvector_cast_emits_vector_type() {
14451        let out = compile_and_emit("SELECT <pgvector::vector>[1.0, 2.0, 3.0]");
14452        assert!(
14453            out.sql.contains("::vector"),
14454            "expected ::vector cast, got:\n{}",
14455            out.sql
14456        );
14457        assert!(out.sql.contains("ARRAY["), "expected ARRAY literal, got:\n{}", out.sql);
14458    }
14459
14460    #[test]
14461    fn test_pgvector_euclidean_distance_emits_l2_operator() {
14462        let out = compile_and_emit(
14463            "SELECT pgvector::euclidean_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14464        );
14465        assert!(out.sql.contains("<->"), "expected <-> operator, got:\n{}", out.sql);
14466    }
14467
14468    #[test]
14469    fn test_pgvector_cosine_distance_emits_cosine_operator() {
14470        let out = compile_and_emit(
14471            "SELECT pgvector::cosine_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14472        );
14473        assert!(out.sql.contains("<=>"), "expected <=> operator, got:\n{}", out.sql);
14474    }
14475
14476    #[test]
14477    fn test_pgvector_neg_inner_product_emits_ip_operator() {
14478        let out = compile_and_emit(
14479            "SELECT pgvector::neg_inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14480        );
14481        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
14482    }
14483
14484    #[test]
14485    fn test_pgvector_inner_product_negates_ip_operator() {
14486        let out = compile_and_emit(
14487            "SELECT pgvector::inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14488        );
14489        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
14490        assert!(out.sql.contains("0.0"), "expected negation of <#>, got:\n{}", out.sql);
14491    }
14492
14493    #[test]
14494    fn test_crypto_digest_str_and_bytes_overloads_both_use_pgcrypto_digest() {
14495        let out = compile_and_emit("SELECT crypto::digest('hello', 'sha256')");
14496        assert!(
14497            out.sql.contains("digest("),
14498            "expected pgcrypto's digest(), got:\n{}",
14499            out.sql
14500        );
14501
14502        let out = compile_and_emit("SELECT crypto::digest(std::from_hex('68656c6c6f'), 'sha256')");
14503        assert!(
14504            out.sql.contains("digest("),
14505            "expected pgcrypto's digest(), got:\n{}",
14506            out.sql
14507        );
14508    }
14509
14510    #[test]
14511    fn test_crypto_hmac_str_and_bytes_overloads_both_use_pgcrypto_hmac() {
14512        let out = compile_and_emit("SELECT crypto::hmac('hello', 'key', 'sha256')");
14513        assert!(
14514            out.sql.contains("hmac("),
14515            "expected pgcrypto's hmac(), got:\n{}",
14516            out.sql
14517        );
14518
14519        let out =
14520            compile_and_emit("SELECT crypto::hmac(std::from_hex('68656c6c6f'), std::from_hex('6b6579'), 'sha256')");
14521        assert!(
14522            out.sql.contains("hmac("),
14523            "expected pgcrypto's hmac(), got:\n{}",
14524            out.sql
14525        );
14526    }
14527
14528    #[test]
14529    fn test_crypto_gen_salt_zero_arg_defaults_to_blowfish() {
14530        let out = compile_and_emit("SELECT crypto::gen_salt()");
14531        assert!(
14532            out.sql.contains("gen_salt('bf')"),
14533            "expected default 'bf' salt type, got:\n{}",
14534            out.sql
14535        );
14536    }
14537
14538    #[test]
14539    fn test_crypto_gen_salt_one_arg_passes_type_through() {
14540        let out = compile_and_emit("SELECT crypto::gen_salt('xdes')");
14541        assert!(
14542            out.sql.contains("gen_salt("),
14543            "expected gen_salt() call, got:\n{}",
14544            out.sql
14545        );
14546    }
14547
14548    #[test]
14549    fn test_crypto_gen_salt_iter_count_casts_to_int4() {
14550        let out = compile_and_emit("SELECT crypto::gen_salt('xdes', 5)");
14551        assert!(
14552            out.sql.contains("::int4"),
14553            "expected int8 -> int4 narrowing cast, got:\n{}",
14554            out.sql
14555        );
14556    }
14557
14558    #[test]
14559    fn test_crypto_crypt_uses_pgcrypto_crypt() {
14560        let out = compile_and_emit("SELECT crypto::crypt('hunter2', crypto::gen_salt())");
14561        assert!(
14562            out.sql.contains("crypt("),
14563            "expected pgcrypto's crypt(), got:\n{}",
14564            out.sql
14565        );
14566    }
14567
14568    #[test]
14569    fn test_postgis_cast_emits_geometry_type() {
14570        let out = compile_and_emit("SELECT <postgis::geometry>'POINT(1 2)'");
14571        assert!(
14572            out.sql.contains("::geometry"),
14573            "expected ::geometry cast, got:\n{}",
14574            out.sql
14575        );
14576    }
14577
14578    #[test]
14579    fn test_postgis_x_uses_st_x_builtin() {
14580        let out = compile_and_emit("SELECT postgis::x(<postgis::geometry>'POINT(1 2)')");
14581        assert!(out.sql.contains("st_x("), "expected st_x() call, got:\n{}", out.sql);
14582    }
14583
14584    #[test]
14585    fn test_postgis_area_geometry_and_geography_overloads() {
14586        let out = compile_and_emit("SELECT postgis::area(<postgis::geometry>'POINT(1 2)')");
14587        assert!(
14588            out.sql.contains("st_area("),
14589            "expected st_area() call, got:\n{}",
14590            out.sql
14591        );
14592
14593        let out = compile_and_emit("SELECT postgis::area(<postgis::geography>'POINT(1 2)', true)");
14594        assert!(
14595            out.sql.contains("st_area("),
14596            "expected st_area() call, got:\n{}",
14597            out.sql
14598        );
14599    }
14600
14601    #[test]
14602    fn test_postgis_setsrid_casts_int64_arg_to_int4() {
14603        let out = compile_and_emit("SELECT postgis::setsrid(<postgis::geometry>'POINT(1 2)', 4326)");
14604        assert!(
14605            out.sql.contains("st_setsrid("),
14606            "expected st_setsrid() call, got:\n{}",
14607            out.sql
14608        );
14609        assert!(
14610            out.sql.contains("::int4"),
14611            "expected int8 -> int4 narrowing cast, got:\n{}",
14612            out.sql
14613        );
14614    }
14615
14616    #[test]
14617    fn test_postgis_quantizecoordinates_default_arity_variants_compile() {
14618        // The reference binding documents this with 3 trailing optional
14619        // params; Pylon has no notion of default args, so each arity is
14620        // its own registered overload — confirm both the 2-arg and 4-arg
14621        // forms resolve.
14622        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5)");
14623        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
14624
14625        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5, 5, 5)");
14626        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
14627    }
14628
14629    #[test]
14630    fn test_postgis_op_contains_emits_infix_operator_not_function_call() {
14631        // Regression: ImplStrategy::SqlOperator was never actually consulted
14632        // by resolve_fn_call — it fell through to the generic "schema.name(args)"
14633        // FunctionCall path, which would have emitted a nonexistent
14634        // `"postgis".op_contains(...)` call instead of the `~` operator.
14635        let out = compile_and_emit(
14636            "SELECT postgis::op_contains(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
14637        );
14638        assert!(out.sql.contains(" ~ "), "expected infix ~ operator, got:\n{}", out.sql);
14639        assert!(
14640            !out.sql.contains("op_contains("),
14641            "must not call a literal op_contains function, got:\n{}",
14642            out.sql
14643        );
14644    }
14645
14646    #[test]
14647    fn test_postgis_op_overlaps_geometry_and_geography_overloads() {
14648        let out = compile_and_emit(
14649            "SELECT postgis::op_overlaps(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
14650        );
14651        assert!(
14652            out.sql.contains(" && "),
14653            "expected infix && operator, got:\n{}",
14654            out.sql
14655        );
14656
14657        let out = compile_and_emit(
14658            "SELECT postgis::op_overlaps(<postgis::geography>'POINT(1 2)', <postgis::geography>'POINT(3 4)')",
14659        );
14660        assert!(
14661            out.sql.contains(" && "),
14662            "expected infix && operator, got:\n{}",
14663            out.sql
14664        );
14665    }
14666
14667    // ── User-defined function tests ───────────────────────────────────────────
14668
14669    fn make_schema_with_fns() -> SchemaDescriptor {
14670        let mut s = make_schema();
14671        s.functions = vec![
14672            FunctionDescriptor {
14673                name: "mysum".into(),
14674                module: "default".into(),
14675                params: vec![
14676                    FunctionParamDescriptor {
14677                        name: "a".into(),
14678                        pg_type: "int8".into(),
14679                    },
14680                    FunctionParamDescriptor {
14681                        name: "b".into(),
14682                        pg_type: "int8".into(),
14683                    },
14684                ],
14685                return_pg_type: "int8".into(),
14686                return_is_object: false,
14687                return_is_set: false,
14688                return_is_polymorphic: false,
14689                volatility: "immutable".into(),
14690                body: "a + b".into(),
14691            },
14692            FunctionDescriptor {
14693                name: "adults".into(),
14694                module: "default".into(),
14695                params: vec![],
14696                return_pg_type: "default::Person".into(),
14697                return_is_object: true,
14698                return_is_set: true,
14699                return_is_polymorphic: false,
14700                volatility: "stable".into(),
14701                body: "select Person filter .age > 18".into(),
14702            },
14703            // A second, 3-arg overload of `mysum`, declared *before* the
14704            // 2-arg one is looked up by resolution below — regression for a
14705            // bug where call resolution grabbed the first name-matching
14706            // overload regardless of arity, instead of searching all
14707            // same-named overloads for one whose param count actually
14708            // matches the call site.
14709            FunctionDescriptor {
14710                name: "mysum".into(),
14711                module: "default".into(),
14712                params: vec![
14713                    FunctionParamDescriptor {
14714                        name: "a".into(),
14715                        pg_type: "int8".into(),
14716                    },
14717                    FunctionParamDescriptor {
14718                        name: "b".into(),
14719                        pg_type: "int8".into(),
14720                    },
14721                    FunctionParamDescriptor {
14722                        name: "c".into(),
14723                        pg_type: "int8".into(),
14724                    },
14725                ],
14726                return_pg_type: "int8".into(),
14727                return_is_object: false,
14728                return_is_set: false,
14729                return_is_polymorphic: false,
14730                volatility: "immutable".into(),
14731                body: "a + b + c".into(),
14732            },
14733        ];
14734        s
14735    }
14736
14737    #[test]
14738    fn test_user_fn_scalar_call() {
14739        let schema = make_schema_with_fns();
14740        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
14741        assert!(out.sql.contains("\"public\".\"mysum\""), "got:\n{}", out.sql);
14742    }
14743
14744    #[test]
14745    fn test_user_fn_overload_resolved_by_argument_count() {
14746        // Regression: with a 2-arg and a 3-arg `mysum` overload both
14747        // declared, a 3-arg call must resolve to the 3-arg overload rather
14748        // than erroring against whichever overload happens to be first in
14749        // `schema.functions`.
14750        let schema = make_schema_with_fns();
14751        let out = compile_and_emit_with("SELECT mysum(1, 2, 3)", &schema);
14752        assert!(
14753            out.sql
14754                .contains("\"public\".\"mysum\"((1)::int8, (2)::int8, (3)::int8)"),
14755            "got:\n{}",
14756            out.sql,
14757        );
14758
14759        // The 2-arg call must still resolve to the 2-arg overload.
14760        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
14761        assert!(
14762            out.sql.contains("\"public\".\"mysum\"((1)::int8, (2)::int8)"),
14763            "got:\n{}",
14764            out.sql,
14765        );
14766    }
14767
14768    #[test]
14769    fn test_user_fn_object_select_no_shape() {
14770        let schema = make_schema_with_fns();
14771        let out = compile_and_emit_with("SELECT adults()", &schema);
14772        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
14773        assert!(out.sql.contains("FROM"), "got:\n{}", out.sql);
14774    }
14775
14776    #[test]
14777    fn test_user_fn_object_select_with_shape() {
14778        let schema = make_schema_with_fns();
14779        let out = compile_and_emit_with("SELECT adults() { name }", &schema);
14780        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
14781        assert!(out.sql.contains("\"name\""), "got:\n{}", out.sql);
14782    }
14783
14784    #[test]
14785    fn test_object_fn_projected_to_a_property_inside_an_expression() {
14786        let schema = make_schema_with_fns();
14787        let out = compile_and_emit_with("SELECT Person { n := adults().name }", &schema);
14788        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
14789        assert!(out.sql.contains("\".\"name\"\n"), "got:\n{}", out.sql);
14790    }
14791
14792    #[test]
14793    fn test_object_fn_sub_select_carries_its_modifiers() {
14794        let schema = make_schema_with_fns();
14795        let out = compile_and_emit_with(
14796            "SELECT Person { n := (select adults() filter .age > 21 limit 1).name }",
14797            &schema,
14798        );
14799        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
14800        assert!(out.sql.contains("\"age\" > 21"), "got:\n{}", out.sql);
14801        assert!(out.sql.contains("LIMIT 1"), "got:\n{}", out.sql);
14802    }
14803
14804    #[test]
14805    fn test_object_fn_without_a_property_still_explains_the_restriction() {
14806        let schema = make_schema_with_fns();
14807        let ast = parse::parse("SELECT Person { n := adults() + 1 }").unwrap();
14808        let err = match ir::compile(&ast, &schema) {
14809            Ok(_) => panic!("expected a compile error"),
14810            Err(e) => format!("{e}"),
14811        };
14812        assert!(err.contains("returns objects"), "{err}");
14813    }
14814
14815    #[test]
14816    fn test_object_fn_projected_to_an_unknown_property_is_rejected() {
14817        let schema = make_schema_with_fns();
14818        let ast = parse::parse("SELECT Person { n := adults().nope }").unwrap();
14819        let err = match ir::compile(&ast, &schema) {
14820            Ok(_) => panic!("expected a compile error"),
14821            Err(e) => format!("{e}"),
14822        };
14823        assert!(err.contains("nope"), "{err}");
14824    }
14825
14826    #[test]
14827    fn test_user_fn_in_cte_exposes_raw_columns() {
14828        // Regression: FunctionSelect as a CTE source must emit SELECT * FROM fn()
14829        // so the outer query can reference raw columns like t1.age.
14830        let schema = make_schema_with_fns();
14831        let out = compile_and_emit_with("WITH persons := adults() SELECT persons FILTER .age > 25", &schema);
14832        assert!(
14833            out.sql.contains("SELECT * FROM \"public\".\"adults\"()"),
14834            "CTE source must be SELECT * FROM fn(), got:\n{}",
14835            out.sql,
14836        );
14837        assert!(
14838            out.sql.contains("\"age\""),
14839            "outer filter must reference raw column, got:\n{}",
14840            out.sql
14841        );
14842    }
14843
14844    // ── vector::search tests ──────────────────────────────────────────────────
14845
14846    fn make_schema_with_vector() -> SchemaDescriptor {
14847        use crate::schema::VectorIndexDescriptor;
14848        let mut s = make_schema();
14849        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
14850            td.vector_indexes.push(VectorIndexDescriptor {
14851                index_name: None,
14852                pointers: vec!["name".into()],
14853                model: "test-embed".into(),
14854                metric: "cosine".into(),
14855                dimensions: 4,
14856            });
14857        }
14858        s
14859    }
14860
14861    #[test]
14862    fn test_vector_search_bare_type_name() {
14863        let schema = make_schema_with_vector();
14864        let out = compile_and_emit_with(
14865            "WITH search := vector::search(Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14866             SELECT search { object { name }, distance }",
14867            &schema,
14868        );
14869        assert!(
14870            out.sql.contains("\"Person\""),
14871            "expected Person table, got:\n{}",
14872            out.sql
14873        );
14874        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14875    }
14876
14877    #[test]
14878    fn test_vector_search_qualified_type_name() {
14879        let schema = make_schema_with_vector();
14880        let out = compile_and_emit_with(
14881            "WITH search := vector::search(default::Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14882             SELECT search { object { name }, distance }",
14883            &schema,
14884        );
14885        assert!(
14886            out.sql.contains("\"Person\""),
14887            "expected Person table, got:\n{}",
14888            out.sql
14889        );
14890        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14891    }
14892
14893    #[test]
14894    fn test_vector_search_subquery_filter_included_in_where() {
14895        let schema = make_schema_with_vector();
14896        let out = compile_and_emit_with(
14897            "WITH search := vector::search((select Person filter .name = 'Alice'), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14898             SELECT search { object { name }, distance }",
14899            &schema,
14900        );
14901        assert!(
14902            out.sql.contains("\"Person\""),
14903            "expected Person table, got:\n{}",
14904            out.sql
14905        );
14906        assert!(out.sql.contains("\"name\""), "expected name filter, got:\n{}", out.sql);
14907        assert!(out.sql.contains("Alice"), "expected filter value, got:\n{}", out.sql);
14908        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14909    }
14910
14911    #[test]
14912    fn test_vector_search_subquery_filter_combined_with_outer_property_filter() {
14913        let schema = make_schema_with_vector();
14914        let out = compile_and_emit_with(
14915            "WITH search := vector::search((select Person filter .age > 18), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14916             SELECT search { object { name }, distance }",
14917            &schema,
14918        );
14919        assert!(
14920            out.sql.contains("\"age\""),
14921            "expected age pre-filter, got:\n{}",
14922            out.sql
14923        );
14924        assert!(out.sql.contains("18"), "expected filter value 18, got:\n{}", out.sql);
14925        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14926    }
14927
14928    #[test]
14929    fn test_vector_search_text_overload_with_subquery_filter() {
14930        let schema = make_schema_with_vector();
14931        let out = compile_and_emit_with(
14932            "WITH search := vector::search((select Person filter .name = 'Alice'), query := $q) \
14933             SELECT search { object { name }, distance }",
14934            &schema,
14935        );
14936        assert!(
14937            out.sql.contains("\"Person\""),
14938            "expected Person table, got:\n{}",
14939            out.sql
14940        );
14941        assert!(
14942            out.sql.contains("Alice"),
14943            "expected pre-filter value, got:\n{}",
14944            out.sql
14945        );
14946        assert!(
14947            out.sql.contains("float8[]"),
14948            "expected float8[] cast for deferred vec param, got:\n{}",
14949            out.sql
14950        );
14951        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14952    }
14953
14954    #[test]
14955    fn test_count_type_ref_compiles_to_agg_over_query() {
14956        let out = compile_and_emit("SELECT count(Person)");
14957        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14958        assert!(
14959            out.sql.contains("\"Person\""),
14960            "expected Person table, got:\n{}",
14961            out.sql
14962        );
14963    }
14964
14965    #[test]
14966    fn test_count_qualified_type_ref_compiles_to_agg_over_query() {
14967        let out = compile_and_emit("SELECT count(default::Person)");
14968        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14969        assert!(
14970            out.sql.contains("\"Person\""),
14971            "expected Person table, got:\n{}",
14972            out.sql
14973        );
14974    }
14975
14976    #[test]
14977    fn test_count_subquery_compiles_to_agg_over_query() {
14978        let out = compile_and_emit("SELECT count((select Person))");
14979        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14980        assert!(
14981            out.sql.contains("\"Person\""),
14982            "expected Person table, got:\n{}",
14983            out.sql
14984        );
14985    }
14986
14987    #[test]
14988    fn test_count_subquery_with_filter() {
14989        let out = compile_and_emit("SELECT count((select Person filter .name = 'Alice'))");
14990        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14991        assert!(
14992            out.sql.contains("\"name\""),
14993            "expected filter on name, got:\n{}",
14994            out.sql
14995        );
14996    }
14997
14998    #[test]
14999    fn test_positional_param_compiles_to_dollar_n() {
15000        let out = compile_and_emit("SELECT Person FILTER .name = $0");
15001        assert!(out.sql.contains("$1"), "expected $1 placeholder, got:\n{}", out.sql);
15002    }
15003
15004    #[test]
15005    fn test_multiple_positional_params_compile_in_order() {
15006        let out = compile_and_emit("SELECT Person FILTER .name = $0 AND .age > $1");
15007        assert!(out.sql.contains("$1"), "expected $1, got:\n{}", out.sql);
15008        assert!(out.sql.contains("$2"), "expected $2, got:\n{}", out.sql);
15009    }
15010
15011    #[test]
15012    fn test_repeated_positional_param_reuses_slot() {
15013        let out = compile_and_emit("SELECT Person FILTER .name = $0 OR .name = $0");
15014        assert_eq!(
15015            out.sql.matches("$1").count(),
15016            2,
15017            "both uses must reference $1, got:\n{}",
15018            out.sql
15019        );
15020    }
15021
15022    #[test]
15023    fn test_cast_to_nonexistent_type_names_full_type() {
15024        let schema = make_schema();
15025        let ast = parse::parse("SELECT Person FILTER .name = <default::Ghost>$name").unwrap();
15026        match ir::compile(&ast, &schema) {
15027            Ok(_) => panic!("expected compile error for unknown type"),
15028            Err(e) => {
15029                let msg = e.to_string();
15030                assert!(
15031                    msg.contains("unknown type 'default::Ghost'"),
15032                    "expected full type name in error, got: {msg}",
15033                );
15034            }
15035        }
15036    }
15037
15038    #[test]
15039    fn test_cast_to_nonexistent_unqualified_type_names_type() {
15040        let schema = make_schema();
15041        let ast = parse::parse("SELECT Person FILTER .name = <Ghost>$name").unwrap();
15042        match ir::compile(&ast, &schema) {
15043            Ok(_) => panic!("expected compile error for unknown type"),
15044            Err(e) => {
15045                let msg = e.to_string();
15046                assert!(
15047                    msg.contains("unknown type 'Ghost'"),
15048                    "expected type name in error, got: {msg}",
15049                );
15050            }
15051        }
15052    }
15053
15054    #[test]
15055    fn test_top_level_cast_to_registered_scalar_uses_its_own_domain() {
15056        // A bare top-level `SELECT <module::Scalar>expr` must compile as a
15057        // plain scalar cast to that scalar's own DOMAIN — not get misrouted
15058        // to `compile_schema_cast_select`'s object-by-id lookup (which
15059        // treated any non-stdlib-module named cast target as an object type,
15060        // since it never checked `resolve_scalar` before falling through).
15061        use crate::schema::ScalarDescriptor;
15062        let mut schema = make_schema();
15063        schema.scalars.push(ScalarDescriptor {
15064            name: "Email".into(),
15065            module: "default".into(),
15066            base: "Str".into(),
15067            pg_type: "text".into(),
15068            check_constraints: vec!["value ~ '^[^@]+@[^@]+\\.[^@]+$'".into()],
15069            is_sequence: false,
15070        });
15071        let out = compile_and_emit_with("SELECT <default::Email>'test@test.de'", &schema);
15072        assert!(
15073            out.sql.contains("\"public\".\"Email\""),
15074            "expected a cast to the scalar's own domain, got:\n{}",
15075            out.sql
15076        );
15077        assert!(
15078            !out.sql.to_lowercase().contains("\"person\""),
15079            "must not be misrouted to an object-type lookup, got:\n{}",
15080            out.sql
15081        );
15082    }
15083
15084    #[test]
15085    fn test_top_level_cast_to_unknown_type_names_full_type() {
15086        // The object-cast fallback (`compile_schema_cast_select`) used to
15087        // discard the module before building its synthetic path, so an
15088        // unknown top-level cast target reported a bare name even when
15089        // written with an explicit module.
15090        let schema = make_schema();
15091        let ast = parse::parse("SELECT <default::Ghost>$name").unwrap();
15092        match ir::compile(&ast, &schema) {
15093            Ok(_) => panic!("expected compile error for unknown type"),
15094            Err(e) => {
15095                let msg = e.to_string();
15096                assert!(
15097                    msg.contains("unknown type 'default::Ghost'"),
15098                    "expected full type name in error, got: {msg}",
15099                );
15100            }
15101        }
15102    }
15103
15104    #[test]
15105    fn test_structural_tuple_cast_unnamed_resolves_to_jsonb() {
15106        let out = compile_and_emit("SELECT <tuple<str, bool>>$p");
15107        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15108    }
15109
15110    #[test]
15111    fn test_jsonb_to_uuid_cast_extracts_via_text() {
15112        // PostgreSQL has no native jsonb -> uuid cast; extract raw text then cast.
15113        let out = compile_and_emit("SELECT <uuid>(<json>$p)");
15114        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
15115        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
15116    }
15117
15118    #[test]
15119    fn test_jsonb_to_datetime_cast_extracts_via_text() {
15120        let out = compile_and_emit("SELECT <datetime>(<json>$p)");
15121        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
15122        assert!(out.sql.contains("::timestamptz"), "got:\n{}", out.sql);
15123    }
15124
15125    #[test]
15126    fn test_jsonb_to_duration_cast_extracts_via_text() {
15127        let out = compile_and_emit("SELECT <duration>(<json>$p)");
15128        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
15129        assert!(out.sql.contains("::interval"), "got:\n{}", out.sql);
15130    }
15131
15132    #[test]
15133    fn test_jsonb_to_array_cast_unpacks_each_element() {
15134        let out = compile_and_emit("SELECT <array<int64>>(<json>$p)");
15135        assert!(out.sql.contains("jsonb_array_elements("), "got:\n{}", out.sql);
15136        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
15137        assert!(out.sql.contains("::int8"), "got:\n{}", out.sql);
15138    }
15139
15140    #[test]
15141    fn test_non_jsonb_cast_is_unaffected_by_jsonb_extraction() {
15142        // A plain str -> uuid cast (source isn't jsonb) must still use the
15143        // ordinary `::pg_type` path, not the jsonb-extraction template.
15144        let out = compile_and_emit("SELECT <uuid>$p");
15145        assert!(!out.sql.contains("#>>"), "got:\n{}", out.sql);
15146        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
15147    }
15148
15149    #[test]
15150    fn test_datetime_plus_duration_type_checks() {
15151        // Regression: types_compatible's bucket-matching (same type, or
15152        // both-int, or both-float) rejected this outright, even though
15153        // Postgres supports `timestamptz + interval` natively and this is
15154        // a legitimate operator overload.
15155        let out = compile_and_emit("SELECT <datetime>$p + <duration>$d");
15156        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15157    }
15158
15159    #[test]
15160    fn test_duration_plus_datetime_type_checks_either_order() {
15161        let out = compile_and_emit("SELECT <duration>$d + <datetime>$p");
15162        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15163    }
15164
15165    #[test]
15166    fn test_local_date_plus_duration_type_checks() {
15167        let out = compile_and_emit("SELECT <cal::local_date>$p + <duration>$d");
15168        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15169    }
15170
15171    #[test]
15172    fn test_datetime_minus_duration_type_checks() {
15173        let out = compile_and_emit("SELECT <datetime>$p - <duration>$d");
15174        assert!(out.sql.contains(" - "), "got:\n{}", out.sql);
15175    }
15176
15177    #[test]
15178    fn test_local_time_plus_duration_type_checks() {
15179        let out = compile_and_emit("SELECT <cal::local_time>$p + <duration>$d");
15180        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15181    }
15182
15183    #[test]
15184    fn test_datetime_plus_duration_only_allowed_for_add_and_sub() {
15185        // Comparing (or otherwise combining) a datetime and a duration with
15186        // anything other than +/- still isn't meaningful and must still error.
15187        let schema = make_schema();
15188        let ast = parse::parse("SELECT <datetime>$p = <duration>$d").unwrap();
15189        match ir::compile(&ast, &schema) {
15190            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
15191            Ok(_) => panic!("expected a compile error"),
15192        }
15193    }
15194
15195    #[test]
15196    fn test_int_and_float_mix_is_allowed() {
15197        // Every int width implicitly casts to every float width, so mixed
15198        // int/float arithmetic type-checks without an explicit cast —
15199        // Postgres's own operator resolution handles the actual promotion.
15200        let out = compile_and_emit("SELECT <int16>1 + <float32>2.0");
15201        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15202    }
15203
15204    #[test]
15205    fn test_int_and_decimal_mix_is_allowed() {
15206        // int64 -> bigint -> decimal is a separate implicit-cast branch
15207        // from int64 -> float32 -> float64, but every int width still
15208        // mixes with numeric/decimal directly.
15209        let out = compile_and_emit("SELECT <int64>1 + <decimal>2.0");
15210        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15211    }
15212
15213    #[test]
15214    fn test_float_and_decimal_mix_still_rejected() {
15215        // float and numeric/decimal are separate branches past int64 in
15216        // the implicit-cast graph and don't cast to each other — this
15217        // still needs an explicit cast.
15218        let schema = make_schema();
15219        let ast = parse::parse("SELECT <decimal>$p + <float64>$n").unwrap();
15220        match ir::compile(&ast, &schema) {
15221            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
15222            Ok(_) => panic!("expected a compile error"),
15223        }
15224    }
15225
15226    #[test]
15227    fn test_unknown_pointer_suggests_a_close_match() {
15228        // `Person` has a `name` property (see make_schema) — `nam` is close
15229        // enough (missing one trailing character) that a "Did you mean"
15230        // suggestion should fire.
15231        let schema = make_schema();
15232        let ast = parse::parse("SELECT Person { nam }").unwrap();
15233        match ir::compile(&ast, &schema) {
15234            Err(err) => assert_eq!(
15235                format!("{err}"),
15236                "object type 'default::Person' has no link or property 'nam'. Did you mean 'name'?"
15237            ),
15238            Ok(_) => panic!("expected a compile error"),
15239        }
15240    }
15241
15242    #[test]
15243    fn test_unrelated_unknown_pointer_gets_no_suggestion() {
15244        // A typo with no plausible match on the type shouldn't force a
15245        // confusing, unrelated suggestion onto the user.
15246        let schema = make_schema();
15247        let ast = parse::parse("SELECT Person { xyzxyzxyz }").unwrap();
15248        match ir::compile(&ast, &schema) {
15249            Err(err) => {
15250                let msg = format!("{err}");
15251                assert!(msg.contains("has no link or property 'xyzxyzxyz'"), "got: {msg}");
15252                assert!(!msg.contains("Did you mean"), "got: {msg}");
15253            }
15254            Ok(_) => panic!("expected a compile error"),
15255        }
15256    }
15257
15258    #[test]
15259    fn test_multilink_sourced_backlink_exists_filter_compiles() {
15260        // Regression for a gap live-execution testing caught: `filter exists
15261        // .<multilink[is Type]` (a backlink whose source pointer on the
15262        // target type is a multi-link, e.g. `Person.posts`, not a single
15263        // FK link) used to fail to compile with "has no link ... pointing
15264        // to" — `compile_backlink_as_exists` only ever checked
15265        // `target_td.links`, never `target_td.multilinks`. Fast SQL-text
15266        // companion to the live-execution test in
15267        // `tests/live_execution_backlinks.rs`, which additionally confirms
15268        // the emitted SQL returns the right rows.
15269        let out = compile_and_emit("SELECT Post filter exists .<posts[is Person]");
15270        assert!(out.sql.contains("EXISTS"), "got:\n{}", out.sql);
15271    }
15272
15273    #[test]
15274    fn test_backlink_as_computed_shape_pointer_compiles() {
15275        // Regression for the bigger gap live-execution testing caught: a
15276        // backlink could only be used in `filter exists .<...>` — using one
15277        // to select/project actual objects as a computed pointer's value
15278        // (`authors := .<posts[is Person] { name }`) failed to compile at
15279        // all ("shapes and set literals are not valid in expression
15280        // context"). `compile_shape_element`'s computed-pointer dispatch
15281        // now recognizes a backlink-rooted `Expr::Shape`/`Expr::Path` RHS
15282        // and routes it through `compile_backlink_pointer`, mirroring
15283        // `compile_multilink_pointer` for the forward direction. Fast
15284        // SQL-text companion to the live-execution tests in
15285        // `tests/live_execution_backlinks.rs`, which additionally confirm
15286        // the emitted SQL returns the right rows, nested 2 levels deep.
15287        let out = compile_and_emit("SELECT Post { title, authors := .<posts[is Person] { name } }");
15288        assert!(out.sql.contains("array_agg(ROW("), "got:\n{}", out.sql);
15289    }
15290
15291    fn schema_with_exclusive_posts() -> SchemaDescriptor {
15292        let mut schema = make_schema();
15293        for td in schema.types.iter_mut().filter(|td| td.name == "Person") {
15294            for ml in td.multilinks.iter_mut().filter(|ml| ml.name == "posts") {
15295                ml.is_exclusive = true;
15296            }
15297        }
15298        schema
15299    }
15300
15301    #[test]
15302    fn test_backlink_through_an_exclusive_multilink_is_single() {
15303        let out = compile_and_emit_with(
15304            "SELECT Post { title, author := .<posts[is Person] { name } }",
15305            &schema_with_exclusive_posts(),
15306        );
15307        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
15308            panic!("expected an object root, got {:?}", out.shape.root)
15309        };
15310        assert!(
15311            matches!(pointers.last(), Some(ShapeNode::Object { name, .. }) if name == "author"),
15312            "got {pointers:?}"
15313        );
15314    }
15315
15316    #[test]
15317    fn test_a_path_through_an_exclusive_backlink_is_single() {
15318        let out = compile_and_emit_with(
15319            "SELECT Post { title, author_name := .<posts[is Person].name }",
15320            &schema_with_exclusive_posts(),
15321        );
15322        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
15323            panic!("expected an object root, got {:?}", out.shape.root)
15324        };
15325        assert!(
15326            matches!(pointers.last(), Some(ShapeNode::Scalar { name, .. }) if name == "author_name"),
15327            "got {pointers:?}"
15328        );
15329        assert!(!out.sql.contains("ARRAY("), "got:\n{}", out.sql);
15330    }
15331
15332    #[test]
15333    fn test_structural_tuple_cast_named_resolves_to_jsonb() {
15334        let out = compile_and_emit("SELECT <tuple<x: float64, y: float64>>$p");
15335        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15336    }
15337
15338    #[test]
15339    fn test_structural_tuple_cast_nested_resolves_to_jsonb() {
15340        let out = compile_and_emit("SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>$p");
15341        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15342    }
15343
15344    #[test]
15345    fn test_nominal_named_tuple_cast_resolves_to_jsonb() {
15346        let mut schema = make_schema();
15347        schema.named_tuples.push(NamedTupleDescriptor {
15348            name: "Point".into(),
15349            module: "default".into(),
15350            members: vec![],
15351        });
15352        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
15353        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15354    }
15355
15356    #[test]
15357    fn test_array_literal_cast_resolves_to_native_pg_array_not_jsonb() {
15358        // The exact query reported as failing — must compile now, and must
15359        // resolve to a real Postgres array (text[]), never jsonb (arrays
15360        // decode natively, unlike tuples).
15361        let out = compile_and_emit("SELECT <array<str>>['foo', 'bar']");
15362        assert!(
15363            out.sql.contains("::text[]") || out.sql.contains("ARRAY["),
15364            "got:\n{}",
15365            out.sql
15366        );
15367        assert!(
15368            !out.sql.contains("jsonb"),
15369            "arrays must not use jsonb, got:\n{}",
15370            out.sql
15371        );
15372    }
15373
15374    #[test]
15375    fn test_array_literal_cast_applies_per_element_cast() {
15376        // Each element gets its own real cast, not a raw untyped ARRAY[...] —
15377        // '1' and '2' must actually coerce to int8 (mirrors the equivalent
15378        // tuple test).
15379        let out = compile_and_emit("SELECT <array<int64>>['1', '2']");
15380        assert!(out.sql.contains("ARRAY[('1')::int8, ('2')::int8]"), "got:\n{}", out.sql);
15381    }
15382
15383    #[test]
15384    fn test_array_param_cast_uses_direct_suffix_cast() {
15385        let out = compile_and_emit("SELECT <array<int64>>$p");
15386        assert!(out.sql.contains("::int8[]"), "got:\n{}", out.sql);
15387    }
15388
15389    #[test]
15390    fn test_array_of_named_tuple_element_casts_to_jsonb_array() {
15391        // An array's element type can be anything except another array —
15392        // including a structural tuple, which still resolves that one
15393        // element to jsonb while the array itself stays a native pg array.
15394        let out = compile_and_emit("SELECT <array<tuple<x: float64, y: float64>>>$p");
15395        assert!(out.sql.contains("::jsonb[]"), "got:\n{}", out.sql);
15396    }
15397
15398    #[test]
15399    fn test_contains_on_array_literal_cast_uses_array_overload_not_strpos() {
15400        // Regression: contains()'s array<Any> overload only matched a bare
15401        // IrExpr::Array node, never a TypeCast-wrapped one — which is what
15402        // every `<array<T>>[...]` cast actually compiles to. Overload
15403        // resolution silently fell back to the *first* registered `contains`
15404        // overload (str, str), emitting a bogus `strpos(text[], text)` call.
15405        let out = compile_and_emit("SELECT contains(<array<str>>[1, 2], '2')");
15406        assert!(out.sql.contains("@> ARRAY["), "got:\n{}", out.sql);
15407        assert!(
15408            !out.sql.contains("strpos"),
15409            "must not fall back to the str/str overload, got:\n{}",
15410            out.sql
15411        );
15412    }
15413
15414    #[test]
15415    fn test_nested_array_type_rejected_at_parse_time() {
15416        match parse::parse("SELECT <array<array<str>>>$p") {
15417            Ok(_) => panic!("expected parse error for nested array type"),
15418            Err(e) => assert!(e.to_string().contains("nested arrays are not supported"), "got: {}", e),
15419        }
15420    }
15421
15422    #[test]
15423    fn test_array_cast_in_computed_shape_field_schema_bound_context() {
15424        // Schema-bound counterpart (compile_expr, not compile_free_expr) — a
15425        // computed shape field casting an array literal.
15426        let out = compile_and_emit("SELECT Person { name, tags := <array<str>>['a', 'b'] }");
15427        assert!(out.sql.contains("ARRAY[('a')::text, ('b')::text]"), "got:\n{}", out.sql);
15428    }
15429
15430    #[test]
15431    fn test_nominal_named_tuple_cast_shape_carries_real_members() {
15432        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15433        let mut schema = make_schema();
15434        schema.named_tuples.push(NamedTupleDescriptor {
15435            name: "Point".into(),
15436            module: "default".into(),
15437            members: vec![
15438                TupleMemberDescriptor {
15439                    name: Some("x".into()),
15440                    kind: TupleMemberKind::Scalar {
15441                        pg_type: "float8".into(),
15442                    },
15443                },
15444                TupleMemberDescriptor {
15445                    name: Some("y".into()),
15446                    kind: TupleMemberKind::Scalar {
15447                        pg_type: "float8".into(),
15448                    },
15449                },
15450            ],
15451        });
15452        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
15453        match &out.shape.root {
15454            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
15455                assert_eq!(type_name.as_deref(), Some("default::Point"));
15456                let members = members.as_ref().expect("expected resolved members");
15457                assert_eq!(members.len(), 2);
15458                assert_eq!(members[0].key.as_deref(), Some("x"));
15459                assert_eq!(members[1].key.as_deref(), Some("y"));
15460            }
15461            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
15462        }
15463    }
15464
15465    #[test]
15466    fn test_structural_tuple_property_read_shape_carries_real_members() {
15467        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15468        let schema = SchemaDescriptor {
15469            types: vec![TypeDescriptor {
15470                name: "Person".into(),
15471                module: "default".into(),
15472                table: "Person".into(),
15473                abstract_: false,
15474                materialized: false,
15475                description: None,
15476                parents: vec![],
15477                interfaces: vec![],
15478                bases: vec![],
15479                properties: vec![PropertyDescriptor {
15480                    name: "address".into(),
15481                    pg_type: "jsonb".into(),
15482                    nullable: true,
15483                    default_sql: None,
15484                    default_pyql: None,
15485                    description: None,
15486                    check_constraints: vec![],
15487                    is_exclusive: false,
15488                    is_pk: false,
15489                    is_readonly: false,
15490                    rewrites: vec![],
15491                    tuple_members: Some(vec![
15492                        TupleMemberDescriptor {
15493                            name: Some("street".into()),
15494                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15495                        },
15496                        TupleMemberDescriptor {
15497                            name: Some("zip".into()),
15498                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15499                        },
15500                    ]),
15501                    column_type: None,
15502                }],
15503                links: vec![],
15504                multilinks: vec![],
15505                computed: vec![],
15506                constraints: vec![],
15507                indexes: vec![],
15508                partition: None,
15509                vector_indexes: vec![],
15510                search_indexes: vec![],
15511                triggers: vec![],
15512                junction: false,
15513                signals: vec![],
15514            }],
15515            scalars: vec![],
15516            enums: vec![],
15517            named_tuples: vec![],
15518            globals: vec![],
15519            functions: vec![],
15520            aliases: vec![],
15521            channels: vec![],
15522            ..Default::default()
15523        };
15524        let out = compile_and_emit_with("SELECT Person { address }", &schema);
15525        assert!(out.sql.contains("::jsonb"), "got:\n{}", out.sql);
15526        match &out.shape.root {
15527            crate::query::ShapeNode::Object { pointers, .. } => {
15528                let address = pointers
15529                    .iter()
15530                    .find(|p| matches!(p, crate::query::ShapeNode::NamedTuple { name, .. } if name == "address"))
15531                    .expect("expected address pointer in shape");
15532                match address {
15533                    crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
15534                        assert_eq!(*type_name, None);
15535                        let members = members.as_ref().expect("expected resolved members");
15536                        assert_eq!(members.len(), 2);
15537                        assert_eq!(members[0].key.as_deref(), Some("street"));
15538                        assert_eq!(members[1].key.as_deref(), Some("zip"));
15539                    }
15540                    other => panic!("expected NamedTuple, got {other:?}"),
15541                }
15542            }
15543            other => panic!("expected ShapeNode::Object, got {other:?}"),
15544        }
15545    }
15546
15547    #[test]
15548    fn test_bare_path_select_structural_tuple_property_shape_carries_real_members() {
15549        // A bare `select Type.property` path traversal (no `{ }` shape) used
15550        // to fall back to `ShapeNode::Scalar` for a *structural* tuple
15551        // property — `emit_path_select`'s IrPathResult::Scalar branch only
15552        // detected a *nominal* named tuple (via the "__nt__:" pg_type
15553        // marker), never consulting the property's own `tuple_members`. This
15554        // decoded every row as an opaque blob instead of a proper tuple
15555        // literal (reported as `select default::Person.address` rendering
15556        // wrong in both pylon-ui and the CLI REPL, while a `{ address }`
15557        // shape query — the test above — already worked).
15558        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15559        let schema = SchemaDescriptor {
15560            types: vec![TypeDescriptor {
15561                name: "Person".into(),
15562                module: "default".into(),
15563                table: "Person".into(),
15564                abstract_: false,
15565                materialized: false,
15566                description: None,
15567                parents: vec![],
15568                interfaces: vec![],
15569                bases: vec![],
15570                properties: vec![PropertyDescriptor {
15571                    name: "address".into(),
15572                    pg_type: "jsonb".into(),
15573                    nullable: true,
15574                    default_sql: None,
15575                    default_pyql: None,
15576                    description: None,
15577                    check_constraints: vec![],
15578                    is_exclusive: false,
15579                    is_pk: false,
15580                    is_readonly: false,
15581                    rewrites: vec![],
15582                    tuple_members: Some(vec![
15583                        TupleMemberDescriptor {
15584                            name: Some("street".into()),
15585                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15586                        },
15587                        TupleMemberDescriptor {
15588                            name: Some("zip".into()),
15589                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15590                        },
15591                    ]),
15592                    column_type: None,
15593                }],
15594                links: vec![],
15595                multilinks: vec![],
15596                computed: vec![],
15597                constraints: vec![],
15598                indexes: vec![],
15599                partition: None,
15600                vector_indexes: vec![],
15601                search_indexes: vec![],
15602                triggers: vec![],
15603                junction: false,
15604                signals: vec![],
15605            }],
15606            scalars: vec![],
15607            enums: vec![],
15608            named_tuples: vec![],
15609            globals: vec![],
15610            functions: vec![],
15611            aliases: vec![],
15612            channels: vec![],
15613            ..Default::default()
15614        };
15615        let out = compile_and_emit_with("SELECT Person.address", &schema);
15616        match &out.shape.root {
15617            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
15618                assert_eq!(*type_name, None);
15619                let members = members.as_ref().expect("expected resolved members");
15620                assert_eq!(members.len(), 2);
15621                assert_eq!(members[0].key.as_deref(), Some("street"));
15622                assert_eq!(members[1].key.as_deref(), Some("zip"));
15623            }
15624            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
15625        }
15626    }
15627
15628    #[test]
15629    fn test_path_traversal_into_structural_tuple_property() {
15630        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15631        let schema = SchemaDescriptor {
15632            types: vec![TypeDescriptor {
15633                name: "Person".into(),
15634                module: "default".into(),
15635                table: "Person".into(),
15636                abstract_: false,
15637                materialized: false,
15638                description: None,
15639                parents: vec![],
15640                interfaces: vec![],
15641                bases: vec![],
15642                properties: vec![PropertyDescriptor {
15643                    name: "address".into(),
15644                    pg_type: "jsonb".into(),
15645                    nullable: true,
15646                    default_sql: None,
15647                    default_pyql: None,
15648                    description: None,
15649                    check_constraints: vec![],
15650                    is_exclusive: false,
15651                    is_pk: false,
15652                    is_readonly: false,
15653                    rewrites: vec![],
15654                    tuple_members: Some(vec![
15655                        TupleMemberDescriptor {
15656                            name: Some("street".into()),
15657                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15658                        },
15659                        TupleMemberDescriptor {
15660                            name: Some("zip".into()),
15661                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15662                        },
15663                    ]),
15664                    column_type: None,
15665                }],
15666                links: vec![],
15667                multilinks: vec![],
15668                computed: vec![],
15669                constraints: vec![],
15670                indexes: vec![],
15671                partition: None,
15672                vector_indexes: vec![],
15673                search_indexes: vec![],
15674                triggers: vec![],
15675                junction: false,
15676                signals: vec![],
15677            }],
15678            scalars: vec![],
15679            enums: vec![],
15680            named_tuples: vec![],
15681            globals: vec![],
15682            functions: vec![],
15683            aliases: vec![],
15684            channels: vec![],
15685            ..Default::default()
15686        };
15687        // Regression: a structural (unnamed) tuple property previously failed
15688        // path traversal with "'address' is a scalar property, not a link —
15689        // cannot traverse further" because compile_path_select only allowed
15690        // further traversal for the nominal `__nt__:` named-tuple marker,
15691        // ignoring `tuple_members` on plain jsonb-typed properties.
15692        let out = compile_and_emit_with("SELECT default::Person.address.street", &schema);
15693        assert!(out.sql.contains("\"address\"->'street'"), "got:\n{}", out.sql);
15694    }
15695
15696    /// A `Person` with one `array<tuple<street: str, zip: str>>` property —
15697    /// a `jsonb[]` column carrying the *element's* members (see
15698    /// `PropertyDescriptor::tuple_members`).
15699    fn make_schema_with_an_array_of_tuples_property() -> SchemaDescriptor {
15700        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15701        SchemaDescriptor {
15702            types: vec![TypeDescriptor {
15703                name: "Person".into(),
15704                module: "default".into(),
15705                table: "Person".into(),
15706                abstract_: false,
15707                materialized: false,
15708                description: None,
15709                parents: vec![],
15710                interfaces: vec![],
15711                bases: vec![],
15712                properties: vec![PropertyDescriptor {
15713                    name: "addresses".into(),
15714                    pg_type: "jsonb[]".into(),
15715                    nullable: true,
15716                    default_sql: None,
15717                    default_pyql: None,
15718                    description: None,
15719                    check_constraints: vec![],
15720                    is_exclusive: false,
15721                    is_pk: false,
15722                    is_readonly: false,
15723                    rewrites: vec![],
15724                    tuple_members: Some(vec![
15725                        TupleMemberDescriptor {
15726                            name: Some("street".into()),
15727                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15728                        },
15729                        TupleMemberDescriptor {
15730                            name: Some("zip".into()),
15731                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15732                        },
15733                    ]),
15734                    column_type: None,
15735                }],
15736                links: vec![],
15737                multilinks: vec![],
15738                computed: vec![],
15739                constraints: vec![],
15740                indexes: vec![],
15741                partition: None,
15742                vector_indexes: vec![],
15743                search_indexes: vec![],
15744                triggers: vec![],
15745                junction: false,
15746                signals: vec![],
15747            }],
15748            scalars: vec![],
15749            enums: vec![],
15750            named_tuples: vec![],
15751            globals: vec![],
15752            functions: vec![],
15753            aliases: vec![],
15754            channels: vec![],
15755            ..Default::default()
15756        }
15757    }
15758
15759    #[test]
15760    fn test_array_of_tuples_property_read_shape_is_an_array_of_named_tuples() {
15761        // Regression: an `array<tuple<…>>` property described itself as a
15762        // plain `Scalar`, so each element stayed the raw decoded jsonb dict
15763        // instead of hydrating into a named-tuple value with its members
15764        // reachable by name (reported as `headers[0].value` raising
15765        // "'dict' object has no attribute 'value'").
15766        let schema = make_schema_with_an_array_of_tuples_property();
15767        let out = compile_and_emit_with("SELECT Person { addresses }", &schema);
15768        assert!(out.sql.contains("::jsonb[]"), "got:\n{}", out.sql);
15769        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
15770            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
15771        };
15772        let addresses = pointers
15773            .iter()
15774            .find(|p| matches!(p, crate::query::ShapeNode::Array { name, .. } if name == "addresses"))
15775            .expect("expected addresses pointer in shape");
15776        let crate::query::ShapeNode::Array { element, .. } = addresses else {
15777            unreachable!("matched as an array just above")
15778        };
15779        match element.as_ref() {
15780            crate::query::ShapeNode::NamedTuple {
15781                type_name,
15782                members,
15783                position,
15784                ..
15785            } => {
15786                assert_eq!(*type_name, None);
15787                assert_eq!(*position, 0);
15788                let members = members.as_ref().expect("expected resolved members");
15789                assert_eq!(members.len(), 2);
15790                assert_eq!(members[0].key.as_deref(), Some("street"));
15791                assert_eq!(members[1].key.as_deref(), Some("zip"));
15792            }
15793            other => panic!("expected NamedTuple element, got {other:?}"),
15794        }
15795    }
15796
15797    #[test]
15798    fn test_a_path_to_an_optional_property_drops_the_rows_without_one() {
15799        // `select Webhook.headers` over two webhooks, one of them without
15800        // headers, is one result — the row that has none reaches no value,
15801        // rather than contributing a NULL of its own.
15802        let out = compile_and_emit("SELECT Person.age");
15803        assert!(out.sql.contains(r#"("t0"."age" IS NOT NULL)"#), "got:\n{}", out.sql);
15804    }
15805
15806    #[test]
15807    fn test_a_path_to_a_required_property_needs_no_guard() {
15808        // A required column is never NULL, so the condition could only ever
15809        // be true — every path select would carry it for nothing.
15810        let out = compile_and_emit("SELECT Person.name");
15811        assert!(!out.sql.contains("IS NOT NULL"), "got:\n{}", out.sql);
15812    }
15813
15814    #[test]
15815    fn test_a_path_to_an_optional_property_keeps_its_own_filter_too() {
15816        let out = compile_and_emit("SELECT Person.age FILTER Person.name = $n");
15817        assert!(
15818            out.sql
15819                .contains(r#"WHERE (("t0"."name" = $1) AND ("t0"."age" IS NOT NULL))"#),
15820            "got:\n{}",
15821            out.sql
15822        );
15823    }
15824
15825    #[test]
15826    fn test_a_set_of_array_valued_properties_aggregates_as_records() {
15827        // PostgreSQL has no array of arrays, so accumulating one row's
15828        // `text[]` beside another's failed outright ("cannot accumulate
15829        // arrays of different dimensionality"). Each element travels as a
15830        // record of one field instead, the way a set of objects does.
15831        let out = compile_and_emit_with(
15832            "SELECT Company { permissions := .<company[is Person].perms }",
15833            &schema_with_array_property(),
15834        );
15835        assert!(out.sql.contains(r#"ROW("t2"."perms")"#), "got:\n{}", out.sql);
15836        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
15837            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
15838        };
15839        let permissions = pointers
15840            .iter()
15841            .find(|p| matches!(p, crate::query::ShapeNode::Array { name, .. } if name == "permissions"))
15842            .expect("expected the permissions pointer");
15843        let crate::query::ShapeNode::Array { element, .. } = permissions else {
15844            unreachable!("matched as an array just above")
15845        };
15846        // One element is the array itself, at position 0 of its record.
15847        match element.as_ref() {
15848            crate::query::ShapeNode::Scalar { position, .. } => assert_eq!(*position, 0),
15849            other => panic!("expected the array at position 0 of its record, got {other:?}"),
15850        }
15851    }
15852
15853    #[test]
15854    fn test_a_set_of_array_of_tuples_properties_keeps_the_member_shape() {
15855        let mut schema = make_schema_with_an_array_of_tuples_property();
15856        // The same `Person`, reached from a `Company` it links to.
15857        schema.types.push(TypeDescriptor {
15858            name: "Company".into(),
15859            module: "default".into(),
15860            table: "Company".into(),
15861            abstract_: false,
15862            materialized: false,
15863            description: None,
15864            parents: vec![],
15865            interfaces: vec![],
15866            bases: vec![],
15867            properties: vec![],
15868            links: vec![],
15869            multilinks: vec![],
15870            computed: vec![],
15871            constraints: vec![],
15872            indexes: vec![],
15873            partition: None,
15874            vector_indexes: vec![],
15875            search_indexes: vec![],
15876            triggers: vec![],
15877            junction: false,
15878            signals: vec![],
15879        });
15880        schema.types[0].links.push(crate::schema::LinkDescriptor {
15881            name: "company".into(),
15882            target: "default::Company".into(),
15883            nullable: true,
15884            description: None,
15885            default_pyql: None,
15886            is_exclusive: false,
15887            is_readonly: false,
15888            rewrites: vec![],
15889            on_delete: vec![],
15890            through: None,
15891        });
15892        let out = compile_and_emit_with(
15893            "SELECT Company { addresses := .<company[is Person].addresses }",
15894            &schema,
15895        );
15896        assert!(out.sql.contains(r#"ROW("t2"."addresses")"#), "got:\n{}", out.sql);
15897        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
15898            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
15899        };
15900        let addresses = pointers
15901            .iter()
15902            .find(|p| matches!(p, crate::query::ShapeNode::Array { name, .. } if name == "addresses"))
15903            .expect("expected the addresses pointer");
15904        let crate::query::ShapeNode::Array { element, .. } = addresses else {
15905            unreachable!("matched as an array just above")
15906        };
15907        // Each element is itself an array, of the named tuples the property
15908        // declares — not the opaque jsonb a flattening would hand back.
15909        match element.as_ref() {
15910            crate::query::ShapeNode::Array { element, position, .. } => {
15911                assert_eq!(*position, 0);
15912                assert!(
15913                    matches!(
15914                        element.as_ref(),
15915                        crate::query::ShapeNode::NamedTuple { members: Some(_), .. }
15916                    ),
15917                    "expected named-tuple elements, got {element:?}"
15918                );
15919            }
15920            other => panic!("expected an array per element, got {other:?}"),
15921        }
15922    }
15923
15924    #[test]
15925    fn test_an_optional_property_aggregated_into_an_array_drops_its_empties() {
15926        // The same rule inside an array: a NULL element is not an empty set,
15927        // and for an array-typed property Postgres refuses it outright
15928        // ("cannot accumulate null arrays").
15929        let out = compile_and_emit("SELECT Company { ages := .<company[is Person].age }");
15930        assert!(out.sql.contains(r#"("t2"."age" IS NOT NULL)"#), "got:\n{}", out.sql);
15931    }
15932
15933    #[test]
15934    fn test_bare_path_select_array_of_tuples_property_shape() {
15935        // Unlike a single tuple property, this one keeps the ROW() wrapper:
15936        // the array is read at a position inside the row, and only its
15937        // elements are jsonb tuples.
15938        let schema = make_schema_with_an_array_of_tuples_property();
15939        let out = compile_and_emit_with("SELECT Person.addresses", &schema);
15940        assert!(out.sql.contains("ROW("), "got:\n{}", out.sql);
15941        match &out.shape.root {
15942            crate::query::ShapeNode::Array { element, position, .. } => {
15943                assert_eq!(*position, 0);
15944                match element.as_ref() {
15945                    crate::query::ShapeNode::NamedTuple { members, .. } => {
15946                        let members = members.as_ref().expect("expected resolved members");
15947                        assert_eq!(members.len(), 2);
15948                        assert_eq!(members[0].key.as_deref(), Some("street"));
15949                    }
15950                    other => panic!("expected NamedTuple element, got {other:?}"),
15951                }
15952            }
15953            other => panic!("expected ShapeNode::Array, got {other:?}"),
15954        }
15955    }
15956
15957    #[test]
15958    fn test_a_with_bound_path_select_keeps_the_shape_the_path_itself_has() {
15959        // `with entries := Person.addresses select entries` reads the CTE's
15960        // own result column, so it has to decode as the path select does —
15961        // it used to fall back to a plain scalar, handing back raw jsonb.
15962        let schema = make_schema_with_an_array_of_tuples_property();
15963        let out = compile_and_emit_with("WITH entries := Person.addresses SELECT entries", &schema);
15964        match &out.shape.root {
15965            crate::query::ShapeNode::Array { element, .. } => assert!(
15966                matches!(
15967                    element.as_ref(),
15968                    crate::query::ShapeNode::NamedTuple { members: Some(_), .. }
15969                ),
15970                "expected a named-tuple element, got {element:?}"
15971            ),
15972            other => panic!("expected ShapeNode::Array, got {other:?}"),
15973        }
15974    }
15975
15976    #[test]
15977    fn test_path_traversal_into_array_of_tuples_property_rejected() {
15978        // A single tuple property allows `.address.street`; an array of them
15979        // has no one tuple to read a field out of.
15980        let schema = make_schema_with_an_array_of_tuples_property();
15981        let ast = parse::parse("SELECT default::Person.addresses.street").unwrap();
15982        match ir::compile(&ast, &schema) {
15983            Ok(_) => panic!("expected error for a field access on an array of tuples"),
15984            Err(e) => assert!(
15985                e.to_string()
15986                    .contains("'addresses' is a scalar property, not a link — cannot traverse further"),
15987                "got: {}",
15988                e
15989            ),
15990        }
15991    }
15992
15993    #[test]
15994    fn test_a_tuple_literal_carries_the_members_it_names() {
15995        // `(amount := 9.99, note := 'x')` says what its members are called,
15996        // but the shape used to carry none — so the value hydrated as a
15997        // plain mapping rather than a named tuple, and the web UI, which
15998        // renders `(key := value, …)` from this list, showed `()`.
15999        let out = compile_and_emit("SELECT (amount := 9.99, note := 'x')");
16000        match &out.shape.root {
16001            crate::query::ShapeNode::NamedTuple { members, .. } => {
16002                let members = members.as_ref().expect("a literal names its own members");
16003                let keys: Vec<_> = members.iter().map(|m| m.key.as_deref()).collect();
16004                assert_eq!(keys, vec![Some("amount"), Some("note")]);
16005            }
16006            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
16007        }
16008    }
16009
16010    #[test]
16011    fn test_a_tuple_literal_member_that_is_itself_a_tuple_recurses() {
16012        let out = compile_and_emit("SELECT (origin := (x := 1, y := 2), label := 'a')");
16013        let crate::query::ShapeNode::NamedTuple { members, .. } = &out.shape.root else {
16014            panic!("expected ShapeNode::NamedTuple, got {:?}", out.shape.root)
16015        };
16016        let members = members.as_ref().expect("a literal names its own members");
16017        match &members[0].kind {
16018            crate::query::JsonMemberKind::Tuple { members, .. } => {
16019                let keys: Vec<_> = members.iter().map(|m| m.key.as_deref()).collect();
16020                assert_eq!(keys, vec![Some("x"), Some("y")]);
16021            }
16022            other => panic!("expected a nested tuple member, got {other:?}"),
16023        }
16024    }
16025
16026    #[test]
16027    fn test_a_tuple_literal_member_holding_an_enum_keeps_its_type() {
16028        // `make_schema` declares no enums of its own.
16029        let mut schema = make_schema();
16030        schema.enums.push(crate::schema::EnumDescriptor {
16031            name: "Gender".into(),
16032            module: "default".into(),
16033            members: vec!["Male".into(), "Female".into()],
16034        });
16035        let out = compile_and_emit_with("SELECT (shade := default::Gender.Male, label := 'a')", &schema);
16036        let crate::query::ShapeNode::NamedTuple { members, .. } = &out.shape.root else {
16037            panic!("expected ShapeNode::NamedTuple, got {:?}", out.shape.root)
16038        };
16039        let members = members.as_ref().expect("a literal names its own members");
16040        match &members[0].kind {
16041            // Pylon-qualified, the way a member's enum type always is.
16042            crate::query::JsonMemberKind::Enum { enum_type } => assert_eq!(enum_type, "default::Gender"),
16043            other => panic!("expected an enum member, got {other:?}"),
16044        }
16045    }
16046
16047    #[test]
16048    fn test_a_free_object_literal_carries_no_member_plan() {
16049        // `{ foo := 'bar' }` as a computed compiles to the same node a tuple
16050        // literal does, but it is not a tuple to its reader: a member plan
16051        // would hydrate it as a named-tuple value instead of the mapping it
16052        // reads as.
16053        let out = compile_and_emit("SELECT Person { meta := { foo := 'bar' } }");
16054        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
16055            panic!("expected ShapeNode::Object, got {:?}", out.shape.root)
16056        };
16057        let meta = pointers
16058            .iter()
16059            .find(|p| matches!(p, crate::query::ShapeNode::NamedTuple { name, .. } if name == "meta"))
16060            .expect("expected the meta pointer");
16061        match meta {
16062            crate::query::ShapeNode::NamedTuple {
16063                members,
16064                is_free_object,
16065                ..
16066            } => {
16067                assert!(is_free_object);
16068                assert!(members.is_none(), "got {members:?}");
16069            }
16070            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
16071        }
16072    }
16073
16074    #[test]
16075    fn test_structural_tuple_cast_shape_carries_real_members() {
16076        let out = compile_and_emit("SELECT <tuple<street: str, zip: str>>$p");
16077        match &out.shape.root {
16078            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
16079                assert_eq!(*type_name, None);
16080                let members = members.as_ref().expect("expected resolved members");
16081                assert_eq!(members.len(), 2);
16082                assert_eq!(members[0].key.as_deref(), Some("street"));
16083                assert_eq!(members[1].key.as_deref(), Some("zip"));
16084            }
16085            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
16086        }
16087    }
16088
16089    #[test]
16090    fn test_array_of_tuples_cast_shape_is_an_array_of_named_tuples() {
16091        // The cast target's members describe one *element* of the array, so
16092        // the value decodes as named tuples rather than as the raw jsonb each
16093        // slot of the `jsonb[]` holds.
16094        let out = compile_and_emit("SELECT <array<tuple<street: str, zip: str>>>$p");
16095        assert!(out.sql.contains("::jsonb[]"), "got:\n{}", out.sql);
16096        match &out.shape.root {
16097            crate::query::ShapeNode::Array { element, position, .. } => {
16098                assert_eq!(*position, 0);
16099                match element.as_ref() {
16100                    crate::query::ShapeNode::NamedTuple { members, position, .. } => {
16101                        assert_eq!(*position, 0);
16102                        let members = members.as_ref().expect("expected resolved members");
16103                        assert_eq!(members.len(), 2);
16104                        assert_eq!(members[0].key.as_deref(), Some("street"));
16105                        assert_eq!(members[1].key.as_deref(), Some("zip"));
16106                    }
16107                    other => panic!("expected NamedTuple element, got {other:?}"),
16108                }
16109            }
16110            other => panic!("expected ShapeNode::Array, got {other:?}"),
16111        }
16112    }
16113
16114    #[test]
16115    fn test_tuple_cast_mixed_named_and_unnamed_elements_rejected() {
16116        match parse::parse("SELECT <tuple<x: float64, bool>>$p") {
16117            Ok(_) => panic!("expected parse error for mixed named/unnamed tuple elements"),
16118            Err(e) => assert!(e.to_string().contains("all named or all unnamed"), "got: {}", e),
16119        }
16120    }
16121
16122    #[test]
16123    fn test_is_with_tuple_type_rejected() {
16124        let schema = make_schema();
16125        let ast = parse::parse("SELECT Person FILTER Person is tuple<x: float64, y: float64>").unwrap();
16126        match ir::compile(&ast, &schema) {
16127            Ok(_) => panic!("expected error for IS with a tuple type"),
16128            Err(e) => {
16129                assert!(
16130                    e.to_string().contains("cannot use IS with a tuple or array type"),
16131                    "got: {}",
16132                    e
16133                );
16134            }
16135        }
16136    }
16137
16138    #[test]
16139    fn test_is_with_array_type_rejected() {
16140        let schema = make_schema();
16141        let ast = parse::parse("SELECT Person FILTER Person is array<str>").unwrap();
16142        match ir::compile(&ast, &schema) {
16143            Ok(_) => panic!("expected error for IS with an array type"),
16144            Err(e) => {
16145                assert!(
16146                    e.to_string().contains("cannot use IS with a tuple or array type"),
16147                    "got: {}",
16148                    e
16149                );
16150            }
16151        }
16152    }
16153
16154    #[test]
16155    fn test_tuple_index_on_non_literal_falls_back_to_jsonb_index() {
16156        // `.1` only constant-folds against a literal tuple; anything else
16157        // (a $param, a cast result, …) needs a genuine runtime jsonb index
16158        // instead of erroring "only supported on tuple literals".
16159        let out = compile_and_emit("SELECT (<tuple<int64, str>>('1', 3)).1");
16160        assert!(out.sql.contains("->1"), "got:\n{}", out.sql);
16161    }
16162
16163    #[test]
16164    fn test_tuple_index_out_of_bounds_on_cast_target_errors_at_compile_time() {
16165        // Regression: `.2` on a 2-element tuple cast silently fell back to a
16166        // runtime jsonb index (returning null) instead of failing, because
16167        // the source is a TypeCast, not a literal Tuple/NamedTuple. The cast's
16168        // target type is statically known here, so this must bounds-check
16169        // and error instead.
16170        let ast = parse::parse("SELECT (<tuple<int64, str>>('1', 3)).2").unwrap();
16171        let schema = make_schema();
16172        match ir::compile(&ast, &schema) {
16173            Ok(_) => panic!("expected out-of-bounds tuple index error"),
16174            Err(e) => {
16175                assert!(
16176                    e.to_string()
16177                        .contains("2 is not a member of tuple<std::int64, std::str>"),
16178                    "got: {}",
16179                    e
16180                );
16181            }
16182        }
16183    }
16184
16185    #[test]
16186    fn test_positional_tuple_literal_cast_to_tuple_type_compiles() {
16187        // Each element must be cast to its own declared type — '1' isn't
16188        // silently jsonb-wrapped unchanged as a string; it's coerced to
16189        // int8.
16190        let out = compile_and_emit("SELECT <tuple<int64, str>>(1, 'x')");
16191        assert!(
16192            out.sql.contains("jsonb_build_array((1)::int8, ('x')::text)"),
16193            "got:\n{}",
16194            out.sql
16195        );
16196    }
16197
16198    #[test]
16199    fn test_positional_tuple_literal_cast_coerces_mismatched_literal_types() {
16200        // The exact case that was silently wrong: casting a string literal to
16201        // int64 and an int literal to str must actually coerce each one, not
16202        // just pass the raw literal through untouched.
16203        let out = compile_and_emit("SELECT <tuple<int64, str>>('1', 3)");
16204        assert!(
16205            out.sql.contains("jsonb_build_array(('1')::int8, (3)::text)"),
16206            "got:\n{}",
16207            out.sql
16208        );
16209    }
16210
16211    #[test]
16212    fn test_nested_tuple_literal_cast_applies_casts_recursively() {
16213        let out = compile_and_emit(
16214            "SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>(point := ('1', 2), label := 5)",
16215        );
16216        assert!(
16217            out.sql.contains(
16218                "jsonb_build_object('point', jsonb_build_object('x', ('1')::float8, 'y', (2)::float8), 'label', (5)::text)"
16219            ),
16220            "got:\n{}",
16221            out.sql
16222        );
16223    }
16224
16225    #[test]
16226    fn test_positional_tuple_literal_nested_inside_named_tuple_compiles() {
16227        let out = compile_and_emit("SELECT (point := (1, 2), label := 'origin')");
16228        assert!(out.sql.contains("jsonb_build_array(1, 2)"), "got:\n{}", out.sql);
16229        assert!(out.sql.contains("jsonb_build_object("), "got:\n{}", out.sql);
16230    }
16231
16232    #[test]
16233    fn test_positional_tuple_literal_in_schema_bound_shape_field_compiles() {
16234        let out = compile_and_emit("SELECT Person { name, pair := (1, 2) }");
16235        assert!(out.sql.contains("jsonb_build_array(1, 2)"), "got:\n{}", out.sql);
16236    }
16237
16238    // ── search index outbox enqueue tests ─────────────────────────────────────
16239    //
16240    // Regression coverage for a real bug: `collect_search_enqueue` used to
16241    // filter for `SearchBackend::OpenSearch` only, so a Meilisearch-backed
16242    // SearchIndex never got an outbox row on insert/update/delete — the
16243    // Meilisearch worker (Rust or the old Python one) never received real
16244    // traffic from normal DML, only from a manually-inserted outbox row.
16245
16246    fn make_schema_with_search_index(backend: crate::schema::SearchBackend) -> SchemaDescriptor {
16247        use crate::schema::{SearchIndexDescriptor, SearchPointerDescriptor, SearchWeight};
16248        let mut s = make_schema();
16249        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
16250            td.search_indexes.push(SearchIndexDescriptor {
16251                index_name: None,
16252                backend,
16253                pointers: vec![SearchPointerDescriptor {
16254                    name: "name".into(),
16255                    weight: SearchWeight::A,
16256                }],
16257            });
16258        }
16259        s
16260    }
16261
16262    #[test]
16263    fn test_insert_enqueues_a_meilisearch_outbox_row() {
16264        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
16265        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
16266        assert!(
16267            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
16268            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
16269            out.sql,
16270        );
16271        assert!(
16272            out.sql.contains("INSERT INTO _pylon.\"IndexOutbox\""),
16273            "got:\n{}",
16274            out.sql
16275        );
16276    }
16277
16278    #[test]
16279    fn test_insert_enqueues_an_opensearch_outbox_row() {
16280        let schema = make_schema_with_search_index(crate::schema::SearchBackend::OpenSearch);
16281        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
16282        assert!(
16283            out.sql.contains("'OpenSearch'::_pylon.\"IndexKind\""),
16284            "expected an OpenSearch outbox enqueue CTE, got:\n{}",
16285            out.sql,
16286        );
16287    }
16288
16289    #[test]
16290    fn test_insert_does_not_enqueue_an_outbox_row_for_a_postgres_backed_search_index() {
16291        // Postgres-backed search indexes are maintained synchronously by a
16292        // trigger-updated tsvector column — no async worker involved.
16293        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Postgres);
16294        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
16295        assert!(
16296            !out.sql.contains("_pylon.\"IndexOutbox\""),
16297            "did not expect an outbox enqueue, got:\n{}",
16298            out.sql
16299        );
16300    }
16301
16302    #[test]
16303    fn test_update_enqueues_a_meilisearch_outbox_row() {
16304        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
16305        let out = compile_and_emit_with("UPDATE Person FILTER .name = 'Alice' SET { age := 31 }", &schema);
16306        assert!(
16307            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
16308            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
16309            out.sql,
16310        );
16311    }
16312
16313    #[test]
16314    fn test_delete_enqueues_a_meilisearch_outbox_delete_job() {
16315        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
16316        let out = compile_and_emit_with("DELETE Person FILTER .name = 'Alice'", &schema);
16317        assert!(
16318            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
16319            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
16320            out.sql,
16321        );
16322        assert!(
16323            out.sql.contains("'delete'"),
16324            "expected the delete operation literal, got:\n{}",
16325            out.sql
16326        );
16327    }
16328
16329    #[test]
16330    fn test_range_intrinsic_resolves_int_literals_to_int8range() {
16331        // Regression: std::range's ImplStrategy::TranspilerIntrinsic had no
16332        // actual substitution anywhere — it fell through resolve_fn_call's
16333        // catch-all, which emitted a literal (nonexistent) `"std"."range"(...)`
16334        // call instead of a real PostgreSQL range constructor.
16335        let out = compile_and_emit("SELECT std::overlaps(std::range(1, 3), std::range(2, 5))");
16336        assert!(out.sql.contains("int8range(1, 3)"), "got:\n{}", out.sql);
16337        assert!(out.sql.contains("int8range(2, 5)"), "got:\n{}", out.sql);
16338        assert!(
16339            out.sql.contains(" && "),
16340            "expected infix && for overlaps, got:\n{}",
16341            out.sql
16342        );
16343        assert!(
16344            !out.sql.contains("\"std\""),
16345            "must not emit a literal std schema call, got:\n{}",
16346            out.sql
16347        );
16348    }
16349
16350    #[test]
16351    fn test_range_intrinsic_resolves_datetime_to_tstzrange() {
16352        let out =
16353            compile_and_emit("SELECT std::range(<datetime>'2024-01-01T00:00:00Z', <datetime>'2024-06-01T00:00:00Z')");
16354        assert!(out.sql.contains("tstzrange("), "got:\n{}", out.sql);
16355    }
16356
16357    /// Bounds written out as literals are folded into the constructor's own
16358    /// bounds string; only the default `[)` leaves it out entirely.
16359    #[test]
16360    fn test_range_intrinsic_named_bounds_become_a_bounds_string() {
16361        let out = compile_and_emit("SELECT std::range(1, 3, inc_lower := true, inc_upper := true)");
16362        assert!(out.sql.contains("int8range(1, 3, '[]')"), "got:\n{}", out.sql);
16363        let out = compile_and_emit("SELECT std::range(1, 3, inc_lower := false)");
16364        assert!(out.sql.contains("int8range(1, 3, '()')"), "got:\n{}", out.sql);
16365    }
16366
16367    /// A bound flag that is not a literal keeps the decision in the SQL.
16368    #[test]
16369    fn test_range_intrinsic_computes_a_bounds_string_from_an_expression() {
16370        let out = compile_and_emit("SELECT std::range(1, 3, inc_upper := <bool>$closed)");
16371        assert!(out.sql.contains("CASE WHEN"), "got:\n{}", out.sql);
16372    }
16373
16374    /// An open-ended range names only the bound it has.
16375    #[test]
16376    fn test_range_intrinsic_leaves_a_missing_upper_bound_unbounded() {
16377        let out = compile_and_emit("SELECT std::range(1)");
16378        assert!(out.sql.contains("int8range(1, NULL)"), "got:\n{}", out.sql);
16379    }
16380
16381    /// A call that writes a named-only argument by position, one that leaves a
16382    /// required one out, and one no overload accepts each say which.
16383    #[test]
16384    fn test_a_named_only_parameter_is_reported_as_named_only() {
16385        assert!(
16386            compile_err("SELECT std::range(1, 5, true, false)").contains("takes 'inc_lower' as a named argument only"),
16387            "{}",
16388            compile_err("SELECT std::range(1, 5, true, false)")
16389        );
16390        assert!(
16391            compile_err("SELECT std::json_set(std::to_json('{}'), 'a', std::to_json('1'))")
16392                .contains("requires the named argument 'value'"),
16393            "{}",
16394            compile_err("SELECT std::json_set(std::to_json('{}'), 'a', std::to_json('1'))")
16395        );
16396        // Counted by positions, not by the whole parameter list.
16397        assert!(
16398            compile_err("SELECT std::range()").contains("takes 1 or 2 argument(s), got 0"),
16399            "{}",
16400            compile_err("SELECT std::range()")
16401        );
16402        // And an overload's named-only parameters are spelled as such.
16403        let message = compile_err("SELECT std::json_get(std::to_json('{}'), 'a', 1, 2)");
16404        assert!(message.contains("default := optional<json>"), "{message}");
16405    }
16406
16407    #[test]
16408    fn test_range_intrinsic_builds_the_empty_range() {
16409        let out = compile_and_emit("SELECT std::range(1, 3, empty := true)");
16410        assert!(out.sql.contains("'empty'::int8range"), "got:\n{}", out.sql);
16411    }
16412
16413    #[test]
16414    fn test_multirange_intrinsic_resolves_from_range_element() {
16415        let out = compile_and_emit("SELECT std::multirange([std::range(1, 3), std::range(5, 7)])");
16416        assert!(out.sql.contains("int8multirange(VARIADIC "), "got:\n{}", out.sql);
16417        assert!(
16418            !out.sql.contains("\"std\""),
16419            "must not emit a literal std schema call, got:\n{}",
16420            out.sql
16421        );
16422    }
16423}