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(path_join_target)
450        .find(|target| target.alias == alias)
451}
452
453fn path_join_target(join: &IrPathJoin) -> &IrSource {
454    match join {
455        IrPathJoin::Single { target, .. }
456        | IrPathJoin::Multi { target, .. }
457        | IrPathJoin::BacklinkSingle { target, .. }
458        | IrPathJoin::BacklinkMulti { target, .. }
459        | IrPathJoin::Function { target, .. }
460        | IrPathJoin::Lateral { target, .. } => target,
461    }
462}
463
464/// The type discriminator for a row from `src`: the fanned-out column when the
465/// source reads from implementors, else the one type its table holds.
466fn source_type_disc(src: &IrSource) -> String {
467    match source_fanout(src) {
468        Some(_) => format!("{}.\"__type__\"", qi(&src.alias)),
469        None => type_disc(&src.type_name),
470    }
471}
472
473// ── Polymorphic UNION ALL ───────────────────────────────────────────────────
474
475fn emit_poly_union(implementors: &[IrPolyImplementor], columns: &[String]) -> String {
476    let col_list = columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
477    implementors
478        .iter()
479        .map(|imp| {
480            format!(
481                "    SELECT {}::text AS \"__type__\", {} FROM {}",
482                sql_str(&imp.type_name),
483                col_list,
484                qn(&imp.module, &imp.table),
485            )
486        })
487        .collect::<Vec<_>>()
488        .join("\n    UNION ALL\n")
489}
490
491// ── SELECT statement ────────────────────────────────────────────────────────
492
493fn emit_select_stmt(sel: &IrSelect, ctes: &[IrCteDef]) -> SqlOutput {
494    match sel.rows.as_slice() {
495        [IrRowSource::Bound { source, shape }] => emit_bound_select(sel, source, shape),
496        rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
497            emit_bound_union_select(sel, rows)
498        }
499        rows => emit_free_rows(sel, rows, ctes),
500    }
501}
502
503/// The branches of `select (a union b)` all carry the same object type, so
504/// they share a column set: union them in the FROM clause and project, filter
505/// and order the combined set once, under the shape's single alias.
506/// `common_columns`, when set, are the columns of the ancestor branches of
507/// different types share: each branch projects those and its rows' concrete
508/// type, rather than columns the others do not have.
509fn bound_union_from_clause(rows: &[IrRowSource], common_columns: &[String]) -> String {
510    let columns = common_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
511    rows.iter()
512        .map(|row| match row {
513            IrRowSource::Bound { source, .. } if !common_columns.is_empty() => {
514                let own_type = match source_fanout(source) {
515                    Some(_) => "\"__type__\"".to_string(),
516                    None => format!("{} AS \"__type__\"", type_disc(&source.type_name)),
517                };
518                format!("    SELECT {own_type}, {columns} FROM {}", source_ref(source))
519            }
520            IrRowSource::Bound { source, .. } => format!("    SELECT * FROM {}", source_ref(source)),
521            IrRowSource::Free(_) => unreachable!("caller checked every row is bound"),
522        })
523        .collect::<Vec<_>>()
524        .join("\n    UNION ALL\n")
525}
526
527fn emit_bound_union_select(sel: &IrSelect, rows: &[IrRowSource]) -> SqlOutput {
528    let IrRowSource::Bound { source, shape } = &rows[0] else {
529        unreachable!("caller checked every row is bound")
530    };
531    let alias = &source.alias;
532    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
533    let row_type = if sel.poly_columns.is_empty() {
534        type_disc(&source.type_name)
535    } else {
536        format!("{}.\"__type__\"", qi(alias))
537    };
538    let mut parts = vec![row_type];
539    parts.extend(pointer_exprs);
540
541    let mut sql = format!(
542        "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
543        if sel.distinct { "DISTINCT " } else { "" },
544        parts.join(",\n    "),
545        bound_union_from_clause(rows, &sel.poly_columns),
546        qi(alias),
547    );
548    append_filter(&mut sql, &sel.filter);
549    append_order_by(&mut sql, &sel.order_by);
550    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
551
552    SqlOutput {
553        sql,
554        shape: ShapeDescriptor {
555            root: ShapeNode::Object {
556                name: String::new(),
557                type_name: Some(source.type_name.clone()),
558                position: 0,
559                cardinality: Cardinality::Many,
560                pointers: prepend_type(shape_pointers),
561                has_implicit_id: shape_has_implicit_id(shape),
562            },
563        },
564        inference_plan: None,
565    }
566}
567
568/// Schema-bound SELECT — has a FROM clause, projected column shape, and
569/// (optionally) a DML source / polymorphic fan-out. `source`/`shape` are the
570/// single `IrRowSource::Bound` entry destructured by `emit_select_stmt`;
571/// everything else (filter/order_by/offset/limit/distinct/dml_source/
572/// polymorphic/poly_*) stays on the outer `IrSelect`.
573fn emit_bound_select(sel: &IrSelect, source: &IrSource, shape: &[IrShapePointer]) -> SqlOutput {
574    let alias = &source.alias;
575    let (pointer_exprs, shape_pointers) = build_shape(shape, alias);
576
577    let type_expr = if sel.polymorphic {
578        format!("{}.\"__type__\"", qi(alias))
579    } else {
580        type_disc(&source.type_name)
581    };
582    let mut parts = vec![type_expr];
583    parts.extend(pointer_exprs);
584    let tuple = parts.join(",\n    ");
585
586    let distinct = if sel.distinct { "DISTINCT " } else { "" };
587
588    // SELECT-over-DML: wrap inner statement in a CTE, select from it.
589    let from_clause = if let Some(dml) = &sel.dml_source {
590        let mut cte_parts = match dml.as_ref() {
591            IrStmt::Update(upd) if update_has_any_multilink(upd) => emit_update_multilink_ctes(upd, DML_CTE),
592            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => emit_insert_multilink_ctes(ins, DML_CTE),
593            // Nested DML the row's values read from: hoisted ahead of the row
594            // itself, since a data-modifying WITH only works at the top level.
595            IrStmt::Insert(ins) if !ins.nested_ctes.is_empty() => {
596                let mut parts = emit_user_cte_parts(&ins.nested_ctes);
597                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
598                parts
599            }
600            IrStmt::Update(upd) if !upd.nested_ctes.is_empty() => {
601                let mut parts = emit_user_cte_parts(&upd.nested_ctes);
602                parts.push(format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml)));
603                parts
604            }
605            IrStmt::Update(upd) if !upd.poly_implementors.is_empty() => emit_poly_update_dml_ctes(upd, "_dml"),
606            IrStmt::Delete(del) if !del.poly_implementors.is_empty() => emit_poly_delete_dml_ctes(del, "_dml"),
607            _ => vec![format!("\"_dml\" AS (\n{}\n)", emit_dml_as_cte_source(dml))],
608        };
609        let (enqueue_v, enqueue_s) = match dml.as_ref() {
610            IrStmt::Insert(ins) => (ins.enqueue_vector.as_slice(), ins.enqueue_search.as_slice()),
611            IrStmt::Update(upd) => (upd.enqueue_vector.as_slice(), upd.enqueue_search.as_slice()),
612            _ => (&[][..], &[][..]),
613        };
614        cte_parts.extend(enqueue_ctes(enqueue_v, "_dml"));
615        cte_parts.extend(enqueue_search_ctes(enqueue_s, "_dml", enqueue_v.len()));
616        format!(
617            "WITH\n{}\nSELECT {}(\n    {}\n) AS result\nFROM \"_dml\" AS {}",
618            cte_parts.join(",\n"),
619            distinct,
620            tuple,
621            qi(alias)
622        )
623    } else if sel.polymorphic && !source.table.starts_with("@cte:") {
624        // Polymorphic interface with no CTE indirection: fan out to implementor tables.
625        let union_sql = emit_poly_union(&sel.poly_implementors, &sel.poly_columns);
626        format!(
627            "SELECT {}(\n    {}\n) AS result\nFROM (\n{}\n) AS {}",
628            distinct,
629            tuple,
630            union_sql,
631            qi(alias)
632        )
633    } else {
634        // Concrete table or CTE (pre-filtered): query directly.
635        // For CTE-backed polymorphic sources, __type__ is already present in the CTE result.
636        format!(
637            "SELECT {}(\n    {}\n) AS result\nFROM {} AS {}",
638            distinct,
639            tuple,
640            source_ref(source),
641            qi(alias)
642        )
643    };
644
645    let mut sql = from_clause;
646    append_filter(&mut sql, &sel.filter);
647    append_order_by(&mut sql, &sel.order_by);
648    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
649    append_lock_clause(&mut sql, &sel.lock);
650
651    let root_pointers = prepend_type(shape_pointers);
652    SqlOutput {
653        sql,
654        shape: ShapeDescriptor {
655            root: ShapeNode::Object {
656                name: String::new(),
657                type_name: Some(source.type_name.clone()),
658                position: 0,
659                cardinality: Cardinality::Many,
660                pointers: root_pointers,
661                has_implicit_id: shape_has_implicit_id(shape),
662            },
663        },
664        inference_plan: None,
665    }
666}
667
668/// Emit a DML statement for use as a CTE source, using `RETURNING *` to expose
669/// all columns to the outer SELECT.  The DML's own returning shape is ignored.
670/// `FROM` over the nested-DML CTEs `reading` takes a column of, prefixed by
671/// `lead`; empty when it takes none. One read through a scalar subquery
672/// (`(SELECT "id" FROM cte)`) needs no join, and joining it anyway drops every
673/// row the moment that CTE is empty -- which a guarded branch is.
674fn nested_cte_from(ctes: &[IrCteDef], reading: &str, lead: &str) -> String {
675    let joined: Vec<String> = ctes
676        .iter()
677        .map(|c| qi(&c.name))
678        .filter(|name| reading.contains(&format!("{name}.")))
679        .collect();
680    if joined.is_empty() {
681        return String::new();
682    }
683    format!("{lead}FROM {}", joined.join(", "))
684}
685
686/// Each operand's rows read through the bare `v` every scalar statement
687/// exposes as a CTE source, so operands of different kinds line up.
688fn emit_scalar_union(branches: &[IrStmt]) -> String {
689    let operands: Vec<String> = branches
690        .iter()
691        .enumerate()
692        .map(|(i, branch)| {
693            format!(
694                "SELECT \"v\" FROM (\n{}\n) AS \"_u{i}\"",
695                emit_dml_as_cte_source(branch)
696            )
697        })
698        .collect();
699    format!(
700        "SELECT ROW(v) AS result, v FROM (\n{}\n) AS _scalar",
701        operands.join("\nUNION ALL\n")
702    )
703}
704
705fn emit_dml_as_cte_source(stmt: &IrStmt) -> String {
706    match stmt {
707        IrStmt::ScalarUnion(branches) => format!("    {}", emit_scalar_union(branches)),
708        IrStmt::Insert(ins) => {
709            let rewrite_cols: std::collections::HashSet<&str> =
710                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
711            let cols: Vec<String> = ins
712                .assignments
713                .iter()
714                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
715                .map(|(c, _)| format!("    {}", qi(c)))
716                .chain(ins.rewrites.iter().map(|r| format!("    {}", qi(&r.column))))
717                .collect();
718            let vals: Vec<String> = ins
719                .assignments
720                .iter()
721                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
722                .map(|(_, e)| format!("    {}", emit_expr(e)))
723                .chain(ins.rewrites.iter().map(|r| format!("    {}", emit_expr(&r.expr))))
724                .collect();
725            let mut sql = if !ins.nested_ctes.is_empty() || ins.guard.is_some() {
726                // The row reads columns out of hoisted nested-DML CTEs, so it
727                // selects from them rather than listing literal values. Those
728                // CTEs are emitted by the caller: Postgres only allows a
729                // data-modifying WITH at the top level, never inside a CTE.
730                // A guarded insert likewise has to be a `SELECT … WHERE`, so
731                // that it writes nothing when the condition is false.
732                format!("    {}", emit_insert_row_sql(ins))
733            } else if cols.is_empty() {
734                // See `emit_insert_row_sql`: a row of nothing but defaults.
735                format!("    INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
736            } else {
737                format!(
738                    "    INSERT INTO {} (\n{}\n    ) VALUES (\n{}\n    )",
739                    target_ref(&ins.target),
740                    cols.join(",\n"),
741                    vals.join(",\n"),
742                )
743            };
744            if let Some(conflict) = &ins.unless_conflict {
745                emit_conflict(&mut sql, conflict);
746            }
747            sql.push_str("\n    RETURNING *");
748            sql
749        }
750        IrStmt::Update(upd) => {
751            // Callers must route an update with any multi-link (junction
752            // table) mutation through emit_update_multilink_ctes instead —
753            // junction INSERT/DELETE CTEs are data-modifying and Postgres
754            // requires those to sit at the *top level* of the query, so they
755            // can't be nested inside this function's single-CTE-body return
756            // value. See emit_cte_prefix and emit_select_stmt's dml_source
757            // branch, both of which check has_any_multilink before calling
758            // this function at all.
759            let alias = &upd.target.alias;
760            let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "    ");
761            let mut sql = format!(
762                "    UPDATE {} AS {}\n    SET\n{}",
763                target_ref(&upd.target),
764                qi(alias),
765                sets.join(",\n"),
766            );
767            // The nested CTEs are emitted alongside this body (see
768            // `emit_user_cte_parts`), so the reference to them needs a FROM.
769            sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
770            append_filter(&mut sql, &upd.filter);
771            // Qualified: past a FROM, a bare `*` carries the joined relation's
772            // columns too, and a reader of this CTE then finds two `id`s.
773            sql.push_str(&format!("\n    RETURNING {}.*", qi(alias)));
774            sql
775        }
776        IrStmt::Delete(del) => {
777            let alias = &del.target.alias;
778            let mut sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
779            append_filter(&mut sql, &del.filter);
780            sql.push_str("\n    RETURNING *");
781            sql
782        }
783        IrStmt::Select(inner) => match inner.rows.as_slice() {
784            // `x := (select (select T filter …) filter …)` — the inner select is
785            // what this one reads, the same `_dml` the top-level emitter reads
786            // it from. Read from the table instead, it returned every row of
787            // the type, with neither filter applied.
788            [IrRowSource::Bound { source, .. }]
789                if let Some(read) = inner.dml_source.as_deref()
790                    && !matches!(read, IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::Delete(_)) =>
791            {
792                // A subquery, not a `WITH`: this may already sit inside one.
793                let mut sql = format!(
794                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
795                    if inner.distinct { "DISTINCT " } else { "" },
796                    emit_dml_as_cte_source(read),
797                    qi(&source.alias),
798                );
799                append_filter(&mut sql, &inner.filter);
800                append_order_by(&mut sql, &inner.order_by);
801                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
802                sql
803            }
804            [IrRowSource::Bound { source, .. }] => {
805                // SELECT-over-SELECT: expose raw columns so the outer SELECT can
806                // project its own shape from them, mirroring DML's RETURNING *.
807                let from = if inner.polymorphic && !source.table.starts_with("@cte:") {
808                    format!(
809                        "(\n{}\n    ) AS {}",
810                        emit_poly_union(&inner.poly_implementors, &inner.poly_columns),
811                        qi(&source.alias),
812                    )
813                } else {
814                    format!("{} AS {}", source_ref(source), qi(&source.alias))
815                };
816                let mut sql = format!(
817                    "    SELECT {}* FROM {}",
818                    if inner.distinct { "DISTINCT " } else { "" },
819                    from
820                );
821                append_filter(&mut sql, &inner.filter);
822                append_order_by(&mut sql, &inner.order_by);
823                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
824                sql
825            }
826            // Object union: same FROM-clause union as `emit_bound_union_select`,
827            // but exposing raw columns like every other arm here.
828            rows if rows.len() > 1 && rows.iter().all(|r| matches!(r, IrRowSource::Bound { .. })) => {
829                let IrRowSource::Bound { source, .. } = &rows[0] else {
830                    unreachable!("checked by the guard")
831                };
832                let mut sql = format!(
833                    "    SELECT {}* FROM (\n{}\n    ) AS {}",
834                    if inner.distinct { "DISTINCT " } else { "" },
835                    bound_union_from_clause(rows, &inner.poly_columns),
836                    qi(&source.alias),
837                );
838                append_filter(&mut sql, &inner.filter);
839                append_order_by(&mut sql, &inner.order_by);
840                append_offset_limit(&mut sql, &inner.offset, &inner.limit);
841                sql
842            }
843            // Free rows: full UNION-ALL emission, same as before the merge
844            // (this arm used to be a separate IrStmt::FreeSelect match). Only
845            // `.sql` is used here, and CtePassthrough's SQL text doesn't
846            // depend on the ctes list (only its *shape* resolution does), so
847            // an empty slice is fine.
848            _ => emit_select_stmt(inner, &[]).sql,
849        },
850        IrStmt::FunctionSelect(sel) => {
851            // Expose raw columns so the outer SELECT can project its own shape,
852            // mirroring how IrStmt::Select works as a CTE source.
853            let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
854            let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
855            let mut sql = format!("    SELECT * FROM {} AS {}", fn_call, qi(&sel.alias));
856            append_filter(&mut sql, &sel.filter);
857            append_order_by(&mut sql, &sel.order_by);
858            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
859            sql
860        }
861        IrStmt::For(f) => {
862            // `for x in S union (...)` as a set source: iterate in the FROM
863            // clause and expose the body's raw columns, the same contract the
864            // other arms honour. An empty literal iterator yields no rows, but
865            // still has to name a column for the outer SELECT to project.
866            if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
867                return "    SELECT NULL AS \"id\" WHERE FALSE".to_string();
868            }
869            let (values_from, _) = emit_for_iterator(&f.iterator, &format!("_for_{}", f.var_name));
870            let body =
871                prefix_body_ctes(emit_dml_as_cte_source(&f.body), &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
872            format!(
873                "    SELECT \"_body\".*\n    FROM {}\n    CROSS JOIN LATERAL (\n    {}\n    ) AS \"_body\"",
874                values_from.replace('\n', "\n    "),
875                body,
876            )
877        }
878        IrStmt::Group(grp) if matches!(grp.output, IrGroupOutput::Elements) => emit_group_elements_source(grp),
879        IrStmt::Group(_) | IrStmt::VectorSearch(_) | IrStmt::FtsSearch(_) => {
880            unreachable!("cannot appear as a CTE source")
881        }
882        IrStmt::PathSelect(ps) => {
883            // A path traversal as a CTE body. What it projects depends on
884            // where the path ends: a scalar becomes a `result` column (which
885            // is what `IrExpr::CteRef { scalar: true }` reads back), an
886            // object exposes its raw columns so the outer query can both
887            // traverse it (`@cte:` source) and read its `id`.
888            let distinct = if ps.distinct { "DISTINCT " } else { "" };
889            let projection = match &ps.result {
890                // Same two-column convention a free scalar binding uses:
891                // `result` is the ROW() composite top-level decoding wants,
892                // `v` the bare value `IrExpr::CteRef { scalar: true }` reads.
893                IrPathResult::Scalar(e, _) => format!("{}{} AS v", distinct, emit_expr(e)),
894                IrPathResult::Object { alias, .. } => format!("{}{}.*", distinct, qi(alias)),
895            };
896            let mut sql = format!(
897                "SELECT {}\n    FROM {}",
898                projection,
899                emit_path_joins(&ps.root, &ps.joins)
900            );
901            append_filter(&mut sql, &ps.filter);
902            append_order_by(&mut sql, &ps.order_by);
903            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
904            match &ps.result {
905                IrPathResult::Scalar(e, _) => {
906                    let row_value = if enum_type_of_expr(e).is_some() { "v::text" } else { "v" };
907                    format!("    SELECT ROW({row_value}) AS result, v FROM ({sql}) AS _scalar")
908                }
909                IrPathResult::Object { .. } => format!("    {sql}"),
910            }
911        }
912    }
913}
914
915// ── Polymorphic DML-as-CTE fan-out ──────────────────────────────────────────
916//
917// A DML statement targeting an interface/abstract type can't be a single
918// `UPDATE`/`DELETE ... RETURNING *` the way emit_dml_as_cte_source handles a
919// concrete-type target — Postgres has no single physical relation backing
920// the interface (each concrete implementor has its own table), and a bare
921// `RETURNING *` off whichever one table `source_ref` happened to resolve
922// would only ever see that one implementor's rows and never carry a
923// `__type__` discriminator column at all (confirmed live: querying such a
924// CTE from an outer polymorphic SELECT raised "column t1.__type__ does not
925// exist", since the outer SELECT — see emit_select_stmt and the "CTE-backed
926// polymorphic sources" comment there — assumes any CTE tied to a polymorphic
927// type already exposes one).
928//
929// Mirrors emit_poly_update_stmt/emit_poly_delete_stmt's per-implementor
930// UNION ALL fan-out (one CTE per concrete table, each RETURNING-ing rows,
931// unioned together with a compile-time-known type literal per branch — the
932// type is known per branch even though it can vary per row across the whole
933// statement, since each branch only ever touches one implementor's table).
934// The difference here: those two functions only need `id` back (a
935// standalone DML's own default RETURNING shape), while a DML-as-CTE source
936// must expose whatever columns the *outer* SELECT might project — so this
937// returns the interface's full common column set (poly_columns: every
938// property + link FK the type declares, guaranteed present on every
939// implementor table) instead of just id.
940//
941// Not handled: a multi-link mutation (+=/-=/:=) on the interface's own
942// pointer combined with poly_implementors on the same statement — that
943// would need per-implementor junction-table CTEs too, which
944// emit_update_multilink_ctes doesn't do either today. Callers check
945// update_has_any_multilink first and keep routing that (rarer) combination
946// through the existing (equally not-poly-aware) path rather than silently
947// mishandling it here.
948
949fn emit_poly_update_dml_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
950    let alias = &upd.target.alias;
951    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
952    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
953
954    let mut cte_parts = vec![];
955    let mut union_parts = vec![];
956    for (i, imp) in upd.poly_implementors.iter().enumerate() {
957        let cte_name = format!("{}__u{}", name, i);
958        let mut upd_sql = format!(
959            "UPDATE {} AS {}\nSET {}",
960            qn(&imp.module, &imp.table),
961            qi(alias),
962            sets.join(", "),
963        );
964        append_filter(&mut upd_sql, &upd.filter);
965        upd_sql.push_str(&format!("\nRETURNING {}", col_list));
966        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
967
968        union_parts.push(format!(
969            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
970            sql_str(&imp.type_name),
971            col_list,
972            cte_name,
973        ));
974    }
975    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
976    cte_parts
977}
978
979/// One implementor's own copy of a polymorphic update: it writes to that
980/// implementor's table, and to that implementor's own junction tables — an
981/// interface's junction is a view over them, which Postgres refuses to
982/// insert into.
983fn concrete_poly_update(upd: &IrUpdate, imp: &IrPolyImplementor) -> IrUpdate {
984    let interface_prefix = format!("{}.", upd.target.table);
985    let own_prefix = format!("{}.", imp.table);
986    let own_junction = |table: &mut String, module: &mut String| {
987        if let Some(link) = table.strip_prefix(&interface_prefix) {
988            *table = format!("{own_prefix}{link}");
989            module.clone_from(&imp.module);
990        }
991    };
992    let mut concrete = upd.clone();
993    concrete.poly_implementors = vec![];
994    concrete.target.table = imp.table.clone();
995    concrete.target.type_name = imp.type_name.clone();
996    concrete.target.poly = None;
997    concrete
998        .multi_link_clears
999        .iter_mut()
1000        .for_each(|c| own_junction(&mut c.junction_table, &mut c.module));
1001    concrete
1002        .multi_link_replaces
1003        .iter_mut()
1004        .chain(concrete.multi_link_appends.iter_mut())
1005        .chain(concrete.multi_link_removals.iter_mut())
1006        .for_each(|m| own_junction(&mut m.junction_table, &mut m.module));
1007    concrete
1008}
1009
1010/// `emit_poly_update_dml_ctes` for an update that also mutates a multi-link:
1011/// each implementor gets the whole sibling set `emit_update_multilink_ctes`
1012/// builds, against its own tables.
1013fn emit_poly_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1014    let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1015    let mut cte_parts = emit_user_cte_parts(&upd.nested_ctes);
1016    let mut union_parts = vec![];
1017    for (i, imp) in upd.poly_implementors.iter().enumerate() {
1018        let cte_name = format!("{}__u{}", name, i);
1019        cte_parts.extend(emit_update_multilink_cte_parts(
1020            &concrete_poly_update(upd, imp),
1021            &cte_name,
1022            false,
1023        ));
1024        union_parts.push(format!(
1025            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1026            sql_str(&imp.type_name),
1027            col_list,
1028            cte_name,
1029        ));
1030    }
1031    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1032    cte_parts
1033}
1034
1035fn emit_poly_delete_dml_ctes(del: &IrDelete, name: &str) -> Vec<String> {
1036    let alias = &del.target.alias;
1037    let col_list = del.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1038
1039    let mut cte_parts = vec![];
1040    let mut union_parts = vec![];
1041    for (i, imp) in del.poly_implementors.iter().enumerate() {
1042        let cte_name = format!("{}__d{}", name, i);
1043        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
1044        append_filter(&mut del_sql, &del.filter);
1045        del_sql.push_str(&format!("\nRETURNING {}", col_list));
1046        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
1047
1048        union_parts.push(format!(
1049            "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1050            sql_str(&imp.type_name),
1051            col_list,
1052            cte_name,
1053        ));
1054    }
1055    cte_parts.push(format!("\"{}\" AS (\n{}\n)", name, union_parts.join("\nUNION ALL\n")));
1056    cte_parts
1057}
1058
1059// ── User CTE helpers ────────────────────────────────────────────────────────
1060
1061fn update_has_any_multilink(upd: &IrUpdate) -> bool {
1062    !upd.multi_link_clears.is_empty()
1063        || !upd.multi_link_replaces.is_empty()
1064        || !upd.multi_link_appends.is_empty()
1065        || !upd.multi_link_removals.is_empty()
1066}
1067
1068/// Extra `AND target NOT IN (...)` clause for a junction clear paired with
1069/// a replace at the same index (`friends := expr` — see `IrUpdate.
1070/// multi_link_clears`'s own doc comment on the shared-index pairing).
1071///
1072/// Without this, a target present in *both* the old and new sets gets
1073/// deleted here and then silently fails to come back: the paired INSERT
1074/// (`emit_ml_append_cte`) runs its `ON CONFLICT` check against the same
1075/// statement-wide snapshot every CTE in one `WITH` shares, which still
1076/// shows the row this DELETE is *about to* remove as present — Postgres
1077/// treats that as a real conflict and skips the insert (`ON CONFLICT DO
1078/// NOTHING`) or updates the row this DELETE is also deleting (`DO UPDATE`,
1079/// the link-properties case), not "insert the row back." Confirmed live:
1080/// reassigning a junction-backed single link to its own already-set value,
1081/// or replacing a multi-link with a set overlapping its current one,
1082/// silently dropped every overlapping member. Excluding still-wanted
1083/// targets from the DELETE instead means they're never touched at all, so
1084/// there's nothing for the INSERT to conflict with.
1085fn ml_clear_exclusion(rep: Option<&IrMultiLinkMutation>) -> String {
1086    match rep {
1087        Some(rep) => {
1088            let vals_ref = emit_multilink_values_subquery(&rep.values, &[]);
1089            format!(
1090                " AND {} NOT IN (SELECT \"_v\".\"id\" FROM {} AS \"_v\")",
1091                qi(&rep.target_col),
1092                vals_ref
1093            )
1094        }
1095        None => String::new(),
1096    }
1097}
1098
1099/// Builds the CTE chain for an UPDATE with multi-link (junction table)
1100/// mutations, bound to a single external `name` (a user WITH-binding, or the
1101/// implicit "_dml" wrapper for `SELECT (UPDATE ...)`). Junction INSERT/DELETE
1102/// statements are themselves data-modifying, and Postgres requires
1103/// data-modifying CTEs to sit at the *top level* of the query — they can't
1104/// be nested inside another CTE's own body (confirmed live: nesting them
1105/// raises "WITH clause containing a data-modifying statement must be at the
1106/// top level"). So this returns a flat list of sibling CTE parts, prefixed
1107/// by `name` to stay collision-free alongside any other bound statements in
1108/// the same WITH block, ending in a `"{name}" AS (SELECT * FROM
1109/// "{name}__ids")` passthrough — every other reference to `name` keeps
1110/// seeing the updated row's full columns exactly as `RETURNING *` would have
1111/// exposed them.
1112fn emit_update_multilink_ctes(upd: &IrUpdate, name: &str) -> Vec<String> {
1113    emit_update_multilink_cte_parts(upd, name, true)
1114}
1115
1116/// `emit_nested` is false for a caller that has already emitted
1117/// `upd.nested_ctes` beside this one: a polymorphic update emits one branch
1118/// per implementor off the same `IrUpdate`, and a `WITH` may name each
1119/// hoisted statement only once.
1120fn emit_update_multilink_cte_parts(upd: &IrUpdate, name: &str, emit_nested: bool) -> Vec<String> {
1121    let alias = &upd.target.alias;
1122    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
1123    let ids_name = format!("{}__ids", name);
1124    // The nested statements its values read from, ahead of it.
1125    let mut parts: Vec<String> = if emit_nested {
1126        emit_user_cte_parts(&upd.nested_ctes)
1127    } else {
1128        vec![]
1129    };
1130
1131    if has_scalar_changes {
1132        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1133        let mut upd_sql = format!(
1134            "UPDATE {} AS {}\nSET {}",
1135            target_ref(&upd.target),
1136            qi(alias),
1137            sets.join(", "),
1138        );
1139        // A nested statement's CTE sits beside this one, so reading its id
1140        // needs a FROM — the same reason the plain update path has one.
1141        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
1142        append_filter(&mut upd_sql, &upd.filter);
1143        // See `emit_dml_as_cte_source`: past a FROM, a bare `*` would carry the
1144        // joined relation's columns into this CTE beside the target's.
1145        upd_sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1146        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, upd_sql));
1147    } else {
1148        let mut sel = format!(
1149            "SELECT {}.* FROM {} AS {}",
1150            qi(alias),
1151            target_ref(&upd.target),
1152            qi(alias),
1153        );
1154        append_filter(&mut sel, &upd.filter);
1155        parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sel));
1156    }
1157
1158    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
1159        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
1160        let del = format!(
1161            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"{}\"){}",
1162            qn(&clr.module, &clr.junction_table),
1163            qi(&clr.source_col),
1164            ids_name,
1165            exclude,
1166        );
1167        parts.push(format!("\"{}__clr_{}\" AS (\n{}\n)", name, i, del));
1168    }
1169    for (i, app) in upd.multi_link_appends.iter().enumerate() {
1170        parts.push(emit_ml_append_cte(
1171            app,
1172            &ids_name,
1173            &upd.target.alias,
1174            &format!("{}__ml_add_{}", name, i),
1175        ));
1176    }
1177    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
1178        parts.push(emit_ml_remove_cte(
1179            rem,
1180            &ids_name,
1181            &upd.target.alias,
1182            &format!("{}__ml_rm_{}", name, i),
1183        ));
1184    }
1185    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
1186        parts.push(emit_ml_append_cte(
1187            rep,
1188            &ids_name,
1189            &upd.target.alias,
1190            &format!("{}__ml_rep_{}", name, i),
1191        ));
1192    }
1193
1194    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1195    parts
1196}
1197
1198fn insert_has_any_multilink(ins: &IrInsert) -> bool {
1199    !ins.multi_link_appends.is_empty()
1200}
1201
1202/// Builds the CTE chain for an INSERT with multi-link (junction table)
1203/// assignments at creation time — same top-level-CTE constraint as
1204/// `emit_update_multilink_ctes` (junction INSERTs are themselves
1205/// data-modifying). The row itself is inserted first (`{name}__ids`, via
1206/// `RETURNING *`) so the junction CTEs can reference its freshly-generated id.
1207fn emit_insert_multilink_ctes(ins: &IrInsert, name: &str) -> Vec<String> {
1208    let ids_name = format!("{}__ids", name);
1209    let mut parts: Vec<String> = emit_user_cte_parts(&ins.nested_ctes);
1210
1211    let mut insert_sql = emit_insert_row_sql(ins);
1212    if let Some(conflict) = &ins.unless_conflict {
1213        emit_conflict(&mut insert_sql, conflict);
1214    }
1215    insert_sql.push_str("\nRETURNING *");
1216    parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, insert_sql));
1217
1218    for (i, app) in ins.multi_link_appends.iter().enumerate() {
1219        parts.push(emit_ml_append_cte(
1220            app,
1221            &ids_name,
1222            &ins.target.alias,
1223            &format!("{}__ml_add_{}", name, i),
1224        ));
1225    }
1226
1227    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1228    parts
1229}
1230
1231/// A `for` whose body mutates, bound to a WITH name: the same iteration-driven
1232/// shapes `emit_for_insert`/`emit_for_update` build at the top level, but split
1233/// into sibling CTE parts. A data-modifying statement can neither sit inside
1234/// another CTE's body nor in a LATERAL, so the loop cannot be emitted as the
1235/// binding's own subquery the way a select-bodied one is.
1236/// A `for` whose body mutates, bound to a WITH name. `outer_alias` names an
1237/// enclosing loop's iteration when this loop reads it, which makes every row
1238/// this one writes belong to one iteration of it rather than to all of them. `outer_alias` names
1239/// that loop's iteration when this loop reads it, which makes every row this
1240/// one writes belong to one iteration of it rather than to all of them.
1241/// A guarded insert writes only where its condition holds. The rows it is
1242/// driven from are filtered rather than the statement skipped, so an
1243/// iteration-driven insert stays one statement.
1244fn append_insert_guard(sql: &mut String, ins: &IrInsert) {
1245    if let Some(guard) = &ins.guard {
1246        sql.push_str(&format!("\nWHERE {}", emit_expr(guard)));
1247    }
1248}
1249
1250fn emit_for_dml_ctes_within(f: &IrFor, name: &str, outer_alias: Option<&str>) -> Vec<String> {
1251    let iter_alias = format!("_for_{}", f.var_name);
1252    let (_, plain_iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
1253    let correlated = outer_alias;
1254    let iter_cte = match correlated {
1255        Some(outer) => emit_nested_for_iterator(&f.iterator, &iter_alias, outer),
1256        None => plain_iter_cte,
1257    };
1258    // The iterator first: a body CTE may read the loop variable, and a WITH
1259    // name is only in scope for the bindings that follow it.
1260    let mut parts: Vec<String> = vec![iter_cte];
1261    parts.extend(emit_user_cte_parts(&f.body_ctes));
1262    let ids_name = format!("{}__ids", name);
1263
1264    // Rows written under a correlated loop carry their iteration key, so the
1265    // enclosing statement can pair them with the row that iteration wrote.
1266    if correlated.is_some()
1267        && let IrStmt::Insert(ins) = f.body.as_ref()
1268        && ins.multi_link_appends.is_empty()
1269    {
1270        let rows_name = format!("{}__rows", name);
1271        let outer_source = format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY));
1272        parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1273        parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1274        parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, rows_name));
1275        return parts;
1276    }
1277
1278    match f.body.as_ref() {
1279        IrStmt::Insert(ins) => {
1280            let rewrite_cols: std::collections::HashSet<&str> =
1281                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1282            let cols: Vec<String> = ins
1283                .assignments
1284                .iter()
1285                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1286                .map(|(c, _)| qi(c))
1287                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1288                .collect();
1289            let values: Vec<String> = ins
1290                .assignments
1291                .iter()
1292                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1293                .map(|(_, e)| emit_expr(e))
1294                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1295                .collect();
1296            // A nested loop that carried the iteration key back means these
1297            // rows need one too, to pair with.
1298            let rows_name = format!("{}__rows", name);
1299            let correlated_targets =
1300                correlated_append_indices(&ins.multi_link_appends, &[&f.body_ctes, &ins.nested_ctes]);
1301            if correlated_targets.is_empty() {
1302                let mut sql = format!(
1303                    "INSERT INTO {} ({})\nSELECT {} FROM {}",
1304                    target_ref(&ins.target),
1305                    cols.join(", "),
1306                    values.join(", "),
1307                    qi(&iter_alias),
1308                );
1309                append_insert_guard(&mut sql, ins);
1310                if let Some(conflict) = &ins.unless_conflict {
1311                    emit_conflict(&mut sql, conflict);
1312                }
1313                sql.push_str("\nRETURNING *");
1314                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1315            } else {
1316                let outer_source = match correlated {
1317                    Some(_) => format!("{}.{}", qi(&iter_alias), qi(OUTER_KEY)),
1318                    None => format!("{}.{}", qi(&iter_alias), qi("v")),
1319                };
1320                parts.push(emit_iteration_rows_cte(ins, &rows_name, &iter_alias, &outer_source));
1321                parts.push(emit_insert_from_rows_cte(ins, &ids_name, &rows_name));
1322            }
1323            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1324                let cte_name = format!("{}__ml_add_{}", name, i);
1325                if correlated_targets.contains(&i) {
1326                    let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
1327                        unreachable!("only a CTE-backed value can carry an iteration key")
1328                    };
1329                    parts.push(emit_correlated_ml_append_cte(append, &rows_name, target, &cte_name));
1330                } else {
1331                    parts.push(emit_ml_append_cte(append, &ids_name, &ins.target.alias, &cte_name));
1332                }
1333            }
1334        }
1335        IrStmt::Update(upd) => {
1336            let alias = &upd.target.alias;
1337            if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
1338                const ITER_COL: &str = "_iter";
1339                // A nested insert gets its id generated here rather than by the
1340                // column default, so the junction below can name the row this
1341                // iteration inserted. `RETURNING` offers no order to pair on.
1342                let generated: Vec<Option<&IrInsert>> = upd
1343                    .multi_link_appends
1344                    .iter()
1345                    .map(|a| per_iteration_insert(a, &upd.nested_ctes))
1346                    .collect();
1347                let new_cols: String = generated
1348                    .iter()
1349                    .enumerate()
1350                    .filter_map(|(i, ins)| {
1351                        ins.map(|ins| {
1352                            format!(
1353                                ", {} AS {}",
1354                                ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
1355                                qi(&format!("_new_{i}"))
1356                            )
1357                        })
1358                    })
1359                    .collect();
1360                let mut ids_sql = format!(
1361                    "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
1362                    ids_name,
1363                    qi(alias),
1364                    qi(&iter_alias),
1365                    qi(ITER_COL),
1366                    new_cols,
1367                    target_ref(&upd.target),
1368                    qi(alias),
1369                    qi(&iter_alias),
1370                );
1371                append_filter(&mut ids_sql, &upd.filter);
1372                ids_sql.push_str("\n)");
1373                parts.push(ids_sql);
1374                for (i, append) in upd.multi_link_appends.iter().enumerate() {
1375                    match generated[i] {
1376                        Some(ins) => {
1377                            parts.push(emit_for_nested_insert_cte(
1378                                ins,
1379                                &ids_name,
1380                                &iter_alias,
1381                                &format!("{}__ml_ins_{}", name, i),
1382                                &format!("_new_{i}"),
1383                            ));
1384                            parts.push(emit_for_ml_append_cte(
1385                                append,
1386                                &ids_name,
1387                                &format!("{}__ml_add_{}", name, i),
1388                                &format!("_new_{i}"),
1389                            ));
1390                        }
1391                        None => parts.push(emit_for_ml_append_cte(
1392                            append,
1393                            &ids_name,
1394                            &format!("{}__ml_add_{}", name, i),
1395                            ITER_COL,
1396                        )),
1397                    }
1398                }
1399            } else if !upd.poly_implementors.is_empty() {
1400                // An interface has no table to update: each implementor is
1401                // driven from the iteration on its own, and the rows unioned
1402                // back under the interface's common columns.
1403                let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1404                let col_list = upd.poly_columns.iter().map(|c| qi(c)).collect::<Vec<_>>().join(", ");
1405                // Qualified: the iterated set is in scope here too, so a bare
1406                // column name could name either side.
1407                let ret_list = upd
1408                    .poly_columns
1409                    .iter()
1410                    .map(|c| format!("{}.{}", qi(alias), qi(c)))
1411                    .collect::<Vec<_>>()
1412                    .join(", ");
1413                let mut union_parts = vec![];
1414                for (i, imp) in upd.poly_implementors.iter().enumerate() {
1415                    let cte_name = format!("{}__u{}", ids_name, i);
1416                    let mut upd_sql = format!(
1417                        "UPDATE {} AS {}\nSET {}\nFROM {}",
1418                        qn(&imp.module, &imp.table),
1419                        qi(alias),
1420                        sets.join(", "),
1421                        qi(&iter_alias),
1422                    );
1423                    append_filter(&mut upd_sql, &upd.filter);
1424                    upd_sql.push_str(&format!("\nRETURNING {}", ret_list));
1425                    parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
1426                    union_parts.push(format!(
1427                        "SELECT {}::text AS \"__type__\", {} FROM \"{}\"",
1428                        sql_str(&imp.type_name),
1429                        col_list,
1430                        cte_name,
1431                    ));
1432                }
1433                parts.push(format!(
1434                    "\"{}\" AS (\n{}\n)",
1435                    ids_name,
1436                    union_parts.join("\nUNION ALL\n")
1437                ));
1438            } else {
1439                let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
1440                let mut sql = format!(
1441                    "UPDATE {} AS {}\nSET {}\nFROM {}",
1442                    target_ref(&upd.target),
1443                    qi(alias),
1444                    sets.join(", "),
1445                    qi(&iter_alias),
1446                );
1447                append_filter(&mut sql, &upd.filter);
1448                sql.push_str(&format!("\nRETURNING {}.*", qi(alias)));
1449                parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1450            }
1451        }
1452        // `for line in … union (for component in … union (insert …))` — one
1453        // iterator CTE per loop, the inner carrying the outer's key, and the
1454        // insert driven from the two joined on it.
1455        IrStmt::For(inner) => {
1456            let inner_alias = format!("_for_{}", inner.var_name);
1457            parts.push(emit_nested_for_iterator(&inner.iterator, &inner_alias, &iter_alias));
1458            parts.extend(emit_user_cte_parts(&inner.body_ctes));
1459            let IrStmt::Insert(ins) = inner.body.as_ref() else {
1460                unreachable!("a nested for-loop's own body is an insert: {:?}", inner.body)
1461            };
1462            let rewrite_cols: std::collections::HashSet<&str> =
1463                ins.rewrites.iter().map(|r| r.column.as_str()).collect();
1464            let cols: Vec<String> = ins
1465                .assignments
1466                .iter()
1467                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1468                .map(|(c, _)| qi(c))
1469                .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
1470                .collect();
1471            let values: Vec<String> = ins
1472                .assignments
1473                .iter()
1474                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
1475                .map(|(_, e)| emit_expr(e))
1476                .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
1477                .collect();
1478            let mut sql = format!(
1479                "INSERT INTO {} ({})\nSELECT {} FROM {}",
1480                target_ref(&ins.target),
1481                cols.join(", "),
1482                values.join(", "),
1483                nested_for_from(&inner_alias, &iter_alias),
1484            );
1485            append_insert_guard(&mut sql, ins);
1486            if let Some(conflict) = &ins.unless_conflict {
1487                emit_conflict(&mut sql, conflict);
1488            }
1489            sql.push_str("\nRETURNING *");
1490            parts.push(format!("\"{}\" AS (\n{}\n)", ids_name, sql));
1491            for (i, append) in ins.multi_link_appends.iter().enumerate() {
1492                parts.push(emit_ml_append_cte(
1493                    append,
1494                    &ids_name,
1495                    &ins.target.alias,
1496                    &format!("{}__ml_add_{}", name, i),
1497                ));
1498            }
1499        }
1500        // Unreachable: only a mutating body is routed here.
1501        other => unreachable!("for-loop body is not a mutation: {other:?}"),
1502    }
1503
1504    parts.push(format!("\"{}\" AS (\n    SELECT * FROM \"{}\"\n)", name, ids_name));
1505    parts
1506}
1507
1508/// Expands a list of user-bound WITH names into their top-level CTE parts —
1509/// usually one part per name, except an UPDATE or INSERT with any multi-link
1510/// mutation expands into several sibling parts (see
1511/// emit_update_multilink_ctes / emit_insert_multilink_ctes), and an UPDATE or
1512/// DELETE targeting an interface/abstract type similarly fans out into one
1513/// part per concrete implementor (see emit_poly_update_dml_ctes /
1514/// emit_poly_delete_dml_ctes) — neither can be nested inside a single name's
1515/// own CTE body.
1516fn emit_user_cte_parts(ctes: &[IrCteDef]) -> Vec<String> {
1517    let mut parts: Vec<String> = vec![];
1518    for c in ctes {
1519        // A hoisted statement may have hoisted one of its own —
1520        // `credentials := (insert access::Credentials { … })` inside an update.
1521        // Those CTEs are emitted beside it rather than inside its body, which
1522        // is where its own SQL expects to read them from.
1523        // The multi-link builders emit a statement's own, ahead of it.
1524        let nested: &[IrCteDef] = match &c.stmt {
1525            IrStmt::Insert(ins) if insert_has_any_multilink(ins) => &[],
1526            IrStmt::Update(upd) if update_has_any_multilink(upd) => &[],
1527            IrStmt::Insert(ins) => &ins.nested_ctes,
1528            IrStmt::Update(upd) => &upd.nested_ctes,
1529            _ => &[],
1530        };
1531        if !nested.is_empty() {
1532            parts.extend(emit_user_cte_parts(nested));
1533        }
1534        if let IrStmt::Update(upd) = &c.stmt {
1535            if !upd.poly_implementors.is_empty() {
1536                parts.extend(if update_has_any_multilink(upd) {
1537                    emit_poly_update_multilink_ctes(upd, &c.name)
1538                } else {
1539                    emit_poly_update_dml_ctes(upd, &c.name)
1540                });
1541                continue;
1542            }
1543            if update_has_any_multilink(upd) {
1544                parts.extend(emit_update_multilink_ctes(upd, &c.name));
1545                continue;
1546            }
1547        }
1548        if let IrStmt::Insert(ins) = &c.stmt
1549            && insert_has_any_multilink(ins)
1550        {
1551            parts.extend(emit_insert_multilink_ctes(ins, &c.name));
1552            continue;
1553        }
1554        if let IrStmt::Delete(del) = &c.stmt
1555            && !del.poly_implementors.is_empty()
1556        {
1557            parts.extend(emit_poly_delete_dml_ctes(del, &c.name));
1558            continue;
1559        }
1560        if let IrStmt::For(f) = &c.stmt
1561            && matches!(f.body.as_ref(), IrStmt::Insert(_) | IrStmt::Update(_) | IrStmt::For(_))
1562        {
1563            parts.extend(emit_for_dml_ctes_within(f, &c.name, c.correlated_to.as_deref()));
1564            continue;
1565        }
1566        let body = emit_dml_as_cte_source(&c.stmt);
1567        parts.push(match &c.correlated_to {
1568            // Evaluated once per iteration and keyed by it, since a CTE is
1569            // evaluated once for the whole statement whatever it reads.
1570            Some(iterator) => format!(
1571                "\"{}\" AS (\nSELECT {}.\"v\" AS {}, \"_row\".*\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_row\"\n)",
1572                c.name,
1573                qi(iterator),
1574                qi(&binding_outer_key(&c.name)),
1575                qi(iterator),
1576                body,
1577            ),
1578            None => format!("\"{}\" AS (\n{}\n)", c.name, body),
1579        });
1580    }
1581    parts
1582}
1583
1584/// Emit `WITH "name" AS (...), ...` prefix (WITH keyword + trailing newline included).
1585fn emit_cte_prefix(ctes: &[IrCteDef]) -> String {
1586    format!("WITH\n{}\n", emit_user_cte_parts(ctes).join(",\n"))
1587}
1588
1589/// Collects every link-property name assigned anywhere in a multilink value
1590/// tree (both sides of any nested `union`), in first-seen order — the SQL
1591/// layer needs one consistent column list across every unioned branch, even
1592/// when different targets set different (or no) properties.
1593pub(crate) fn collect_link_prop_names(vals: &IrMultiLinkValues, names: &mut Vec<String>) {
1594    for (name, _) in &vals.link_props {
1595        if !names.contains(name) {
1596            names.push(name.clone());
1597        }
1598    }
1599    match &vals.source {
1600        IrMultiLinkValueSource::Union(a, b) => {
1601            collect_link_prop_names(a, names);
1602            collect_link_prop_names(b, names);
1603        }
1604        IrMultiLinkValueSource::Asserted { inner, .. } => collect_link_prop_names(inner, names),
1605        _ => {}
1606    }
1607}
1608
1609/// `, <expr> AS "name"` for each entry in `prop_names` — the value assigned to
1610/// *this* node if any, else a bare `NULL` (Postgres infers its type from the
1611/// other union branch's typed value in the same column position; if no
1612/// branch ever sets it, the INSERT's target column type coerces it).
1613fn emit_link_prop_cols(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1614    prop_names
1615        .iter()
1616        .map(|name| match vals.link_props.iter().find(|(n, _)| n == name) {
1617            Some((_, expr)) => format!(", {} AS {}", emit_expr(expr), qi(name)),
1618            None => format!(", NULL AS {}", qi(name)),
1619        })
1620        .collect()
1621}
1622
1623/// Emit `(SELECT id[, prop, ...] FROM ...)` sub-expression for a multi-link
1624/// values source. `prop_names` is the full set of link-property names used
1625/// anywhere in the enclosing mutation (see `collect_link_prop_names`) — every
1626/// leaf projects all of them so a `union` of heterogeneous branches has a
1627/// consistent column list.
1628fn emit_multilink_values_subquery(vals: &IrMultiLinkValues, prop_names: &[String]) -> String {
1629    emit_multilink_values_inner(vals, prop_names, true)
1630}
1631
1632/// `bare_cte_ok` is false wherever the result has to be a SELECT in its own
1633/// right — a `UNION ALL` operand, or the body of an assert. A CTE reference
1634/// otherwise emits just its quoted name, which only works where the caller
1635/// aliases it into a FROM.
1636fn emit_multilink_values_inner(vals: &IrMultiLinkValues, prop_names: &[String], bare_cte_ok: bool) -> String {
1637    if let IrMultiLinkValueSource::Union(a, b) = &vals.source {
1638        return format!(
1639            "({}\nUNION ALL\n{})",
1640            emit_multilink_values_inner(a, prop_names, false),
1641            emit_multilink_values_inner(b, prop_names, false),
1642        );
1643    }
1644
1645    // The assert wraps the whole value, so the ids are collected once, passed
1646    // through the check, and unnested back into rows. Reading the inner value
1647    // twice — once for the check, once for the rows — would run any `insert`
1648    // inside it twice.
1649    if let IrMultiLinkValueSource::Asserted {
1650        fn_name,
1651        inner,
1652        message,
1653    } = &vals.source
1654    {
1655        let inner_sql = emit_multilink_values_inner(inner, &[], false);
1656        let prop_cols = emit_link_prop_cols(vals, prop_names);
1657        return format!(
1658            "(SELECT unnest(\"_pylon\".{}(ARRAY(SELECT \"_v\".\"id\" FROM {} AS \"_v\"){})) AS \"id\"{})",
1659            qi(fn_name),
1660            inner_sql,
1661            assert_message_arg(message),
1662            prop_cols,
1663        );
1664    }
1665
1666    let prop_cols = emit_link_prop_cols(vals, prop_names);
1667
1668    match &vals.source {
1669        IrMultiLinkValueSource::CteRef(name) => {
1670            if prop_cols.is_empty() && bare_cte_ok {
1671                // Just the CTE name; will be aliased at the call site.
1672                format!("\"{}\"", name)
1673            } else {
1674                format!("(SELECT \"_s\".\"id\"{} FROM \"{}\" AS \"_s\")", prop_cols, name)
1675            }
1676        }
1677        IrMultiLinkValueSource::Select(s) => {
1678            // Compiler only ever constructs this variant for a single
1679            // schema-bound row (compile_subquery_exists's callers reject
1680            // free rows before wrapping) — see compiler.rs's multilink-
1681            // value-source sites.
1682            let [IrRowSource::Bound { source, .. }] = s.rows.as_slice() else {
1683                unreachable!("IrMultiLinkValueSource::Select is always schema-bound")
1684            };
1685            let alias = &source.alias;
1686            let mut sql = format!(
1687                "(SELECT {}.\"id\"{} FROM {} AS {}",
1688                qi(alias),
1689                prop_cols,
1690                source_ref(source),
1691                qi(alias)
1692            );
1693            append_filter(&mut sql, &s.filter);
1694            sql.push(')');
1695            sql
1696        }
1697        IrMultiLinkValueSource::PathSelect(ps) => {
1698            let root_alias = &ps.root.alias;
1699            // The targets are the rows the walk lands on, not the one it
1700            // starts from.
1701            let target_alias = match &ps.result {
1702                IrPathResult::Object { alias, .. } => alias,
1703                IrPathResult::Scalar(..) => root_alias,
1704            };
1705            let mut sql = format!(
1706                "(SELECT {}.\"id\"{} FROM {} AS {}",
1707                qi(target_alias),
1708                prop_cols,
1709                source_ref(&ps.root),
1710                qi(root_alias)
1711            );
1712            for join in &ps.joins {
1713                sql.push_str(&emit_path_join_sql(join));
1714            }
1715            append_filter(&mut sql, &ps.filter);
1716            sql.push(')');
1717            sql
1718        }
1719        IrMultiLinkValueSource::Function(fs) => {
1720            let args = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1721            let mut sql = format!(
1722                "(SELECT {}.\"id\"{} FROM {}.{}({}) AS {}",
1723                qi(&fs.alias),
1724                prop_cols,
1725                pg_schema(&fs.fn_module),
1726                qi(&fs.fn_name),
1727                args,
1728                qi(&fs.alias),
1729            );
1730            append_filter(&mut sql, &fs.filter);
1731            sql.push(')');
1732            sql
1733        }
1734        IrMultiLinkValueSource::Union(..) | IrMultiLinkValueSource::Asserted { .. } => {
1735            unreachable!("handled above")
1736        }
1737    }
1738}
1739
1740/// SQL fragment for a single path join (used in multilink values emission).
1741fn emit_path_join_sql(join: &IrPathJoin) -> String {
1742    match join {
1743        IrPathJoin::Lateral { inner, target } => {
1744            let projected = match &inner.result {
1745                IrPathResult::Object { alias, .. } => alias.clone(),
1746                IrPathResult::Scalar(..) => unreachable!("a lateral step is only built for an object-valued computed"),
1747            };
1748            let mut body = format!(
1749                "SELECT {}.* FROM {}",
1750                qi(&projected),
1751                emit_path_joins(&inner.root, &inner.joins)
1752            );
1753            append_filter(&mut body, &inner.filter);
1754            append_order_by(&mut body, &inner.order_by);
1755            append_offset_limit(&mut body, &inner.offset, &inner.limit);
1756            format!(" JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias))
1757        }
1758        IrPathJoin::Function {
1759            fn_module,
1760            fn_name,
1761            args,
1762            target,
1763        } => {
1764            let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
1765            format!(
1766                " CROSS JOIN LATERAL {}.{}({}) AS {}",
1767                pg_schema(fn_module),
1768                qi(fn_name),
1769                args_sql,
1770                qi(&target.alias),
1771            )
1772        }
1773        IrPathJoin::Single {
1774            source_alias,
1775            fk_col,
1776            target,
1777        } => {
1778            format!(
1779                " JOIN {} AS {} ON {}.\"id\" = {}.{}",
1780                source_ref(target),
1781                qi(&target.alias),
1782                qi(&target.alias),
1783                qi(source_alias),
1784                qi(fk_col)
1785            )
1786        }
1787        IrPathJoin::Multi {
1788            source_alias,
1789            junction_alias,
1790            join: ml_join,
1791            target,
1792        } => {
1793            let (jt_ref, src_col, tgt_col) = match ml_join {
1794                IrMultiLinkJoin::Standard { junction_table, module } => (
1795                    junction_ref(module, junction_table),
1796                    "source".to_string(),
1797                    "target".to_string(),
1798                ),
1799                IrMultiLinkJoin::Through {
1800                    junction_table,
1801                    module,
1802                    source_col,
1803                    target_col,
1804                } => (
1805                    junction_ref(module, junction_table),
1806                    source_col.clone(),
1807                    target_col.clone(),
1808                ),
1809                // A forward `IrPathJoin::Multi` step is always built from a
1810                // real multi-link (`compile_path_select`'s middle-step
1811                // handling), never a backlink — the Backlink* variants only
1812                // ever appear inside `IrShapePointer::MultiLink.join`.
1813                IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
1814                    unreachable!("a forward multi-link path step never uses a backlink join variant")
1815                }
1816            };
1817            format!(
1818                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.{} = {}.\"id\"",
1819                jt_ref,
1820                qi(junction_alias),
1821                qi(junction_alias),
1822                qi(&src_col),
1823                qi(source_alias),
1824                source_ref(target),
1825                qi(&target.alias),
1826                qi(junction_alias),
1827                qi(&tgt_col),
1828                qi(&target.alias),
1829            )
1830        }
1831        IrPathJoin::BacklinkSingle {
1832            source_alias,
1833            fk_col,
1834            target,
1835        } => {
1836            format!(
1837                " JOIN {} AS {} ON {}.{} = {}.\"id\"",
1838                source_ref(target),
1839                qi(&target.alias),
1840                qi(&target.alias),
1841                qi(fk_col),
1842                qi(source_alias),
1843            )
1844        }
1845        IrPathJoin::BacklinkMulti {
1846            source_alias,
1847            junction_alias,
1848            junction_table,
1849            module,
1850            owner_col,
1851            current_col,
1852            target,
1853        } => {
1854            format!(
1855                " JOIN {} AS {} ON {}.{} = {}.\"id\" JOIN {} AS {} ON {}.\"id\" = {}.{}",
1856                junction_ref(module, junction_table),
1857                qi(junction_alias),
1858                qi(junction_alias),
1859                qi(current_col),
1860                qi(source_alias),
1861                source_ref(target),
1862                qi(&target.alias),
1863                qi(&target.alias),
1864                qi(junction_alias),
1865                qi(owner_col),
1866            )
1867        }
1868    }
1869}
1870
1871/// Emit the CTE clause for a junction table INSERT (append / replace-insert).
1872/// `ids_name` is the row-source CTE to join against (usually "_ids", but
1873/// callers hoisting this into a shared top-level WITH block alongside other
1874/// user-bound statements pass a name prefixed for collision-safety instead).
1875/// The loop variable an append's value stands for, when the value is exactly
1876/// that variable (`memberships += membership`).
1877///
1878/// Such a value is compiled as a select over the target table narrowed to the
1879/// key the variable holds. Inside a `for` that key is already carried on the
1880/// driving rows, so the junction insert can read it from there instead of
1881/// correlating to a CTE it has no range-table entry for.
1882pub(crate) fn append_value_is_the_loop_variable(values: &IrMultiLinkValues, var: &str) -> bool {
1883    let IrMultiLinkValueSource::Select(sel) = &values.source else {
1884        return false;
1885    };
1886    let Some(IrExpr::BinOp(cmp)) = sel.filter.as_ref() else {
1887        return false;
1888    };
1889    matches!(
1890        (&cmp.left, &cmp.op, &cmp.right),
1891        (
1892            IrExpr::ColumnRef { column, .. },
1893            crate::parse::ast::BinOpKind::Eq,
1894            IrExpr::ForVar { name, .. },
1895        ) if column == "id" && name == var
1896    ) && values.link_props.is_empty()
1897}
1898
1899/// `emit_ml_append_cte` for a `for` body: the target comes from the column the
1900/// driving rows carry rather than from a correlated subquery.
1901/// The nested insert a multi-link append's value names, when it is one of this
1902/// update's own hoisted CTEs. Inside a `for` body such an insert runs once per
1903/// iteration, so it cannot stay a standalone CTE.
1904pub(crate) fn per_iteration_insert<'c>(mutation: &IrMultiLinkMutation, nested: &'c [IrCteDef]) -> Option<&'c IrInsert> {
1905    let IrMultiLinkValueSource::CteRef(name) = &mutation.values.source else {
1906        return None;
1907    };
1908    if !mutation.values.link_props.is_empty() {
1909        return None;
1910    }
1911    nested.iter().find(|c| &c.name == name).and_then(|c| match &c.stmt {
1912        IrStmt::Insert(ins) if ins.id_default_sql.is_some() && ins.multi_link_appends.is_empty() => Some(ins),
1913        _ => None,
1914    })
1915}
1916
1917/// A nested insert inside a `for` body: one row per driving row, with its id
1918/// taken from the column the driving CTE generated so the junction can pair
1919/// with it. The iterator CTE is joined in because the inserted values are
1920/// correlated subqueries that read the loop variable.
1921fn emit_for_nested_insert_cte(
1922    ins: &IrInsert,
1923    ids_name: &str,
1924    iter_alias: &str,
1925    cte_name: &str,
1926    id_col: &str,
1927) -> String {
1928    let mut cols = vec![qi("id")];
1929    let mut values = vec![format!("\"{}\".{}", ids_name, qi(id_col))];
1930    for (col, expr) in &ins.assignments {
1931        cols.push(qi(col));
1932        values.push(emit_expr(expr));
1933    }
1934    format!(
1935        "\"{}\" AS (\nINSERT INTO {} ({})\nSELECT {} FROM \"{}\", {}\nWHERE \"{}\".{} = {}.\"v\"\nRETURNING {}\n)",
1936        cte_name,
1937        target_ref(&ins.target),
1938        cols.join(", "),
1939        values.join(", "),
1940        ids_name,
1941        qi(iter_alias),
1942        ids_name,
1943        qi("_iter"),
1944        qi(iter_alias),
1945        qi("id"),
1946    )
1947}
1948
1949fn emit_for_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, cte_name: &str, iter_col: &str) -> String {
1950    format!(
1951        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"{}\".\"id\", \"{}\".{} FROM \"{}\"\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
1952        cte_name,
1953        qn(&mutation.module, &mutation.junction_table),
1954        qi(&mutation.source_col),
1955        qi(&mutation.target_col),
1956        ids_name,
1957        ids_name,
1958        qi(iter_col),
1959        ids_name,
1960        qi(&mutation.source_col),
1961        qi(&mutation.target_col),
1962    )
1963}
1964
1965/// `ids_alias` is the name the rows being written go by inside the value:
1966/// a walk off the row (`+= (select .notifications …)`) is correlated to it.
1967fn emit_ml_append_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
1968    let mut prop_names = vec![];
1969    collect_link_prop_names(&mutation.values, &mut prop_names);
1970    let vals_ref = emit_multilink_values_subquery(&mutation.values, &prop_names);
1971
1972    let extra_cols: String = prop_names.iter().map(|n| format!(", {}", qi(n))).collect();
1973    let extra_select: String = prop_names.iter().map(|n| format!(", \"_v\".{}", qi(n))).collect();
1974
1975    // Re-checking an already-linked target with a new property value must
1976    // update in place rather than error — a bare `DO NOTHING` (the no-
1977    // properties case) would silently keep the old value instead.
1978    let conflict_clause = if mutation.single {
1979        // A junction-backed single link's table has `PRIMARY KEY (source)`
1980        // alone (D3, cardinality-one) — the paired `_clr_i` DELETE (see
1981        // `ml_clear_exclusion`) removes any existing row for this source in
1982        // a sibling CTE that shares this whole statement's one snapshot, so
1983        // this INSERT still "sees" that old row and treats it as a
1984        // same-key conflict regardless of what the new target is — a plain
1985        // `DO NOTHING` would then silently drop a genuine reassignment.
1986        // `source` alone is always this table's conflict target, so this
1987        // must always be an upsert, not just when link properties exist.
1988        let mut sets = vec![format!(
1989            "{} = EXCLUDED.{}",
1990            qi(&mutation.target_col),
1991            qi(&mutation.target_col)
1992        )];
1993        sets.extend(prop_names.iter().map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n))));
1994        format!(
1995            "ON CONFLICT ({}) DO UPDATE SET {}",
1996            qi(&mutation.source_col),
1997            sets.join(", ")
1998        )
1999    } else if prop_names.is_empty() {
2000        "ON CONFLICT DO NOTHING".to_string()
2001    } else {
2002        let sets: Vec<String> = prop_names
2003            .iter()
2004            .map(|n| format!("{} = EXCLUDED.{}", qi(n), qi(n)))
2005            .collect();
2006        format!(
2007            "ON CONFLICT ({}, {}) DO UPDATE SET {}",
2008            qi(&mutation.source_col),
2009            qi(&mutation.target_col),
2010            sets.join(", "),
2011        )
2012    };
2013
2014    // A subquery value may read the row it is written for, so it joins
2015    // laterally; a bare CTE name cannot take LATERAL.
2016    let lateral = if vals_ref.starts_with('(') { "LATERAL " } else { "" };
2017    let ins = format!(
2018        "INSERT INTO {} ({}, {}{})\nSELECT {}.\"id\", \"_v\".\"id\"{} FROM \"{}\" AS {} CROSS JOIN {}{} AS \"_v\"\n{}\nRETURNING {}, {}",
2019        qn(&mutation.module, &mutation.junction_table),
2020        qi(&mutation.source_col),
2021        qi(&mutation.target_col),
2022        extra_cols,
2023        qi(ids_alias),
2024        extra_select,
2025        ids_name,
2026        qi(ids_alias),
2027        lateral,
2028        vals_ref,
2029        conflict_clause,
2030        qi(&mutation.source_col),
2031        qi(&mutation.target_col),
2032    );
2033    format!("\"{}\" AS (\n{}\n)", cte_name, ins)
2034}
2035
2036/// Emit the CTE clause for a junction table DELETE (remove). See
2037/// emit_ml_append_cte re: `ids_name` and `ids_alias`.
2038fn emit_ml_remove_cte(mutation: &IrMultiLinkMutation, ids_name: &str, ids_alias: &str, cte_name: &str) -> String {
2039    // Removal never carries link-property assignments (rejected at compile
2040    // time in ir/compiler.rs), so no extra columns are ever needed here.
2041    let vals_ref = emit_multilink_values_subquery(&mutation.values, &[]);
2042    // A walk off the row (`-= (select .line_items …)`) is correlated to
2043    // `ids_alias`, so the rows being written are joined in under that name
2044    // rather than read through an uncorrelated `IN`.
2045    let del = format!(
2046        "DELETE FROM {} AS \"_jn\"\nUSING \"{}\" AS {}\nWHERE \"_jn\".{} = {}.\"id\"\n  AND \"_jn\".{} IN (SELECT \"id\" FROM {})\nRETURNING \"_jn\".{}, \"_jn\".{}",
2047        qn(&mutation.module, &mutation.junction_table),
2048        ids_name,
2049        qi(ids_alias),
2050        qi(&mutation.source_col),
2051        qi(ids_alias),
2052        qi(&mutation.target_col),
2053        vals_ref,
2054        qi(&mutation.source_col),
2055        qi(&mutation.target_col),
2056    );
2057    format!("\"{}\" AS (\n{}\n)", cte_name, del)
2058}
2059
2060// ── Conflict helper ─────────────────────────────────────────────────────────
2061
2062use crate::ir::IrConflict;
2063
2064fn emit_conflict(sql: &mut String, conflict: &IrConflict) {
2065    // `unless conflict on (.email, .invitee)` names several columns, not one
2066    // composite value: Postgres infers the arbiter index from a column list,
2067    // and would look for an expression index on the tuple instead.
2068    let on_sql = conflict.on.as_ref().map(|e| match e {
2069        IrExpr::Tuple(elements) => format!("({})", elements.iter().map(emit_expr).collect::<Vec<_>>().join(", ")),
2070        other => format!("({})", emit_expr(other)),
2071    });
2072    match (&on_sql, &conflict.do_update) {
2073        (None, None) => sql.push_str(" ON CONFLICT DO NOTHING"),
2074        (Some(on), None) => sql.push_str(&format!(" ON CONFLICT {} DO NOTHING", on)),
2075        (None, Some(updates)) => {
2076            sql.push_str(&format!(" ON CONFLICT DO UPDATE SET {}", do_update_sets(updates)));
2077        }
2078        (Some(on), Some(updates)) => {
2079            sql.push_str(&format!(
2080                " ON CONFLICT {} DO UPDATE SET {}",
2081                on,
2082                do_update_sets(updates),
2083            ));
2084        }
2085    }
2086    if conflict.do_update.is_some()
2087        && let Some(predicate) = &conflict.do_update_where
2088    {
2089        sql.push_str(&format!(" WHERE {}", emit_expr(predicate)));
2090    }
2091}
2092
2093fn do_update_sets(updates: &[(String, IrExpr)]) -> String {
2094    updates
2095        .iter()
2096        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
2097        .collect::<Vec<_>>()
2098        .join(", ")
2099}
2100
2101// ── FREE SELECT ─────────────────────────────────────────────────────────────
2102
2103/// Types returned as plain top-level columns rather than wrapped in the
2104/// `result` ROW() composite, so each keeps its own natural top-level shape.
2105fn is_integer_expr(expr: &IrExpr) -> bool {
2106    match expr {
2107        IrExpr::ColumnRef { pg_type, .. } => matches!(
2108            pg_type.as_str(),
2109            "int2" | "int4" | "int8" | "integer" | "bigint" | "smallint"
2110        ),
2111        IrExpr::Literal(crate::ir::IrLiteral::Int(_)) => true,
2112        IrExpr::BinOp(op) => is_integer_expr(&op.left) && is_integer_expr(&op.right),
2113        _ => false,
2114    }
2115}
2116
2117fn is_raw_scalar(expr: &IrExpr) -> bool {
2118    matches!(expr, IrExpr::Array(_))
2119        || matches!(expr, IrExpr::TypeCast(c) if c.pg_type == "jsonb")
2120        || matches!(expr, IrExpr::NamedTuple { .. })
2121        || matches!(expr, IrExpr::Tuple(_))
2122        || matches!(expr, IrExpr::JsonbField { .. })
2123        || matches!(expr, IrExpr::JsonbIndex { .. })
2124}
2125
2126/// Free (non-schema-bound) SELECT rows — one or more UNION ALL branches with
2127/// a ROW(…) wrapper. `rows` is `sel.rows` with any leading `Bound` entries
2128/// already ruled out by `emit_select_stmt`'s dispatch (a schema object and a
2129/// free literal can never appear in the same UNION — `check_union_type_compat`
2130/// rejects that at compile time), so every entry here is `IrRowSource::Free`.
2131fn emit_free_rows(sel: &IrSelect, rows: &[IrRowSource], ctes: &[IrCteDef]) -> SqlOutput {
2132    use crate::query::ShapeNode;
2133
2134    let items: Vec<&IrFreeExpr> = rows
2135        .iter()
2136        .map(|r| match r {
2137            IrRowSource::Free(item) => item,
2138            IrRowSource::Bound { .. } => unreachable!("mixed Bound/Free rows rejected at compile time"),
2139        })
2140        .collect();
2141
2142    if items.is_empty() {
2143        return SqlOutput {
2144            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
2145            shape: ShapeDescriptor {
2146                root: ShapeNode::Scalar {
2147                    name: String::new(),
2148                    position: 0,
2149                },
2150            },
2151            inference_plan: None,
2152        };
2153    }
2154
2155    // assert_exists / assert_distinct: set-returning — emit as unnest, not UNION ALL
2156    if items.len() == 1
2157        && let IrFreeExpr::AssertSet {
2158            fn_name,
2159            inner,
2160            message,
2161        } = items[0]
2162    {
2163        let array_sql = emit_array_source(inner) + &assert_message_arg(message);
2164        let mut sql = format!(
2165            "SELECT ROW(v) AS result FROM unnest(\"_pylon\".{}({})) AS _assert(v)",
2166            fn_name, array_sql,
2167        );
2168        if sel.distinct {
2169            sql = format!("SELECT DISTINCT * FROM ({}) AS \"_distinct\"", sql);
2170        }
2171        append_order_by(&mut sql, &sel.order_by);
2172        append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2173        return SqlOutput {
2174            sql,
2175            shape: ShapeDescriptor {
2176                root: ShapeNode::Scalar {
2177                    name: String::new(),
2178                    position: 0,
2179                },
2180            },
2181            inference_plan: None,
2182        };
2183    }
2184
2185    let shape_root = free_item_shape(items.first().unwrap(), ctes);
2186
2187    let branches: Vec<String> = items
2188        .iter()
2189        .map(|item| match item {
2190            IrFreeExpr::Scalar(expr) => {
2191                // An object standing as the whole result is already the
2192                // composite row `result` is meant to be — wrapping it in
2193                // another ROW() would bury its columns one level down from
2194                // where its shape node says they are.
2195                if matches!(expr, IrExpr::ObjectSubquery(_)) {
2196                    return format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _obj", emit_expr(expr));
2197                }
2198                // Arrays and jsonb are returned as plain top-level columns
2199                // rather than wrapped, so they keep their own shape node.
2200                if is_raw_scalar(expr) {
2201                    // `v` alongside it for the same reason the wrapped form
2202                    // has one: a `with` binding of an array is read by name.
2203                    format!("SELECT v AS result, v FROM (SELECT {} AS v) AS _raw", emit_expr(expr))
2204                } else {
2205                    // `result` is a ROW() composite for top-level decoding.
2206                    // `v` is the unwrapped scalar for use in CteRef expression context.
2207                    // Wrap in a subquery so volatile functions (nextval, etc.) are called once.
2208                    // Enum values also need a ::text cast inside the ROW() — same
2209                    // unregistered-OID problem as arrays/jsonb — but the bare `v`
2210                    // column stays natively typed for CteRef expression use.
2211                    let e = emit_expr(expr);
2212                    let row_value = if enum_type_of_expr(expr).is_some() {
2213                        "v::text"
2214                    } else {
2215                        "v"
2216                    };
2217                    // In a set of several, an element that is empty is not
2218                    // in the set at all (`{a.id, b.id}` with no `a` is just
2219                    // `b.id`) — where SQL would carry it as a NULL row.
2220                    let present = if items.len() > 1 { " WHERE v IS NOT NULL" } else { "" };
2221                    format!("SELECT ROW({row_value}) AS result, v FROM (SELECT {e} AS v) AS _scalar{present}")
2222                }
2223            }
2224            IrFreeExpr::FreeObject(fields) => {
2225                // Each field is computed once in an inner subquery (so a
2226                // volatile expression like nextval() isn't evaluated twice) and
2227                // exposed both packed into the `result` composite (for whole-
2228                // object passthrough/decoding) and as its own named column (for
2229                // `IrExpr::CteFieldRef` — `with x := {a := ...} select x.a`).
2230                let inner_cols: Vec<String> = fields
2231                    .iter()
2232                    .enumerate()
2233                    .map(|(i, (_, e))| format!("{} AS \"_f{}\"", emit_expr(e), i))
2234                    .collect();
2235                let row_items: Vec<String> = fields
2236                    .iter()
2237                    .enumerate()
2238                    .map(|(i, (_, e))| {
2239                        if enum_type_of_expr(e).is_some() {
2240                            format!("\"_f{}\"::text", i)
2241                        } else {
2242                            format!("\"_f{}\"", i)
2243                        }
2244                    })
2245                    .collect();
2246                let named_cols: Vec<String> = fields
2247                    .iter()
2248                    .enumerate()
2249                    .map(|(i, (name, _))| format!("\"_f{}\" AS {}", i, qi(name)))
2250                    .collect();
2251                format!(
2252                    "SELECT ROW({}) AS result, {} FROM (SELECT {}) AS _obj",
2253                    row_items.join(", "),
2254                    named_cols.join(", "),
2255                    inner_cols.join(", "),
2256                )
2257            }
2258            IrFreeExpr::Tuple(exprs) => {
2259                if exprs.len() == 1 {
2260                    format!("SELECT ROW({}) AS result", emit_free_field_expr(&exprs[0]))
2261                } else {
2262                    let parts: Vec<String> = exprs.iter().map(emit_free_field_expr).collect();
2263                    format!("SELECT ({}) AS result", parts.join(", "))
2264                }
2265            }
2266            IrFreeExpr::NamedTupleRow(fields) => {
2267                let parts: Vec<String> = fields.iter().map(|(_, e)| emit_free_field_expr(e)).collect();
2268                if parts.len() == 1 {
2269                    format!("SELECT ROW({}) AS result", parts[0])
2270                } else {
2271                    format!("SELECT ({}) AS result", parts.join(", "))
2272                }
2273            }
2274            IrFreeExpr::AssertSet { .. } => unreachable!("AssertSet is handled by early return above"),
2275            IrFreeExpr::CtePassthrough(name) => format!("SELECT \"result\" FROM {}", qi(name)),
2276        })
2277        .collect();
2278
2279    let union_sql = branches.join("\nUNION ALL\n");
2280
2281    let mut sql = if sel.distinct {
2282        // Wrap UNION ALL in an outer SELECT DISTINCT to deduplicate.
2283        format!("SELECT DISTINCT * FROM (\n{}\n) AS \"_distinct\"", union_sql)
2284    } else {
2285        union_sql
2286    };
2287    // A free SELECT's FILTER has no row of its own to sit beside, so it gates
2288    // the result the branches already produced rather than joining their own
2289    // WHERE clauses.
2290    if let Some(filter) = &sel.filter {
2291        sql = format!(
2292            "SELECT * FROM (\n{}\n) AS \"_filtered\"\nWHERE {}",
2293            sql,
2294            emit_expr(filter)
2295        );
2296    }
2297    append_order_by(&mut sql, &sel.order_by);
2298    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
2299
2300    SqlOutput {
2301        sql,
2302        shape: ShapeDescriptor { root: shape_root },
2303        inference_plan: None,
2304    }
2305}
2306
2307/// Determine whether `expr`'s runtime SQL type is a custom/enum type whose
2308/// OID is assigned by the database rather than fixed — and
2309/// if so, the Postgres-schema-qualified enum type name for `ShapeNode::Enum`
2310/// tagging (same convention `pg_quoted_to_pylon` expects). Covers both a
2311/// column reference to an enum-typed property (quoted pg_type) and a bare
2312/// enum literal (`default::Gender.Male`) — the two ways an enum value can
2313/// appear as a free scalar/object/tuple field, which (unlike a schema
2314/// object's `emit_scalar`) has no dedicated per-pointer descriptor to carry
2315/// this, so it must be recovered from the expression itself.
2316fn enum_type_of_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2317    match expr {
2318        IrExpr::ColumnRef { pg_type, .. } => QualifiedPgType::of(pg_type),
2319        IrExpr::EnumLiteral { pg_type, .. } => Some(QualifiedPgType {
2320            name: pg_quoted_to_pylon(pg_type),
2321            is_array: false,
2322        }),
2323        _ => None,
2324    }
2325}
2326
2327/// The enum type a shape position decodes as — wider than `enum_type_of_expr`,
2328/// which only answers for the values that need a `::text` cast emitting.
2329///
2330/// `[is Concrete].*` wraps each of the concrete type's properties in a
2331/// correlated subquery of its own, so the enum sits a level down and the
2332/// subquery's own emission has already cast it. Without looking through to it
2333/// the outer shape stays a plain `Scalar` and the label is handed back as a
2334/// bare string rather than the enum member.
2335fn enum_type_of_shape_expr(expr: &IrExpr) -> Option<QualifiedPgType> {
2336    if let Some(qualified) = enum_type_of_expr(expr) {
2337        return Some(qualified);
2338    }
2339    let IrExpr::Subquery(select) = expr else {
2340        return None;
2341    };
2342    let [IrRowSource::Bound { shape, .. }] = select.rows.as_slice() else {
2343        return None;
2344    };
2345    let [IrShapePointer::Scalar(scalar)] = shape.as_slice() else {
2346        return None;
2347    };
2348    QualifiedPgType::of(&scalar.pg_type)
2349}
2350
2351/// Emit `expr` for use as a free object/tuple field value — casts to
2352/// `::text` when it's enum-typed (see `enum_type_of_expr`), which keeps the
2353/// value's shape independent of the enum's database-assigned type OID.
2354/// (`pylon-pgcon` also discovers enum OIDs at connect time now, so this cast
2355/// is belt-and-braces rather than load-bearing for decoding.)
2356fn emit_free_field_expr(expr: &IrExpr) -> String {
2357    match enum_type_of_expr(expr) {
2358        Some(qualified) => format!("{}{}", emit_expr(expr), qualified.text_cast()),
2359        None => emit_expr(expr),
2360    }
2361}
2362
2363/// Shape node for one free scalar/object/tuple field — `Enum` when the
2364/// value is enum-typed (see `enum_type_of_expr`), else a plain `Scalar`.
2365fn free_field_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2366    use crate::query::{Cardinality, ShapeNode};
2367    if let IrExpr::ObjectSubquery(sel) = expr {
2368        let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2369            unreachable!("an object subquery is always schema-bound")
2370        };
2371        let (_, nodes) = build_shape(shape, &source.alias);
2372        return ShapeNode::Object {
2373            name: name.to_string(),
2374            type_name: Some(source.type_name.clone()),
2375            position,
2376            cardinality: Cardinality::Optional,
2377            pointers: prepend_type(nodes),
2378            has_implicit_id: shape_has_implicit_id(shape),
2379        };
2380    }
2381    // A walk landing on one object: `(select … limit 1).latest_data { … }`.
2382    if matches!(expr, IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { .. }) {
2383        return expr_shape_node(name, position, expr);
2384    }
2385    // A set of objects keeps its rows' own shape, as it does on a schema
2386    // shape; described as a scalar, each row hydrates as a bare tuple.
2387    if let IrExpr::ArrayFromSelect(source) = expr
2388        && matches!(
2389            source.as_ref(),
2390            IrArraySource::ObjectSelect(_)
2391                | IrArraySource::ObjectFunction(_)
2392                | IrArraySource::PathSelect(_)
2393                | IrArraySource::Group(_)
2394        )
2395    {
2396        return expr_shape_node(name, position, expr);
2397    }
2398    match enum_type_of_shape_expr(expr) {
2399        Some(qualified) => qualified.shape_node(name.to_string(), position),
2400        None => ShapeNode::Scalar {
2401            name: name.to_string(),
2402            position,
2403        },
2404    }
2405}
2406
2407/// Shape node for an arbitrary compiled expression appearing at a shape
2408/// position (a computed pointer, or a free scalar/tuple/object field) —
2409/// `NamedTuple`/`JsonScalar` when the expression produces jsonb (tuple
2410/// literals, nested free objects, tuple-typed casts), `RawScalar` when
2411/// it is returned as a bare top-level column, `Enum` when
2412/// enum-typed, else a plain `Scalar`.
2413fn expr_shape_node(name: &str, position: usize, expr: &IrExpr) -> crate::query::ShapeNode {
2414    use crate::query::{Cardinality, ShapeNode};
2415    match expr {
2416        IrExpr::ObjectPathUnion { branches, multi, .. } => {
2417            let first = branches.first().expect("a union has at least one branch");
2418            let IrPathResult::Object {
2419                alias,
2420                type_name,
2421                shape,
2422            } = &first.result
2423            else {
2424                unreachable!("an object path union's branches always land on objects")
2425            };
2426            let (_, pointer_nodes) = build_shape(shape, alias);
2427            let object = ShapeNode::Object {
2428                name: if *multi { String::new() } else { name.to_string() },
2429                type_name: Some(type_name.clone()),
2430                position: if *multi { 0 } else { position },
2431                cardinality: if *multi {
2432                    Cardinality::Many
2433                } else {
2434                    Cardinality::Optional
2435                },
2436                pointers: prepend_type(pointer_nodes),
2437                has_implicit_id: shape_has_implicit_id(shape),
2438            };
2439            if *multi {
2440                ShapeNode::Array {
2441                    name: name.to_string(),
2442                    position,
2443                    element: Box::new(object),
2444                }
2445            } else {
2446                object
2447            }
2448        }
2449        IrExpr::ObjectPathSubquery(ps) => {
2450            let IrPathResult::Object {
2451                alias,
2452                type_name,
2453                shape,
2454            } = &ps.result
2455            else {
2456                unreachable!("an object path subquery always lands on an object")
2457            };
2458            let (_, pointer_nodes) = build_shape(shape, alias);
2459            ShapeNode::Object {
2460                name: name.to_string(),
2461                type_name: Some(type_name.clone()),
2462                position,
2463                cardinality: Cardinality::Optional,
2464                pointers: prepend_type(pointer_nodes),
2465                has_implicit_id: shape_has_implicit_id(shape),
2466            }
2467        }
2468        IrExpr::TypeCast(c) if c.tuple_shape.is_some() => {
2469            let shape = c.tuple_shape.as_ref().unwrap();
2470            ShapeNode::NamedTuple {
2471                name: name.to_string(),
2472                position,
2473                type_name: shape.type_name.clone(),
2474                members: Some(shape.members.clone()),
2475                is_free_object: false,
2476            }
2477        }
2478        // A `<json>` cast at a shape position is an ordinary column of the row
2479        // tuple, so it needs the pointer's own name and position like any
2480        // other leaf. `JsonScalar` is the *root-only* form, where the result
2481        // column itself is the value and there is no pointer to name — it
2482        // carries neither field, and `free_item_shape` is what selects it.
2483        IrExpr::TypeCast(c) if c.pg_type == "jsonb" => ShapeNode::Scalar {
2484            name: name.to_string(),
2485            position,
2486        },
2487        IrExpr::NamedTuple { is_free_object, .. } => ShapeNode::NamedTuple {
2488            name: name.to_string(),
2489            position,
2490            type_name: None,
2491            members: None,
2492            is_free_object: *is_free_object,
2493        },
2494        // A path select aggregated into an array keeps its rows' own shape, so
2495        // the elements hydrate as objects rather than as opaque scalars.
2496        IrExpr::ArrayFromSelect(source) => match source.as_ref() {
2497            IrArraySource::ObjectFunction(fs) => {
2498                let (_, nodes) = build_shape(&fs.shape, &fs.alias);
2499                ShapeNode::Array {
2500                    name: name.to_string(),
2501                    position,
2502                    element: Box::new(ShapeNode::Object {
2503                        name: String::new(),
2504                        type_name: Some(fs.type_name.clone()),
2505                        position: 0,
2506                        cardinality: Cardinality::Many,
2507                        pointers: prepend_type(nodes),
2508                        has_implicit_id: shape_has_implicit_id(&fs.shape),
2509                    }),
2510                }
2511            }
2512            IrArraySource::Group(grp) => ShapeNode::Array {
2513                name: name.to_string(),
2514                position,
2515                element: Box::new(emit_group(grp).shape.root),
2516            },
2517            IrArraySource::ObjectSelect(sel) => {
2518                let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
2519                    unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2520                };
2521                let (_, nodes) = build_shape(shape, &source.alias);
2522                ShapeNode::Array {
2523                    name: name.to_string(),
2524                    position,
2525                    element: Box::new(ShapeNode::Object {
2526                        name: String::new(),
2527                        type_name: Some(source.type_name.clone()),
2528                        position: 0,
2529                        cardinality: Cardinality::Many,
2530                        pointers: prepend_type(nodes),
2531                        has_implicit_id: shape_has_implicit_id(shape),
2532                    }),
2533                }
2534            }
2535            IrArraySource::PathSelect(ps) => match &ps.result {
2536                IrPathResult::Object {
2537                    alias,
2538                    type_name,
2539                    shape,
2540                } => {
2541                    let (_, pointer_nodes) = build_shape(shape, alias);
2542                    ShapeNode::Array {
2543                        name: name.to_string(),
2544                        position,
2545                        element: Box::new(ShapeNode::Object {
2546                            name: String::new(),
2547                            type_name: Some(type_name.clone()),
2548                            position: 0,
2549                            cardinality: Cardinality::Many,
2550                            pointers: prepend_type(pointer_nodes),
2551                            has_implicit_id: shape_has_implicit_id(shape),
2552                        }),
2553                    }
2554                }
2555                IrPathResult::Scalar(..) => ShapeNode::Scalar {
2556                    name: name.to_string(),
2557                    position,
2558                },
2559            },
2560            _ => ShapeNode::Scalar {
2561                name: name.to_string(),
2562                position,
2563            },
2564        },
2565        e if is_raw_scalar(e) => ShapeNode::RawScalar,
2566        e => free_field_shape_node(name, position, e),
2567    }
2568}
2569
2570fn free_item_shape(item: &IrFreeExpr, ctes: &[IrCteDef]) -> crate::query::ShapeNode {
2571    use crate::query::{Cardinality, ShapeNode};
2572    match item {
2573        IrFreeExpr::Scalar(e) => match e {
2574            // Top level: the result column *is* the jsonb value rather than a
2575            // field inside a row tuple. A tuple-shaped cast is excluded because
2576            // `expr_shape_node` decodes that as a `NamedTuple`.
2577            IrExpr::TypeCast(c) if c.pg_type == "jsonb" && c.tuple_shape.is_none() => ShapeNode::JsonScalar,
2578            _ => expr_shape_node("", 0, e),
2579        },
2580        IrFreeExpr::FreeObject(fields) => ShapeNode::Object {
2581            name: String::new(),
2582            type_name: None,
2583            position: 0,
2584            cardinality: Cardinality::Many,
2585            pointers: fields
2586                .iter()
2587                .enumerate()
2588                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2589                .collect(),
2590            has_implicit_id: false,
2591        },
2592        IrFreeExpr::Tuple(exprs) => ShapeNode::Tuple {
2593            position: 0,
2594            elements: exprs
2595                .iter()
2596                .enumerate()
2597                .map(|(i, e)| free_field_shape_node("", i, e))
2598                .collect(),
2599            names: None,
2600        },
2601        IrFreeExpr::NamedTupleRow(fields) => ShapeNode::Tuple {
2602            position: 0,
2603            elements: fields
2604                .iter()
2605                .enumerate()
2606                .map(|(i, (name, e))| free_field_shape_node(name, i, e))
2607                .collect(),
2608            names: Some(fields.iter().map(|(name, _)| name.clone()).collect()),
2609        },
2610        IrFreeExpr::AssertSet { .. } => ShapeNode::Scalar {
2611            name: String::new(),
2612            position: 0,
2613        },
2614        // The referenced CTE's own `result` column carries whatever shape its
2615        // defining free row has (a bare scalar, a multi-field free object, a
2616        // tuple, or even another passthrough) — resolve it by looking the CTE
2617        // up rather than assuming Scalar, otherwise a multi-field free object
2618        // bound in a `WITH` clause decodes as just its first field's value.
2619        IrFreeExpr::CtePassthrough(name) => ctes
2620            .iter()
2621            .find(|c| &c.name == name)
2622            .and_then(|c| match &c.stmt {
2623                IrStmt::Select(sel) => match sel.rows.first() {
2624                    Some(IrRowSource::Free(inner)) => Some(free_item_shape(inner, ctes)),
2625                    _ => None,
2626                },
2627                _ => None,
2628            })
2629            .unwrap_or(ShapeNode::Scalar {
2630                name: String::new(),
2631                position: 0,
2632            }),
2633    }
2634}
2635
2636// ── PATH SELECT ─────────────────────────────────────────────────────────────
2637
2638fn emit_path_joins(root: &IrSource, joins: &[IrPathJoin]) -> String {
2639    let mut parts = vec![format!("{} AS {}", source_ref(root), qi(&root.alias))];
2640    for join in joins {
2641        match join {
2642            IrPathJoin::Single {
2643                source_alias,
2644                fk_col,
2645                target,
2646            } => {
2647                parts.push(format!(
2648                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2649                    source_ref(target),
2650                    qi(&target.alias),
2651                    qi(source_alias),
2652                    qi(fk_col),
2653                    qi(&target.alias),
2654                ));
2655            }
2656            IrPathJoin::Multi {
2657                source_alias,
2658                junction_alias,
2659                join,
2660                target,
2661            } => {
2662                match join {
2663                    IrMultiLinkJoin::Standard { junction_table, module } => {
2664                        parts.push(format!(
2665                            "JOIN {} AS {} ON {}.\"source\" = {}.\"id\"",
2666                            junction_ref(module, junction_table),
2667                            qi(junction_alias),
2668                            qi(junction_alias),
2669                            qi(source_alias),
2670                        ));
2671                        parts.push(format!(
2672                            "JOIN {} AS {} ON {}.\"id\" = {}.\"target\"",
2673                            source_ref(target),
2674                            qi(&target.alias),
2675                            qi(&target.alias),
2676                            qi(junction_alias),
2677                        ));
2678                    }
2679                    IrMultiLinkJoin::Through {
2680                        junction_table,
2681                        module,
2682                        source_col,
2683                        target_col,
2684                    } => {
2685                        parts.push(format!(
2686                            "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2687                            junction_ref(module, junction_table),
2688                            qi(junction_alias),
2689                            qi(junction_alias),
2690                            qi(source_col),
2691                            qi(source_alias),
2692                        ));
2693                        parts.push(format!(
2694                            "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2695                            source_ref(target),
2696                            qi(&target.alias),
2697                            qi(&target.alias),
2698                            qi(junction_alias),
2699                            qi(target_col),
2700                        ));
2701                    }
2702                    // See the identical comment in `emit_path_join_sql` above.
2703                    IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
2704                        unreachable!("a forward multi-link path step never uses a backlink join variant")
2705                    }
2706                }
2707            }
2708            IrPathJoin::BacklinkSingle {
2709                source_alias,
2710                fk_col,
2711                target,
2712            } => {
2713                parts.push(format!(
2714                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2715                    source_ref(target),
2716                    qi(&target.alias),
2717                    qi(&target.alias),
2718                    qi(fk_col),
2719                    qi(source_alias),
2720                ));
2721            }
2722            // LATERAL, not a plain join: the arguments read from the alias
2723            // the traversal has reached, which a non-lateral function call
2724            // in FROM cannot see.
2725            IrPathJoin::Function {
2726                fn_module,
2727                fn_name,
2728                args,
2729                target,
2730            } => {
2731                let args_sql = args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2732                parts.push(format!(
2733                    "CROSS JOIN LATERAL {}.{}({}) AS {}",
2734                    pg_schema(fn_module),
2735                    qi(fn_name),
2736                    args_sql,
2737                    qi(&target.alias),
2738                ));
2739            }
2740            IrPathJoin::Lateral { inner, target } => {
2741                let projected = match &inner.result {
2742                    IrPathResult::Object { alias, .. } => alias.clone(),
2743                    IrPathResult::Scalar(..) => {
2744                        unreachable!("a lateral step is only built for an object-valued computed")
2745                    }
2746                };
2747                let mut body = format!(
2748                    "SELECT {}.* FROM {}",
2749                    qi(&projected),
2750                    emit_path_joins(&inner.root, &inner.joins)
2751                );
2752                append_filter(&mut body, &inner.filter);
2753                append_order_by(&mut body, &inner.order_by);
2754                append_offset_limit(&mut body, &inner.offset, &inner.limit);
2755                parts.push(format!("JOIN LATERAL (\n{}\n) AS {} ON TRUE", body, qi(&target.alias)));
2756            }
2757            IrPathJoin::BacklinkMulti {
2758                source_alias,
2759                junction_alias,
2760                junction_table,
2761                module,
2762                owner_col,
2763                current_col,
2764                target,
2765            } => {
2766                parts.push(format!(
2767                    "JOIN {} AS {} ON {}.{} = {}.\"id\"",
2768                    junction_ref(module, junction_table),
2769                    qi(junction_alias),
2770                    qi(junction_alias),
2771                    qi(current_col),
2772                    qi(source_alias),
2773                ));
2774                parts.push(format!(
2775                    "JOIN {} AS {} ON {}.\"id\" = {}.{}",
2776                    source_ref(target),
2777                    qi(&target.alias),
2778                    qi(&target.alias),
2779                    qi(junction_alias),
2780                    qi(owner_col),
2781                ));
2782            }
2783        }
2784    }
2785    parts.join("\n")
2786}
2787
2788/// Emit `ARRAY(SELECT scalar FROM source [JOINs] [WHERE filter])`.
2789fn emit_array_source(src: &IrArraySource) -> String {
2790    match src {
2791        IrArraySource::Select(s) => {
2792            // compile_subquery_to_array_source only ever constructs this
2793            // variant for a single schema-bound row.
2794            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2795                unreachable!("IrArraySource::Select is always schema-bound")
2796            };
2797            let scalar = match shape.first() {
2798                Some(IrShapePointer::Scalar(sf)) => format!("{}.{}", qi(&source.alias), qi(&sf.column)),
2799                _ => format!("{}.\"id\"", qi(&source.alias)),
2800            };
2801            let mut sql = format!("SELECT {} FROM {} AS {}", scalar, source_ref(source), qi(&source.alias));
2802            append_filter(&mut sql, &s.filter);
2803            format!("ARRAY({})", sql)
2804        }
2805        IrArraySource::ObjectFunction(fs) => {
2806            let (exprs, _) = build_shape(&fs.shape, &fs.alias);
2807            let mut parts = vec![sql_str(&fs.type_name) + "::text"];
2808            parts.extend(exprs);
2809            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
2810            let mut sql = format!(
2811                "SELECT (\n    {}\n) FROM {}.{}({}) AS {}",
2812                parts.join(",\n    "),
2813                pg_schema(&fs.fn_module),
2814                qi(&fs.fn_name),
2815                args_sql,
2816                qi(&fs.alias),
2817            );
2818            append_filter(&mut sql, &fs.filter);
2819            append_order_by(&mut sql, &fs.order_by);
2820            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
2821            format!("ARRAY({})", sql)
2822        }
2823        IrArraySource::ObjectSelect(s) => {
2824            let [IrRowSource::Bound { source, shape }] = s.rows.as_slice() else {
2825                unreachable!("IrArraySource::ObjectSelect is always schema-bound")
2826            };
2827            let (exprs, _) = build_shape(shape, &source.alias);
2828            let mut parts = vec![source_type_disc(source)];
2829            parts.extend(exprs);
2830            let mut sql = format!(
2831                "SELECT (\n    {}\n) FROM {} AS {}",
2832                parts.join(",\n    "),
2833                source_ref(source),
2834                qi(&source.alias)
2835            );
2836            append_filter(&mut sql, &s.filter);
2837            append_order_by(&mut sql, &s.order_by);
2838            append_offset_limit(&mut sql, &s.offset, &s.limit);
2839            format!("ARRAY({})", sql)
2840        }
2841        IrArraySource::Group(grp) => format!("ARRAY({})", emit_group(grp).sql),
2842        IrArraySource::StmtColumn { stmt, column } => format!(
2843            "ARRAY(SELECT {} FROM (\n{}\n) AS \"_rows\")",
2844            qi(column),
2845            emit_dml_as_cte_source(stmt),
2846        ),
2847        IrArraySource::PathSelect(ps) => {
2848            let scalar = match &ps.result {
2849                IrPathResult::Scalar(e, _) => emit_expr(e),
2850                // Aggregating objects keeps the whole row, not the id: this is
2851                // how a computed pointer that walks through a multi-link comes
2852                // back as hydrated rows rather than a list of uuids.
2853                IrPathResult::Object {
2854                    alias,
2855                    type_name,
2856                    shape,
2857                } => {
2858                    let (pointer_exprs, _) = build_shape(shape, alias);
2859                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
2860                    parts.extend(pointer_exprs);
2861                    format!("(\n    {}\n)", parts.join(",\n    "))
2862                }
2863            };
2864            let from_sql = emit_path_joins(&ps.root, &ps.joins);
2865            let mut sql = format!(
2866                "SELECT {}{} FROM {}",
2867                if ps.distinct { "DISTINCT " } else { "" },
2868                scalar,
2869                from_sql
2870            );
2871            append_filter(&mut sql, &ps.filter);
2872            append_order_by(&mut sql, &ps.order_by);
2873            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
2874            format!("ARRAY({})", sql)
2875        }
2876        IrArraySource::RawExpr {
2877            source,
2878            poly_implementors,
2879            poly_columns,
2880            expr,
2881        } => {
2882            let from_sql = if !poly_implementors.is_empty() {
2883                format!(
2884                    "(\n{}\n) AS {}",
2885                    emit_poly_union(poly_implementors, poly_columns),
2886                    qi(&source.alias)
2887                )
2888            } else {
2889                format!("{} AS {}", source_ref(source), qi(&source.alias))
2890            };
2891            format!("ARRAY(SELECT {} FROM {})", emit_expr(expr), from_sql)
2892        }
2893    }
2894}
2895
2896/// Emit a group-key expression, casting schema-qualified enum ColumnRefs to `::text`
2897/// so they decode outside of a typed composite.
2898fn emit_key_expr(expr: &IrExpr) -> String {
2899    if let IrExpr::ColumnRef { alias, column, pg_type } = expr
2900        && let Some(qualified) = QualifiedPgType::of(pg_type)
2901    {
2902        let col_ref = if alias.is_empty() {
2903            qi(column)
2904        } else {
2905            format!("{}.{}", qi(alias), qi(column))
2906        };
2907        return format!("{}{}", col_ref, qualified.text_cast());
2908    }
2909    emit_expr(expr)
2910}
2911
2912/// The rows a group reads, as `(FROM, WHERE)`. A per-group OFFSET/LIMIT
2913/// can't be a plain LIMIT — that would cut whole groups — so the rows are
2914/// ranked within each key first and only the wanted slice kept.
2915fn group_rows(grp: &IrGroup) -> (String, Option<String>) {
2916    let alias = &grp.source.alias;
2917    if grp.limit.is_none() && grp.offset.is_none() {
2918        return (
2919            format!("{} AS {}", source_ref(&grp.source), qi(alias)),
2920            grp.filter.as_ref().map(emit_expr),
2921        );
2922    }
2923    let ranked = format!(
2924        "(SELECT {}.*, row_number() OVER (PARTITION BY {}{}) AS \"__rk\"\n    FROM {} AS {}{}) AS {}",
2925        qi(alias),
2926        group_by_sql(grp),
2927        group_element_order(grp),
2928        source_ref(&grp.source),
2929        qi(alias),
2930        grp.filter
2931            .as_ref()
2932            .map(|f| format!("\n    WHERE {}", emit_expr(f)))
2933            .unwrap_or_default(),
2934        qi(alias),
2935    );
2936    let lower = grp.offset.as_ref().map(emit_expr).unwrap_or_else(|| "0".to_string());
2937    let mut conds = vec![format!("\"__rk\" > {}", lower)];
2938    if let Some(l) = &grp.limit {
2939        conds.push(format!("\"__rk\" <= {} + {}", lower, emit_expr(l)));
2940    }
2941    (ranked, Some(conds.join(" AND ")))
2942}
2943
2944fn group_by_sql(grp: &IrGroup) -> String {
2945    grp.keys
2946        .iter()
2947        .map(|(_, key_expr)| emit_expr(key_expr))
2948        .collect::<Vec<_>>()
2949        .join(", ")
2950}
2951
2952fn group_element_order(grp: &IrGroup) -> String {
2953    if grp.order_by.is_empty() {
2954        return String::new();
2955    }
2956    let s: Vec<_> = grp.order_by.iter().map(emit_sort_clause).collect();
2957    format!(" ORDER BY {}", s.join(", "))
2958}
2959
2960/// A `for` over a group's elements used as a CTE body: the rows themselves,
2961/// so the outer query can traverse them like any object binding.
2962fn emit_group_elements_source(grp: &IrGroup) -> String {
2963    let (from_sql, cond) = group_rows(grp);
2964    let mut sql = format!("SELECT {}.*\n    FROM {}", qi(&grp.source.alias), from_sql);
2965    if let Some(cond) = cond {
2966        sql.push_str(&format!("\n    WHERE {}", cond));
2967    }
2968    sql
2969}
2970
2971fn emit_group(grp: &IrGroup) -> SqlOutput {
2972    match &grp.output {
2973        IrGroupOutput::Groups => emit_group_rows(grp),
2974        IrGroupOutput::Projection(projection) => emit_group_projection(
2975            grp,
2976            &projection.pointers,
2977            &projection.order_by,
2978            &projection.offset,
2979            &projection.limit,
2980        ),
2981        IrGroupOutput::Elements => emit_group_elements(grp),
2982    }
2983}
2984
2985fn emit_group_projection(
2986    grp: &IrGroup,
2987    pointers: &[IrShapePointer],
2988    order_by: &[IrSort],
2989    offset: &Option<IrExpr>,
2990    limit: &Option<IrExpr>,
2991) -> SqlOutput {
2992    let (exprs, nodes) = build_shape(pointers, &grp.source.alias);
2993    let mut parts = vec!["NULL::text".to_string()];
2994    parts.extend(exprs);
2995    let (from_sql, cond) = group_rows(grp);
2996    let mut sql = format!(
2997        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
2998        parts.join(",\n    "),
2999        from_sql
3000    );
3001    if let Some(cond) = cond {
3002        sql.push_str(&format!("\nWHERE {}", cond));
3003    }
3004    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
3005    if !order_by.is_empty() {
3006        let sorts: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
3007        sql.push_str(&format!("\nORDER BY {}", sorts.join(", ")));
3008    }
3009    append_offset_limit(&mut sql, offset, limit);
3010    SqlOutput {
3011        sql,
3012        shape: ShapeDescriptor {
3013            root: ShapeNode::Object {
3014                name: String::new(),
3015                type_name: None,
3016                position: 0,
3017                cardinality: Cardinality::Many,
3018                pointers: nodes,
3019                has_implicit_id: false,
3020            },
3021        },
3022        inference_plan: None,
3023    }
3024}
3025
3026fn emit_group_elements(grp: &IrGroup) -> SqlOutput {
3027    let (exprs, nodes) = build_shape(&grp.shape, &grp.source.alias);
3028    let mut parts = vec![source_type_disc(&grp.source)];
3029    parts.extend(exprs);
3030    let (from_sql, cond) = group_rows(grp);
3031    let mut sql = format!(
3032        "SELECT (\n    {}\n) AS \"result\"\nFROM {}",
3033        parts.join(",\n    "),
3034        from_sql
3035    );
3036    if let Some(cond) = cond {
3037        sql.push_str(&format!("\nWHERE {}", cond));
3038    }
3039    SqlOutput {
3040        sql,
3041        shape: ShapeDescriptor {
3042            root: ShapeNode::Object {
3043                name: String::new(),
3044                type_name: Some(grp.source.type_name.clone()),
3045                position: 0,
3046                cardinality: Cardinality::Many,
3047                pointers: prepend_type(nodes),
3048                has_implicit_id: shape_has_implicit_id(&grp.shape),
3049            },
3050        },
3051        inference_plan: None,
3052    }
3053}
3054
3055fn emit_group_rows(grp: &IrGroup) -> SqlOutput {
3056    let alias = &grp.source.alias;
3057    let (shape_exprs, shape_nodes) = build_shape(&grp.shape, alias);
3058
3059    // Build the elements ROW: type discriminator at pos 0, then shape pointers.
3060    let mut elem_row_parts = vec![source_type_disc(&grp.source)];
3061    elem_row_parts.extend(shape_exprs);
3062    let elem_row = elem_row_parts.join(",\n            ");
3063
3064    // Key positions: NULL at pos 0 (type slot), keys at 1..=N, grouping at N+1, elements at N+2.
3065    let n_keys = grp.keys.len();
3066    let grouping_pos = n_keys + 1;
3067    let elements_pos = n_keys + 2;
3068
3069    // Build key SQL expressions and ShapeNodes.
3070    let mut key_exprs_sql: Vec<String> = vec![];
3071    let mut key_nodes: Vec<ShapeNode> = vec![];
3072    for (i, (key_name, key_expr)) in grp.keys.iter().enumerate() {
3073        let pos = i + 1;
3074        if let IrExpr::ColumnRef { pg_type, .. } = key_expr
3075            && let Some(qualified) = QualifiedPgType::of(pg_type)
3076        {
3077            key_exprs_sql.push(emit_key_expr(key_expr));
3078            key_nodes.push(qualified.shape_node(key_name.clone(), pos));
3079            continue;
3080        }
3081        key_exprs_sql.push(emit_expr(key_expr));
3082        key_nodes.push(ShapeNode::Scalar {
3083            name: key_name.clone(),
3084            position: pos,
3085        });
3086    }
3087
3088    // Build the outer SELECT tuple.
3089    let mut outer_parts = vec!["NULL::text".to_string()];
3090    outer_parts.extend(key_exprs_sql.clone());
3091    let key_names_sql = grp
3092        .keys
3093        .iter()
3094        .map(|(name, _)| format!("'{}'", name))
3095        .collect::<Vec<_>>()
3096        .join(", ");
3097    outer_parts.push(format!("ARRAY[{}]::text[]", key_names_sql));
3098    outer_parts.push(format!(
3099        "array_agg(ROW(\n            {}\n        )::record{})",
3100        elem_row,
3101        group_element_order(grp)
3102    ));
3103
3104    let outer_tuple = outer_parts.join(",\n    ");
3105    let (from_sql, cond) = group_rows(grp);
3106    let mut sql = format!("SELECT (\n    {}\n) AS \"result\"\nFROM {}", outer_tuple, from_sql,);
3107    if let Some(cond) = cond {
3108        sql.push_str(&format!("\nWHERE {}", cond));
3109    }
3110    sql.push_str(&format!("\nGROUP BY {}", group_by_sql(grp)));
3111
3112    // ShapeNode for each element (Object with the selected pointers).
3113    let element_node = ShapeNode::Object {
3114        name: String::new(),
3115        type_name: Some(grp.source.type_name.clone()),
3116        position: 0,
3117        cardinality: Cardinality::Many,
3118        pointers: prepend_type(shape_nodes),
3119        has_implicit_id: shape_has_implicit_id(&grp.shape),
3120    };
3121
3122    let root = ShapeNode::Group {
3123        key_nodes,
3124        grouping_position: grouping_pos,
3125        elements_position: elements_pos,
3126        element: Box::new(element_node),
3127    };
3128
3129    SqlOutput {
3130        sql,
3131        shape: ShapeDescriptor { root },
3132        inference_plan: None,
3133    }
3134}
3135
3136fn emit_poly_union_type_only(implementors: &[IrPolyImplementor]) -> String {
3137    implementors
3138        .iter()
3139        .map(|imp| {
3140            format!(
3141                "    SELECT {}::text AS \"__type__\" FROM {}",
3142                sql_str(&imp.type_name),
3143                qn(&imp.module, &imp.table),
3144            )
3145        })
3146        .collect::<Vec<_>>()
3147        .join("\n    UNION ALL\n")
3148}
3149
3150fn emit_path_select(sel: &IrPathSelect) -> SqlOutput {
3151    let distinct = if sel.distinct { "DISTINCT " } else { "" };
3152    let from_sql = if !sel.poly_implementors.is_empty() {
3153        format!(
3154            "(\n{}\n) AS {}",
3155            emit_poly_union_type_only(&sel.poly_implementors),
3156            qi(&sel.root.alias),
3157        )
3158    } else {
3159        emit_path_joins(&sel.root, &sel.joins)
3160    };
3161
3162    let (result_expr, shape_root) = match &sel.result {
3163        IrPathResult::Scalar(ir_expr, tuple_shape) => {
3164            // Named tuples / jsonb field accesses can't be decoded inside ROW() — emit raw.
3165            let is_nt = matches!(ir_expr, IrExpr::NamedTuple { .. })
3166                || matches!(ir_expr, IrExpr::Tuple(_))
3167                || matches!(ir_expr, IrExpr::JsonbField { .. })
3168                || matches!(ir_expr, IrExpr::JsonbIndex { .. })
3169                || matches!(ir_expr, IrExpr::ColumnRef { pg_type, .. } if pg_type.starts_with("__nt__:"))
3170                || tuple_shape.is_some();
3171            if is_nt {
3172                let expr_sql = format!("{} AS result", emit_expr(ir_expr));
3173                let shape = if matches!(
3174                    ir_expr,
3175                    IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::Tuple(_)
3176                ) {
3177                    ShapeNode::RawScalar
3178                } else if let Some(shape) = tuple_shape {
3179                    // A bare tuple-typed property reference (nominal or
3180                    // structural) — real member shape already resolved at
3181                    // compile time (see resolve_property_tuple_shape), same
3182                    // as a `Type { tuple_property }` shape query gets via
3183                    // emit_scalar, instead of falling back to `members: None`.
3184                    ShapeNode::NamedTuple {
3185                        name: String::new(),
3186                        position: 0,
3187                        type_name: shape.type_name.clone(),
3188                        members: Some(shape.members.clone()),
3189                        is_free_object: false,
3190                    }
3191                } else {
3192                    let type_name = match ir_expr {
3193                        IrExpr::ColumnRef { pg_type, .. } => pg_type.strip_prefix("__nt__:").map(|s| s.to_string()),
3194                        _ => None,
3195                    };
3196                    ShapeNode::NamedTuple {
3197                        name: String::new(),
3198                        position: 0,
3199                        type_name,
3200                        members: None,
3201                        is_free_object: false,
3202                    }
3203                };
3204                (expr_sql, shape)
3205            } else {
3206                // Schema-qualified types (enums, domains) have unknown OIDs inside ROW() —
3207                // cast to text so the value's shape doesn't depend on a runtime OID.
3208                if let IrExpr::ColumnRef { pg_type, .. } = ir_expr {
3209                    if let Some(qualified) = QualifiedPgType::of(pg_type) {
3210                        let expr = format!("ROW({}{}) AS result", emit_expr(ir_expr), qualified.text_cast());
3211                        let shape = qualified.shape_node(String::new(), 0);
3212                        (expr, shape)
3213                    } else {
3214                        let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3215                        (
3216                            expr,
3217                            ShapeNode::Scalar {
3218                                name: String::new(),
3219                                position: 0,
3220                            },
3221                        )
3222                    }
3223                } else {
3224                    let expr = format!("ROW({}) AS result", emit_expr(ir_expr));
3225                    (
3226                        expr,
3227                        ShapeNode::Scalar {
3228                            name: String::new(),
3229                            position: 0,
3230                        },
3231                    )
3232                }
3233            }
3234        }
3235        IrPathResult::Object {
3236            alias,
3237            type_name,
3238            shape,
3239        } => {
3240            let (pointer_exprs, pointer_nodes) = build_shape(shape, alias);
3241            let mut parts = vec![result_type_disc(sel, alias, type_name)];
3242            parts.extend(pointer_exprs);
3243            let expr = format!("(\n    {}\n) AS result", parts.join(",\n    "));
3244            let shape_root = ShapeNode::Object {
3245                name: String::new(),
3246                type_name: Some(type_name.clone()),
3247                position: 0,
3248                cardinality: Cardinality::Many,
3249                pointers: prepend_type(pointer_nodes),
3250                has_implicit_id: shape_has_implicit_id(shape),
3251            };
3252            (expr, shape_root)
3253        }
3254    };
3255
3256    let mut sql = format!("SELECT {}{}\nFROM {}", distinct, result_expr, from_sql);
3257    append_filter(&mut sql, &sel.filter);
3258    append_order_by(&mut sql, &sel.order_by);
3259    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
3260
3261    SqlOutput {
3262        sql,
3263        shape: ShapeDescriptor { root: shape_root },
3264        inference_plan: None,
3265    }
3266}
3267
3268// ── FOR LOOP ─────────────────────────────────────────────────────────────────
3269
3270/// The loop's iterator as a relation with a single column named `v`, in both
3271/// the forms the two body kinds need: one for a `FROM` clause, one for a CTE.
3272/// True for an expression PostgreSQL hands back as `jsonb`, whose value a
3273/// scalar cast has to reach into rather than render.
3274fn yields_jsonb(expr: &IrExpr) -> bool {
3275    match expr {
3276        IrExpr::JsonbField { .. } | IrExpr::JsonbIndex { .. } | IrExpr::NamedTuple { .. } => true,
3277        IrExpr::TypeCast(c) => c.pg_type == "jsonb",
3278        IrExpr::ColumnRef { pg_type, .. } | IrExpr::FnParam { pg_type, .. } => pg_type == "jsonb",
3279        IrExpr::CteRef { pg_type, .. } | IrExpr::ForVar { pg_type, .. } => pg_type.as_deref() == Some("jsonb"),
3280        // A call that recorded a return type answers from it, which is the
3281        // only thing that speaks for one reached through `_pylon` or a schema
3282        // module — the registry scan below only recognises an inline builtin.
3283        IrExpr::FunctionCall(f) if f.return_pg_type.is_some() => f.return_pg_type.as_deref() == Some("jsonb"),
3284        IrExpr::FunctionCall(f) if f.schema.is_none() => {
3285            let mut overloads = crate::stdlib::registry().iter().filter(|d| d.name == f.name).peekable();
3286            overloads.peek().is_some() && overloads.all(|d| matches!(d.return_type, crate::stdlib::PylonType::Json))
3287        }
3288        _ => false,
3289    }
3290}
3291
3292fn emit_for_iterator(it: &IrForIterator, iter_alias: &str) -> (String, String) {
3293    match it {
3294        IrForIterator::Values { exprs, pg_type } => {
3295            let rows: Vec<String> = exprs
3296                .iter()
3297                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3298                .collect();
3299            (
3300                format!("(VALUES {}) AS {}(\"v\")", rows.join(", "), qi(iter_alias)),
3301                format!("{}(\"v\") AS (VALUES {})", qi(iter_alias), rows.join(", ")),
3302            )
3303        }
3304        IrForIterator::Query { stmt, scalar } => {
3305            let inner = emit_dml_as_cte_source(stmt);
3306            // A scalar-yielding select already exposes `v` (the same
3307            // convention a scalar `with` binding uses); an object-yielding
3308            // one exposes its raw columns, so the loop variable binds `id`.
3309            let body = if *scalar {
3310                inner
3311            } else {
3312                format!("    SELECT \"id\" AS v FROM (\n{}\n    ) AS _src", inner)
3313            };
3314            (
3315                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3316                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3317            )
3318        }
3319        IrForIterator::SetReturning { expr, pg_type } => {
3320            let body = format!("    SELECT {}::{} AS v", emit_expr(expr), pg_type);
3321            (
3322                format!("(\n{}\n) AS {}", body, qi(iter_alias)),
3323                format!("{} AS (\n{}\n)", qi(iter_alias), body),
3324            )
3325        }
3326    }
3327}
3328
3329/// The iterator CTE of a `for` nested in another one. The enclosing iterator
3330/// goes in its FROM, so the walk it iterates can read the outer loop variable,
3331/// and its key rides along as `_outer` for the body to join back on — the bond
3332/// that holds a chain of iterator CTEs together.
3333fn emit_nested_for_iterator(it: &IrForIterator, iter_alias: &str, outer_alias: &str) -> String {
3334    const OUTER: &str = "_outer";
3335    match it {
3336        IrForIterator::Values { exprs, pg_type } => {
3337            let rows: Vec<String> = exprs
3338                .iter()
3339                .map(|e| format!("({}::{})", emit_expr(e), pg_type))
3340                .collect();
3341            format!(
3342                "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, (VALUES {}) AS \"_vals\"(\"v\")\n)",
3343                qi(iter_alias),
3344                qi(outer_alias),
3345                qi(OUTER),
3346                qi(outer_alias),
3347                rows.join(", "),
3348            )
3349        }
3350        IrForIterator::Query { stmt, scalar } => {
3351            let inner = emit_dml_as_cte_source(stmt);
3352            let projected = if *scalar { "\"_src\".\"v\"" } else { "\"_src\".\"id\"" };
3353            format!(
3354                "{} AS (\nSELECT {}.\"v\" AS {}, {} AS v\nFROM {}\nCROSS JOIN LATERAL (\n{}\n) AS \"_src\"\n)",
3355                qi(iter_alias),
3356                qi(outer_alias),
3357                qi(OUTER),
3358                projected,
3359                qi(outer_alias),
3360                inner,
3361            )
3362        }
3363        IrForIterator::SetReturning { expr, pg_type } => format!(
3364            "{} AS (\nSELECT {}.\"v\" AS {}, \"_vals\".\"v\" AS v\nFROM {}, LATERAL (SELECT {}::{} AS v) AS \"_vals\"\n)",
3365            qi(iter_alias),
3366            qi(outer_alias),
3367            qi(OUTER),
3368            qi(outer_alias),
3369            emit_expr(expr),
3370            pg_type,
3371        ),
3372    }
3373}
3374
3375/// `FROM <inner iterator> JOIN <outer iterator> ON …` — both loop variables in
3376/// scope for the body, each still read as `"_for_<name>"."v"`.
3377fn nested_for_from(inner_alias: &str, outer_alias: &str) -> String {
3378    format!(
3379        "{} JOIN {} ON {}.\"v\" = {}.\"_outer\"",
3380        qi(inner_alias),
3381        qi(outer_alias),
3382        qi(outer_alias),
3383        qi(inner_alias),
3384    )
3385}
3386
3387/// Put the bindings a `for` body declared in front of the body itself: they may
3388/// read the loop variable, so they cannot sit in the enclosing WITH clause.
3389/// A `for` body's CTEs as they must read inside the LATERAL that runs the body.
3390///
3391/// `emit_user_cte_parts` keys a binding that reads the loop variable by the
3392/// iteration, joining `_for_<slot>` to carry its key — right where the binding
3393/// sits in the statement's own WITH beside the iterator CTE, as the DML forms
3394/// emit it. Inside the LATERAL there is no such relation: `_for_<slot>` is the
3395/// outer FROM item's alias, so `FROM "_for_<slot>"` is SQL PostgreSQL rejects
3396/// ("relation does not exist"), and spelling it legally would widen the binding
3397/// to every iteration's rows at once. The LATERAL has already pinned the
3398/// iteration to one row, and the correlated column reference the body carries
3399/// reads it, so the key is neither needed nor correct here.
3400fn uncorrelated_body_ctes(f: &IrFor) -> Vec<IrCteDef> {
3401    f.body_ctes
3402        .iter()
3403        .map(|cte| IrCteDef {
3404            correlated_to: None,
3405            ..cte.clone()
3406        })
3407        .collect()
3408}
3409
3410fn prefix_body_ctes(sql: String, body_ctes: &[IrCteDef]) -> String {
3411    if body_ctes.is_empty() {
3412        return sql;
3413    }
3414    merge_into_existing_with(&sql, &emit_user_cte_parts(body_ctes))
3415        .unwrap_or_else(|| format!("{}{}", emit_cte_prefix(body_ctes), sql))
3416}
3417
3418fn emit_for_stmt(f: &IrFor, user_ctes: &[IrCteDef]) -> SqlOutput {
3419    let iter_alias = format!("_for_{}", f.var_name);
3420
3421    if matches!(&f.iterator, IrForIterator::Values { exprs, .. } if exprs.is_empty()) {
3422        let empty = SqlOutput {
3423            sql: "SELECT NULL AS result WHERE FALSE".to_string(),
3424            shape: ShapeDescriptor {
3425                root: ShapeNode::Scalar {
3426                    name: String::new(),
3427                    position: 0,
3428                },
3429            },
3430            inference_plan: None,
3431        };
3432        return empty;
3433    }
3434
3435    let (values_from, iter_cte) = emit_for_iterator(&f.iterator, &iter_alias);
3436
3437    match f.body.as_ref() {
3438        IrStmt::Insert(ins) => emit_for_insert(ins, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3439        IrStmt::Update(upd) => emit_for_update(upd, &iter_alias, &iter_cte, user_ctes, &f.body_ctes),
3440        body => {
3441            let body_out = match body {
3442                IrStmt::Select(sel) => emit_select_stmt(sel, user_ctes),
3443                IrStmt::PathSelect(sel) => emit_path_select(sel),
3444                // Unreachable: `Compiler::compile_for` rejects every other
3445                // body kind with a PyQL error before an `IrFor` is built.
3446                other => unreachable!("for-loop body should have been rejected at compile time: {other:?}"),
3447            };
3448            let indent_body = prefix_body_ctes(body_out.sql, &uncorrelated_body_ctes(f)).replace('\n', "\n    ");
3449            let cte_prefix = if !user_ctes.is_empty() {
3450                emit_cte_prefix(user_ctes)
3451            } else {
3452                String::new()
3453            };
3454            let sql = format!(
3455                "{}SELECT \"_body\".result\nFROM {}\nCROSS JOIN LATERAL (\n    {}\n) AS \"_body\"",
3456                cte_prefix, values_from, indent_body,
3457            );
3458            SqlOutput {
3459                sql,
3460                shape: body_out.shape,
3461                inference_plan: None,
3462            }
3463        }
3464    }
3465}
3466
3467/// A `for` whose body updates: driven from the iteration with `UPDATE … FROM`,
3468/// since a LATERAL -- what the select-bodied form uses -- cannot hold DML.
3469/// Both the SET clause and the filter may read the loop variable, and both are
3470/// already compiled against the iteration alias.
3471fn emit_for_update(
3472    upd: &IrUpdate,
3473    iter_alias: &str,
3474    iter_cte: &str,
3475    user_ctes: &[IrCteDef],
3476    body_ctes: &[IrCteDef],
3477) -> SqlOutput {
3478    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3479    // The iterator first: a body CTE may read the loop variable, and a WITH
3480    // name is only in scope for the bindings that follow it.
3481    cte_parts.push(iter_cte.to_string());
3482    cte_parts.extend(emit_user_cte_parts(body_ctes));
3483
3484    let alias = &upd.target.alias;
3485    // With only junction rows to write there is nothing to SET, so the driving
3486    // rows are selected rather than updated -- the same shape a multi-link-only
3487    // update takes outside a loop.
3488    if upd.assignments.is_empty() && upd.rewrites.is_empty() && !upd.multi_link_appends.is_empty() {
3489        const ITER_COL: &str = "_iter";
3490        let ids_name = "_ids";
3491        // A nested insert gets its id generated here rather than by the column
3492        // default, so the junction below can name the row *this* iteration
3493        // inserted — `RETURNING` offers no order to pair on.
3494        let generated: Vec<Option<&IrInsert>> = upd
3495            .multi_link_appends
3496            .iter()
3497            .map(|a| per_iteration_insert(a, &upd.nested_ctes))
3498            .collect();
3499        let new_cols: String = generated
3500            .iter()
3501            .enumerate()
3502            .filter_map(|(i, ins)| {
3503                ins.map(|ins| {
3504                    format!(
3505                        ", {} AS {}",
3506                        ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3507                        qi(&format!("_new_{i}"))
3508                    )
3509                })
3510            })
3511            .collect();
3512        let mut ids_sql = format!(
3513            "\"{}\" AS (\nSELECT {}.*, {}.\"v\" AS {}{} FROM {} AS {}, {}",
3514            ids_name,
3515            qi(alias),
3516            qi(iter_alias),
3517            qi(ITER_COL),
3518            new_cols,
3519            target_ref(&upd.target),
3520            qi(alias),
3521            qi(iter_alias),
3522        );
3523        append_filter(&mut ids_sql, &upd.filter);
3524        ids_sql.push_str("\n)");
3525        cte_parts.push(ids_sql);
3526        for (i, append) in upd.multi_link_appends.iter().enumerate() {
3527            match generated[i] {
3528                Some(ins) => {
3529                    cte_parts.push(emit_for_nested_insert_cte(
3530                        ins,
3531                        ids_name,
3532                        iter_alias,
3533                        &format!("_ml_ins_{i}"),
3534                        &format!("_new_{i}"),
3535                    ));
3536                    cte_parts.push(emit_for_ml_append_cte(
3537                        append,
3538                        ids_name,
3539                        &format!("_ml_add_{i}"),
3540                        &format!("_new_{i}"),
3541                    ));
3542                }
3543                None => cte_parts.push(emit_for_ml_append_cte(
3544                    append,
3545                    ids_name,
3546                    &format!("_ml_add_{i}"),
3547                    ITER_COL,
3548                )),
3549            }
3550        }
3551        let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, ids_name);
3552        let mut parts = vec![type_disc(&upd.target.type_name)];
3553        parts.extend(pointer_exprs);
3554        let sql = format!(
3555            "WITH {}\nSELECT (\n    {}\n) AS result\nFROM \"{}\"",
3556            cte_parts.join(",\n"),
3557            parts.join(",\n    "),
3558            ids_name,
3559        );
3560        return SqlOutput {
3561            sql,
3562            shape: crate::query::ShapeDescriptor {
3563                root: ShapeNode::Object {
3564                    name: String::new(),
3565                    type_name: Some(upd.target.type_name.clone()),
3566                    position: 0,
3567                    cardinality: Cardinality::Many,
3568                    pointers: prepend_type(shape_nodes),
3569                    has_implicit_id: shape_has_implicit_id(&upd.returning),
3570                },
3571            },
3572            inference_plan: None,
3573        };
3574    }
3575    let sets: Vec<String> = upd
3576        .assignments
3577        .iter()
3578        .map(|(col, expr)| format!("{} = {}", qi(col), emit_expr(expr)))
3579        .chain(
3580            upd.rewrites
3581                .iter()
3582                .map(|rw| format!("{} = {}", qi(&rw.column), emit_expr(&rw.expr))),
3583        )
3584        .collect();
3585    if !upd.poly_implementors.is_empty() {
3586        return emit_for_poly_update(upd, iter_alias, &sets, cte_parts);
3587    }
3588    let mut sql = format!(
3589        "WITH {}\nUPDATE {} AS {}\nSET {}\nFROM {}",
3590        cte_parts.join(",\n"),
3591        target_ref(&upd.target),
3592        qi(alias),
3593        sets.join(", "),
3594        qi(iter_alias),
3595    );
3596    append_filter(&mut sql, &upd.filter);
3597    let mut parts = vec![type_disc(&upd.target.type_name)];
3598    let (pointer_exprs, shape_nodes) = build_shape(&upd.returning, alias);
3599    parts.extend(pointer_exprs);
3600    sql.push_str(&format!("\nRETURNING (\n    {}\n) AS result", parts.join(",\n    ")));
3601    SqlOutput {
3602        sql,
3603        shape: crate::query::ShapeDescriptor {
3604            root: ShapeNode::Object {
3605                name: String::new(),
3606                type_name: Some(upd.target.type_name.clone()),
3607                position: 0,
3608                cardinality: Cardinality::Many,
3609                pointers: prepend_type(shape_nodes),
3610                has_implicit_id: shape_has_implicit_id(&upd.returning),
3611            },
3612        },
3613        inference_plan: None,
3614    }
3615}
3616
3617/// The iteration key a correlated nested CTE carries, so rows written per
3618/// iteration can be paired back with the row that iteration wrote. `RETURNING`
3619/// can only name the inserted row's own columns, so the pairing is computed in
3620/// a staging CTE beside the insert rather than read back out of it.
3621const OUTER_KEY: &str = "_outer";
3622
3623/// The iteration key a correlated `with` binding carries, named after the
3624/// binding itself. A binding that reads another one re-exports that one's key
3625/// alongside its own, so a single shared name would leave two columns under
3626/// it and make every read of either ambiguous.
3627fn binding_outer_key(cte_name: &str) -> String {
3628    format!("_outer__{cte_name}")
3629}
3630
3631/// `SELECT <id> AS "id", <iter>."v" AS "_outer", <value> AS "<column>", …` —
3632/// the rows one iteration-driven insert will write, with their ids generated
3633/// here rather than by the column default so a junction can name them before
3634/// the insert has run.
3635fn emit_iteration_rows_cte(ins: &IrInsert, cte_name: &str, iter_alias: &str, outer_source: &str) -> String {
3636    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3637    let mut selected = vec![
3638        format!(
3639            "{} AS {}",
3640            ins.id_default_sql.as_deref().unwrap_or("uuidv7()"),
3641            qi("id")
3642        ),
3643        format!("{} AS {}", outer_source, qi(OUTER_KEY)),
3644    ];
3645    selected.extend(
3646        ins.assignments
3647            .iter()
3648            .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3649            .map(|(c, e)| format!("{} AS {}", emit_expr(e), qi(c)))
3650            .chain(
3651                ins.rewrites
3652                    .iter()
3653                    .map(|r| format!("{} AS {}", emit_expr(&r.expr), qi(&r.column))),
3654            ),
3655    );
3656    let mut sql = format!(
3657        "\"{}\" AS (\nSELECT {}\nFROM {}",
3658        cte_name,
3659        selected.join(", "),
3660        qi(iter_alias),
3661    );
3662    append_insert_guard(&mut sql, ins);
3663    sql.push_str("\n)");
3664    sql
3665}
3666
3667/// The insert that reads its rows back out of the staging CTE above.
3668fn emit_insert_from_rows_cte(ins: &IrInsert, cte_name: &str, rows_name: &str) -> String {
3669    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3670    let cols: Vec<String> = std::iter::once(qi("id"))
3671        .chain(
3672            ins.assignments
3673                .iter()
3674                .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3675                .map(|(c, _)| qi(c)),
3676        )
3677        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3678        .collect();
3679    let mut sql = format!(
3680        "INSERT INTO {} ({})\nSELECT {} FROM \"{}\"",
3681        target_ref(&ins.target),
3682        cols.join(", "),
3683        cols.join(", "),
3684        rows_name,
3685    );
3686    if let Some(conflict) = &ins.unless_conflict {
3687        emit_conflict(&mut sql, conflict);
3688    }
3689    sql.push_str("\nRETURNING *");
3690    format!("\"{}\" AS (\n{}\n)", cte_name, sql)
3691}
3692
3693/// A junction append whose targets were written per iteration: pair the two
3694/// staging CTEs on the iteration key rather than cross-joining every row
3695/// written against every row that wrote one.
3696fn emit_correlated_ml_append_cte(
3697    mutation: &IrMultiLinkMutation,
3698    rows_name: &str,
3699    targets_name: &str,
3700    cte_name: &str,
3701) -> String {
3702    format!(
3703        "\"{}\" AS (\nINSERT INTO {} ({}, {})\nSELECT \"_src\".{}, \"_tgt\".{} FROM \"{}\" AS \"_src\" JOIN \"{}\" AS \"_tgt\" ON \"_tgt\".{} = \"_src\".{}\nON CONFLICT DO NOTHING\nRETURNING {}, {}\n)",
3704        cte_name,
3705        qn(&mutation.module, &mutation.junction_table),
3706        qi(&mutation.source_col),
3707        qi(&mutation.target_col),
3708        qi("id"),
3709        qi("id"),
3710        rows_name,
3711        targets_name,
3712        qi(OUTER_KEY),
3713        qi(OUTER_KEY),
3714        qi(&mutation.source_col),
3715        qi(&mutation.target_col),
3716    )
3717}
3718
3719/// Which of `appends` name a CTE that wrote its rows per iteration, and so
3720/// pair on the iteration key instead of cross-joining.
3721fn correlated_append_indices(appends: &[IrMultiLinkMutation], defs: &[&[IrCteDef]]) -> Vec<usize> {
3722    appends
3723        .iter()
3724        .enumerate()
3725        .filter(|(_, append)| match &append.values.source {
3726            IrMultiLinkValueSource::CteRef(target) => defs
3727                .iter()
3728                .flat_map(|group| group.iter())
3729                .any(|c| &c.name == target && c.correlated_to.is_some()),
3730            _ => false,
3731        })
3732        .map(|(i, _)| i)
3733        .collect()
3734}
3735
3736/// `emit_for_update` for an interface target: one UPDATE per implementor,
3737/// each driven from the iteration, unioned back under a per-branch type
3738/// literal the way a standalone polymorphic update is.
3739fn emit_for_poly_update(upd: &IrUpdate, iter_alias: &str, sets: &[String], mut cte_parts: Vec<String>) -> SqlOutput {
3740    let alias = &upd.target.alias;
3741    let mut union_parts = vec![];
3742    for (i, imp) in upd.poly_implementors.iter().enumerate() {
3743        let cte_name = format!("_u{}", i);
3744        let mut upd_sql = format!(
3745            "UPDATE {} AS {}\nSET {}\nFROM {}",
3746            qn(&imp.module, &imp.table),
3747            qi(alias),
3748            sets.join(", "),
3749            qi(iter_alias),
3750        );
3751        append_filter(&mut upd_sql, &upd.filter);
3752        upd_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
3753        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
3754
3755        let r_alias = format!("_r{}", i);
3756        union_parts.push(format!(
3757            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
3758            sql_str(&imp.type_name),
3759            qi(&r_alias),
3760            cte_name,
3761            qi(&r_alias),
3762        ));
3763    }
3764    let sql = format!("WITH {}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"));
3765    let (shape, _) = emit_returning_shape(&upd.target, &upd.returning, true);
3766    SqlOutput {
3767        sql,
3768        shape,
3769        inference_plan: None,
3770    }
3771}
3772
3773fn emit_for_insert(
3774    ins: &IrInsert,
3775    iter_alias: &str,
3776    iter_cte: &str,
3777    user_ctes: &[IrCteDef],
3778    body_ctes: &[IrCteDef],
3779) -> SqlOutput {
3780    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
3781
3782    let cols: Vec<String> = ins
3783        .assignments
3784        .iter()
3785        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3786        .map(|(c, _)| qi(c))
3787        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
3788        .collect();
3789    let sel_exprs: Vec<String> = ins
3790        .assignments
3791        .iter()
3792        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
3793        .map(|(_, e)| emit_expr(e))
3794        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
3795        .collect();
3796
3797    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
3798    // The iterator first: a body CTE, and any DML nested in the row's values,
3799    // may read the loop variable, and a WITH name is only in scope for the
3800    // bindings that follow it.
3801    cte_parts.push(iter_cte.to_string());
3802    cte_parts.extend(emit_user_cte_parts(body_ctes));
3803    cte_parts.extend(emit_user_cte_parts(&ins.nested_ctes));
3804    // A nested loop that carried the iteration key back means this insert's
3805    // own rows need one too, to pair with.
3806    let correlated_targets = correlated_append_indices(&ins.multi_link_appends, &[body_ctes, &ins.nested_ctes]);
3807
3808    let mut insert_sql = format!(
3809        "INSERT INTO {} ({})\nSELECT {} FROM {}",
3810        target_ref(&ins.target),
3811        cols.join(", "),
3812        sel_exprs.join(", "),
3813        qi(iter_alias),
3814    );
3815    append_insert_guard(&mut insert_sql, ins);
3816    if let Some(conflict) = &ins.unless_conflict {
3817        emit_conflict(&mut insert_sql, conflict);
3818    }
3819    let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
3820
3821    if ins.multi_link_appends.is_empty() {
3822        let mut sql = format!("WITH {}\n{}", cte_parts.join(",\n"), insert_sql);
3823        if let Some(r) = returning_sql {
3824            sql.push_str(&r);
3825        }
3826        return SqlOutput {
3827            sql,
3828            shape,
3829            inference_plan: None,
3830        };
3831    }
3832
3833    // A junction row needs the id of the row this iteration just wrote, so
3834    // the insert becomes a CTE the junction inserts read back from.
3835    const IDS: &str = "_for_dml__ids";
3836    const ROWS: &str = "_for_dml__rows";
3837    if correlated_targets.is_empty() {
3838        insert_sql.push_str("\nRETURNING *");
3839        cte_parts.push(format!("\"{}\" AS (\n{}\n)", IDS, insert_sql));
3840    } else {
3841        let outer_source = format!("{}.{}", qi(iter_alias), qi("v"));
3842        cte_parts.push(emit_iteration_rows_cte(ins, ROWS, iter_alias, &outer_source));
3843        cte_parts.push(emit_insert_from_rows_cte(ins, IDS, ROWS));
3844    }
3845    for (i, append) in ins.multi_link_appends.iter().enumerate() {
3846        let cte_name = format!("_for_dml__ml_add_{}", i);
3847        if correlated_targets.contains(&i) {
3848            let IrMultiLinkValueSource::CteRef(target) = &append.values.source else {
3849                unreachable!("only a CTE-backed value can carry an iteration key")
3850            };
3851            cte_parts.push(emit_correlated_ml_append_cte(append, ROWS, target, &cte_name));
3852        } else {
3853            cte_parts.push(emit_ml_append_cte(append, IDS, &ins.target.alias, &cte_name));
3854        }
3855    }
3856
3857    let projection = match &returning_sql {
3858        // `emit_returning_shape` spells the projection as a RETURNING clause;
3859        // read off a CTE it is the same tuple in a SELECT list.
3860        Some(r) => r.trim_start_matches('\n').replacen("RETURNING ", "SELECT ", 1),
3861        None => "SELECT 1".to_string(),
3862    };
3863    SqlOutput {
3864        sql: format!("WITH {}\n{}\nFROM \"{}\"", cte_parts.join(",\n"), projection, IDS),
3865        shape,
3866        inference_plan: None,
3867    }
3868}
3869
3870// ── INSERT ──────────────────────────────────────────────────────────────────
3871
3872// ── Vector index enqueue helpers ─────────────────────────────────────────────
3873
3874/// Build one `"_eqN" AS (INSERT INTO _pylon."IndexOutbox" ...)` CTE string.
3875fn enqueue_cte_sql(eq: &VectorEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
3876    let index_name_sql = match &eq.index_name {
3877        None => "NULL".to_string(),
3878        Some(name) => sql_str(name),
3879    };
3880    format!(
3881        concat!(
3882            "\"{}\" AS (\n",
3883            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
3884            "        (object_id, type_name, index_kind, index_name)\n",
3885            "    SELECT \"id\", {}, 'Vector'::_pylon.\"IndexKind\", {}\n",
3886            "    FROM \"{}\"\n",
3887            "    ON CONFLICT (object_id, index_kind, index_name)\n",
3888            "    DO UPDATE SET status = 'Pending', enqueued_at = now()\n",
3889            ")",
3890        ),
3891        cte_name,
3892        sql_str(&eq.type_name),
3893        index_name_sql,
3894        source_cte,
3895    )
3896}
3897
3898/// Build the full list of enqueue CTE strings for a set of vector indexes.
3899fn enqueue_ctes(enqueue: &[VectorEnqueueInfo], source_cte: &str) -> Vec<String> {
3900    enqueue
3901        .iter()
3902        .enumerate()
3903        .map(|(i, eq)| enqueue_cte_sql(eq, source_cte, &format!("_eq{}", i)))
3904        .collect()
3905}
3906
3907/// The `_pylon."IndexOutbox".index_kind` enum label for a search backend —
3908/// `Postgres`-backed indexes never reach this (see `collect_search_enqueue`),
3909/// so there's no corresponding `IndexKind` value for that variant.
3910fn search_backend_index_kind(backend: &crate::schema::SearchBackend) -> &'static str {
3911    match backend {
3912        crate::schema::SearchBackend::OpenSearch => "OpenSearch",
3913        crate::schema::SearchBackend::Meilisearch => "Meilisearch",
3914        crate::schema::SearchBackend::Postgres => {
3915            unreachable!("Postgres-backed search indexes are never collected into SearchEnqueueInfo")
3916        }
3917    }
3918}
3919
3920/// Build one OpenSearch/Meilisearch outbox CTE string.
3921fn enqueue_search_cte_sql(eq: &SearchEnqueueInfo, source_cte: &str, cte_name: &str) -> String {
3922    let index_name_sql = match &eq.index_name {
3923        None => "NULL".to_string(),
3924        Some(name) => sql_str(name),
3925    };
3926    format!(
3927        concat!(
3928            "\"{}\" AS (\n",
3929            "    INSERT INTO _pylon.\"IndexOutbox\"\n",
3930            "        (object_id, type_name, index_kind, index_name, operation)\n",
3931            "    SELECT \"id\", {}, '{}'::_pylon.\"IndexKind\", {}, {}\n",
3932            "    FROM \"{}\"\n",
3933            "    ON CONFLICT (object_id, index_kind, index_name)\n",
3934            "    DO UPDATE SET status = 'Pending', operation = EXCLUDED.operation, enqueued_at = now()\n",
3935            ")",
3936        ),
3937        cte_name,
3938        sql_str(&eq.type_name),
3939        search_backend_index_kind(&eq.backend),
3940        index_name_sql,
3941        sql_str(eq.operation),
3942        source_cte,
3943    )
3944}
3945
3946/// Build the full list of OpenSearch/Meilisearch enqueue CTE strings.
3947fn enqueue_search_ctes(enqueue: &[SearchEnqueueInfo], source_cte: &str, offset: usize) -> Vec<String> {
3948    enqueue
3949        .iter()
3950        .enumerate()
3951        .map(|(i, eq)| enqueue_search_cte_sql(eq, source_cte, &format!("_es{}", offset + i)))
3952        .collect()
3953}
3954
3955/// Build `SELECT (...) AS result FROM "cte_name"` plus its ShapeDescriptor,
3956/// mirroring `emit_returning_shape` but for the CTE-wrapper SELECT path.
3957fn shape_select_from_cte(
3958    target: &IrSource,
3959    returning: &[IrShapePointer],
3960    cte_name: &str,
3961) -> (ShapeDescriptor, Option<String>) {
3962    if returning.is_empty() {
3963        return (
3964            ShapeDescriptor {
3965                root: ShapeNode::Scalar {
3966                    name: String::new(),
3967                    position: 0,
3968                },
3969            },
3970            None,
3971        );
3972    }
3973    let (pointer_exprs, shape_pointers) = build_shape(returning, "");
3974    let mut parts = vec![type_disc(&target.type_name)];
3975    parts.extend(pointer_exprs);
3976    let tuple = parts.join(",\n    ");
3977    let sql = format!("SELECT (\n    {}\n) AS result\nFROM {}", tuple, qi(cte_name));
3978    let root_pointers = prepend_type(shape_pointers);
3979    let shape = ShapeDescriptor {
3980        root: ShapeNode::Object {
3981            name: String::new(),
3982            type_name: Some(target.type_name.clone()),
3983            position: 0,
3984            cardinality: Cardinality::Required,
3985            pointers: root_pointers,
3986            has_implicit_id: shape_has_implicit_id(returning),
3987        },
3988    };
3989    (shape, Some(sql))
3990}
3991
3992/// Builds the base `INSERT INTO t (cols) VALUES (...)` row source — or,
3993/// when `ins.nested_ctes` is non-empty, `INSERT INTO t (cols) SELECT ...
3994/// FROM cte1, cte2, ...` instead, since a VALUES list can't reference a
3995/// CTE's columns (needed when a link's value is sourced from a hoisted
3996/// nested INSERT/UPDATE/DELETE — see `IrInsert::nested_ctes`). Each nested
3997/// CTE is guaranteed exactly one row, so an implicit cross join is safe.
3998/// Shared by the plain wrap path and the multi-link junction wrap path
3999/// below, both of which need this same VALUES/SELECT choice.
4000fn emit_insert_row_sql(ins: &IrInsert) -> String {
4001    let rewrite_cols: std::collections::HashSet<&str> = ins.rewrites.iter().map(|r| r.column.as_str()).collect();
4002    let cols: Vec<String> = ins
4003        .assignments
4004        .iter()
4005        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
4006        .map(|(c, _)| qi(c))
4007        .chain(ins.rewrites.iter().map(|r| qi(&r.column)))
4008        .collect();
4009    let vals: Vec<String> = ins
4010        .assignments
4011        .iter()
4012        .filter(|(c, _)| !rewrite_cols.contains(c.as_str()))
4013        .map(|(_, e)| emit_expr(e))
4014        .chain(ins.rewrites.iter().map(|r| emit_expr(&r.expr)))
4015        .collect();
4016    // A guarded insert writes nothing when its condition is false, so the row
4017    // comes from a `SELECT` that returns none -- `VALUES` always yields one.
4018    if let Some(guard) = &ins.guard {
4019        let from_ctes = nested_cte_from(&ins.nested_ctes, &vals.join(","), " ");
4020        let projection = if cols.is_empty() {
4021            String::new()
4022        } else {
4023            format!(" {}", vals.join(", "))
4024        };
4025        let column_list = if cols.is_empty() {
4026            String::new()
4027        } else {
4028            format!(" ({})", cols.join(", "))
4029        };
4030        return format!(
4031            "INSERT INTO {}{} SELECT{}{} WHERE {}",
4032            target_ref(&ins.target),
4033            column_list,
4034            projection,
4035            from_ctes,
4036            emit_expr(guard),
4037        );
4038    }
4039    if cols.is_empty() && ins.nested_ctes.is_empty() {
4040        // `insert Preferences {}` — a row made entirely of its own defaults.
4041        // An empty column list is not SQL; `DEFAULT VALUES` is how it is said.
4042        format!("INSERT INTO {} DEFAULT VALUES", target_ref(&ins.target))
4043    } else if ins.nested_ctes.is_empty() {
4044        format!(
4045            "INSERT INTO {} ({}) VALUES ({})",
4046            target_ref(&ins.target),
4047            cols.join(", "),
4048            vals.join(", ")
4049        )
4050    } else {
4051        format!(
4052            "INSERT INTO {} ({}) SELECT {}{}",
4053            target_ref(&ins.target),
4054            cols.join(", "),
4055            vals.join(", "),
4056            nested_cte_from(&ins.nested_ctes, &vals.join(","), " "),
4057        )
4058    }
4059}
4060
4061fn emit_insert_stmt(ins: &IrInsert) -> SqlOutput {
4062    if ins.enqueue_vector.is_empty()
4063        && ins.enqueue_search.is_empty()
4064        && !insert_has_any_multilink(ins)
4065        && ins.nested_ctes.is_empty()
4066    {
4067        let mut sql = emit_insert_row_sql(ins);
4068        if let Some(conflict) = &ins.unless_conflict {
4069            emit_conflict(&mut sql, conflict);
4070        }
4071        let (shape, returning_sql) = emit_returning_shape(&ins.target, &ins.returning, false);
4072        if let Some(r) = returning_sql {
4073            sql.push_str(&r);
4074        }
4075        return SqlOutput {
4076            sql,
4077            shape,
4078            inference_plan: None,
4079        };
4080    }
4081
4082    // Wrap path: needed for outbox enqueue CTEs, junction-table population
4083    // (see emit_insert_multilink_ctes), and/or hoisted nested-DML CTEs (see
4084    // IrInsert::nested_ctes) — all of which require either the row's own id
4085    // or a WITH prefix, so the plain single-statement INSERT above can't be
4086    // used.
4087    let mut cte_parts = if insert_has_any_multilink(ins) {
4088        emit_insert_multilink_ctes(ins, "_w")
4089    } else {
4090        let mut cte_parts = emit_user_cte_parts(&ins.nested_ctes);
4091        let mut insert_sql = emit_insert_row_sql(ins);
4092        if let Some(conflict) = &ins.unless_conflict {
4093            emit_conflict(&mut insert_sql, conflict);
4094        }
4095        insert_sql.push_str("\nRETURNING \"id\"");
4096        cte_parts.push(format!("\"_w\" AS (\n{}\n)", insert_sql));
4097        cte_parts
4098    };
4099    cte_parts.extend(enqueue_ctes(&ins.enqueue_vector, "_w"));
4100    cte_parts.extend(enqueue_search_ctes(&ins.enqueue_search, "_w", ins.enqueue_vector.len()));
4101
4102    let (shape, select_sql) = shape_select_from_cte(&ins.target, &ins.returning, "_w");
4103    let sql = format!(
4104        "WITH\n{}\n{}",
4105        cte_parts.join(",\n"),
4106        select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4107    );
4108    SqlOutput {
4109        sql,
4110        shape,
4111        inference_plan: None,
4112    }
4113}
4114
4115// ── UPDATE ──────────────────────────────────────────────────────────────────
4116
4117fn emit_poly_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4118    let alias = &upd.target.alias;
4119    let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4120
4121    // Nested-DML CTEs first — a single hoisted CTE (e.g. a nested INSERT)
4122    // is materialized once and safely referenced from every per-implementor
4123    // branch below via its own FROM clause; at most one branch's filter
4124    // will ever actually match a row, since each row belongs to exactly
4125    // one concrete implementor table.
4126    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4127    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4128    let mut union_parts = vec![];
4129    let from_ctes = nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n");
4130
4131    let has_any_multilink = !upd.multi_link_clears.is_empty()
4132        || !upd.multi_link_replaces.is_empty()
4133        || !upd.multi_link_appends.is_empty()
4134        || !upd.multi_link_removals.is_empty();
4135    for (i, imp) in upd.poly_implementors.iter().enumerate() {
4136        let cte_name = format!("_u{}", i);
4137        // Junction rows live in each implementor's own junction table, so
4138        // with any to write each implementor is updated as the concrete type
4139        // it is.
4140        if has_any_multilink {
4141            cte_parts.extend(emit_update_multilink_cte_parts(
4142                &concrete_poly_update(upd, imp),
4143                &cte_name,
4144                false,
4145            ));
4146        } else {
4147            let mut upd_sql = format!(
4148                "UPDATE {} AS {}\nSET {}{}",
4149                qn(&imp.module, &imp.table),
4150                qi(alias),
4151                sets.join(", "),
4152                from_ctes,
4153            );
4154            append_filter(&mut upd_sql, &upd.filter);
4155            upd_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4156            cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, upd_sql));
4157        }
4158
4159        let r_alias = format!("_r{}", i);
4160        union_parts.push(format!(
4161            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4162            sql_str(&imp.type_name),
4163            qi(&r_alias),
4164            cte_name,
4165            qi(&r_alias),
4166        ));
4167    }
4168
4169    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4170
4171    let (shape, _) = emit_returning_shape(&upd.target, &upd.returning, true);
4172    SqlOutput {
4173        sql,
4174        shape,
4175        inference_plan: None,
4176    }
4177}
4178
4179fn emit_update_stmt(upd: &IrUpdate, user_ctes: &[IrCteDef]) -> SqlOutput {
4180    if !upd.poly_implementors.is_empty() {
4181        return emit_poly_update_stmt(upd, user_ctes);
4182    }
4183    let alias = &upd.target.alias;
4184    let (shape, returning_sql) = emit_returning_shape(&upd.target, &upd.returning, true);
4185
4186    let has_any_multilink = !upd.multi_link_clears.is_empty()
4187        || !upd.multi_link_replaces.is_empty()
4188        || !upd.multi_link_appends.is_empty()
4189        || !upd.multi_link_removals.is_empty();
4190
4191    if !has_any_multilink && upd.enqueue_vector.is_empty() && upd.enqueue_search.is_empty() {
4192        // No junction changes, no enqueue — plain UPDATE (possibly with a
4193        // user CTE prefix and/or hoisted nested-DML CTEs from a link value
4194        // like `author := (select (insert Person {...}) { id })` — see
4195        // IrUpdate::nested_ctes). A nested CTE's `id` column is referenced
4196        // from the SET clause, which needs a `FROM` clause to see it —
4197        // ordinary `UPDATE ... SET ...` has no FROM of its own otherwise.
4198        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4199        let mut sql = format!(
4200            "UPDATE {} AS {}\nSET {}",
4201            target_ref(&upd.target),
4202            qi(alias),
4203            sets.join(", "),
4204        );
4205        sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4206        append_filter(&mut sql, &upd.filter);
4207        if let Some(r) = returning_sql {
4208            sql.push_str(&r);
4209        }
4210        // The user's bindings first: a nested statement may read them, and a
4211        // CTE only sees the ones before it.
4212        let combined_ctes: Vec<IrCteDef> = user_ctes
4213            .iter()
4214            .cloned()
4215            .chain(upd.nested_ctes.iter().cloned())
4216            .collect();
4217        if !combined_ctes.is_empty() {
4218            sql = format!("{}{}", emit_cte_prefix(&combined_ctes), sql);
4219        }
4220        return SqlOutput {
4221            sql,
4222            shape,
4223            inference_plan: None,
4224        };
4225    }
4226
4227    if !has_any_multilink && (!upd.enqueue_vector.is_empty() || !upd.enqueue_search.is_empty()) {
4228        // No junction changes but need to enqueue — wrap UPDATE in a CTE.
4229        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4230        let mut upd_sql = format!(
4231            "    UPDATE {} AS {}\n    SET {}",
4232            target_ref(&upd.target),
4233            qi(alias),
4234            sets.join(", "),
4235        );
4236        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n    "));
4237        append_filter(&mut upd_sql, &upd.filter);
4238        upd_sql.push_str("\n    RETURNING \"id\"");
4239
4240        let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4241        cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4242        cte_parts.push(format!("\"_w\" AS (\n{}\n)", upd_sql));
4243        cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_w"));
4244        cte_parts.extend(enqueue_search_ctes(&upd.enqueue_search, "_w", upd.enqueue_vector.len()));
4245
4246        let (shape2, select_sql) = shape_select_from_cte(&upd.target, &upd.returning, "_w");
4247        let sql = format!(
4248            "WITH\n{}\n{}",
4249            cte_parts.join(",\n"),
4250            select_sql.unwrap_or_else(|| "SELECT * FROM \"_w\"".to_string()),
4251        );
4252        return SqlOutput {
4253            sql,
4254            shape: shape2,
4255            inference_plan: None,
4256        };
4257    }
4258
4259    // CTE-based UPDATE for junction table mutations.
4260    let result_expr = if !upd.returning.is_empty() {
4261        let (pointer_exprs, _) = build_shape(&upd.returning, alias);
4262        let mut parts = vec![type_disc(&upd.target.type_name)];
4263        parts.extend(pointer_exprs);
4264        parts.join(",\n    ")
4265    } else {
4266        format!("{}.id", qi(alias))
4267    };
4268
4269    let has_scalar_changes = !upd.assignments.is_empty() || !upd.rewrites.is_empty();
4270
4271    // Nested-DML CTEs first (see IrUpdate::nested_ctes) — only relevant when
4272    // has_scalar_changes, since nested_ctes is only ever populated while
4273    // compiling scalar assignments, which is exactly the condition below.
4274    let mut cte_parts: Vec<String> = emit_user_cte_parts(user_ctes);
4275    cte_parts.extend(emit_user_cte_parts(&upd.nested_ctes));
4276
4277    // _ids: the target rows (updated or selected).
4278    if has_scalar_changes {
4279        let sets = update_set_fragments(&upd.assignments, &upd.rewrites, "");
4280        let mut upd_sql = format!(
4281            "UPDATE {} AS {}\nSET {}",
4282            target_ref(&upd.target),
4283            qi(alias),
4284            sets.join(", "),
4285        );
4286        upd_sql.push_str(&nested_cte_from(&upd.nested_ctes, &sets.join(","), "\n"));
4287        append_filter(&mut upd_sql, &upd.filter);
4288        upd_sql.push_str("\nRETURNING *");
4289        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", upd_sql));
4290    } else {
4291        let mut sel = format!(
4292            "SELECT {}.* FROM {} AS {}",
4293            qi(alias),
4294            target_ref(&upd.target),
4295            qi(alias),
4296        );
4297        append_filter(&mut sel, &upd.filter);
4298        cte_parts.push(format!("\"_ids\" AS (\n{}\n)", sel));
4299    }
4300
4301    // Junction clears (`:= {}` and `:= expr` — the clear part of replace).
4302    for (i, clr) in upd.multi_link_clears.iter().enumerate() {
4303        let exclude = ml_clear_exclusion(upd.multi_link_replaces.get(i));
4304        let del = format!(
4305            "DELETE FROM {} WHERE {} IN (SELECT id FROM \"_ids\"){}",
4306            qn(&clr.module, &clr.junction_table),
4307            qi(&clr.source_col),
4308            exclude,
4309        );
4310        cte_parts.push(format!("\"_clr_{}\" AS (\n{}\n)", i, del));
4311    }
4312
4313    // Junction appends (`+=`).
4314    for (i, app) in upd.multi_link_appends.iter().enumerate() {
4315        cte_parts.push(emit_ml_append_cte(
4316            app,
4317            "_ids",
4318            &upd.target.alias,
4319            &format!("_ml_add_{}", i),
4320        ));
4321    }
4322
4323    // Junction removals (`-=`).
4324    for (i, rem) in upd.multi_link_removals.iter().enumerate() {
4325        cte_parts.push(emit_ml_remove_cte(
4326            rem,
4327            "_ids",
4328            &upd.target.alias,
4329            &format!("_ml_rm_{}", i),
4330        ));
4331    }
4332
4333    // Junction inserts for replace (`:= expr` — insert after the clear).
4334    for (i, rep) in upd.multi_link_replaces.iter().enumerate() {
4335        cte_parts.push(emit_ml_append_cte(
4336            rep,
4337            "_ids",
4338            &upd.target.alias,
4339            &format!("_ml_rep_{}", i),
4340        ));
4341    }
4342
4343    // Enqueue CTEs (source is _ids which has all columns including id).
4344    cte_parts.extend(enqueue_ctes(&upd.enqueue_vector, "_ids"));
4345    cte_parts.extend(enqueue_search_ctes(
4346        &upd.enqueue_search,
4347        "_ids",
4348        upd.enqueue_vector.len(),
4349    ));
4350
4351    let sql = format!(
4352        "WITH\n{}\nSELECT (\n    {}\n) AS result\nFROM \"_ids\" AS {}",
4353        cte_parts.join(",\n"),
4354        result_expr,
4355        qi(alias),
4356    );
4357    SqlOutput {
4358        sql,
4359        shape,
4360        inference_plan: None,
4361    }
4362}
4363
4364// ── DELETE ──────────────────────────────────────────────────────────────────
4365
4366fn emit_delete_stmt(del: &IrDelete) -> SqlOutput {
4367    if !del.poly_implementors.is_empty() {
4368        return emit_poly_delete_stmt(del);
4369    }
4370    let alias = &del.target.alias;
4371
4372    if del.enqueue_search.is_empty() {
4373        let mut sql = format!("DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4374        append_filter(&mut sql, &del.filter);
4375        let (shape, returning_sql) = emit_returning_shape(&del.target, &del.returning, true);
4376        if let Some(r) = returning_sql {
4377            sql.push_str(&r);
4378        }
4379        return SqlOutput {
4380            sql,
4381            shape,
4382            inference_plan: None,
4383        };
4384    }
4385
4386    // Wrap DELETE in a CTE to enqueue OpenSearch delete jobs.
4387    let mut del_sql = format!("    DELETE FROM {} AS {}", target_ref(&del.target), qi(alias),);
4388    append_filter(&mut del_sql, &del.filter);
4389    del_sql.push_str("\n    RETURNING \"id\"");
4390
4391    let mut cte_parts = vec![format!("\"_del\" AS (\n{}\n)", del_sql)];
4392    cte_parts.extend(enqueue_search_ctes(&del.enqueue_search, "_del", 0));
4393
4394    let (shape, select_sql) = shape_select_from_cte(&del.target, &del.returning, "_del");
4395    let sql = format!(
4396        "WITH\n{}\n{}",
4397        cte_parts.join(",\n"),
4398        select_sql.unwrap_or_else(|| "SELECT * FROM \"_del\"".to_string()),
4399    );
4400    SqlOutput {
4401        sql,
4402        shape,
4403        inference_plan: None,
4404    }
4405}
4406
4407fn emit_poly_delete_stmt(del: &IrDelete) -> SqlOutput {
4408    let alias = &del.target.alias;
4409    let mut cte_parts = vec![];
4410    let mut union_parts = vec![];
4411
4412    for (i, imp) in del.poly_implementors.iter().enumerate() {
4413        let cte_name = format!("_d{}", i);
4414        let mut del_sql = format!("DELETE FROM {} AS {}", qn(&imp.module, &imp.table), qi(alias),);
4415        append_filter(&mut del_sql, &del.filter);
4416        del_sql.push_str(&format!("\nRETURNING {}.\"id\"", qi(alias)));
4417        cte_parts.push(format!("\"{}\" AS (\n{}\n)", cte_name, del_sql));
4418
4419        let r_alias = format!("_r{}", i);
4420        union_parts.push(format!(
4421            "SELECT ROW({}::text, {}.\"id\") AS result FROM \"{}\" AS {}",
4422            sql_str(&imp.type_name),
4423            qi(&r_alias),
4424            cte_name,
4425            qi(&r_alias),
4426        ));
4427    }
4428
4429    let sql = format!("WITH\n{}\n{}", cte_parts.join(",\n"), union_parts.join("\nUNION ALL\n"),);
4430
4431    let (shape, _) = emit_returning_shape(&del.target, &del.returning, true);
4432    SqlOutput {
4433        sql,
4434        shape,
4435        inference_plan: None,
4436    }
4437}
4438
4439// ── RETURNING helper ─────────────────────────────────────────────────────────
4440
4441/// Builds the RETURNING clause and ShapeDescriptor for DML.
4442/// `with_alias`: UPDATE/DELETE can use the table alias; INSERT cannot.
4443fn emit_returning_shape(
4444    target: &IrSource,
4445    returning: &[IrShapePointer],
4446    with_alias: bool,
4447) -> (ShapeDescriptor, Option<String>) {
4448    if returning.is_empty() {
4449        return (
4450            ShapeDescriptor {
4451                root: ShapeNode::Scalar {
4452                    name: String::new(),
4453                    position: 0,
4454                },
4455            },
4456            None,
4457        );
4458    }
4459
4460    let alias = if with_alias { target.alias.as_str() } else { "" };
4461    let (pointer_exprs, shape_pointers) = build_shape(returning, alias);
4462
4463    let mut parts = vec![type_disc(&target.type_name)];
4464    parts.extend(pointer_exprs);
4465    let tuple = parts.join(",\n    ");
4466    let sql = format!("\nRETURNING (\n    {}\n) AS result", tuple);
4467
4468    let root_pointers = prepend_type(shape_pointers);
4469    let shape = ShapeDescriptor {
4470        root: ShapeNode::Object {
4471            name: String::new(),
4472            type_name: Some(target.type_name.clone()),
4473            position: 0,
4474            cardinality: Cardinality::Required,
4475            pointers: root_pointers,
4476            has_implicit_id: shape_has_implicit_id(returning),
4477        },
4478    };
4479    (shape, Some(sql))
4480}
4481
4482// ── Shape emission ───────────────────────────────────────────────────────────
4483
4484fn emit_scalar_set(f: &IrScalarSetPointer, pos: usize) -> (String, ShapeNode) {
4485    let from_sql = if !f.poly_implementors.is_empty() {
4486        format!(
4487            "(\n{}\n) AS {}",
4488            emit_poly_union(&f.poly_implementors, &f.poly_columns),
4489            qi(&f.source.alias)
4490        )
4491    } else {
4492        format!("{} AS {}", source_ref(&f.source), qi(&f.source.alias))
4493    };
4494    let sql = format!(
4495        "(SELECT COALESCE(array_agg(ROW({})::record), ARRAY[]::record[]) FROM {})",
4496        emit_expr(&f.bool_expr),
4497        from_sql,
4498    );
4499    let node = ShapeNode::Array {
4500        name: f.alias.clone(),
4501        position: pos,
4502        element: Box::new(ShapeNode::Scalar {
4503            name: String::new(),
4504            position: 0,
4505        }),
4506    };
4507    (sql, node)
4508}
4509
4510/// Build SQL expressions and ShapeNodes for `pointers`, starting at position 1
4511/// (position 0 is always the type discriminator, added by the caller).
4512/// True when the compiler put an `id` at the front of this shape that the
4513/// query never asked for — see `IrScalarPointer::implicit_id`.
4514fn shape_has_implicit_id(pointers: &[IrShapePointer]) -> bool {
4515    matches!(pointers.first(), Some(IrShapePointer::Scalar(p)) if p.implicit_id)
4516}
4517
4518fn build_shape(pointers: &[IrShapePointer], table_alias: &str) -> (Vec<String>, Vec<ShapeNode>) {
4519    let mut exprs = Vec::new();
4520    let mut nodes = Vec::new();
4521
4522    for (i, pointer) in pointers.iter().enumerate() {
4523        let (sql, node) = emit_shape_pointer(pointer, table_alias, i + 1);
4524        exprs.push(sql);
4525        nodes.push(node);
4526    }
4527
4528    (exprs, nodes)
4529}
4530
4531/// A `$1`, `$2`, … template with each placeholder replaced by its argument's
4532/// SQL, in one pass over the template alone. Replacing one number at a time
4533/// over the growing string would rewrite a query parameter an argument
4534/// carries (`$5` inside the first argument, replaced again as the fifth), and
4535/// `$1` also matches the start of `$10`.
4536fn fill_template(template: &str, args: &[String]) -> String {
4537    let mut out = String::with_capacity(template.len());
4538    let mut chars = template.char_indices().peekable();
4539    while let Some((start, c)) = chars.next() {
4540        if c != '$' {
4541            out.push(c);
4542            continue;
4543        }
4544        let mut end = start + 1;
4545        while let Some((i, d)) = chars.peek().copied()
4546            && d.is_ascii_digit()
4547        {
4548            end = i + d.len_utf8();
4549            chars.next();
4550        }
4551        match template[start + 1..end].parse::<usize>() {
4552            Ok(n) if (1..=args.len()).contains(&n) => out.push_str(&args[n - 1]),
4553            _ => out.push_str(&template[start..end]),
4554        }
4555    }
4556    out
4557}
4558
4559/// `, message` for an assert given `message := …`, else nothing.
4560fn assert_message_arg(message: &Option<IrExpr>) -> String {
4561    message
4562        .as_ref()
4563        .map(|m| format!(", ({})::text", emit_expr(m)))
4564        .unwrap_or_default()
4565}
4566
4567fn emit_shape_pointer(pointer: &IrShapePointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4568    match pointer {
4569        IrShapePointer::Scalar(f) => emit_scalar(f, table_alias, pos),
4570        IrShapePointer::SingleLink(f) => emit_single_link(f, table_alias, pos),
4571        IrShapePointer::MultiLink(f) => emit_multi_link(f, table_alias, pos),
4572        IrShapePointer::Computed(f) => (emit_expr(&f.expr), expr_shape_node(&f.alias, pos, &f.expr)),
4573        IrShapePointer::ScalarSet(f) => emit_scalar_set(f, pos),
4574        IrShapePointer::Asserted(a) => {
4575            let (sql, node) = emit_shape_pointer(&a.inner, table_alias, pos);
4576            // The inner pointer aggregates rows to `record[]`, which a PL/pgSQL
4577            // function cannot take as a parameter at all -- hence the cast to
4578            // `text[]`, whose rendering compares element-for-element the same
4579            // way. Bound in a sub-select so the pointer's own subquery is
4580            // evaluated once rather than in both the check and the result, and
4581            // read through `cardinality` because the assert hands back the
4582            // array it was given.
4583            // A pointer landing on one object reads as a lone record, not an
4584            // array, so its set is re-read through `ARRAY(…)`; the unlimited
4585            // check form would otherwise fail on more than one row.
4586            let checked_set = match &a.check {
4587                Some(check) if emits_one_object(check) => {
4588                    format!("ARRAY{}", emit_shape_pointer(check, table_alias, pos).0)
4589                }
4590                Some(check) => emit_shape_pointer(check, table_alias, pos).0,
4591                None if emits_one_object(&a.inner) => format!("ARRAY{sql}"),
4592                None => "\"_a\".\"v\"".to_string(),
4593            };
4594            let checked = format!(
4595                "(SELECT \"_a\".\"v\" FROM (SELECT {sql} AS \"v\") AS \"_a\"\n                     WHERE cardinality(\"_pylon\".{}({checked_set}::text[]{})) >= 0)",
4596                qi(&a.fn_name),
4597                assert_message_arg(&a.message),
4598            );
4599            (checked, node)
4600        }
4601    }
4602}
4603
4604/// True when the pointer is emitted as a single `(SELECT …)` record rather
4605/// than an aggregated array of them.
4606fn emits_one_object(pointer: &IrShapePointer) -> bool {
4607    match pointer {
4608        IrShapePointer::SingleLink(_) => true,
4609        IrShapePointer::Computed(c) => matches!(
4610            c.expr,
4611            IrExpr::ObjectPathSubquery(_) | IrExpr::ObjectPathUnion { multi: false, .. }
4612        ),
4613        IrShapePointer::Asserted(a) => emits_one_object(&a.inner),
4614        _ => false,
4615    }
4616}
4617
4618/// Convert a PostgreSQL schema-qualified type name (`"module"."TypeName"`) to
4619/// a Pylon-qualified name (`module::TypeName`).
4620fn pg_quoted_to_pylon(pg_type: &str) -> String {
4621    let inner = pg_type.trim_start_matches('"');
4622    if let Some(idx) = inner.find(r#""."#) {
4623        let module = &inner[..idx];
4624        let type_name = inner[idx + 3..].trim_end_matches('"');
4625        format!("{}::{}", module, type_name)
4626    } else {
4627        pg_type.to_string()
4628    }
4629}
4630
4631/// A schema-qualified custom type (an enum or a domain) named in a property's
4632/// `pg_type`, together with whether the column holds an array of it.
4633///
4634/// Both forms are emitted as text -- `::text` for a scalar, `::text[]` for an
4635/// array -- so a value's shape never depends on an OID the database assigns
4636/// when the migration creating the type runs. The array case has to be told
4637/// apart from the scalar one: `"access"."AuthenticationMethod"[]` starts with a
4638/// quote just as `"account"."InterfaceAppearance"` does, and reading it as a
4639/// scalar enum casts a whole array to a single `text` (`'{MagicLink}'`) and
4640/// shapes it as one enum value.
4641struct QualifiedPgType {
4642    /// Pylon-qualified name of the element type, e.g. `access::AuthenticationMethod`.
4643    name: String,
4644    is_array: bool,
4645}
4646
4647impl QualifiedPgType {
4648    fn of(pg_type: &str) -> Option<Self> {
4649        let (element, is_array) = match pg_type.strip_suffix("[]") {
4650            Some(element) => (element, true),
4651            None => (pg_type, false),
4652        };
4653        element.starts_with('"').then(|| Self {
4654            name: pg_quoted_to_pylon(element),
4655            is_array,
4656        })
4657    }
4658
4659    /// The cast that carries this value back as text.
4660    fn text_cast(&self) -> &'static str {
4661        if self.is_array { "::text[]" } else { "::text" }
4662    }
4663
4664    /// How the decoded value is shaped: a single enum, or a list of them.
4665    fn shape_node(&self, name: String, position: usize) -> ShapeNode {
4666        let element = ShapeNode::Enum {
4667            name: name.clone(),
4668            position,
4669            enum_type: self.name.clone(),
4670        };
4671        if !self.is_array {
4672            return element;
4673        }
4674        ShapeNode::Array {
4675            name,
4676            // Each element arrives as the label itself rather than as a field
4677            // of a record, which the decoders read at position 0.
4678            element: Box::new(ShapeNode::Enum {
4679                name: String::new(),
4680                position: 0,
4681                enum_type: self.name.clone(),
4682            }),
4683            position,
4684        }
4685    }
4686}
4687
4688fn emit_scalar(f: &IrScalarPointer, table_alias: &str, pos: usize) -> (String, ShapeNode) {
4689    if let Some(nt_name) = f.pg_type.strip_prefix("__nt__:") {
4690        let sql = if table_alias.is_empty() {
4691            format!("{}::jsonb", qi(&f.column))
4692        } else {
4693            format!("{}.{}::jsonb", qi(table_alias), qi(&f.column))
4694        };
4695        return (
4696            sql,
4697            ShapeNode::NamedTuple {
4698                name: f.alias.clone(),
4699                position: pos,
4700                type_name: Some(nt_name.to_string()),
4701                members: f.tuple_shape.as_ref().map(|s| s.members.clone()),
4702                is_free_object: false,
4703            },
4704        );
4705    }
4706    // Schema-qualified custom types (enums, domains) have runtime OIDs unknown to a static
4707    // anonymous_record_decode. Cast to text — the string label is all the decoder needs.
4708    if let Some(qualified) = QualifiedPgType::of(&f.pg_type) {
4709        let cast = qualified.text_cast();
4710        let sql = if table_alias.is_empty() {
4711            format!("{}{}", qi(&f.column), cast)
4712        } else {
4713            format!("{}.{}{}", qi(table_alias), qi(&f.column), cast)
4714        };
4715        return (sql, qualified.shape_node(f.alias.clone(), pos));
4716    }
4717    // A structural pylon.Tuple[...]-typed property — same jsonb column shape
4718    // as the nominal `__nt__:` case above, just with no registered dataclass
4719    // to hydrate (type_name stays None).
4720    if let Some(shape) = &f.tuple_shape {
4721        let sql = if table_alias.is_empty() {
4722            format!("{}::jsonb", qi(&f.column))
4723        } else {
4724            format!("{}.{}::jsonb", qi(table_alias), qi(&f.column))
4725        };
4726        return (
4727            sql,
4728            ShapeNode::NamedTuple {
4729                name: f.alias.clone(),
4730                position: pos,
4731                type_name: shape.type_name.clone(),
4732                members: Some(shape.members.clone()),
4733                is_free_object: false,
4734            },
4735        );
4736    }
4737    let sql = if table_alias.is_empty() {
4738        format!("{}::{}", qi(&f.column), f.pg_type)
4739    } else {
4740        format!("{}.{}::{}", qi(table_alias), qi(&f.column), f.pg_type)
4741    };
4742    (
4743        sql,
4744        ShapeNode::Scalar {
4745            name: f.alias.clone(),
4746            position: pos,
4747        },
4748    )
4749}
4750
4751fn emit_single_link(f: &IrSingleLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
4752    let sub = &f.subquery;
4753    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
4754        unreachable!("single-link subquery is always schema-bound")
4755    };
4756    let sub_alias = &source.alias;
4757
4758    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
4759    let mut parts = vec![source_type_disc(source)];
4760    parts.extend(sub_exprs);
4761
4762    // Link properties: read from the junction table alias "jt" — only ever
4763    // populated for a junction-backed link (see `IrSingleLinkPointer`'s doc
4764    // comment), which is exactly when `f.correlation` below puts "jt" in
4765    // scope.
4766    for lp in &f.link_properties {
4767        parts.push(format!("\"jt\".{}", qi(&lp.name)));
4768        let pos = sub_nodes.len() + 1;
4769        sub_nodes.push(ShapeNode::Scalar {
4770            name: format!("@{}", lp.name),
4771            position: pos,
4772        });
4773    }
4774
4775    let tuple = parts.join(",\n        ");
4776
4777    let (from_sql, mut where_parts) = match &f.correlation {
4778        IrSingleLinkCorrelation::Fk { fk_column, target_pk } => {
4779            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
4780            let cond = format!(
4781                "{}.{} = {}.{}",
4782                qi(parent_alias),
4783                qi(fk_column),
4784                qi(sub_alias),
4785                qi(target_pk),
4786            );
4787            (from, vec![cond])
4788        }
4789        // Junction-backed — same join shape a multi-link's correlated
4790        // subquery uses; cardinality-one is a schema-level invariant on the
4791        // junction table, not something this query needs to enforce itself.
4792        IrSingleLinkCorrelation::Junction { join, target_pk: _ } => match join {
4793            IrMultiLinkJoin::Standard { junction_table, module } => {
4794                let from = format!(
4795                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
4796                    junction_ref(module, junction_table),
4797                    source_ref(source),
4798                    qi(sub_alias),
4799                    qi(sub_alias),
4800                );
4801                let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
4802                (from, vec![cond])
4803            }
4804            IrMultiLinkJoin::Through {
4805                junction_table,
4806                module,
4807                source_col,
4808                target_col,
4809            } => {
4810                let from = format!(
4811                    "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4812                    junction_ref(module, junction_table),
4813                    source_ref(source),
4814                    qi(sub_alias),
4815                    qi(sub_alias),
4816                    qi(target_col),
4817                );
4818                let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
4819                (from, vec![cond])
4820            }
4821            IrMultiLinkJoin::BacklinkFk { .. } | IrMultiLinkJoin::BacklinkJunction { .. } => {
4822                unreachable!("a junction-backed single link's own forward join is always Standard or Through")
4823            }
4824        },
4825    };
4826    if let Some(filter) = &sub.filter {
4827        where_parts.push(emit_expr(filter));
4828    }
4829
4830    let mut sql = format!(
4831        "(SELECT (\n        {}\n    )\n    {}\n    WHERE {}",
4832        tuple,
4833        from_sql,
4834        where_parts.join(" AND "),
4835    );
4836    if !sub.order_by.is_empty() {
4837        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
4838        sql.push_str(&format!("\n    ORDER BY {}", s.join(", ")));
4839    }
4840    sql.push(')');
4841
4842    let node = ShapeNode::Object {
4843        name: f.alias.clone(),
4844        type_name: Some(source.type_name.clone()),
4845        position: pos,
4846        cardinality: Cardinality::Optional,
4847        pointers: prepend_type(sub_nodes),
4848        has_implicit_id: shape_has_implicit_id(shape),
4849    };
4850    (sql, node)
4851}
4852
4853fn emit_multi_link(f: &IrMultiLinkPointer, parent_alias: &str, pos: usize) -> (String, ShapeNode) {
4854    let sub = &f.subquery;
4855    let [IrRowSource::Bound { source, shape }] = sub.rows.as_slice() else {
4856        unreachable!("multi-link subquery is always schema-bound")
4857    };
4858    let sub_alias = &source.alias;
4859
4860    let (sub_exprs, mut sub_nodes) = build_shape(shape, sub_alias);
4861    let mut row_parts = vec![source_type_disc(source)];
4862    row_parts.extend(sub_exprs);
4863
4864    // Link properties: read from the junction table alias "jt".
4865    // The ShapeNode name carries the `@` prefix so hydration stores it as
4866    // `@prop` in the object's __dict__ and the REPL displays it with `@`.
4867    for lp in &f.link_properties {
4868        row_parts.push(format!("\"jt\".{}", qi(&lp.name)));
4869        // Positions are 1-based (0 = type discriminator). sub_nodes.len() gives
4870        // the count of already-assigned positions, so the next position is len+1.
4871        let pos = sub_nodes.len() + 1;
4872        sub_nodes.push(ShapeNode::Scalar {
4873            name: format!("@{}", lp.name),
4874            position: pos,
4875        });
4876    }
4877
4878    let row = row_parts.join(",\n            ");
4879
4880    // ORDER BY inside array_agg
4881    let order_sql = if !sub.order_by.is_empty() {
4882        let s: Vec<_> = sub.order_by.iter().map(emit_sort_clause).collect();
4883        format!(" ORDER BY {}", s.join(", "))
4884    } else {
4885        String::new()
4886    };
4887
4888    let (from_sql, source_cond) = match &f.join {
4889        IrMultiLinkJoin::Standard { junction_table, module } => {
4890            let from = format!(
4891                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".target",
4892                junction_ref(module, junction_table),
4893                source_ref(source),
4894                qi(sub_alias),
4895                qi(sub_alias),
4896            );
4897            let cond = format!("\"jt\".source = {}.id", qi(parent_alias));
4898            (from, cond)
4899        }
4900        IrMultiLinkJoin::Through {
4901            junction_table,
4902            module,
4903            source_col,
4904            target_col,
4905        } => {
4906            let from = format!(
4907                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4908                junction_ref(module, junction_table),
4909                source_ref(source),
4910                qi(sub_alias),
4911                qi(sub_alias),
4912                qi(target_col),
4913            );
4914            let cond = format!("\"jt\".{} = {}.id", qi(source_col), qi(parent_alias));
4915            (from, cond)
4916        }
4917        // Reverse of a single link: no junction table, the owner
4918        // (sub-select) rows are correlated directly by their own FK column.
4919        IrMultiLinkJoin::BacklinkFk { fk_col } => {
4920            let from = format!("FROM {} AS {}", source_ref(source), qi(sub_alias));
4921            let cond = format!("{}.{} = {}.id", qi(sub_alias), qi(fk_col), qi(parent_alias));
4922            (from, cond)
4923        }
4924        // Reverse of a multi-link: same junction table shape as `Standard`/
4925        // `Through`, with the owner/current column roles swapped — the
4926        // sub-select's own rows join via `owner_col`, the outer (current)
4927        // row correlates via `current_col`.
4928        IrMultiLinkJoin::BacklinkJunction {
4929            junction_table,
4930            module,
4931            owner_col,
4932            current_col,
4933        } => {
4934            let from = format!(
4935                "FROM {} AS \"jt\"\n    INNER JOIN {} AS {}\n    ON {}.id = \"jt\".{}",
4936                junction_ref(module, junction_table),
4937                source_ref(source),
4938                qi(sub_alias),
4939                qi(sub_alias),
4940                qi(owner_col),
4941            );
4942            let cond = format!("\"jt\".{} = {}.id", qi(current_col), qi(parent_alias));
4943            (from, cond)
4944        }
4945    };
4946
4947    let mut where_parts = vec![source_cond];
4948    if let Some(filter) = &sub.filter {
4949        where_parts.push(emit_expr(filter));
4950    }
4951
4952    if f.single {
4953        let mut sql = format!(
4954            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
4955            row,
4956            from_sql,
4957            where_parts.join(" AND "),
4958        );
4959        append_order_by(&mut sql, &sub.order_by);
4960        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
4961        sql.push(')');
4962        let node = ShapeNode::Object {
4963            name: f.alias.clone(),
4964            type_name: Some(source.type_name.clone()),
4965            position: pos,
4966            cardinality: Cardinality::Optional,
4967            pointers: prepend_type(sub_nodes),
4968            has_implicit_id: shape_has_implicit_id(shape),
4969        };
4970        return (sql, node);
4971    }
4972
4973    if f.single {
4974        let mut sql = format!(
4975            "(SELECT (\n            {}\n        )\n    {}\n    WHERE {}",
4976            row,
4977            from_sql,
4978            where_parts.join(" AND "),
4979        );
4980        append_order_by(&mut sql, &sub.order_by);
4981        append_offset_limit(&mut sql, &sub.offset, &sub.limit);
4982        sql.push(')');
4983        let node = ShapeNode::Object {
4984            name: f.alias.clone(),
4985            type_name: Some(source.type_name.clone()),
4986            position: pos,
4987            cardinality: Cardinality::Optional,
4988            pointers: prepend_type(sub_nodes),
4989            has_implicit_id: shape_has_implicit_id(shape),
4990        };
4991        return (sql, node);
4992    }
4993
4994    let sql = if sub.limit.is_some() || sub.offset.is_some() {
4995        // OFFSET/LIMIT cut the *rows* that go into the array, so they can't
4996        // sit next to the aggregate — the aggregate collapses them to one
4997        // row first. Select the row's parts (and any sort keys) in a derived
4998        // table that carries the modifiers, then aggregate over that.
4999        let mut cols: Vec<String> = row_parts
5000            .iter()
5001            .enumerate()
5002            .map(|(i, part)| format!("{} AS \"c{}\"", part, i))
5003            .collect();
5004        let agg_row: Vec<String> = (0..row_parts.len()).map(|i| format!("\"__lim\".\"c{}\"", i)).collect();
5005        let mut agg_order: Vec<String> = vec![];
5006        for (i, s) in sub.order_by.iter().enumerate() {
5007            cols.push(format!("{} AS \"s{}\"", emit_expr(&s.expr), i));
5008            agg_order.push(emit_sort_clause(&IrSort {
5009                expr: IrExpr::ColumnRef {
5010                    alias: "__lim".to_string(),
5011                    column: format!("s{}", i),
5012                    pg_type: String::new(),
5013                },
5014                direction: s.direction.clone(),
5015                nulls: s.nulls.clone(),
5016            }));
5017        }
5018        let mut inner = format!(
5019            "SELECT {}\n    {}\n    WHERE {}",
5020            cols.join(",\n        "),
5021            from_sql,
5022            where_parts.join(" AND "),
5023        );
5024        append_order_by(&mut inner, &sub.order_by);
5025        append_offset_limit(&mut inner, &sub.offset, &sub.limit);
5026        let agg_order_sql = if agg_order.is_empty() {
5027            String::new()
5028        } else {
5029            format!(" ORDER BY {}", agg_order.join(", "))
5030        };
5031        format!(
5032            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    FROM ({}) AS \"__lim\")",
5033            agg_row.join(",\n            "),
5034            agg_order_sql,
5035            inner,
5036        )
5037    } else {
5038        format!(
5039            "(SELECT COALESCE(\n        array_agg(ROW(\n            {}\n        )::record{}),\n        ARRAY[]::record[]\n    )\n    {}\n    WHERE {})",
5040            row,
5041            order_sql,
5042            from_sql,
5043            where_parts.join(" AND "),
5044        )
5045    };
5046
5047    let node = ShapeNode::Array {
5048        name: f.alias.clone(),
5049        position: pos,
5050        element: Box::new(ShapeNode::Object {
5051            name: String::new(),
5052            type_name: Some(source.type_name.clone()),
5053            position: 0,
5054            cardinality: Cardinality::Required,
5055            pointers: prepend_type(sub_nodes),
5056            has_implicit_id: shape_has_implicit_id(shape),
5057        }),
5058    };
5059    (sql, node)
5060}
5061
5062/// Prepend `ShapeNode::Scalar { name: "__type__", position: 0 }` and shift
5063/// existing nodes' positions by 1.
5064fn prepend_type(nodes: Vec<ShapeNode>) -> Vec<ShapeNode> {
5065    let mut out = vec![ShapeNode::Scalar {
5066        name: "__type__".into(),
5067        position: 0,
5068    }];
5069    out.extend(nodes);
5070    out
5071}
5072
5073// ── SQL clause helpers ──────────────────────────────────────────────────────
5074
5075fn append_filter(sql: &mut String, filter: &Option<IrExpr>) {
5076    if let Some(f) = filter {
5077        sql.push_str(&format!("\nWHERE {}", emit_expr(f)));
5078    }
5079}
5080
5081fn append_order_by(sql: &mut String, order_by: &[IrSort]) {
5082    if !order_by.is_empty() {
5083        let s: Vec<_> = order_by.iter().map(emit_sort_clause).collect();
5084        sql.push_str(&format!("\nORDER BY {}", s.join(", ")));
5085    }
5086}
5087
5088fn append_offset_limit(sql: &mut String, offset: &Option<IrExpr>, limit: &Option<IrExpr>) {
5089    if let Some(o) = offset {
5090        sql.push_str(&format!("\nOFFSET {}", emit_expr(o)));
5091    }
5092    if let Some(l) = limit {
5093        sql.push_str(&format!("\nLIMIT {}", emit_expr(l)));
5094    }
5095}
5096
5097/// `FOR UPDATE`/`FOR SHARE`/... — Postgres's own grammar places this last,
5098/// after `ORDER BY`/`LIMIT`/`OFFSET`, so this must be the final thing
5099/// appended to a bound SELECT's SQL. No `OF table_name` — the outer
5100/// SELECT's own aliased table is always the only lockable target (nested
5101/// shape pointers are correlated subqueries, never part of the outer FROM),
5102/// so a bare `FOR UPDATE` already targets exactly the right row(s).
5103fn append_lock_clause(sql: &mut String, lock: &Option<IrLockClause>) {
5104    let Some(lock) = lock else { return };
5105    let strength = match lock.strength {
5106        IrLockStrength::Update => "UPDATE",
5107        IrLockStrength::NoKeyUpdate => "NO KEY UPDATE",
5108        IrLockStrength::Share => "SHARE",
5109        IrLockStrength::KeyShare => "KEY SHARE",
5110    };
5111    sql.push_str(&format!("\nFOR {}", strength));
5112    match lock.wait {
5113        IrLockWait::Block => {}
5114        IrLockWait::NoWait => sql.push_str(" NOWAIT"),
5115        IrLockWait::SkipLocked => sql.push_str(" SKIP LOCKED"),
5116    }
5117}
5118
5119fn emit_sort_clause(s: &IrSort) -> String {
5120    let dir = match s.direction {
5121        IrSortDir::Asc => "ASC",
5122        IrSortDir::Desc => "DESC",
5123    };
5124    let nulls = match s.nulls {
5125        IrNulls::First => "NULLS FIRST",
5126        IrNulls::Last => "NULLS LAST",
5127    };
5128    format!("{} {} {}", emit_expr(&s.expr), dir, nulls)
5129}
5130
5131// ── Expression emission ─────────────────────────────────────────────────────
5132
5133/// One side of `a intersect b` as the rows of the set it stands for: an
5134/// array's elements, a binding's rows, else the single value.
5135fn set_operand(expr: &IrExpr) -> String {
5136    match expr {
5137        IrExpr::ArrayFromSelect(_) => format!("SELECT unnest({})", emit_expr(expr)),
5138        IrExpr::CteRef { name, scalar, .. } => {
5139            format!("SELECT {} FROM {}", if *scalar { "\"v\"" } else { "\"id\"" }, qi(name))
5140        }
5141        _ => format!("SELECT {}", emit_expr(expr)),
5142    }
5143}
5144
5145/// `emit_expr`, reading the types with subtypes through `fanouts` the way
5146/// `emit` does — see `IrOutput::subtype_fanouts`.
5147pub fn emit_expr_with_fanouts(expr: &IrExpr, fanouts: &HashMap<(String, String), IrPolyFanout>) -> String {
5148    with_subtype_fanouts(fanouts, || emit_expr(expr))
5149}
5150
5151pub fn emit_expr(expr: &IrExpr) -> String {
5152    match expr {
5153        IrExpr::ColumnRef { alias, column, .. } => {
5154            if alias.is_empty() {
5155                qi(column)
5156            } else if alias == "NEW" || alias == "OLD" {
5157                // plpgsql's trigger row variables (`ir::compile_trigger_handler`'s
5158                // `__new__`/`__old__` anchors) are recognized by the plpgsql
5159                // parser as bare, unquoted identifiers — quoting them like an
5160                // ordinary table alias (`"NEW"."col"`) turns this into an
5161                // *ordinary* SQL column reference instead, which Postgres then
5162                // rejects with "missing FROM-clause entry for table NEW" the
5163                // moment it appears inside a nested SQL command (e.g. the
5164                // VALUES list of an `INSERT` the trigger handler runs) — there's
5165                // no real `NEW` table to resolve it against there, only
5166                // plpgsql's own row-variable substitution recognizes it.
5167                format!("{alias}.{}", qi(column))
5168            } else {
5169                format!("{}.{}", qi(alias), qi(column))
5170            }
5171        }
5172        IrExpr::Param { index } => format!("${}", index + 1),
5173        IrExpr::Literal(lit) => emit_literal(lit),
5174        IrExpr::BinOp(op) => {
5175            let l = emit_expr(&op.left);
5176            let r = emit_expr(&op.right);
5177            match op.op {
5178                BinOpKind::Add => format!("({} + {})", l, r),
5179                BinOpKind::Sub => format!("({} - {})", l, r),
5180                BinOpKind::Mul => format!("({} * {})", l, r),
5181                BinOpKind::Div => format!("({} / {})", l, r),
5182                BinOpKind::FloorDiv => {
5183                    if is_integer_expr(&op.left) && is_integer_expr(&op.right) {
5184                        format!("({} / {})", l, r)
5185                    } else {
5186                        format!("floor(({}) / ({}))", l, r)
5187                    }
5188                }
5189                BinOpKind::Mod => format!("({} % {})", l, r),
5190                BinOpKind::Pow => format!("power({}, {})", l, r),
5191                BinOpKind::Eq => format!("({} = {})", l, r),
5192                BinOpKind::Ne => format!("({} <> {})", l, r),
5193                BinOpKind::Lt => format!("({} < {})", l, r),
5194                BinOpKind::Le => format!("({} <= {})", l, r),
5195                BinOpKind::Gt => format!("({} > {})", l, r),
5196                BinOpKind::Ge => format!("({} >= {})", l, r),
5197                BinOpKind::And => format!("({} AND {})", l, r),
5198                BinOpKind::Or => format!("({} OR {})", l, r),
5199                BinOpKind::Like => format!("({} LIKE {})", l, r),
5200                BinOpKind::Ilike => format!("({} ILIKE {})", l, r),
5201                BinOpKind::NotLike => format!("({} NOT LIKE {})", l, r),
5202                BinOpKind::NotIlike => format!("({} NOT ILIKE {})", l, r),
5203                // `ANY`/`ALL` take the array directly, so unwrap a
5204                // right-hand `std::array_unpack` rather than emitting its
5205                // `unnest()` — a set-returning function is not allowed in
5206                // `WHERE`, and PostgreSQL rejects it at execution time even
5207                // though the PyQL and the SQL both look fine.
5208                BinOpKind::In => format!("({} = ANY({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5209                BinOpKind::NotIn => format!("({} <> ALL({}))", l, unwrap_unnest_for_any(&op.right, &r)),
5210                BinOpKind::Coalesce => format!("COALESCE({}, {})", l, r),
5211                BinOpKind::CoalesceEq => format!("({} IS NOT DISTINCT FROM {})", l, r),
5212                BinOpKind::CoalesceNe => format!("({} IS DISTINCT FROM {})", l, r),
5213                BinOpKind::Concat => format!("({} || {})", l, r),
5214            }
5215        }
5216        IrExpr::UnaryOp(op) => {
5217            let inner = emit_expr(&op.operand);
5218            match op.op {
5219                UnaryOpKind::Not => format!("(NOT {})", inner),
5220                UnaryOpKind::Minus => format!("(-{})", inner),
5221                UnaryOpKind::Exists => format!("EXISTS({})", inner),
5222                // A value that reached expression position is a single one,
5223                // and a single value is already distinct — `DISTINCT x` is not
5224                // an SQL expression. Set-valued operands carry their own
5225                // DISTINCT, applied where the set is built.
5226                UnaryOpKind::Distinct => inner,
5227            }
5228        }
5229        IrExpr::FunctionCall(f) => {
5230            let args: Vec<_> = f.args.iter().map(emit_expr).collect();
5231            if let Some(tmpl) = &f.sql_template {
5232                return fill_template(tmpl, &args);
5233            }
5234            let name = match &f.schema {
5235                Some(s) => format!("{}.{}", pg_schema(s), qi(&f.name)),
5236                None => f.name.clone(),
5237            };
5238            format!("{}({})", name, args.join(", "))
5239        }
5240        IrExpr::TypeCast(c) => {
5241            // PostgreSQL doesn't support arbitrary_type::jsonb; to_jsonb() accepts any input.
5242            // String literals have type "unknown" in PG, so cast to text first.
5243            if c.pg_type == "jsonb" {
5244                match &c.expr {
5245                    // A bare `$N` parameter has no PG type at all until told
5246                    // otherwise. to_jsonb($N) can't resolve it — it's a
5247                    // polymorphic function, which needs a *concrete* input
5248                    // type to dispatch on, and an unknown-typed placeholder
5249                    // gives it nothing to work with ("could not determine
5250                    // polymorphic type because input has type unknown").
5251                    // A direct ($N)::jsonb cast works because PG specially
5252                    // resolves an unknown-typed parameter against an
5253                    // explicit cast — the same reason `$1::uuid` works fine
5254                    // elsewhere in this file.
5255                    IrExpr::Param { .. } => format!("({})::jsonb", emit_expr(&c.expr)),
5256                    IrExpr::Literal(IrLiteral::Str(_)) => {
5257                        format!("to_jsonb({}::text)", emit_expr(&c.expr))
5258                    }
5259                    _ => format!("to_jsonb({})", emit_expr(&c.expr)),
5260                }
5261            } else if yields_jsonb(&c.expr) {
5262                // Casting json to a scalar yields the value, not its JSON
5263                // spelling — `<str>` of a json string is the string, with no
5264                // quotes around it. `::text` would keep them.
5265                format!("(({}) #>> '{{}}')::{}", emit_expr(&c.expr), c.pg_type)
5266            } else {
5267                format!("({})::{}", emit_expr(&c.expr), c.pg_type)
5268            }
5269        }
5270        IrExpr::IfElse(ie) => format!(
5271            "CASE WHEN {} THEN {} ELSE {} END",
5272            emit_expr(&ie.condition),
5273            emit_expr(&ie.if_),
5274            emit_expr(&ie.else_),
5275        ),
5276        IrExpr::Array(elems) => {
5277            if elems.is_empty() {
5278                "ARRAY[]::text[]".to_string()
5279            } else {
5280                let parts: Vec<String> = elems.iter().map(emit_expr).collect();
5281                format!("ARRAY[{}]", parts.join(", "))
5282            }
5283        }
5284        IrExpr::Null => "NULL".to_string(),
5285        IrExpr::AggOverSet {
5286            fn_name,
5287            schema: _,
5288            elems,
5289        } => {
5290            let union_all = elems
5291                .iter()
5292                .map(|e| format!("SELECT {}", emit_expr(e)))
5293                .collect::<Vec<_>>()
5294                .join(" UNION ALL ");
5295            format!("(SELECT {}(v) FROM ({}) AS _set(v))", fn_name, union_all)
5296        }
5297        IrExpr::AggOverCte { fn_name, cte, column } => format!(
5298            "(SELECT {}({}) FROM {})",
5299            fn_name,
5300            column.as_deref().map(qi).unwrap_or_else(|| "*".to_string()),
5301            qi(cte),
5302        ),
5303        IrExpr::ExistsOverCte { cte, column } => match column {
5304            Some(column) => format!("EXISTS(SELECT 1 FROM {} WHERE {} IS NOT NULL)", qi(cte), qi(column)),
5305            None => format!("EXISTS(SELECT 1 FROM {})", qi(cte)),
5306        },
5307        IrExpr::AggOverQuery { fn_name, inner } => {
5308            let inner_sql = emit_select_stmt(inner, &[]).sql;
5309            format!("(SELECT {}(*) FROM ({}) _agg)", fn_name, inner_sql)
5310        }
5311        IrExpr::ArrayFromSelect(src) => emit_array_source(src),
5312        IrExpr::ScalarSubquery(sel) => {
5313            format!(
5314                "(SELECT \"v\" FROM (\n{}\n) AS \"_scalar_sub\")",
5315                emit_select_stmt(sel, &[]).sql
5316            )
5317        }
5318
5319        IrExpr::SetOp { op, left, right, mode } => {
5320            let set = format!("({}) {} ({})", set_operand(left), op.sql(), set_operand(right));
5321            match mode {
5322                crate::ir::SetOpMode::Exists => format!("EXISTS({set})"),
5323                crate::ir::SetOpMode::Array => format!("ARRAY({set})"),
5324                crate::ir::SetOpMode::Aggregate(f) => {
5325                    format!("(SELECT {f}(\"_s\".\"v\") FROM ({set}) AS \"_s\"(\"v\"))")
5326                }
5327            }
5328        }
5329
5330        IrExpr::CteRef { name, scalar, .. } => {
5331            // scalar CTEs emit `ROW(expr) AS result, expr AS v`; use `v` for
5332            // expression context so we get the plain scalar type, not record.
5333            let col = if *scalar { "v" } else { "id" };
5334            format!("(SELECT \"{}\" FROM \"{}\")", col, name)
5335        }
5336
5337        IrExpr::CteFieldRef { name, field, .. } => {
5338            format!("(SELECT {} FROM {})", qi(field), qi(name))
5339        }
5340
5341        IrExpr::ForVar { name, .. } => format!("\"_for_{}\".\"v\"", name),
5342
5343        IrExpr::EnumLiteral { pg_type, variant } => {
5344            format!("'{}'::{}", variant.replace('\'', "''"), pg_type)
5345        }
5346
5347        IrExpr::GlobalParam { index, pg_type } => {
5348            format!("(${}::{})", index + 1, pg_type)
5349        }
5350
5351        IrExpr::GlobalRef { cte_name } => {
5352            format!("(SELECT \"value\" FROM \"{}\")", cte_name)
5353        }
5354
5355        IrExpr::NamedTuple { fields, .. } => {
5356            let pairs: Vec<String> = fields
5357                .iter()
5358                .flat_map(|(k, v)| [format!("'{}'", k.replace('\'', "''")), emit_expr(v)])
5359                .collect();
5360            format!("jsonb_build_object({})", pairs.join(", "))
5361        }
5362
5363        IrExpr::Tuple(elems) => {
5364            let items: Vec<String> = elems.iter().map(emit_expr).collect();
5365            format!("jsonb_build_array({})", items.join(", "))
5366        }
5367
5368        IrExpr::Subscript { expr, index, is_array } => {
5369            let e = emit_expr(expr);
5370            let i = emit_expr(index);
5371            if *is_array {
5372                format!("_pylon.array_subscript({}, ({})::bigint)", e, i)
5373            } else {
5374                format!("_pylon.str_subscript({}, ({})::bigint)", e, i)
5375            }
5376        }
5377
5378        IrExpr::Slice {
5379            expr,
5380            lower,
5381            upper,
5382            is_array,
5383        } => {
5384            let e = emit_expr(expr);
5385            if *is_array {
5386                let lo = lower
5387                    .as_deref()
5388                    .map(|x| format!("({}) + 1", emit_expr(x)))
5389                    .unwrap_or_else(|| "1".to_string());
5390                let hi = upper.as_deref().map(emit_expr).unwrap_or_default();
5391                if hi.is_empty() {
5392                    format!("({})[{}:]", e, lo)
5393                } else {
5394                    format!("({})[{}:{}]", e, lo, hi)
5395                }
5396            } else {
5397                // substr(expr, start, length) for text/bytea.
5398                let start = lower
5399                    .as_deref()
5400                    .map(|x| format!("({}) + 1", emit_expr(x)))
5401                    .unwrap_or_else(|| "1".to_string());
5402                match upper.as_deref() {
5403                    Some(hi_expr) => {
5404                        let lo_val = lower.as_deref().map(emit_expr).unwrap_or_else(|| "0".to_string());
5405                        // GREATEST(0, ...) so reversed bounds yield '' instead of a PG error.
5406                        format!(
5407                            "substr({}, {}, GREATEST(0, ({}) - ({})))",
5408                            e,
5409                            start,
5410                            emit_expr(hi_expr),
5411                            lo_val
5412                        )
5413                    }
5414                    None => format!("substr({}, {})", e, start),
5415                }
5416            }
5417        }
5418
5419        IrExpr::JsonbField { expr, field } => {
5420            format!("({}->{})", emit_expr(expr), sql_str(field))
5421        }
5422
5423        IrExpr::JsonbIndex { expr, index } => {
5424            format!("({}->{})", emit_expr(expr), index)
5425        }
5426
5427        IrExpr::FnParam { name, .. } => qi(name),
5428
5429        IrExpr::FnSubquery(fs) => {
5430            let scalar = match fs.shape.as_slice() {
5431                [IrShapePointer::Computed(c)] => emit_expr(&c.expr),
5432                _ => unreachable!("a function scalar subquery always projects exactly one computed pointer"),
5433            };
5434            let args_sql = fs.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
5435            let mut sql = format!(
5436                "(SELECT {}{}\nFROM {}.{}({}) AS {}",
5437                if fs.distinct { "DISTINCT " } else { "" },
5438                scalar,
5439                pg_schema(&fs.fn_module),
5440                qi(&fs.fn_name),
5441                args_sql,
5442                qi(&fs.alias),
5443            );
5444            append_filter(&mut sql, &fs.filter);
5445            append_order_by(&mut sql, &fs.order_by);
5446            append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5447            sql.push(')');
5448            sql
5449        }
5450
5451        IrExpr::PathSubquery(ps) => {
5452            let scalar = match &ps.result {
5453                IrPathResult::Scalar(e, _) => emit_expr(e),
5454                IrPathResult::Object { alias, .. } => format!("{}.\"id\"", qi(alias)),
5455            };
5456            let from_sql = emit_path_joins(&ps.root, &ps.joins);
5457            let mut sql = format!(
5458                "(SELECT {}{}\nFROM {}",
5459                if ps.distinct { "DISTINCT " } else { "" },
5460                scalar,
5461                from_sql
5462            );
5463            append_filter(&mut sql, &ps.filter);
5464            append_order_by(&mut sql, &ps.order_by);
5465            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5466            sql.push(')');
5467            sql
5468        }
5469
5470        IrExpr::ObjectPathUnion { branches, limit, multi } => {
5471            let arms: Vec<String> = branches
5472                .iter()
5473                .map(|ps| {
5474                    let IrPathResult::Object {
5475                        alias,
5476                        type_name,
5477                        shape,
5478                    } = &ps.result
5479                    else {
5480                        unreachable!("an object path union's branches always land on objects")
5481                    };
5482                    let (pointer_exprs, _) = build_shape(shape, alias);
5483                    let mut parts = vec![result_type_disc(ps, alias, type_name)];
5484                    parts.extend(pointer_exprs);
5485                    let mut sql = format!(
5486                        "SELECT (\n    {}\n) AS \"r\"\nFROM {}",
5487                        parts.join(",\n    "),
5488                        emit_path_joins(&ps.root, &ps.joins),
5489                    );
5490                    append_filter(&mut sql, &ps.filter);
5491                    sql
5492                })
5493                .collect();
5494            let mut sql = format!(
5495                "{}(SELECT \"r\" FROM (\n{}\n) AS \"_u\"",
5496                if *multi { "ARRAY" } else { "" },
5497                arms.join("\nUNION ALL\n")
5498            );
5499            if let Some(limit) = limit {
5500                sql.push_str(&format!("\nLIMIT {}", emit_expr(limit)));
5501            }
5502            sql.push(')');
5503            sql
5504        }
5505
5506        IrExpr::ObjectPathSubquery(ps) => {
5507            let IrPathResult::Object {
5508                alias,
5509                type_name,
5510                shape,
5511            } = &ps.result
5512            else {
5513                unreachable!("an object path subquery always lands on an object")
5514            };
5515            let (pointer_exprs, _) = build_shape(shape, alias);
5516            let mut parts = vec![result_type_disc(ps, alias, type_name)];
5517            parts.extend(pointer_exprs);
5518            let mut sql = format!(
5519                "(SELECT (\n    {}\n)\nFROM {}",
5520                parts.join(",\n    "),
5521                emit_path_joins(&ps.root, &ps.joins),
5522            );
5523            append_filter(&mut sql, &ps.filter);
5524            append_order_by(&mut sql, &ps.order_by);
5525            append_offset_limit(&mut sql, &ps.offset, &ps.limit);
5526            sql.push(')');
5527            sql
5528        }
5529
5530        IrExpr::ObjectSubquery(sel) => {
5531            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5532                unreachable!("an object subquery is always schema-bound")
5533            };
5534            let alias = &source.alias;
5535            let (sub_exprs, _) = build_shape(shape, alias);
5536            let mut row_parts = vec![source_type_disc(source)];
5537            row_parts.extend(sub_exprs);
5538            let mut sql = format!(
5539                "(SELECT (\n        {}\n    )\n    FROM {} AS {}",
5540                row_parts.join(",\n        "),
5541                source_ref(source),
5542                qi(alias),
5543            );
5544            append_filter(&mut sql, &sel.filter);
5545            append_order_by(&mut sql, &sel.order_by);
5546            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5547            sql.push(')');
5548            sql
5549        }
5550
5551        IrExpr::Subquery(sel) => {
5552            let [IrRowSource::Bound { source, shape }] = sel.rows.as_slice() else {
5553                unreachable!("scalar/exists subquery is always schema-bound")
5554            };
5555            let alias = &source.alias;
5556            let mut sql = if shape.is_empty() {
5557                // EXISTS inner: SELECT 1 FROM …
5558                format!("(SELECT 1\nFROM {} AS {}", source_ref(source), qi(alias))
5559            } else if let Some(c) = shape.iter().find_map(|f| {
5560                if let IrShapePointer::Computed(c) = f {
5561                    Some(c)
5562                } else {
5563                    None
5564                }
5565            }) {
5566                // Computed subquery (e.g. a type-intersection splat's
5567                // computed pointer): select the compiled expression itself,
5568                // not a bare column — the expression already references
5569                // `alias`'s own columns via ColumnRef.
5570                format!(
5571                    "(SELECT {}\nFROM {} AS {}",
5572                    emit_expr(&c.expr),
5573                    source_ref(source),
5574                    qi(alias)
5575                )
5576            } else {
5577                // Scalar subquery: SELECT alias.col FROM …
5578                let pk_col = shape
5579                    .iter()
5580                    .find_map(|f| {
5581                        if let IrShapePointer::Scalar(s) = f {
5582                            Some(s.column.as_str())
5583                        } else {
5584                            None
5585                        }
5586                    })
5587                    .unwrap_or("id");
5588                format!(
5589                    "(SELECT {}.{}\nFROM {} AS {}",
5590                    qi(alias),
5591                    qi(pk_col),
5592                    source_ref(source),
5593                    qi(alias),
5594                )
5595            };
5596            append_filter(&mut sql, &sel.filter);
5597            append_order_by(&mut sql, &sel.order_by);
5598            append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5599            sql.push(')');
5600            sql
5601        }
5602        IrExpr::RawSql(s) => format!("({s})"),
5603    }
5604}
5605
5606// ── Vector search ────────────────────────────────────────────────────────────
5607
5608fn emit_vector_search(vs: &IrVectorSearch) -> SqlOutput {
5609    let alias = &vs.source.alias;
5610    let dist_sql = format!(
5611        "{}.{} {} {}",
5612        qi(alias),
5613        qi(&vs.vector_col),
5614        vs.distance_op,
5615        emit_expr(&vs.query_expr),
5616    );
5617
5618    // Build the object sub-tuple.  If object_shape is empty, include all properties
5619    // (type disc + id implicitly come from build_shape when no elements provided;
5620    //  with no shape elements the shape is empty, so we fall back to "just id").
5621    // Use build_shape when there are explicit shape pointers; otherwise emit a minimal tuple.
5622    let (obj_tuple, object_shape_nodes) = if vs.object_shape.is_empty() {
5623        // No explicit shape: produce (type_disc, id) as minimum.
5624        let type_expr = type_disc(&vs.source.type_name);
5625        let id_expr = format!("{}.\"id\"", qi(alias));
5626        let tuple = format!("{},\n    {}", type_expr, id_expr);
5627        let id_node = ShapeNode::Scalar {
5628            name: "id".to_string(),
5629            position: 1,
5630        };
5631        (tuple, vec![id_node])
5632    } else {
5633        let (pointer_exprs, shape_pointers) = build_shape(&vs.object_shape, alias);
5634        let mut parts = vec![type_disc(&vs.source.type_name)];
5635        parts.extend(pointer_exprs);
5636        (parts.join(",\n    "), prepend_type(shape_pointers))
5637    };
5638
5639    // Outer tuple: NULL (type slot), object sub-tuple at pos 1, distance at pos 2.
5640    let outer = format!(
5641        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5642        obj_tuple, dist_sql,
5643    );
5644    let mut sql = format!(
5645        "SELECT (\n    {}\n) AS result\nFROM {} AS {}",
5646        outer,
5647        source_ref(&vs.source),
5648        qi(alias),
5649    );
5650    append_filter(&mut sql, &vs.filter);
5651
5652    // ORDER BY distance if requested.
5653    if let Some(dir) = &vs.order_by_distance {
5654        let dir_sql = match dir {
5655            IrSortDir::Asc => "ASC",
5656            IrSortDir::Desc => "DESC",
5657        };
5658        sql.push_str(&format!("\nORDER BY {} {}", dist_sql, dir_sql));
5659    }
5660    append_offset_limit(&mut sql, &vs.offset, &vs.limit);
5661
5662    let object_node = ShapeNode::Object {
5663        name: "object".to_string(),
5664        type_name: Some(vs.source.type_name.clone()),
5665        position: 1,
5666        cardinality: Cardinality::Many,
5667        pointers: object_shape_nodes,
5668        has_implicit_id: shape_has_implicit_id(&vs.object_shape),
5669    };
5670    let shape = ShapeDescriptor {
5671        root: ShapeNode::VectorSearch {
5672            object_position: 1,
5673            distance_position: 2,
5674            object_node: Box::new(object_node),
5675        },
5676    };
5677    let inference_plan = vs.inference_model.as_ref().map(|model_name| InferencePlan::Embedding {
5678        model_name: model_name.clone(),
5679        type_name: vs.inference_type_name.clone().unwrap_or_default(),
5680        index_name: vs.inference_index_name.clone().unwrap_or(None),
5681        query_param_name: vs.inference_query_param_name.clone().unwrap_or_default(),
5682        query_literal: vs.inference_query_literal.clone(),
5683    });
5684    SqlOutput {
5685        sql,
5686        shape,
5687        inference_plan,
5688    }
5689}
5690
5691// ── FTS search ───────────────────────────────────────────────────────────────
5692
5693fn emit_fts_search(fs: &IrFtsSearch) -> SqlOutput {
5694    use crate::schema::SearchBackend;
5695    if fs.backend != SearchBackend::Postgres {
5696        return emit_fts_search_deferred(fs);
5697    }
5698
5699    let alias = &fs.source.alias;
5700    let search_col = format!("{}.{}", qi(alias), qi(&fs.search_col));
5701    let query_sql = emit_expr(&fs.query_expr);
5702    let tsquery = format!("{}('english', {})", fs.tsquery_fn, query_sql);
5703    let rank_sql = format!("ts_rank({}, {})", search_col, tsquery);
5704
5705    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5706        let type_expr = type_disc(&fs.source.type_name);
5707        let id_expr = format!("{}.\"id\"", qi(alias));
5708        let tuple = format!("{},\n    {}", type_expr, id_expr);
5709        let id_node = ShapeNode::Scalar {
5710            name: "id".to_string(),
5711            position: 1,
5712        };
5713        (tuple, vec![id_node])
5714    } else {
5715        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5716        let mut parts = vec![type_disc(&fs.source.type_name)];
5717        parts.extend(pointer_exprs);
5718        (parts.join(",\n    "), prepend_type(shape_pointers))
5719    };
5720
5721    let outer = format!(
5722        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    {}",
5723        obj_tuple, rank_sql,
5724    );
5725    let mut sql = format!(
5726        "SELECT (\n    {}\n) AS result\nFROM {} AS {}\nWHERE {} @@ {}",
5727        outer,
5728        source_ref(&fs.source),
5729        qi(alias),
5730        search_col,
5731        tsquery,
5732    );
5733    if let Some(f) = &fs.filter {
5734        sql.push_str(&format!(" AND ({})", emit_expr(f)));
5735    }
5736    if let Some(dir) = &fs.order_by_rank {
5737        let dir_sql = match dir {
5738            IrSortDir::Asc => "ASC",
5739            IrSortDir::Desc => "DESC",
5740        };
5741        sql.push_str(&format!("\nORDER BY {} {}", rank_sql, dir_sql));
5742    }
5743    append_offset_limit(&mut sql, &fs.offset, &fs.limit);
5744
5745    let object_node = ShapeNode::Object {
5746        name: "object".to_string(),
5747        type_name: Some(fs.source.type_name.clone()),
5748        position: 1,
5749        cardinality: Cardinality::Many,
5750        pointers: object_shape_nodes,
5751        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
5752    };
5753    let shape = ShapeDescriptor {
5754        root: ShapeNode::FtsSearch {
5755            object_position: 1,
5756            rank_position: 2,
5757            object_node: Box::new(object_node),
5758        },
5759    };
5760    SqlOutput {
5761        sql,
5762        shape,
5763        inference_plan: None,
5764    }
5765}
5766
5767fn emit_fts_search_deferred(fs: &IrFtsSearch) -> SqlOutput {
5768    let alias = &fs.source.alias;
5769    let ids_idx = fs
5770        .deferred_ids_param
5771        .expect("deferred_ids_param must be set for deferred backend");
5772    let scores_idx = fs
5773        .deferred_scores_param
5774        .expect("deferred_scores_param must be set for deferred backend");
5775    let ids_param = format!("${}", ids_idx + 1);
5776    let scores_param = format!("${}", scores_idx + 1);
5777
5778    let (obj_tuple, object_shape_nodes) = if fs.object_shape.is_empty() {
5779        let type_expr = type_disc(&fs.source.type_name);
5780        let id_expr = format!("{}.\"id\"", qi(alias));
5781        let tuple = format!("{},\n    {}", type_expr, id_expr);
5782        let id_node = ShapeNode::Scalar {
5783            name: "id".to_string(),
5784            position: 1,
5785        };
5786        (tuple, vec![id_node])
5787    } else {
5788        let (pointer_exprs, shape_pointers) = build_shape(&fs.object_shape, alias);
5789        let mut parts = vec![type_disc(&fs.source.type_name)];
5790        parts.extend(pointer_exprs);
5791        (parts.join(",\n    "), prepend_type(shape_pointers))
5792    };
5793
5794    let outer = format!(
5795        "NULL::text,\n    ROW(\n    {}\n    )::record,\n    \"_os\".\"score\"",
5796        obj_tuple,
5797    );
5798    let mut sql = format!(
5799        concat!(
5800            "SELECT (\n    {}\n) AS result\n",
5801            "FROM {} AS {}\n",
5802            "JOIN UNNEST({}::uuid[], {}::float8[]) AS \"_os\"(\"id\", \"score\")\n",
5803            "    ON \"_os\".\"id\" = {}.\"id\"",
5804        ),
5805        outer,
5806        source_ref(&fs.source),
5807        qi(alias),
5808        ids_param,
5809        scores_param,
5810        qi(alias),
5811    );
5812    if let Some(f) = &fs.filter {
5813        sql.push_str(&format!("\nWHERE ({})", emit_expr(f)));
5814    }
5815    if let Some(dir) = &fs.order_by_rank {
5816        let dir_sql = match dir {
5817            IrSortDir::Asc => "ASC",
5818            IrSortDir::Desc => "DESC",
5819        };
5820        sql.push_str(&format!("\nORDER BY \"_os\".\"score\" {}", dir_sql));
5821    }
5822    // limit/offset are passed to OpenSearch as size/from, not emitted in Postgres SQL.
5823    let size = fs.limit.as_ref().and_then(|lim| {
5824        if let IrExpr::Literal(IrLiteral::Int(n)) = lim {
5825            Some(*n as usize)
5826        } else {
5827            None
5828        }
5829    });
5830
5831    let object_node = ShapeNode::Object {
5832        name: "object".to_string(),
5833        type_name: Some(fs.source.type_name.clone()),
5834        position: 1,
5835        cardinality: Cardinality::Many,
5836        pointers: object_shape_nodes,
5837        has_implicit_id: shape_has_implicit_id(&fs.object_shape),
5838    };
5839    let shape = ShapeDescriptor {
5840        root: ShapeNode::FtsSearch {
5841            object_position: 1,
5842            rank_position: 2,
5843            object_node: Box::new(object_node),
5844        },
5845    };
5846    let backend_str = match fs.backend {
5847        crate::schema::SearchBackend::Meilisearch => "meilisearch",
5848        _ => "opensearch",
5849    };
5850    let inference_plan = Some(InferencePlan::Search {
5851        backend: backend_str.to_string(),
5852        index_name: fs.deferred_index_name.clone().unwrap_or_default(),
5853        query_param_name: fs.deferred_query_param_name.clone().unwrap_or_default(),
5854        query_literal: fs.deferred_query_literal.clone(),
5855        size,
5856    });
5857    SqlOutput {
5858        sql,
5859        shape,
5860        inference_plan,
5861    }
5862}
5863
5864// ── Function select ──────────────────────────────────────────────────────────
5865
5866fn emit_function_select(sel: &IrFunctionSelect) -> SqlOutput {
5867    let alias = &sel.alias;
5868    let (pointer_exprs, shape_pointers) = build_shape(&sel.shape, alias);
5869
5870    let type_expr = if sel.polymorphic {
5871        format!("{}.\"__type__\"", qi(alias))
5872    } else {
5873        type_disc(&sel.type_name)
5874    };
5875    let mut parts = vec![type_expr];
5876    parts.extend(pointer_exprs);
5877    let tuple = parts.join(",\n    ");
5878    let distinct = if sel.distinct { "DISTINCT " } else { "" };
5879
5880    let args_sql = sel.fn_args.iter().map(emit_expr).collect::<Vec<_>>().join(", ");
5881    let fn_call = format!("{}.{}({})", pg_schema(&sel.fn_module), qi(&sel.fn_name), args_sql);
5882
5883    let from_clause = if sel.polymorphic {
5884        // Polymorphic: we can't peek inside the function — assume it returns
5885        // a __type__ column since we control the DDL.  Use the fn call directly.
5886        format!("{} AS {}", fn_call, qi(alias))
5887    } else {
5888        format!("{} AS {}", fn_call, qi(alias))
5889    };
5890
5891    let mut sql = format!("SELECT {}(\n    {}\n) AS result\nFROM {}", distinct, tuple, from_clause,);
5892    append_filter(&mut sql, &sel.filter);
5893    append_order_by(&mut sql, &sel.order_by);
5894    append_offset_limit(&mut sql, &sel.offset, &sel.limit);
5895
5896    let root_pointers = prepend_type(shape_pointers);
5897    SqlOutput {
5898        sql,
5899        shape: ShapeDescriptor {
5900            root: ShapeNode::Object {
5901                name: String::new(),
5902                type_name: Some(sel.type_name.clone()),
5903                position: 0,
5904                cardinality: Cardinality::Many,
5905                pointers: root_pointers,
5906                has_implicit_id: shape_has_implicit_id(&sel.shape),
5907            },
5908        },
5909        inference_plan: None,
5910    }
5911}
5912
5913/// Emit the SQL body expression for a user-defined function DDL.
5914///
5915/// For a bare scalar free select emits just the expression — e.g. `"a" + "b"`.
5916/// For everything else (object functions, non-scalar free selects) emits a
5917/// full `SELECT … FROM …` via `emit_dml_as_cte_source`.
5918pub fn emit_fn_body(ir: &crate::ir::IrOutput) -> String {
5919    with_subtype_fanouts(&ir.subtype_fanouts, || emit_output_fn_body(ir))
5920}
5921
5922fn emit_output_fn_body(ir: &crate::ir::IrOutput) -> String {
5923    let body = match &ir.stmt {
5924        IrStmt::Select(sel) if matches!(sel.rows.as_slice(), [IrRowSource::Free(IrFreeExpr::Scalar(_))]) => {
5925            let IrRowSource::Free(IrFreeExpr::Scalar(e)) = &sel.rows[0] else {
5926                unreachable!()
5927            };
5928            format!("SELECT {}", emit_expr(e))
5929        }
5930        other => emit_dml_as_cte_source(other),
5931    };
5932    if ir.ctes.is_empty() {
5933        body
5934    } else {
5935        let cte_prefix = emit_cte_prefix(&ir.ctes);
5936        format!("{}{}", cte_prefix, body)
5937    }
5938}
5939
5940fn emit_literal(lit: &IrLiteral) -> String {
5941    match lit {
5942        IrLiteral::Str(s) => sql_str(s),
5943        IrLiteral::Int(i) => i.to_string(),
5944        IrLiteral::Float(f) => {
5945            // Explicit ::float8 cast — a bare untyped numeral like `1.0`
5946            // defaults to Postgres `numeric`, but an un-cast PyQL float
5947            // literal means `float64`; only an explicit `<decimal>`/`123n`
5948            // literal should ever produce a real decimal. Without this,
5949            // `numeric`'s different wire OID
5950            // also broke decoding inside ROW() composites (see
5951            // _pg_decode_numeric in pylon/client.py, still needed for
5952            // genuine decimal casts).
5953            let s = f.to_string();
5954            let s = if s.contains('.') || s.contains('e') {
5955                s
5956            } else {
5957                format!("{}.0", s)
5958            };
5959            format!("({}::float8)", s)
5960        }
5961        IrLiteral::Bool(b) => {
5962            if *b {
5963                "TRUE".into()
5964            } else {
5965                "FALSE".into()
5966            }
5967        }
5968    }
5969}
5970
5971// ── Tests ────────────────────────────────────────────────────────────────────
5972
5973#[cfg(test)]
5974mod tests {
5975    use super::*;
5976    use crate::ir;
5977    use crate::parse;
5978    use crate::schema::{
5979        FunctionDescriptor, FunctionParamDescriptor, GlobalDescriptor, LinkDescriptor, MultiLinkDescriptor,
5980        NamedTupleDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor,
5981    };
5982
5983    #[test]
5984    fn a_template_argument_keeps_the_parameters_it_carries() {
5985        // The first argument carries query parameter `$2`; filled one number
5986        // at a time, it would be rewritten into the second argument.
5987        let filled = fill_template("f($1, $2, $10)", &["($2)::int".to_string(), "b".to_string()]);
5988        assert_eq!(filled, "f(($2)::int, b, $10)");
5989    }
5990
5991    fn make_schema() -> SchemaDescriptor {
5992        SchemaDescriptor {
5993            types: vec![
5994                TypeDescriptor {
5995                    name: "Person".into(),
5996                    module: "default".into(),
5997                    table: "Person".into(),
5998                    abstract_: false,
5999                    materialized: false,
6000                    description: None,
6001                    parents: vec![],
6002                    interfaces: vec![],
6003                    bases: vec![],
6004                    properties: vec![
6005                        PropertyDescriptor {
6006                            name: "id".into(),
6007                            pg_type: "uuid".into(),
6008                            nullable: false,
6009                            default_sql: Some("uuidv7()".into()),
6010                            default_pyql: None,
6011                            description: None,
6012                            check_constraints: vec![],
6013                            is_exclusive: true,
6014                            is_pk: true,
6015                            is_readonly: true,
6016                            rewrites: vec![],
6017                            tuple_members: None,
6018                            column_type: None,
6019                        },
6020                        PropertyDescriptor {
6021                            name: "name".into(),
6022                            pg_type: "text".into(),
6023                            nullable: false,
6024                            default_sql: None,
6025                            default_pyql: None,
6026                            description: None,
6027                            check_constraints: vec![],
6028                            is_exclusive: false,
6029                            is_pk: false,
6030                            is_readonly: false,
6031                            rewrites: vec![],
6032                            tuple_members: None,
6033                            column_type: None,
6034                        },
6035                        PropertyDescriptor {
6036                            name: "age".into(),
6037                            pg_type: "int8".into(),
6038                            nullable: true,
6039                            default_sql: None,
6040                            default_pyql: None,
6041                            description: None,
6042                            check_constraints: vec![],
6043                            is_exclusive: false,
6044                            is_pk: false,
6045                            is_readonly: false,
6046                            rewrites: vec![],
6047                            tuple_members: None,
6048                            column_type: None,
6049                        },
6050                    ],
6051                    links: vec![LinkDescriptor {
6052                        name: "company".into(),
6053                        target: "default::Company".into(),
6054                        nullable: true,
6055                        through: None,
6056                        description: None,
6057                        default_pyql: None,
6058                        is_exclusive: false,
6059                        is_readonly: false,
6060                        rewrites: vec![],
6061                        on_delete: vec![],
6062                    }],
6063                    multilinks: vec![MultiLinkDescriptor {
6064                        name: "posts".into(),
6065                        target: "default::Post".into(),
6066                        through: None,
6067                        nullable: false,
6068                        description: None,
6069                        default_pyql: None,
6070                        on_delete: vec![],
6071                        is_exclusive: false,
6072                    }],
6073                    computed: vec![],
6074                    constraints: vec![],
6075                    indexes: vec![],
6076                    partition: None,
6077                    vector_indexes: vec![],
6078                    search_indexes: vec![],
6079                    triggers: vec![],
6080                    junction: false,
6081                    signals: vec![],
6082                },
6083                TypeDescriptor {
6084                    name: "Company".into(),
6085                    module: "default".into(),
6086                    table: "Company".into(),
6087                    abstract_: false,
6088                    materialized: false,
6089                    description: None,
6090                    parents: vec![],
6091                    interfaces: vec![],
6092                    bases: vec![],
6093                    properties: vec![PropertyDescriptor {
6094                        name: "name".into(),
6095                        pg_type: "text".into(),
6096                        nullable: false,
6097                        default_sql: None,
6098                        default_pyql: None,
6099                        description: None,
6100                        check_constraints: vec![],
6101                        is_exclusive: false,
6102                        is_pk: false,
6103                        is_readonly: false,
6104                        rewrites: vec![],
6105                        tuple_members: None,
6106                        column_type: None,
6107                    }],
6108                    links: vec![],
6109                    multilinks: vec![],
6110                    computed: vec![],
6111                    constraints: vec![],
6112                    indexes: vec![],
6113                    partition: None,
6114                    vector_indexes: vec![],
6115                    search_indexes: vec![],
6116                    triggers: vec![],
6117                    junction: false,
6118                    signals: vec![],
6119                },
6120                TypeDescriptor {
6121                    name: "Post".into(),
6122                    module: "default".into(),
6123                    table: "Post".into(),
6124                    abstract_: false,
6125                    materialized: false,
6126                    description: None,
6127                    parents: vec![],
6128                    interfaces: vec![],
6129                    bases: vec![],
6130                    properties: vec![PropertyDescriptor {
6131                        name: "title".into(),
6132                        pg_type: "text".into(),
6133                        nullable: false,
6134                        default_sql: None,
6135                        default_pyql: None,
6136                        description: None,
6137                        check_constraints: vec![],
6138                        is_exclusive: false,
6139                        is_pk: false,
6140                        is_readonly: false,
6141                        rewrites: vec![],
6142                        tuple_members: None,
6143                        column_type: None,
6144                    }],
6145                    links: vec![],
6146                    multilinks: vec![],
6147                    computed: vec![],
6148                    constraints: vec![],
6149                    indexes: vec![],
6150                    partition: None,
6151                    vector_indexes: vec![],
6152                    search_indexes: vec![],
6153                    triggers: vec![],
6154                    junction: false,
6155                    signals: vec![],
6156                },
6157            ],
6158            scalars: vec![],
6159            enums: vec![],
6160            named_tuples: vec![],
6161            globals: vec![],
6162            functions: vec![],
6163            aliases: vec![],
6164            channels: vec![],
6165            ..Default::default()
6166        }
6167    }
6168
6169    fn compile_and_emit(query: &str) -> SqlOutput {
6170        let schema = make_schema();
6171        compile_and_emit_with(query, &schema)
6172    }
6173
6174    fn compile_and_emit_with(query: &str, schema: &SchemaDescriptor) -> SqlOutput {
6175        let ast = parse::parse(query).expect("parse failed");
6176        let ir = ir::compile(&ast, schema).expect("IR compile failed");
6177        emit(&ir)
6178    }
6179
6180    /// A set literal is the haystack `in` reads, so a walk that crosses a
6181    /// multi-link can be tested against one. Compiled as an ordinary operand
6182    /// it was refused outright — the walk reaches the EXISTS builder before
6183    /// the arm that knows what a set literal on the right of `in` means.
6184    #[test]
6185    fn a_multilink_walk_can_be_tested_against_a_set_literal() {
6186        let out = compile_and_emit("SELECT Person FILTER .posts.title IN {'a', 'b'}");
6187        assert!(out.sql.contains("= ANY(ARRAY['a', 'b'])"), "{}", out.sql);
6188    }
6189
6190    /// `all` is not `any`. Both used to fold into the same bare EXISTS over
6191    /// the junction, which answers "some element matches" — so `all` silently
6192    /// agreed with `any` on every one of these.
6193    #[test]
6194    fn all_over_a_multilink_is_not_the_same_question_as_any() {
6195        for condition in [
6196            ".posts.title IN {'a', 'b'}",
6197            ".posts IN ps",
6198            ".posts = ps",
6199            ".posts.title = 'x'",
6200        ] {
6201            let sql = |quantifier: &str| {
6202                compile_and_emit(&format!(
6203                    "WITH ps := (SELECT Post) SELECT Person FILTER {quantifier}({condition})"
6204                ))
6205                .sql
6206            };
6207            assert_ne!(
6208                sql("all"),
6209                sql("any"),
6210                "all({condition}) must not compile to what any({condition}) does"
6211            );
6212        }
6213    }
6214
6215    /// The two shapes `all` takes: a walk with a tail negates the test around
6216    /// each element inside one EXISTS, and a walk that *is* the link gathers
6217    /// the answers and reduces them.
6218    #[test]
6219    fn all_over_a_multilink_answers_for_every_element() {
6220        let out = compile_and_emit("SELECT Person FILTER all(.posts.title IN {'a', 'b'})");
6221        assert!(
6222            out.sql.contains("NOT EXISTS(") && out.sql.contains("NOT (\"t2\".\"title\" = ANY("),
6223            "no element fails the test:\n{}",
6224            out.sql
6225        );
6226        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER all(.posts IN ps)");
6227        assert!(
6228            out.sql
6229                .contains("coalesce(bool_and(\"_s\".\"v\"), true) FROM unnest(ARRAY(SELECT ("),
6230            "every answer, reduced:\n{}",
6231            out.sql
6232        );
6233    }
6234
6235    /// `any` keeps the EXISTS fold, which is exactly what it means and reads
6236    /// more directly than gathering the answers to reduce them.
6237    #[test]
6238    fn any_over_a_multilink_keeps_its_exists() {
6239        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER any(.posts IN ps)");
6240        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
6241        assert!(!out.sql.contains("bool_or"), "{}", out.sql);
6242    }
6243
6244    /// Comparing a multi-link to a binding of more than one row is the same
6245    /// membership question `in` asks — the reading PyQL gives `=` against a
6246    /// set everywhere else. Compared against the binding whole it became
6247    /// `= (SELECT id FROM ps)`, which Postgres aborts on as soon as the
6248    /// binding holds two rows.
6249    #[test]
6250    fn comparing_a_multilink_to_a_multi_row_binding_is_membership() {
6251        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts = ps");
6252        assert!(
6253            out.sql.contains("\"target\" = ANY((SELECT \"id\" FROM \"ps\"))"),
6254            "{}",
6255            out.sql
6256        );
6257        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts != ps");
6258        assert!(
6259            out.sql
6260                .contains("NOT (\"t2\".\"target\" = ANY((SELECT \"id\" FROM \"ps\")))"),
6261            "{}",
6262            out.sql
6263        );
6264    }
6265
6266    /// A binding of one row is still one value, so the comparison stays one.
6267    #[test]
6268    fn comparing_a_multilink_to_a_single_row_binding_stays_an_equality() {
6269        let out = compile_and_emit("WITH p := (SELECT Post LIMIT 1) SELECT Person FILTER .posts = p");
6270        assert!(
6271            out.sql.contains("\"target\" = (SELECT \"id\" FROM \"p\")"),
6272            "{}",
6273            out.sql
6274        );
6275    }
6276
6277    /// `select a in b` over two bindings asks the question once per element of
6278    /// `a`, so `a` is the row source. Read as a plain value the left side
6279    /// became a scalar subquery over the whole binding, which Postgres aborts
6280    /// on the moment it holds more than one row ("more than one row returned
6281    /// by a subquery used as an expression").
6282    #[test]
6283    fn membership_of_one_set_in_another_answers_per_element() {
6284        let out = compile_and_emit("WITH a := (SELECT Person), b := (SELECT Person) SELECT a IN b");
6285        assert!(
6286            out.sql.contains("FROM \"a\""),
6287            "the left set is the source:\n{}",
6288            out.sql
6289        );
6290        assert!(
6291            out.sql.contains("\"id\" = ANY((SELECT \"id\" FROM \"b\"))"),
6292            "each row tests its own id:\n{}",
6293            out.sql
6294        );
6295        assert!(
6296            !out.sql.contains("(SELECT \"id\" FROM \"a\") = ANY"),
6297            "the left set must not collapse to one value:\n{}",
6298            out.sql
6299        );
6300    }
6301
6302    /// The same question asked of a type rather than a binding.
6303    #[test]
6304    fn membership_of_a_type_in_a_set_answers_per_row() {
6305        let out = compile_and_emit("WITH b := (SELECT Person) SELECT Person NOT IN b");
6306        assert!(
6307            out.sql.contains("FROM \"public\".\"Person\""),
6308            "the type is the source:\n{}",
6309            out.sql
6310        );
6311        assert!(
6312            out.sql.contains("\"id\" <> ALL((SELECT \"id\" FROM \"b\"))"),
6313            "{}",
6314            out.sql
6315        );
6316    }
6317
6318    /// A quantifier reduces those per-element answers to one, so the set it
6319    /// reduces still has to be built per element: the left binding must not
6320    /// collapse into the one value a scalar subquery reads.
6321    #[test]
6322    fn quantifying_membership_of_one_set_in_another_aggregates_the_elements() {
6323        for (query, aggregate) in [("SELECT all(a IN b)", "bool_and"), ("SELECT any(a IN b)", "bool_or")] {
6324            let out = compile_and_emit(&format!("WITH a := (SELECT Person), b := (SELECT Person) {query}"));
6325            assert!(
6326                out.sql.contains(&format!("{aggregate}(\"_s\".\"v\")")),
6327                "{query}:\n{}",
6328                out.sql
6329            );
6330            assert!(
6331                out.sql.contains("FROM unnest(ARRAY(SELECT ("),
6332                "one answer per element, then reduced — {query}:\n{}",
6333                out.sql
6334            );
6335            assert!(
6336                !out.sql.contains("(SELECT \"id\" FROM \"a\") = ANY"),
6337                "the left set must not collapse to one value — {query}:\n{}",
6338                out.sql
6339            );
6340        }
6341    }
6342
6343    /// `t := .posts in ps` is a set of booleans, one per post — the same array
6344    /// any other multi-valued computed comes back as. Collapsed to an EXISTS it
6345    /// answered once for the whole link, which is what `any(…)` spells.
6346    #[test]
6347    fn membership_of_a_multilink_is_a_boolean_per_element() {
6348        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := .posts IN ps }");
6349        assert!(
6350            out.sql
6351                .contains("ARRAY(SELECT (\"t3\".\"id\" = ANY((SELECT \"id\" FROM \"ps\")))"),
6352            "one answer per post:\n{}",
6353            out.sql
6354        );
6355        assert!(
6356            !out.sql.contains("EXISTS("),
6357            "not one answer for the link:\n{}",
6358            out.sql
6359        );
6360    }
6361
6362    /// A FILTER wants a single boolean out of the whole set, which is the
6363    /// EXISTS the element-wise reading above must not take away.
6364    #[test]
6365    fn membership_of_a_multilink_in_a_filter_stays_one_answer() {
6366        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person FILTER .posts IN ps");
6367        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
6368    }
6369
6370    /// And `all(…)`/`any(…)` are how a computed asks for that single boolean:
6371    /// the quantifier ranges over the set's elements, not over the rows of the
6372    /// query the array was built in.
6373    #[test]
6374    fn quantifying_membership_of_a_multilink_aggregates_its_elements() {
6375        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := all(.posts IN ps) }");
6376        assert!(
6377            out.sql
6378                .contains("coalesce(bool_and(\"_s\".\"v\"), true) FROM unnest(ARRAY(SELECT ("),
6379            "{}",
6380            out.sql
6381        );
6382        let out = compile_and_emit("WITH ps := (SELECT Post) SELECT Person { t := any(.posts IN ps) }");
6383        assert!(
6384            out.sql
6385                .contains("coalesce(bool_or(\"_s\".\"v\"), false) FROM unnest(ARRAY(SELECT ("),
6386            "{}",
6387            out.sql
6388        );
6389    }
6390
6391    /// A set *literal* on the right is still the array `= ANY(…)` wants, even
6392    /// once the left is walked element by element.
6393    #[test]
6394    fn membership_in_a_set_literal_answers_per_element() {
6395        let out = compile_and_emit("SELECT Person { t := .posts.title IN {'a', 'b'} }");
6396        assert!(
6397            out.sql
6398                .contains("ARRAY(SELECT (\"t2\".\"title\" = ANY(ARRAY['a', 'b']))"),
6399            "{}",
6400            out.sql
6401        );
6402    }
6403
6404    /// `select (for x in S union (insert T { … })) { … }` — a shape, or any
6405    /// clause of the select's own, stops the loop from simply *being* the
6406    /// statement, so the loop becomes this select's source. Emitted as one it
6407    /// landed in a `CROSS JOIN LATERAL`, where Postgres rejects a
6408    /// data-modifying statement outright ("syntax error at or near INSERT").
6409    /// It is bound to a CTE instead, exactly as the `with c := (for … union
6410    /// (insert …)) select c { … }` spelling of the same query already was.
6411    #[test]
6412    fn test_a_mutating_loop_as_a_select_source_is_bound_to_a_cte() {
6413        let out =
6414            compile_and_emit("SELECT (FOR p IN (SELECT Person) UNION (INSERT Post { title := p.name })) { title }");
6415        assert!(
6416            !out.sql.contains("LATERAL"),
6417            "the insert must not sit in a LATERAL, got:\n{}",
6418            out.sql
6419        );
6420        assert!(
6421            out.sql
6422                .contains("\"_nested_dml_0__ids\" AS (\nINSERT INTO \"public\".\"Post\""),
6423            "the loop's insert belongs in a top-level CTE, got:\n{}",
6424            out.sql
6425        );
6426        assert!(
6427            out.sql.contains("FROM \"_nested_dml_0\" AS "),
6428            "the select must read the bound loop back, got:\n{}",
6429            out.sql
6430        );
6431    }
6432
6433    /// The same binding also puts the loop variable in scope before the body's
6434    /// subject is resolved — `update p` named no type until then, so this
6435    /// spelling used to fail to compile at all while its `with` form worked.
6436    #[test]
6437    fn test_a_loop_updating_its_own_variable_compiles_as_a_select_source() {
6438        let out = compile_and_emit("SELECT (FOR p IN (SELECT Person) UNION (UPDATE p SET { age := 1 })) { name }");
6439        assert!(
6440            out.sql
6441                .contains("\"_nested_dml_0__ids\" AS (\nUPDATE \"public\".\"Person\""),
6442            "got:\n{}",
6443            out.sql
6444        );
6445        assert!(!out.sql.contains("LATERAL"), "got:\n{}", out.sql);
6446    }
6447
6448    /// `(insert Outer { link := (insert Inner …) }) if cond else {}` — the
6449    /// guard belongs to the insert the conditional names. The nested insert
6450    /// compiles first and used to claim it, leaving the outer one to write a
6451    /// row whatever the condition said. `compile_update`/`compile_delete`
6452    /// take theirs up front for the same reason.
6453    /// A loop's own bindings mutate too — an order line's attributes are
6454    /// bound inside the loop that writes the line, two levels under the
6455    /// insert the condition is on.
6456    #[test]
6457    fn test_a_guarded_insert_reaches_a_binding_inside_a_nested_loop() {
6458        let out = compile_and_emit(
6459            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6460                  made := (INSERT Person { name := 'a', \
6461                             posts := (FOR s IN {'p','q'} UNION ( \
6462                               WITH extra := (INSERT Company { name := s }) \
6463                               INSERT Post { title := s })) }) \
6464                          IF NOT EXISTS existing ELSE {} \
6465             SELECT made",
6466        );
6467        let at = out
6468            .sql
6469            .find("INSERT INTO \"public\".\"Company\"")
6470            .unwrap_or_else(|| panic!("no Company insert in:\n{}", out.sql));
6471        let tail = &out.sql[at..];
6472        let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6473        assert!(
6474            tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6475            "a binding inside the loop must carry the condition too, got:\n{}",
6476            out.sql
6477        );
6478    }
6479
6480    /// Everything a guarded insert writes is guarded with it: the row, the
6481    /// values nested in it, and a multi-link's rows — which are hoisted
6482    /// beside the statement rather than inside it. Otherwise a false
6483    /// condition still leaves those behind, owned by nothing.
6484    #[test]
6485    fn test_a_guarded_insert_carries_its_condition_into_what_it_nests() {
6486        let out = compile_and_emit(
6487            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6488                  made := (INSERT Person { name := 'a', company := (INSERT Company { name := 'b' }), \
6489                                           posts := (FOR s IN {'p','q'} UNION (INSERT Post { title := s })) }) \
6490                          IF NOT EXISTS existing ELSE {} \
6491             SELECT made",
6492        );
6493        for table in ["\"Person\"", "\"Company\" (", "\"Post\""] {
6494            let at = out
6495                .sql
6496                .find(&format!("INSERT INTO \"public\".{}", table))
6497                .unwrap_or_else(|| panic!("no insert into {} in:\n{}", table, out.sql));
6498            let tail = &out.sql[at..];
6499            let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6500            assert!(
6501                tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6502                "insert into {} writes whatever the condition says, got:\n{}",
6503                table,
6504                out.sql
6505            );
6506        }
6507    }
6508
6509    #[test]
6510    fn test_a_guarded_insert_keeps_its_guard_when_it_nests_another() {
6511        let out = compile_and_emit(
6512            "WITH existing := (SELECT Company FILTER .name = 'x'), \
6513                  made := (INSERT Person { name := 'a', company := (INSERT Company { name := 'b' }) }) \
6514                          IF NOT EXISTS existing ELSE {} \
6515             SELECT made",
6516        );
6517        let outer = out
6518            .sql
6519            .find("INSERT INTO \"public\".\"Person\"")
6520            .unwrap_or_else(|| panic!("no Person insert in:\n{}", out.sql));
6521        let tail = &out.sql[outer..];
6522        let stmt_end = tail.find("RETURNING").unwrap_or(tail.len());
6523        assert!(
6524            tail[..stmt_end].contains("NOT EXISTS(SELECT 1 FROM \"existing\")"),
6525            "the insert the conditional names must carry the condition, got:\n{}",
6526            out.sql
6527        );
6528    }
6529
6530    #[test]
6531    fn a_nested_read_inside_a_with_opens_no_second_with() {
6532        let out = compile_and_emit_with(
6533            "WITH a := (SELECT Person FILTER .age > 1), b := (SELECT Person LIMIT 1) SELECT (SELECT (a UNION b))",
6534            &make_schema(),
6535        );
6536        assert_eq!(out.sql.matches("WITH").count(), 1, "{}", out.sql);
6537    }
6538
6539    #[test]
6540    fn a_comparison_against_a_multi_row_with_binding_tests_membership() {
6541        // Read as a scalar subquery, the binding aborts the query on its
6542        // second row: "more than one row returned by a subquery used as an
6543        // expression".
6544        let out = compile_and_emit_with(
6545            "WITH companies := (SELECT Company FILTER .name = 'x') SELECT Person FILTER .company = companies",
6546            &make_schema(),
6547        );
6548        assert!(
6549            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6550            "{}",
6551            out.sql
6552        );
6553
6554        let out = compile_and_emit_with(
6555            "WITH companies := (SELECT Company) SELECT Person FILTER any(.company = companies)",
6556            &make_schema(),
6557        );
6558        assert!(
6559            out.sql.contains("= ANY((SELECT \"id\" FROM \"companies\"))"),
6560            "{}",
6561            out.sql
6562        );
6563
6564        let out = compile_and_emit_with(
6565            "WITH companies := (SELECT Company) SELECT Person FILTER .company != companies",
6566            &make_schema(),
6567        );
6568        assert!(out.sql.contains("NOT (\"t1\".\"company_id\" = ANY("), "{}", out.sql);
6569    }
6570
6571    #[test]
6572    fn a_comparison_against_a_single_row_with_binding_stays_a_scalar_read() {
6573        let out = compile_and_emit_with(
6574            "WITH company := (SELECT Company LIMIT 1) SELECT Person FILTER .company = company",
6575            &make_schema(),
6576        );
6577        assert!(out.sql.contains("= (SELECT \"id\" FROM \"company\")"), "{}", out.sql);
6578    }
6579
6580    #[test]
6581    fn an_aggregate_over_a_backlink_counts_inside_a_subquery() {
6582        let out = compile_and_emit_with("SELECT Post { n := count(.<posts) }", &make_schema());
6583        assert!(
6584            !out.sql.contains("count((SELECT"),
6585            "an aggregate over a scalar subquery:\n{}",
6586            out.sql
6587        );
6588        assert!(out.sql.contains("count("), "{}", out.sql);
6589    }
6590
6591    #[test]
6592    fn an_aggregate_over_a_relative_subselect_counts_inside_a_subquery() {
6593        let out = compile_and_emit_with(
6594            "SELECT Post { n := count((SELECT .<posts FILTER .age > 1)) }",
6595            &make_schema(),
6596        );
6597        assert!(
6598            !out.sql.contains("count((SELECT"),
6599            "an aggregate over a scalar subquery:\n{}",
6600            out.sql
6601        );
6602        assert!(
6603            out.sql.contains(r#"(SELECT count("_s"."v") FROM unnest(ARRAY("#),
6604            "{}",
6605            out.sql
6606        );
6607    }
6608
6609    #[test]
6610    fn any_over_a_link_type_check_tests_each_element() {
6611        let out = compile_and_emit_with(
6612            "SELECT Company { name } FILTER any(.<company.posts IS Post)",
6613            &make_schema(),
6614        );
6615        assert!(
6616            !out.sql.contains("bool_or(ARRAY"),
6617            "an aggregate over outer rows:\n{}",
6618            out.sql
6619        );
6620        assert!(
6621            out.sql
6622                .contains("FROM unnest(ARRAY(SELECT ('default::Post' = 'default::Post')"),
6623            "{}",
6624            out.sql
6625        );
6626    }
6627
6628    #[test]
6629    fn a_link_type_check_reads_the_linked_object() {
6630        let out = compile_and_emit_with("SELECT Person { name } FILTER .company IS Company", &make_schema());
6631        assert!(!out.sql.contains("WHERE FALSE"), "{}", out.sql);
6632        assert!(out.sql.contains("'default::Company'"), "{}", out.sql);
6633    }
6634
6635    /// `make_schema` with an `Admin` extending `Person`, in a table of its own.
6636    fn schema_with_a_subtype() -> SchemaDescriptor {
6637        let mut schema = make_schema();
6638        let mut admin = schema.types[0].clone();
6639        admin.name = "Admin".into();
6640        admin.table = "Admin".into();
6641        admin.bases = vec!["default::Person".into()];
6642        schema.types.push(admin);
6643        schema
6644    }
6645
6646    #[test]
6647    fn a_type_with_subtypes_reads_their_rows_too() {
6648        let out = compile_and_emit_with("SELECT Person { name }", &schema_with_a_subtype());
6649        assert!(
6650            out.sql.contains("'default::Admin'::text AS \"__type__\"") && out.sql.contains("FROM \"public\".\"Admin\""),
6651            "expected the subtype's table in the read:\n{}",
6652            out.sql
6653        );
6654    }
6655
6656    #[test]
6657    fn a_path_from_a_type_with_subtypes_reads_their_rows_too() {
6658        let out = compile_and_emit_with("SELECT Person.name", &schema_with_a_subtype());
6659        assert!(out.sql.contains("FROM \"public\".\"Admin\""), "{}", out.sql);
6660    }
6661
6662    #[test]
6663    fn a_multilink_of_a_type_with_subtypes_reads_their_junctions_too() {
6664        let out = compile_and_emit_with("SELECT Person { posts: { title } }", &schema_with_a_subtype());
6665        assert!(
6666            out.sql.contains(
6667                "(SELECT \"source\", \"target\" FROM \"public\".\"Person.posts\" UNION ALL \
6668                 SELECT \"source\", \"target\" FROM \"public\".\"Admin.posts\")"
6669            ),
6670            "expected the junctions unioned:\n{}",
6671            out.sql
6672        );
6673    }
6674
6675    #[test]
6676    fn is_a_supertype_matches_its_subtypes() {
6677        let out = compile_and_emit_with(
6678            "SELECT Person { name } FILTER Person IS Admin",
6679            &schema_with_a_subtype(),
6680        );
6681        assert!(out.sql.contains("\"__type__\" = 'default::Admin'"), "{}", out.sql);
6682    }
6683
6684    #[test]
6685    fn a_write_to_a_type_with_subtypes_reaches_their_tables() {
6686        let out = compile_and_emit_with("UPDATE Person SET { age := 1 }", &schema_with_a_subtype());
6687        assert!(
6688            out.sql.contains("UPDATE \"public\".\"Person\"") && out.sql.contains("UPDATE \"public\".\"Admin\""),
6689            "expected both tables updated:\n{}",
6690            out.sql
6691        );
6692        assert!(
6693            !out.sql.contains("UPDATE (\n"),
6694            "a write never targets the union:\n{}",
6695            out.sql
6696        );
6697    }
6698
6699    #[test]
6700    fn test_comparing_a_multilink_itself_to_an_object() {
6701        // `filter any(.posts = post)` — the multi-link with no tail step. It
6702        // used to reach `compile_path`, which only knows properties, links
6703        // and computeds, and reported "has no link or property 'posts'".
6704        let out = compile_and_emit_with(
6705            "WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER any(.posts = p)",
6706            &make_schema(),
6707        );
6708        assert!(
6709            out.sql.contains("EXISTS((SELECT 1\nFROM \"public\".\"Person.posts\""),
6710            "expected a junction EXISTS:\n{}",
6711            out.sql
6712        );
6713        assert!(
6714            out.sql.contains("\"target\" = (SELECT \"id\" FROM \"p\")"),
6715            "expected the junction target compared to the bound object's id:\n{}",
6716            out.sql
6717        );
6718    }
6719
6720    #[test]
6721    fn test_comparing_a_multilink_itself_warns_without_any() {
6722        let ast = parse::parse("WITH p := (SELECT Post LIMIT 1) SELECT Person { id } FILTER .posts = p").unwrap();
6723        let ir = ir::compile(&ast, &make_schema()).unwrap();
6724        assert!(
6725            ir.warnings.iter().any(|w| w.contains("multi-link '.posts'")),
6726            "expected the set-valued FILTER warning, got: {:?}",
6727            ir.warnings
6728        );
6729    }
6730
6731    #[test]
6732    fn test_detached_names_the_type_of_a_nested_select() {
6733        // `detached T` as an insert's link value: the prefix used to hide the
6734        // type name from the nested select's subject resolution, which then
6735        // reported "expected a type name as SELECT subject".
6736        let out = compile_and_emit_with(
6737            "INSERT Person { name := $n, company := (SELECT detached Company FILTER .name = $c LIMIT 1) }",
6738            &make_schema(),
6739        );
6740        assert!(
6741            out.sql.contains("INSERT INTO \"public\".\"Person\""),
6742            "expected the insert to compile:\n{}",
6743            out.sql
6744        );
6745        assert!(
6746            out.sql.contains("FROM \"public\".\"Company\""),
6747            "expected the detached select to read the Company table:\n{}",
6748            out.sql
6749        );
6750    }
6751
6752    #[test]
6753    fn test_select_over_an_update_on_a_with_binding() {
6754        // A bare `with` binding as an update's subject names rows, not a
6755        // type. The top-level form already knew that; wrapping it in a select
6756        // asked what type the statement yields and got the binding's name,
6757        // reported as "unknown type 'p'".
6758        let out = compile_and_emit_with(
6759            "WITH p := (SELECT Person FILTER .id = $a LIMIT 1) SELECT (UPDATE p SET { name := $n }) { id }",
6760            &make_schema(),
6761        );
6762        assert!(
6763            out.sql.contains("UPDATE \"public\".\"Person\""),
6764            "expected the update to resolve to the bound type's table:\n{}",
6765            out.sql
6766        );
6767    }
6768
6769    #[test]
6770    fn test_a_mutation_as_a_free_object_field() {
6771        // Already allowed as an item of a free *set*; a named field of a free
6772        // object is the same question and used to answer "an update cannot
6773        // stand in for a value".
6774        let out = compile_and_emit_with(
6775            "SELECT { renamed := (UPDATE Person FILTER .id = $a SET { name := $n }), n := 1 }",
6776            &make_schema(),
6777        );
6778        assert!(
6779            out.sql.contains("UPDATE \"public\".\"Person\""),
6780            "expected the mutation to become a data-modifying CTE:\n{}",
6781            out.sql
6782        );
6783        assert!(
6784            out.sql
6785                .contains("(SELECT \"t1\".\"id\"\nFROM \"_nested_dml_0\" AS \"t1\") AS \"_f0\""),
6786            "expected the field to carry the rows the mutation touched:\n{}",
6787            out.sql
6788        );
6789    }
6790
6791    #[test]
6792    fn test_a_sub_selects_filter_stays_on_its_own_subject_when_a_field_chain_projects_off_it() {
6793        // `(select Person filter .id = $a).company.name` filters People, not
6794        // Companies. The merge that splices the field chain onto the inner
6795        // select used to leave the filter to bind against the last step.
6796        let out = compile_and_emit_with("SELECT (SELECT Person FILTER .name = $n).company.name", &make_schema());
6797        assert!(
6798            out.sql.contains("WHERE (\"t0\".\"name\" = $1)"),
6799            "the inner filter belongs to the Person alias:\n{}",
6800            out.sql
6801        );
6802    }
6803
6804    #[test]
6805    fn test_reading_back_a_nested_insert_through_its_own_statement() {
6806        let out = compile_and_emit_with(
6807            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) { id, posts: { title } }",
6808            &make_schema(),
6809        );
6810        assert!(out.sql.contains("__ml_add_0"), "got:\n{}", out.sql);
6811    }
6812
6813    #[test]
6814    fn test_reading_back_a_nested_insert_within_its_own_statement() {
6815        // Postgres does not show one statement's CTE writes to the rest of
6816        // that statement, so reading the link back off the base tables found
6817        // nothing and it hydrated as empty/None (confirmed live).
6818        let out = compile_and_emit_with(
6819            "SELECT (INSERT Person { name := $n, posts := (INSERT Post { title := $t }) }) \
6820             { id, posts: { title } }",
6821            &make_schema(),
6822        );
6823        assert!(
6824            out.sql.contains("FROM \"_dml__ml_add_0\" AS \"jt\""),
6825            "the junction rows must come from the CTE that wrote them:\n{}",
6826            out.sql
6827        );
6828        assert!(
6829            out.sql.contains("\"_nested_dml_0\""),
6830            "and the targets from the nested insert's own CTE:\n{}",
6831            out.sql
6832        );
6833    }
6834
6835    #[test]
6836    fn test_a_nested_single_link_insert_reads_back_from_its_cte() {
6837        let out = compile_and_emit_with(
6838            "SELECT (INSERT Person { name := $n, company := (INSERT Company { name := $c }) }) \
6839             { id, company: { name } }",
6840            &make_schema(),
6841        );
6842        assert!(
6843            out.sql.contains("FROM \"_nested_dml_0\""),
6844            "the linked row must be read from the CTE that inserted it:\n{}",
6845            out.sql
6846        );
6847    }
6848
6849    #[test]
6850    fn test_a_free_object_field_holding_an_object_keeps_it_an_object() {
6851        // A free shape is a real object type whose fields are real pointers,
6852        // so an object field stays an object. Pylon used to build the free
6853        // object as jsonb, which flattened it to the bare id.
6854        let out = compile_and_emit_with(
6855            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }",
6856            &make_schema(),
6857        );
6858        assert!(
6859            out.sql.contains("'default::Company'::text"),
6860            "the field must carry the object's own row:\n{}",
6861            out.sql
6862        );
6863    }
6864
6865    #[test]
6866    fn test_projecting_an_object_off_a_free_object() {
6867        // `.co` is a path step through a pointer, not jsonb extraction, so
6868        // what comes back is the object with its shape.
6869        let out = compile_and_emit_with(
6870            "WITH c := (SELECT Company LIMIT 1) SELECT { co := c { name }, n := 1 }.co",
6871            &make_schema(),
6872        );
6873        assert!(
6874            !out.sql.contains("jsonb_build_object"),
6875            "projecting a pointer must not go through jsonb:\n{}",
6876            out.sql
6877        );
6878        assert!(
6879            out.sql.contains("SELECT v AS result, v FROM"),
6880            "the object row is already the result row:\n{}",
6881            out.sql
6882        );
6883    }
6884
6885    #[test]
6886    fn test_a_shape_on_a_single_valued_object_path() {
6887        // `c := .company { name }` — an object route existed only for walks
6888        // that crossed a multi step, so a single-valued one fell through to
6889        // expression position and reported "shapes and set literals are not
6890        // valid in expression context".
6891        let out = compile_and_emit_with("SELECT Person { c := .company { name } }", &make_schema());
6892        assert!(
6893            out.sql.contains("'default::Company'::text"),
6894            "the pointer must carry the object's own row:\n{}",
6895            out.sql
6896        );
6897        assert!(
6898            !out.sql.contains("ARRAY(SELECT"),
6899            "a single-valued walk is one object, not an array of one:\n{}",
6900            out.sql
6901        );
6902    }
6903
6904    #[test]
6905    fn test_a_shapeless_single_link_still_reads_as_its_value() {
6906        // The object route is taken only when a shape says an object was
6907        // meant; without one this stays what it always was.
6908        let out = compile_and_emit_with("SELECT Person { c := .company }", &make_schema());
6909        assert!(
6910            !out.sql.contains("'default::Company'::text"),
6911            "a bare link reference must not grow an object row:\n{}",
6912            out.sql
6913        );
6914    }
6915
6916    #[test]
6917    fn test_a_tuple_element_holding_a_shaped_object() {
6918        // `select (c { name }, 1)` — an element is the same question a free
6919        // object's field asks, and used to report "shapes and set literals
6920        // are not valid in expression context".
6921        let out = compile_and_emit_with(
6922            "WITH c := (SELECT Company LIMIT 1) SELECT (c { name }, 1)",
6923            &make_schema(),
6924        );
6925        assert!(
6926            out.sql.contains("'default::Company'::text"),
6927            "the element must carry the object's own row:\n{}",
6928            out.sql
6929        );
6930    }
6931
6932    #[test]
6933    fn test_an_object_returning_function_as_a_link_value() {
6934        // `created_by := account_of_transaction()` — the function names the
6935        // row to link to, so its key is what gets stored. The schema itself
6936        // writes this as a link default; written out in a query it used to be
6937        // refused as "part of a larger expression".
6938        let mut schema = make_schema();
6939        // make_schema's Company carries only a name; a link's value is its key.
6940        let company = schema
6941            .types
6942            .iter_mut()
6943            .find(|t| t.name == "Company")
6944            .expect("make_schema declares Company");
6945        company.properties.push(PropertyDescriptor {
6946            name: "id".into(),
6947            pg_type: "uuid".into(),
6948            nullable: false,
6949            default_sql: None,
6950            default_pyql: None,
6951            description: None,
6952            check_constraints: vec![],
6953            is_exclusive: true,
6954            is_pk: true,
6955            is_readonly: true,
6956            rewrites: vec![],
6957            tuple_members: None,
6958            column_type: None,
6959        });
6960        schema.functions.push(crate::schema::FunctionDescriptor {
6961            name: "current_company".into(),
6962            module: "default".into(),
6963            params: vec![],
6964            return_pg_type: "default::Company".into(),
6965            return_is_object: true,
6966            return_is_set: false,
6967            return_is_polymorphic: false,
6968            volatility: "stable".into(),
6969            body: "select Company limit 1".into(),
6970        });
6971        let out = compile_and_emit_with("INSERT Person { name := $n, company := current_company() }", &schema);
6972        assert!(
6973            out.sql.contains("FROM \"public\".\"current_company\"()"),
6974            "the function must supply the foreign key:\n{}",
6975            out.sql
6976        );
6977    }
6978
6979    /// `make_schema` plus a `Company`-returning function and a `Person.company`
6980    /// link defaulting to it — jaldis's `Creatable.created_by :=
6981    /// account_of_transaction()` in miniature.
6982    fn make_schema_with_an_object_returning_default() -> SchemaDescriptor {
6983        let mut schema = make_schema();
6984        let company = schema
6985            .types
6986            .iter_mut()
6987            .find(|t| t.name == "Company")
6988            .expect("make_schema declares Company");
6989        company.properties.push(PropertyDescriptor {
6990            name: "id".into(),
6991            pg_type: "uuid".into(),
6992            nullable: false,
6993            default_sql: None,
6994            default_pyql: None,
6995            description: None,
6996            check_constraints: vec![],
6997            is_exclusive: true,
6998            is_pk: true,
6999            is_readonly: true,
7000            rewrites: vec![],
7001            tuple_members: None,
7002            column_type: None,
7003        });
7004        schema.functions.push(crate::schema::FunctionDescriptor {
7005            name: "current_company".into(),
7006            module: "default".into(),
7007            params: vec![],
7008            return_pg_type: "default::Company".into(),
7009            return_is_object: true,
7010            return_is_set: false,
7011            return_is_polymorphic: false,
7012            volatility: "stable".into(),
7013            body: "select Company limit 1".into(),
7014        });
7015        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7016        let company_link = person.links.iter_mut().find(|l| l.name == "company").unwrap();
7017        company_link.default_pyql = Some("current_company()".into());
7018        schema
7019    }
7020
7021    #[test]
7022    fn an_object_returning_link_default_is_applied_by_the_insert() {
7023        // A column DEFAULT cannot hold this one — PostgreSQL evaluates it with
7024        // no query in scope — so the insert has to carry it, expanded into
7025        // the shape.
7026        let schema = make_schema_with_an_object_returning_default();
7027        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
7028        assert!(
7029            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
7030            "the default must supply the foreign key:\n{}",
7031            out.sql
7032        );
7033    }
7034
7035    #[test]
7036    fn an_explicit_value_wins_over_an_inlined_default() {
7037        let schema = make_schema_with_an_object_returning_default();
7038        let out = compile_and_emit_with("INSERT Person { name := $n, company := {} }", &schema);
7039        assert!(
7040            !out.sql.contains("current_company"),
7041            "the shape named the pointer, so the default does not apply:\n{}",
7042            out.sql
7043        );
7044    }
7045
7046    #[test]
7047    fn an_object_returning_link_default_gets_no_column_default() {
7048        // Emitting one produces DDL PostgreSQL refuses to run; it used to be
7049        // dropped silently here, leaving the pointer with no default at all.
7050        let schema = make_schema_with_an_object_returning_default();
7051        let ddl = crate::export::export_schema(&schema).unwrap();
7052        assert!(
7053            !ddl.contains("\"company_id\" uuid DEFAULT"),
7054            "the column must carry no default:\n{ddl}"
7055        );
7056        crate::validate::validate_schema_types(&schema).expect("an inlined default is not an error");
7057    }
7058
7059    #[test]
7060    fn a_column_expressible_default_stays_in_the_ddl() {
7061        // Only what a column DEFAULT cannot hold moves into the insert.
7062        let mut schema = make_schema();
7063        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7064        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
7065        age.default_pyql = Some("21".into());
7066        assert!(
7067            crate::ir::inlined_pointer_defaults(schema.types.iter().find(|t| t.name == "Person").unwrap(), &schema)
7068                .is_empty()
7069        );
7070        let ddl = crate::export::export_schema(&schema).unwrap();
7071        assert!(ddl.contains("DEFAULT 21"), "{ddl}");
7072        let out = compile_and_emit_with("INSERT Person { name := $n }", &schema);
7073        assert!(
7074            !out.sql.contains("21"),
7075            "the column DEFAULT still applies it:\n{}",
7076            out.sql
7077        );
7078    }
7079
7080    /// `make_schema` plus an `Employee` extending `Person`, where `Person`
7081    /// declares a computed naming itself — jaldis's
7082    /// `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)` in miniature.
7083    fn make_schema_with_an_inherited_self_naming_computed() -> SchemaDescriptor {
7084        let mut schema = make_schema();
7085        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7086        person.computed.push(crate::schema::ComputedDescriptor {
7087            name: "own_name".into(),
7088            expression: "default::Person.name".into(),
7089            return_type: Some("text".into()),
7090            link_target: None,
7091            link_multi: false,
7092        });
7093        let mut employee = person.clone();
7094        employee.name = "Employee".into();
7095        employee.table = "Employee".into();
7096        employee.bases = vec!["default::Person".into()];
7097        schema.types.push(employee);
7098        schema
7099    }
7100
7101    #[test]
7102    fn an_inherited_computed_naming_its_declaring_type_still_means_the_row() {
7103        // A computed is compiled once, against the type that declares it, so
7104        // the name reaches the subject on every subtype that inherits it.
7105        // Pylon compiles it again per subtype, where the name matched nothing
7106        // and the reference read the whole table instead.
7107        let schema = make_schema_with_an_inherited_self_naming_computed();
7108        let out = compile_and_emit_with("SELECT Employee { own_name }", &schema);
7109        assert!(
7110            !out.sql.contains("\"Person\""),
7111            "the computed must read the Employee row, not Person's table:\n{}",
7112            out.sql
7113        );
7114        crate::validate::validate_schema_types(&schema).expect("an inherited computed is single-valued");
7115    }
7116
7117    #[test]
7118    fn an_inherited_type_check_computed_tests_the_row_it_is_read_on() {
7119        // jaldis's own: `BrandAddon.bundle := (BrandAddon is BrandAddonBundle)`,
7120        // read on `BrandAddonBundle`. `is` had a same-scope check of its own
7121        // that only knew the two spellings of the type being compiled, so on
7122        // the subtype it read `BrandAddon` as a separate set — many values
7123        // behind a declared `bool`.
7124        let mut schema = make_schema();
7125        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7126        person.computed.push(crate::schema::ComputedDescriptor {
7127            name: "employed".into(),
7128            expression: "(default::Person is default::Employee)".into(),
7129            return_type: Some("boolean".into()),
7130            link_target: None,
7131            link_multi: false,
7132        });
7133        let mut employee = person.clone();
7134        employee.name = "Employee".into();
7135        employee.table = "Employee".into();
7136        employee.bases = vec!["default::Person".into()];
7137        schema.types.push(employee);
7138
7139        crate::validate::validate_schema_types(&schema).expect("the check is single-valued on both types");
7140        let out = compile_and_emit_with("SELECT Employee { employed }", &schema);
7141        assert!(
7142            !out.sql.contains("\"Person\""),
7143            "the check must read the Employee row's own type:\n{}",
7144            out.sql
7145        );
7146    }
7147
7148    /// The interface schema plus a type outside its hierarchy, so a query
7149    /// over that type can name the interface and get one answer per row of
7150    /// it rather than a same-scope check. `Account` also gains a second
7151    /// implementor: with only `Individual` under it, `Account is Individual`
7152    /// holds for every row and is settled without reading any, which is the
7153    /// answer but not the case these tests are about.
7154    fn make_interface_schema_with_an_outsider() -> SchemaDescriptor {
7155        let mut schema = make_interface_schema();
7156        let individual = schema
7157            .types
7158            .iter()
7159            .find(|t| t.name == "Individual")
7160            .expect("Individual is in the interface schema")
7161            .clone();
7162        let mut company = individual.clone();
7163        company.name = "Company".into();
7164        company.table = "Company".into();
7165        company.interfaces = vec![];
7166        company.computed = vec![];
7167        schema.types.push(company);
7168        let mut organisation = individual;
7169        organisation.name = "Organisation".into();
7170        organisation.table = "Organisation".into();
7171        organisation.computed = vec![];
7172        schema.types.push(organisation);
7173        schema
7174    }
7175
7176    #[test]
7177    fn a_type_check_naming_another_type_reads_as_one_value_in_a_filter() {
7178        // jaldis's own: `filter (… if brand::Brand is brand::CustomBrief else
7179        // true)` inside `select brand::Brief`. Naming a type other than the
7180        // one being selected asks the question once per row of that type,
7181        // which came out as `ARRAY(SELECT …)` — a `boolean[]`, which Postgres
7182        // refuses outright as a CASE/WHEN condition. `Company` is not
7183        // polymorphic, so every row answers the same and the check is the
7184        // constant it reads as.
7185        let out = compile_and_emit(
7186            "SELECT Person { name } FILTER (.name = 'a' if default::Company is default::Post else FALSE)",
7187        );
7188        assert!(
7189            !out.sql.contains("ARRAY(SELECT"),
7190            "an answer that is the same for every row has nothing to iterate:\n{}",
7191            out.sql
7192        );
7193        assert!(
7194            out.sql.contains("CASE WHEN FALSE"),
7195            "a Company is never a Post:\n{}",
7196            out.sql
7197        );
7198    }
7199
7200    #[test]
7201    fn a_polymorphic_type_is_never_one_from_a_hierarchy_it_shares_nothing_with() {
7202        // jaldis's own again, and the half the first fix missed: their
7203        // `brand::Brand` is itself an interface, so it is polymorphic and the
7204        // check did *not* fold — it became one answer per Brand row, and with
7205        // three of them the filter failed the single-value check at runtime.
7206        // No row of one hierarchy can ever carry a `__type__` from a disjoint
7207        // one, so the answer is false without reading any.
7208        let schema = make_interface_schema_with_an_outsider();
7209        let out = compile_and_emit_with(
7210            "SELECT Company { id } FILTER (.first_name = 'a' if default::Account is default::Company else FALSE)",
7211            &schema,
7212        );
7213        assert!(
7214            !out.sql.contains("assert_single") && !out.sql.contains("ARRAY(SELECT"),
7215            "nothing to ask per row when the hierarchies are disjoint:\n{}",
7216            out.sql
7217        );
7218        assert!(
7219            out.sql.contains("CASE WHEN FALSE"),
7220            "an Account is never a Company:\n{}",
7221            out.sql
7222        );
7223    }
7224
7225    #[test]
7226    fn a_check_covering_every_implementor_holds_without_reading_a_row() {
7227        // The mirror of the disjoint case: `Individual` and `Organisation`
7228        // are all there is under `Account`, so naming both leaves nothing
7229        // that could answer differently.
7230        let mut schema = make_interface_schema_with_an_outsider();
7231        schema.types.retain(|t| t.name != "Organisation");
7232        let out = compile_and_emit_with(
7233            "SELECT Company { id } FILTER (default::Account is default::Individual)",
7234            &schema,
7235        );
7236        assert!(
7237            !out.sql.contains("assert_single"),
7238            "every Account is an Individual when it is the only implementor:\n{}",
7239            out.sql
7240        );
7241        assert!(out.sql.contains("WHERE TRUE"), "{}", out.sql);
7242    }
7243
7244    #[test]
7245    fn a_type_check_over_a_polymorphic_outsider_collapses_to_one_value() {
7246        // What is left after both folds: `Account` has two implementors, so
7247        // `is Individual` really does differ per row. A filter still takes one
7248        // value, and the message names the check rather than the internal
7249        // helper enforcing it.
7250        let schema = make_interface_schema_with_an_outsider();
7251        let out = compile_and_emit_with(
7252            "SELECT Company { id } FILTER (default::Account is default::Individual)",
7253            &schema,
7254        );
7255        assert!(
7256            out.sql.contains("\"_pylon\".\"assert_single\"(ARRAY(SELECT"),
7257            "one answer per Account row, read as a single value:\n{}",
7258            out.sql
7259        );
7260        assert!(
7261            out.sql
7262                .contains("is default::Individual'' is asked once for every default::Account object"),
7263            "the error has to name the check, not assert_single:\n{}",
7264            out.sql
7265        );
7266    }
7267
7268    #[test]
7269    fn a_type_check_naming_another_type_keeps_its_whole_set_in_a_shape() {
7270        // The other half of the same rule: a shape pointer holds a set, so
7271        // there the per-row answers stay a set rather than collapsing.
7272        let schema = make_interface_schema_with_an_outsider();
7273        let out = compile_and_emit_with(
7274            "SELECT Company { id, each := default::Account is default::Individual }",
7275            &schema,
7276        );
7277        assert!(
7278            out.sql.contains("array_agg") && !out.sql.contains("assert_single"),
7279            "a shape keeps every answer:\n{}",
7280            out.sql
7281        );
7282    }
7283
7284    #[test]
7285    fn a_shape_writing_the_computed_out_itself_does_not_get_the_anchor() {
7286        // The rule is narrow: only a *declared* computed is compiled against
7287        // its declaring type. Naming a supertype anywhere else is that
7288        // supertype's own set —
7289        // `select BrandAddonBundle { n := count(BrandAddon) }` is the full
7290        // count while the same shape naming `BrandAddonBundle` is 1. So the
7291        // same expression written out in a shape keeps resolving on its own,
7292        // even where it shadows the inherited computed's name.
7293        let schema = make_schema_with_an_inherited_self_naming_computed();
7294        let ast = parse::parse("SELECT Employee { own_name := default::Person.name }").expect("parse failed");
7295        let Err(error) = ir::compile(&ast, &schema) else {
7296            panic!("an absolute path has nothing to anchor to here")
7297        };
7298        assert!(
7299            format!("{error:?}").contains("absolute paths are not valid"),
7300            "{error:?}"
7301        );
7302    }
7303
7304    #[test]
7305    fn a_default_that_compiles_nowhere_is_still_an_error() {
7306        // jaldis carried `sequence_next(INTROSPECT marketplace::OrderNo)`,
7307        // which PyQL cannot parse. Inlining must not turn that into silence.
7308        let mut schema = make_schema();
7309        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
7310        let age = person.properties.iter_mut().find(|p| p.name == "age").unwrap();
7311        age.default_pyql = Some("sequence_next(INTROSPECT default::AgeSeq)".into());
7312        let errs = crate::validate::validate_schema_types(&schema).unwrap_err();
7313        let (_, msg, _) = errs[0].class_name_message_position();
7314        assert!(msg.contains("default"), "{msg}");
7315    }
7316
7317    #[test]
7318    fn test_comparing_a_link_with_an_object_returning_function() {
7319        // Comparing objects compares identity, so the call stands for the id
7320        // of the row it returns. Left whole it is a function call with nowhere
7321        // to go but the subject of a select.
7322        let mut schema = make_schema();
7323        // make_schema's Company carries only a name; identity needs its key.
7324        let company = schema
7325            .types
7326            .iter_mut()
7327            .find(|t| t.name == "Company")
7328            .expect("make_schema declares Company");
7329        company.properties.push(PropertyDescriptor {
7330            name: "id".into(),
7331            pg_type: "uuid".into(),
7332            nullable: false,
7333            default_sql: None,
7334            default_pyql: None,
7335            description: None,
7336            check_constraints: vec![],
7337            is_exclusive: true,
7338            is_pk: true,
7339            is_readonly: true,
7340            rewrites: vec![],
7341            tuple_members: None,
7342            column_type: None,
7343        });
7344        schema.functions.push(crate::schema::FunctionDescriptor {
7345            name: "current_company".into(),
7346            module: "default".into(),
7347            params: vec![],
7348            return_pg_type: "default::Company".into(),
7349            return_is_object: true,
7350            return_is_set: false,
7351            return_is_polymorphic: false,
7352            volatility: "stable".into(),
7353            body: "select Company limit 1".into(),
7354        });
7355        let out = compile_and_emit_with("SELECT Person FILTER .company = current_company()", &schema);
7356        assert!(
7357            out.sql.contains("\"company_id\"") && out.sql.contains("current_company"),
7358            "the link's key against the function's row:\n{}",
7359            out.sql
7360        );
7361    }
7362
7363    #[test]
7364    fn test_choosing_between_two_object_sets() {
7365        // `A if cond else B` over objects is not the scalar CASE an if/else
7366        // over values gives: it stands for A's rows when the condition holds
7367        // and B's when it does not.
7368        let out = compile_and_emit_with(
7369            "WITH a := (SELECT Person FILTER .name = $n), b := (SELECT Person) \
7370             SELECT (a IF EXISTS a ELSE b) { name }",
7371            &make_schema(),
7372        );
7373        assert!(
7374            out.sql.contains("UNION ALL"),
7375            "expected one branch per side:\n{}",
7376            out.sql
7377        );
7378    }
7379
7380    #[test]
7381    fn test_a_union_operand_written_inline() {
7382        // A union reads one relation per branch, so an operand that names no
7383        // relation yet -- a sub-select, or an insert in a get-or-create -- is
7384        // hoisted into a CTE of its own first.
7385        let out = compile_and_emit_with(
7386            "SELECT ((SELECT Person FILTER .name = $n) UNION (SELECT Person)) { name }",
7387            &make_schema(),
7388        );
7389        assert!(
7390            out.sql.starts_with("WITH"),
7391            "expected the operands hoisted:\n{}",
7392            out.sql
7393        );
7394        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7395    }
7396
7397    #[test]
7398    fn test_a_for_loop_variable_over_objects() {
7399        // The variable holds the row's key, so a path off it reads that row
7400        // back. It used to be treated as a scalar throughout, and any path
7401        // rooted at one reported "unknown type".
7402        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p.name)", &make_schema());
7403        assert!(
7404            out.sql.contains("FROM \"public\".\"Person\"") && out.sql.contains("\"_for_p\".\"v\""),
7405            "the body must read the row the variable names:\n{}",
7406            out.sql
7407        );
7408    }
7409
7410    #[test]
7411    fn test_a_shape_on_a_for_loop_variable() {
7412        let out = compile_and_emit_with("FOR p IN (SELECT Person) UNION (SELECT p { name })", &make_schema());
7413        assert!(
7414            out.sql.contains("'default::Person'::text"),
7415            "a shape on the variable yields its object:\n{}",
7416            out.sql
7417        );
7418    }
7419
7420    #[test]
7421    fn test_a_for_loop_variable_over_values_is_still_a_scalar() {
7422        let out = compile_and_emit_with("FOR n IN {1, 2} UNION (SELECT n)", &make_schema());
7423        assert!(out.sql.contains("VALUES"), "{}", out.sql);
7424    }
7425
7426    #[test]
7427    fn test_exists_over_a_sub_select_on_a_relative_path() {
7428        // `exists ((select .posts filter …))` — the sub-select is relative to
7429        // the enclosing object, so it needs the same rooting and correlation
7430        // `count((select .posts))` gets. Compiled without them it resolved in
7431        // free context and called the pointer unknown.
7432        let out = compile_and_emit_with(
7433            "SELECT Person { id } FILTER EXISTS ((SELECT .posts FILTER .title = $t))",
7434            &make_schema(),
7435        );
7436        assert!(out.sql.contains("EXISTS("), "{}", out.sql);
7437        assert!(
7438            out.sql.contains("\"Person.posts\""),
7439            "the junction join must survive into the EXISTS:\n{}",
7440            out.sql
7441        );
7442        assert!(
7443            out.sql.contains("\"t0\".\"id\""),
7444            "and it must correlate to the enclosing row:\n{}",
7445            out.sql
7446        );
7447    }
7448
7449    #[test]
7450    fn test_counting_the_rows_a_mutation_touched() {
7451        // `count((delete X filter …))` — the mutation runs as its own
7452        // data-modifying CTE and the aggregate reads it back, rather than
7453        // being refused as "a delete cannot stand in for a value".
7454        let out = compile_and_emit_with("SELECT count((DELETE Person FILTER .name = $n))", &make_schema());
7455        assert!(out.sql.starts_with("WITH"), "{}", out.sql);
7456        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""), "{}", out.sql);
7457        assert!(out.sql.contains("count(*)"), "{}", out.sql);
7458    }
7459
7460    #[test]
7461    fn test_a_conflict_target_naming_several_columns() {
7462        // `unless conflict on (.a, .b)` names columns for Postgres to infer
7463        // the arbiter index from; emitted as one composite value it would
7464        // look for an expression index on the tuple instead.
7465        let out = compile_and_emit_with(
7466            "INSERT Person { name := $n } UNLESS CONFLICT ON (.name, .id) ELSE (UPDATE Person SET { name := $n })",
7467            &make_schema(),
7468        );
7469        assert!(
7470            out.sql.contains("ON CONFLICT (\"name\", \"id\")"),
7471            "expected a column list:\n{}",
7472            out.sql
7473        );
7474    }
7475
7476    #[test]
7477    fn test_a_for_loop_over_a_path() {
7478        // `for post in p.posts union (…)` — a walk off a binding names a set
7479        // to iterate just as a sub-select does, only written without the
7480        // parentheses.
7481        let out = compile_and_emit_with(
7482            "WITH p := (SELECT Person LIMIT 1) FOR post IN p.posts UNION (SELECT post.title)",
7483            &make_schema(),
7484        );
7485        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
7486    }
7487
7488    #[test]
7489    fn test_a_relative_sub_select_keeps_its_enclosing_object() {
7490        // `assert_exists((select .posts { title }))` and `posts := (select
7491        // .posts filter …)` in an update both compiled their sub-select with
7492        // no enclosing object, so the relative path resolved in free context
7493        // and the pointer came back unknown.
7494        let out = compile_and_emit_with(
7495            "SELECT Person { ps := assert_exists((SELECT .posts { title })) }",
7496            &make_schema(),
7497        );
7498        assert!(out.sql.contains("assert_exists"), "{}", out.sql);
7499        assert!(
7500            out.sql.contains("\"Person.posts\""),
7501            "the walk must reach the junction:\n{}",
7502            out.sql
7503        );
7504    }
7505
7506    #[test]
7507    fn test_a_relative_sub_select_as_a_multi_link_value() {
7508        let out = compile_and_emit_with(
7509            "UPDATE Person FILTER .name = $n SET { posts := (SELECT .posts FILTER .title = $t) }",
7510            &make_schema(),
7511        );
7512        assert!(
7513            out.sql.contains("\"Person.posts\""),
7514            "the value must read the row's own posts:\n{}",
7515            out.sql
7516        );
7517    }
7518
7519    #[test]
7520    fn test_a_shape_written_after_a_sub_select() {
7521        // `(select Post filter …) { title }` means the same as putting the
7522        // shape inside the parentheses, so the shape is pushed onto the inner
7523        // statement's own result rather than refused.
7524        let out = compile_and_emit_with(
7525            "SELECT Person { ps := (SELECT Post FILTER .title = $t) { title } }",
7526            &make_schema(),
7527        );
7528        assert!(
7529            out.sql.contains("'default::Post'::text"),
7530            "the pointer must carry the object rows:\n{}",
7531            out.sql
7532        );
7533    }
7534
7535    #[test]
7536    fn test_a_shape_after_a_sub_select_with_a_with_block() {
7537        let out = compile_and_emit_with(
7538            "SELECT Person { ps := (WITH t := $t SELECT Post FILTER .title = t) { title } }",
7539            &make_schema(),
7540        );
7541        assert!(out.sql.contains("'default::Post'::text"), "{}", out.sql);
7542    }
7543
7544    #[test]
7545    fn test_a_computed_declared_as_an_object_returning_call() {
7546        // `recent := recent()` — the rows the function yields are the
7547        // pointer's objects. Read as a plain expression the call was "part of
7548        // a larger expression", which such a function refuses, so a splat over
7549        // a type carrying one could not compile at all.
7550        let mut schema = make_schema();
7551        schema.functions.push(crate::schema::FunctionDescriptor {
7552            name: "recent".into(),
7553            module: "default".into(),
7554            params: vec![],
7555            return_pg_type: "default::Post".into(),
7556            return_is_object: true,
7557            return_is_set: true,
7558            return_is_polymorphic: false,
7559            volatility: "stable".into(),
7560            body: "select Post".into(),
7561        });
7562        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
7563            name: "recent".into(),
7564            expression: "default::recent()".into(),
7565            return_type: None,
7566            link_target: None,
7567            link_multi: false,
7568        });
7569        let out = compile_and_emit_with("SELECT Person { recent }", &schema);
7570        assert!(
7571            out.sql.contains("ARRAY(SELECT") && out.sql.contains("'default::Post'::text"),
7572            "the pointer must carry the function's rows:\n{}",
7573            out.sql
7574        );
7575    }
7576
7577    #[test]
7578    fn test_one_branch_of_an_object_if_else_is_empty() {
7579        // `{}` contributes no rows, so the full rewrite (`SELECT A WHERE
7580        // Cond UNION ALL SELECT B WHERE NOT Cond`) reduces to the other
7581        // branch under its guard.
7582        let out = compile_and_emit_with(
7583            "WITH p := (SELECT Person LIMIT 1) SELECT p { name } IF EXISTS p ELSE {}",
7584            &make_schema(),
7585        );
7586        assert!(!out.sql.contains("UNION ALL"), "nothing to union with:\n{}", out.sql);
7587        assert!(out.sql.contains("'default::Person'::text"), "{}", out.sql);
7588    }
7589
7590    #[test]
7591    fn test_a_conditional_insert_is_guarded_by_its_condition() {
7592        // `(insert …) if cond else {}` — guarding a reader would not do: the
7593        // insert is a data-modifying CTE that Postgres runs regardless, so
7594        // the condition goes into the insert itself and it writes nothing
7595        // when false. Verified live: running it twice leaves one row.
7596        let out = compile_and_emit_with("SELECT (INSERT Person { name := $n }) IF FALSE ELSE {}", &make_schema());
7597        assert!(
7598            out.sql.contains("SELECT") && out.sql.contains("WHERE"),
7599            "a guarded insert selects its row rather than listing values:\n{}",
7600            out.sql
7601        );
7602        assert!(!out.sql.contains("VALUES"), "VALUES always yields a row:\n{}", out.sql);
7603    }
7604
7605    #[test]
7606    fn test_a_conditional_update_carries_its_condition() {
7607        // Guarding the branch only filters what is read back -- the mutation
7608        // is its own CTE and Postgres runs it regardless. An update has a
7609        // WHERE of its own to fold the condition into, which is what keeps it
7610        // from running when the condition is false.
7611        let out = compile_and_emit(
7612            "SELECT (UPDATE Person FILTER .name = $n SET { name := $m }) IF EXISTS (SELECT Company) ELSE {}",
7613        );
7614        let update = out.sql.find("UPDATE").expect("an update is emitted");
7615        let where_clause = out.sql[update..].find("WHERE").expect("the update is filtered");
7616        assert!(
7617            out.sql[update + where_clause..].contains("EXISTS"),
7618            "the condition narrows the rows the update touches:\n{}",
7619            out.sql
7620        );
7621    }
7622
7623    #[test]
7624    fn test_a_conditional_delete_carries_its_condition() {
7625        // Guarding the branch would only filter what is read back — Postgres
7626        // runs a data-modifying CTE regardless — so the condition has to be
7627        // ANDed into the DELETE's own WHERE, as it already is for an update.
7628        let out = compile_and_emit("SELECT (DELETE Person FILTER .name = $n) IF FALSE ELSE {}");
7629        let delete = out
7630            .sql
7631            .split("DELETE FROM")
7632            .nth(1)
7633            .expect("the delete should still be emitted");
7634        let where_clause = delete.split("WHERE").nth(1).expect("the delete should be filtered");
7635        assert!(
7636            where_clause.contains("FALSE") || where_clause.contains("false"),
7637            "the condition must narrow the delete itself:\n{}",
7638            out.sql
7639        );
7640    }
7641
7642    #[test]
7643    fn test_a_pointer_a_binding_declared_in_its_own_shape() {
7644        // `p := (select Person { boss := … })` then `p { boss }` — the
7645        // pointer is on no type, so reading it back has to find it through
7646        // the binding it was written on, and compile it against that row.
7647        let out = compile_and_emit_with(
7648            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT p { name, co: { name } }",
7649            &make_schema(),
7650        );
7651        assert!(
7652            out.sql.contains("'default::Company'::text"),
7653            "the declared pointer must be inlined:\n{}",
7654            out.sql
7655        );
7656    }
7657
7658    #[test]
7659    fn test_a_binding_declared_pointer_read_from_a_tuple_element() {
7660        let out = compile_and_emit_with(
7661            "WITH p := (SELECT Person { co := .company { name } } LIMIT 1) SELECT (p { co: { name } }, 1)",
7662            &make_schema(),
7663        );
7664        assert!(out.sql.contains("'default::Company'::text"), "{}", out.sql);
7665    }
7666
7667    #[test]
7668    fn test_a_for_loop_variable_shaped_inside_a_tuple() {
7669        // The variable holds one row's key, so the element has to be narrowed
7670        // to it. Unfiltered it read the whole table and answered with rows
7671        // the loop never named -- wrong, and silently so.
7672        let out = compile_and_emit_with(
7673            "WITH p := (SELECT Person LIMIT 1) FOR q IN p.posts UNION (SELECT (a := q { title }, b := 1))",
7674            &make_schema(),
7675        );
7676        assert!(
7677            out.sql.contains("\"_for_q\".\"v\""),
7678            "the element must be narrowed to the loop's row:\n{}",
7679            out.sql
7680        );
7681    }
7682
7683    #[test]
7684    fn test_choosing_between_two_walks() {
7685        // `(select p.posts …) if cond else (select p.posts …)` — a walk names
7686        // no type of its own, so neither branch could be resolved.
7687        let out = compile_and_emit_with(
7688            "WITH p := (SELECT Person LIMIT 1), q := (SELECT p.posts LIMIT 1) IF TRUE ELSE (SELECT p.posts) \
7689             SELECT q { title }",
7690            &make_schema(),
7691        );
7692        assert!(out.sql.contains("UNION ALL"), "one branch per side:\n{}", out.sql);
7693    }
7694
7695    #[test]
7696    fn test_a_bare_coalesce_of_correlated_walks_reaching_many_objects() {
7697        // Without a shape the walks stand for the ids they reach, which is
7698        // still a set -- SQL's COALESCE takes one value per side.
7699        let out = compile_and_emit_with(
7700            "SELECT Post { owners := .<posts[is Person] ?? .<posts[is Person] }",
7701            &make_schema(),
7702        );
7703        assert!(
7704            !out.sql.contains("COALESCE("),
7705            "not a value-by-value choice:\n{}",
7706            out.sql
7707        );
7708        assert!(
7709            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7710            "the arms are aggregated:\n{}",
7711            out.sql
7712        );
7713    }
7714
7715    #[test]
7716    fn test_a_union_of_correlated_walks_reaching_many() {
7717        // Without the LIMIT the union stands for every object both walks
7718        // reach, so its arms have to be aggregated. Read as a lone record it
7719        // compiled fine and failed in PostgreSQL on the second row, with
7720        // "more than one row returned by a subquery used as an expression".
7721        let out = compile_and_emit_with(
7722            "SELECT Post { owners := (.<posts[is Person] UNION .<posts[is Person]) { name } }",
7723            &make_schema(),
7724        );
7725        assert!(
7726            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7727            "the arms are aggregated:\n{}",
7728            out.sql
7729        );
7730    }
7731
7732    #[test]
7733    fn test_a_coalesce_of_correlated_walks_reaching_many() {
7734        // `([is A].links ?? [is B].links) { … }` — the same set a union of
7735        // them gives, so it aggregates the same way.
7736        let out = compile_and_emit_with(
7737            "SELECT Post { owners := (.<posts[is Person] ?? .<posts[is Person]) { name } }",
7738            &make_schema(),
7739        );
7740        assert!(
7741            out.sql.contains("ARRAY(SELECT \"r\" FROM ("),
7742            "the arms are aggregated:\n{}",
7743            out.sql
7744        );
7745    }
7746
7747    #[test]
7748    fn test_a_union_of_correlated_walks() {
7749        // `(.<a[is T] union .<b[is T]) { id }` — each operand hangs off the
7750        // enclosing row, so none can be hoisted into a CTE the way a
7751        // standalone union's operands are; they are read as one set instead.
7752        let out = compile_and_emit_with(
7753            "SELECT Post { owners := (SELECT (.<posts[is Person] UNION .<posts[is Person]) { name } LIMIT 1) }",
7754            &make_schema(),
7755        );
7756        assert!(out.sql.contains("UNION ALL"), "one arm per operand:\n{}", out.sql);
7757        assert!(
7758            out.sql.contains("AS \"_u\""),
7759            "the arms are read as one set:\n{}",
7760            out.sql
7761        );
7762    }
7763
7764    #[test]
7765    fn test_a_for_loop_whose_body_updates() {
7766        // Driven from the iteration with `UPDATE … FROM`: the select-bodied
7767        // form uses a LATERAL, which cannot hold DML. Verified live -- three
7768        // rows iterated, three rows updated.
7769        let out = compile_and_emit_with(
7770            "FOR p IN (SELECT Person) UNION (UPDATE Person FILTER .id = p.id SET { name := $n })",
7771            &make_schema(),
7772        );
7773        assert!(out.sql.contains("UPDATE \"public\".\"Person\""), "{}", out.sql);
7774        assert!(
7775            out.sql.contains("FROM \"_for_p\""),
7776            "driven from the iteration:\n{}",
7777            out.sql
7778        );
7779        assert!(
7780            !out.sql.contains("LATERAL"),
7781            "DML cannot sit in a LATERAL:\n{}",
7782            out.sql
7783        );
7784    }
7785
7786    #[test]
7787    fn test_a_for_loop_body_appends_the_row_it_is_iterating() {
7788        let out = compile_and_emit(
7789            "WITH t := (SELECT Post) FOR q IN t UNION (UPDATE Person FILTER .id = $i SET { posts += q })",
7790        );
7791        // One junction row per iteration, taken from the iteration itself --
7792        // not every Post, which an uncorrelated value subquery would append.
7793        assert!(
7794            out.sql.contains("\"_iter\"") && out.sql.contains("FROM \"_ids\""),
7795            "junction rows driven from the iteration:\n{}",
7796            out.sql
7797        );
7798        assert!(
7799            !out.sql.contains("LATERAL"),
7800            "DML cannot sit in a LATERAL:\n{}",
7801            out.sql
7802        );
7803    }
7804
7805    #[test]
7806    fn test_select_on_a_for_loop_variable_reads_only_its_row() {
7807        let out = compile_and_emit("FOR p IN (SELECT Person) UNION (SELECT (SELECT p) { name })");
7808        assert!(
7809            out.sql.contains("= \"_for_p\".\"v\""),
7810            "narrowed to the row the variable holds:\n{}",
7811            out.sql
7812        );
7813    }
7814
7815    #[test]
7816    fn test_a_walk_off_a_for_loop_variable_reads_the_binding() {
7817        // The rows a sibling CTE just wrote are not in the base table yet, so
7818        // a walk that re-reads the table finds nothing.
7819        let out = compile_and_emit("WITH ps := (SELECT Post) FOR q IN ps UNION (SELECT Person FILTER .name = q.title)");
7820        assert!(
7821            out.sql.contains("FROM \"ps\""),
7822            "the walk reads the binding:\n{}",
7823            out.sql
7824        );
7825    }
7826
7827    #[test]
7828    fn test_a_mutating_for_loop_binding_becomes_sibling_ctes() {
7829        let out = compile_and_emit(
7830            "WITH made := (FOR n IN {'a', 'b'} UNION (INSERT Post { title := n })) SELECT count(made)",
7831        );
7832        assert!(
7833            out.sql.contains("\"made__ids\" AS (\nINSERT INTO"),
7834            "the insert is its own top-level CTE:\n{}",
7835            out.sql
7836        );
7837        assert!(
7838            !out.sql.contains("LATERAL"),
7839            "DML cannot sit in a LATERAL:\n{}",
7840            out.sql
7841        );
7842    }
7843
7844    #[test]
7845    fn test_count_of_a_binding_counts_its_rows() {
7846        // Read as an ordinary expression a binding becomes a scalar subquery,
7847        // which aborts on the second row.
7848        let out = compile_and_emit("WITH people := (SELECT Person) SELECT count(people)");
7849        assert!(out.sql.contains("(SELECT count(*) FROM \"people\")"), "{}", out.sql);
7850    }
7851
7852    #[test]
7853    fn test_dividing_integers_yields_a_float() {
7854        for (query, expected) in [
7855            ("SELECT 100 / 133", "((100)::float8 / 133)"),
7856            ("SELECT Person { a := .age / 2 }", "::float8 / 2)"),
7857            ("SELECT Person.age / 2", "::float8 / 2)"),
7858            (
7859                "WITH total := max({133}), current := 38 - 1 SELECT 100 / total * current",
7860                "(((100)::float8 / (SELECT \"v\" FROM \"total\")) * (SELECT \"v\" FROM \"current\"))",
7861            ),
7862            (
7863                "WITH p := (SELECT Person LIMIT 1), index := p.age - 1, total := max(Person.age) SELECT 100 / total * index",
7864                "((100)::float8 / (SELECT \"v\" FROM \"total\"))",
7865            ),
7866        ] {
7867            let out = compile_and_emit(query);
7868            assert!(out.sql.contains(expected), "{query}:\n{}", out.sql);
7869        }
7870    }
7871
7872    #[test]
7873    fn test_dividing_anything_but_two_integers_is_left_to_postgres() {
7874        for query in [
7875            "SELECT 100 // 133",
7876            "SELECT 100 / 1.5",
7877            "SELECT <decimal>100 / <decimal>3",
7878            "SELECT 100 / <decimal>3",
7879        ] {
7880            let out = compile_and_emit(query);
7881            assert!(!out.sql.contains("::float8 /"), "{query}:\n{}", out.sql);
7882        }
7883    }
7884
7885    #[test]
7886    fn test_a_free_object_under_a_condition_stays_an_object() {
7887        for query in [
7888            "SELECT { a := 1 } IF true ELSE {}",
7889            "SELECT {} IF true ELSE { a := 1 }",
7890            "SELECT { a := 1 } IF true ELSE {} LIMIT 1",
7891        ] {
7892            let out = compile_and_emit(query);
7893            assert!(
7894                matches!(&out.shape.root, ShapeNode::Object { type_name: None, pointers, .. } if pointers.len() == 1),
7895                "{query}: {:?}\n{}",
7896                out.shape.root,
7897                out.sql
7898            );
7899        }
7900    }
7901
7902    #[test]
7903    fn test_exists_of_a_binding_asks_for_any_row() {
7904        let out = compile_and_emit("WITH people := (SELECT Person) SELECT exists people");
7905        assert!(out.sql.contains("EXISTS(SELECT 1 FROM \"people\")"), "{}", out.sql);
7906
7907        let out = compile_and_emit("WITH names := {'a', 'b'} SELECT exists names");
7908        assert!(
7909            out.sql
7910                .contains("EXISTS(SELECT 1 FROM \"names\" WHERE \"v\" IS NOT NULL)"),
7911            "{}",
7912            out.sql
7913        );
7914    }
7915
7916    #[test]
7917    fn test_a_conditional_insert_beside_a_read_branch_unions_both() {
7918        let out = compile_and_emit(
7919            "WITH existing := (SELECT Person FILTER .name = $n LIMIT 1) \
7920             SELECT (existing IF EXISTS existing ELSE (INSERT Person { name := $n })) { name }",
7921        );
7922        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
7923        assert!(
7924            out.sql.contains("INSERT INTO") && out.sql.contains("WHERE (NOT EXISTS(SELECT 1 FROM \"existing\"))"),
7925            "the insert carries the condition itself:\n{}",
7926            out.sql
7927        );
7928    }
7929
7930    /// `@pylon.abstract` — unlike an interface it is materialised as nothing
7931    /// at all, so nothing can read a relation under its name. Modelled on
7932    /// `Person`, the one test type carrying the `id` every implementor of a
7933    /// real abstract has.
7934    fn make_schema_with_a_plain_abstract() -> SchemaDescriptor {
7935        let mut schema = make_schema();
7936        let mut archivable = schema
7937            .types
7938            .iter()
7939            .find(|t| t.name == "Person")
7940            .expect("Person is in the test schema")
7941            .clone();
7942        archivable.name = "Archivable".into();
7943        archivable.table = "Archivable".into();
7944        archivable.abstract_ = true;
7945        archivable.materialized = false;
7946        archivable.links = vec![];
7947        archivable.multilinks = vec![];
7948        schema.types.push(archivable);
7949        let person = schema
7950            .types
7951            .iter_mut()
7952            .find(|t| t.name == "Person")
7953            .expect("just found");
7954        person.parents.push("default::Archivable".into());
7955        schema
7956    }
7957
7958    #[test]
7959    fn test_an_intersection_with_a_plain_abstract_fans_out() {
7960        let schema = make_schema_with_a_plain_abstract();
7961        let out = compile_and_emit_with("SELECT Post { who := [is default::Archivable].name }", &schema);
7962        assert!(
7963            !out.sql.contains("\"Archivable\""),
7964            "a plain abstract backs no relation:\n{}",
7965            out.sql
7966        );
7967        assert!(
7968            out.sql.contains("\"public\".\"Person\""),
7969            "read from the type that carries the column:\n{}",
7970            out.sql
7971        );
7972    }
7973
7974    /// The same plain abstract, but keeping `Person`'s `company` link so a
7975    /// backlink can be narrowed to it the way jaldis narrows one to
7976    /// `default::Creatable`.
7977    fn make_schema_with_a_linking_plain_abstract() -> SchemaDescriptor {
7978        let mut schema = make_schema_with_a_plain_abstract();
7979        let person_links = schema
7980            .types
7981            .iter()
7982            .find(|t| t.name == "Person")
7983            .expect("Person is in the test schema")
7984            .links
7985            .clone();
7986        let archivable = schema
7987            .types
7988            .iter_mut()
7989            .find(|t| t.name == "Archivable")
7990            .expect("just added");
7991        archivable.links = person_links;
7992        schema
7993    }
7994
7995    #[test]
7996    fn test_a_plain_abstract_in_a_subquery_fans_out() {
7997        let schema = make_schema_with_a_plain_abstract();
7998        let out = compile_and_emit_with(
7999            "SELECT Person FILTER .id IN (SELECT default::Archivable FILTER .age > 1).id",
8000            &schema,
8001        );
8002        assert!(
8003            !out.sql.contains("\"Archivable\""),
8004            "a plain abstract backs no relation:\n{}",
8005            out.sql
8006        );
8007    }
8008
8009    #[test]
8010    fn test_a_backlink_narrowed_to_a_plain_abstract_fans_out() {
8011        let schema = make_schema_with_a_linking_plain_abstract();
8012        let out = compile_and_emit_with(
8013            "SELECT Company FILTER .<company[is default::Archivable].name = 'a'",
8014            &schema,
8015        );
8016        assert!(
8017            !out.sql.contains("\"Archivable\""),
8018            "a plain abstract backs no relation:\n{}",
8019            out.sql
8020        );
8021    }
8022
8023    /// `Account` as a real interface (a view), with a plain abstract only its
8024    /// implementor carries — jaldis reads `[is default::Auditable].*` off
8025    /// `marketplace::IdentifiableListing` exactly this way.
8026    fn make_interface_schema_with_a_plain_abstract() -> SchemaDescriptor {
8027        let mut schema = make_interface_schema();
8028        let mut archivable = schema
8029            .types
8030            .iter()
8031            .find(|t| t.name == "Individual")
8032            .expect("Individual is in the interface schema")
8033            .clone();
8034        archivable.name = "Archivable".into();
8035        archivable.table = "Archivable".into();
8036        archivable.abstract_ = true;
8037        archivable.materialized = false;
8038        archivable.interfaces = vec![];
8039        archivable.links = vec![];
8040        archivable.multilinks = vec![];
8041        archivable.computed = vec![];
8042        schema.types.push(archivable);
8043        for t in schema.types.iter_mut() {
8044            match t.name.as_str() {
8045                // A view to read, so the splat cannot fall back to it.
8046                "Account" => t.materialized = true,
8047                "Individual" => t.parents.push("default::Archivable".into()),
8048                _ => {}
8049            }
8050        }
8051        schema
8052    }
8053
8054    #[test]
8055    fn test_a_splat_over_a_plain_abstract_fans_out() {
8056        let schema = make_interface_schema_with_a_plain_abstract();
8057        let out = compile_and_emit_with("SELECT Account { id, [is default::Archivable].* }", &schema);
8058        assert!(
8059            !out.sql.contains("\"Archivable\""),
8060            "a plain abstract backs no relation:\n{}",
8061            out.sql
8062        );
8063        assert!(
8064            out.sql.contains("first_name"),
8065            "the splat still reaches the mixin's own pointers:\n{}",
8066            out.sql
8067        );
8068    }
8069
8070    #[test]
8071    fn test_an_aggregate_over_a_relative_walk_runs_inside_the_subquery() {
8072        // Compiled as an expression the walk stands for the array of its
8073        // elements, leaving the aggregate with an array argument.
8074        let out = compile_and_emit("SELECT Person { latest := max(.posts.title) }");
8075        assert!(out.sql.contains("(SELECT max("), "{}", out.sql);
8076        assert!(!out.sql.contains("max(ARRAY("), "{}", out.sql);
8077    }
8078
8079    /// `Company.staff` — a computed standing for a narrowed backlink, the
8080    /// shape jaldis reaches for wherever one type owns rows of another.
8081    fn make_schema_with_a_computed_backlink() -> SchemaDescriptor {
8082        use crate::schema::ComputedDescriptor;
8083        let mut schema = make_schema();
8084        let company = schema
8085            .types
8086            .iter_mut()
8087            .find(|t| t.name == "Company")
8088            .expect("Company is in the test schema");
8089        company.computed = vec![ComputedDescriptor {
8090            name: "staff".into(),
8091            expression: ".<company[is default::Person]".into(),
8092            return_type: None,
8093            link_target: None,
8094            link_multi: false,
8095        }];
8096        schema
8097    }
8098
8099    #[test]
8100    fn test_a_computed_backlink_reads_the_objects_it_lands_on() {
8101        let schema = make_schema_with_a_computed_backlink();
8102        let out = compile_and_emit_with("SELECT Company { staff: { name } }", &schema);
8103        assert!(
8104            out.sql.contains("\"public\".\"Person\""),
8105            "the walk reaches Person:\n{}",
8106            out.sql
8107        );
8108    }
8109
8110    #[test]
8111    fn test_a_computed_backlinks_modifiers_belong_to_what_it_lands_on() {
8112        // Bound to the declaring type instead, `.name` is a field Company has
8113        // not got and the query is rejected outright.
8114        let schema = make_schema_with_a_computed_backlink();
8115        let out = compile_and_emit_with("SELECT Company.staff { name } FILTER .name = 'ada'", &schema);
8116        assert!(out.sql.contains("'ada'"), "{}", out.sql);
8117        assert!(
8118            out.sql.contains("\"public\".\"Person\""),
8119            "filtered on the Person the walk lands on:\n{}",
8120            out.sql
8121        );
8122    }
8123
8124    #[test]
8125    fn test_an_unreserved_keyword_names_a_free_shape_field() {
8126        // `last`, `first` and `order` are unreserved keywords, so a shape can
8127        // carry them. Read as a set literal, the `:=` has nowhere to go.
8128        let out = compile_and_emit("SELECT { last := 2, first := 1, order := 3 }");
8129        assert!(out.sql.contains("\"last\""), "{}", out.sql);
8130        assert!(out.sql.contains("\"first\""), "{}", out.sql);
8131        assert!(out.sql.contains("\"order\""), "{}", out.sql);
8132    }
8133
8134    #[test]
8135    fn test_a_shaped_sub_select_over_a_type_is_an_object() {
8136        // Shapeless it stands for the row's key; with a shape it is the object
8137        // the shape reads, which used to be refused outright.
8138        let out = compile_and_emit("SELECT Person { latest := (SELECT Post { title } LIMIT 1) }");
8139        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8140    }
8141
8142    #[test]
8143    fn test_a_walk_inside_a_path_selects_shape_finds_its_row() {
8144        // The shape is compiled against the type the walk landed on, so a
8145        // relative path inside it has that row to resolve against.
8146        let out = compile_and_emit("SELECT Person.posts { mine := (SELECT Post FILTER .title = .title LIMIT 1) }");
8147        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
8148    }
8149
8150    #[test]
8151    fn test_a_walk_off_a_binding_in_a_free_select() {
8152        // A free select has no row of its own, so the walk is read back as one
8153        // subquery; resolved as a type name the binding is simply unknown.
8154        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := p.company.name }");
8155        assert!(out.sql.contains("\"p\""), "{}", out.sql);
8156        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
8157    }
8158
8159    #[test]
8160    fn test_a_free_selects_filter_can_root_at_a_binding() {
8161        let out = compile_and_emit("WITH p := (SELECT Person) SELECT { n := 1 } FILTER p.company.name = 'Acme'");
8162        assert!(out.sql.contains("'Acme'"), "{}", out.sql);
8163    }
8164
8165    #[test]
8166    fn test_a_trailing_shape_on_a_field_access_select() {
8167        let out = compile_and_emit("SELECT (SELECT Person).company { name }");
8168        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8169        assert!(out.sql.contains("\"public\".\"Company\""), "{}", out.sql);
8170    }
8171
8172    #[test]
8173    fn test_a_union_of_walks_names_each_branch() {
8174        // A walk names no relation, and a union is emitted one relation per
8175        // branch — so each is hoisted into a CTE of its own first.
8176        let out = compile_and_emit(
8177            "WITH p := (SELECT Person FILTER .name = $a), q := (SELECT Person FILTER .name = $b), \
8178             t := (SELECT p.posts UNION q.posts) SELECT t { title }",
8179        );
8180        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
8181        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8182    }
8183
8184    #[test]
8185    fn test_sibling_for_loops_each_get_their_own_iterator() {
8186        // One WITH clause cannot hold `_for_p` twice, and two loops written
8187        // over the same variable name are ordinary.
8188        let out = compile_and_emit(
8189            "WITH a := (SELECT Post), b := (SELECT Post), \
8190             x := (FOR p IN a UNION (INSERT Person { name := p.title, age := 1 })), \
8191             y := (FOR p IN b UNION (INSERT Person { name := p.title, age := 2 })) \
8192             SELECT x",
8193        );
8194        assert!(out.sql.contains("\"_for_p\" AS"), "{}", out.sql);
8195        assert!(out.sql.contains("\"_for_p__1\" AS"), "{}", out.sql);
8196    }
8197
8198    #[test]
8199    fn test_a_nested_for_carries_the_outer_loops_key() {
8200        // The inner iterator has the outer one in its FROM, and the insert
8201        // joins the two back on the key it carries.
8202        let out = compile_and_emit(
8203            "WITH made := (FOR p IN (SELECT Person) UNION ( \
8204               FOR q IN p.posts UNION (INSERT Company { name := q.title }) \
8205             )) SELECT count(made)",
8206        );
8207        assert!(
8208            out.sql.contains("\"_outer\""),
8209            "the inner iterator carries the bond:\n{}",
8210            out.sql
8211        );
8212        assert!(
8213            out.sql.contains("JOIN \"_for_p\" ON"),
8214            "the insert reads both loop variables:\n{}",
8215            out.sql
8216        );
8217        assert!(
8218            !out.sql.contains("LATERAL (\nINSERT"),
8219            "DML cannot sit in a LATERAL:\n{}",
8220            out.sql
8221        );
8222    }
8223
8224    #[test]
8225    fn test_a_link_value_can_be_a_walk_off_a_binding() {
8226        // `(select placed.line_items limit 1)` — the walk names no type of its
8227        // own, so reading one off it used to report the path as no type at all.
8228        let mut schema = make_schema();
8229        let company = schema
8230            .types
8231            .iter_mut()
8232            .find(|t| t.name == "Company")
8233            .expect("make_schema declares Company");
8234        company.properties.push(PropertyDescriptor {
8235            name: "id".into(),
8236            pg_type: "uuid".into(),
8237            nullable: false,
8238            default_sql: None,
8239            default_pyql: None,
8240            description: None,
8241            check_constraints: vec![],
8242            is_exclusive: true,
8243            is_pk: true,
8244            is_readonly: true,
8245            rewrites: vec![],
8246            tuple_members: None,
8247            column_type: None,
8248        });
8249        let out = compile_and_emit_with(
8250            "WITH others := (SELECT Person) \
8251             SELECT (INSERT Person { name := $n, age := 1, company := (SELECT others.company LIMIT 1) })",
8252            &schema,
8253        );
8254        assert!(out.sql.contains("\"company_id\""), "{}", out.sql);
8255    }
8256
8257    #[test]
8258    fn test_a_shape_over_a_coalesce_of_walks() {
8259        // Each operand is a walk off the enclosing row, and every one after the
8260        // first only stands in when the ones before it are empty.
8261        let schema = make_schema_with_computed_links();
8262        let out = compile_and_emit_with("SELECT Person { x := (.published ?? .plain) { title } }", &schema);
8263        assert!(out.sql.contains("UNION ALL"), "{}", out.sql);
8264        assert!(
8265            out.sql.contains("NOT EXISTS"),
8266            "the later branch only stands in when the first is empty:\n{}",
8267            out.sql
8268        );
8269    }
8270
8271    #[test]
8272    fn test_an_aggregate_over_a_walk_onto_objects_counts_its_rows() {
8273        // Falling through instead put the aggregate back through the very
8274        // expression route that sent it here, and the recursion took the
8275        // process down with it.
8276        let out = compile_and_emit("WITH c := (SELECT Company) SELECT c { n := count(.<company[is default::Person]) }");
8277        assert!(out.sql.contains("count("), "{}", out.sql);
8278        assert!(out.sql.contains("\"public\".\"Person\""), "{}", out.sql);
8279    }
8280
8281    #[test]
8282    fn an_aggregate_over_a_computed_set_counts_inside_a_subquery() {
8283        // `count(.plain)` is one step, so the junction-only count a stored
8284        // multi-link gets cannot apply — it has no junction to read. Taking
8285        // the walk as a value instead made the whole select an aggregate
8286        // query, which Postgres rejects ("column must appear in the GROUP BY
8287        // clause").
8288        let schema = make_schema_with_computed_links();
8289        let out = compile_and_emit_with("SELECT Person { n := count(.plain) }", &schema);
8290        assert!(
8291            !out.sql.contains("count((SELECT"),
8292            "the aggregate belongs inside the walk's subquery, not around it:\n{}",
8293            out.sql
8294        );
8295        assert!(
8296            out.sql.contains("(SELECT count(") && out.sql.contains("\"public\".\"Post\""),
8297            "it counts the rows the computed's own path lands on:\n{}",
8298            out.sql
8299        );
8300    }
8301
8302    /// The specialized count for a stored multi-link reads its junction table
8303    /// alone — the computed case above must not have pulled it off that route.
8304    #[test]
8305    fn an_aggregate_over_a_stored_multi_link_still_counts_its_junction_rows() {
8306        let schema = make_schema_with_computed_links();
8307        let out = compile_and_emit_with("SELECT Person { n := count(.posts) }", &schema);
8308        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
8309        assert!(
8310            !out.sql.contains("JOIN \"public\".\"Post\""),
8311            "counting junction rows needs no join onto the targets:\n{}",
8312            out.sql
8313        );
8314    }
8315
8316    #[test]
8317    fn test_a_select_can_name_its_own_result() {
8318        // A select may name its own result: the alias names it and the
8319        // select's own clauses may read it by that name.
8320        let out = compile_and_emit("SELECT oldest := max(Person.age)");
8321        assert!(out.sql.contains("max("), "{}", out.sql);
8322        let filtered = compile_and_emit("SELECT n := Person.age FILTER n > 18");
8323        assert!(
8324            filtered.sql.contains("18"),
8325            "the alias resolves to the result:\n{}",
8326            filtered.sql
8327        );
8328    }
8329
8330    #[test]
8331    fn test_exists_sees_a_computed_pointer() {
8332        // The suggester could see the name the resolver had just reported as
8333        // unknown — `has no link or property 'published'. Did you mean
8334        // 'published'?`
8335        let schema = make_schema_with_computed_links();
8336        let out = compile_and_emit_with("SELECT Person FILTER EXISTS .published", &schema);
8337        assert!(out.sql.contains("EXISTS"), "{}", out.sql);
8338        assert!(
8339            out.sql.contains("\"public\".\"Post\""),
8340            "the computed's own path:\n{}",
8341            out.sql
8342        );
8343    }
8344
8345    #[test]
8346    fn test_walking_through_a_computed_field_access() {
8347        use crate::schema::ComputedDescriptor;
8348        // `parent := (select .parents … limit 1).parent` — the modifiers belong
8349        // to the step before the field access, not to what it lands on.
8350        let mut schema = make_schema_with_computed_links();
8351        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
8352        person.computed.push(ComputedDescriptor {
8353            name: "latest_title".into(),
8354            expression: "(select .posts order by .title desc limit 1).title".into(),
8355            return_type: None,
8356            link_target: None,
8357            link_multi: false,
8358        });
8359        let out = compile_and_emit_with("SELECT Person { x := .latest_title }", &schema);
8360        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8361        assert!(
8362            out.sql.contains("LIMIT"),
8363            "the computed's own limit survives:\n{}",
8364            out.sql
8365        );
8366    }
8367
8368    #[test]
8369    fn test_a_computed_field_access_onto_an_object_takes_a_shape() {
8370        use crate::schema::ComputedDescriptor;
8371        // Compiled as an expression, the pointer came back as the bare id of
8372        // the object it lands on, whatever shape was written on it.
8373        let mut schema = make_schema_with_computed_links();
8374        let post = schema.types.iter_mut().find(|t| t.name == "Post").expect("Post");
8375        post.links.push(LinkDescriptor {
8376            name: "author".into(),
8377            target: "default::Person".into(),
8378            nullable: true,
8379            through: None,
8380            description: None,
8381            default_pyql: None,
8382            is_exclusive: false,
8383            is_readonly: false,
8384            rewrites: vec![],
8385            on_delete: vec![],
8386        });
8387        let person = schema.types.iter_mut().find(|t| t.name == "Person").expect("Person");
8388        person.computed.retain(|c| c.name != "looper");
8389        person.computed.push(ComputedDescriptor {
8390            name: "latest_author".into(),
8391            expression: "((select .posts order by .title desc limit 1)).author".into(),
8392            return_type: None,
8393            link_target: None,
8394            link_multi: false,
8395        });
8396        let out = compile_and_emit_with("SELECT Person { latest_author: { name } }", &schema);
8397        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8398        assert!(
8399            out.sql.contains("\"title\" DESC"),
8400            "the order stays on the posts:\n{}",
8401            out.sql
8402        );
8403        assert!(
8404            out.sql.contains("LIMIT"),
8405            "the computed's own limit survives:\n{}",
8406            out.sql
8407        );
8408        let splat = compile_and_emit_with("SELECT Person { * }", &schema);
8409        assert!(
8410            !splat.sql.contains("latest_author"),
8411            "`*` leaves links out:\n{}",
8412            splat.sql
8413        );
8414    }
8415
8416    #[test]
8417    fn test_a_narrowed_pointer_can_carry_a_shape() {
8418        // `[is T].posts: { title }` — the parser stopped at the `:`, and the
8419        // route behind it resolved a stored property and nothing else, so a
8420        // link reported itself as the name it had just been asked for.
8421        let out = compile_and_emit("SELECT Company { x := 1, [is default::Person].posts: { title } }");
8422        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8423        assert!(out.sql.contains("\"public\".\"Post\""), "{}", out.sql);
8424    }
8425
8426    #[test]
8427    fn test_select_type_name_as_a_path_step() {
8428        let out = compile_and_emit("SELECT Person.__type__");
8429        assert!(out.sql.contains("ROW('default::Person')"), "{}", out.sql);
8430        assert!(out.sql.contains("\"Person\" AS \"t0\""), "{}", out.sql);
8431    }
8432
8433    #[test]
8434    fn test_aggregate_over_a_with_bound_object_set() {
8435        let out = compile_and_emit("WITH people := (SELECT Person) SELECT max(people.age)");
8436        assert!(out.sql.contains("\"people\" AS ("));
8437        assert!(out.sql.contains("max("));
8438        // Reads the binding, not the base table again.
8439        assert!(!out.sql.contains("FROM \"default\".\"Person\" AS \"t1\""));
8440    }
8441
8442    #[test]
8443    fn test_select_union_of_two_object_bindings() {
8444        let out = compile_and_emit("WITH a := (SELECT Person LIMIT 1), b := (SELECT Person) SELECT (a UNION b)");
8445        assert!(out.sql.contains("SELECT * FROM \"a\""));
8446        assert!(out.sql.contains("UNION ALL"));
8447        assert!(out.sql.contains("SELECT * FROM \"b\""));
8448    }
8449
8450    #[test]
8451    fn test_select_union_of_different_object_types_is_rejected() {
8452        let schema = make_schema();
8453        let ast = parse::parse("WITH a := (SELECT Person), b := (SELECT Company) SELECT (a UNION b)").unwrap();
8454        let Err(err) = ir::compile(&ast, &schema) else {
8455            panic!("union of unrelated types must be rejected")
8456        };
8457        assert!(format!("{err}").contains("UNION"));
8458    }
8459
8460    #[test]
8461    fn test_for_union_as_a_function_body() {
8462        let schema = make_schema();
8463        let descriptor = FunctionDescriptor {
8464            name: "recent".into(),
8465            module: "default".into(),
8466            params: vec![],
8467            return_pg_type: "default::Person".into(),
8468            return_is_object: true,
8469            return_is_set: true,
8470            return_is_polymorphic: false,
8471            volatility: "stable".into(),
8472            body: "for n in {1, 2} union (select Person filter .age = n)".into(),
8473        };
8474        let ir = ir::compile_fn_body(&descriptor, &schema).expect("function body must compile");
8475        let body = emit_fn_body(&ir);
8476        assert!(body.contains("VALUES"), "{body}");
8477        assert!(body.contains("CROSS JOIN LATERAL"), "{body}");
8478        assert!(body.contains("\"_body\".*"), "{body}");
8479    }
8480
8481    #[test]
8482    fn test_with_binding_in_a_computed_reads_the_enclosing_object() {
8483        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
8484        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8485    }
8486
8487    #[test]
8488    fn test_subject_path_update_names_its_own_alias() {
8489        let mut schema = make_schema();
8490        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
8491        company.properties.insert(
8492            0,
8493            PropertyDescriptor {
8494                name: "id".into(),
8495                pg_type: "uuid".into(),
8496                nullable: false,
8497                default_sql: Some("uuidv7()".into()),
8498                default_pyql: None,
8499                description: None,
8500                check_constraints: vec![],
8501                is_exclusive: true,
8502                is_pk: true,
8503                is_readonly: true,
8504                rewrites: vec![],
8505                tuple_members: None,
8506                column_type: None,
8507            },
8508        );
8509        // The row set narrowing the update compares against *that update's*
8510        // alias; a bare `id` would be ambiguous once a nested statement's CTE
8511        // joins the FROM.
8512        let out = compile_and_emit_with("SELECT (UPDATE Person.company SET { name := 'x' }) { name }", &schema);
8513        assert!(out.sql.contains("\"t1\".\"id\" = ANY("), "{}", out.sql);
8514    }
8515
8516    #[test]
8517    fn test_an_update_past_a_from_returns_only_its_own_columns() {
8518        // `UPDATE … FROM cte … RETURNING *` returns the joined relation's
8519        // columns as well, so a CTE built that way carries two `id`s and any
8520        // later reference to one is ambiguous.
8521        let out = compile_and_emit(
8522            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
8523        );
8524        assert!(out.sql.contains("FROM \"_nested_dml_0\""), "{}", out.sql);
8525        assert!(
8526            out.sql.contains("RETURNING \"t0\".*"),
8527            "the update names its own target:\n{}",
8528            out.sql
8529        );
8530    }
8531
8532    #[test]
8533    fn test_nested_insert_in_a_mutation_written_as_a_free_set() {
8534        let out = compile_and_emit(
8535            "SELECT { (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) }",
8536        );
8537        assert!(
8538            out.sql.contains("INSERT INTO \"public\".\"Company\""),
8539            "the hoisted insert is defined, not just referenced:\n{}",
8540            out.sql
8541        );
8542    }
8543
8544    #[test]
8545    fn test_nested_insert_as_a_link_value_in_an_update() {
8546        // The hoisted insert sits beside the update rather than inside it, so
8547        // the update needs a FROM to read its id, and its own `id` has to name
8548        // the update's alias — a bare one could mean either relation.
8549        let out = compile_and_emit(
8550            "SELECT (UPDATE Person FILTER .name = 'a' SET { company := (INSERT Company { name := 'c' }) }) { name }",
8551        );
8552        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8553        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
8554        assert!(
8555            out.sql.contains("FROM \"_nested_dml_0\""),
8556            "the update reads the hoisted CTE:\n{}",
8557            out.sql
8558        );
8559    }
8560
8561    #[test]
8562    fn test_nested_insert_as_a_link_value() {
8563        let out = compile_and_emit(
8564            "SELECT (INSERT Person { name := 'a', company := (INSERT Company { name := 'c' }) }) { name }",
8565        );
8566        assert!(out.sql.contains("_nested_dml_0"), "{}", out.sql);
8567        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8568    }
8569
8570    #[test]
8571    fn test_nested_insert_in_a_one_element_set_is_that_insert() {
8572        let out = compile_and_emit(
8573            "SELECT (INSERT Person { name := 'a', company := { (INSERT Company { name := 'c' }) } }) { name }",
8574        );
8575        assert!(out.sql.contains("INSERT INTO \"public\".\"Company\""), "{}", out.sql);
8576    }
8577
8578    #[test]
8579    fn test_insert_with_no_assignments_uses_default_values() {
8580        let out = compile_and_emit("SELECT (INSERT Person { name := 'a', company := (INSERT Company {}) }) { name }");
8581        assert!(out.sql.contains("DEFAULT VALUES"), "{}", out.sql);
8582    }
8583
8584    #[test]
8585    fn test_backlink_narrowed_to_an_interface_its_implementor_declares() {
8586        // `notes` is declared on Individual; `[is Account]` is satisfied by an
8587        // Individual row, so the backlink resolves through the implementor.
8588        use crate::schema::{LinkDescriptor, TypeDescriptor};
8589        let mut schema = make_interface_schema();
8590        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
8591        schema.types.push(TypeDescriptor {
8592            name: "Note".into(),
8593            module: "default".into(),
8594            table: "Note".into(),
8595            abstract_: false,
8596            materialized: true,
8597            description: None,
8598            parents: vec![],
8599            interfaces: vec![],
8600            bases: vec![],
8601            properties: individual.properties[..1].to_vec(),
8602            links: vec![],
8603            multilinks: vec![],
8604            computed: vec![],
8605            constraints: vec![],
8606            indexes: vec![],
8607            partition: None,
8608            vector_indexes: vec![],
8609            search_indexes: vec![],
8610            triggers: vec![],
8611            junction: false,
8612            signals: vec![],
8613        });
8614        schema
8615            .types
8616            .iter_mut()
8617            .find(|t| t.name == "Individual")
8618            .unwrap()
8619            .links
8620            .push(LinkDescriptor {
8621                name: "note".into(),
8622                target: "default::Note".into(),
8623                nullable: true,
8624                description: None,
8625                default_pyql: None,
8626                is_exclusive: false,
8627                is_readonly: false,
8628                rewrites: vec![],
8629                on_delete: vec![],
8630                through: None,
8631            });
8632        let out = compile_and_emit_with("SELECT Note FILTER EXISTS .<note[is Account]", &schema);
8633        assert!(out.sql.contains("\"Individual\""), "{}", out.sql);
8634    }
8635
8636    #[test]
8637    fn test_subject_reads_the_row_a_constraint_checks() {
8638        let schema = make_schema();
8639        let sql = ir::compile_constraint_expr("__subject__.age > 18", "default::Person", &schema)
8640            .expect("__subject__ names the row under check");
8641        assert!(sql.contains("\"age\""), "{sql}");
8642    }
8643
8644    #[test]
8645    fn test_free_select_filter_gates_the_result_and_warns() {
8646        let schema = make_schema();
8647        let ast = parse::parse("SELECT count(Person) FILTER (Person.age > 18)").unwrap();
8648        let ir = ir::compile(&ast, &schema).unwrap();
8649        let out = emit(&ir);
8650        assert!(
8651            out.sql.contains("EXISTS("),
8652            "the condition gates the count:\n{}",
8653            out.sql
8654        );
8655        assert!(
8656            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
8657            "a set-valued filter warns: {:?}",
8658            ir.warnings
8659        );
8660    }
8661
8662    #[test]
8663    fn test_free_sub_select_with_modifiers_reads_inline() {
8664        // The inner statement reads the enclosing row, so it cannot become a
8665        // CTE ahead of the FROM clause that defines it.
8666        let out = compile_and_emit(
8667            "SELECT Person { n := (WITH mine := .name SELECT count(Post) FILTER (Post.title = mine)) }",
8668        );
8669        assert!(out.sql.contains("_scalar_sub"), "{}", out.sql);
8670        assert!(!out.sql.contains("WITH"), "nothing is hoisted:\n{}", out.sql);
8671    }
8672
8673    #[test]
8674    fn test_correlated_with_binding_is_not_hoisted() {
8675        let out = compile_and_emit("SELECT Person { n := (WITH own := .name SELECT own) }");
8676        assert!(
8677            !out.sql.contains("\"own\" AS ("),
8678            "the binding is inlined:\n{}",
8679            out.sql
8680        );
8681        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8682    }
8683
8684    #[test]
8685    fn test_array_literal_binding_is_readable_and_typed() {
8686        let out = compile_and_emit("WITH order := ['a', 'b'] SELECT std::find(order, 'b')");
8687        assert!(out.sql.contains("array_position"), "its type is known:\n{}", out.sql);
8688        assert!(
8689            out.sql.contains("AS v"),
8690            "the binding exposes a value column:\n{}",
8691            out.sql
8692        );
8693    }
8694
8695    #[test]
8696    fn test_any_over_a_single_boolean_is_that_boolean() {
8697        let out = compile_and_emit("SELECT Person FILTER std::any(.age > 18)");
8698        assert!(!out.sql.contains("bool_or"), "no aggregate in WHERE:\n{}", out.sql);
8699        assert!(out.sql.contains("> 18"), "{}", out.sql);
8700    }
8701
8702    #[test]
8703    fn test_comparing_a_value_to_a_set_is_membership() {
8704        let schema = make_schema_with_computed_links();
8705        let out = compile_and_emit_with("SELECT Person FILTER .name = Person.posts.title", &schema);
8706        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8707    }
8708
8709    #[test]
8710    fn test_for_over_a_with_binding_iterates_every_row() {
8711        let out = compile_and_emit(
8712            "WITH names := (SELECT Person.name) FOR n IN names UNION (SELECT Person FILTER .name = n)",
8713        );
8714        assert!(out.sql.contains("FROM \"names\""), "{}", out.sql);
8715        assert!(!out.sql.contains("VALUES"), "the whole set is iterated:\n{}", out.sql);
8716    }
8717
8718    #[test]
8719    fn test_for_body_bindings_stay_inside_the_body() {
8720        // `matching` reads the loop variable, so it cannot be hoisted to the
8721        // statement's own WITH — nothing there can see `_for_n`.
8722        let out = compile_and_emit(
8723            "WITH names := (SELECT Person.name) \
8724             FOR n IN names UNION (WITH matching := (SELECT Person FILTER .name = n) SELECT matching)",
8725        );
8726        let lateral = out.sql.find("CROSS JOIN LATERAL").expect("a lateral body");
8727        let binding = out.sql.find("\"matching\" AS (").expect("the body's binding");
8728        assert!(binding > lateral, "the binding sits inside the body:\n{}", out.sql);
8729    }
8730
8731    #[test]
8732    fn test_array_valued_binding_picks_the_array_overload() {
8733        // The binding carries `text[]`, so the call resolves to the array
8734        // overload even though the value itself is no longer an array literal.
8735        let out = compile_and_emit("WITH l := <array<std::str>>$0 SELECT std::find(l, 'a')");
8736        assert!(out.sql.contains("array_position"), "{}", out.sql);
8737        assert!(!out.sql.contains("strpos"), "{}", out.sql);
8738    }
8739
8740    #[test]
8741    fn test_aggregate_over_a_path_in_a_filter_is_a_subquery() {
8742        let out = compile_and_emit("SELECT Person FILTER .age = std::max(Person.age)");
8743        assert!(
8744            out.sql.contains("(SELECT max("),
8745            "an aggregate in WHERE needs its own query:\n{}",
8746            out.sql
8747        );
8748    }
8749
8750    #[test]
8751    fn test_distinct_over_a_single_value_is_that_value() {
8752        let out = compile_and_emit("SELECT Person { n := (DISTINCT .name) }");
8753        assert!(out.sql.contains("\"name\""), "{}", out.sql);
8754        assert!(!out.sql.contains("DISTINCT \""), "{}", out.sql);
8755    }
8756
8757    #[test]
8758    fn test_narrowing_an_interface_joins_the_implementor_table() {
8759        // `first_name` lives on Individual's own table; the Account view has
8760        // only the columns every implementor shares.
8761        let mut schema = make_interface_schema();
8762        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
8763        account.materialized = true;
8764        let out = compile_and_emit_with("SELECT Account[is Individual].first_name", &schema);
8765        assert!(
8766            out.sql.contains("JOIN \"public\".\"Individual\""),
8767            "the implementor's own table is joined:\n{}",
8768            out.sql
8769        );
8770        assert!(out.sql.contains("\"first_name\""), "{}", out.sql);
8771    }
8772
8773    #[test]
8774    fn test_sub_select_shape_declares_names_its_filter_and_projection_read() {
8775        let out = compile_and_emit("SELECT Person { n := (SELECT .posts { t := .title } FILTER .t = 'x' LIMIT 1).t }");
8776        assert!(out.sql.contains("'x'"), "{}", out.sql);
8777        assert!(out.sql.contains("\"title\""), "{}", out.sql);
8778    }
8779
8780    #[test]
8781    fn test_assert_over_an_object_set_returns_rows() {
8782        let out = compile_and_emit("SELECT std::assert_distinct((SELECT Person))");
8783        assert!(
8784            out.sql.contains("\"assert_distinct\"(ARRAY(SELECT \"id\""),
8785            "{}",
8786            out.sql
8787        );
8788        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8789        assert!(out.sql.contains("\"Person\" AS \"t1\""), "{}", out.sql);
8790        assert!(!out.sql.contains("unnest("), "{}", out.sql);
8791    }
8792
8793    #[test]
8794    fn test_assert_over_a_for_union_reads_its_rows() {
8795        let out =
8796            compile_and_emit("SELECT std::assert_distinct((FOR n IN {1, 2} UNION (SELECT Person FILTER .age = n)))");
8797        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
8798        assert!(out.sql.contains("= ANY("), "{}", out.sql);
8799    }
8800
8801    #[test]
8802    fn test_declared_computed_with_binding_reads_its_own_object() {
8803        // Validation compiles a declared computed on its own, with no
8804        // enclosing SELECT — `.name` in the binding still means this object's.
8805        use crate::schema::ComputedDescriptor;
8806        let schema = make_schema();
8807        let cd = ComputedDescriptor {
8808            name: "shouted".into(),
8809            expression: "(with own := .name select std::str_upper(own))".into(),
8810            return_type: None,
8811            link_target: None,
8812            link_multi: false,
8813        };
8814        let ir = ir::compile_computed_in_type(&cd, "default::Person", &schema)
8815            .expect("a computed may read the object it is declared on");
8816        assert!(ir.is_some());
8817    }
8818
8819    #[test]
8820    fn test_sub_select_modifiers_scope_to_its_own_subject() {
8821        // `.title` belongs to Post, the sub-select's subject — not to Person,
8822        // where the projection `.author.name` lands.
8823        let mut schema = make_schema();
8824        let post = schema
8825            .types
8826            .iter_mut()
8827            .find(|t| t.name == "Post")
8828            .expect("Post is in the test schema");
8829        post.links.push(LinkDescriptor {
8830            name: "author".into(),
8831            target: "default::Person".into(),
8832            nullable: true,
8833            description: None,
8834            default_pyql: None,
8835            is_exclusive: false,
8836            is_readonly: false,
8837            rewrites: vec![],
8838            on_delete: vec![],
8839            through: None,
8840        });
8841        let out = compile_and_emit_with(
8842            "SELECT Person { a := (SELECT .posts FILTER .title = 'x' LIMIT 1).author.name }",
8843            &schema,
8844        );
8845        assert!(out.sql.contains("'x'"), "{}", out.sql);
8846    }
8847
8848    #[test]
8849    fn test_free_select_set_literal() {
8850        let schema = make_schema();
8851        let ast = parse::parse("SELECT {1, 2, 3}").unwrap();
8852        let ir = ir::compile(&ast, &schema).unwrap();
8853        let out = emit(&ir);
8854        // Three UNION ALL branches
8855        assert_eq!(out.sql.matches("UNION ALL").count(), 2);
8856        assert!(out.sql.contains("1 AS v"));
8857        assert!(out.sql.contains("2 AS v"));
8858        assert!(out.sql.contains("3 AS v"));
8859        assert!(out.sql.contains("ROW(v) AS result"));
8860        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
8861    }
8862
8863    #[test]
8864    fn test_free_select_free_object() {
8865        let schema = make_schema();
8866        let ast = parse::parse("SELECT { foo := 'bar', n := 42 }").unwrap();
8867        let ir = ir::compile(&ast, &schema).unwrap();
8868        let out = emit(&ir);
8869        assert!(out.sql.contains("'bar'"));
8870        assert!(out.sql.contains("42"));
8871        assert!(out.sql.contains("AS result"));
8872        // Shape should describe an object with pointers foo and n
8873        let crate::query::ShapeNode::Object {
8874            pointers, type_name, ..
8875        } = &out.shape.root
8876        else {
8877            panic!("expected Object shape")
8878        };
8879        assert!(type_name.is_none());
8880        assert_eq!(pointers.len(), 2);
8881        assert!(matches!(&pointers[0], crate::query::ShapeNode::Scalar { name, position: 0 } if name == "foo"));
8882        assert!(matches!(&pointers[1], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "n"));
8883    }
8884
8885    #[test]
8886    fn test_free_select_object_with_enum_field_casts_to_text_and_tags_shape() {
8887        // Regression: `select { gender := default::Gender.Male }` failed at
8888        // runtime with "no decoder for composite type element ... " —
8889        // A custom enum's OID is database-assigned, so inside an anonymous ROW()
8890        // composite. free_item_shape/emit_free_select always treated every
8891        // free-object field as a plain untyped Scalar, never casting an
8892        // enum-valued field to ::text (unlike a schema object's emit_scalar,
8893        // which already does this for a real enum-typed property column).
8894        let mut schema = make_schema();
8895        schema.enums.push(crate::schema::EnumDescriptor {
8896            name: "Gender".into(),
8897            module: "default".into(),
8898            members: vec!["Male".into(), "Female".into()],
8899        });
8900        let out = compile_and_emit_with("select { gender := default::Gender.Male }", &schema);
8901        // The enum value is computed once (`'Male'::"public"."Gender" AS
8902        // "_f0"`) and reused for both the `result` composite (cast to
8903        // `::text` there, since an enum's OID is database-assigned inside an
8904        // anonymous ROW()) and the plain per-field column exposed for
8905        // `IrExpr::CteFieldRef` access — so the `::text` cast now applies
8906        // to that computed column, not inline on the enum literal itself.
8907        assert!(
8908            out.sql.contains("'Male'::\"public\".\"Gender\""),
8909            "expected the enum literal, got:\n{}",
8910            out.sql
8911        );
8912        assert!(
8913            out.sql.contains("ROW(\"_f0\"::text) AS result"),
8914            "expected the ROW composite to cast the enum field to text, got:\n{}",
8915            out.sql
8916        );
8917        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
8918            panic!("expected Object shape")
8919        };
8920        assert_eq!(pointers.len(), 1);
8921        assert!(
8922            matches!(
8923                &pointers[0],
8924                crate::query::ShapeNode::Enum { name, position: 0, enum_type }
8925                    if name == "gender" && enum_type == "public::Gender"
8926            ),
8927            "expected Enum-tagged shape, got: {:?}",
8928            pointers[0]
8929        );
8930    }
8931
8932    #[test]
8933    fn test_free_select_bare_enum_literal_casts_to_text_inside_row() {
8934        // Same bug, bare scalar form: `select default::Gender.Male;` (no
8935        // shape/object wrapper) also wraps the value in ROW() for top-level
8936        // top-level decoding.
8937        let mut schema = make_schema();
8938        schema.enums.push(crate::schema::EnumDescriptor {
8939            name: "Gender".into(),
8940            module: "default".into(),
8941            members: vec!["Male".into(), "Female".into()],
8942        });
8943        let out = compile_and_emit_with("select default::Gender.Male", &schema);
8944        assert!(
8945            out.sql.contains("ROW(v::text) AS result"),
8946            "expected ROW(v::text), got:\n{}",
8947            out.sql
8948        );
8949        assert!(
8950            matches!(
8951                &out.shape.root,
8952                crate::query::ShapeNode::Enum { enum_type, .. } if enum_type == "public::Gender"
8953            ),
8954            "expected Enum-tagged shape, got: {:?}",
8955            out.shape.root
8956        );
8957    }
8958
8959    // ── FOR UPDATE / FOR SHARE row-locking clause ──────────────────────────────
8960
8961    #[test]
8962    fn test_for_update_defaults_to_blocking() {
8963        let out = compile_and_emit("SELECT Person FOR UPDATE");
8964        assert!(out.sql.trim_end().ends_with("FOR UPDATE"), "got:\n{}", out.sql);
8965    }
8966
8967    #[test]
8968    fn test_for_update_skip_locked() {
8969        let out = compile_and_emit("SELECT Person FOR UPDATE SKIP LOCKED");
8970        assert!(
8971            out.sql.trim_end().ends_with("FOR UPDATE SKIP LOCKED"),
8972            "got:\n{}",
8973            out.sql
8974        );
8975    }
8976
8977    #[test]
8978    fn test_for_update_nowait() {
8979        let out = compile_and_emit("SELECT Person FOR UPDATE NOWAIT");
8980        assert!(out.sql.trim_end().ends_with("FOR UPDATE NOWAIT"), "got:\n{}", out.sql);
8981    }
8982
8983    #[test]
8984    fn test_for_no_key_update_skip_locked() {
8985        let out = compile_and_emit("SELECT Person FOR NO KEY UPDATE SKIP LOCKED");
8986        assert!(
8987            out.sql.trim_end().ends_with("FOR NO KEY UPDATE SKIP LOCKED"),
8988            "got:\n{}",
8989            out.sql
8990        );
8991    }
8992
8993    #[test]
8994    fn test_for_share() {
8995        let out = compile_and_emit("SELECT Person FOR SHARE");
8996        assert!(out.sql.trim_end().ends_with("FOR SHARE"), "got:\n{}", out.sql);
8997    }
8998
8999    #[test]
9000    fn test_for_key_share_nowait() {
9001        let out = compile_and_emit("SELECT Person FOR KEY SHARE NOWAIT");
9002        assert!(
9003            out.sql.trim_end().ends_with("FOR KEY SHARE NOWAIT"),
9004            "got:\n{}",
9005            out.sql
9006        );
9007    }
9008
9009    #[test]
9010    fn test_for_update_comes_after_order_by_limit_offset_in_emitted_sql() {
9011        // Postgres's own grammar places the locking clause last — confirm
9012        // the emitter matches, not just that all the pieces are present.
9013        let out = compile_and_emit("SELECT Person { name } ORDER BY .name OFFSET 1 LIMIT 5 FOR UPDATE SKIP LOCKED");
9014        let order_pos = out.sql.find("ORDER BY").unwrap();
9015        let offset_pos = out.sql.find("OFFSET").unwrap();
9016        let limit_pos = out.sql.find("LIMIT").unwrap();
9017        let for_pos = out.sql.find("FOR UPDATE").unwrap();
9018        assert!(
9019            order_pos < offset_pos && offset_pos < limit_pos && limit_pos < for_pos,
9020            "got:\n{}",
9021            out.sql
9022        );
9023    }
9024
9025    #[test]
9026    fn test_for_update_combined_with_distinct_is_rejected() {
9027        let ast = parse::parse("SELECT DISTINCT Person FOR UPDATE").expect("parse failed");
9028        let err = ir::compile(&ast, &make_schema())
9029            .err()
9030            .expect("expected a compile error");
9031        assert!(err.to_string().contains("DISTINCT"), "unexpected: {err}");
9032    }
9033
9034    #[test]
9035    fn test_for_update_combined_with_select_over_insert_is_rejected() {
9036        let ast = parse::parse("SELECT (INSERT Person { name := 'Alice' }) { name } FOR UPDATE").expect("parse failed");
9037        let err = ir::compile(&ast, &make_schema())
9038            .err()
9039            .expect("expected a compile error");
9040        assert!(err.to_string().contains("INSERT"), "unexpected: {err}");
9041    }
9042
9043    #[test]
9044    fn test_for_update_on_an_interface_type_is_rejected() {
9045        fn id_prop() -> PropertyDescriptor {
9046            PropertyDescriptor {
9047                name: "id".into(),
9048                pg_type: "uuid".into(),
9049                nullable: false,
9050                default_sql: Some("uuidv7()".into()),
9051                default_pyql: None,
9052                description: None,
9053                check_constraints: vec![],
9054                is_exclusive: true,
9055                is_pk: true,
9056                is_readonly: true,
9057                rewrites: vec![],
9058                tuple_members: None,
9059                column_type: None,
9060            }
9061        }
9062        let schema = SchemaDescriptor {
9063            types: vec![
9064                TypeDescriptor {
9065                    name: "Account".into(),
9066                    module: "default".into(),
9067                    table: "Account".into(),
9068                    abstract_: true,
9069                    materialized: true,
9070                    description: None,
9071                    parents: vec![],
9072                    interfaces: vec![],
9073                    bases: vec![],
9074                    properties: vec![id_prop()],
9075                    links: vec![],
9076                    multilinks: vec![],
9077                    computed: vec![],
9078                    constraints: vec![],
9079                    indexes: vec![],
9080                    partition: None,
9081                    vector_indexes: vec![],
9082                    search_indexes: vec![],
9083                    triggers: vec![],
9084                    junction: false,
9085                    signals: vec![],
9086                },
9087                TypeDescriptor {
9088                    name: "Individual".into(),
9089                    module: "default".into(),
9090                    table: "Individual".into(),
9091                    abstract_: false,
9092                    materialized: true,
9093                    description: None,
9094                    parents: vec![],
9095                    interfaces: vec!["default::Account".into()],
9096                    bases: vec![],
9097                    properties: vec![id_prop()],
9098                    links: vec![],
9099                    multilinks: vec![],
9100                    computed: vec![],
9101                    constraints: vec![],
9102                    indexes: vec![],
9103                    partition: None,
9104                    vector_indexes: vec![],
9105                    search_indexes: vec![],
9106                    triggers: vec![],
9107                    junction: false,
9108                    signals: vec![],
9109                },
9110            ],
9111            scalars: vec![],
9112            enums: vec![],
9113            named_tuples: vec![],
9114            globals: vec![],
9115            functions: vec![],
9116            aliases: vec![],
9117            channels: vec![],
9118            ..Default::default()
9119        };
9120        let ast = parse::parse("SELECT Account FOR UPDATE").expect("parse failed");
9121        let err = ir::compile(&ast, &schema).err().expect("expected a compile error");
9122        assert!(err.to_string().contains("interface"), "unexpected: {err}");
9123    }
9124
9125    #[test]
9126    fn test_schema_select_distinct_emits_distinct_keyword() {
9127        // Regression: compile_stmt's Distinct/Detached unwrap and
9128        // compile_select's own re-derivation of the same unwrap were merged
9129        // into a single pass-through (Phase 2 of the compile_expr merge) —
9130        // confirm `select distinct` still compiles and emits DISTINCT.
9131        let out = compile_and_emit("SELECT DISTINCT Person { name }");
9132        assert!(out.sql.contains("DISTINCT"), "expected DISTINCT in SQL:\n{}", out.sql);
9133    }
9134
9135    #[test]
9136    fn test_schema_select_detached_compiles_as_ordinary_select() {
9137        // Same merge — `detached` at the top level of a schema select is a
9138        // no-op (already independent); confirm it still compiles cleanly
9139        // instead of erroring or double-unwrapping.
9140        let out = compile_and_emit("SELECT DETACHED Person { name }");
9141        assert!(
9142            out.sql.contains("\"name\""),
9143            "expected name column in SQL:\n{}",
9144            out.sql
9145        );
9146    }
9147
9148    #[test]
9149    fn test_detached_subquery_correlates_against_the_outer_row() {
9150        // The anti-join idiom: "the Person with no other Person of the same
9151        // name that is younger". `detached` is what makes the inner `Person` a
9152        // different row from the outer one, so the bare `Person.name` inside
9153        // has to resolve to the *outer* alias. It used to be stripped as a
9154        // no-op, which compiled to `t1.name = t1.name` — a tautology that
9155        // quietly matched every row instead of erroring.
9156        let out = compile_and_emit(
9157            "SELECT Person { name } FILTER NOT EXISTS (\
9158             SELECT DETACHED Person FILTER .name = Person.name AND .age > Person.age)",
9159        );
9160        assert!(
9161            out.sql.contains("\"t1\".\"name\" = \"t0\".\"name\""),
9162            "the inner row should be compared against the outer one:\n{}",
9163            out.sql
9164        );
9165        assert!(
9166            !out.sql.contains("\"t1\".\"name\" = \"t1\".\"name\""),
9167            "self-comparison means the correlation was lost:\n{}",
9168            out.sql
9169        );
9170    }
9171
9172    #[test]
9173    fn test_an_assert_on_a_pointer_checks_its_rows() {
9174        // The pointer's own subquery is bound once and read back through the
9175        // check, rather than appearing in both — and the cast is what makes
9176        // the call legal at all, since PL/pgSQL cannot take a `record[]`.
9177        let out = compile_and_emit("SELECT Person { name, p := assert_exists(.posts { title }) }");
9178        assert!(
9179            out.sql
9180                .contains(r#"cardinality("_pylon"."assert_exists"("_a"."v"::text[]))"#),
9181            "the assert should read the aggregated rows:\n{}",
9182            out.sql
9183        );
9184        assert_eq!(
9185            out.sql.matches("array_agg(ROW(").count(),
9186            1,
9187            "the pointer's subquery should be evaluated once, not once per use:\n{}",
9188            out.sql
9189        );
9190    }
9191
9192    #[test]
9193    fn test_an_assert_on_a_scalar_stays_an_ordinary_call() {
9194        // Only an object pointer takes the wrapper; `assert_exists(.name)` is
9195        // a value expression and must keep compiling as the function call it is.
9196        let out = compile_and_emit("SELECT Person { n := assert_exists(.name) }");
9197        assert!(
9198            !out.sql.contains(r#""_a"."v""#),
9199            "a scalar assert should not be wrapped as a pointer check:\n{}",
9200            out.sql
9201        );
9202    }
9203
9204    #[test]
9205    fn test_a_backlink_tail_walks_past_the_first_step() {
9206        // `.<link[is T].multilink.prop` — the tail after a backlink was read
9207        // one arity at a time (property, or link-then-property), so anything
9208        // crossing a multi-link had no single column to read and was refused.
9209        // Counted against the migrated data the answer is 4 and 4.
9210        let out = compile_and_emit("SELECT Post FILTER any(.<posts[is Person].posts.title = $t)");
9211        assert!(
9212            out.sql.contains(r#""public"."Person.posts""#),
9213            "the tail should walk the multi-link's junction:\n{}",
9214            out.sql
9215        );
9216    }
9217
9218    #[test]
9219    fn test_an_intersection_narrows_the_rest_of_a_multilink_walk() {
9220        // `.posts[is Post].title` — the narrowed type's own table is what the
9221        // remaining steps join to, so a row of any other type drops out
9222        // without a separate check. On real data the answer is 1 and 1.
9223        let out = compile_and_emit("SELECT Person FILTER any(.posts[is Post].title = $t)");
9224        assert!(
9225            out.sql.contains(r#""public"."Post""#),
9226            "the walk should continue from the narrowed type:\n{}",
9227            out.sql
9228        );
9229    }
9230
9231    #[test]
9232    fn test_a_union_of_bindings_emits_selects_not_bare_names() {
9233        // A CTE reference normally emits just its quoted name, which works
9234        // where the caller aliases it into a FROM. As a `UNION ALL` operand it
9235        // has to be a SELECT of its own — `("a" UNION ALL "b")` is not SQL.
9236        let out = compile_and_emit_with(
9237            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
9238             SELECT (INSERT Person { name := $n, posts := (a UNION b) })",
9239            &make_schema(),
9240        );
9241        assert!(
9242            out.sql.contains(r#"(SELECT "_s"."id" FROM "a" AS "_s")"#),
9243            "each union operand should be a select:\n{}",
9244            out.sql
9245        );
9246    }
9247
9248    #[test]
9249    fn test_a_bare_narrowing_reads_as_a_type_check_and_as_an_object() {
9250        // `exists [is Sub]` is whether the row *is* one; `x := [is Sub] { … }`
9251        // is that row read as it. Both used to fall off the end of the path
9252        // walk ("invalid path step" / "empty path traversal").
9253        for query in [
9254            "SELECT Person FILTER EXISTS [is Person]",
9255            "SELECT Person { p := [is Person] { name } }",
9256        ] {
9257            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query}: {e}"));
9258            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
9259        }
9260    }
9261
9262    #[test]
9263    fn test_a_multilink_takes_a_set_wrapped_in_a_select() {
9264        // `translations := (select { a, b })` — a select with no clauses of its
9265        // own is the expression it wraps. Left wrapped, a free row source (a
9266        // set literal, a union) reached the generic subquery arm and was
9267        // refused, while the identical unwrapped `{ a, b }` compiled.
9268        let out = compile_and_emit_with(
9269            "WITH a := (INSERT Post { title := $t1 }), b := (INSERT Post { title := $t2 }) \
9270             SELECT (INSERT Person { name := $n, posts := (SELECT { a, b }) })",
9271            &make_schema(),
9272        );
9273        assert!(
9274            out.sql.contains(r#""public"."Person.posts""#),
9275            "both targets should reach the junction:\n{}",
9276            out.sql
9277        );
9278    }
9279
9280    #[test]
9281    fn test_an_aggregate_over_a_subselected_path() {
9282        // `array_agg((select Person.name))` — the sub-select walks to a scalar,
9283        // which has no type name to be a schema select's subject. It means the
9284        // same as `array_agg(Person.name)` with the select's own modifiers on
9285        // the walk, which the path-select builder already handles. Checked
9286        // element for element against the unwrapped spelling.
9287        let out = compile_and_emit("SELECT array_agg((SELECT Person.name))");
9288        assert!(
9289            out.sql.contains("array_agg") && out.sql.contains(r#""public"."Person""#),
9290            "the aggregate should wrap the walk's column:\n{}",
9291            out.sql
9292        );
9293    }
9294
9295    #[test]
9296    fn test_a_distinct_aggregate_argument_reaches_the_aggregate() {
9297        // `array_agg(distinct x)` used to emit `array_agg(x)`: the unary
9298        // `distinct` emits its operand unchanged, because it is normally
9299        // applied where the set is built — which an aggregate argument is not.
9300        // Both spellings mean the same thing, and both have to return one
9301        // element where the old output returned two.
9302        for query in [
9303            "SELECT array_agg(DISTINCT Person.name)",
9304            "SELECT array_agg((SELECT DISTINCT Person.name))",
9305        ] {
9306            let out = compile_and_emit(query);
9307            assert!(
9308                out.sql.contains("array_agg(DISTINCT "),
9309                "{query} should deduplicate inside the aggregate:\n{}",
9310                out.sql
9311            );
9312        }
9313    }
9314
9315    #[test]
9316    fn test_is_not_negates_the_type_check() {
9317        // `x IS NOT T` is `Expr IS NOT TypeExpr` — the negation of the check,
9318        // not a comparison against some type `not T`. Counted against the
9319        // migrated data it came out as 3 of 3.
9320        let positive = compile_and_emit("SELECT Person FILTER Person IS Person");
9321        let negative = compile_and_emit("SELECT Person FILTER Person IS NOT Person");
9322        assert!(
9323            negative.sql.contains("NOT") && !positive.sql.contains("NOT"),
9324            "the negation should reach the SQL:\npositive:\n{}\nnegative:\n{}",
9325            positive.sql,
9326            negative.sql
9327        );
9328    }
9329
9330    #[test]
9331    fn test_a_cast_takes_a_prefix_operator_as_its_operand() {
9332        // `<bool>exists x` is legal — a cast takes a whole `Expr` at CAST
9333        // precedence. Pylon's cast went straight to a postfix expression, so
9334        // any prefix operator after one failed to parse.
9335        for query in ["SELECT <bool>EXISTS (SELECT Person LIMIT 1)", "SELECT <int64>-1"] {
9336            let ast = parse::parse(query).unwrap_or_else(|e| panic!("{query} should parse: {e}"));
9337            assert!(ir::compile(&ast, &make_schema()).is_ok(), "{query} should compile");
9338        }
9339    }
9340
9341    #[test]
9342    fn test_a_bare_multilink_reads_as_a_set() {
9343        // `p in .posts` — a multi-link read as a value, not traversed through.
9344        // The single-step resolver knew properties, links and computeds but
9345        // never multi-links, so the pointer reported itself as unknown while
9346        // the suggester, which does know them, offered the name straight back.
9347        let out =
9348            compile_and_emit("WITH p := (SELECT DETACHED Post LIMIT 1) SELECT Person { name } FILTER p IN .posts");
9349        assert!(
9350            out.sql.contains(r#""public"."Person.posts""#),
9351            "the membership test should read the link's junction:\n{}",
9352            out.sql
9353        );
9354    }
9355
9356    #[test]
9357    fn test_a_select_reads_its_own_shape_computed_in_its_clauses() {
9358        // `select T { x := … } order by .x` — a shape's computeds are in scope
9359        // for the select's own clauses, but not for a sibling pointer, so the
9360        // scope stops at the clauses.
9361        let out = compile_and_emit("SELECT Person { n := .name } ORDER BY .n ASC");
9362        assert!(
9363            out.sql.contains("ORDER BY"),
9364            "the order by should compile against the declared pointer:\n{}",
9365            out.sql
9366        );
9367    }
9368
9369    #[test]
9370    fn test_a_declared_pointer_can_be_walked_through() {
9371        // `p { boss := … }` read back as `.boss.name` — the walk builder
9372        // resolved a *schema* computed as a step but not one a shape in scope
9373        // declared, so anything past the first step reported it as unknown.
9374        let out = compile_and_emit_with(
9375            "WITH p := (SELECT Person { boss := .company } LIMIT 1) SELECT p { name, boss_name := .boss.name }",
9376            &make_schema(),
9377        );
9378        assert!(
9379            out.sql.contains(r#""public"."Company""#),
9380            "the walk should reach the declared pointer's own target table:\n{}",
9381            out.sql
9382        );
9383    }
9384
9385    #[test]
9386    fn test_a_replaced_subject_shape_stays_in_scope() {
9387        // `(select .company { n := .name }) { n }` — a shape at the point of
9388        // use replaces the subject's own, so what that one declared has to
9389        // stay readable or the replacement cannot see it. Before, this was an
9390        // outright compile error.
9391        let out = compile_and_emit("SELECT Person { c := (SELECT .company { n := .name }) { n } }");
9392        assert!(
9393            out.sql.contains(r#"'default::Company'::text"#) && out.sql.contains(r#""t2"."name""#),
9394            "`n` should resolve to the company's own column:\n{}",
9395            out.sql
9396        );
9397    }
9398
9399    #[test]
9400    fn test_a_sibling_shape_pointer_is_not_in_scope() {
9401        // One shape pointer reading another is refused, which is what keeps
9402        // four graph queries reported as the application bugs they are.
9403        let schema = make_schema();
9404        let ast = parse::parse("SELECT Person { n := .name, copy := .n }").unwrap();
9405        assert!(ir::compile(&ast, &schema).is_err());
9406    }
9407
9408    #[test]
9409    fn test_shape_over_a_with_binding_reads_the_binding() {
9410        // `metadata := metadata { … }` — a binding names a row source, so a
9411        // shape on it projects that source. The pointer route only took
9412        // relative paths, so this fell through to the expression route and was
9413        // rejected as "shapes are not valid in expression context".
9414        let out = compile_and_emit("WITH c := (SELECT Company LIMIT 1) SELECT Person { name, employer := c { name } }");
9415        assert!(
9416            out.sql.contains("\"c\""),
9417            "the pointer should read the binding's CTE:\n{}",
9418            out.sql
9419        );
9420    }
9421
9422    #[test]
9423    fn test_shape_over_a_bare_type_name_is_still_rejected() {
9424        // Unlike a binding, a type name in an enclosing scope is a factored
9425        // prefix, so a shape on one does not mean "every row of the table".
9426        let schema = make_schema();
9427        let ast = parse::parse("SELECT Person { name, other := Company { name } }").unwrap();
9428        assert!(ir::compile(&ast, &schema).is_err());
9429    }
9430
9431    #[test]
9432    fn test_nested_shape_reads_a_prefix_bound_by_the_outer_select() {
9433        // `Person.name` inside the `posts` shape names a type the enclosing
9434        // select binds, so it reads the outer row — not every Person. The
9435        // prefix used to be rejected outright because only the *innermost*
9436        // subject was checked.
9437        let out = compile_and_emit("SELECT Person { name, posts: { title, who := Person.name } }");
9438        assert!(
9439            out.sql.contains(r#""t0"."name""#),
9440            "the nested computed should read the outer Person alias:\n{}",
9441            out.sql
9442        );
9443        assert!(
9444            !out.sql.contains(r#"FROM "public"."Person" "t2""#),
9445            "the prefix should not open a second Person source:\n{}",
9446            out.sql
9447        );
9448    }
9449
9450    #[test]
9451    fn test_prefix_naming_a_type_out_of_scope_is_rejected() {
9452        // Nothing binds `Company` here, so the prefix stays an absolute path —
9453        // Pylon must not silently read the whole table.
9454        let schema = make_schema();
9455        let ast = parse::parse("SELECT Person { name, who := Company.name }").unwrap();
9456        assert!(ir::compile(&ast, &schema).is_err());
9457    }
9458
9459    #[test]
9460    fn test_free_select_tuple() {
9461        let schema = make_schema();
9462        let ast = parse::parse("SELECT (1, 2)").unwrap();
9463        let ir = ir::compile(&ast, &schema).unwrap();
9464        let out = emit(&ir);
9465        assert!(out.sql.contains("1"));
9466        assert!(out.sql.contains("2"));
9467        assert!(out.sql.contains("AS result"));
9468        assert!(matches!(out.shape.root, crate::query::ShapeNode::Tuple { .. }));
9469    }
9470
9471    #[test]
9472    fn test_free_select_scalar_literal() {
9473        let schema = make_schema();
9474        let ast = parse::parse("SELECT 'hello'").unwrap();
9475        let ir = ir::compile(&ast, &schema).unwrap();
9476        let out = emit(&ir);
9477        assert!(out.sql.contains("SELECT 'hello' AS v"));
9478        assert!(out.sql.contains("ROW(v) AS result"));
9479        assert!(matches!(out.shape.root, crate::query::ShapeNode::Scalar { .. }));
9480    }
9481
9482    #[test]
9483    fn test_float_literal_casts_to_float8() {
9484        // Regression: an un-cast float literal like `1.0` is untyped in
9485        // Postgres and defaults to `numeric`, not `float64` — silently
9486        // changing PyQL's semantics (only an explicit `<decimal>`/`123n`
9487        // literal should ever produce a real decimal) and breaking decode
9488        // inside ROW() composites (numeric's wire format differs from
9489        // float8's). An explicit ::float8 cast keeps it a real float.
9490        let out = compile_and_emit("SELECT 1.0");
9491        assert!(
9492            out.sql.contains("(1.0::float8)"),
9493            "expected explicit float8 cast, got:\n{}",
9494            out.sql
9495        );
9496    }
9497
9498    #[test]
9499    fn test_free_select_array_literal() {
9500        let schema = make_schema();
9501        let ast = parse::parse("SELECT [1, 2, 3]").unwrap();
9502        let ir = ir::compile(&ast, &schema).unwrap();
9503        let out = emit(&ir);
9504        assert!(out.sql.contains("SELECT ARRAY[1, 2, 3] AS v"), "{}", out.sql);
9505        assert!(out.sql.contains("SELECT v AS result, v FROM"), "{}", out.sql);
9506        assert!(matches!(out.shape.root, crate::query::ShapeNode::RawScalar));
9507    }
9508
9509    #[test]
9510    fn test_select_scalars() {
9511        let out = compile_and_emit("SELECT Person { name, age }");
9512        assert!(out.sql.contains("'default::Person'::text"));
9513        assert!(out.sql.contains("\"name\"::text"));
9514        assert!(out.sql.contains("\"age\"::int8"));
9515        assert!(out.sql.contains("FROM \"public\".\"Person\""));
9516        assert!(out.sql.contains(") AS result"));
9517    }
9518
9519    #[test]
9520    fn test_select_filter_param() {
9521        let out = compile_and_emit("SELECT Person { name } FILTER .name = $name");
9522        assert!(out.sql.contains("WHERE"));
9523        assert!(out.sql.contains("$1"));
9524    }
9525
9526    #[test]
9527    fn test_filter_in_set_literal_compiles_to_any_array() {
9528        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol'}");
9529        assert!(out.sql.contains("= ANY(ARRAY['Carol'])"), "got:\n{}", out.sql);
9530    }
9531
9532    #[test]
9533    fn test_filter_in_multi_element_set_literal_compiles_to_any_array() {
9534        let out = compile_and_emit("SELECT Person { name } FILTER .name IN {'Carol', 'Bob'}");
9535        assert!(out.sql.contains("= ANY(ARRAY['Carol', 'Bob'])"), "got:\n{}", out.sql);
9536    }
9537
9538    /// `x IN std::array_unpack(arr)` is the documented way to test membership
9539    /// of an array parameter. It used to emit `= ANY(unnest(arr))` — a
9540    /// set-returning function inside `WHERE`, which PostgreSQL rejects at
9541    /// execution time (`set-returning functions are not allowed in WHERE`)
9542    /// even though both the PyQL and the SQL parse cleanly. `ANY` takes the
9543    /// array directly, so the `unnest` has to go.
9544    #[test]
9545    fn test_filter_in_array_unpack_passes_the_array_straight_to_any() {
9546        let out = compile_and_emit("SELECT Person { name } FILTER .name IN std::array_unpack(<array<str>>$names)");
9547        assert!(
9548            !out.sql.contains("ANY(unnest("),
9549            "unnest inside ANY is invalid in WHERE:\n{}",
9550            out.sql
9551        );
9552        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9553    }
9554
9555    #[test]
9556    fn test_filter_not_in_array_unpack_passes_the_array_straight_to_all() {
9557        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN std::array_unpack(<array<str>>$names)");
9558        assert!(
9559            !out.sql.contains("ALL(unnest("),
9560            "unnest inside ALL is invalid in WHERE:\n{}",
9561            out.sql
9562        );
9563        assert!(out.sql.contains("<> ALL("), "got:\n{}", out.sql);
9564    }
9565
9566    /// `all(array_unpack($t) in .posts.title)` quantifies over the unpacked
9567    /// array, not over `.posts`. Read as a condition on one `.posts` element
9568    /// it would both ask the opposite question and leave the `unnest()` in a
9569    /// `WHERE`, which PostgreSQL rejects at execution time.
9570    #[test]
9571    fn test_all_over_unpacked_array_aggregates_the_array_not_the_multilink() {
9572        let out = compile_and_emit(
9573            "SELECT Person { name } FILTER all(std::array_unpack(<array<str>>$titles) IN .posts.title)",
9574        );
9575        assert!(out.sql.contains("bool_and("), "got:\n{}", out.sql);
9576        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
9577    }
9578
9579    /// The mirror of the above: the multilink is what is quantified over and
9580    /// the array is only the haystack, so `IN` still folds into `= ANY(array)`
9581    /// and nothing unnests.
9582    #[test]
9583    fn test_any_over_a_multilink_against_an_unpacked_array_still_folds_into_any() {
9584        let out = compile_and_emit(
9585            "SELECT Person { name } FILTER any(.posts.title IN std::array_unpack(<array<str>>$titles))",
9586        );
9587        assert!(!out.sql.contains("unnest("), "got:\n{}", out.sql);
9588        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9589    }
9590
9591    #[test]
9592    fn test_any_over_unpacked_array_aggregates_the_array_not_the_multilink() {
9593        let out = compile_and_emit(
9594            "SELECT Person { name } FILTER any(std::array_unpack(<array<str>>$titles) IN .posts.title)",
9595        );
9596        assert!(out.sql.contains("bool_or("), "got:\n{}", out.sql);
9597        assert!(out.sql.contains("_unnested"), "got:\n{}", out.sql);
9598    }
9599
9600    /// A schema with a `text[]` property, which `make_schema` has none of.
9601    fn schema_with_array_property() -> SchemaDescriptor {
9602        let mut schema = make_schema();
9603        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
9604            name: "perms".into(),
9605            pg_type: "text[]".into(),
9606            nullable: true,
9607            default_sql: None,
9608            default_pyql: None,
9609            description: None,
9610            check_constraints: vec![],
9611            is_exclusive: false,
9612            is_pk: false,
9613            is_readonly: false,
9614            rewrites: vec![],
9615            tuple_members: None,
9616            column_type: None,
9617        });
9618        schema
9619    }
9620
9621    /// `array_agg(array_unpack(teams.perms))` flattens every row's array into
9622    /// one. Compiled as written it emitted `array_agg(unnest(...))`, which
9623    /// PostgreSQL rejects at execution time ("aggregate function calls cannot
9624    /// contain set-returning function calls"), so the unpacking has to happen
9625    /// in a row source the aggregate reads from outside.
9626    #[test]
9627    fn test_aggregate_over_an_unpacked_walk_unnests_in_a_row_source() {
9628        let out = compile_and_emit_with(
9629            "WITH teams := (SELECT Person { perms } FILTER .age > 1) SELECT std::array_agg(std::array_unpack(teams.perms))",
9630            &schema_with_array_property(),
9631        );
9632        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9633        assert!(
9634            out.sql.contains(
9635                r#"coalesce(array_agg("_s"."v"), '{}') FROM unnest(ARRAY(SELECT unnest("t1"."perms") FROM "teams" AS "t1"))"#
9636            ),
9637            "got:\n{}",
9638            out.sql
9639        );
9640    }
9641
9642    #[test]
9643    fn test_aggregate_over_an_unpacked_type_rooted_path_unnests_in_a_row_source() {
9644        let out = compile_and_emit_with(
9645            "SELECT std::count(std::array_unpack(Person.perms))",
9646            &schema_with_array_property(),
9647        );
9648        assert!(!out.sql.contains("count(unnest("), "got:\n{}", out.sql);
9649        assert!(
9650            out.sql
9651                .contains(r#"FROM unnest(ARRAY(SELECT unnest("t0"."perms") FROM "public"."Person" AS "t0"))"#),
9652            "got:\n{}",
9653            out.sql
9654        );
9655    }
9656
9657    /// One array standing on its own — a parameter, or one row's property —
9658    /// is already the row source, so it needs no walk gathered around it.
9659    #[test]
9660    fn test_aggregate_over_an_unpacked_parameter_unnests_the_array_itself() {
9661        let out = compile_and_emit("SELECT std::array_agg(std::array_unpack(<array<str>>$names))");
9662        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9663        assert!(
9664            out.sql.contains(r#"FROM unnest(($1)::text[]) AS "_s"("v")"#),
9665            "got:\n{}",
9666            out.sql
9667        );
9668    }
9669
9670    #[test]
9671    fn test_aggregate_over_an_unpacked_property_unnests_that_row_s_array() {
9672        let out = compile_and_emit_with(
9673            "SELECT Person { n := std::array_agg(std::array_unpack(.perms)) }",
9674            &schema_with_array_property(),
9675        );
9676        assert!(!out.sql.contains("array_agg(unnest("), "got:\n{}", out.sql);
9677        assert!(
9678            out.sql.contains(r#"FROM unnest("t0"."perms") AS "_s"("v")"#),
9679            "got:\n{}",
9680            out.sql
9681        );
9682    }
9683
9684    /// `array_agg` over no rows is `[]` in PyQL and NULL in SQL, which reaches
9685    /// a caller as a missing value where a list was promised.
9686    #[test]
9687    fn test_array_agg_over_nothing_is_an_empty_array_not_null() {
9688        for query in [
9689            "SELECT std::array_agg(Person.name)",
9690            "SELECT std::array_agg((SELECT Person).name)",
9691        ] {
9692            let out = compile_and_emit(query);
9693            assert!(
9694                out.sql.contains("coalesce(") && out.sql.contains("'{}'"),
9695                "{query} got:\n{}",
9696                out.sql
9697            );
9698        }
9699    }
9700
9701    /// The `coalesce` that empty array wears must not hide what the binding
9702    /// holds: `contains(ids, .id)` picks its overload off the binding's type,
9703    /// and an array read as text silently becomes a substring search.
9704    #[test]
9705    fn test_an_array_agg_binding_stays_an_array_through_its_coalesce() {
9706        let out = compile_and_emit(
9707            "WITH ids := std::array_agg(Person.id) SELECT Person { name } FILTER std::contains(ids, .id)",
9708        );
9709        assert!(
9710            out.sql.contains("@> ARRAY["),
9711            "expected the array overload, got:\n{}",
9712            out.sql
9713        );
9714        assert!(
9715            !out.sql.contains("strpos("),
9716            "the string overload must not win, got:\n{}",
9717            out.sql
9718        );
9719    }
9720
9721    /// An aggregate over a walk that is *not* unpacked keeps its own row
9722    /// source — the rewrite above must not reach it.
9723    #[test]
9724    fn test_aggregate_over_a_plain_walk_keeps_its_row_source() {
9725        let out = compile_and_emit("SELECT std::array_agg(Person.name)");
9726        assert!(
9727            out.sql.contains(r#"array_agg("t0"."name")"#) && out.sql.contains(r#"FROM "public"."Person""#),
9728            "got:\n{}",
9729            out.sql
9730        );
9731    }
9732
9733    /// The order check is a safety net for every emitter that assembles a
9734    /// WITH clause, so it has to actually catch one.
9735    #[test]
9736    fn test_the_cte_order_check_catches_a_forward_reference() {
9737        let bad = "WITH \"b\" AS (\n SELECT * FROM \"a\"\n),\n\"a\" AS (\n SELECT 1\n)\nSELECT 1";
9738        assert!(
9739            super::forward_cte_reference(bad).is_some(),
9740            "expected a forward reference"
9741        );
9742        let good = "WITH \"a\" AS (\n SELECT 1\n),\n\"b\" AS (\n SELECT * FROM \"a\"\n)\nSELECT 1";
9743        assert_eq!(super::forward_cte_reference(good), None, "ordered CTEs must pass");
9744    }
9745
9746    /// `.status IN <Enum>array_unpack($1)` — the cast sits between `IN` and
9747    /// the unpack, and used to hide it, leaving a set-returning function in a
9748    /// `WHERE` that PostgreSQL rejects at execution time.
9749    #[test]
9750    fn test_a_cast_unpacked_array_still_folds_into_any() {
9751        let out = compile_and_emit("SELECT Person { name } FILTER .name IN <str>std::array_unpack(<array<str>>$names)");
9752        assert!(
9753            !out.sql.contains("unnest("),
9754            "the unpack must fold into ANY, got:\n{}",
9755            out.sql
9756        );
9757        assert!(out.sql.contains("= ANY("), "got:\n{}", out.sql);
9758    }
9759
9760    /// A bare `array_unpack` outside `IN` still has to unnest — the unwrap is
9761    /// specific to `ANY`/`ALL`, which take an array rather than a set.
9762    #[test]
9763    fn test_array_unpack_outside_in_still_unnests() {
9764        let out = compile_and_emit("SELECT std::array_unpack(<array<str>>$names)");
9765        assert!(out.sql.contains("unnest("), "got:\n{}", out.sql);
9766    }
9767
9768    #[test]
9769    fn test_filter_not_in_set_literal_compiles_to_all_array() {
9770        let out = compile_and_emit("SELECT Person { name } FILTER .name NOT IN {'Carol'}");
9771        assert!(out.sql.contains("<> ALL(ARRAY['Carol'])"), "got:\n{}", out.sql);
9772    }
9773
9774    #[test]
9775    fn test_deep_splat_fetches_single_link_target_properties_not_just_id() {
9776        // `**` on a single link must expand to the target type's own
9777        // properties (one level of `*`, not `**` again — recursing into
9778        // the target's own links would never terminate for a cyclic link
9779        // graph), not just an implicit `{ id }`.
9780        let out = compile_and_emit("SELECT Person { ** }");
9781        assert!(
9782            out.sql.contains("\"name\""),
9783            "expected Company.name pulled in via .company's ** expansion, got:\n{}",
9784            out.sql
9785        );
9786    }
9787
9788    #[test]
9789    fn test_deep_splat_fetches_multilink_target_properties_not_just_id() {
9790        let out = compile_and_emit("SELECT Person { ** }");
9791        assert!(
9792            out.sql.contains("\"title\""),
9793            "expected Post.title pulled in via .posts' ** expansion, got:\n{}",
9794            out.sql
9795        );
9796    }
9797
9798    #[test]
9799    fn test_schema_type_cast_select() {
9800        let out = compile_and_emit("SELECT <default::Person><uuid>'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'");
9801        assert!(out.sql.contains("FROM \"public\".\"Person\""));
9802        assert!(out.sql.contains("WHERE"));
9803        assert!(out.sql.contains("'019ef1bb-0d42-7a9f-8f6b-b38d028a49ba'"));
9804    }
9805
9806    #[test]
9807    fn test_select_single_link() {
9808        let out = compile_and_emit("SELECT Person { name, company { name } }");
9809        assert!(out.sql.contains("'default::Company'::text"));
9810        assert!(out.sql.contains("FROM \"public\".\"Company\""));
9811        // join condition: parent FK column = target PK
9812        assert!(out.sql.contains("\"company_id\" = "));
9813    }
9814
9815    #[test]
9816    fn test_select_multi_link() {
9817        let out = compile_and_emit("SELECT Person { name, posts { title } }");
9818        assert!(out.sql.contains("array_agg(ROW("));
9819        assert!(out.sql.contains("ARRAY[]::record[]"));
9820        assert!(out.sql.contains("'default::Post'::text"));
9821        assert!(out.sql.contains("\"Person.posts\""));
9822    }
9823
9824    fn compile_err(query: &str) -> String {
9825        let schema = make_schema();
9826        let ast = parse::parse(query).expect("parse failed");
9827        match ir::compile(&ast, &schema) {
9828            Ok(_) => panic!("expected a compile error"),
9829            Err(e) => format!("{e}"),
9830        }
9831    }
9832
9833    #[test]
9834    fn test_multi_link_limit_cuts_rows_before_aggregating() {
9835        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' limit 1 }");
9836        // The LIMIT has to sit on the row source, not next to array_agg —
9837        // the aggregate has already collapsed the rows by then.
9838        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9839        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
9840        let agg = out.sql.find("array_agg").unwrap();
9841        let limit = out.sql.find("LIMIT 1").unwrap();
9842        assert!(agg < limit, "{}", out.sql);
9843        assert!(out.sql.contains("\"t1\".\"title\"::text AS \"c1\""), "{}", out.sql);
9844    }
9845
9846    #[test]
9847    fn test_multi_link_order_by_with_limit_orders_the_aggregate_too() {
9848        let out = compile_and_emit("SELECT Person { posts: { title } order by .title desc offset 1 limit 2 }");
9849        // The sort key rides along as its own column so the array keeps the
9850        // derived table's order rather than relying on aggregation order.
9851        assert!(out.sql.contains("\"t1\".\"title\" AS \"s0\""), "{}", out.sql);
9852        assert!(
9853            out.sql.contains("ORDER BY \"__lim\".\"s0\" DESC NULLS LAST"),
9854            "{}",
9855            out.sql
9856        );
9857        assert!(out.sql.contains("OFFSET 1"), "{}", out.sql);
9858        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
9859    }
9860
9861    #[test]
9862    fn test_multi_link_without_limit_keeps_the_flat_aggregate() {
9863        let out = compile_and_emit("SELECT Person { posts: { title } filter .title = 'x' }");
9864        assert!(!out.sql.contains("__lim"), "{}", out.sql);
9865        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
9866    }
9867
9868    #[test]
9869    fn test_sub_select_as_computed_pointer_is_a_link_pointer() {
9870        let out = compile_and_emit("SELECT Person { recent := (select .posts filter .title = 'x' limit 1) { title } }");
9871        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9872            panic!()
9873        };
9874        // Named after the pointer, not after the link it selects from, and
9875        // one object: `limit 1` makes the sub-select at most one row.
9876        let ShapeNode::Object {
9877            name,
9878            pointers: elem_pointers,
9879            ..
9880        } = &pointers[2]
9881        else {
9882            panic!("{:?}", pointers[2])
9883        };
9884        assert_eq!(name, "recent");
9885        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
9886        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
9887        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9888    }
9889
9890    #[test]
9891    fn test_sub_select_shape_inside_the_parens_is_equivalent() {
9892        let outer = compile_and_emit("SELECT Person { recent := (select .posts limit 1) { title } }");
9893        let inner = compile_and_emit("SELECT Person { recent := (select .posts { title } limit 1) }");
9894        assert_eq!(outer.sql, inner.sql);
9895    }
9896
9897    #[test]
9898    fn test_sub_select_field_access_is_a_correlated_scalar_subquery() {
9899        let out = compile_and_emit("SELECT Person { t := (select .posts filter .title = 'x' limit 1).title }");
9900        // Rooted at the enclosing type and correlated back to its row, so
9901        // the subquery only ever sees this Person's posts.
9902        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9903        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
9904        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
9905        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9906            panic!()
9907        };
9908        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, .. } if name == "t"));
9909    }
9910
9911    #[test]
9912    fn test_sub_select_field_access_over_a_single_link() {
9913        let out = compile_and_emit("SELECT Person { c := (select .company).name }");
9914        assert!(out.sql.contains("\"t1\".\"company_id\" = \"t2\".\"id\""), "{}", out.sql);
9915        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
9916    }
9917
9918    #[test]
9919    fn test_sub_select_field_access_in_a_filter() {
9920        let out = compile_and_emit("SELECT Person { name } filter (select .posts limit 1).title = 'x'");
9921        assert!(out.sql.contains("WHERE ((SELECT \"t2\".\"title\""), "{}", out.sql);
9922        assert!(out.sql.contains("LIMIT 1) = 'x')"), "{}", out.sql);
9923    }
9924
9925    #[test]
9926    fn test_sub_select_over_an_absolute_path_is_not_correlated() {
9927        let out = compile_and_emit("SELECT Person { n := (select Company.name limit 1) }");
9928        assert!(out.sql.contains("FROM \"public\".\"Company\""), "{}", out.sql);
9929        assert!(!out.sql.contains("= \"t0\".\"id\""), "{}", out.sql);
9930    }
9931
9932    #[test]
9933    fn test_backlink_sub_select_carries_its_modifiers() {
9934        let out = compile_and_emit("SELECT Post { authors := (select .<posts[is Person] { name } limit 2) }");
9935        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
9936        assert!(out.sql.contains("AS \"__lim\""), "{}", out.sql);
9937        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
9938            panic!()
9939        };
9940        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
9941    }
9942
9943    #[test]
9944    fn test_with_bound_scalar_is_typed_for_overload_resolution() {
9945        // A `with`-bound value had no inferred type, so a call over one fell
9946        // back to the first registered overload and needed an explicit cast.
9947        let out = compile_and_emit("WITH xs := (select Person.name) SELECT contains(xs, 'a')");
9948        assert!(
9949            out.sql.contains("strpos((SELECT \"v\" FROM \"xs\"), 'a')"),
9950            "{}",
9951            out.sql
9952        );
9953    }
9954
9955    /// `Person` with a translation-style computed model: one computed
9956    /// resolves a filtered subset of a multi-link, others read through it.
9957    fn make_schema_with_computed_links() -> SchemaDescriptor {
9958        use crate::schema::ComputedDescriptor;
9959        let mut schema = make_schema();
9960        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
9961        person.computed = vec![
9962            ComputedDescriptor {
9963                name: "published".into(),
9964                expression: "(select .posts filter .title != '')".into(),
9965                return_type: None,
9966                link_target: None,
9967                link_multi: false,
9968            },
9969            ComputedDescriptor {
9970                name: "plain".into(),
9971                expression: ".posts".into(),
9972                return_type: None,
9973                link_target: None,
9974                link_multi: false,
9975            },
9976            ComputedDescriptor {
9977                name: "featured".into(),
9978                expression: "(select .published filter .title != 'draft')".into(),
9979                return_type: None,
9980                link_target: None,
9981                link_multi: false,
9982            },
9983            ComputedDescriptor {
9984                name: "looper".into(),
9985                expression: "(select .looper)".into(),
9986                return_type: None,
9987                link_target: None,
9988                link_multi: false,
9989            },
9990            ComputedDescriptor {
9991                name: "capped".into(),
9992                expression: "(select .posts limit 1)".into(),
9993                return_type: None,
9994                link_target: None,
9995                link_multi: false,
9996            },
9997        ];
9998        schema
9999    }
10000
10001    /// `Person.recent` computed from an object-returning function call, the
10002    /// shape jaldis's translation model uses.
10003    fn make_schema_with_object_fn_computed() -> SchemaDescriptor {
10004        use crate::schema::{ComputedDescriptor, FunctionDescriptor, FunctionParamDescriptor};
10005        let mut schema = make_schema();
10006        schema.functions = vec![FunctionDescriptor {
10007            name: "latest".into(),
10008            module: "default".into(),
10009            params: vec![FunctionParamDescriptor {
10010                name: "owner".into(),
10011                pg_type: "uuid".into(),
10012            }],
10013            return_pg_type: "default::Post".into(),
10014            return_is_object: true,
10015            return_is_set: true,
10016            return_is_polymorphic: false,
10017            volatility: "stable".into(),
10018            body: String::new(),
10019        }];
10020        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10021        person.computed = vec![
10022            ComputedDescriptor {
10023                name: "recent".into(),
10024                expression: "latest(.id)".into(),
10025                return_type: None,
10026                link_target: None,
10027                link_multi: false,
10028            },
10029            ComputedDescriptor {
10030                name: "vetted".into(),
10031                expression: "(select latest(.id) filter .title != '')".into(),
10032                return_type: None,
10033                link_target: None,
10034                link_multi: false,
10035            },
10036        ];
10037        schema
10038    }
10039
10040    #[test]
10041    fn test_path_traverses_through_a_function_backed_computed() {
10042        // `recent := latest(.id)` has no path to splice in, so the call
10043        // itself becomes the next row source — LATERAL, because its
10044        // arguments read the alias the traversal has reached.
10045        let schema = make_schema_with_object_fn_computed();
10046        let out = compile_and_emit_with("SELECT Person { t := .recent.title }", &schema);
10047        assert!(
10048            out.sql
10049                .contains("CROSS JOIN LATERAL \"public\".\"latest\"(\"t1\".\"id\") AS \"t2\""),
10050            "{}",
10051            out.sql
10052        );
10053        // The function returns a set, so the traversal is multi-valued.
10054        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10055    }
10056
10057    #[test]
10058    fn test_sub_select_over_a_function_backed_computed() {
10059        // The jaldis translation model, minimal: a function-backed computed
10060        // resolves the set, a scalar computed picks an attribute out of it.
10061        let schema = make_schema_with_object_fn_computed();
10062        let out = compile_and_emit_with(
10063            "SELECT Person { t := (select .recent filter .title = 'x' limit 1).title }",
10064            &schema,
10065        );
10066        assert!(
10067            out.sql.contains("CROSS JOIN LATERAL \"public\".\"latest\""),
10068            "{}",
10069            out.sql
10070        );
10071        assert!(out.sql.contains("\"t2\".\"title\" = 'x'"), "{}", out.sql);
10072        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10073        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10074    }
10075
10076    #[test]
10077    fn test_function_backed_computed_carries_its_own_filter() {
10078        let schema = make_schema_with_object_fn_computed();
10079        let out = compile_and_emit_with(
10080            "SELECT Person { t := (select .vetted filter .title = 'x' limit 1).title }",
10081            &schema,
10082        );
10083        assert!(
10084            out.sql
10085                .contains("(\"t2\".\"title\" = 'x') AND (\"t2\".\"title\" <> '')"),
10086            "{}",
10087            out.sql
10088        );
10089    }
10090
10091    #[test]
10092    fn test_function_backed_computed_checks_its_argument_count() {
10093        use crate::schema::ComputedDescriptor;
10094        let mut schema = make_schema_with_object_fn_computed();
10095        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10096        person.computed = vec![ComputedDescriptor {
10097            name: "recent".into(),
10098            expression: "latest()".into(),
10099            return_type: None,
10100            link_target: None,
10101            link_multi: false,
10102        }];
10103        let ast = parse::parse("SELECT Person { t := .recent.title }").unwrap();
10104        let err = match ir::compile(&ast, &schema) {
10105            Ok(_) => panic!("expected a compile error"),
10106            Err(e) => format!("{e}"),
10107        };
10108        assert!(err.contains("expects 1 argument(s), got 0"), "{err}");
10109    }
10110
10111    #[test]
10112    fn test_path_traverses_through_a_computed_pointer() {
10113        // `.published` is itself computed, so it has no column to join on —
10114        // its own path takes the step's place and its filter rides along.
10115        let schema = make_schema_with_computed_links();
10116        let out = compile_and_emit_with("SELECT Person { t := .published.title }", &schema);
10117        assert!(out.sql.contains("\"public\".\"Person.posts\""), "{}", out.sql);
10118        assert!(
10119            out.sql
10120                .contains("(\"t1\".\"id\" = \"t0\".\"id\") AND (\"t2\".\"title\" <> '')"),
10121            "{}",
10122            out.sql
10123        );
10124    }
10125
10126    #[test]
10127    fn test_sub_select_over_a_computed_pointer_projects_a_property() {
10128        // The shape the jaldis translation model is built on:
10129        // `(select .translation filter .attribute = X limit 1).value`.
10130        let schema = make_schema_with_computed_links();
10131        let out = compile_and_emit_with(
10132            "SELECT Person { t := (select .published filter .title = 'a' limit 1).title }",
10133            &schema,
10134        );
10135        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10136        // Both the computed's own filter and the use-site one apply.
10137        assert!(out.sql.contains("\"t2\".\"title\" = 'a'"), "{}", out.sql);
10138        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
10139    }
10140
10141    #[test]
10142    fn test_chained_computed_pointers_each_contribute_their_filter() {
10143        let schema = make_schema_with_computed_links();
10144        let out = compile_and_emit_with("SELECT Person { t := .featured.title }", &schema);
10145        assert_eq!(out.sql.matches("\"public\".\"Person.posts\"").count(), 1, "{}", out.sql);
10146        assert!(out.sql.contains("\"t2\".\"title\" <> 'draft'"), "{}", out.sql);
10147        assert!(out.sql.contains("\"t2\".\"title\" <> ''"), "{}", out.sql);
10148    }
10149
10150    #[test]
10151    fn test_computed_pointer_naming_a_bare_link_is_traversable() {
10152        let schema = make_schema_with_computed_links();
10153        let out = compile_and_emit_with("SELECT Person { t := .plain.title }", &schema);
10154        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10155    }
10156
10157    #[test]
10158    fn test_a_cycle_of_computed_pointers_is_reported_not_hung_on() {
10159        let schema = make_schema_with_computed_links();
10160        let ast = parse::parse("SELECT Person { t := .looper.title }").unwrap();
10161        let err = match ir::compile(&ast, &schema) {
10162            Ok(_) => panic!("expected a compile error"),
10163            Err(e) => format!("{e}"),
10164        };
10165        assert!(err.contains("expands into itself"), "{err}");
10166    }
10167
10168    #[test]
10169    fn test_a_cycle_of_computed_pointers_read_as_a_value_is_reported_not_hung_on() {
10170        // Read as the last step, the computed was compiled as an expression,
10171        // which walked the same path again with no count of how often.
10172        let schema = make_schema_with_computed_links();
10173        for query in ["SELECT Person { looper }", "SELECT Person { * }"] {
10174            let ast = parse::parse(query).unwrap();
10175            let err = match ir::compile(&ast, &schema) {
10176                Ok(_) => panic!("expected a compile error for {query}"),
10177                Err(e) => format!("{e}"),
10178            };
10179            assert!(err.contains("expands into itself"), "{query}: {err}");
10180        }
10181    }
10182
10183    #[test]
10184    fn test_traversing_through_a_limited_computed_uses_a_lateral() {
10185        // `capped` picks one row per Person, so the traversal correlates
10186        // through a LATERAL rather than a plain join — and its `limit 1`
10187        // means the result is one title, not an array of them.
10188        let schema = make_schema_with_computed_links();
10189        let out = compile_and_emit_with("SELECT Person { t := .capped.title }", &schema);
10190        assert!(out.sql.contains("JOIN LATERAL ("), "{}", out.sql);
10191        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10192        assert!(!out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
10193    }
10194
10195    #[test]
10196    fn test_backlink_from_a_type_whose_interface_the_link_targets() {
10197        use crate::schema::{LinkDescriptor, TypeDescriptor};
10198        let mut schema = make_interface_schema();
10199        let individual = schema.types.iter().find(|t| t.name == "Individual").unwrap().clone();
10200        schema.types.push(TypeDescriptor {
10201            name: "Note".into(),
10202            module: "default".into(),
10203            table: "Note".into(),
10204            abstract_: false,
10205            materialized: true,
10206            description: None,
10207            parents: vec![],
10208            interfaces: vec![],
10209            bases: vec![],
10210            properties: individual.properties[..1].to_vec(),
10211            // Targets the *interface*, which `Individual` implements.
10212            links: vec![LinkDescriptor {
10213                name: "owner".into(),
10214                target: "default::Account".into(),
10215                nullable: true,
10216                through: None,
10217                description: None,
10218                default_pyql: None,
10219                is_exclusive: false,
10220                is_readonly: false,
10221                rewrites: vec![],
10222                on_delete: vec![],
10223            }],
10224            multilinks: vec![],
10225            computed: vec![],
10226            constraints: vec![],
10227            indexes: vec![],
10228            partition: None,
10229            vector_indexes: vec![],
10230            search_indexes: vec![],
10231            triggers: vec![],
10232            junction: false,
10233            signals: vec![],
10234        });
10235        // Reported "link 'Note::owner' does not target 'Individual'" — but a
10236        // link to an interface accepts every implementor, and they share ids.
10237        let out = compile_and_emit_with("SELECT Individual { notes := .<owner[is Note] }", &schema);
10238        assert!(out.sql.contains("\"public\".\"Note\""), "{}", out.sql);
10239        assert!(out.sql.contains("\"owner_id\" = \"t0\".id"), "{}", out.sql);
10240    }
10241
10242    #[test]
10243    fn test_with_block_in_expression_position_hoists_its_bindings() {
10244        // A `with` has nowhere to put its CTEs mid-expression, so they move
10245        // to the enclosing statement's own WITH clause.
10246        let out = compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select c) }");
10247        assert!(out.sql.starts_with("WITH\n\"c\" AS ("), "{}", out.sql);
10248        assert!(out.sql.contains("(SELECT \"v\" FROM \"c\")"), "{}", out.sql);
10249    }
10250
10251    #[test]
10252    fn test_hoisted_binding_is_usable_in_the_expression_around_it() {
10253        let out =
10254            compile_and_emit("SELECT Person { t := (with c := (select Company.name limit 1) select .name ++ c) }");
10255        assert!(
10256            out.sql.contains("(\"t0\".\"name\" || (SELECT \"v\" FROM \"c\"))"),
10257            "{}",
10258            out.sql
10259        );
10260    }
10261
10262    #[test]
10263    fn test_hoisted_binding_joins_the_statements_own_with_clause() {
10264        let out = compile_and_emit(
10265            "WITH top := (select Person.name limit 1) \
10266             SELECT Person { t := (with c := (select Company.name limit 1) select c) }",
10267        );
10268        let top = out.sql.find("\"top\" AS (").expect("top-level binding");
10269        let inner = out.sql.find("\"c\" AS (").expect("hoisted binding");
10270        assert!(top < inner, "the statement's own bindings come first:\n{}", out.sql);
10271    }
10272
10273    #[test]
10274    fn test_a_binding_named_after_a_keyword_is_usable() {
10275        // The lexer is case-insensitive, so `order` arrives as the ORDER
10276        // token and read as "expected an expression, found 'order'" — and the
10277        // binding keeps the casing it was written with, not the keyword
10278        // table's.
10279        let out = compile_and_emit("WITH order := (select Person.name) SELECT Person { name } FILTER .name = order");
10280        assert!(out.sql.contains("(SELECT \"v\" FROM \"order\")"), "{}", out.sql);
10281    }
10282
10283    #[test]
10284    fn test_computed_declared_on_an_interface_is_visible_from_an_implementor() {
10285        use crate::schema::ComputedDescriptor;
10286        let mut schema = make_interface_schema();
10287        let account = schema.types.iter_mut().find(|t| t.name == "Account").unwrap();
10288        account.computed = vec![ComputedDescriptor {
10289            name: "tier".into(),
10290            expression: "'gold'".into(),
10291            return_type: None,
10292            link_target: None,
10293            link_multi: false,
10294        }];
10295        let individual = schema.types.iter_mut().find(|t| t.name == "Individual").unwrap();
10296        individual.computed = vec![];
10297        // Reported as "has no link or property 'tier'. Did you mean 'tier'?"
10298        // — the suggester searched interfaces, the resolver didn't.
10299        for q in ["SELECT Individual { tier }", "SELECT Individual { t := .tier }"] {
10300            let out = compile_and_emit_with(q, &schema);
10301            assert!(out.sql.contains("'gold'"), "{q}: {}", out.sql);
10302        }
10303    }
10304
10305    #[test]
10306    fn test_computed_may_carry_trailing_modifiers_without_a_select() {
10307        use crate::schema::ComputedDescriptor;
10308        let mut schema = make_schema();
10309        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10310        person.computed = vec![ComputedDescriptor {
10311            name: "recent".into(),
10312            expression: ".posts order by .title desc limit 5".into(),
10313            return_type: None,
10314            link_target: None,
10315            link_multi: false,
10316        }];
10317        // A schema fragment has no statement around it to hang modifiers
10318        // off, so this used to be "expected an expression, found 'order'".
10319        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
10320        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
10321        assert!(out.sql.contains("ORDER BY \"t1\".\"title\" DESC"), "{}", out.sql);
10322    }
10323
10324    #[test]
10325    fn test_computed_may_lead_with_a_bare_select() {
10326        use crate::schema::ComputedDescriptor;
10327        let mut schema = make_schema();
10328        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
10329        person.computed = vec![ComputedDescriptor {
10330            name: "recent".into(),
10331            expression: "select .posts order by .title desc limit 5".into(),
10332            return_type: None,
10333            link_target: None,
10334            link_multi: false,
10335        }];
10336        let out = compile_and_emit_with("SELECT Person { recent { title } }", &schema);
10337        assert!(out.sql.contains("LIMIT 5"), "{}", out.sql);
10338    }
10339
10340    #[test]
10341    fn test_sub_select_with_a_shape_may_project_a_property_off_it() {
10342        let out = compile_and_emit("SELECT Person { t := (select .posts { title } limit 1).title }");
10343        assert!(out.sql.contains("\"t2\".\"title\""), "{}", out.sql);
10344        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10345    }
10346
10347    #[test]
10348    fn test_sub_statement_error_names_what_it_got() {
10349        let err = compile_err("SELECT Person { t := (insert Company { name := 'a' }) }");
10350        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
10351    }
10352
10353    #[test]
10354    fn test_with_bound_scalar_path_select_emits_a_real_select() {
10355        // The CTE body used to be just the FROM clause — `"xs" AS (
10356        // "public"."Person" AS "t0" )` — which is not SQL at all.
10357        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name }");
10358        assert!(
10359            out.sql
10360                .contains("SELECT ROW(v) AS result, v FROM (SELECT \"t0\".\"name\" AS v"),
10361            "{}",
10362            out.sql
10363        );
10364    }
10365
10366    #[test]
10367    fn test_with_bound_scalar_path_select_is_typed_as_its_result() {
10368        // Binding `Person.name` binds text, not `default::Person`, so a
10369        // reference to it reads the CTE's value column rather than an `id`.
10370        let out = compile_and_emit("WITH xs := (select Person.name) SELECT Person { name } FILTER .name IN xs");
10371        assert!(out.sql.contains("ANY((SELECT \"v\" FROM \"xs\"))"), "{}", out.sql);
10372    }
10373
10374    #[test]
10375    fn test_distinct_survives_a_with_binding() {
10376        let out = compile_and_emit("WITH xs := (select distinct Person.name) SELECT Person { name }");
10377        assert!(out.sql.contains("SELECT DISTINCT \"t0\".\"name\" AS v"), "{}", out.sql);
10378    }
10379
10380    #[test]
10381    fn test_for_over_a_derived_set_iterates_every_row() {
10382        // A non-literal iterator used to be wrapped in a one-row VALUES, so
10383        // the body ran once against a scalar subquery instead of per row.
10384        let out = compile_and_emit("FOR x IN (select Person.name) UNION (SELECT Person { name } FILTER .name = x)");
10385        assert!(!out.sql.contains("VALUES"), "{}", out.sql);
10386        assert!(out.sql.contains(") AS \"_for_x\""), "{}", out.sql);
10387        assert!(out.sql.contains("CROSS JOIN LATERAL"), "{}", out.sql);
10388        assert!(out.sql.contains("\"t1\".\"name\" = \"_for_x\".\"v\""), "{}", out.sql);
10389    }
10390
10391    #[test]
10392    fn test_for_insert_over_a_derived_set_uses_it_as_the_cte() {
10393        let out = compile_and_emit("FOR x IN (select Person.name) UNION (INSERT Company { name := x })");
10394        assert!(out.sql.starts_with("WITH \"_for_x\" AS ("), "{}", out.sql);
10395        assert!(
10396            out.sql.contains("SELECT \"_for_x\".\"v\" FROM \"_for_x\""),
10397            "{}",
10398            out.sql
10399        );
10400    }
10401
10402    #[test]
10403    fn test_for_over_objects_binds_their_id() {
10404        let out = compile_and_emit("FOR p IN (select Person) UNION (INSERT Company { name := <str>p })");
10405        assert!(out.sql.contains("SELECT \"id\" AS v FROM ("), "{}", out.sql);
10406    }
10407
10408    #[test]
10409    fn test_for_over_a_non_select_statement_is_rejected() {
10410        let ast = parse::parse("FOR x IN (INSERT Company { name := 'a' }) UNION (SELECT Person { name })").unwrap();
10411        let schema = make_schema();
10412        let err = match ir::compile(&ast, &schema) {
10413            Ok(_) => panic!("expected a compile error"),
10414            Err(e) => format!("{e}"),
10415        };
10416        assert!(err.contains("only a select can be iterated"), "{err}");
10417    }
10418
10419    #[test]
10420    fn test_multi_link_path_outside_a_comparison_is_an_array() {
10421        // It stands for a set of values — as a scalar subquery Postgres
10422        // would reject it the moment a Person had two posts.
10423        let out = compile_and_emit("SELECT Person { t := .posts.title }");
10424        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"title\""), "{}", out.sql);
10425        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10426    }
10427
10428    #[test]
10429    fn test_multi_link_path_inside_a_comparison_is_still_exists() {
10430        let out = compile_and_emit("SELECT Person { name } filter .posts.title = 'x'");
10431        assert!(out.sql.contains("WHERE EXISTS("), "{}", out.sql);
10432        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10433    }
10434
10435    #[test]
10436    fn test_path_traversal_deeper_than_two_steps() {
10437        let schema = make_schema_with_through();
10438        let out = compile_and_emit_with("SELECT Person { t := .friends.friends.name }", &schema);
10439        // Two junction hops, then the property — one subquery, not an error.
10440        assert_eq!(out.sql.matches("\"public\".\"PersonFriend\"").count(), 2, "{}", out.sql);
10441        assert!(out.sql.contains("ARRAY(SELECT \"t4\".\"name\""), "{}", out.sql);
10442    }
10443
10444    #[test]
10445    fn test_coalesce_over_backlinked_values_is_a_set_choice() {
10446        // The backlink was taken for a comparison and rewritten to an EXISTS,
10447        // with a COALESCE of a value and an array inside it.
10448        let out = compile_and_emit("SELECT Post ORDER BY max(.<posts[is Person].age ?? .<posts[is Person].age)");
10449        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
10450        assert!(!out.sql.contains("COALESCE("), "{}", out.sql);
10451        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
10452        assert!(
10453            out.sql
10454                .contains("(SELECT coalesce(max(\"_s\".\"v\"), NULL) FROM unnest("),
10455            "{}",
10456            out.sql
10457        );
10458    }
10459
10460    #[test]
10461    fn test_coalesce_over_multilink_values_is_a_set_choice() {
10462        let out = compile_and_emit("SELECT Person { t := .posts.title ?? .posts.title }");
10463        assert!(!out.sql.contains("EXISTS"), "{}", out.sql);
10464        assert!(out.sql.contains("CASE WHEN (cardinality("), "{}", out.sql);
10465    }
10466
10467    #[test]
10468    fn test_coalesce_of_a_set_with_a_single_value_falls_back_to_it() {
10469        let out = compile_and_emit("SELECT Post { a := .<posts[is Person].age ?? 0 }");
10470        assert!(out.sql.contains("ELSE array_remove(ARRAY[0], NULL) END"), "{}", out.sql);
10471    }
10472
10473    #[test]
10474    fn test_traversal_after_a_backlink() {
10475        let out = compile_and_emit("SELECT Post { t := .<posts[is Person].name }");
10476        assert!(out.sql.contains("ARRAY(SELECT \"t2\".\"name\""), "{}", out.sql);
10477        assert!(out.sql.contains("\"t3\".\"target\" = \"t1\".\"id\""), "{}", out.sql);
10478    }
10479
10480    #[test]
10481    fn test_computed_pointer_on_a_linked_type_is_reachable() {
10482        use crate::schema::ComputedDescriptor;
10483        let mut schema = make_schema();
10484        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
10485        company.computed = vec![ComputedDescriptor {
10486            name: "shout".into(),
10487            expression: ".name ++ '!'".into(),
10488            return_type: Some("text".into()),
10489            link_target: None,
10490            link_multi: false,
10491        }];
10492        // `.company.shout` used to report "has no link or property 'shout'"
10493        // while helpfully suggesting 'shout' — path traversal never looked
10494        // at the target type's computed pointers.
10495        let out = compile_and_emit_with("SELECT Person { t := .company.shout }", &schema);
10496        assert!(out.sql.contains("(\"t2\".\"name\" || '!')"), "{}", out.sql);
10497    }
10498
10499    #[test]
10500    fn test_computed_pointer_behind_a_type_intersection() {
10501        let schema = make_interface_schema();
10502        let out = compile_and_emit_with("SELECT Account { n := [is Individual].full_name }", &schema);
10503        assert!(out.sql.contains("upper(\"t1\".\"first_name\")"), "{}", out.sql);
10504        assert!(out.sql.contains("FROM \"public\".\"Individual\""), "{}", out.sql);
10505        assert!(out.sql.contains("(\"t1\".\"id\" = \"t0\".\"id\")"), "{}", out.sql);
10506    }
10507
10508    #[test]
10509    fn test_computed_pointer_cannot_be_traversed_through() {
10510        use crate::schema::ComputedDescriptor;
10511        let mut schema = make_schema();
10512        let company = schema.types.iter_mut().find(|t| t.name == "Company").unwrap();
10513        company.computed = vec![ComputedDescriptor {
10514            name: "shout".into(),
10515            expression: ".name ++ '!'".into(),
10516            return_type: Some("text".into()),
10517            link_target: None,
10518            link_multi: false,
10519        }];
10520        let ast = parse::parse("SELECT Person { t := .company.shout.nope }").unwrap();
10521        let err = match ir::compile(&ast, &schema) {
10522            Ok(_) => panic!("expected a compile error"),
10523            Err(e) => format!("{e}"),
10524        };
10525        assert!(err.contains("is a computed pointer"), "{err}");
10526    }
10527
10528    #[test]
10529    fn test_sub_select_over_a_multi_link_path_keeps_its_modifiers() {
10530        let out = compile_and_emit("SELECT Person { t := (select .posts.title order by .title desc limit 2) }");
10531        assert!(out.sql.contains("ARRAY(SELECT"), "{}", out.sql);
10532        assert!(out.sql.contains("ORDER BY \"t2\".\"title\" DESC"), "{}", out.sql);
10533        assert!(out.sql.contains("LIMIT 2"), "{}", out.sql);
10534    }
10535
10536    #[test]
10537    fn test_limit_one_over_a_multi_link_path_stays_a_scalar() {
10538        // `limit 1` is what makes the set single-valued, so this one is a
10539        // scalar subquery rather than a one-element array.
10540        let out = compile_and_emit("SELECT Person { t := (select .posts.title limit 1) }");
10541        assert!(!out.sql.contains("ARRAY("), "{}", out.sql);
10542        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
10543    }
10544
10545    #[test]
10546    fn test_computed_multilink_can_carry_a_nested_shape() {
10547        let out = compile_and_emit("SELECT Person { p := .posts { title } }");
10548        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10549            panic!()
10550        };
10551        let ShapeNode::Array { name, element, .. } = &pointers[2] else {
10552            panic!("{:?}", pointers[2])
10553        };
10554        assert_eq!(name, "p");
10555        let ShapeNode::Object {
10556            pointers: elem_pointers,
10557            ..
10558        } = element.as_ref()
10559        else {
10560            panic!()
10561        };
10562        assert!(matches!(&elem_pointers[1], ShapeNode::Scalar { name, .. } if name == "title"));
10563    }
10564
10565    #[test]
10566    fn test_schema_declared_backlink_computed_is_an_object_pointer() {
10567        use crate::schema::ComputedDescriptor;
10568        let mut schema = make_schema();
10569        let post = schema.types.iter_mut().find(|t| t.name == "Post").unwrap();
10570        post.computed = vec![ComputedDescriptor {
10571            name: "authors".into(),
10572            expression: ".<posts[is Person]".into(),
10573            return_type: None,
10574            link_target: None,
10575            link_multi: false,
10576        }];
10577        // A backlink computed used to compile as an EXISTS boolean here —
10578        // the pointer builders were only reachable from an inline `:=`.
10579        let out = compile_and_emit_with("SELECT Post { authors { name } }", &schema);
10580        assert!(out.sql.contains("array_agg(ROW("), "{}", out.sql);
10581        assert!(out.sql.contains("\"t1\".\"name\"::text"), "{}", out.sql);
10582        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10583            panic!()
10584        };
10585        assert!(matches!(&pointers[1], ShapeNode::Array { name, .. } if name == "authors"));
10586    }
10587
10588    #[test]
10589    fn test_schema_declared_multilink_computed_defaults_to_ids_without_a_shape() {
10590        use crate::schema::ComputedDescriptor;
10591        let mut schema = make_schema();
10592        schema.types[0].computed = vec![ComputedDescriptor {
10593            name: "everything".into(),
10594            expression: ".posts".into(),
10595            return_type: None,
10596            link_target: None,
10597            link_multi: false,
10598        }];
10599        let out = compile_and_emit_with("SELECT Person { everything }", &schema);
10600        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10601            panic!()
10602        };
10603        assert!(matches!(&pointers[2], ShapeNode::Array { name, .. } if name == "everything"));
10604    }
10605
10606    #[test]
10607    fn test_schema_declared_computed_sub_select_is_a_link_pointer() {
10608        use crate::schema::ComputedDescriptor;
10609        let mut schema = make_schema();
10610        schema.types[0].computed = vec![
10611            ComputedDescriptor {
10612                name: "recent".into(),
10613                expression: "(select .posts order by .title desc limit 1)".into(),
10614                return_type: None,
10615                link_target: None,
10616                link_multi: false,
10617            },
10618            ComputedDescriptor {
10619                name: "recent_title".into(),
10620                expression: "(select .posts order by .title desc limit 1).title".into(),
10621                return_type: Some("text".into()),
10622                link_target: None,
10623                link_multi: false,
10624            },
10625        ];
10626        let out = compile_and_emit_with("SELECT Person { recent { title }, recent_title }", &schema);
10627        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
10628            panic!()
10629        };
10630        // A declared computed gets the same treatment as one written inline:
10631        // object-valued when it selects a link, scalar when it projects one.
10632        assert!(matches!(&pointers[2], ShapeNode::Object { name, .. } if name == "recent"));
10633        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, .. } if name == "recent_title"));
10634        assert!(out.sql.contains("\"jt\".source = \"t0\".id"), "{}", out.sql);
10635        assert_eq!(out.sql.matches("LIMIT 1").count(), 2, "{}", out.sql);
10636        crate::validate::validate_schema_types(&schema).expect("schema should validate");
10637    }
10638
10639    /// `select (insert T { … }).id` — the mutation is hoisted into the
10640    /// statement's own WITH and the field read off it, which is what the
10641    /// `with x := (insert …) select x.id` spelling already produced. Without
10642    /// the CTE the projection would read the whole table instead.
10643    #[test]
10644    fn test_a_mutation_read_through_a_path_runs_as_a_hoisted_cte() {
10645        let out = compile_and_emit("SELECT (INSERT Person { name := 'x' }).name");
10646        assert!(
10647            out.sql.contains("\"_nested_dml_0\" AS (") && out.sql.contains("INSERT INTO"),
10648            "the mutation must run as a CTE:\n{}",
10649            out.sql
10650        );
10651        assert!(
10652            out.sql.contains("FROM \"_nested_dml_0\""),
10653            "the projection must read the mutation's own rows:\n{}",
10654            out.sql
10655        );
10656    }
10657
10658    #[test]
10659    fn test_dml_sub_statement_in_expression_position_still_rejected() {
10660        let err = compile_err("SELECT Person { x := (insert Company { name := 'c' }).name }");
10661        assert!(err.contains("an insert cannot stand in for a value"), "{err}");
10662    }
10663
10664    #[test]
10665    fn test_sub_select_with_a_shape_in_expression_position_is_rejected() {
10666        // Only when the shape *is* the result — projecting a property off
10667        // it is fine, since the shape then says nothing the projection
10668        // doesn't (see test_sub_select_with_a_shape_may_project_a_property_off_it).
10669        let err = compile_err("SELECT Person { name } filter (select .posts { title }) = 1");
10670        assert!(err.contains("sub-select with a shape"), "{err}");
10671    }
10672
10673    fn make_schema_with_through() -> SchemaDescriptor {
10674        let id_prop = || PropertyDescriptor {
10675            name: "id".into(),
10676            pg_type: "uuid".into(),
10677            nullable: false,
10678            default_sql: Some("gen_random_uuid()".into()),
10679            description: None,
10680            default_pyql: None,
10681            check_constraints: vec![],
10682            is_exclusive: true,
10683            is_pk: true,
10684            is_readonly: true,
10685            rewrites: vec![],
10686            tuple_members: None,
10687            column_type: None,
10688        };
10689        let name_prop = || PropertyDescriptor {
10690            name: "name".into(),
10691            pg_type: "text".into(),
10692            nullable: false,
10693            default_sql: None,
10694            description: None,
10695            check_constraints: vec![],
10696            default_pyql: None,
10697            is_exclusive: false,
10698            is_pk: false,
10699            is_readonly: false,
10700            rewrites: vec![],
10701            tuple_members: None,
10702            column_type: None,
10703        };
10704        SchemaDescriptor {
10705            types: vec![
10706                TypeDescriptor {
10707                    name: "Person".into(),
10708                    module: "default".into(),
10709                    table: "Person".into(),
10710                    abstract_: false,
10711                    materialized: false,
10712                    description: None,
10713                    parents: vec![],
10714                    interfaces: vec![],
10715                    bases: vec![],
10716                    properties: vec![id_prop(), name_prop()],
10717                    links: vec![],
10718                    multilinks: vec![MultiLinkDescriptor {
10719                        name: "friends".into(),
10720                        target: "default::Person".into(),
10721                        through: Some("default::PersonFriend".into()),
10722                        nullable: false,
10723                        description: None,
10724                        default_pyql: None,
10725                        on_delete: vec![],
10726                        is_exclusive: false,
10727                    }],
10728                    computed: vec![],
10729                    constraints: vec![],
10730                    indexes: vec![],
10731                    partition: None,
10732                    vector_indexes: vec![],
10733                    search_indexes: vec![],
10734                    triggers: vec![],
10735                    junction: false,
10736                    signals: vec![],
10737                },
10738                TypeDescriptor {
10739                    name: "PersonFriend".into(),
10740                    module: "default".into(),
10741                    table: "PersonFriend".into(),
10742                    abstract_: false,
10743                    materialized: false,
10744                    description: None,
10745                    parents: vec![],
10746                    interfaces: vec![],
10747                    bases: vec![],
10748                    properties: vec![id_prop()],
10749                    links: vec![
10750                        LinkDescriptor {
10751                            name: "person".into(),
10752                            target: "default::Person".into(),
10753                            nullable: false,
10754                            through: None,
10755                            description: None,
10756                            default_pyql: None,
10757                            is_exclusive: false,
10758                            is_readonly: false,
10759                            rewrites: vec![],
10760                            on_delete: vec![],
10761                        },
10762                        LinkDescriptor {
10763                            name: "friend".into(),
10764                            target: "default::Person".into(),
10765                            nullable: false,
10766                            through: None,
10767                            description: None,
10768                            default_pyql: None,
10769                            is_exclusive: false,
10770                            is_readonly: false,
10771                            rewrites: vec![],
10772                            on_delete: vec![],
10773                        },
10774                    ],
10775                    multilinks: vec![],
10776                    computed: vec![],
10777                    constraints: vec![],
10778                    indexes: vec![],
10779                    partition: None,
10780                    vector_indexes: vec![],
10781                    search_indexes: vec![],
10782                    triggers: vec![],
10783                    junction: false,
10784                    signals: vec![],
10785                },
10786            ],
10787            scalars: vec![],
10788            enums: vec![],
10789            named_tuples: vec![],
10790            globals: vec![],
10791            functions: vec![],
10792            aliases: vec![],
10793            channels: vec![],
10794            ..Default::default()
10795        }
10796    }
10797
10798    #[test]
10799    fn test_select_through_multi_link() {
10800        let schema = make_schema_with_through();
10801        let ast = crate::parse::parse("SELECT Person { name, friends { name } }").unwrap();
10802        let ir = crate::ir::compile(&ast, &schema).unwrap();
10803        let out = emit(&ir);
10804        // Junction table is the PersonFriend table, not the standard dotted name
10805        assert!(out.sql.contains("\"public\".\"PersonFriend\""));
10806        // Source FK column (person → Person) and target FK column (friend → Person)
10807        assert!(out.sql.contains("\"friend\""));
10808        assert!(out.sql.contains("\"person\""));
10809        // Still emits array_agg pattern
10810        assert!(out.sql.contains("array_agg(ROW("));
10811    }
10812
10813    /// Person/Org — a junction-backed single link (`Person.spouse: Link[Org,
10814    /// through(Marriage)]`, exclusive). Deliberately targets a different
10815    /// type (Org) rather than a self-link, for the same reason
10816    /// `test_select_through_multi_link` avoids Person-to-Person — see the
10817    /// comment on `make_schema_with_through` above.
10818    #[test]
10819    fn test_limited_multi_link_keeps_a_nested_multi_link_in_scope() {
10820        // The nested pointer's own correlated subquery moves inside the
10821        // derived table the LIMIT sits on, where its alias is still bound.
10822        let schema = make_schema_with_through();
10823        let ast = crate::parse::parse("SELECT Person { friends: { name, friends { name } } limit 1 }").unwrap();
10824        let ir = crate::ir::compile(&ast, &schema).unwrap();
10825        let out = emit(&ir);
10826        assert_eq!(out.sql.matches("array_agg(ROW(").count(), 2, "{}", out.sql);
10827        let derived = out.sql.find("AS \"__lim\"").unwrap();
10828        let nested = out.sql.rfind("array_agg(ROW(").unwrap();
10829        assert!(
10830            nested < derived,
10831            "nested aggregate must sit inside the derived table:\n{}",
10832            out.sql
10833        );
10834    }
10835
10836    fn make_schema_with_junction_backed_link() -> SchemaDescriptor {
10837        let id_prop = || PropertyDescriptor {
10838            name: "id".into(),
10839            pg_type: "uuid".into(),
10840            nullable: false,
10841            default_sql: Some("gen_random_uuid()".into()),
10842            description: None,
10843            default_pyql: None,
10844            check_constraints: vec![],
10845            is_exclusive: true,
10846            is_pk: true,
10847            is_readonly: true,
10848            rewrites: vec![],
10849            tuple_members: None,
10850            column_type: None,
10851        };
10852        let name_prop = || PropertyDescriptor {
10853            name: "name".into(),
10854            pg_type: "text".into(),
10855            nullable: false,
10856            default_sql: None,
10857            description: None,
10858            check_constraints: vec![],
10859            default_pyql: None,
10860            is_exclusive: false,
10861            is_pk: false,
10862            is_readonly: false,
10863            rewrites: vec![],
10864            tuple_members: None,
10865            column_type: None,
10866        };
10867        SchemaDescriptor {
10868            types: vec![
10869                TypeDescriptor {
10870                    name: "Person".into(),
10871                    module: "default".into(),
10872                    table: "Person".into(),
10873                    abstract_: false,
10874                    materialized: false,
10875                    description: None,
10876                    parents: vec![],
10877                    interfaces: vec![],
10878                    bases: vec![],
10879                    properties: vec![id_prop(), name_prop()],
10880                    links: vec![LinkDescriptor {
10881                        name: "spouse".into(),
10882                        target: "default::Org".into(),
10883                        nullable: true,
10884                        through: Some("default::Marriage".into()),
10885                        description: None,
10886                        default_pyql: None,
10887                        is_exclusive: true,
10888                        is_readonly: false,
10889                        rewrites: vec![],
10890                        on_delete: vec![],
10891                    }],
10892                    multilinks: vec![],
10893                    computed: vec![],
10894                    constraints: vec![],
10895                    indexes: vec![],
10896                    partition: None,
10897                    vector_indexes: vec![],
10898                    search_indexes: vec![],
10899                    triggers: vec![],
10900                    junction: false,
10901                    signals: vec![],
10902                },
10903                TypeDescriptor {
10904                    name: "Org".into(),
10905                    module: "default".into(),
10906                    table: "Org".into(),
10907                    abstract_: false,
10908                    materialized: false,
10909                    description: None,
10910                    parents: vec![],
10911                    interfaces: vec![],
10912                    bases: vec![],
10913                    properties: vec![id_prop(), name_prop()],
10914                    links: vec![],
10915                    multilinks: vec![],
10916                    computed: vec![],
10917                    constraints: vec![],
10918                    indexes: vec![],
10919                    partition: None,
10920                    vector_indexes: vec![],
10921                    search_indexes: vec![],
10922                    triggers: vec![],
10923                    junction: false,
10924                    signals: vec![],
10925                },
10926                TypeDescriptor {
10927                    name: "Marriage".into(),
10928                    module: "default".into(),
10929                    table: "Person.spouse".into(),
10930                    abstract_: false,
10931                    materialized: false,
10932                    description: None,
10933                    parents: vec![],
10934                    interfaces: vec![],
10935                    bases: vec![],
10936                    properties: vec![
10937                        id_prop(),
10938                        PropertyDescriptor {
10939                            name: "since".into(),
10940                            pg_type: "int8".into(),
10941                            nullable: true,
10942                            default_sql: None,
10943                            description: None,
10944                            check_constraints: vec![],
10945                            default_pyql: None,
10946                            is_exclusive: false,
10947                            is_pk: false,
10948                            is_readonly: false,
10949                            rewrites: vec![],
10950                            tuple_members: None,
10951                            column_type: None,
10952                        },
10953                    ],
10954                    links: vec![
10955                        LinkDescriptor {
10956                            name: "source".into(),
10957                            target: "default::Person".into(),
10958                            nullable: false,
10959                            through: None,
10960                            description: None,
10961                            default_pyql: None,
10962                            is_exclusive: false,
10963                            is_readonly: false,
10964                            rewrites: vec![],
10965                            on_delete: vec![],
10966                        },
10967                        LinkDescriptor {
10968                            name: "target".into(),
10969                            target: "default::Org".into(),
10970                            nullable: false,
10971                            through: None,
10972                            description: None,
10973                            default_pyql: None,
10974                            is_exclusive: false,
10975                            is_readonly: false,
10976                            rewrites: vec![],
10977                            on_delete: vec![],
10978                        },
10979                    ],
10980                    multilinks: vec![],
10981                    computed: vec![],
10982                    constraints: vec![],
10983                    indexes: vec![],
10984                    partition: None,
10985                    vector_indexes: vec![],
10986                    search_indexes: vec![],
10987                    triggers: vec![],
10988                    junction: true,
10989                    signals: vec![],
10990                },
10991            ],
10992            scalars: vec![],
10993            enums: vec![],
10994            named_tuples: vec![],
10995            globals: vec![],
10996            functions: vec![],
10997            aliases: vec![],
10998            channels: vec![],
10999            ..Default::default()
11000        }
11001    }
11002
11003    #[test]
11004    fn test_select_shape_over_junction_backed_single_link() {
11005        let schema = make_schema_with_junction_backed_link();
11006        let ast = crate::parse::parse("SELECT Person { name, spouse { name } }").unwrap();
11007        let ir = crate::ir::compile(&ast, &schema).unwrap();
11008        let out = emit(&ir);
11009        // Junction table is the through type's own table, joined like a multi-link's.
11010        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11011        assert!(out.sql.contains("\"jt\".source"), "got:\n{}", out.sql);
11012        assert!(out.sql.contains("\"jt\".target"), "got:\n{}", out.sql);
11013        // Cardinality-one: a scalar correlated subquery, never array_agg.
11014        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
11015        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11016    }
11017
11018    #[test]
11019    fn test_select_shape_over_junction_backed_single_link_with_link_property() {
11020        // A junction-backed single link can read its own link properties
11021        // (`@since`) in a nested shape, same as a multi-link's `@weight` —
11022        // this is the one read site with no reusable FK-based form
11023        // (`IrSingleLinkPointer` needed its own `link_properties`, mirroring
11024        // `IrMultiLinkPointer`'s).
11025        let schema = make_schema_with_junction_backed_link();
11026        let ast = crate::parse::parse("SELECT Person { name, spouse { name, @since } }").unwrap();
11027        let ir = crate::ir::compile(&ast, &schema).unwrap();
11028        let out = emit(&ir);
11029        assert!(out.sql.contains("\"jt\".\"since\""), "got:\n{}", out.sql);
11030        assert!(!out.sql.contains("array_agg"), "got:\n{}", out.sql);
11031    }
11032
11033    #[test]
11034    fn test_select_path_over_junction_backed_single_link() {
11035        let schema = make_schema_with_junction_backed_link();
11036        let ast = crate::parse::parse("SELECT Person.spouse { name }").unwrap();
11037        let ir = crate::ir::compile(&ast, &schema).unwrap();
11038        let out = emit(&ir);
11039        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11040        assert!(out.sql.contains("\"source\""), "got:\n{}", out.sql);
11041        assert!(out.sql.contains("\"target\""), "got:\n{}", out.sql);
11042        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11043    }
11044
11045    #[test]
11046    fn test_insert_with_junction_backed_single_link() {
11047        let schema = make_schema_with_junction_backed_link();
11048        let out = compile_and_emit_with(
11049            "INSERT Person { name := $name, \
11050                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
11051            &schema,
11052        );
11053        assert!(out.sql.contains("\"public\".\"Person.spouse\""), "got:\n{}", out.sql);
11054        assert!(out.sql.contains("\"since\""), "got:\n{}", out.sql);
11055        assert!(!out.sql.contains("spouse_id"), "got:\n{}", out.sql);
11056        // A fresh row has no prior junction entry, but the emitted ON
11057        // CONFLICT target must still name only `source` — no `(source,
11058        // target)` composite unique constraint exists on this table (D3).
11059        // The conflict action is always an upsert (not DO NOTHING) since a
11060        // same-statement reassignment's paired DELETE shares this
11061        // statement's snapshot and can still "conflict" with the old row.
11062        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
11063    }
11064
11065    #[test]
11066    fn test_update_replace_junction_backed_single_link() {
11067        let schema = make_schema_with_junction_backed_link();
11068        let out = compile_and_emit_with(
11069            "UPDATE Person FILTER .id = $id SET { \
11070                spouse := (SELECT Org FILTER .id = $oid) { @since := <int64>$since } }",
11071            &schema,
11072        );
11073        // Replace = clear the existing junction row, then insert the new one.
11074        assert!(
11075            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11076            "got:\n{}",
11077            out.sql
11078        );
11079        assert!(
11080            out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11081            "got:\n{}",
11082            out.sql
11083        );
11084        assert!(out.sql.contains("ON CONFLICT (\"source\") DO UPDATE SET \"target\" = EXCLUDED.\"target\", \"since\" = EXCLUDED.\"since\""), "got:\n{}", out.sql);
11085        // The paired DELETE must not race the new INSERT for a target that's
11086        // being kept, but for a single link (PK is `source` alone) ANY
11087        // existing row conflicts regardless of target, so the upsert above
11088        // is what actually makes reassignment work, not the exclusion here.
11089        assert!(out.sql.contains("NOT IN"), "got:\n{}", out.sql);
11090    }
11091
11092    /// `unless conflict on .k else (update T filter … set { … })` — the ON
11093    /// CONFLICT target says which row conflicts, the filter says whether to
11094    /// touch it. Dropping the filter turned automator's advisory lock into an
11095    /// unconditional steal of a live holder's row.
11096    #[test]
11097    fn test_unless_conflict_else_update_keeps_its_filter() {
11098        let out = compile_and_emit(
11099            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name \
11100             ELSE (UPDATE Person FILTER .age < 30 SET { name := 'b' })",
11101        );
11102        assert!(
11103            out.sql.contains("DO UPDATE SET") && out.sql.contains("WHERE"),
11104            "the ELSE UPDATE's filter must survive as a DO UPDATE predicate:\n{}",
11105            out.sql
11106        );
11107        assert!(
11108            out.sql.contains("\"Person\".\"age\""),
11109            "the predicate must read the existing row, not `excluded`:\n{}",
11110            out.sql
11111        );
11112    }
11113
11114    /// No filter, no predicate — an unconditional ELSE UPDATE still updates.
11115    #[test]
11116    fn test_unless_conflict_else_update_without_a_filter_has_no_predicate() {
11117        let out = compile_and_emit(
11118            "INSERT Person { name := 'a' } UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { name := 'b' })",
11119        );
11120        let after_set = out.sql.split("DO UPDATE SET").nth(1).unwrap_or_default();
11121        assert!(
11122            !after_set.contains("WHERE"),
11123            "got a predicate we never asked for:\n{}",
11124            out.sql
11125        );
11126    }
11127
11128    #[test]
11129    fn test_junction_backed_single_link_rejected_in_unless_conflict_else() {
11130        // `compile_conflict_else` calls `compile_assignments_for_update`
11131        // directly on the raw ELSE-clause shape, bypassing the
11132        // shape-classification loop `compile_insert`/`compile_update` use to
11133        // intercept a junction-backed link before it reaches the plain
11134        // FK-column assignment path — must fail loudly here instead of
11135        // emitting a `spouse_id` column that doesn't exist.
11136        let schema = make_schema_with_junction_backed_link();
11137        let ast = parse::parse(
11138            "INSERT Person { name := $name } \
11139             UNLESS CONFLICT ON .name ELSE (UPDATE Person SET { \
11140                spouse := (SELECT Org FILTER .id = $oid) })",
11141        )
11142        .unwrap();
11143        assert!(ir::compile(&ast, &schema).is_err());
11144    }
11145
11146    #[test]
11147    fn test_update_clear_junction_backed_single_link() {
11148        let schema = make_schema_with_junction_backed_link();
11149        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := {} }", &schema);
11150        assert!(
11151            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11152            "got:\n{}",
11153            out.sql
11154        );
11155        assert!(
11156            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11157            "clearing must not also insert:\n{}",
11158            out.sql
11159        );
11160    }
11161
11162    #[test]
11163    fn test_update_clear_junction_backed_single_link_with_cast_empty_set() {
11164        // Regression: the frontend (generateStatements.ts's `setNull`
11165        // handling) always emits the cast form `<Type>{}`, never the bare
11166        // `{}` — the junction-backed clear path originally only recognized
11167        // bare `Expr::Set([])`, so this form fell through to
11168        // `compile_multilink_values` and errored with "multilink value must
11169        // be a CTE reference, parenthesised subquery, or type path" instead
11170        // of clearing (confirmed live via the Data Explorer).
11171        let schema = make_schema_with_junction_backed_link();
11172        let out = compile_and_emit_with("UPDATE Person FILTER .id = $id SET { spouse := <Org>{} }", &schema);
11173        assert!(
11174            out.sql.contains("DELETE FROM \"public\".\"Person.spouse\""),
11175            "got:\n{}",
11176            out.sql
11177        );
11178        assert!(
11179            !out.sql.contains("INSERT INTO \"public\".\"Person.spouse\""),
11180            "clearing must not also insert:\n{}",
11181            out.sql
11182        );
11183    }
11184
11185    /// Product/Tag/ProductTag — the actual real-world shape link properties
11186    /// were built for (mirrors pylon-demo). Deliberately *not* built off
11187    /// `make_schema_with_through` (Person self-linking to Person): a
11188    /// same-type through-link hits a pre-existing, unrelated column-
11189    /// resolution bug in `multilink_junction_info` (both source_col and
11190    /// target_col resolve to the same link name when source type == target
11191    /// type) — tracked separately, not fixed here.
11192    fn make_schema_with_through_and_prop() -> SchemaDescriptor {
11193        let id_prop = || PropertyDescriptor {
11194            name: "id".into(),
11195            pg_type: "uuid".into(),
11196            nullable: false,
11197            default_sql: Some("gen_random_uuid()".into()),
11198            description: None,
11199            default_pyql: None,
11200            check_constraints: vec![],
11201            is_exclusive: true,
11202            is_pk: true,
11203            is_readonly: true,
11204            rewrites: vec![],
11205            tuple_members: None,
11206            column_type: None,
11207        };
11208        let name_prop = || PropertyDescriptor {
11209            name: "name".into(),
11210            pg_type: "text".into(),
11211            nullable: false,
11212            default_sql: None,
11213            description: None,
11214            check_constraints: vec![],
11215            default_pyql: None,
11216            is_exclusive: false,
11217            is_pk: false,
11218            is_readonly: false,
11219            rewrites: vec![],
11220            tuple_members: None,
11221            column_type: None,
11222        };
11223        SchemaDescriptor {
11224            types: vec![
11225                TypeDescriptor {
11226                    name: "Product".into(),
11227                    module: "default".into(),
11228                    table: "Product".into(),
11229                    abstract_: false,
11230                    materialized: false,
11231                    description: None,
11232                    parents: vec![],
11233                    interfaces: vec![],
11234                    bases: vec![],
11235                    properties: vec![id_prop(), name_prop()],
11236                    links: vec![],
11237                    multilinks: vec![MultiLinkDescriptor {
11238                        name: "tags".into(),
11239                        target: "default::Tag".into(),
11240                        through: Some("default::ProductTag".into()),
11241                        nullable: false,
11242                        description: None,
11243                        default_pyql: None,
11244                        on_delete: vec![],
11245                        is_exclusive: false,
11246                    }],
11247                    computed: vec![],
11248                    constraints: vec![],
11249                    indexes: vec![],
11250                    partition: None,
11251                    vector_indexes: vec![],
11252                    search_indexes: vec![],
11253                    triggers: vec![],
11254                    junction: false,
11255                    signals: vec![],
11256                },
11257                TypeDescriptor {
11258                    name: "Tag".into(),
11259                    module: "default".into(),
11260                    table: "Tag".into(),
11261                    abstract_: false,
11262                    materialized: false,
11263                    description: None,
11264                    parents: vec![],
11265                    interfaces: vec![],
11266                    bases: vec![],
11267                    properties: vec![id_prop(), name_prop()],
11268                    links: vec![],
11269                    multilinks: vec![],
11270                    computed: vec![],
11271                    constraints: vec![],
11272                    indexes: vec![],
11273                    partition: None,
11274                    vector_indexes: vec![],
11275                    search_indexes: vec![],
11276                    triggers: vec![],
11277                    junction: false,
11278                    signals: vec![],
11279                },
11280                TypeDescriptor {
11281                    name: "ProductTag".into(),
11282                    module: "default".into(),
11283                    table: "Product.tags".into(),
11284                    abstract_: false,
11285                    materialized: false,
11286                    description: None,
11287                    parents: vec![],
11288                    interfaces: vec![],
11289                    bases: vec![],
11290                    properties: vec![
11291                        id_prop(),
11292                        PropertyDescriptor {
11293                            name: "weight".into(),
11294                            pg_type: "float8".into(),
11295                            nullable: false,
11296                            default_sql: None,
11297                            default_pyql: None,
11298                            description: None,
11299                            check_constraints: vec![],
11300                            is_exclusive: false,
11301                            is_pk: false,
11302                            is_readonly: false,
11303                            rewrites: vec![],
11304                            tuple_members: None,
11305                            column_type: None,
11306                        },
11307                    ],
11308                    // No declared Link pointers — matches pylon-demo's actual
11309                    // ProductTag, which relies on multilink_junction_info's
11310                    // "source"/"target" defaults.
11311                    links: vec![],
11312                    multilinks: vec![],
11313                    computed: vec![],
11314                    constraints: vec![],
11315                    indexes: vec![],
11316                    partition: None,
11317                    vector_indexes: vec![],
11318                    search_indexes: vec![],
11319                    triggers: vec![],
11320                    junction: true,
11321                    signals: vec![],
11322                },
11323            ],
11324            scalars: vec![],
11325            enums: vec![],
11326            named_tuples: vec![],
11327            globals: vec![],
11328            functions: vec![],
11329            aliases: vec![],
11330            channels: vec![],
11331            ..Default::default()
11332        }
11333    }
11334
11335    #[test]
11336    fn test_link_property_in_a_links_own_filter() {
11337        // `filter @weight > x` reads the junction row of the link being
11338        // filtered — `@prop` used to parse only as a postfix on a named
11339        // path, so a bare one was "expected an expression, found '@'".
11340        let schema = make_schema_with_through_and_prop();
11341        let out = compile_and_emit_with("SELECT Product { tags: { } filter @weight > 0.5 }", &schema);
11342        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11343    }
11344
11345    #[test]
11346    fn test_exists_on_a_link_property() {
11347        // `exists @weight` — the junction column the link's own modifiers are
11348        // compiled against. `exists` only knew how to take a pointer name.
11349        let schema = make_schema_with_through_and_prop();
11350        let out = compile_and_emit_with("SELECT Product { tags: { } filter exists @weight }", &schema);
11351        assert!(out.sql.contains("\"jt\".\"weight\" IS NOT NULL"), "{}", out.sql);
11352    }
11353
11354    #[test]
11355    fn test_link_property_in_a_computed_link_filter() {
11356        let schema = make_schema_with_through_and_prop();
11357        let out = compile_and_emit_with(
11358            "SELECT Product { best := (select .tags filter @weight > 0.5 limit 1) { @weight } }",
11359            &schema,
11360        );
11361        assert!(out.sql.contains("\"jt\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11362        assert!(out.sql.contains("LIMIT 1"), "{}", out.sql);
11363    }
11364
11365    #[test]
11366    fn test_link_property_in_a_projected_sub_select_reads_the_junction() {
11367        // The flat-join form names the junction with an ordinary alias
11368        // rather than "jt", so the scope has to carry the alias too.
11369        let schema = make_schema_with_through_and_prop();
11370        let out = compile_and_emit_with(
11371            "SELECT Product { t := (select .tags filter @weight > 0.5 limit 1).id }",
11372            &schema,
11373        );
11374        assert!(out.sql.contains("\"t3\".\"weight\" > (0.5::float8)"), "{}", out.sql);
11375    }
11376
11377    #[test]
11378    fn test_unknown_link_property_names_the_through_type() {
11379        let schema = make_schema_with_through_and_prop();
11380        let ast = parse::parse("SELECT Product { tags: { } filter @nope > 0.5 }").unwrap();
11381        let err = match ir::compile(&ast, &schema) {
11382            Ok(_) => panic!("expected a compile error"),
11383            Err(e) => format!("{e}"),
11384        };
11385        assert!(err.contains("default::ProductTag") && err.contains("nope"), "{err}");
11386    }
11387
11388    #[test]
11389    fn test_link_property_outside_a_link_says_where_it_belongs() {
11390        let schema = make_schema_with_through_and_prop();
11391        let ast = parse::parse("SELECT Product { t := @weight }").unwrap();
11392        let err = match ir::compile(&ast, &schema) {
11393            Ok(_) => panic!("expected a compile error"),
11394            Err(e) => format!("{e}"),
11395        };
11396        assert!(err.contains("is a link property"), "{err}");
11397    }
11398
11399    #[test]
11400    fn test_multilink_append_with_link_property() {
11401        let schema = make_schema_with_through_and_prop();
11402        let out = compile_and_emit_with(
11403            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11404            &schema,
11405        );
11406        // Extra junction column present in both the INSERT column list and the SELECT list.
11407        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
11408        // Re-linking an existing pair with a new weight must update in place,
11409        // not silently keep the old value (a bare `DO NOTHING` would).
11410        assert!(
11411            out.sql
11412                .contains("ON CONFLICT (\"source\", \"target\") DO UPDATE SET \"weight\" = EXCLUDED.\"weight\""),
11413            "missing upsert conflict clause:\n{}",
11414            out.sql
11415        );
11416    }
11417
11418    #[test]
11419    fn test_multilink_append_union_with_different_link_property_values() {
11420        // The realistic Data Explorer scenario: multiple checked targets in
11421        // one `+=`, each with its own distinct property value — expressed as
11422        // a `union` of individually-shaped target selects.
11423        let schema = make_schema_with_through_and_prop();
11424        let out = compile_and_emit_with(
11425            "UPDATE Product FILTER .id = $id SET { \
11426                tags += (SELECT Tag FILTER .id = $aid) { @weight := <float64>$w1 } \
11427                    union (SELECT Tag FILTER .id = $bid) { @weight := <float64>$w2 } \
11428            }",
11429            &schema,
11430        );
11431        assert!(
11432            out.sql.contains("UNION ALL"),
11433            "expected a UNION ALL between the two shaped targets:\n{}",
11434            out.sql
11435        );
11436        // Both branches must select the same "weight" column (with their own
11437        // value) so the union has a consistent column list.
11438        assert_eq!(
11439            out.sql.matches("AS \"weight\"").count(),
11440            2,
11441            "each union branch must project its own weight:\n{}",
11442            out.sql
11443        );
11444    }
11445
11446    #[test]
11447    fn test_multilink_append_without_link_property_keeps_do_nothing() {
11448        // No `@prop := ...` anywhere — must keep the original DO NOTHING
11449        // behavior (no spurious upsert/extra columns for the common case).
11450        let schema = make_schema_with_through_and_prop();
11451        let out = compile_and_emit_with(
11452            "UPDATE Product FILTER .id = $id SET { tags += (SELECT Tag FILTER .id = $tid) }",
11453            &schema,
11454        );
11455        assert!(
11456            out.sql.contains("ON CONFLICT DO NOTHING"),
11457            "expected plain DO NOTHING when no link properties are set:\n{}",
11458            out.sql
11459        );
11460        assert!(
11461            !out.sql.contains("\"weight\""),
11462            "unexpected weight column with no link property assignment:\n{}",
11463            out.sql
11464        );
11465    }
11466
11467    #[test]
11468    fn test_multilink_link_property_rejected_on_standard_junction() {
11469        // Person.posts is a Standard (implicit) junction — no user-declared
11470        // properties, so `@prop := ...` must be rejected at compile time.
11471        let ast = crate::parse::parse(
11472            "UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $t) { @weight := <float64>$w } }",
11473        ).unwrap();
11474        match crate::ir::compile(&ast, &make_schema()) {
11475            Ok(_) => panic!("expected a compile error for link property on a Standard junction"),
11476            Err(e) => assert!(
11477                e.to_string().contains("Through"),
11478                "expected a Through[...]-related error, got: {e}"
11479            ),
11480        }
11481    }
11482
11483    #[test]
11484    fn test_multilink_link_property_rejected_on_remove() {
11485        let schema = make_schema_with_through_and_prop();
11486        let ast = crate::parse::parse(
11487            "UPDATE Product FILTER .id = $id SET { tags -= (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11488        )
11489        .unwrap();
11490        match crate::ir::compile(&ast, &schema) {
11491            Ok(_) => panic!("expected a compile error for link property on a remove (-=)"),
11492            Err(e) => assert!(
11493                e.to_string().contains("removing"),
11494                "expected a remove-related error, got: {e}"
11495            ),
11496        }
11497    }
11498
11499    #[test]
11500    fn test_insert_with_multilink_assignment() {
11501        // Regression test: `insert Product { tags := ... }` used to fail with
11502        // "object type 'default::Product' has no link or property 'tags'" —
11503        // compile_assignments_inner never checked resolve_multilink, and
11504        // IrInsert had no multi-link handling at all.
11505        let schema = make_schema_with_through_and_prop();
11506        let out = compile_and_emit_with(
11507            "INSERT Product { name := $name, tags := (SELECT Tag FILTER .id = $tid) { @weight := <float64>$w } }",
11508            &schema,
11509        );
11510        // The row insert must happen before the junction insert references its id.
11511        assert!(
11512            out.sql.contains("\"_w__ids\" AS (\nINSERT INTO"),
11513            "missing row-insert CTE:\n{}",
11514            out.sql
11515        );
11516        assert!(
11517            out.sql.contains("\"_w__ml_add_0\" AS ("),
11518            "missing junction-append CTE:\n{}",
11519            out.sql
11520        );
11521        assert!(out.sql.contains("\"weight\""), "missing weight column:\n{}", out.sql);
11522        assert!(
11523            out.sql.contains("\"_w\" AS (\n    SELECT * FROM \"_w__ids\"\n)"),
11524            "missing _w passthrough:\n{}",
11525            out.sql
11526        );
11527        assert_eq!(
11528            out.sql.matches("WITH\n").count(),
11529            1,
11530            "must be a single flat top-level WITH block:\n{}",
11531            out.sql
11532        );
11533    }
11534
11535    /// A user `with` binding *consumed by* a statement that builds CTEs of
11536    /// its own. The binding is emitted by the generic `ir.ctes` path while
11537    /// the multi-link/junction machinery has already opened its own `WITH`;
11538    /// prefixing a second one produced `WITH ... WITH ...`, which compiles
11539    /// cleanly and then fails at execution with `syntax error at or near
11540    /// "WITH"`. Covers both link kinds, since each reaches the junction
11541    /// emitter by a different route.
11542    #[test]
11543    fn test_user_with_binding_merges_into_a_statements_own_ctes() {
11544        let schema = make_schema_with_through_and_prop();
11545        for query in [
11546            "with t := (select Tag filter .id = $tid) insert Product { name := $name, tags := t }",
11547            "with t := (select Tag filter .id = $tid) \
11548             update Product filter .name = $name set { tags += t }",
11549        ] {
11550            let out = compile_and_emit_with(query, &schema);
11551            let flat: String = out.sql.split_whitespace().collect::<Vec<_>>().join(" ");
11552            assert!(
11553                !flat.contains(") WITH "),
11554                "a second WITH clause is invalid SQL:\n{}",
11555                out.sql
11556            );
11557            assert_eq!(
11558                out.sql.matches("WITH").count(),
11559                1,
11560                "exactly one WITH keyword expected:\n{}",
11561                out.sql
11562            );
11563        }
11564    }
11565
11566    #[test]
11567    fn test_with_bound_insert_with_multilink_assignment() {
11568        // Same as above, but bound via WITH (the Data Explorer's actual
11569        // shape) — exercises emit_user_cte_parts's insert_has_any_multilink
11570        // branch instead of emit_insert_stmt's bare-statement path.
11571        let schema = make_schema_with_through_and_prop();
11572        let out = compile_and_emit_with(
11573            "with insert0 := (insert Product { name := $name, tags := (select Tag filter .id = $tid) }) select insert0",
11574            &schema,
11575        );
11576        assert!(
11577            out.sql.contains("\"insert0__ids\" AS (\nINSERT INTO"),
11578            "missing row-insert CTE:\n{}",
11579            out.sql
11580        );
11581        assert!(
11582            out.sql.contains("\"insert0__ml_add_0\" AS ("),
11583            "missing junction-append CTE:\n{}",
11584            out.sql
11585        );
11586        assert!(
11587            out.sql
11588                .contains("\"insert0\" AS (\n    SELECT * FROM \"insert0__ids\"\n)"),
11589            "missing insert0 passthrough:\n{}",
11590            out.sql
11591        );
11592        assert_eq!(
11593            out.sql.matches("WITH\n").count(),
11594            1,
11595            "must be a single flat top-level WITH block:\n{}",
11596            out.sql
11597        );
11598    }
11599
11600    #[test]
11601    fn test_with_block_cte_over_computed_global_merges_into_single_with_clause() {
11602        // Regression: `with user := (select global current_user) select user;`
11603        // produced two separate top-level `WITH` keywords ("syntax error at
11604        // or near WITH" from Postgres) whenever the computed global's own
11605        // expression referenced a session global (registering a global CTE
11606        // in addition to the user-defined "user" CTE). The merge check in
11607        // emit() only recognized the "WITH " (space) prefix convention, not
11608        // emit_cte_prefix's "WITH\n" (newline) convention used here, so it
11609        // prepended a second WITH block instead of merging into the first.
11610        let mut schema = make_schema();
11611        schema.globals.push(GlobalDescriptor {
11612            name: "current_user_id".into(),
11613            module: "default".into(),
11614            scalar_type: "std::uuid".into(),
11615            required: false,
11616            default_expr: None,
11617            computed_expr: None,
11618        });
11619        schema.globals.push(GlobalDescriptor {
11620            name: "current_user".into(),
11621            module: "default".into(),
11622            scalar_type: "Person".into(),
11623            required: false,
11624            default_expr: None,
11625            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11626        });
11627        let out = compile_and_emit_with("with\n  user := (select global current_user)\nselect user;", &schema);
11628        assert_eq!(
11629            out.sql.matches("WITH").count(),
11630            1,
11631            "must be a single WITH clause, got:\n{}",
11632            out.sql
11633        );
11634    }
11635
11636    #[test]
11637    fn test_several_mutations_in_a_free_set_each_become_a_cte() {
11638        let schema = make_schema();
11639        let out = compile_and_emit_with(
11640            "select { (update Person filter .name = 'a' set { age := 1 }), \
11641                      (update Company filter .name = 'b' set { name := 'c' }) }",
11642            &schema,
11643        );
11644        assert_eq!(
11645            out.sql.matches("UPDATE").count(),
11646            2,
11647            "both mutations have to run, got:\n{}",
11648            out.sql
11649        );
11650        assert!(out.sql.contains("WITH"), "each becomes a CTE, got:\n{}", out.sql);
11651    }
11652
11653    #[test]
11654    fn test_updating_a_binding_touches_only_its_own_rows() {
11655        // The binding names the rows to update, so resolving it to its type
11656        // without narrowing would rewrite every row in the table.
11657        let schema = make_schema();
11658        let out = compile_and_emit_with(
11659            "with mine := (select Person filter .name = 'a') select { (update mine set { age := 1 }) }",
11660            &schema,
11661        );
11662        assert!(
11663            out.sql.contains("UPDATE") && out.sql.contains("= ANY(ARRAY(SELECT"),
11664            "expected the update narrowed to the binding's rows, got:\n{}",
11665            out.sql
11666        );
11667    }
11668
11669    #[test]
11670    fn test_updating_a_traversal_touches_only_the_rows_it_lands_on() {
11671        let mut schema = make_schema();
11672        // The traversal's target needs an id to narrow against; every real
11673        // schema declares one, this one leaves it off Company.
11674        let company = schema
11675            .types
11676            .iter_mut()
11677            .find(|t| t.name == "Company")
11678            .expect("test schema has a Company type");
11679        company.properties.push(crate::schema::PropertyDescriptor {
11680            name: "id".into(),
11681            pg_type: "uuid".into(),
11682            nullable: false,
11683            default_sql: Some("uuidv7()".into()),
11684            default_pyql: None,
11685            description: None,
11686            check_constraints: vec![],
11687            is_exclusive: true,
11688            is_pk: true,
11689            is_readonly: true,
11690            rewrites: vec![],
11691            tuple_members: None,
11692            column_type: None,
11693        });
11694        let out = compile_and_emit_with(
11695            "with mine := (select Person filter .name = 'a') select { (update mine.company set { name := 'x' }) }",
11696            &schema,
11697        );
11698        assert!(
11699            out.sql.contains("= ANY(ARRAY(SELECT"),
11700            "expected the update narrowed to the traversal's rows, got:\n{}",
11701            out.sql
11702        );
11703        assert!(
11704            out.sql.contains("\"Company\""),
11705            "expected the table the traversal ends on, got:\n{}",
11706            out.sql
11707        );
11708    }
11709
11710    #[test]
11711    fn test_aggregate_over_a_multi_valued_path_keeps_the_set_flat() {
11712        // Regression: `array_agg(a.posts.title)` compiled the path as an
11713        // expression, where a multi-valued path stands for the array of its
11714        // elements — so the aggregate wrapped that array and the result came
11715        // back one level deep, `[[t1, t2]]` rather than `[t1, t2]`.
11716        let schema = make_schema();
11717        let out = compile_and_emit_with(
11718            "with a := (select Person limit 1) select { titles := array_agg(a.posts.title) }",
11719            &schema,
11720        );
11721        assert!(
11722            !out.sql.contains("array_agg(ARRAY("),
11723            "the aggregate must take the set, not the array standing for it, got:\n{}",
11724            out.sql
11725        );
11726        assert!(
11727            out.sql.contains("array_agg(") && out.sql.contains("\"title\""),
11728            "expected the aggregate over the traversal's own column, got:\n{}",
11729            out.sql
11730        );
11731        // The free object keeps its composite emission, so values stay typed
11732        // rather than passing through jsonb as strings.
11733        assert!(
11734            out.sql.contains("ROW(") && !out.sql.contains("jsonb_build_object"),
11735            "expected a composite row, got:\n{}",
11736            out.sql
11737        );
11738    }
11739
11740    #[test]
11741    fn test_with_bound_root_read_by_name_inside_a_nested_select() {
11742        // Regression: `compile_path_select` resolved a `with`-bound root
11743        // correctly, but the multi-valued check straight after it re-resolved
11744        // the same root as a plain type, so reading the binding by name from a
11745        // nested sub-select failed with "unknown type 'owner'".
11746        let schema = make_schema();
11747        let out = compile_and_emit_with(
11748            "with
11749  owner := (select Person limit 1)
11750select owner { posts := (select owner.posts.title) };",
11751            &schema,
11752        );
11753        assert!(
11754            out.sql.contains("WITH") && out.sql.contains("\"title\""),
11755            "expected the nested select to traverse the binding, got:\n{}",
11756            out.sql
11757        );
11758    }
11759
11760    #[test]
11761    fn test_path_traversal_into_with_bound_cte_of_object_type() {
11762        // Regression: `with user := (select global current_user) select
11763        // user.name;` failed with "unknown type 'user'" — compile_path_select
11764        // (used for multi-step absolute paths like `user.name`) only ever
11765        // tried resolve_type(root_name), never checking whether the root
11766        // name is a WITH-block CTE bound to an object type.
11767        let mut schema = make_schema();
11768        schema.globals.push(GlobalDescriptor {
11769            name: "current_user_id".into(),
11770            module: "default".into(),
11771            scalar_type: "std::uuid".into(),
11772            required: false,
11773            default_expr: None,
11774            computed_expr: None,
11775        });
11776        schema.globals.push(GlobalDescriptor {
11777            name: "current_user".into(),
11778            module: "default".into(),
11779            scalar_type: "Person".into(),
11780            required: false,
11781            default_expr: None,
11782            computed_expr: Some("select default::Person filter .id = global current_user_id".into()),
11783        });
11784        let out = compile_and_emit_with(
11785            "with\n  user := (select global current_user)\nselect user.name;",
11786            &schema,
11787        );
11788        assert!(
11789            out.sql.contains("FROM \"user\""),
11790            "expected path traversal from the CTE, got:\n{}",
11791            out.sql
11792        );
11793        assert_eq!(
11794            out.sql.matches("WITH").count(),
11795            1,
11796            "must be a single WITH clause, got:\n{}",
11797            out.sql
11798        );
11799    }
11800
11801    #[test]
11802    fn test_with_bound_schema_object_property_access_in_filter_expression() {
11803        // Regression: `with person := (select detached Person filter ...)
11804        // select Person filter .id not in person.id;` failed with "absolute
11805        // paths are not valid in expression context" — compile_path's
11806        // schema-bound-CTE-in-expression-position case only ever handled a
11807        // WITH-bound *free object* (resolve_cte_path), never a WITH-bound
11808        // *schema object* CTE, even though its own SELECT * CTE source
11809        // (emit_dml_as_cte_source) always exposes every raw column.
11810        let schema = make_schema();
11811        let out = compile_and_emit_with(
11812            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11813             select Person filter .id not in person.id;",
11814            &schema,
11815        );
11816        assert!(
11817            out.sql.contains("<> ALL((SELECT") && out.sql.contains(".\"id\"") && out.sql.contains("FROM \"person\""),
11818            "got:\n{}",
11819            out.sql
11820        );
11821    }
11822
11823    #[test]
11824    fn test_with_bound_schema_object_scalar_property_access() {
11825        // Not just `.id` — any scalar property of the bound type is
11826        // reachable, since the CTE source is `SELECT *` regardless of the
11827        // binding's own declared shape.
11828        let schema = make_schema();
11829        let out = compile_and_emit_with(
11830            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11831             select Person filter .name = person.name;",
11832            &schema,
11833        );
11834        assert!(
11835            out.sql.contains("(SELECT") && out.sql.contains(".\"name\"") && out.sql.contains("FROM \"person\""),
11836            "got:\n{}",
11837            out.sql
11838        );
11839    }
11840
11841    #[test]
11842    fn test_with_bound_schema_object_link_traversal_reaches_nested_property() {
11843        // Generalization: a with-bound schema object's CTE source is
11844        // `SELECT *`, so it's traversable exactly like a real type name —
11845        // not just its own scalar properties, but a *link's* properties too
11846        // (`person.company.name`), via the same path-traversal machinery
11847        // `TypeName.a.b.c` already uses (compile_path_select), wrapped as a
11848        // correlated subquery (IrExpr::PathSubquery).
11849        let schema = make_schema();
11850        let out = compile_and_emit_with(
11851            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11852             select Company filter .name = person.company.name;",
11853            &schema,
11854        );
11855        assert!(
11856            out.sql.contains("(SELECT") && out.sql.contains("\"name\"") && out.sql.contains("\"Company\""),
11857            "got:\n{}",
11858            out.sql
11859        );
11860    }
11861
11862    #[test]
11863    fn test_with_bound_schema_object_bare_link_reduces_to_id() {
11864        // A bare link at the end of the chain (`person.company`, no further
11865        // traversal) reduces to the linked object's id, matching the
11866        // existing bare-CTE-reference convention (IrExpr::CteRef).
11867        let schema = make_schema();
11868        let out = compile_and_emit_with(
11869            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11870             select Person filter .id = person.company;",
11871            &schema,
11872        );
11873        assert!(
11874            out.sql.contains("(SELECT") && out.sql.contains("\"id\""),
11875            "got:\n{}",
11876            out.sql
11877        );
11878    }
11879
11880    #[test]
11881    fn test_with_bound_schema_object_unknown_property_suggests_a_close_match() {
11882        let schema = make_schema();
11883        let ast = parse::parse(
11884            "with\n  person := (select detached Person filter .id = <uuid>$id)\n\
11885             select Person filter .name = person.nam;",
11886        )
11887        .unwrap();
11888        match ir::compile(&ast, &schema) {
11889            Err(err) => assert!(format!("{err}").contains("Did you mean 'name'"), "got: {err}"),
11890            Ok(_) => panic!("expected a compile error"),
11891        }
11892    }
11893
11894    #[test]
11895    fn test_with_bound_free_object_passthrough_preserves_all_fields() {
11896        // Regression: `with test := { test2 := 1.0, test3 := 'str' } select
11897        // test;` decoded as just `1.0` (the CTE's first field) — a bare
11898        // reference to a free-object CTE (`IrFreeExpr::CtePassthrough`) had
11899        // its shape hardcoded to `ShapeNode::Scalar` regardless of what the
11900        // CTE actually held.
11901        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test;");
11902        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11903            panic!("expected Object shape, got {:?}", out.shape.root)
11904        };
11905        assert_eq!(pointers.len(), 2);
11906        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, .. } if name == "test2"));
11907        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, .. } if name == "test3"));
11908    }
11909
11910    #[test]
11911    fn test_with_bound_free_object_field_access() {
11912        // Regression: `with test := {...} select test.test2;` failed with
11913        // "unknown type 'test'" — any absolute path with 2+ steps
11914        // unconditionally routed to compile_path_select, which only knows
11915        // how to resolve real schema types, never a free-value CTE.
11916        let out = compile_and_emit("with\n  test := { test2 := 1.0, test3 := 'str' }\nselect test.test2;");
11917        assert!(out.sql.contains("\"test2\" FROM \"test\""), "got:\n{}", out.sql);
11918    }
11919
11920    #[test]
11921    fn test_with_bound_free_object_nested_field_access_chain() {
11922        // A field-access chain through a *nested* free object literal
11923        // (`test.test3.foo`) must resolve the first hop via the CTE's own
11924        // materialized column, then extract `foo` as jsonb from that value
11925        // — the fix must not be hardcoded to exactly 2 path steps.
11926        let out =
11927            compile_and_emit("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.foo;");
11928        assert!(out.sql.contains("\"test3\" FROM \"test\""), "got:\n{}", out.sql);
11929        assert!(
11930            out.sql.contains("->'foo'"),
11931            "expected jsonb field extraction, got:\n{}",
11932            out.sql
11933        );
11934    }
11935
11936    #[test]
11937    fn test_with_bound_free_object_nested_field_access_wrong_field_errors() {
11938        // A typo'd field name anywhere in the chain must still be a compile
11939        // error, not silently emit SQL that returns NULL at runtime.
11940        let schema = make_schema();
11941        let ast = parse::parse("with\n  test := { test2 := 1.0, test3 := { foo := 'bar' } }\nselect test.test3.nope;")
11942            .unwrap();
11943        assert!(ir::compile(&ast, &schema).is_err());
11944    }
11945
11946    #[test]
11947    fn test_nested_free_object_literal_in_computed_shape_element() {
11948        // Regression: a free object literal (`{ foo := 'bar' }`) nested
11949        // inside a computed shape element — not the top-level SELECT
11950        // result — hit "shapes and set literals are not valid in
11951        // expression context"; free object literals were only ever handled
11952        // at the statement level.
11953        let out = compile_and_emit("select default::Person { id, test := { foo := 'bar' } };");
11954        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
11955            panic!("expected Object shape")
11956        };
11957        let test_node = pointers
11958            .iter()
11959            .find(|p| matches!(p, ShapeNode::NamedTuple { name, .. } if name == "test"))
11960            .unwrap_or_else(|| panic!("expected a NamedTuple shape node for 'test', got {:?}", pointers));
11961        assert!(matches!(
11962            test_node,
11963            ShapeNode::NamedTuple {
11964                is_free_object: true,
11965                ..
11966            }
11967        ));
11968    }
11969
11970    #[test]
11971    fn test_bare_free_cte_reference_in_computed_shape_collapses_to_empty() {
11972        // A free-object CTE referenced bare (no shape) as a computed shape
11973        // element's value has nothing to project — a free object needs an
11974        // explicit shape to know what to expose (unlike a tuple, which is
11975        // a plain value with no such requirement).
11976        let out = compile_and_emit(
11977            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
11978             select default::Person { id, test := test };",
11979        );
11980        assert!(
11981            out.sql.contains("jsonb_build_object()"),
11982            "expected an empty free object, got:\n{}",
11983            out.sql
11984        );
11985    }
11986
11987    #[test]
11988    fn test_shaped_free_cte_reference_projects_fields() {
11989        // `test := test { test2 }` must project just the named field out
11990        // of the underlying free object — neither collapsing to empty nor
11991        // returning every field.
11992        let out = compile_and_emit(
11993            "with\n  test := { test2 := 1.0, test3 := 'str' }\n\
11994             select default::Person { id, test := test { test2 } };",
11995        );
11996        assert!(out.sql.contains("jsonb_build_object('test2'"), "got:\n{}", out.sql);
11997        assert!(
11998            !out.sql.contains("'test3'"),
11999            "test3 should not be projected, got:\n{}",
12000            out.sql
12001        );
12002    }
12003
12004    #[test]
12005    fn test_insert_multilink_remove_rejected() {
12006        let schema = make_schema_with_through_and_prop();
12007        let ast =
12008            crate::parse::parse("INSERT Product { name := $name, tags -= (SELECT Tag FILTER .id = $tid) }").unwrap();
12009        match crate::ir::compile(&ast, &schema) {
12010            Ok(_) => panic!("expected a compile error for `-=` on a multi-link at insert time"),
12011            Err(e) => assert!(
12012                e.to_string().contains("nothing to remove"),
12013                "expected a 'nothing to remove yet' error, got: {e}"
12014            ),
12015        }
12016    }
12017
12018    #[test]
12019    fn test_multilink_junction_info_disambiguates_self_referencing_through_type() {
12020        // Regression test for a self-referencing through-link (Person.friends
12021        // via PersonFriend, which declares two Person-typed links: "person"
12022        // and "friend"). Naively matching purely by target type resolves
12023        // *both* source_col and target_col to the same first-matching link
12024        // ("person"), silently collapsing the junction to a single column
12025        // and losing the other side entirely.
12026        let schema = make_schema_with_through();
12027        let out = compile_and_emit_with(
12028            "UPDATE Person FILTER .id = $id SET { friends += (SELECT Person FILTER .id = $fid) }",
12029            &schema,
12030        );
12031        assert!(
12032            out.sql.contains("(\"person\", \"friend\")"),
12033            "expected two distinct FK columns:\n{}",
12034            out.sql
12035        );
12036        assert!(
12037            !out.sql.contains("(\"person\", \"person\")"),
12038            "source/target collapsed to the same column:\n{}",
12039            out.sql
12040        );
12041    }
12042
12043    #[test]
12044    fn test_shape_descriptor_scalars() {
12045        let out = compile_and_emit("SELECT Person { name, age }");
12046        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12047            panic!()
12048        };
12049        assert_eq!(pointers.len(), 4); // __type__, the implicit id, name, age
12050        assert!(matches!(&pointers[0], ShapeNode::Scalar { name, position: 0 } if name == "__type__"));
12051        assert!(matches!(&pointers[1], ShapeNode::Scalar { name, position: 1 } if name == "id"));
12052        assert!(matches!(&pointers[2], ShapeNode::Scalar { name, position: 2 } if name == "name"));
12053        assert!(matches!(&pointers[3], ShapeNode::Scalar { name, position: 3 } if name == "age"));
12054    }
12055
12056    #[test]
12057    fn test_shape_descriptor_multi_link() {
12058        let out = compile_and_emit("SELECT Person { name, posts { title } }");
12059        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
12060            panic!()
12061        };
12062        // pointers: [__type__, id, name, posts]
12063        assert_eq!(pointers.len(), 4);
12064        let ShapeNode::Array {
12065            name,
12066            position,
12067            element,
12068        } = &pointers[3]
12069        else {
12070            panic!()
12071        };
12072        assert_eq!(name, "posts");
12073        assert_eq!(*position, 3);
12074        let ShapeNode::Object {
12075            pointers: elem_pointers,
12076            ..
12077        } = element.as_ref()
12078        else {
12079            panic!()
12080        };
12081        // element pointers: [__type__, title] — `Post` has no pk property in
12082        // this fixture, so nothing is injected into its shape.
12083        assert_eq!(elem_pointers.len(), 2);
12084    }
12085
12086    #[test]
12087    fn test_select_order_by_limit() {
12088        let out = compile_and_emit("SELECT Person { name } ORDER BY .name ASC LIMIT 10");
12089        assert!(out.sql.contains("ORDER BY"));
12090        assert!(out.sql.contains("LIMIT 10"));
12091    }
12092
12093    #[test]
12094    fn test_insert_returning() {
12095        let out = compile_and_emit("INSERT Person { name := 'Alice', age := 30 }");
12096        assert!(out.sql.contains("INSERT INTO \"public\".\"Person\""));
12097        assert!(out.sql.contains("RETURNING"));
12098        assert!(out.sql.contains("'default::Person'::text"));
12099        assert!(out.sql.contains(") AS result"));
12100        // Bare INSERT returns pk only
12101        let ShapeNode::Object {
12102            cardinality, pointers, ..
12103        } = &out.shape.root
12104        else {
12105            panic!()
12106        };
12107        assert_eq!(*cardinality, Cardinality::Required);
12108        // Only __type__ and id — not name or age
12109        assert!(
12110            pointers
12111                .iter()
12112                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "id"))
12113        );
12114        assert!(
12115            !pointers
12116                .iter()
12117                .any(|f| matches!(f, ShapeNode::Scalar { name, .. } if name == "name"))
12118        );
12119    }
12120
12121    #[test]
12122    fn test_update_returning() {
12123        let out = compile_and_emit("UPDATE Person FILTER .name = $name SET { age := 31 }");
12124        assert!(out.sql.contains("UPDATE \"public\".\"Person\""));
12125        assert!(out.sql.contains("SET"));
12126        // Bare UPDATE returns pk only
12127        assert!(out.sql.contains("RETURNING"));
12128        assert!(out.sql.contains("'default::Person'::text"));
12129        assert!(!out.sql.contains("\"name\"::text"), "bare UPDATE must not return name");
12130    }
12131
12132    #[test]
12133    fn test_update_set_tuple_param_cast_uses_direct_jsonb_cast_not_to_jsonb() {
12134        // Regression: to_jsonb($N) on a bare, still-untyped parameter fails at
12135        // execution time with "could not determine polymorphic type because
12136        // input has type unknown" — Postgres can't dispatch a polymorphic
12137        // function against an unknown-typed placeholder. A direct
12138        // ($N)::jsonb cast resolves the parameter's type from the cast
12139        // itself instead, matching how e.g. `$1::uuid` already works.
12140        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := <tuple<x: float64>>$val }");
12141        assert!(
12142            out.sql.contains(")::jsonb"),
12143            "expected a direct ::jsonb cast, got:\n{}",
12144            out.sql
12145        );
12146        assert!(
12147            !out.sql.contains("to_jsonb($"),
12148            "must not pass a bare param straight into to_jsonb(): got:\n{}",
12149            out.sql
12150        );
12151    }
12152
12153    #[test]
12154    fn test_empty_set_cast_to_object_type_clears_optional_link() {
12155        // Regression: unsetting an optional single-link generates
12156        // `<TargetType>{}` (a cast of the empty set, not the bare `{}` the
12157        // assignment-position special case in compile_assignments_inner
12158        // already handled) — that TypeCast wraps the empty set, so it fell
12159        // through to the generic Set/Shape rejection instead ("shapes and
12160        // set literals are not valid in expression context"). Must compile
12161        // to a plain NULL for the link's FK column: `<AnyType>{}` is always
12162        // NULL regardless of context.
12163        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { company := <default::Company>{} }");
12164        assert!(out.sql.contains("\"company_id\" = NULL"), "got:\n{}", out.sql);
12165    }
12166
12167    #[test]
12168    fn test_an_aggregate_over_a_set_literal_inside_a_shape() {
12169        // `min({cap, .age})` is one value read from a two-element set, and the
12170        // set is the aggregate's argument wherever the call is written. In a
12171        // schema-bound shape it used to report "shapes and set literals are not
12172        // valid in expression context" while the same call compiled at the top
12173        // level, so a saturating cap had no spelling here at all.
12174        let out = compile_and_emit("UPDATE Person FILTER .id = $id SET { age := min({<int64>100, .age}) }");
12175        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12176        assert!(
12177            out.sql.contains("UNION ALL"),
12178            "both operands must reach the aggregate:\n{}",
12179            out.sql
12180        );
12181    }
12182
12183    #[test]
12184    fn test_an_aggregate_over_a_set_literal_reads_the_subject() {
12185        // The operands compile with the shape's own context, so a relative path
12186        // among them still resolves to the subject's column.
12187        let out = compile_and_emit("SELECT Person { capped := min({<int64>100, .age}) }");
12188        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12189        assert!(
12190            out.sql.contains("\"age\""),
12191            "the relative path must reach the column:\n{}",
12192            out.sql
12193        );
12194    }
12195
12196    #[test]
12197    fn test_an_aggregate_over_a_union_inside_a_shape() {
12198        // `min((a union b))` says the same thing as `min({a, b})`, and used to
12199        // report "union is not valid in expression context".
12200        let out = compile_and_emit("SELECT Person { capped := min((<int64>100 union .age)) }");
12201        assert!(out.sql.contains("min(v)"), "got:\n{}", out.sql);
12202        assert!(
12203            out.sql.contains("UNION ALL"),
12204            "both arms must reach the aggregate:\n{}",
12205            out.sql
12206        );
12207    }
12208
12209    #[test]
12210    fn test_an_aggregate_over_a_union_of_three_arms_flattens() {
12211        let out = compile_and_emit("SELECT Person { capped := min((<int64>1 union <int64>2 union .age)) }");
12212        assert_eq!(
12213            out.sql.matches("UNION ALL").count(),
12214            2,
12215            "three arms means two joins:\n{}",
12216            out.sql
12217        );
12218    }
12219
12220    #[test]
12221    fn test_an_aggregate_over_an_empty_set_emits_no_row_source() {
12222        // An empty set has nothing to aggregate, and the row source it would
12223        // emit (`FROM () AS _set(v)`) is not valid SQL -- so `{}` must not be
12224        // taken for an aggregate's operands, in either context.
12225        for query in ["SELECT Person { capped := min({}) }", "SELECT min({})"] {
12226            let ast = parse::parse(query).expect("parse failed");
12227            let sql = ir::compile(&ast, &make_schema())
12228                .map(|ir| emit(&ir).sql)
12229                .unwrap_or_default();
12230            assert!(
12231                !sql.contains("FROM () AS"),
12232                "{query} emitted an empty row source:\n{sql}"
12233            );
12234        }
12235    }
12236
12237    #[test]
12238    fn test_delete_returning() {
12239        let out = compile_and_emit("DELETE Person FILTER .id = $id");
12240        assert!(out.sql.contains("DELETE FROM \"public\".\"Person\""));
12241        // Bare DELETE returns pk only
12242        assert!(out.sql.contains("RETURNING"));
12243        assert!(out.sql.contains("'default::Person'::text"));
12244        assert!(!out.sql.contains("\"name\"::text"), "bare DELETE must not return name");
12245    }
12246
12247    #[test]
12248    fn test_select_over_insert() {
12249        let out = compile_and_emit("SELECT (INSERT Person { name := $name, age := $age }) { id, name }");
12250        // Must use a CTE
12251        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12252        assert!(out.sql.contains("INSERT INTO"));
12253        assert!(out.sql.contains("RETURNING *"));
12254        // Outer SELECT shapes the result
12255        assert!(out.sql.contains("'default::Person'::text"));
12256        assert!(out.sql.contains("\"name\"::text"));
12257    }
12258
12259    #[test]
12260    fn test_select_over_update() {
12261        let out = compile_and_emit("SELECT (UPDATE Person FILTER .id = $id SET { name := $name }) { id, name }");
12262        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12263        assert!(out.sql.contains("UPDATE"));
12264        assert!(out.sql.contains("RETURNING \"t1\".*"), "{}", out.sql);
12265        assert!(out.sql.contains("\"name\"::text"));
12266    }
12267
12268    #[test]
12269    fn test_insert_user_specified_id_denied_by_default() {
12270        let schema = make_schema();
12271        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
12272        match ir::compile_with_config(&ast, &schema, &ir::SessionConfig::default()) {
12273            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
12274            Ok(_) => panic!("expected id assignment to be denied by default"),
12275        }
12276    }
12277
12278    #[test]
12279    fn test_insert_user_specified_id_allowed_when_configured() {
12280        let schema = make_schema();
12281        let ast = parse::parse("INSERT Person { id := <uuid>$id, name := $name, age := $age }").unwrap();
12282        let config = ir::SessionConfig {
12283            allow_user_specified_id: true,
12284        };
12285        let ir_out = ir::compile_with_config(&ast, &schema, &config)
12286            .expect("expected id assignment to be allowed with allow_user_specified_id");
12287        let out = emit(&ir_out);
12288        assert!(out.sql.contains("INSERT INTO"));
12289    }
12290
12291    #[test]
12292    fn test_update_user_specified_id_denied_even_when_configured() {
12293        let schema = make_schema();
12294        let ast = parse::parse("UPDATE Person FILTER .name = $name SET { id := <uuid>$id }").unwrap();
12295        let config = ir::SessionConfig {
12296            allow_user_specified_id: true,
12297        };
12298        match ir::compile_with_config(&ast, &schema, &config) {
12299            Err(err) => assert!(err.to_string().contains("cannot assign to property 'id'"), "got: {err}"),
12300            Ok(_) => panic!("expected UPDATE to always deny reassigning id"),
12301        }
12302    }
12303
12304    #[test]
12305    fn test_select_over_update_multilink_only() {
12306        // Regression test: an UPDATE bound to a single external name (here,
12307        // the implicit "_dml" wrapper for `SELECT (UPDATE ...)`) whose SET
12308        // clause is *only* a multi-link mutation, with no scalar/single-link
12309        // assignment. This used to either emit an empty `SET` clause
12310        // ("UPDATE ... SET  WHERE ..." — invalid SQL) or, in an earlier fix
12311        // attempt, nest the junction INSERT inside "_dml"'s own CTE body —
12312        // which Postgres rejects outright ("WITH clause containing a
12313        // data-modifying statement must be at the top level"). The junction
12314        // CTEs must instead be hoisted out as top-level siblings of "_dml".
12315        let out = compile_and_emit(
12316            "SELECT (UPDATE Person FILTER .id = $id SET { posts += (SELECT Post FILTER .title = $title) }) { id, name }",
12317        );
12318        assert!(
12319            out.sql.contains("\"_dml__ml_add_0\""),
12320            "missing junction-append CTE:\n{}",
12321            out.sql
12322        );
12323        assert!(out.sql.contains("INSERT INTO"), "missing junction INSERT:\n{}", out.sql);
12324        // No scalar changes -> the row-source CTE must be a SELECT, not an UPDATE
12325        // with an empty SET clause.
12326        assert!(
12327            out.sql.contains("\"_dml__ids\" AS (\nSELECT"),
12328            "expected SELECT-based _ids CTE:\n{}",
12329            out.sql
12330        );
12331        assert!(
12332            !out.sql.contains("SET\n\nWHERE") && !out.sql.contains("SET \nWHERE"),
12333            "empty SET clause regression:\n{}",
12334            out.sql
12335        );
12336        // The junction CTE must be a *sibling* at the top-level WITH, not
12337        // nested inside another CTE's body — only one "WITH" keyword total.
12338        assert_eq!(
12339            out.sql.matches("WITH\n").count(),
12340            1,
12341            "junction CTE must not be nested in a second WITH:\n{}",
12342            out.sql
12343        );
12344        // "_dml" itself must still resolve (as a passthrough) for the outer SELECT.
12345        assert!(
12346            out.sql.contains("\"_dml\" AS (\n    SELECT * FROM \"_dml__ids\"\n)"),
12347            "missing _dml passthrough:\n{}",
12348            out.sql
12349        );
12350    }
12351
12352    #[test]
12353    fn test_select_over_update_scalar_and_multilink() {
12354        // Mixed case: a scalar assignment alongside a multi-link mutation, both
12355        // bound to the same external name — the scalar SET must survive *and*
12356        // the junction mutation must still be emitted as its own top-level CTE.
12357        let out = compile_and_emit(
12358            "SELECT (UPDATE Person FILTER .id = $id SET { name := $name, posts += (SELECT Post FILTER .title = $title) }) { id, name }",
12359        );
12360        assert!(
12361            out.sql.contains("\"_dml__ml_add_0\""),
12362            "missing junction-append CTE:\n{}",
12363            out.sql
12364        );
12365        assert!(
12366            out.sql.contains("\"_dml__ids\" AS (\nUPDATE"),
12367            "expected UPDATE-based _ids CTE:\n{}",
12368            out.sql
12369        );
12370        assert!(
12371            out.sql.contains("\"name\" = "),
12372            "missing scalar SET assignment:\n{}",
12373            out.sql
12374        );
12375        assert_eq!(
12376            out.sql.matches("WITH\n").count(),
12377            1,
12378            "junction CTE must not be nested in a second WITH:\n{}",
12379            out.sql
12380        );
12381    }
12382
12383    #[test]
12384    fn test_with_bound_insert_and_multilink_update_forward_ref() {
12385        // The actual shape pylon-ui's Data Explorer generates for its
12386        // flagship "insert + link in one batch" scenario: a same-batch
12387        // forward-reference (Person.posts += a not-yet-existing Post, bound
12388        // to insert0) inside a multi-statement WITH. This is the exact query
12389        // that failed live against a real Postgres before this fix ("WITH
12390        // clause containing a data-modifying statement must be at the top
12391        // level"), so it's the most important regression to pin down.
12392        let out = compile_and_emit(
12393            "with insert0 := (insert Post { title := $title }), update0 := (update Person filter .id = $id set { posts += (select insert0) }) select { insert0, update0 }",
12394        );
12395        assert!(
12396            out.sql.contains("\"insert0\" AS (\n    INSERT INTO"),
12397            "missing insert0 CTE:\n{}",
12398            out.sql
12399        );
12400        assert!(
12401            out.sql.contains("\"update0__ml_add_0\""),
12402            "missing junction-append CTE for update0:\n{}",
12403            out.sql
12404        );
12405        assert!(
12406            out.sql.contains("\"update0__ids\" AS (\nSELECT"),
12407            "expected SELECT-based update0 ids CTE (no scalar changes):\n{}",
12408            out.sql
12409        );
12410        assert!(
12411            out.sql
12412                .contains("\"update0\" AS (\n    SELECT * FROM \"update0__ids\"\n)"),
12413            "missing update0 passthrough:\n{}",
12414            out.sql
12415        );
12416        // Exactly one WITH keyword — every CTE (insert0, update0__ids,
12417        // update0__ml_add_0, update0) must be a top-level sibling.
12418        assert_eq!(
12419            out.sql.matches("WITH\n").count(),
12420            1,
12421            "must be a single flat top-level WITH block:\n{}",
12422            out.sql
12423        );
12424    }
12425
12426    #[test]
12427    fn test_select_over_delete() {
12428        let out = compile_and_emit("SELECT (DELETE Person FILTER .id = $id) { id, name }");
12429        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12430        assert!(out.sql.contains("DELETE FROM"));
12431        assert!(out.sql.contains("RETURNING *"));
12432        assert!(out.sql.contains("\"name\"::text"));
12433    }
12434
12435    fn make_schema_with_rewrite() -> SchemaDescriptor {
12436        use crate::schema::RewriteEntry;
12437        let mut schema = make_schema();
12438        // Add a `slug` property to Person with an INSERT+UPDATE rewrite: lower(.name)
12439        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
12440        person.properties.push(PropertyDescriptor {
12441            name: "slug".into(),
12442            pg_type: "text".into(),
12443            nullable: true,
12444            default_sql: None,
12445            default_pyql: None,
12446            description: None,
12447            check_constraints: vec![],
12448            is_exclusive: false,
12449            is_pk: false,
12450            is_readonly: false,
12451            rewrites: vec![
12452                RewriteEntry {
12453                    on: 1,
12454                    handler: "str_lower(.name)".into(),
12455                }, // INSERT
12456                RewriteEntry {
12457                    on: 2,
12458                    handler: "str_lower(.name)".into(),
12459                }, // UPDATE
12460            ],
12461            tuple_members: None,
12462            column_type: None,
12463        });
12464        schema
12465    }
12466
12467    #[test]
12468    fn a_link_assigned_from_a_select_keeps_its_clauses() {
12469        let out = compile_and_emit(
12470            "INSERT Person { name := 'a', age := 1, company := (SELECT Company ORDER BY .name LIMIT 1) }",
12471        );
12472        assert!(
12473            out.sql.contains("ORDER BY") && out.sql.contains("LIMIT 1"),
12474            "{}",
12475            out.sql
12476        );
12477    }
12478
12479    #[test]
12480    fn a_rewrite_leaves_the_statement_alone() {
12481        // It runs in the table's own `BEFORE` trigger, on the row as written.
12482        let schema = make_schema_with_rewrite();
12483        for query in [
12484            "INSERT Person { name := $name, age := 30 }",
12485            "UPDATE Person FILTER .id = $id SET { name := $name }",
12486        ] {
12487            let out = compile_and_emit_with(query, &schema);
12488            assert!(!out.sql.contains("\"slug\""), "{}", out.sql);
12489        }
12490    }
12491
12492    #[test]
12493    fn a_rewrite_reads_the_row_being_written() {
12494        let schema = make_schema_with_rewrite();
12495        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
12496        let [assignment] = assignments.as_slice() else {
12497            panic!("one rewrite on insert")
12498        };
12499        assert_eq!(assignment.column, "slug");
12500        assert!(assignment.sql.contains("NEW.\"name\""), "{}", assignment.sql);
12501    }
12502
12503    #[test]
12504    fn rewrites_become_before_triggers() {
12505        let ddl = crate::export::export_schema(&make_schema_with_rewrite()).unwrap();
12506        for event in ["BEFORE INSERT", "BEFORE UPDATE"] {
12507            assert!(
12508                ddl.contains(&format!("{event} ON \"public\".\"Person\"")),
12509                "{event}:\n{ddl}"
12510            );
12511        }
12512        assert!(ddl.contains("NEW.\"slug\" := _pylon_rewrites.\"v0\";"), "{ddl}");
12513    }
12514
12515    #[test]
12516    fn a_rewrite_walking_a_link_starts_from_the_new_row() {
12517        // The row is not in its table yet when a `BEFORE INSERT` trigger runs.
12518        use crate::schema::RewriteEntry;
12519        let mut schema = make_schema_with_rewrite();
12520        let person = schema.types.iter_mut().find(|t| t.name == "Person").unwrap();
12521        let slug = person.properties.iter_mut().find(|p| p.name == "slug").unwrap();
12522        slug.rewrites = vec![RewriteEntry {
12523            on: 1,
12524            handler: "<str>(.company is Company) ++ .company.name".into(),
12525        }];
12526        let assignments = crate::ir::compile_rewrite_assignments("default::Person", 1, &schema).unwrap();
12527        let sql = &assignments[0].sql;
12528        assert!(!sql.contains("\"public\".\"Person\""), "{sql}");
12529        assert!(
12530            sql.contains("(SELECT (NEW).*)") && sql.contains("NEW.\"company_id\""),
12531            "{sql}"
12532        );
12533    }
12534
12535    #[test]
12536    fn test_unless_conflict_do_nothing() {
12537        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT");
12538        assert!(out.sql.contains("ON CONFLICT DO NOTHING"));
12539    }
12540
12541    #[test]
12542    fn test_unless_conflict_on_do_nothing() {
12543        let out = compile_and_emit("INSERT Person { name := $name } UNLESS CONFLICT ON .name");
12544        assert!(out.sql.contains("ON CONFLICT (\"name\") DO NOTHING"));
12545    }
12546
12547    #[test]
12548    fn test_unless_conflict_do_update() {
12549        let out = compile_and_emit(
12550            "INSERT Person { name := $name, age := $age } \
12551             UNLESS CONFLICT ON .name \
12552             ELSE (UPDATE Person SET { age := $age })",
12553        );
12554        assert!(out.sql.contains("ON CONFLICT (\"name\") DO UPDATE SET"));
12555        assert!(out.sql.contains("\"age\" = $2"));
12556        // The param $age is shared — same index as in the INSERT VALUES
12557        assert!(!out.sql.contains("DO NOTHING"));
12558    }
12559
12560    #[test]
12561    fn test_unless_conflict_else_update_self_reference_is_qualified() {
12562        // Regression: a self-referencing ELSE-update assignment (`age :=
12563        // .age + 1`) used to compile its RHS with an empty (unqualified)
12564        // alias, emitting a bare `"age"` — ambiguous in Postgres's `ON
12565        // CONFLICT DO UPDATE SET` context between the existing row and the
12566        // `excluded` pseudo-row (confirmed live: "column reference ... is
12567        // ambiguous"). It must be qualified with the target table's own
12568        // bare name instead.
12569        let out = compile_and_emit(
12570            "INSERT Person { name := $name, age := 0 } \
12571             UNLESS CONFLICT ON .name \
12572             ELSE (UPDATE Person SET { age := .age + 1 })",
12573        );
12574        assert!(
12575            out.sql.contains("\"Person\".\"age\""),
12576            "self-reference must be qualified with the table's own name, got:\n{}",
12577            out.sql
12578        );
12579        assert!(
12580            !out.sql.contains("SET \"age\" = (\"age\""),
12581            "must not emit an unqualified (ambiguous) self-reference, got:\n{}",
12582            out.sql
12583        );
12584    }
12585
12586    #[test]
12587    fn test_unless_conflict_do_update_no_on() {
12588        let out = compile_and_emit(
12589            "INSERT Person { name := $name } \
12590             UNLESS CONFLICT \
12591             ELSE (UPDATE Person SET { age := 0 })",
12592        );
12593        assert!(out.sql.contains("ON CONFLICT DO UPDATE SET"));
12594        assert!(out.sql.contains("\"age\" = 0"));
12595    }
12596
12597    #[test]
12598    fn test_select_over_select() {
12599        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name }");
12600        // Must use a CTE
12601        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12602        // CTE exposes raw columns via SELECT *
12603        assert!(out.sql.contains("SELECT *"));
12604        assert!(out.sql.contains("FROM \"public\".\"Person\""));
12605        // CTE carries the inner filter
12606        assert!(out.sql.contains("WHERE"));
12607        // Outer SELECT projects its own shape
12608        assert!(out.sql.contains("'default::Person'::text"));
12609        assert!(out.sql.contains("\"name\"::text"));
12610    }
12611
12612    #[test]
12613    fn test_select_over_select_with_outer_filter() {
12614        let out = compile_and_emit("SELECT (SELECT Person FILTER .age > 18) { name } FILTER .name = $name");
12615        assert!(out.sql.contains("WITH\n\"_dml\" AS ("));
12616        assert!(out.sql.contains("SELECT *"));
12617        // Both filters present: one inside CTE, one in outer SELECT
12618        assert_eq!(out.sql.matches("WHERE").count(), 2);
12619        assert!(out.sql.contains("$1"));
12620    }
12621
12622    #[test]
12623    fn test_insert_link_subquery() {
12624        let out = compile_and_emit("INSERT Person { name := $name, company := (SELECT Company FILTER .name = $co) }");
12625        // The company FK column should be assigned via a scalar subquery
12626        assert!(out.sql.contains("\"company_id\""));
12627        assert!(out.sql.contains("SELECT"));
12628        // The subquery must select the pk (id) of Company
12629        assert!(out.sql.contains("\"id\""));
12630        assert!(out.sql.contains("FROM \"public\".\"Company\""));
12631        // Filter param must appear
12632        assert!(out.sql.contains("$2")); // $1 = name, $2 = co
12633    }
12634
12635    #[test]
12636    fn test_update_link_subquery() {
12637        let out =
12638            compile_and_emit("UPDATE Person FILTER .id = $id SET { company := (SELECT Company FILTER .name = $co) }");
12639        assert!(out.sql.contains("\"company_id\""));
12640        assert!(out.sql.contains("SELECT"));
12641        assert!(out.sql.contains("FROM \"public\".\"Company\""));
12642    }
12643
12644    #[test]
12645    fn test_insert_link_value_from_nested_insert_hoists_a_with_cte() {
12646        // `company := (select (insert Company {...}) { id })` — Postgres has
12647        // no way to run a nested INSERT inside another statement's VALUES
12648        // list without hoisting it into its own WITH CTE first (confirmed
12649        // live against real Postgres — see live_execution_insert_nested.rs).
12650        // Regression: an earlier attempt at this compiled cleanly but
12651        // silently emitted a subquery that dropped the nested INSERT
12652        // entirely and selected an arbitrary unrelated row instead.
12653        let out = compile_and_emit(
12654            "INSERT Person { name := 'Alice', company := (select (insert Company { name := 'Acme' }) { id }) }",
12655        );
12656        assert!(
12657            out.sql.starts_with("WITH"),
12658            "expected a WITH-hoisted CTE, got:\n{}",
12659            out.sql
12660        );
12661        assert!(
12662            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12663            "expected the nested insert to be its own CTE, got:\n{}",
12664            out.sql,
12665        );
12666        // The outer insert must reference the nested CTE's own id, not a
12667        // freestanding subquery against the Company table.
12668        assert!(
12669            out.sql.contains("\"company_id\") SELECT") && out.sql.contains(".\"id\" FROM"),
12670            "expected the outer insert to switch from VALUES to SELECT ... FROM <cte>, got:\n{}",
12671            out.sql,
12672        );
12673        assert!(
12674            !out.sql.contains("FROM \"public\".\"Company\" AS"),
12675            "must not select from the real Company table, got:\n{}",
12676            out.sql
12677        );
12678    }
12679
12680    #[test]
12681    fn test_update_link_value_from_nested_insert_hoists_a_with_cte_and_adds_from_clause() {
12682        let out = compile_and_emit(
12683            "UPDATE Person FILTER .id = $id SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
12684        );
12685        assert!(
12686            out.sql.starts_with("WITH"),
12687            "expected a WITH-hoisted CTE, got:\n{}",
12688            out.sql
12689        );
12690        assert!(
12691            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12692            "expected the nested insert to be its own CTE, got:\n{}",
12693            out.sql,
12694        );
12695        // Plain `UPDATE ... SET ...` has no FROM clause of its own — one
12696        // must be added so the SET clause can reference the CTE's column.
12697        assert!(
12698            out.sql.contains("\nFROM \""),
12699            "expected a FROM clause referencing the nested CTE, got:\n{}",
12700            out.sql
12701        );
12702        assert!(out.sql.contains("SET \"company_id\" = "), "got:\n{}", out.sql);
12703    }
12704
12705    #[test]
12706    fn test_update_link_value_from_nested_insert_combines_with_multilink_mutation() {
12707        // A nested-insert link value and a `+=` multi-link mutation in the
12708        // same UPDATE route through a completely different emitter branch
12709        // (the junction-CTE "_ids" path, not the plain-UPDATE path the
12710        // previous test covers) — both must still see the hoisted CTE.
12711        let out = compile_and_emit(
12712            "UPDATE Person FILTER .id = $id SET { \
12713                 company := (select (insert Company { name := 'Acme' }) { id }), \
12714                 posts += (SELECT Post FILTER .title = $t) \
12715             }",
12716        );
12717        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
12718        assert!(
12719            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12720            "expected the nested insert to be its own CTE, got:\n{}",
12721            out.sql,
12722        );
12723        assert!(
12724            out.sql.contains("\"_ids\" AS (\nUPDATE") && out.sql.contains("\nFROM \""),
12725            "expected the _ids UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
12726            out.sql,
12727        );
12728        assert!(
12729            out.sql.contains("\"_ml_add_0\""),
12730            "expected the junction-append CTE to still be present, got:\n{}",
12731            out.sql
12732        );
12733    }
12734
12735    #[test]
12736    fn test_update_link_value_from_nested_insert_combines_with_interface_target() {
12737        // A nested-insert link value on an UPDATE targeting an interface
12738        // (abstract + materialized) type routes through the poly fan-out
12739        // branch (emit_poly_update_stmt) — a third, independent emitter path
12740        // from the previous two tests. The hoisted CTE must be materialized
12741        // once and referenced from every per-implementor UPDATE branch.
12742        fn id_prop() -> PropertyDescriptor {
12743            PropertyDescriptor {
12744                name: "id".into(),
12745                pg_type: "uuid".into(),
12746                nullable: false,
12747                default_sql: Some("uuidv7()".into()),
12748                default_pyql: None,
12749                description: None,
12750                check_constraints: vec![],
12751                is_exclusive: true,
12752                is_pk: true,
12753                is_readonly: true,
12754                rewrites: vec![],
12755                tuple_members: None,
12756                column_type: None,
12757            }
12758        }
12759        fn text_prop(name: &str) -> PropertyDescriptor {
12760            PropertyDescriptor {
12761                name: name.into(),
12762                pg_type: "text".into(),
12763                nullable: false,
12764                default_sql: None,
12765                default_pyql: None,
12766                description: None,
12767                check_constraints: vec![],
12768                is_exclusive: false,
12769                is_pk: false,
12770                is_readonly: false,
12771                rewrites: vec![],
12772                tuple_members: None,
12773                column_type: None,
12774            }
12775        }
12776        fn company_link() -> LinkDescriptor {
12777            LinkDescriptor {
12778                name: "company".into(),
12779                target: "default::Company".into(),
12780                nullable: true,
12781                through: None,
12782                description: None,
12783                default_pyql: None,
12784                is_exclusive: false,
12785                is_readonly: false,
12786                rewrites: vec![],
12787                on_delete: vec![],
12788            }
12789        }
12790        let schema = SchemaDescriptor {
12791            types: vec![
12792                TypeDescriptor {
12793                    name: "Company".into(),
12794                    module: "default".into(),
12795                    table: "Company".into(),
12796                    abstract_: false,
12797                    materialized: true,
12798                    description: None,
12799                    parents: vec![],
12800                    interfaces: vec![],
12801                    bases: vec![],
12802                    properties: vec![id_prop(), text_prop("name")],
12803                    links: vec![],
12804                    multilinks: vec![],
12805                    computed: vec![],
12806                    constraints: vec![],
12807                    indexes: vec![],
12808                    partition: None,
12809                    vector_indexes: vec![],
12810                    search_indexes: vec![],
12811                    triggers: vec![],
12812                    junction: false,
12813                    signals: vec![],
12814                },
12815                TypeDescriptor {
12816                    name: "Account".into(),
12817                    module: "default".into(),
12818                    table: "Account".into(),
12819                    abstract_: true,
12820                    materialized: true,
12821                    description: None,
12822                    parents: vec![],
12823                    interfaces: vec![],
12824                    bases: vec![],
12825                    properties: vec![id_prop(), text_prop("email")],
12826                    links: vec![company_link()],
12827                    multilinks: vec![],
12828                    computed: vec![],
12829                    constraints: vec![],
12830                    indexes: vec![],
12831                    partition: None,
12832                    vector_indexes: vec![],
12833                    search_indexes: vec![],
12834                    triggers: vec![],
12835                    junction: false,
12836                    signals: vec![],
12837                },
12838                TypeDescriptor {
12839                    name: "Individual".into(),
12840                    module: "default".into(),
12841                    table: "Individual".into(),
12842                    abstract_: false,
12843                    materialized: true,
12844                    description: None,
12845                    parents: vec![],
12846                    interfaces: vec!["default::Account".into()],
12847                    bases: vec![],
12848                    properties: vec![id_prop(), text_prop("email"), text_prop("first_name")],
12849                    links: vec![company_link()],
12850                    multilinks: vec![],
12851                    computed: vec![],
12852                    constraints: vec![],
12853                    indexes: vec![],
12854                    partition: None,
12855                    vector_indexes: vec![],
12856                    search_indexes: vec![],
12857                    triggers: vec![],
12858                    junction: false,
12859                    signals: vec![],
12860                },
12861            ],
12862            scalars: vec![],
12863            enums: vec![],
12864            named_tuples: vec![],
12865            globals: vec![],
12866            functions: vec![],
12867            aliases: vec![],
12868            channels: vec![],
12869            ..Default::default()
12870        };
12871        let out = compile_and_emit_with(
12872            "UPDATE Account FILTER .email = $email \
12873             SET { company := (select (insert Company { name := 'Acme' }) { id }) }",
12874            &schema,
12875        );
12876        assert!(out.sql.starts_with("WITH"), "got:\n{}", out.sql);
12877        assert!(
12878            out.sql.contains("INSERT INTO \"public\".\"Company\""),
12879            "expected the nested insert to be its own CTE, got:\n{}",
12880            out.sql,
12881        );
12882        assert!(
12883            out.sql.contains("UPDATE \"public\".\"Individual\"") && out.sql.contains("\nFROM \"_nested_dml_0\""),
12884            "expected the per-implementor UPDATE to gain a FROM clause referencing the nested CTE, got:\n{}",
12885            out.sql,
12886        );
12887    }
12888
12889    #[test]
12890    fn test_a_backlink_narrowed_to_a_supertype() {
12891        // `.<company[is Account]` — the intersection narrows what comes back,
12892        // while the link itself is declared further down. Pylon looked for it
12893        // on the narrowed type alone and reported it had no such link.
12894        let mut schema = interface_link_schema();
12895        // Leave `company` declared only on the implementor, so the narrowed
12896        // type genuinely does not have it.
12897        schema
12898            .types
12899            .iter_mut()
12900            .find(|t| t.name == "Account")
12901            .expect("the helper declares Account")
12902            .links
12903            .clear();
12904        let out = compile_and_emit_with("SELECT Company { owners := .<company[is Account] { email } }", &schema);
12905        assert!(
12906            out.sql.contains("\"public\".\"Individual\""),
12907            "the backlink must read the type that declares it:\n{}",
12908            out.sql
12909        );
12910    }
12911
12912    #[test]
12913    fn a_path_through_a_backlink_narrowed_to_a_supertype() {
12914        let mut schema = interface_link_schema();
12915        schema
12916            .types
12917            .iter_mut()
12918            .find(|t| t.name == "Account")
12919            .expect("the helper declares Account")
12920            .links
12921            .clear();
12922        let out = compile_and_emit_with(
12923            "SELECT Company { name } FILTER EXISTS (SELECT .<company[is Account])",
12924            &schema,
12925        );
12926        assert!(out.sql.contains("\"public\".\"Individual\""), "{}", out.sql);
12927    }
12928
12929    /// Company, the Account interface, its Individual implementor and a
12930    /// Token linking to the interface -- enough to exercise reading and
12931    /// backlinking across one.
12932    fn interface_link_schema() -> SchemaDescriptor {
12933        // A link whose target is an interface is read by fanning the
12934        // implementors out inline. That union used to project only the
12935        // interface's *properties*, so a shape that then followed one of the
12936        // interface's own links failed with "column t1.company_id does not
12937        // exist".
12938        fn id_prop() -> PropertyDescriptor {
12939            PropertyDescriptor {
12940                name: "id".into(),
12941                pg_type: "uuid".into(),
12942                nullable: false,
12943                default_sql: None,
12944                default_pyql: None,
12945                description: None,
12946                check_constraints: vec![],
12947                is_exclusive: true,
12948                is_pk: true,
12949                is_readonly: true,
12950                rewrites: vec![],
12951                tuple_members: None,
12952                column_type: None,
12953            }
12954        }
12955        fn text_prop(name: &str) -> PropertyDescriptor {
12956            PropertyDescriptor {
12957                name: name.into(),
12958                pg_type: "text".into(),
12959                nullable: false,
12960                default_sql: None,
12961                default_pyql: None,
12962                description: None,
12963                check_constraints: vec![],
12964                is_exclusive: false,
12965                is_pk: false,
12966                is_readonly: false,
12967                rewrites: vec![],
12968                tuple_members: None,
12969                column_type: None,
12970            }
12971        }
12972        fn link(name: &str, target: &str) -> LinkDescriptor {
12973            LinkDescriptor {
12974                name: name.into(),
12975                target: target.into(),
12976                nullable: true,
12977                through: None,
12978                description: None,
12979                default_pyql: None,
12980                is_exclusive: false,
12981                is_readonly: false,
12982                rewrites: vec![],
12983                on_delete: vec![],
12984            }
12985        }
12986        fn ty(
12987            name: &str,
12988            abstract_: bool,
12989            interfaces: Vec<String>,
12990            properties: Vec<PropertyDescriptor>,
12991            links: Vec<LinkDescriptor>,
12992        ) -> TypeDescriptor {
12993            TypeDescriptor {
12994                name: name.into(),
12995                module: "default".into(),
12996                table: name.into(),
12997                abstract_,
12998                materialized: true,
12999                description: None,
13000                parents: vec![],
13001                interfaces,
13002                bases: vec![],
13003                properties,
13004                links,
13005                multilinks: vec![],
13006                computed: vec![],
13007                constraints: vec![],
13008                indexes: vec![],
13009                partition: None,
13010                vector_indexes: vec![],
13011                search_indexes: vec![],
13012                triggers: vec![],
13013                junction: false,
13014                signals: vec![],
13015            }
13016        }
13017        SchemaDescriptor {
13018            types: vec![
13019                ty("Company", false, vec![], vec![id_prop(), text_prop("name")], vec![]),
13020                ty(
13021                    "Account",
13022                    true,
13023                    vec![],
13024                    vec![id_prop(), text_prop("email")],
13025                    vec![link("company", "default::Company")],
13026                ),
13027                ty(
13028                    "Individual",
13029                    false,
13030                    vec!["default::Account".into()],
13031                    vec![id_prop(), text_prop("email")],
13032                    vec![link("company", "default::Company")],
13033                ),
13034                ty(
13035                    "Token",
13036                    false,
13037                    vec![],
13038                    vec![id_prop(), text_prop("value")],
13039                    vec![link("account", "default::Account")],
13040                ),
13041            ],
13042            scalars: vec![],
13043            enums: vec![],
13044            named_tuples: vec![],
13045            globals: vec![],
13046            functions: vec![],
13047            aliases: vec![],
13048            channels: vec![],
13049            ..Default::default()
13050        }
13051    }
13052
13053    /// `Token`, but reached through a second single link and narrowed to the
13054    /// interface's implementor -- conduit's trust-tier walk,
13055    /// `installation.connector.provider[is Individual].staff`.
13056    fn two_hop_interface_schema() -> SchemaDescriptor {
13057        let mut schema = interface_link_schema();
13058        let token = schema
13059            .types
13060            .iter()
13061            .find(|t| t.name == "Token")
13062            .expect("the helper declares Token")
13063            .clone();
13064        let mut connector = token.clone();
13065        connector.name = "Connector".into();
13066        connector.table = "Connector".into();
13067        connector.links[0].name = "provider".into();
13068        let mut installation = token;
13069        installation.name = "Installation".into();
13070        installation.table = "Installation".into();
13071        installation.links[0].name = "connector".into();
13072        installation.links[0].target = "default::Connector".into();
13073        schema.types.push(connector);
13074        schema.types.push(installation);
13075        schema
13076    }
13077
13078    #[test]
13079    fn a_narrowed_two_hop_walk_off_a_pinned_row_is_one_value() {
13080        // Every step is a forward single link and the filter pins the id, so
13081        // the `[is Individual]` narrowing does not make the walk set-valued.
13082        // Gathered as an array the `??` default became `array_remove(ARRAY[…])`
13083        // and the result decoded as a one-element set rather than as the value
13084        // itself.
13085        let out = compile_and_emit_with(
13086            "SELECT { s := ((SELECT Installation FILTER .id = <uuid>$0).connector.provider[is Individual].email) ?? 'y' }",
13087            &two_hop_interface_schema(),
13088        );
13089        assert!(
13090            !out.sql.contains("ARRAY(SELECT"),
13091            "expected a value, not a set:\n{}",
13092            out.sql
13093        );
13094        assert!(
13095            out.sql.contains("COALESCE("),
13096            "the default must stay a scalar one:\n{}",
13097            out.sql
13098        );
13099    }
13100
13101    #[test]
13102    fn a_narrowed_two_hop_walk_off_an_unpinned_row_stays_a_set() {
13103        let out = compile_and_emit_with(
13104            "SELECT { s := (SELECT Installation FILTER .value = 'x').connector.provider[is Individual].email }",
13105            &two_hop_interface_schema(),
13106        );
13107        assert!(
13108            out.sql.contains("ARRAY(SELECT"),
13109            "a walk off many rows is a set:\n{}",
13110            out.sql
13111        );
13112    }
13113
13114    /// `exists .account[is Individual]` — a walk narrowed to an implementor
13115    /// with no pointer read after it. The two-step form needs a trailing
13116    /// column to read, so it rejected the intersection outright ("type
13117    /// intersections are not valid in expression context") even though the
13118    /// same walk with a pointer on the end (`[is Individual].email`) has
13119    /// always compiled.
13120    #[test]
13121    fn a_walk_ending_in_a_type_intersection_is_valid_in_a_filter() {
13122        let out = compile_and_emit_with(
13123            "SELECT Token { id } FILTER EXISTS .account[is Individual]",
13124            &interface_link_schema(),
13125        );
13126        assert!(
13127            out.sql.contains("\"public\".\"Individual\""),
13128            "the narrowing must reach the implementor's table:\n{}",
13129            out.sql
13130        );
13131        assert!(
13132            out.sql.contains("IS NOT NULL"),
13133            "exists over the narrowed walk is a nullness test:\n{}",
13134            out.sql
13135        );
13136    }
13137
13138    #[test]
13139    fn a_walk_ending_in_a_type_intersection_is_valid_off_a_binding() {
13140        let out = compile_and_emit_with(
13141            "WITH t := (SELECT Token LIMIT 1) SELECT { e := EXISTS t.account[is Individual] }",
13142            &interface_link_schema(),
13143        );
13144        assert!(
13145            out.sql.contains("\"public\".\"Individual\""),
13146            "the narrowing must reach the implementor's table:\n{}",
13147            out.sql
13148        );
13149    }
13150
13151    #[test]
13152    fn test_reading_a_link_through_an_interface_target() {
13153        let schema = interface_link_schema();
13154        let out = compile_and_emit_with("SELECT Token { account: { email, company: { name } } }", &schema);
13155        assert!(
13156            out.sql
13157                .contains("\"email\", \"company_id\" FROM \"public\".\"Individual\""),
13158            "the fanned-out interface must carry its own link columns:\n{}",
13159            out.sql
13160        );
13161    }
13162
13163    #[test]
13164    fn test_computed_pointer_in_shape_emits_expression() {
13165        let mut schema = make_schema();
13166        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13167            name: "upper_name".into(),
13168            expression: "str_upper(.name)".into(),
13169            return_type: Some("text".into()),
13170            link_target: None,
13171            link_multi: false,
13172        });
13173        let out = compile_and_emit_with("SELECT Person { upper_name }", &schema);
13174        assert!(
13175            out.sql.to_lowercase().contains("upper"),
13176            "expected upper() in SQL, got:\n{}",
13177            out.sql
13178        );
13179    }
13180
13181    /// Interface `Account` (non-materialized) with one implementor,
13182    /// `Individual`, carrying a stored property and a computed pointer.
13183    fn make_interface_schema() -> SchemaDescriptor {
13184        fn id_prop() -> PropertyDescriptor {
13185            PropertyDescriptor {
13186                name: "id".into(),
13187                pg_type: "uuid".into(),
13188                nullable: false,
13189                default_sql: Some("uuidv7()".into()),
13190                default_pyql: None,
13191                description: None,
13192                check_constraints: vec![],
13193                is_exclusive: true,
13194                is_pk: true,
13195                is_readonly: true,
13196                rewrites: vec![],
13197                tuple_members: None,
13198                column_type: None,
13199            }
13200        }
13201        SchemaDescriptor {
13202            types: vec![
13203                TypeDescriptor {
13204                    name: "Account".into(),
13205                    module: "default".into(),
13206                    table: "Account".into(),
13207                    abstract_: true,
13208                    materialized: false,
13209                    description: None,
13210                    parents: vec![],
13211                    interfaces: vec![],
13212                    bases: vec![],
13213                    properties: vec![
13214                        id_prop(),
13215                        PropertyDescriptor {
13216                            name: "email".into(),
13217                            pg_type: "text".into(),
13218                            nullable: false,
13219                            default_sql: None,
13220                            default_pyql: None,
13221                            description: None,
13222                            check_constraints: vec![],
13223                            is_exclusive: false,
13224                            is_pk: false,
13225                            is_readonly: false,
13226                            rewrites: vec![],
13227                            tuple_members: None,
13228                            column_type: None,
13229                        },
13230                    ],
13231                    links: vec![],
13232                    multilinks: vec![],
13233                    computed: vec![],
13234                    constraints: vec![],
13235                    indexes: vec![],
13236                    partition: None,
13237                    vector_indexes: vec![],
13238                    search_indexes: vec![],
13239                    triggers: vec![],
13240                    junction: false,
13241                    signals: vec![],
13242                },
13243                TypeDescriptor {
13244                    name: "Individual".into(),
13245                    module: "default".into(),
13246                    table: "Individual".into(),
13247                    abstract_: false,
13248                    materialized: true,
13249                    description: None,
13250                    parents: vec![],
13251                    interfaces: vec!["default::Account".into()],
13252                    bases: vec![],
13253                    properties: vec![
13254                        id_prop(),
13255                        PropertyDescriptor {
13256                            name: "first_name".into(),
13257                            pg_type: "text".into(),
13258                            nullable: false,
13259                            default_sql: None,
13260                            default_pyql: None,
13261                            description: None,
13262                            check_constraints: vec![],
13263                            is_exclusive: false,
13264                            is_pk: false,
13265                            is_readonly: false,
13266                            rewrites: vec![],
13267                            tuple_members: None,
13268                            column_type: None,
13269                        },
13270                    ],
13271                    links: vec![],
13272                    multilinks: vec![],
13273                    computed: vec![crate::schema::ComputedDescriptor {
13274                        name: "full_name".into(),
13275                        expression: "str_upper(.first_name)".into(),
13276                        return_type: Some("text".into()),
13277                        link_target: None,
13278                        link_multi: false,
13279                    }],
13280                    constraints: vec![],
13281                    indexes: vec![],
13282                    partition: None,
13283                    vector_indexes: vec![],
13284                    search_indexes: vec![],
13285                    triggers: vec![],
13286                    junction: false,
13287                    signals: vec![],
13288                },
13289            ],
13290            scalars: vec![],
13291            enums: vec![],
13292            named_tuples: vec![],
13293            globals: vec![],
13294            functions: vec![],
13295            aliases: vec![],
13296            channels: vec![],
13297            ..Default::default()
13298        }
13299    }
13300
13301    #[test]
13302    fn test_type_intersection_splat_includes_concrete_computed_pointers() {
13303        // `[is Concrete].*` must expand to the concrete type's own computed
13304        // pointers too (e.g. `full_name`), not just its stored properties —
13305        // querying `Individual` directly with `*` already included them, but
13306        // the interface-splat path never even looked at `td.computed`.
13307        let schema = make_interface_schema();
13308        let out = compile_and_emit_with("SELECT Account { *, [is Individual].* }", &schema);
13309        assert!(
13310            out.sql.to_lowercase().contains("upper"),
13311            "expected the concrete type's computed pointer (str_upper(...)) in the shape, got:\n{}",
13312            out.sql
13313        );
13314        assert!(
13315            out.sql.contains("\"first_name\""),
13316            "expected the concrete type's stored property too, got:\n{}",
13317            out.sql
13318        );
13319    }
13320
13321    #[test]
13322    fn test_enum_array_property_casts_to_text_array_and_shapes_as_a_list() {
13323        // Regression: an `array<enum>` property's pg_type is
13324        // `"module"."Type"[]`, which starts with a quote exactly as a scalar
13325        // enum's does. Read as a scalar it was cast to a single `::text`,
13326        // so `{MagicLink}` -- Postgres's text rendering of the whole array --
13327        // came back as one enum value, and the polymorphic path, which casts
13328        // nothing, failed outright with "no decoder for PostgreSQL type OID".
13329        let mut schema = make_schema();
13330        schema.enums.push(crate::schema::EnumDescriptor {
13331            name: "Gender".into(),
13332            module: "default".into(),
13333            members: vec!["Male".into(), "Female".into()],
13334        });
13335        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
13336            name: "genders".into(),
13337            pg_type: r#""default"."Gender"[]"#.into(),
13338            nullable: true,
13339            default_sql: None,
13340            default_pyql: None,
13341            description: None,
13342            check_constraints: vec![],
13343            is_exclusive: false,
13344            is_pk: false,
13345            is_readonly: false,
13346            rewrites: vec![],
13347            tuple_members: None,
13348            column_type: None,
13349        });
13350
13351        let out = compile_and_emit_with("SELECT Person { genders }", &schema);
13352        assert!(
13353            out.sql.contains(r#""genders"::text[]"#),
13354            "expected a text[] cast, got:\n{}",
13355            out.sql
13356        );
13357
13358        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13359            panic!("expected Object shape, got {:?}", out.shape.root)
13360        };
13361        let genders = pointers
13362            .iter()
13363            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "genders"))
13364            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
13365        let crate::query::ShapeNode::Array { element, .. } = genders else {
13366            unreachable!()
13367        };
13368        assert!(
13369            matches!(
13370                element.as_ref(),
13371                crate::query::ShapeNode::Enum { enum_type, position: 0, .. }
13372                    if enum_type == "default::Gender"
13373            ),
13374            "expected the elements to be enum-shaped, got {element:?}",
13375        );
13376    }
13377
13378    #[test]
13379    fn test_scalar_enum_property_still_casts_to_a_single_text() {
13380        let mut schema = make_schema();
13381        schema.enums.push(crate::schema::EnumDescriptor {
13382            name: "Gender".into(),
13383            module: "default".into(),
13384            members: vec!["Male".into(), "Female".into()],
13385        });
13386        schema.types[0].properties.push(crate::schema::PropertyDescriptor {
13387            name: "gender".into(),
13388            pg_type: r#""default"."Gender""#.into(),
13389            nullable: true,
13390            default_sql: None,
13391            default_pyql: None,
13392            description: None,
13393            check_constraints: vec![],
13394            is_exclusive: false,
13395            is_pk: false,
13396            is_readonly: false,
13397            rewrites: vec![],
13398            tuple_members: None,
13399            column_type: None,
13400        });
13401
13402        let out = compile_and_emit_with("SELECT Person { gender }", &schema);
13403        assert!(
13404            out.sql.contains(r#""gender"::text"#) && !out.sql.contains(r#""gender"::text[]"#),
13405            "expected a plain text cast, got:\n{}",
13406            out.sql
13407        );
13408        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13409            panic!("expected Object shape")
13410        };
13411        assert!(
13412            pointers.iter().any(|node| matches!(
13413                node,
13414                crate::query::ShapeNode::Enum { name, enum_type, .. }
13415                    if name == "gender" && enum_type == "default::Gender"
13416            )),
13417            "expected an Enum-shaped pointer, got {pointers:?}",
13418        );
13419    }
13420
13421    #[test]
13422    fn test_type_intersection_splat_keeps_enum_pointers_enum_shaped() {
13423        // Regression: `[is Concrete].*` wraps each property in a correlated
13424        // subquery, so the outer shape saw an `IrExpr::Subquery` rather than a
13425        // column and fell back to a plain Scalar — handing back `'System'`
13426        // where a direct select gave `InterfaceAppearance.System`.
13427        let mut schema = make_interface_schema();
13428        schema.enums.push(crate::schema::EnumDescriptor {
13429            name: "Gender".into(),
13430            module: "default".into(),
13431            members: vec!["Male".into(), "Female".into()],
13432        });
13433        // An enum that lives only on the concrete type, so it can only be
13434        // reached through the intersection.
13435        let individual = schema
13436            .types
13437            .iter_mut()
13438            .find(|t| t.name == "Individual")
13439            .expect("the interface schema has an Individual type");
13440        individual.properties.push(crate::schema::PropertyDescriptor {
13441            name: "gender".into(),
13442            pg_type: r#""default"."Gender""#.into(),
13443            nullable: true,
13444            default_sql: None,
13445            default_pyql: None,
13446            description: None,
13447            check_constraints: vec![],
13448            is_exclusive: false,
13449            is_pk: false,
13450            is_readonly: false,
13451            rewrites: vec![],
13452            tuple_members: None,
13453            column_type: None,
13454        });
13455
13456        let out = compile_and_emit_with("SELECT Account { [is Individual].* }", &schema);
13457        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13458            panic!("expected Object shape, got {:?}", out.shape.root)
13459        };
13460        assert!(
13461            pointers.iter().any(|node| matches!(
13462                node,
13463                crate::query::ShapeNode::Enum { name, enum_type, .. }
13464                    if name == "gender" && enum_type == "default::Gender"
13465            )),
13466            "expected the enum property to stay enum-shaped through the intersection, got {pointers:?}",
13467        );
13468    }
13469
13470    #[test]
13471    fn test_shallow_splat_leaves_object_valued_computeds_to_the_deep_form() {
13472        // Regression: `*` expanded every computed, link-valued ones included.
13473        // `*` is properties only; `**` adds links.
13474        // On jaldis's schema that pulled a computed multi-link
13475        // (`members := .memberships.member`) into every splat query.
13476        let mut schema = make_schema();
13477        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13478            name: "authors".into(),
13479            expression: ".posts".into(),
13480            return_type: None,
13481            link_target: None,
13482            link_multi: false,
13483        });
13484        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13485            name: "age_next".into(),
13486            expression: ".age + 1".into(),
13487            return_type: Some("int8".into()),
13488            link_target: None,
13489            link_multi: false,
13490        });
13491
13492        let shallow = compile_and_emit_with("SELECT Person { * }", &schema);
13493        let crate::query::ShapeNode::Object { pointers, .. } = &shallow.shape.root else {
13494            panic!("expected Object shape")
13495        };
13496        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
13497        assert!(
13498            names.contains(&"age_next"),
13499            "a computed property belongs in `*`: {names:?}"
13500        );
13501        assert!(!names.contains(&"authors"), "a computed link does not: {names:?}");
13502
13503        let deep = compile_and_emit_with("SELECT Person { ** }", &schema);
13504        let crate::query::ShapeNode::Object { pointers, .. } = &deep.shape.root else {
13505            panic!("expected Object shape")
13506        };
13507        let names: Vec<&str> = pointers.iter().map(shape_pointer_name).collect();
13508        assert!(
13509            names.contains(&"authors"),
13510            "`**` includes links, computed ones included: {names:?}"
13511        );
13512    }
13513
13514    fn shape_pointer_name(node: &crate::query::ShapeNode) -> &str {
13515        use crate::query::ShapeNode;
13516        match node {
13517            ShapeNode::Scalar { name, .. }
13518            | ShapeNode::Enum { name, .. }
13519            | ShapeNode::NamedTuple { name, .. }
13520            | ShapeNode::Object { name, .. }
13521            | ShapeNode::Array { name, .. } => name,
13522            _ => "",
13523        }
13524    }
13525
13526    #[test]
13527    fn test_any_over_a_multilink_comparison_does_not_warn() {
13528        // Regression: the warning fired whether or not the comparison was
13529        // wrapped, so `any()` -- the very thing it asks for -- did not silence
13530        // it. The argument is compiled before the call is resolved, so the
13531        // suppression has to be in place while the argument is compiled.
13532        let schema = make_schema();
13533        let ast = parse::parse("SELECT Person FILTER any(.posts.title = 'x')").unwrap();
13534        let ir = ir::compile(&ast, &schema).unwrap();
13535        assert!(
13536            !ir.warnings.iter().any(|w| w.contains("FILTER clause")),
13537            "any() states the intent, so nothing should warn: {:?}",
13538            ir.warnings
13539        );
13540    }
13541
13542    #[test]
13543    fn test_bare_multilink_comparison_still_warns() {
13544        let schema = make_schema();
13545        let ast = parse::parse("SELECT Person FILTER .posts.title = 'x'").unwrap();
13546        let ir = ir::compile(&ast, &schema).unwrap();
13547        assert!(
13548            ir.warnings.iter().any(|w| w.contains("FILTER clause")),
13549            "an unwrapped set-valued comparison still warns: {:?}",
13550            ir.warnings
13551        );
13552    }
13553
13554    #[test]
13555    fn test_link_to_an_interface_expands_over_its_implementors() {
13556        // Regression: an interface's view carries only the interface's own
13557        // columns, so reading a link through it tagged every row as the
13558        // interface and hydrated `Profile` where the row was an
13559        // `IndividualProfile`. The root of a query already expands inline;
13560        // a nested link did not.
13561        let mut schema = make_interface_schema();
13562        let account = schema
13563            .types
13564            .iter_mut()
13565            .find(|t| t.name == "Account")
13566            .expect("the interface schema has an Account type");
13567        account.materialized = true;
13568        // A second implementor, so the fan-out is a real union.
13569        let mut organization = schema
13570            .types
13571            .iter()
13572            .find(|t| t.name == "Individual")
13573            .expect("the interface schema has an Individual type")
13574            .clone();
13575        organization.name = "Organization".into();
13576        organization.table = "Organization".into();
13577        organization.computed.clear();
13578        schema.types.push(organization);
13579
13580        let owner = TypeDescriptor {
13581            name: "Note".into(),
13582            module: "default".into(),
13583            table: "Note".into(),
13584            abstract_: false,
13585            materialized: false,
13586            description: None,
13587            parents: vec![],
13588            interfaces: vec![],
13589            bases: vec![],
13590            properties: vec![],
13591            links: vec![LinkDescriptor {
13592                name: "owner".into(),
13593                target: "default::Account".into(),
13594                nullable: true,
13595                description: None,
13596                default_pyql: None,
13597                is_exclusive: false,
13598                is_readonly: false,
13599                rewrites: vec![],
13600                on_delete: vec![],
13601                through: None,
13602            }],
13603            multilinks: vec![],
13604            computed: vec![],
13605            constraints: vec![],
13606            indexes: vec![],
13607            partition: None,
13608            vector_indexes: vec![],
13609            search_indexes: vec![],
13610            triggers: vec![],
13611            junction: false,
13612            signals: vec![],
13613        };
13614        schema.types.push(owner);
13615
13616        let out = compile_and_emit_with("SELECT Note { owner: { id } }", &schema);
13617        assert!(
13618            out.sql.contains("UNION ALL"),
13619            "the link's target should expand over its implementors, got:\n{}",
13620            out.sql
13621        );
13622        assert!(
13623            out.sql.contains("'default::Individual'::text AS \"__type__\""),
13624            "each branch should carry its own discriminator, got:\n{}",
13625            out.sql
13626        );
13627        assert!(
13628            !out.sql.contains("FROM \"default\".\"Account\" AS"),
13629            "the interface's own view should no longer be read directly, got:\n{}",
13630            out.sql
13631        );
13632    }
13633
13634    /// `make_interface_schema` with a second implementor of `Account`, so a
13635    /// read of it is a real union of two concrete types.
13636    fn make_two_implementor_schema() -> SchemaDescriptor {
13637        let mut schema = make_interface_schema();
13638        let mut organization = schema
13639            .types
13640            .iter()
13641            .find(|t| t.name == "Individual")
13642            .expect("the interface schema has an Individual type")
13643            .clone();
13644        organization.name = "Organization".into();
13645        organization.table = "Organization".into();
13646        organization.computed.clear();
13647        schema.types.push(organization);
13648        schema
13649    }
13650
13651    /// Every row read of an interface names its own concrete type, so none may
13652    /// be labelled with the interface's.
13653    fn assert_rows_carry_their_concrete_type(query: &str) {
13654        let out = compile_and_emit_with(query, &make_two_implementor_schema());
13655        assert!(
13656            !out.sql.contains("'default::Account'::text"),
13657            "rows are labelled with the interface instead of their own type:\n{}",
13658            out.sql
13659        );
13660        assert!(
13661            out.sql.contains("'default::Individual'::text AS \"__type__\""),
13662            "the rows should come from the implementors, each tagging its own:\n{}",
13663            out.sql
13664        );
13665    }
13666
13667    #[test]
13668    fn an_aggregated_select_of_an_interface_carries_the_concrete_type() {
13669        assert_rows_carry_their_concrete_type("SELECT (xs := array_agg((SELECT Account { id })))");
13670    }
13671
13672    #[test]
13673    fn an_aggregated_binding_of_an_interface_carries_the_concrete_type() {
13674        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (xs := array_agg((SELECT a { id })))");
13675    }
13676
13677    #[test]
13678    fn a_single_object_read_off_a_binding_carries_the_concrete_type() {
13679        assert_rows_carry_their_concrete_type("WITH a := (SELECT Account) SELECT (x := (SELECT a { id } LIMIT 1))");
13680    }
13681
13682    #[test]
13683    fn grouped_elements_of_an_interface_carry_the_concrete_type() {
13684        assert_rows_carry_their_concrete_type("group Account { id } by .email");
13685    }
13686
13687    /// `make_two_implementor_schema` with a `Note` that links to an `Account`,
13688    /// and an `Account` that links back to a `Note`.
13689    fn make_note_schema() -> SchemaDescriptor {
13690        fn link_to(name: &str, target: &str) -> LinkDescriptor {
13691            LinkDescriptor {
13692                name: name.into(),
13693                target: target.into(),
13694                nullable: true,
13695                description: None,
13696                default_pyql: None,
13697                is_exclusive: false,
13698                is_readonly: false,
13699                rewrites: vec![],
13700                on_delete: vec![],
13701                through: None,
13702            }
13703        }
13704        let mut schema = make_two_implementor_schema();
13705        for account in schema
13706            .types
13707            .iter_mut()
13708            .filter(|t| ["Account", "Individual", "Organization"].contains(&t.name.as_str()))
13709        {
13710            account.links.push(link_to("pinned", "default::Note"));
13711        }
13712        schema.types.push(TypeDescriptor {
13713            name: "Note".into(),
13714            module: "default".into(),
13715            table: "Note".into(),
13716            abstract_: false,
13717            materialized: false,
13718            description: None,
13719            parents: vec![],
13720            interfaces: vec![],
13721            bases: vec![],
13722            properties: vec![],
13723            links: vec![link_to("owner", "default::Account")],
13724            multilinks: vec![],
13725            computed: vec![],
13726            constraints: vec![],
13727            indexes: vec![],
13728            partition: None,
13729            vector_indexes: vec![],
13730            search_indexes: vec![],
13731            triggers: vec![],
13732            junction: false,
13733            signals: vec![],
13734        });
13735        schema
13736    }
13737
13738    #[test]
13739    fn a_backlink_to_an_interface_carries_the_concrete_type() {
13740        let out = compile_and_emit_with(
13741            "SELECT Note { pinners := .<pinned[is Account] { id } }",
13742            &make_note_schema(),
13743        );
13744        assert!(
13745            !out.sql.contains("'default::Account'::text"),
13746            "the backlink's rows are labelled with the interface instead of their own type:\n{}",
13747            out.sql
13748        );
13749    }
13750
13751    #[test]
13752    fn a_link_reached_through_a_deep_splat_carries_the_concrete_type() {
13753        let out = compile_and_emit_with("SELECT Note { ** }", &make_note_schema());
13754        assert!(
13755            !out.sql.contains("'default::Account'::text"),
13756            "the splatted link's rows are labelled with the interface instead of their own type:\n{}",
13757            out.sql
13758        );
13759    }
13760
13761    #[test]
13762    fn test_path_traversal_onto_an_interface_carries_the_concrete_type() {
13763        // The hole the chained-pointer fix landed on: a *path join* onto an
13764        // interface still read the interface's view, so the rows a traversal
13765        // returned were tagged with the interface and hydrated its class. Links
13766        // fan out; this is the traversal route they share with computed chains.
13767        let mut schema = make_interface_schema();
13768        let account = schema
13769            .types
13770            .iter_mut()
13771            .find(|t| t.name == "Account")
13772            .expect("the interface schema has an Account type");
13773        account.materialized = true;
13774        let mut organization = schema
13775            .types
13776            .iter()
13777            .find(|t| t.name == "Individual")
13778            .expect("the interface schema has an Individual type")
13779            .clone();
13780        organization.name = "Organization".into();
13781        organization.table = "Organization".into();
13782        organization.computed.clear();
13783        schema.types.push(organization);
13784        schema.types.push(TypeDescriptor {
13785            name: "Note".into(),
13786            module: "default".into(),
13787            table: "Note".into(),
13788            abstract_: false,
13789            materialized: false,
13790            description: None,
13791            parents: vec![],
13792            interfaces: vec![],
13793            bases: vec![],
13794            properties: vec![],
13795            links: vec![LinkDescriptor {
13796                name: "owner".into(),
13797                target: "default::Account".into(),
13798                nullable: true,
13799                description: None,
13800                default_pyql: None,
13801                is_exclusive: false,
13802                is_readonly: false,
13803                rewrites: vec![],
13804                on_delete: vec![],
13805                through: None,
13806            }],
13807            multilinks: vec![],
13808            computed: vec![],
13809            constraints: vec![],
13810            indexes: vec![],
13811            partition: None,
13812            vector_indexes: vec![],
13813            search_indexes: vec![],
13814            triggers: vec![],
13815            junction: false,
13816            signals: vec![],
13817        });
13818
13819        let out = compile_and_emit_with("SELECT Note.owner { id }", &schema);
13820        assert!(
13821            out.sql.contains("UNION ALL") && out.sql.contains("'default::Individual'::text AS \"__type__\""),
13822            "the traversal's target should fan out over implementors, got:\n{}",
13823            out.sql
13824        );
13825        assert!(
13826            !out.sql.contains("'default::Account'::text,"),
13827            "the row's type should be read off the row, not fixed to the interface, got:\n{}",
13828            out.sql
13829        );
13830    }
13831
13832    #[test]
13833    fn test_computed_multilink_chain_comes_back_as_rows() {
13834        // Regression: `members := .memberships.member` is a chain, and
13835        // try_compile_pointer_expr only recognised a single-step `.multilink`,
13836        // so it fell through to expression position and became a scalar
13837        // subquery — which Postgres rejects the moment a second row matches.
13838        let mut schema = make_schema();
13839        schema.types[0].computed.push(crate::schema::ComputedDescriptor {
13840            name: "coauthors".into(),
13841            expression: ".posts.author".into(),
13842            return_type: None,
13843            link_target: None,
13844            link_multi: false,
13845        });
13846        let post = schema
13847            .types
13848            .iter_mut()
13849            .find(|t| t.name == "Post")
13850            .expect("test schema has a Post type");
13851        post.links.push(LinkDescriptor {
13852            name: "author".into(),
13853            target: "default::Person".into(),
13854            nullable: true,
13855            description: None,
13856            default_pyql: None,
13857            is_exclusive: false,
13858            is_readonly: false,
13859            rewrites: vec![],
13860            on_delete: vec![],
13861            through: None,
13862        });
13863
13864        let out = compile_and_emit_with("SELECT Person { coauthors: { name } }", &schema);
13865        assert!(
13866            out.sql.contains("ARRAY(SELECT"),
13867            "the chain should aggregate into an array, got:\n{}",
13868            out.sql
13869        );
13870        assert!(
13871            out.sql.contains("\"name\""),
13872            "the requested sub-shape should survive, got:\n{}",
13873            out.sql
13874        );
13875
13876        let crate::query::ShapeNode::Object { pointers, .. } = &out.shape.root else {
13877            panic!("expected Object shape, got {:?}", out.shape.root)
13878        };
13879        let coauthors = pointers
13880            .iter()
13881            .find(|node| matches!(node, crate::query::ShapeNode::Array { name, .. } if name == "coauthors"))
13882            .unwrap_or_else(|| panic!("expected an Array-shaped pointer, got {pointers:?}"));
13883        let crate::query::ShapeNode::Array { element, .. } = coauthors else {
13884            unreachable!()
13885        };
13886        assert!(
13887            matches!(
13888                element.as_ref(),
13889                crate::query::ShapeNode::Object { type_name: Some(t), .. } if t == "default::Person"
13890            ),
13891            "the elements should hydrate as objects, got {element:?}",
13892        );
13893    }
13894
13895    #[test]
13896    fn test_multi_sort_with_then_emits_two_order_keys() {
13897        let out = compile_and_emit("SELECT Person { name } ORDER BY .name THEN .age DESC");
13898        assert!(out.sql.contains("ORDER BY"), "expected ORDER BY");
13899        // Both columns should appear in the ORDER BY clause
13900        assert!(out.sql.contains("\"name\""));
13901        assert!(out.sql.contains("\"age\""));
13902        assert!(out.sql.contains("DESC"));
13903    }
13904
13905    #[test]
13906    fn test_string_index_emits_str_subscript() {
13907        let out = compile_and_emit("SELECT 'hello'[1]");
13908        assert!(
13909            out.sql.contains("_pylon.str_subscript"),
13910            "expected _pylon.str_subscript() for string index, got:\n{}",
13911            out.sql
13912        );
13913    }
13914
13915    /// PostgreSQL forbids a set-returning function inside `VALUES`, so a loop
13916    /// over `array_unpack` has to iterate a select instead.
13917    #[test]
13918    fn test_for_over_a_set_returning_call_is_not_a_values_clause() {
13919        let out = compile_and_emit("FOR n IN array_unpack(<array<int64>>$ns) UNION (SELECT n)");
13920        assert!(
13921            !out.sql.contains("VALUES (unnest"),
13922            "a set-returning iterator must not land in VALUES, got:\n{}",
13923            out.sql
13924        );
13925        assert!(
13926            out.sql.contains("SELECT unnest"),
13927            "expected the iterator in a select list, got:\n{}",
13928            out.sql
13929        );
13930    }
13931
13932    /// The loop variable's type is the unpacked array's element type, not
13933    /// whatever the generically-declared call says.
13934    #[test]
13935    fn test_for_over_array_unpack_binds_the_element_type() {
13936        let out = compile_and_emit("FOR j IN array_unpack(<array<json>>$rows) UNION (SELECT <str>j['k'])");
13937        assert!(
13938            out.sql.contains("::jsonb AS v"),
13939            "the loop variable should carry the element type, got:\n{}",
13940            out.sql
13941        );
13942    }
13943
13944    /// A binding that holds an array has to say so, or no overload of
13945    /// `contains` accepts it and the call is reported as one nobody wrote.
13946    #[test]
13947    fn test_a_binding_keeps_its_array_type_through_an_aggregate_and_a_condition() {
13948        for query in [
13949            "WITH ids := ([<uuid>$a] IF true ELSE [<uuid>$b]) SELECT Person FILTER contains(ids, .id)",
13950            "WITH ns := array_agg((SELECT Person.name)) SELECT Person FILTER contains(ns, .name)",
13951        ] {
13952            let out = compile_and_emit(query);
13953            assert!(
13954                out.sql.contains("@> ARRAY["),
13955                "expected the array overload of contains for `{query}`, got:\n{}",
13956                out.sql
13957            );
13958        }
13959    }
13960
13961    /// Each element of an array literal is one value. A walk gathered into an
13962    /// array of its own leaves `ARRAY[uuid, uuid[]]`, which PostgreSQL
13963    /// refuses: "ARRAY types uuid and uuid[] cannot be matched".
13964    #[test]
13965    fn test_an_array_literals_walk_element_is_read_as_one_value() {
13966        let out = compile_and_emit("WITH p := (SELECT Person LIMIT 1) SELECT [<uuid>$a, p.id]");
13967        assert!(
13968            !out.sql.contains("ARRAY(SELECT"),
13969            "the element should be a scalar subquery, not an array of its own:\n{}",
13970            out.sql
13971        );
13972    }
13973
13974    /// A sub-select's own filter speaks about its subject however the outer
13975    /// select is written: ordering the walk must not re-read `.name` off what
13976    /// the walk lands on, which silently filtered the wrong table.
13977    #[test]
13978    fn test_ordering_a_walk_leaves_the_inner_filter_on_its_own_subject() {
13979        let out = compile_and_emit("SELECT (SELECT Person FILTER .name = 'x' LIMIT 1).posts { title } ORDER BY .title");
13980        assert!(
13981            out.sql.contains("WHERE (\"t0\".\"name\" = 'x')"),
13982            "the filter belongs to the Person the walk starts from, got:\n{}",
13983            out.sql
13984        );
13985        assert!(
13986            out.sql.contains("\"title\" ASC"),
13987            "the ordering belongs to the Post the walk lands on, got:\n{}",
13988            out.sql
13989        );
13990    }
13991
13992    /// A sub-select's `limit` counts its own rows. Left on the joined result
13993    /// it took one row of the walk instead — one promotion of a cart that has
13994    /// two, with nothing to say it had dropped any.
13995    #[test]
13996    fn test_a_walks_head_keeps_its_own_limit() {
13997        let out = compile_and_emit("SELECT (SELECT Person FILTER .name = 'x' LIMIT 1).posts { title }");
13998        let (head, walk) = out.sql.split_once(")\nSELECT").expect("expected a bound head");
13999        assert!(
14000            head.contains("LIMIT 1"),
14001            "the head keeps its own row count, got:\n{}",
14002            out.sql
14003        );
14004        assert!(
14005            !walk.contains("LIMIT"),
14006            "the walk keeps every row it reaches, got:\n{}",
14007            out.sql
14008        );
14009    }
14010
14011    /// An overload that declares its element type carries it whatever it was
14012    /// handed, so the body reads the loop variable as json rather than as the
14013    /// text `jsonb_array_elements` would be cast to by default.
14014    #[test]
14015    fn test_for_over_json_array_unpack_binds_json() {
14016        let out = compile_and_emit(
14017            "FOR entry IN json_array_unpack(to_json(<str>$rows)) UNION (SELECT <str>json_get(entry, 'k'))",
14018        );
14019        assert!(
14020            out.sql.contains("::jsonb AS v"),
14021            "the loop variable should carry json, got:\n{}",
14022            out.sql
14023        );
14024    }
14025
14026    /// `json_get` yields json, so casting its result to a scalar reads the
14027    /// value out of it. `::text` on its own would keep a json string's quotes.
14028    #[test]
14029    fn test_casting_json_get_reads_the_value_not_its_json_spelling() {
14030        let out = compile_and_emit("SELECT <uuid><str>json_get(<json>$p, 'id')");
14031        assert!(
14032            out.sql.contains("#>> '{}'"),
14033            "expected the json value to be read out, got:\n{}",
14034            out.sql
14035        );
14036    }
14037
14038    /// Indexing json reaches into it with `->`; neither string nor array
14039    /// subscripting applies.
14040    #[test]
14041    fn test_indexing_json_emits_a_jsonb_accessor() {
14042        let out = compile_and_emit("SELECT (<json>$payload)['label']");
14043        assert!(
14044            out.sql.contains("->'label'"),
14045            "expected a jsonb field access, got:\n{}",
14046            out.sql
14047        );
14048        assert!(
14049            !out.sql.contains("str_subscript"),
14050            "json must not be subscripted as a string, got:\n{}",
14051            out.sql
14052        );
14053    }
14054
14055    /// Casting json to a scalar yields the value, not its JSON spelling —
14056    /// `<str>` of a json string has no quotes around it.
14057    #[test]
14058    fn test_casting_json_to_a_scalar_unwraps_it() {
14059        let out = compile_and_emit("SELECT <str>to_json(<str>$s)");
14060        assert!(
14061            out.sql.contains("#>> '{}'"),
14062            "expected the json value to be extracted, got:\n{}",
14063            out.sql
14064        );
14065    }
14066
14067    /// A `with` binding that names the loop variable holds a different value
14068    /// each iteration. Evaluated once for the statement it reads an iterator
14069    /// that is not in its FROM; every read of it belongs to one iteration.
14070    #[test]
14071    fn test_a_binding_that_reads_the_loop_variable_is_keyed_by_iteration() {
14072        let out = compile_and_emit(
14073            "WITH made := (FOR p IN (SELECT Person) UNION ( \
14074               WITH mine := (SELECT p.posts LIMIT 1) \
14075               INSERT Company { name := mine.title } \
14076             )) SELECT count(made)",
14077        );
14078        assert!(
14079            out.sql
14080                .contains("\"mine\" AS (\nSELECT \"_for_p\".\"v\" AS \"_outer__mine\""),
14081            "the binding must be evaluated per iteration, got:\n{}",
14082            out.sql
14083        );
14084        assert!(
14085            out.sql.contains("\"mine\".\"_outer__mine\" = \"_for_p\".\"v\""),
14086            "a read of it must pin to the iteration in scope, got:\n{}",
14087            out.sql
14088        );
14089    }
14090
14091    /// A `for … union (insert …)` assigned to a multi link *inside another
14092    /// loop* writes rows per iteration. They used to be cross-joined onto
14093    /// every row the outer loop wrote; they belong to one iteration of it.
14094    #[test]
14095    fn test_a_loop_nested_in_a_loop_pairs_its_rows_by_iteration() {
14096        let out = compile_and_emit(
14097            "FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (FOR t IN {n} UNION (INSERT Post { title := t })) })",
14098        );
14099        assert!(
14100            out.sql.contains("\"_outer\""),
14101            "the nested rows must carry an iteration key, got:\n{}",
14102            out.sql
14103        );
14104        assert!(
14105            !out.sql.contains("CROSS JOIN \"_nested_dml_0\""),
14106            "they must not be cross-joined onto every row, got:\n{}",
14107            out.sql
14108        );
14109    }
14110
14111    /// A multi link assigned in a `for`-bodied insert used to be dropped: the
14112    /// rows went in, the junction rows never did, and nothing reported it.
14113    #[test]
14114    fn test_for_bodied_insert_writes_its_multi_link() {
14115        let out = compile_and_emit("FOR n IN {'a', 'b'} UNION (INSERT Person { name := n, posts := (SELECT Post) })");
14116        assert!(
14117            out.sql.contains("INSERT INTO \"public\".\"Person.posts\""),
14118            "the junction rows must be written too, got:\n{}",
14119            out.sql
14120        );
14121    }
14122
14123    #[test]
14124    fn test_string_slice_emits_substr() {
14125        let out = compile_and_emit("SELECT 'hello'[1:3]");
14126        assert!(
14127            out.sql.contains("substr"),
14128            "expected substr() for string slice, got:\n{}",
14129            out.sql
14130        );
14131    }
14132
14133    #[test]
14134    fn test_array_index_emits_subscript() {
14135        let out = compile_and_emit("SELECT [1, 2, 3][1]");
14136        assert!(
14137            out.sql.contains("_pylon.array_subscript"),
14138            "expected _pylon.array_subscript() for array index, got:\n{}",
14139            out.sql
14140        );
14141    }
14142
14143    #[test]
14144    fn test_index_into_array_returning_function_emits_array_subscript() {
14145        let out = compile_and_emit("SELECT str_split('a::b', '::')[-1]");
14146        assert!(
14147            out.sql.contains("_pylon.array_subscript(string_to_array"),
14148            "expected _pylon.array_subscript() over str_split, got:\n{}",
14149            out.sql
14150        );
14151    }
14152
14153    #[test]
14154    fn test_array_slice_emits_subscript() {
14155        let out = compile_and_emit("SELECT [1, 2, 3][0:2]");
14156        assert!(
14157            !out.sql.contains("substr"),
14158            "should not use substr for array, got:\n{}",
14159            out.sql
14160        );
14161        assert!(out.sql.contains(")["), "expected array slice syntax, got:\n{}", out.sql);
14162    }
14163
14164    #[test]
14165    fn test_open_ended_string_slice_emits_substr_no_length() {
14166        let out = compile_and_emit("SELECT 'hello'[2:]");
14167        // substr(expr, start) without length argument
14168        assert!(out.sql.contains("substr"), "expected substr(), got:\n{}", out.sql);
14169        // Should NOT have a third argument (length)
14170        let substr_idx = out.sql.find("substr").unwrap();
14171        let after = &out.sql[substr_idx..];
14172        let commas = after.chars().take_while(|&c| c != ')').filter(|&c| c == ',').count();
14173        assert_eq!(commas, 1, "open-ended slice should use 2-arg substr, got:\n{}", out.sql);
14174    }
14175
14176    #[test]
14177    fn test_group_by_single_key() {
14178        let out = compile_and_emit("group Person { name } by .age");
14179        // GROUP BY clause present
14180        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
14181        // Key column referenced
14182        assert!(out.sql.contains("\"age\""), "expected age column, got:\n{}", out.sql);
14183        // array_agg for elements
14184        assert!(
14185            out.sql.contains("array_agg(ROW("),
14186            "expected array_agg, got:\n{}",
14187            out.sql
14188        );
14189        // grouping names array
14190        assert!(
14191            out.sql.contains("ARRAY['age']"),
14192            "expected grouping array, got:\n{}",
14193            out.sql
14194        );
14195        // shape node is Group
14196        assert!(matches!(out.shape.root, crate::query::ShapeNode::Group { .. }));
14197        if let crate::query::ShapeNode::Group {
14198            key_nodes,
14199            grouping_position,
14200            elements_position,
14201            ..
14202        } = &out.shape.root
14203        {
14204            assert_eq!(key_nodes.len(), 1);
14205            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, position: 1 } if name == "age"));
14206            assert_eq!(*grouping_position, 2);
14207            assert_eq!(*elements_position, 3);
14208        }
14209    }
14210
14211    #[test]
14212    fn test_group_using_alias() {
14213        let out = compile_and_emit("group Person using decade := .age // 10 by decade");
14214        assert!(out.sql.contains("GROUP BY"), "expected GROUP BY, got:\n{}", out.sql);
14215        assert!(
14216            out.sql.contains("ARRAY['decade']"),
14217            "expected grouping array, got:\n{}",
14218            out.sql
14219        );
14220        if let crate::query::ShapeNode::Group { key_nodes, .. } = &out.shape.root {
14221            assert_eq!(key_nodes.len(), 1);
14222            assert!(matches!(&key_nodes[0], crate::query::ShapeNode::Scalar { name, .. } if name == "decade"));
14223        }
14224    }
14225
14226    #[test]
14227    fn test_group_orders_elements_within_each_group() {
14228        let out = compile_and_emit("group Person { name } by .age order by .name desc");
14229        assert!(
14230            out.sql.contains(")::record ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
14231            "{}",
14232            out.sql
14233        );
14234        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
14235    }
14236
14237    #[test]
14238    fn test_group_limit_trims_each_group_not_the_result() {
14239        // The newest row per key: a trailing LIMIT would drop whole groups,
14240        // so the rows are ranked within each key instead.
14241        let out = compile_and_emit("group Person { name } by .age order by .name desc limit 1");
14242        assert!(
14243            out.sql
14244                .contains("row_number() OVER (PARTITION BY \"t0\".\"age\" ORDER BY \"t0\".\"name\" DESC NULLS LAST)"),
14245            "{}",
14246            out.sql
14247        );
14248        assert!(
14249            out.sql.contains("WHERE \"__rk\" > 0 AND \"__rk\" <= 0 + 1"),
14250            "{}",
14251            out.sql
14252        );
14253        assert!(!out.sql.contains("\nLIMIT"), "{}", out.sql);
14254    }
14255
14256    #[test]
14257    fn test_group_by_two_keys_with_a_per_group_limit() {
14258        let out = compile_and_emit(
14259            "group Person { name } by .age, .name filter .age > 18 order by .name desc offset 1 limit 2",
14260        );
14261        assert!(
14262            out.sql.contains("PARTITION BY \"t0\".\"age\", \"t0\".\"name\""),
14263            "{}",
14264            out.sql
14265        );
14266        // The filter picks which rows are grouped, so it sits inside the
14267        // ranking subquery — ranking must not see rows the filter excluded.
14268        let where_pos = out.sql.find("WHERE (\"t0\".\"age\" > 18)").expect("filter");
14269        let rank_pos = out.sql.find("WHERE \"__rk\"").expect("rank filter");
14270        assert!(where_pos < rank_pos, "{}", out.sql);
14271        assert!(
14272            out.sql.contains("GROUP BY \"t0\".\"age\", \"t0\".\"name\""),
14273            "{}",
14274            out.sql
14275        );
14276    }
14277
14278    #[test]
14279    fn test_group_filter_without_a_limit_is_a_plain_where() {
14280        let out = compile_and_emit("group Person { name } by .age filter .age > 18");
14281        assert!(out.sql.contains("WHERE (\"t0\".\"age\" > 18)"), "{}", out.sql);
14282        assert!(!out.sql.contains("row_number()"), "{}", out.sql);
14283    }
14284
14285    #[test]
14286    fn test_abs_path_concat_same_type() {
14287        let out = compile_and_emit("SELECT Person.name ++ ' ' ++ Person.name");
14288        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
14289        assert!(out.sql.contains("||"), "expected concat operator, got:\n{}", out.sql);
14290        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
14291    }
14292
14293    #[test]
14294    fn test_abs_path_single_property() {
14295        let out = compile_and_emit("SELECT Person.name");
14296        assert!(out.sql.contains("\"name\""), "expected name column, got:\n{}", out.sql);
14297        assert!(out.sql.contains("FROM"), "expected FROM clause, got:\n{}", out.sql);
14298    }
14299
14300    #[test]
14301    fn test_pgvector_cast_emits_vector_type() {
14302        let out = compile_and_emit("SELECT <pgvector::vector>[1.0, 2.0, 3.0]");
14303        assert!(
14304            out.sql.contains("::vector"),
14305            "expected ::vector cast, got:\n{}",
14306            out.sql
14307        );
14308        assert!(out.sql.contains("ARRAY["), "expected ARRAY literal, got:\n{}", out.sql);
14309    }
14310
14311    #[test]
14312    fn test_pgvector_euclidean_distance_emits_l2_operator() {
14313        let out = compile_and_emit(
14314            "SELECT pgvector::euclidean_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14315        );
14316        assert!(out.sql.contains("<->"), "expected <-> operator, got:\n{}", out.sql);
14317    }
14318
14319    #[test]
14320    fn test_pgvector_cosine_distance_emits_cosine_operator() {
14321        let out = compile_and_emit(
14322            "SELECT pgvector::cosine_distance(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14323        );
14324        assert!(out.sql.contains("<=>"), "expected <=> operator, got:\n{}", out.sql);
14325    }
14326
14327    #[test]
14328    fn test_pgvector_neg_inner_product_emits_ip_operator() {
14329        let out = compile_and_emit(
14330            "SELECT pgvector::neg_inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14331        );
14332        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
14333    }
14334
14335    #[test]
14336    fn test_pgvector_inner_product_negates_ip_operator() {
14337        let out = compile_and_emit(
14338            "SELECT pgvector::inner_product(<pgvector::vector>[1.0, 2.0], <pgvector::vector>[3.0, 4.0])",
14339        );
14340        assert!(out.sql.contains("<#>"), "expected <#> operator, got:\n{}", out.sql);
14341        assert!(out.sql.contains("0.0"), "expected negation of <#>, got:\n{}", out.sql);
14342    }
14343
14344    #[test]
14345    fn test_crypto_digest_str_and_bytes_overloads_both_use_pgcrypto_digest() {
14346        let out = compile_and_emit("SELECT crypto::digest('hello', 'sha256')");
14347        assert!(
14348            out.sql.contains("digest("),
14349            "expected pgcrypto's digest(), got:\n{}",
14350            out.sql
14351        );
14352
14353        let out = compile_and_emit("SELECT crypto::digest(std::from_hex('68656c6c6f'), 'sha256')");
14354        assert!(
14355            out.sql.contains("digest("),
14356            "expected pgcrypto's digest(), got:\n{}",
14357            out.sql
14358        );
14359    }
14360
14361    #[test]
14362    fn test_crypto_hmac_str_and_bytes_overloads_both_use_pgcrypto_hmac() {
14363        let out = compile_and_emit("SELECT crypto::hmac('hello', 'key', 'sha256')");
14364        assert!(
14365            out.sql.contains("hmac("),
14366            "expected pgcrypto's hmac(), got:\n{}",
14367            out.sql
14368        );
14369
14370        let out =
14371            compile_and_emit("SELECT crypto::hmac(std::from_hex('68656c6c6f'), std::from_hex('6b6579'), 'sha256')");
14372        assert!(
14373            out.sql.contains("hmac("),
14374            "expected pgcrypto's hmac(), got:\n{}",
14375            out.sql
14376        );
14377    }
14378
14379    #[test]
14380    fn test_crypto_gen_salt_zero_arg_defaults_to_blowfish() {
14381        let out = compile_and_emit("SELECT crypto::gen_salt()");
14382        assert!(
14383            out.sql.contains("gen_salt('bf')"),
14384            "expected default 'bf' salt type, got:\n{}",
14385            out.sql
14386        );
14387    }
14388
14389    #[test]
14390    fn test_crypto_gen_salt_one_arg_passes_type_through() {
14391        let out = compile_and_emit("SELECT crypto::gen_salt('xdes')");
14392        assert!(
14393            out.sql.contains("gen_salt("),
14394            "expected gen_salt() call, got:\n{}",
14395            out.sql
14396        );
14397    }
14398
14399    #[test]
14400    fn test_crypto_gen_salt_iter_count_casts_to_int4() {
14401        let out = compile_and_emit("SELECT crypto::gen_salt('xdes', 5)");
14402        assert!(
14403            out.sql.contains("::int4"),
14404            "expected int8 -> int4 narrowing cast, got:\n{}",
14405            out.sql
14406        );
14407    }
14408
14409    #[test]
14410    fn test_crypto_crypt_uses_pgcrypto_crypt() {
14411        let out = compile_and_emit("SELECT crypto::crypt('hunter2', crypto::gen_salt())");
14412        assert!(
14413            out.sql.contains("crypt("),
14414            "expected pgcrypto's crypt(), got:\n{}",
14415            out.sql
14416        );
14417    }
14418
14419    #[test]
14420    fn test_postgis_cast_emits_geometry_type() {
14421        let out = compile_and_emit("SELECT <postgis::geometry>'POINT(1 2)'");
14422        assert!(
14423            out.sql.contains("::geometry"),
14424            "expected ::geometry cast, got:\n{}",
14425            out.sql
14426        );
14427    }
14428
14429    #[test]
14430    fn test_postgis_x_uses_st_x_builtin() {
14431        let out = compile_and_emit("SELECT postgis::x(<postgis::geometry>'POINT(1 2)')");
14432        assert!(out.sql.contains("st_x("), "expected st_x() call, got:\n{}", out.sql);
14433    }
14434
14435    #[test]
14436    fn test_postgis_area_geometry_and_geography_overloads() {
14437        let out = compile_and_emit("SELECT postgis::area(<postgis::geometry>'POINT(1 2)')");
14438        assert!(
14439            out.sql.contains("st_area("),
14440            "expected st_area() call, got:\n{}",
14441            out.sql
14442        );
14443
14444        let out = compile_and_emit("SELECT postgis::area(<postgis::geography>'POINT(1 2)', true)");
14445        assert!(
14446            out.sql.contains("st_area("),
14447            "expected st_area() call, got:\n{}",
14448            out.sql
14449        );
14450    }
14451
14452    #[test]
14453    fn test_postgis_setsrid_casts_int64_arg_to_int4() {
14454        let out = compile_and_emit("SELECT postgis::setsrid(<postgis::geometry>'POINT(1 2)', 4326)");
14455        assert!(
14456            out.sql.contains("st_setsrid("),
14457            "expected st_setsrid() call, got:\n{}",
14458            out.sql
14459        );
14460        assert!(
14461            out.sql.contains("::int4"),
14462            "expected int8 -> int4 narrowing cast, got:\n{}",
14463            out.sql
14464        );
14465    }
14466
14467    #[test]
14468    fn test_postgis_quantizecoordinates_default_arity_variants_compile() {
14469        // The reference binding documents this with 3 trailing optional
14470        // params; Pylon has no notion of default args, so each arity is
14471        // its own registered overload — confirm both the 2-arg and 4-arg
14472        // forms resolve.
14473        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5)");
14474        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
14475
14476        let out = compile_and_emit("SELECT postgis::quantizecoordinates(<postgis::geometry>'POINT(1 2)', 5, 5, 5)");
14477        assert!(out.sql.contains("st_quantizecoordinates("), "got:\n{}", out.sql);
14478    }
14479
14480    #[test]
14481    fn test_postgis_op_contains_emits_infix_operator_not_function_call() {
14482        // Regression: ImplStrategy::SqlOperator was never actually consulted
14483        // by resolve_fn_call — it fell through to the generic "schema.name(args)"
14484        // FunctionCall path, which would have emitted a nonexistent
14485        // `"postgis".op_contains(...)` call instead of the `~` operator.
14486        let out = compile_and_emit(
14487            "SELECT postgis::op_contains(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
14488        );
14489        assert!(out.sql.contains(" ~ "), "expected infix ~ operator, got:\n{}", out.sql);
14490        assert!(
14491            !out.sql.contains("op_contains("),
14492            "must not call a literal op_contains function, got:\n{}",
14493            out.sql
14494        );
14495    }
14496
14497    #[test]
14498    fn test_postgis_op_overlaps_geometry_and_geography_overloads() {
14499        let out = compile_and_emit(
14500            "SELECT postgis::op_overlaps(<postgis::geometry>'POINT(1 2)', <postgis::geometry>'POINT(3 4)')",
14501        );
14502        assert!(
14503            out.sql.contains(" && "),
14504            "expected infix && operator, got:\n{}",
14505            out.sql
14506        );
14507
14508        let out = compile_and_emit(
14509            "SELECT postgis::op_overlaps(<postgis::geography>'POINT(1 2)', <postgis::geography>'POINT(3 4)')",
14510        );
14511        assert!(
14512            out.sql.contains(" && "),
14513            "expected infix && operator, got:\n{}",
14514            out.sql
14515        );
14516    }
14517
14518    // ── User-defined function tests ───────────────────────────────────────────
14519
14520    fn make_schema_with_fns() -> SchemaDescriptor {
14521        let mut s = make_schema();
14522        s.functions = vec![
14523            FunctionDescriptor {
14524                name: "mysum".into(),
14525                module: "default".into(),
14526                params: vec![
14527                    FunctionParamDescriptor {
14528                        name: "a".into(),
14529                        pg_type: "int8".into(),
14530                    },
14531                    FunctionParamDescriptor {
14532                        name: "b".into(),
14533                        pg_type: "int8".into(),
14534                    },
14535                ],
14536                return_pg_type: "int8".into(),
14537                return_is_object: false,
14538                return_is_set: false,
14539                return_is_polymorphic: false,
14540                volatility: "immutable".into(),
14541                body: "a + b".into(),
14542            },
14543            FunctionDescriptor {
14544                name: "adults".into(),
14545                module: "default".into(),
14546                params: vec![],
14547                return_pg_type: "default::Person".into(),
14548                return_is_object: true,
14549                return_is_set: true,
14550                return_is_polymorphic: false,
14551                volatility: "stable".into(),
14552                body: "select Person filter .age > 18".into(),
14553            },
14554            // A second, 3-arg overload of `mysum`, declared *before* the
14555            // 2-arg one is looked up by resolution below — regression for a
14556            // bug where call resolution grabbed the first name-matching
14557            // overload regardless of arity, instead of searching all
14558            // same-named overloads for one whose param count actually
14559            // matches the call site.
14560            FunctionDescriptor {
14561                name: "mysum".into(),
14562                module: "default".into(),
14563                params: vec![
14564                    FunctionParamDescriptor {
14565                        name: "a".into(),
14566                        pg_type: "int8".into(),
14567                    },
14568                    FunctionParamDescriptor {
14569                        name: "b".into(),
14570                        pg_type: "int8".into(),
14571                    },
14572                    FunctionParamDescriptor {
14573                        name: "c".into(),
14574                        pg_type: "int8".into(),
14575                    },
14576                ],
14577                return_pg_type: "int8".into(),
14578                return_is_object: false,
14579                return_is_set: false,
14580                return_is_polymorphic: false,
14581                volatility: "immutable".into(),
14582                body: "a + b + c".into(),
14583            },
14584        ];
14585        s
14586    }
14587
14588    #[test]
14589    fn test_user_fn_scalar_call() {
14590        let schema = make_schema_with_fns();
14591        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
14592        assert!(out.sql.contains("\"public\".\"mysum\""), "got:\n{}", out.sql);
14593    }
14594
14595    #[test]
14596    fn test_user_fn_overload_resolved_by_argument_count() {
14597        // Regression: with a 2-arg and a 3-arg `mysum` overload both
14598        // declared, a 3-arg call must resolve to the 3-arg overload rather
14599        // than erroring against whichever overload happens to be first in
14600        // `schema.functions`.
14601        let schema = make_schema_with_fns();
14602        let out = compile_and_emit_with("SELECT mysum(1, 2, 3)", &schema);
14603        assert!(
14604            out.sql
14605                .contains("\"public\".\"mysum\"((1)::int8, (2)::int8, (3)::int8)"),
14606            "got:\n{}",
14607            out.sql,
14608        );
14609
14610        // The 2-arg call must still resolve to the 2-arg overload.
14611        let out = compile_and_emit_with("SELECT mysum(1, 2)", &schema);
14612        assert!(
14613            out.sql.contains("\"public\".\"mysum\"((1)::int8, (2)::int8)"),
14614            "got:\n{}",
14615            out.sql,
14616        );
14617    }
14618
14619    #[test]
14620    fn test_user_fn_object_select_no_shape() {
14621        let schema = make_schema_with_fns();
14622        let out = compile_and_emit_with("SELECT adults()", &schema);
14623        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
14624        assert!(out.sql.contains("FROM"), "got:\n{}", out.sql);
14625    }
14626
14627    #[test]
14628    fn test_user_fn_object_select_with_shape() {
14629        let schema = make_schema_with_fns();
14630        let out = compile_and_emit_with("SELECT adults() { name }", &schema);
14631        assert!(out.sql.contains("\"public\".\"adults\"()"), "got:\n{}", out.sql);
14632        assert!(out.sql.contains("\"name\""), "got:\n{}", out.sql);
14633    }
14634
14635    #[test]
14636    fn test_object_fn_projected_to_a_property_inside_an_expression() {
14637        let schema = make_schema_with_fns();
14638        let out = compile_and_emit_with("SELECT Person { n := adults().name }", &schema);
14639        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
14640        assert!(out.sql.contains("\".\"name\"\n"), "got:\n{}", out.sql);
14641    }
14642
14643    #[test]
14644    fn test_object_fn_sub_select_carries_its_modifiers() {
14645        let schema = make_schema_with_fns();
14646        let out = compile_and_emit_with(
14647            "SELECT Person { n := (select adults() filter .age > 21 limit 1).name }",
14648            &schema,
14649        );
14650        assert!(out.sql.contains("FROM \"public\".\"adults\"() AS"), "got:\n{}", out.sql);
14651        assert!(out.sql.contains("\"age\" > 21"), "got:\n{}", out.sql);
14652        assert!(out.sql.contains("LIMIT 1"), "got:\n{}", out.sql);
14653    }
14654
14655    #[test]
14656    fn test_object_fn_without_a_property_still_explains_the_restriction() {
14657        let schema = make_schema_with_fns();
14658        let ast = parse::parse("SELECT Person { n := adults() + 1 }").unwrap();
14659        let err = match ir::compile(&ast, &schema) {
14660            Ok(_) => panic!("expected a compile error"),
14661            Err(e) => format!("{e}"),
14662        };
14663        assert!(err.contains("returns objects"), "{err}");
14664    }
14665
14666    #[test]
14667    fn test_object_fn_projected_to_an_unknown_property_is_rejected() {
14668        let schema = make_schema_with_fns();
14669        let ast = parse::parse("SELECT Person { n := adults().nope }").unwrap();
14670        let err = match ir::compile(&ast, &schema) {
14671            Ok(_) => panic!("expected a compile error"),
14672            Err(e) => format!("{e}"),
14673        };
14674        assert!(err.contains("nope"), "{err}");
14675    }
14676
14677    #[test]
14678    fn test_user_fn_in_cte_exposes_raw_columns() {
14679        // Regression: FunctionSelect as a CTE source must emit SELECT * FROM fn()
14680        // so the outer query can reference raw columns like t1.age.
14681        let schema = make_schema_with_fns();
14682        let out = compile_and_emit_with("WITH persons := adults() SELECT persons FILTER .age > 25", &schema);
14683        assert!(
14684            out.sql.contains("SELECT * FROM \"public\".\"adults\"()"),
14685            "CTE source must be SELECT * FROM fn(), got:\n{}",
14686            out.sql,
14687        );
14688        assert!(
14689            out.sql.contains("\"age\""),
14690            "outer filter must reference raw column, got:\n{}",
14691            out.sql
14692        );
14693    }
14694
14695    // ── vector::search tests ──────────────────────────────────────────────────
14696
14697    fn make_schema_with_vector() -> SchemaDescriptor {
14698        use crate::schema::VectorIndexDescriptor;
14699        let mut s = make_schema();
14700        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
14701            td.vector_indexes.push(VectorIndexDescriptor {
14702                index_name: None,
14703                pointers: vec!["name".into()],
14704                model: "test-embed".into(),
14705                metric: "cosine".into(),
14706                dimensions: 4,
14707            });
14708        }
14709        s
14710    }
14711
14712    #[test]
14713    fn test_vector_search_bare_type_name() {
14714        let schema = make_schema_with_vector();
14715        let out = compile_and_emit_with(
14716            "WITH search := vector::search(Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14717             SELECT search { object { name }, distance }",
14718            &schema,
14719        );
14720        assert!(
14721            out.sql.contains("\"Person\""),
14722            "expected Person table, got:\n{}",
14723            out.sql
14724        );
14725        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14726    }
14727
14728    #[test]
14729    fn test_vector_search_qualified_type_name() {
14730        let schema = make_schema_with_vector();
14731        let out = compile_and_emit_with(
14732            "WITH search := vector::search(default::Person, <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14733             SELECT search { object { name }, distance }",
14734            &schema,
14735        );
14736        assert!(
14737            out.sql.contains("\"Person\""),
14738            "expected Person table, got:\n{}",
14739            out.sql
14740        );
14741        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14742    }
14743
14744    #[test]
14745    fn test_vector_search_subquery_filter_included_in_where() {
14746        let schema = make_schema_with_vector();
14747        let out = compile_and_emit_with(
14748            "WITH search := vector::search((select Person filter .name = 'Alice'), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14749             SELECT search { object { name }, distance }",
14750            &schema,
14751        );
14752        assert!(
14753            out.sql.contains("\"Person\""),
14754            "expected Person table, got:\n{}",
14755            out.sql
14756        );
14757        assert!(out.sql.contains("\"name\""), "expected name filter, got:\n{}", out.sql);
14758        assert!(out.sql.contains("Alice"), "expected filter value, got:\n{}", out.sql);
14759        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14760    }
14761
14762    #[test]
14763    fn test_vector_search_subquery_filter_combined_with_outer_property_filter() {
14764        let schema = make_schema_with_vector();
14765        let out = compile_and_emit_with(
14766            "WITH search := vector::search((select Person filter .age > 18), <pgvector::vector>[1.0, 2.0, 3.0, 4.0]) \
14767             SELECT search { object { name }, distance }",
14768            &schema,
14769        );
14770        assert!(
14771            out.sql.contains("\"age\""),
14772            "expected age pre-filter, got:\n{}",
14773            out.sql
14774        );
14775        assert!(out.sql.contains("18"), "expected filter value 18, got:\n{}", out.sql);
14776        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14777    }
14778
14779    #[test]
14780    fn test_vector_search_text_overload_with_subquery_filter() {
14781        let schema = make_schema_with_vector();
14782        let out = compile_and_emit_with(
14783            "WITH search := vector::search((select Person filter .name = 'Alice'), query := $q) \
14784             SELECT search { object { name }, distance }",
14785            &schema,
14786        );
14787        assert!(
14788            out.sql.contains("\"Person\""),
14789            "expected Person table, got:\n{}",
14790            out.sql
14791        );
14792        assert!(
14793            out.sql.contains("Alice"),
14794            "expected pre-filter value, got:\n{}",
14795            out.sql
14796        );
14797        assert!(
14798            out.sql.contains("float8[]"),
14799            "expected float8[] cast for deferred vec param, got:\n{}",
14800            out.sql
14801        );
14802        assert!(out.sql.contains("<=>"), "expected cosine operator, got:\n{}", out.sql);
14803    }
14804
14805    #[test]
14806    fn test_count_type_ref_compiles_to_agg_over_query() {
14807        let out = compile_and_emit("SELECT count(Person)");
14808        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14809        assert!(
14810            out.sql.contains("\"Person\""),
14811            "expected Person table, got:\n{}",
14812            out.sql
14813        );
14814    }
14815
14816    #[test]
14817    fn test_count_qualified_type_ref_compiles_to_agg_over_query() {
14818        let out = compile_and_emit("SELECT count(default::Person)");
14819        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14820        assert!(
14821            out.sql.contains("\"Person\""),
14822            "expected Person table, got:\n{}",
14823            out.sql
14824        );
14825    }
14826
14827    #[test]
14828    fn test_count_subquery_compiles_to_agg_over_query() {
14829        let out = compile_and_emit("SELECT count((select Person))");
14830        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14831        assert!(
14832            out.sql.contains("\"Person\""),
14833            "expected Person table, got:\n{}",
14834            out.sql
14835        );
14836    }
14837
14838    #[test]
14839    fn test_count_subquery_with_filter() {
14840        let out = compile_and_emit("SELECT count((select Person filter .name = 'Alice'))");
14841        assert!(out.sql.contains("count(*)"), "expected count(*), got:\n{}", out.sql);
14842        assert!(
14843            out.sql.contains("\"name\""),
14844            "expected filter on name, got:\n{}",
14845            out.sql
14846        );
14847    }
14848
14849    #[test]
14850    fn test_positional_param_compiles_to_dollar_n() {
14851        let out = compile_and_emit("SELECT Person FILTER .name = $0");
14852        assert!(out.sql.contains("$1"), "expected $1 placeholder, got:\n{}", out.sql);
14853    }
14854
14855    #[test]
14856    fn test_multiple_positional_params_compile_in_order() {
14857        let out = compile_and_emit("SELECT Person FILTER .name = $0 AND .age > $1");
14858        assert!(out.sql.contains("$1"), "expected $1, got:\n{}", out.sql);
14859        assert!(out.sql.contains("$2"), "expected $2, got:\n{}", out.sql);
14860    }
14861
14862    #[test]
14863    fn test_repeated_positional_param_reuses_slot() {
14864        let out = compile_and_emit("SELECT Person FILTER .name = $0 OR .name = $0");
14865        assert_eq!(
14866            out.sql.matches("$1").count(),
14867            2,
14868            "both uses must reference $1, got:\n{}",
14869            out.sql
14870        );
14871    }
14872
14873    #[test]
14874    fn test_cast_to_nonexistent_type_names_full_type() {
14875        let schema = make_schema();
14876        let ast = parse::parse("SELECT Person FILTER .name = <default::Ghost>$name").unwrap();
14877        match ir::compile(&ast, &schema) {
14878            Ok(_) => panic!("expected compile error for unknown type"),
14879            Err(e) => {
14880                let msg = e.to_string();
14881                assert!(
14882                    msg.contains("unknown type 'default::Ghost'"),
14883                    "expected full type name in error, got: {msg}",
14884                );
14885            }
14886        }
14887    }
14888
14889    #[test]
14890    fn test_cast_to_nonexistent_unqualified_type_names_type() {
14891        let schema = make_schema();
14892        let ast = parse::parse("SELECT Person FILTER .name = <Ghost>$name").unwrap();
14893        match ir::compile(&ast, &schema) {
14894            Ok(_) => panic!("expected compile error for unknown type"),
14895            Err(e) => {
14896                let msg = e.to_string();
14897                assert!(
14898                    msg.contains("unknown type 'Ghost'"),
14899                    "expected type name in error, got: {msg}",
14900                );
14901            }
14902        }
14903    }
14904
14905    #[test]
14906    fn test_top_level_cast_to_registered_scalar_uses_its_own_domain() {
14907        // A bare top-level `SELECT <module::Scalar>expr` must compile as a
14908        // plain scalar cast to that scalar's own DOMAIN — not get misrouted
14909        // to `compile_schema_cast_select`'s object-by-id lookup (which
14910        // treated any non-stdlib-module named cast target as an object type,
14911        // since it never checked `resolve_scalar` before falling through).
14912        use crate::schema::ScalarDescriptor;
14913        let mut schema = make_schema();
14914        schema.scalars.push(ScalarDescriptor {
14915            name: "Email".into(),
14916            module: "default".into(),
14917            base: "Str".into(),
14918            pg_type: "text".into(),
14919            check_constraints: vec!["value ~ '^[^@]+@[^@]+\\.[^@]+$'".into()],
14920            is_sequence: false,
14921        });
14922        let out = compile_and_emit_with("SELECT <default::Email>'test@test.de'", &schema);
14923        assert!(
14924            out.sql.contains("\"public\".\"Email\""),
14925            "expected a cast to the scalar's own domain, got:\n{}",
14926            out.sql
14927        );
14928        assert!(
14929            !out.sql.to_lowercase().contains("\"person\""),
14930            "must not be misrouted to an object-type lookup, got:\n{}",
14931            out.sql
14932        );
14933    }
14934
14935    #[test]
14936    fn test_top_level_cast_to_unknown_type_names_full_type() {
14937        // The object-cast fallback (`compile_schema_cast_select`) used to
14938        // discard the module before building its synthetic path, so an
14939        // unknown top-level cast target reported a bare name even when
14940        // written with an explicit module.
14941        let schema = make_schema();
14942        let ast = parse::parse("SELECT <default::Ghost>$name").unwrap();
14943        match ir::compile(&ast, &schema) {
14944            Ok(_) => panic!("expected compile error for unknown type"),
14945            Err(e) => {
14946                let msg = e.to_string();
14947                assert!(
14948                    msg.contains("unknown type 'default::Ghost'"),
14949                    "expected full type name in error, got: {msg}",
14950                );
14951            }
14952        }
14953    }
14954
14955    #[test]
14956    fn test_structural_tuple_cast_unnamed_resolves_to_jsonb() {
14957        let out = compile_and_emit("SELECT <tuple<str, bool>>$p");
14958        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
14959    }
14960
14961    #[test]
14962    fn test_jsonb_to_uuid_cast_extracts_via_text() {
14963        // PostgreSQL has no native jsonb -> uuid cast; extract raw text then cast.
14964        let out = compile_and_emit("SELECT <uuid>(<json>$p)");
14965        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14966        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
14967    }
14968
14969    #[test]
14970    fn test_jsonb_to_datetime_cast_extracts_via_text() {
14971        let out = compile_and_emit("SELECT <datetime>(<json>$p)");
14972        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14973        assert!(out.sql.contains("::timestamptz"), "got:\n{}", out.sql);
14974    }
14975
14976    #[test]
14977    fn test_jsonb_to_duration_cast_extracts_via_text() {
14978        let out = compile_and_emit("SELECT <duration>(<json>$p)");
14979        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14980        assert!(out.sql.contains("::interval"), "got:\n{}", out.sql);
14981    }
14982
14983    #[test]
14984    fn test_jsonb_to_array_cast_unpacks_each_element() {
14985        let out = compile_and_emit("SELECT <array<int64>>(<json>$p)");
14986        assert!(out.sql.contains("jsonb_array_elements("), "got:\n{}", out.sql);
14987        assert!(out.sql.contains("#>> '{}'"), "got:\n{}", out.sql);
14988        assert!(out.sql.contains("::int8"), "got:\n{}", out.sql);
14989    }
14990
14991    #[test]
14992    fn test_non_jsonb_cast_is_unaffected_by_jsonb_extraction() {
14993        // A plain str -> uuid cast (source isn't jsonb) must still use the
14994        // ordinary `::pg_type` path, not the jsonb-extraction template.
14995        let out = compile_and_emit("SELECT <uuid>$p");
14996        assert!(!out.sql.contains("#>>"), "got:\n{}", out.sql);
14997        assert!(out.sql.contains("::uuid"), "got:\n{}", out.sql);
14998    }
14999
15000    #[test]
15001    fn test_datetime_plus_duration_type_checks() {
15002        // Regression: types_compatible's bucket-matching (same type, or
15003        // both-int, or both-float) rejected this outright, even though
15004        // Postgres supports `timestamptz + interval` natively and this is
15005        // a legitimate operator overload.
15006        let out = compile_and_emit("SELECT <datetime>$p + <duration>$d");
15007        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15008    }
15009
15010    #[test]
15011    fn test_duration_plus_datetime_type_checks_either_order() {
15012        let out = compile_and_emit("SELECT <duration>$d + <datetime>$p");
15013        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15014    }
15015
15016    #[test]
15017    fn test_local_date_plus_duration_type_checks() {
15018        let out = compile_and_emit("SELECT <cal::local_date>$p + <duration>$d");
15019        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15020    }
15021
15022    #[test]
15023    fn test_datetime_minus_duration_type_checks() {
15024        let out = compile_and_emit("SELECT <datetime>$p - <duration>$d");
15025        assert!(out.sql.contains(" - "), "got:\n{}", out.sql);
15026    }
15027
15028    #[test]
15029    fn test_local_time_plus_duration_type_checks() {
15030        let out = compile_and_emit("SELECT <cal::local_time>$p + <duration>$d");
15031        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15032    }
15033
15034    #[test]
15035    fn test_datetime_plus_duration_only_allowed_for_add_and_sub() {
15036        // Comparing (or otherwise combining) a datetime and a duration with
15037        // anything other than +/- still isn't meaningful and must still error.
15038        let schema = make_schema();
15039        let ast = parse::parse("SELECT <datetime>$p = <duration>$d").unwrap();
15040        match ir::compile(&ast, &schema) {
15041            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
15042            Ok(_) => panic!("expected a compile error"),
15043        }
15044    }
15045
15046    #[test]
15047    fn test_int_and_float_mix_is_allowed() {
15048        // Every int width implicitly casts to every float width, so mixed
15049        // int/float arithmetic type-checks without an explicit cast —
15050        // Postgres's own operator resolution handles the actual promotion.
15051        let out = compile_and_emit("SELECT <int16>1 + <float32>2.0");
15052        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15053    }
15054
15055    #[test]
15056    fn test_int_and_decimal_mix_is_allowed() {
15057        // int64 -> bigint -> decimal is a separate implicit-cast branch
15058        // from int64 -> float32 -> float64, but every int width still
15059        // mixes with numeric/decimal directly.
15060        let out = compile_and_emit("SELECT <int64>1 + <decimal>2.0");
15061        assert!(out.sql.contains(" + "), "got:\n{}", out.sql);
15062    }
15063
15064    #[test]
15065    fn test_float_and_decimal_mix_still_rejected() {
15066        // float and numeric/decimal are separate branches past int64 in
15067        // the implicit-cast graph and don't cast to each other — this
15068        // still needs an explicit cast.
15069        let schema = make_schema();
15070        let ast = parse::parse("SELECT <decimal>$p + <float64>$n").unwrap();
15071        match ir::compile(&ast, &schema) {
15072            Err(err) => assert!(format!("{err}").contains("cannot be applied"), "got: {err}"),
15073            Ok(_) => panic!("expected a compile error"),
15074        }
15075    }
15076
15077    #[test]
15078    fn test_unknown_pointer_suggests_a_close_match() {
15079        // `Person` has a `name` property (see make_schema) — `nam` is close
15080        // enough (missing one trailing character) that a "Did you mean"
15081        // suggestion should fire.
15082        let schema = make_schema();
15083        let ast = parse::parse("SELECT Person { nam }").unwrap();
15084        match ir::compile(&ast, &schema) {
15085            Err(err) => assert_eq!(
15086                format!("{err}"),
15087                "object type 'default::Person' has no link or property 'nam'. Did you mean 'name'?"
15088            ),
15089            Ok(_) => panic!("expected a compile error"),
15090        }
15091    }
15092
15093    #[test]
15094    fn test_unrelated_unknown_pointer_gets_no_suggestion() {
15095        // A typo with no plausible match on the type shouldn't force a
15096        // confusing, unrelated suggestion onto the user.
15097        let schema = make_schema();
15098        let ast = parse::parse("SELECT Person { xyzxyzxyz }").unwrap();
15099        match ir::compile(&ast, &schema) {
15100            Err(err) => {
15101                let msg = format!("{err}");
15102                assert!(msg.contains("has no link or property 'xyzxyzxyz'"), "got: {msg}");
15103                assert!(!msg.contains("Did you mean"), "got: {msg}");
15104            }
15105            Ok(_) => panic!("expected a compile error"),
15106        }
15107    }
15108
15109    #[test]
15110    fn test_multilink_sourced_backlink_exists_filter_compiles() {
15111        // Regression for a gap live-execution testing caught: `filter exists
15112        // .<multilink[is Type]` (a backlink whose source pointer on the
15113        // target type is a multi-link, e.g. `Person.posts`, not a single
15114        // FK link) used to fail to compile with "has no link ... pointing
15115        // to" — `compile_backlink_as_exists` only ever checked
15116        // `target_td.links`, never `target_td.multilinks`. Fast SQL-text
15117        // companion to the live-execution test in
15118        // `tests/live_execution_backlinks.rs`, which additionally confirms
15119        // the emitted SQL returns the right rows.
15120        let out = compile_and_emit("SELECT Post filter exists .<posts[is Person]");
15121        assert!(out.sql.contains("EXISTS"), "got:\n{}", out.sql);
15122    }
15123
15124    #[test]
15125    fn test_backlink_as_computed_shape_pointer_compiles() {
15126        // Regression for the bigger gap live-execution testing caught: a
15127        // backlink could only be used in `filter exists .<...>` — using one
15128        // to select/project actual objects as a computed pointer's value
15129        // (`authors := .<posts[is Person] { name }`) failed to compile at
15130        // all ("shapes and set literals are not valid in expression
15131        // context"). `compile_shape_element`'s computed-pointer dispatch
15132        // now recognizes a backlink-rooted `Expr::Shape`/`Expr::Path` RHS
15133        // and routes it through `compile_backlink_pointer`, mirroring
15134        // `compile_multilink_pointer` for the forward direction. Fast
15135        // SQL-text companion to the live-execution tests in
15136        // `tests/live_execution_backlinks.rs`, which additionally confirm
15137        // the emitted SQL returns the right rows, nested 2 levels deep.
15138        let out = compile_and_emit("SELECT Post { title, authors := .<posts[is Person] { name } }");
15139        assert!(out.sql.contains("array_agg(ROW("), "got:\n{}", out.sql);
15140    }
15141
15142    fn schema_with_exclusive_posts() -> SchemaDescriptor {
15143        let mut schema = make_schema();
15144        for td in schema.types.iter_mut().filter(|td| td.name == "Person") {
15145            for ml in td.multilinks.iter_mut().filter(|ml| ml.name == "posts") {
15146                ml.is_exclusive = true;
15147            }
15148        }
15149        schema
15150    }
15151
15152    #[test]
15153    fn test_backlink_through_an_exclusive_multilink_is_single() {
15154        let out = compile_and_emit_with(
15155            "SELECT Post { title, author := .<posts[is Person] { name } }",
15156            &schema_with_exclusive_posts(),
15157        );
15158        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
15159            panic!("expected an object root, got {:?}", out.shape.root)
15160        };
15161        assert!(
15162            matches!(pointers.last(), Some(ShapeNode::Object { name, .. }) if name == "author"),
15163            "got {pointers:?}"
15164        );
15165    }
15166
15167    #[test]
15168    fn test_a_path_through_an_exclusive_backlink_is_single() {
15169        let out = compile_and_emit_with(
15170            "SELECT Post { title, author_name := .<posts[is Person].name }",
15171            &schema_with_exclusive_posts(),
15172        );
15173        let ShapeNode::Object { pointers, .. } = &out.shape.root else {
15174            panic!("expected an object root, got {:?}", out.shape.root)
15175        };
15176        assert!(
15177            matches!(pointers.last(), Some(ShapeNode::Scalar { name, .. }) if name == "author_name"),
15178            "got {pointers:?}"
15179        );
15180        assert!(!out.sql.contains("ARRAY("), "got:\n{}", out.sql);
15181    }
15182
15183    #[test]
15184    fn test_structural_tuple_cast_named_resolves_to_jsonb() {
15185        let out = compile_and_emit("SELECT <tuple<x: float64, y: float64>>$p");
15186        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15187    }
15188
15189    #[test]
15190    fn test_structural_tuple_cast_nested_resolves_to_jsonb() {
15191        let out = compile_and_emit("SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>$p");
15192        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15193    }
15194
15195    #[test]
15196    fn test_nominal_named_tuple_cast_resolves_to_jsonb() {
15197        let mut schema = make_schema();
15198        schema.named_tuples.push(NamedTupleDescriptor {
15199            name: "Point".into(),
15200            module: "default".into(),
15201            members: vec![],
15202        });
15203        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
15204        assert!(out.sql.contains("($1)::jsonb"), "got:\n{}", out.sql);
15205    }
15206
15207    #[test]
15208    fn test_array_literal_cast_resolves_to_native_pg_array_not_jsonb() {
15209        // The exact query reported as failing — must compile now, and must
15210        // resolve to a real Postgres array (text[]), never jsonb (arrays
15211        // decode natively, unlike tuples).
15212        let out = compile_and_emit("SELECT <array<str>>['foo', 'bar']");
15213        assert!(
15214            out.sql.contains("::text[]") || out.sql.contains("ARRAY["),
15215            "got:\n{}",
15216            out.sql
15217        );
15218        assert!(
15219            !out.sql.contains("jsonb"),
15220            "arrays must not use jsonb, got:\n{}",
15221            out.sql
15222        );
15223    }
15224
15225    #[test]
15226    fn test_array_literal_cast_applies_per_element_cast() {
15227        // Each element gets its own real cast, not a raw untyped ARRAY[...] —
15228        // '1' and '2' must actually coerce to int8 (mirrors the equivalent
15229        // tuple test).
15230        let out = compile_and_emit("SELECT <array<int64>>['1', '2']");
15231        assert!(out.sql.contains("ARRAY[('1')::int8, ('2')::int8]"), "got:\n{}", out.sql);
15232    }
15233
15234    #[test]
15235    fn test_array_param_cast_uses_direct_suffix_cast() {
15236        let out = compile_and_emit("SELECT <array<int64>>$p");
15237        assert!(out.sql.contains("::int8[]"), "got:\n{}", out.sql);
15238    }
15239
15240    #[test]
15241    fn test_array_of_named_tuple_element_casts_to_jsonb_array() {
15242        // An array's element type can be anything except another array —
15243        // including a structural tuple, which still resolves that one
15244        // element to jsonb while the array itself stays a native pg array.
15245        let out = compile_and_emit("SELECT <array<tuple<x: float64, y: float64>>>$p");
15246        assert!(out.sql.contains("::jsonb[]"), "got:\n{}", out.sql);
15247    }
15248
15249    #[test]
15250    fn test_contains_on_array_literal_cast_uses_array_overload_not_strpos() {
15251        // Regression: contains()'s array<Any> overload only matched a bare
15252        // IrExpr::Array node, never a TypeCast-wrapped one — which is what
15253        // every `<array<T>>[...]` cast actually compiles to. Overload
15254        // resolution silently fell back to the *first* registered `contains`
15255        // overload (str, str), emitting a bogus `strpos(text[], text)` call.
15256        let out = compile_and_emit("SELECT contains(<array<str>>[1, 2], '2')");
15257        assert!(out.sql.contains("@> ARRAY["), "got:\n{}", out.sql);
15258        assert!(
15259            !out.sql.contains("strpos"),
15260            "must not fall back to the str/str overload, got:\n{}",
15261            out.sql
15262        );
15263    }
15264
15265    #[test]
15266    fn test_nested_array_type_rejected_at_parse_time() {
15267        match parse::parse("SELECT <array<array<str>>>$p") {
15268            Ok(_) => panic!("expected parse error for nested array type"),
15269            Err(e) => assert!(e.to_string().contains("nested arrays are not supported"), "got: {}", e),
15270        }
15271    }
15272
15273    #[test]
15274    fn test_array_cast_in_computed_shape_field_schema_bound_context() {
15275        // Schema-bound counterpart (compile_expr, not compile_free_expr) — a
15276        // computed shape field casting an array literal.
15277        let out = compile_and_emit("SELECT Person { name, tags := <array<str>>['a', 'b'] }");
15278        assert!(out.sql.contains("ARRAY[('a')::text, ('b')::text]"), "got:\n{}", out.sql);
15279    }
15280
15281    #[test]
15282    fn test_nominal_named_tuple_cast_shape_carries_real_members() {
15283        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15284        let mut schema = make_schema();
15285        schema.named_tuples.push(NamedTupleDescriptor {
15286            name: "Point".into(),
15287            module: "default".into(),
15288            members: vec![
15289                TupleMemberDescriptor {
15290                    name: Some("x".into()),
15291                    kind: TupleMemberKind::Scalar {
15292                        pg_type: "float8".into(),
15293                    },
15294                },
15295                TupleMemberDescriptor {
15296                    name: Some("y".into()),
15297                    kind: TupleMemberKind::Scalar {
15298                        pg_type: "float8".into(),
15299                    },
15300                },
15301            ],
15302        });
15303        let out = compile_and_emit_with("SELECT <default::Point>$p", &schema);
15304        match &out.shape.root {
15305            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
15306                assert_eq!(type_name.as_deref(), Some("default::Point"));
15307                let members = members.as_ref().expect("expected resolved members");
15308                assert_eq!(members.len(), 2);
15309                assert_eq!(members[0].key.as_deref(), Some("x"));
15310                assert_eq!(members[1].key.as_deref(), Some("y"));
15311            }
15312            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
15313        }
15314    }
15315
15316    #[test]
15317    fn test_structural_tuple_property_read_shape_carries_real_members() {
15318        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15319        let schema = SchemaDescriptor {
15320            types: vec![TypeDescriptor {
15321                name: "Person".into(),
15322                module: "default".into(),
15323                table: "Person".into(),
15324                abstract_: false,
15325                materialized: false,
15326                description: None,
15327                parents: vec![],
15328                interfaces: vec![],
15329                bases: vec![],
15330                properties: vec![PropertyDescriptor {
15331                    name: "address".into(),
15332                    pg_type: "jsonb".into(),
15333                    nullable: true,
15334                    default_sql: None,
15335                    default_pyql: None,
15336                    description: None,
15337                    check_constraints: vec![],
15338                    is_exclusive: false,
15339                    is_pk: false,
15340                    is_readonly: false,
15341                    rewrites: vec![],
15342                    tuple_members: Some(vec![
15343                        TupleMemberDescriptor {
15344                            name: Some("street".into()),
15345                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15346                        },
15347                        TupleMemberDescriptor {
15348                            name: Some("zip".into()),
15349                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15350                        },
15351                    ]),
15352                    column_type: None,
15353                }],
15354                links: vec![],
15355                multilinks: vec![],
15356                computed: vec![],
15357                constraints: vec![],
15358                indexes: vec![],
15359                partition: None,
15360                vector_indexes: vec![],
15361                search_indexes: vec![],
15362                triggers: vec![],
15363                junction: false,
15364                signals: vec![],
15365            }],
15366            scalars: vec![],
15367            enums: vec![],
15368            named_tuples: vec![],
15369            globals: vec![],
15370            functions: vec![],
15371            aliases: vec![],
15372            channels: vec![],
15373            ..Default::default()
15374        };
15375        let out = compile_and_emit_with("SELECT Person { address }", &schema);
15376        assert!(out.sql.contains("::jsonb"), "got:\n{}", out.sql);
15377        match &out.shape.root {
15378            crate::query::ShapeNode::Object { pointers, .. } => {
15379                let address = pointers
15380                    .iter()
15381                    .find(|p| matches!(p, crate::query::ShapeNode::NamedTuple { name, .. } if name == "address"))
15382                    .expect("expected address pointer in shape");
15383                match address {
15384                    crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
15385                        assert_eq!(*type_name, None);
15386                        let members = members.as_ref().expect("expected resolved members");
15387                        assert_eq!(members.len(), 2);
15388                        assert_eq!(members[0].key.as_deref(), Some("street"));
15389                        assert_eq!(members[1].key.as_deref(), Some("zip"));
15390                    }
15391                    other => panic!("expected NamedTuple, got {other:?}"),
15392                }
15393            }
15394            other => panic!("expected ShapeNode::Object, got {other:?}"),
15395        }
15396    }
15397
15398    #[test]
15399    fn test_bare_path_select_structural_tuple_property_shape_carries_real_members() {
15400        // A bare `select Type.property` path traversal (no `{ }` shape) used
15401        // to fall back to `ShapeNode::Scalar` for a *structural* tuple
15402        // property — `emit_path_select`'s IrPathResult::Scalar branch only
15403        // detected a *nominal* named tuple (via the "__nt__:" pg_type
15404        // marker), never consulting the property's own `tuple_members`. This
15405        // decoded every row as an opaque blob instead of a proper tuple
15406        // literal (reported as `select default::Person.address` rendering
15407        // wrong in both pylon-ui and the CLI REPL, while a `{ address }`
15408        // shape query — the test above — already worked).
15409        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15410        let schema = SchemaDescriptor {
15411            types: vec![TypeDescriptor {
15412                name: "Person".into(),
15413                module: "default".into(),
15414                table: "Person".into(),
15415                abstract_: false,
15416                materialized: false,
15417                description: None,
15418                parents: vec![],
15419                interfaces: vec![],
15420                bases: vec![],
15421                properties: vec![PropertyDescriptor {
15422                    name: "address".into(),
15423                    pg_type: "jsonb".into(),
15424                    nullable: true,
15425                    default_sql: None,
15426                    default_pyql: None,
15427                    description: None,
15428                    check_constraints: vec![],
15429                    is_exclusive: false,
15430                    is_pk: false,
15431                    is_readonly: false,
15432                    rewrites: vec![],
15433                    tuple_members: Some(vec![
15434                        TupleMemberDescriptor {
15435                            name: Some("street".into()),
15436                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15437                        },
15438                        TupleMemberDescriptor {
15439                            name: Some("zip".into()),
15440                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15441                        },
15442                    ]),
15443                    column_type: None,
15444                }],
15445                links: vec![],
15446                multilinks: vec![],
15447                computed: vec![],
15448                constraints: vec![],
15449                indexes: vec![],
15450                partition: None,
15451                vector_indexes: vec![],
15452                search_indexes: vec![],
15453                triggers: vec![],
15454                junction: false,
15455                signals: vec![],
15456            }],
15457            scalars: vec![],
15458            enums: vec![],
15459            named_tuples: vec![],
15460            globals: vec![],
15461            functions: vec![],
15462            aliases: vec![],
15463            channels: vec![],
15464            ..Default::default()
15465        };
15466        let out = compile_and_emit_with("SELECT Person.address", &schema);
15467        match &out.shape.root {
15468            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
15469                assert_eq!(*type_name, None);
15470                let members = members.as_ref().expect("expected resolved members");
15471                assert_eq!(members.len(), 2);
15472                assert_eq!(members[0].key.as_deref(), Some("street"));
15473                assert_eq!(members[1].key.as_deref(), Some("zip"));
15474            }
15475            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
15476        }
15477    }
15478
15479    #[test]
15480    fn test_path_traversal_into_structural_tuple_property() {
15481        use crate::schema::{TupleMemberDescriptor, TupleMemberKind};
15482        let schema = SchemaDescriptor {
15483            types: vec![TypeDescriptor {
15484                name: "Person".into(),
15485                module: "default".into(),
15486                table: "Person".into(),
15487                abstract_: false,
15488                materialized: false,
15489                description: None,
15490                parents: vec![],
15491                interfaces: vec![],
15492                bases: vec![],
15493                properties: vec![PropertyDescriptor {
15494                    name: "address".into(),
15495                    pg_type: "jsonb".into(),
15496                    nullable: true,
15497                    default_sql: None,
15498                    default_pyql: None,
15499                    description: None,
15500                    check_constraints: vec![],
15501                    is_exclusive: false,
15502                    is_pk: false,
15503                    is_readonly: false,
15504                    rewrites: vec![],
15505                    tuple_members: Some(vec![
15506                        TupleMemberDescriptor {
15507                            name: Some("street".into()),
15508                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15509                        },
15510                        TupleMemberDescriptor {
15511                            name: Some("zip".into()),
15512                            kind: TupleMemberKind::Scalar { pg_type: "text".into() },
15513                        },
15514                    ]),
15515                    column_type: None,
15516                }],
15517                links: vec![],
15518                multilinks: vec![],
15519                computed: vec![],
15520                constraints: vec![],
15521                indexes: vec![],
15522                partition: None,
15523                vector_indexes: vec![],
15524                search_indexes: vec![],
15525                triggers: vec![],
15526                junction: false,
15527                signals: vec![],
15528            }],
15529            scalars: vec![],
15530            enums: vec![],
15531            named_tuples: vec![],
15532            globals: vec![],
15533            functions: vec![],
15534            aliases: vec![],
15535            channels: vec![],
15536            ..Default::default()
15537        };
15538        // Regression: a structural (unnamed) tuple property previously failed
15539        // path traversal with "'address' is a scalar property, not a link —
15540        // cannot traverse further" because compile_path_select only allowed
15541        // further traversal for the nominal `__nt__:` named-tuple marker,
15542        // ignoring `tuple_members` on plain jsonb-typed properties.
15543        let out = compile_and_emit_with("SELECT default::Person.address.street", &schema);
15544        assert!(out.sql.contains("\"address\"->'street'"), "got:\n{}", out.sql);
15545    }
15546
15547    #[test]
15548    fn test_structural_tuple_cast_shape_carries_real_members() {
15549        let out = compile_and_emit("SELECT <tuple<street: str, zip: str>>$p");
15550        match &out.shape.root {
15551            crate::query::ShapeNode::NamedTuple { type_name, members, .. } => {
15552                assert_eq!(*type_name, None);
15553                let members = members.as_ref().expect("expected resolved members");
15554                assert_eq!(members.len(), 2);
15555                assert_eq!(members[0].key.as_deref(), Some("street"));
15556                assert_eq!(members[1].key.as_deref(), Some("zip"));
15557            }
15558            other => panic!("expected ShapeNode::NamedTuple, got {other:?}"),
15559        }
15560    }
15561
15562    #[test]
15563    fn test_tuple_cast_mixed_named_and_unnamed_elements_rejected() {
15564        match parse::parse("SELECT <tuple<x: float64, bool>>$p") {
15565            Ok(_) => panic!("expected parse error for mixed named/unnamed tuple elements"),
15566            Err(e) => assert!(e.to_string().contains("all named or all unnamed"), "got: {}", e),
15567        }
15568    }
15569
15570    #[test]
15571    fn test_is_with_tuple_type_rejected() {
15572        let schema = make_schema();
15573        let ast = parse::parse("SELECT Person FILTER Person is tuple<x: float64, y: float64>").unwrap();
15574        match ir::compile(&ast, &schema) {
15575            Ok(_) => panic!("expected error for IS with a tuple type"),
15576            Err(e) => {
15577                assert!(
15578                    e.to_string().contains("cannot use IS with a tuple or array type"),
15579                    "got: {}",
15580                    e
15581                );
15582            }
15583        }
15584    }
15585
15586    #[test]
15587    fn test_is_with_array_type_rejected() {
15588        let schema = make_schema();
15589        let ast = parse::parse("SELECT Person FILTER Person is array<str>").unwrap();
15590        match ir::compile(&ast, &schema) {
15591            Ok(_) => panic!("expected error for IS with an array type"),
15592            Err(e) => {
15593                assert!(
15594                    e.to_string().contains("cannot use IS with a tuple or array type"),
15595                    "got: {}",
15596                    e
15597                );
15598            }
15599        }
15600    }
15601
15602    #[test]
15603    fn test_tuple_index_on_non_literal_falls_back_to_jsonb_index() {
15604        // `.1` only constant-folds against a literal tuple; anything else
15605        // (a $param, a cast result, …) needs a genuine runtime jsonb index
15606        // instead of erroring "only supported on tuple literals".
15607        let out = compile_and_emit("SELECT (<tuple<int64, str>>('1', 3)).1");
15608        assert!(out.sql.contains("->1"), "got:\n{}", out.sql);
15609    }
15610
15611    #[test]
15612    fn test_tuple_index_out_of_bounds_on_cast_target_errors_at_compile_time() {
15613        // Regression: `.2` on a 2-element tuple cast silently fell back to a
15614        // runtime jsonb index (returning null) instead of failing, because
15615        // the source is a TypeCast, not a literal Tuple/NamedTuple. The cast's
15616        // target type is statically known here, so this must bounds-check
15617        // and error instead.
15618        let ast = parse::parse("SELECT (<tuple<int64, str>>('1', 3)).2").unwrap();
15619        let schema = make_schema();
15620        match ir::compile(&ast, &schema) {
15621            Ok(_) => panic!("expected out-of-bounds tuple index error"),
15622            Err(e) => {
15623                assert!(
15624                    e.to_string()
15625                        .contains("2 is not a member of tuple<std::int64, std::str>"),
15626                    "got: {}",
15627                    e
15628                );
15629            }
15630        }
15631    }
15632
15633    #[test]
15634    fn test_positional_tuple_literal_cast_to_tuple_type_compiles() {
15635        // Each element must be cast to its own declared type — '1' isn't
15636        // silently jsonb-wrapped unchanged as a string; it's coerced to
15637        // int8.
15638        let out = compile_and_emit("SELECT <tuple<int64, str>>(1, 'x')");
15639        assert!(
15640            out.sql.contains("jsonb_build_array((1)::int8, ('x')::text)"),
15641            "got:\n{}",
15642            out.sql
15643        );
15644    }
15645
15646    #[test]
15647    fn test_positional_tuple_literal_cast_coerces_mismatched_literal_types() {
15648        // The exact case that was silently wrong: casting a string literal to
15649        // int64 and an int literal to str must actually coerce each one, not
15650        // just pass the raw literal through untouched.
15651        let out = compile_and_emit("SELECT <tuple<int64, str>>('1', 3)");
15652        assert!(
15653            out.sql.contains("jsonb_build_array(('1')::int8, (3)::text)"),
15654            "got:\n{}",
15655            out.sql
15656        );
15657    }
15658
15659    #[test]
15660    fn test_nested_tuple_literal_cast_applies_casts_recursively() {
15661        let out = compile_and_emit(
15662            "SELECT <tuple<point: tuple<x: float64, y: float64>, label: str>>(point := ('1', 2), label := 5)",
15663        );
15664        assert!(
15665            out.sql.contains(
15666                "jsonb_build_object('point', jsonb_build_object('x', ('1')::float8, 'y', (2)::float8), 'label', (5)::text)"
15667            ),
15668            "got:\n{}",
15669            out.sql
15670        );
15671    }
15672
15673    #[test]
15674    fn test_positional_tuple_literal_nested_inside_named_tuple_compiles() {
15675        let out = compile_and_emit("SELECT (point := (1, 2), label := 'origin')");
15676        assert!(out.sql.contains("jsonb_build_array(1, 2)"), "got:\n{}", out.sql);
15677        assert!(out.sql.contains("jsonb_build_object("), "got:\n{}", out.sql);
15678    }
15679
15680    #[test]
15681    fn test_positional_tuple_literal_in_schema_bound_shape_field_compiles() {
15682        let out = compile_and_emit("SELECT Person { name, pair := (1, 2) }");
15683        assert!(out.sql.contains("jsonb_build_array(1, 2)"), "got:\n{}", out.sql);
15684    }
15685
15686    // ── search index outbox enqueue tests ─────────────────────────────────────
15687    //
15688    // Regression coverage for a real bug: `collect_search_enqueue` used to
15689    // filter for `SearchBackend::OpenSearch` only, so a Meilisearch-backed
15690    // SearchIndex never got an outbox row on insert/update/delete — the
15691    // Meilisearch worker (Rust or the old Python one) never received real
15692    // traffic from normal DML, only from a manually-inserted outbox row.
15693
15694    fn make_schema_with_search_index(backend: crate::schema::SearchBackend) -> SchemaDescriptor {
15695        use crate::schema::{SearchIndexDescriptor, SearchPointerDescriptor, SearchWeight};
15696        let mut s = make_schema();
15697        if let Some(td) = s.types.iter_mut().find(|t| t.name == "Person") {
15698            td.search_indexes.push(SearchIndexDescriptor {
15699                index_name: None,
15700                backend,
15701                pointers: vec![SearchPointerDescriptor {
15702                    name: "name".into(),
15703                    weight: SearchWeight::A,
15704                }],
15705            });
15706        }
15707        s
15708    }
15709
15710    #[test]
15711    fn test_insert_enqueues_a_meilisearch_outbox_row() {
15712        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
15713        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
15714        assert!(
15715            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
15716            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
15717            out.sql,
15718        );
15719        assert!(
15720            out.sql.contains("INSERT INTO _pylon.\"IndexOutbox\""),
15721            "got:\n{}",
15722            out.sql
15723        );
15724    }
15725
15726    #[test]
15727    fn test_insert_enqueues_an_opensearch_outbox_row() {
15728        let schema = make_schema_with_search_index(crate::schema::SearchBackend::OpenSearch);
15729        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
15730        assert!(
15731            out.sql.contains("'OpenSearch'::_pylon.\"IndexKind\""),
15732            "expected an OpenSearch outbox enqueue CTE, got:\n{}",
15733            out.sql,
15734        );
15735    }
15736
15737    #[test]
15738    fn test_insert_does_not_enqueue_an_outbox_row_for_a_postgres_backed_search_index() {
15739        // Postgres-backed search indexes are maintained synchronously by a
15740        // trigger-updated tsvector column — no async worker involved.
15741        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Postgres);
15742        let out = compile_and_emit_with("INSERT Person { name := 'Alice', age := 30 }", &schema);
15743        assert!(
15744            !out.sql.contains("_pylon.\"IndexOutbox\""),
15745            "did not expect an outbox enqueue, got:\n{}",
15746            out.sql
15747        );
15748    }
15749
15750    #[test]
15751    fn test_update_enqueues_a_meilisearch_outbox_row() {
15752        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
15753        let out = compile_and_emit_with("UPDATE Person FILTER .name = 'Alice' SET { age := 31 }", &schema);
15754        assert!(
15755            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
15756            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
15757            out.sql,
15758        );
15759    }
15760
15761    #[test]
15762    fn test_delete_enqueues_a_meilisearch_outbox_delete_job() {
15763        let schema = make_schema_with_search_index(crate::schema::SearchBackend::Meilisearch);
15764        let out = compile_and_emit_with("DELETE Person FILTER .name = 'Alice'", &schema);
15765        assert!(
15766            out.sql.contains("'Meilisearch'::_pylon.\"IndexKind\""),
15767            "expected a Meilisearch outbox enqueue CTE, got:\n{}",
15768            out.sql,
15769        );
15770        assert!(
15771            out.sql.contains("'delete'"),
15772            "expected the delete operation literal, got:\n{}",
15773            out.sql
15774        );
15775    }
15776
15777    #[test]
15778    fn test_range_intrinsic_resolves_int_literals_to_int8range() {
15779        // Regression: std::range's ImplStrategy::TranspilerIntrinsic had no
15780        // actual substitution anywhere — it fell through resolve_fn_call's
15781        // catch-all, which emitted a literal (nonexistent) `"std"."range"(...)`
15782        // call instead of a real PostgreSQL range constructor.
15783        let out = compile_and_emit("SELECT std::overlaps(std::range(1, 3), std::range(2, 5))");
15784        assert!(out.sql.contains("int8range(1, 3)"), "got:\n{}", out.sql);
15785        assert!(out.sql.contains("int8range(2, 5)"), "got:\n{}", out.sql);
15786        assert!(
15787            out.sql.contains(" && "),
15788            "expected infix && for overlaps, got:\n{}",
15789            out.sql
15790        );
15791        assert!(
15792            !out.sql.contains("\"std\""),
15793            "must not emit a literal std schema call, got:\n{}",
15794            out.sql
15795        );
15796    }
15797
15798    #[test]
15799    fn test_range_intrinsic_resolves_datetime_to_tstzrange() {
15800        let out =
15801            compile_and_emit("SELECT std::range(<datetime>'2024-01-01T00:00:00Z', <datetime>'2024-06-01T00:00:00Z')");
15802        assert!(out.sql.contains("tstzrange("), "got:\n{}", out.sql);
15803    }
15804
15805    /// Bounds written out as literals are folded into the constructor's own
15806    /// bounds string; only the default `[)` leaves it out entirely.
15807    #[test]
15808    fn test_range_intrinsic_named_bounds_become_a_bounds_string() {
15809        let out = compile_and_emit("SELECT std::range(1, 3, inc_lower := true, inc_upper := true)");
15810        assert!(out.sql.contains("int8range(1, 3, '[]')"), "got:\n{}", out.sql);
15811        let out = compile_and_emit("SELECT std::range(1, 3, inc_lower := false)");
15812        assert!(out.sql.contains("int8range(1, 3, '()')"), "got:\n{}", out.sql);
15813    }
15814
15815    /// A bound flag that is not a literal keeps the decision in the SQL.
15816    #[test]
15817    fn test_range_intrinsic_computes_a_bounds_string_from_an_expression() {
15818        let out = compile_and_emit("SELECT std::range(1, 3, inc_upper := <bool>$closed)");
15819        assert!(out.sql.contains("CASE WHEN"), "got:\n{}", out.sql);
15820    }
15821
15822    /// An open-ended range names only the bound it has.
15823    #[test]
15824    fn test_range_intrinsic_leaves_a_missing_upper_bound_unbounded() {
15825        let out = compile_and_emit("SELECT std::range(1)");
15826        assert!(out.sql.contains("int8range(1, NULL)"), "got:\n{}", out.sql);
15827    }
15828
15829    /// A call that writes a named-only argument by position, one that leaves a
15830    /// required one out, and one no overload accepts each say which.
15831    #[test]
15832    fn test_a_named_only_parameter_is_reported_as_named_only() {
15833        assert!(
15834            compile_err("SELECT std::range(1, 5, true, false)").contains("takes 'inc_lower' as a named argument only"),
15835            "{}",
15836            compile_err("SELECT std::range(1, 5, true, false)")
15837        );
15838        assert!(
15839            compile_err("SELECT std::json_set(std::to_json('{}'), 'a', std::to_json('1'))")
15840                .contains("requires the named argument 'value'"),
15841            "{}",
15842            compile_err("SELECT std::json_set(std::to_json('{}'), 'a', std::to_json('1'))")
15843        );
15844        // Counted by positions, not by the whole parameter list.
15845        assert!(
15846            compile_err("SELECT std::range()").contains("takes 1 or 2 argument(s), got 0"),
15847            "{}",
15848            compile_err("SELECT std::range()")
15849        );
15850        // And an overload's named-only parameters are spelled as such.
15851        let message = compile_err("SELECT std::json_get(std::to_json('{}'), 'a', 1, 2)");
15852        assert!(message.contains("default := optional<json>"), "{message}");
15853    }
15854
15855    #[test]
15856    fn test_range_intrinsic_builds_the_empty_range() {
15857        let out = compile_and_emit("SELECT std::range(1, 3, empty := true)");
15858        assert!(out.sql.contains("'empty'::int8range"), "got:\n{}", out.sql);
15859    }
15860
15861    #[test]
15862    fn test_multirange_intrinsic_resolves_from_range_element() {
15863        let out = compile_and_emit("SELECT std::multirange([std::range(1, 3), std::range(5, 7)])");
15864        assert!(out.sql.contains("int8multirange(VARIADIC "), "got:\n{}", out.sql);
15865        assert!(
15866            !out.sql.contains("\"std\""),
15867            "must not emit a literal std schema call, got:\n{}",
15868            out.sql
15869        );
15870    }
15871}