Skip to main content

pylon_core/
validate.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
20//! Post-build schema validation.
21//!
22//! `walk()` (the Python schema builder) checks structure — duplicate names,
23//! dangling references, link cycles, interface conformance — but every PyQL
24//! body embedded in the schema (function bodies, computed pointers,
25//! defaults, mutation rewrites, triggers, aliases, computed globals) used to
26//! only ever get compiled lazily, the first time something actually
27//! exercised it (a query, a migration, a DDL export) — so a broken one could
28//! sit undetected in the schema indefinitely. This module closes that gap by
29//! eagerly compiling every one of them at `finalize()` time.
30//!
31//! Two kinds of check:
32//! - **Type consistency** (functions, computed pointers, defaults, rewrites)
33//!   — the body compiles *and* its inferred return type matches what's
34//!   declared. A body that does not compile at all is an error here, not a
35//!   skip: nothing downstream re-reports it, because `export`'s
36//!   `column_default` and `diff`'s `resolve_default` both drop an
37//!   uncompilable default with `.ok()`, which is how a pointer declared
38//!   `Default('std::uuid_generate_v7j()')` — a function that does not exist
39//!   — used to reach Postgres as a column with no default at all and fail
40//!   on the first insert instead.
41//!
42//!   The type half is still not exhaustive: `infer_ir_type` types what it
43//!   recognizes (column refs, casts, literals, enum members, named tuples,
44//!   function params, global params, slices, arithmetic, and any function
45//!   call whose resolution recorded a scalar return type) and anything it
46//!   cannot type is skipped rather than rejected.
47//! - **Compile-only** (triggers, aliases, computed globals) — these have no
48//!   single declared scalar type to compare against (a trigger handler is
49//!   void, an alias/computed-global can select any shape), so only "does it
50//!   compile" is checked.
51
52use crate::error::{Position, PyQLError, PyQLFragmentError};
53use crate::ir::{
54    IrFreeExpr, IrRowSource, IrStmt, compile, compile_fn_body, compile_scalar_default_typed, compile_trigger_handler,
55    infer_ir_type, types_compatible,
56};
57use crate::schema::SchemaDescriptor;
58
59/// Why `expr` yields more than one value, if it does.
60///
61/// A pointer declared with a single scalar type (`Computed[pylon.Str, …]`)
62/// promises one value per row. Two expressions quietly break that promise:
63/// a path crossing a multilink, which compiles to `ARRAY(SELECT …)` and so
64/// hands back a `text[]` behind a declared `text`; and a call to a
65/// set-returning function, which PostgreSQL expands into rows wherever it
66/// sits. Neither produced an error before — the first widened the value
67/// silently and the second only failed once a query ran.
68///
69/// Only the top of the expression is examined, looking through the wrappers
70/// a computed routinely picks up (a cast, a `coalesce`) — which covers a
71/// pointer whose whole body is the offending expression, the form both of
72/// these actually take. One buried inside a larger expression is not caught
73/// here.
74fn multi_valued(expr: &crate::ir::IrExpr, schema: &SchemaDescriptor) -> Option<String> {
75    use crate::ir::IrExpr as E;
76    match expr {
77        E::FunctionCall(f) if f.schema.is_none() && f.name == "coalesce" => {
78            f.args.first().and_then(|a| multi_valued(a, schema))
79        }
80        E::TypeCast(c) => multi_valued(&c.expr, schema),
81        E::ArrayFromSelect(_) => Some("a path that crosses a multilink, so it yields many values".into()),
82        E::SetOp { mode, .. } if *mode == crate::ir::SetOpMode::Array => {
83            Some("a set operation, so it yields many values".into())
84        }
85        E::FunctionCall(f) => {
86            let module = f.schema.as_deref()?;
87            let fd = schema
88                .functions
89                .iter()
90                .find(|d| d.module == module && d.name == f.name && d.return_is_set)?;
91            Some(format!(
92                "a call to set-returning function '{}::{}', so it yields many values",
93                fd.module, fd.name
94            ))
95        }
96        _ => None,
97    }
98}
99
100fn mismatch(context: String, message: String) -> PyQLError {
101    PyQLError::Fragment(PyQLFragmentError {
102        message,
103        position: Position { line: 0, col: 0 },
104        context,
105    })
106}
107
108/// Partition-key column types PostgreSQL range partitioning is supported on
109/// here. Range partitioning works on any orderable type, but the automatic
110/// "create the next N ranges, drop past retention" maintenance only makes
111/// sense against time.
112const PARTITIONABLE_PG_TYPES: [&str; 3] = ["timestamptz", "timestamp", "date"];
113
114/// Validates every `Partition` declaration in `schema`.
115///
116/// Each of these is a constraint PostgreSQL itself would reject later — but
117/// later means at migration time, as a raw Postgres error against generated
118/// DDL. Catching them here reports them against the schema the author wrote.
119pub fn validate_partitions(schema: &SchemaDescriptor) -> Vec<PyQLError> {
120    let mut errors = Vec::new();
121
122    for td in &schema.types {
123        let Some(part) = &td.partition else { continue };
124        let type_name = format!("{}::{}", td.module, td.name);
125        let context = format!("{type_name} (partition)");
126
127        // An abstract type has no table, so there is nothing to partition —
128        // its fields flatten into each concrete subtype, and partitioning
129        // each of those is a decision each one has to make for itself.
130        if td.abstract_ && !td.materialized {
131            errors.push(mismatch(
132                context.clone(),
133                format!(
134                    "type '{type_name}' is abstract and has no table of its own, so it cannot declare a Partition — \
135                     declare it on each concrete type instead"
136                ),
137            ));
138            continue;
139        }
140        // An interface is a view over its implementors; a view has no
141        // storage to partition either.
142        if td.abstract_ && td.materialized {
143            errors.push(mismatch(
144                context.clone(),
145                format!(
146                    "type '{type_name}' is an interface, backed by a view rather than a table, so it cannot declare \
147                     a Partition — declare it on each implementing type instead"
148                ),
149            ));
150            continue;
151        }
152
153        let Some(prop) = td.properties.iter().find(|p| p.name == part.pointer) else {
154            errors.push(mismatch(
155                context.clone(),
156                format!(
157                    "Partition on '{type_name}' names pointer '{}', which is not a property of this type",
158                    part.pointer
159                ),
160            ));
161            continue;
162        };
163
164        if !PARTITIONABLE_PG_TYPES.contains(&prop.pg_type.as_str()) {
165            errors.push(mismatch(
166                context.clone(),
167                format!(
168                    "Partition on '{type_name}' names property '{}' of type '{}' — the partition key must be a \
169                     datetime or date property",
170                    part.pointer, prop.pg_type
171                ),
172            ));
173        }
174
175        // PostgreSQL rejects a NULL partition key outright: there is no
176        // range for it to land in.
177        if prop.nullable {
178            errors.push(mismatch(
179                context.clone(),
180                format!(
181                    "Partition on '{type_name}' names optional property '{}' — a partition key can never be empty",
182                    part.pointer
183                ),
184            ));
185        }
186
187        if part.premake == 0 {
188            errors.push(mismatch(
189                context.clone(),
190                format!(
191                    "Partition on '{type_name}' has premake=0 — with no future partitions pre-created, the first \
192                     write past the current range fails"
193                ),
194            ));
195        }
196    }
197
198    errors
199}
200
201/// Compile every user function body, computed-pointer expression, and
202/// property/link default in `schema`, and collect every declared-vs-actual
203/// return-type mismatch found — not fail-fast, so a caller can report every
204/// problem in the schema at once rather than a fix-one-rerun loop. Partition
205/// declarations (`validate_partitions`) are checked in the same pass, for the
206/// same reason.
207pub fn validate_schema_types(schema: &SchemaDescriptor) -> Result<(), Vec<PyQLError>> {
208    let mut errors = validate_partitions(schema);
209
210    for fd in &schema.functions {
211        // Object-returning functions build a row shape, not a single scalar
212        // IrExpr — matching-object-shape correctness is a separate, larger
213        // problem than this pass's scope.
214        if fd.return_is_object {
215            continue;
216        }
217        let context = format!("{}::{}", fd.module, fd.name);
218        let ir_output = match compile_fn_body(fd, schema) {
219            Ok(o) => o,
220            Err(e) => {
221                errors.push(e);
222                continue;
223            }
224        };
225        let IrStmt::Select(sel) = &ir_output.stmt else { continue };
226        let [IrRowSource::Free(IrFreeExpr::Scalar(e))] = sel.rows.as_slice() else {
227            continue;
228        };
229        let Some(actual) = infer_ir_type(e) else { continue };
230        if !types_compatible(actual, &fd.return_pg_type) {
231            errors.push(mismatch(
232                context.clone(),
233                format!(
234                    "return type mismatch in function '{}': declared {}, body produces {}",
235                    context, fd.return_pg_type, actual
236                ),
237            ));
238        }
239    }
240
241    for td in &schema.types {
242        let type_name = format!("{}::{}", td.module, td.name);
243
244        for cd in &td.computed {
245            let Some(declared) = &cd.return_type else { continue };
246            let context = format!("{}.{} (computed)", type_name, cd.name);
247            // Compiled as the pointer it is, not as a bare expression: a
248            // computed that selects objects (`(select .emails limit 1)`)
249            // legitimately has no scalar type, and compiling it as an
250            // expression would reject it instead of skipping the check.
251            let ir = match crate::ir::compile_computed_in_type(cd, &type_name, schema) {
252                Ok(Some(ir)) => ir,
253                Ok(None) => continue,
254                Err(e) => {
255                    errors.push(e);
256                    continue;
257                }
258            };
259            // Cardinality before type: an array-valued expression has no
260            // scalar type to compare, so reporting the mismatch as a *type*
261            // error would name the wrong problem even when one is inferable.
262            // A computed that declares an array type is asking for the many
263            // values and is left alone.
264            if !declared.ends_with("[]")
265                && let Some(why) = multi_valued(&ir, schema)
266            {
267                errors.push(mismatch(
268                    context.clone(),
269                    format!(
270                        "cardinality mismatch in computed pointer '{context}': declared {declared}, a single \
271                         value, but the expression is {why} — declare it as an array \
272                         (e.g. Computed[pylon.Array[...], …]) or reduce it to one value \
273                         (e.g. with 'limit 1', 'assert_single()', or an aggregate)"
274                    ),
275                ));
276                continue;
277            }
278            let Some(actual) = infer_ir_type(&ir) else { continue };
279            if !types_compatible(actual, declared) {
280                errors.push(mismatch(
281                    context.clone(),
282                    format!(
283                        "return type mismatch in computed pointer '{}': declared {}, expression produces {}",
284                        context, declared, actual
285                    ),
286                ));
287            }
288        }
289
290        for prop in &td.properties {
291            let Some(pyql) = &prop.default_pyql else { continue };
292            let context = format!("{}.{} (default)", type_name, prop.name);
293            // A default a column DEFAULT cannot hold is expanded into the
294            // insert instead, and checked there — see the loop over
295            // `inlined_pointer_defaults` below, which reports for the
296            // concrete types this abstract one's pointers land on.
297            let Ok((_, ir)) = compile_scalar_default_typed(pyql, schema) else {
298                continue;
299            };
300            if crate::ir::default_blocker(&ir).is_some() {
301                continue;
302            }
303            let Some(actual) = infer_ir_type(&ir) else { continue };
304            if !types_compatible(actual, &prop.pg_type) {
305                errors.push(mismatch(
306                    context.clone(),
307                    format!(
308                        "default value type mismatch for '{}': expected {}, default produces {}",
309                        context, prop.pg_type, actual
310                    ),
311                ));
312            }
313        }
314
315        for link in &td.links {
316            let Some(pyql) = &link.default_pyql else { continue };
317            let context = format!("{}.{} (default)", type_name, link.name);
318            // As for a property above: one a column DEFAULT cannot hold is
319            // the insert's to apply, and the insert's to be checked against.
320            let Ok((_, ir)) = compile_scalar_default_typed(pyql, schema) else {
321                continue;
322            };
323            if crate::ir::default_blocker(&ir).is_some() {
324                continue;
325            }
326            let Some(actual) = infer_ir_type(&ir) else { continue };
327            if !types_compatible(actual, "uuid") {
328                errors.push(mismatch(
329                    context.clone(),
330                    format!(
331                        "default value type mismatch for '{}': expected uuid, default produces {}",
332                        context, actual
333                    ),
334                ));
335            }
336        }
337
338        // Defaults a column DEFAULT cannot hold, compiled the way an insert
339        // expands them into its own shape. A default that compiles as neither
340        // is a real error, and this is where it surfaces.
341        if !td.abstract_ && !td.junction {
342            for (pointer, pyql) in crate::ir::inlined_pointer_defaults(td, schema) {
343                let context = format!("{type_name}.{pointer} (default)");
344                let (column, ir) = match crate::ir::compile_inlined_default(&type_name, &pointer, &pyql, schema) {
345                    Ok(assignment) => assignment,
346                    Err(e) => {
347                        errors.push(mismatch(context.clone(), format!("default for '{context}': {e}")));
348                        continue;
349                    }
350                };
351                let Some(actual) = infer_ir_type(&ir) else { continue };
352                let declared = td
353                    .properties
354                    .iter()
355                    .find(|p| p.name == column)
356                    .map(|p| p.pg_type.as_str())
357                    .unwrap_or("uuid");
358                if !types_compatible(actual, declared) {
359                    errors.push(mismatch(
360                        context.clone(),
361                        format!(
362                            "default value type mismatch for '{context}': expected {declared}, \
363                             default produces {actual}"
364                        ),
365                    ));
366                }
367            }
368        }
369
370        // Mutation rewrites, compiled the way the type's `BEFORE` triggers
371        // run them: against the row being written.
372        if !td.abstract_ && !td.junction {
373            for on in [1u8, 2] {
374                let assignments = match crate::ir::compile_rewrite_assignments(&type_name, on, schema) {
375                    Ok(assignments) => assignments,
376                    Err(e) => {
377                        let context = format!("{type_name} (rewrite)");
378                        errors.push(mismatch(context.clone(), format!("{context}: {e}")));
379                        continue;
380                    }
381                };
382                for assignment in assignments {
383                    let pg_type = td
384                        .properties
385                        .iter()
386                        .find(|p| p.name == assignment.pointer)
387                        .map(|p| p.pg_type.as_str())
388                        .unwrap_or("uuid");
389                    let Some(actual) = infer_ir_type(&assignment.ir) else {
390                        continue;
391                    };
392                    if !types_compatible(actual, pg_type) {
393                        let context = format!("{}.{} (rewrite)", type_name, assignment.pointer);
394                        errors.push(mismatch(
395                            context.clone(),
396                            format!(
397                                "rewrite handler type mismatch for '{}': expected {}, handler produces {}",
398                                context, pg_type, actual
399                            ),
400                        ));
401                    }
402                }
403            }
404        }
405
406        // Schema-defined triggers: compile-only — a trigger handler has no
407        // declared return type to check (it's a side-effecting statement,
408        // not a value producer), but it currently only ever gets compiled
409        // at DDL-emission time (`export::emit_triggers`), so a broken
410        // handler on a type nobody's exported yet would otherwise pass
411        // `finalize()` silently.
412        for trig in &td.triggers {
413            if let Err(e) = compile_trigger_handler(&trig.handler, &type_name, trig.on, schema) {
414                let context = format!("{type_name} (trigger)");
415                let handler: String = trig.handler.chars().take(80).collect();
416                errors.push(mismatch(context.clone(), format!("{context} `{handler}…`: {e}")));
417            }
418        }
419    }
420
421    // Schema aliases: compile-only — an alias has no declared return type at
422    // all (it's just a named query fragment that can select any shape, not
423    // just a scalar), so there's nothing to type-compare against. But like
424    // triggers, an alias's own body currently only ever gets compiled lazily,
425    // the first time a query actually references it (`try_compile_alias_select`)
426    // — so a broken alias nobody's queried yet would otherwise pass
427    // `finalize()` silently.
428    for alias in &schema.aliases {
429        let parsed = match crate::parse::parse(&alias.expr) {
430            Ok(ast) => ast,
431            Err(e) => {
432                errors.push(PyQLError::Syntax(e));
433                continue;
434            }
435        };
436        if let Err(e) = compile(&parsed, schema) {
437            errors.push(e);
438        }
439    }
440
441    // Computed globals: compile-only, same reasoning as aliases — a computed
442    // global (`select User filter .id = global current_user_id`) can select
443    // any shape, not just a scalar matching `scalar_type`, and it's also
444    // only ever compiled lazily today, the first time a query references it
445    // (`Compiler::compile_global`). `Global.default_expr` (as opposed to
446    // `computed_expr`) is deliberately not checked here — it's always a raw
447    // SQL literal built from a plain Python value at schema-build time
448    // (`_python_value_to_sql`), never a PyQL expression, so there's nothing
449    // to compile.
450    for global in &schema.globals {
451        let Some(computed_expr) = &global.computed_expr else {
452            continue;
453        };
454        let parsed = match crate::parse::parse(computed_expr) {
455            Ok(ast) => ast,
456            Err(e) => {
457                errors.push(PyQLError::Syntax(e));
458                continue;
459            }
460        };
461        if let Err(e) = compile(&parsed, schema) {
462            errors.push(e);
463        }
464    }
465
466    if errors.is_empty() { Ok(()) } else { Err(errors) }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::schema::{
473        AliasDescriptor, ComputedDescriptor, FunctionDescriptor, FunctionParamDescriptor, GlobalDescriptor,
474        LinkDescriptor, PropertyDescriptor, RewriteEntry, TriggerDescriptor, TypeDescriptor,
475    };
476
477    // ── Partition validation ──────────────────────────────────────────
478
479    fn ts_prop(name: &str, nullable: bool) -> PropertyDescriptor {
480        PropertyDescriptor {
481            name: name.into(),
482            pg_type: "timestamptz".into(),
483            nullable,
484            default_sql: None,
485            default_pyql: None,
486            description: None,
487            check_constraints: vec![],
488            is_exclusive: false,
489            is_pk: false,
490            is_readonly: false,
491            rewrites: vec![],
492            tuple_members: None,
493            column_type: None,
494        }
495    }
496
497    fn partitioned(part: crate::schema::PartitionDescriptor, props: Vec<PropertyDescriptor>) -> SchemaDescriptor {
498        let mut td = person_type(vec![], props);
499        td.partition = Some(part);
500        SchemaDescriptor {
501            types: vec![td],
502            ..Default::default()
503        }
504    }
505
506    fn monthly(pointer: &str) -> crate::schema::PartitionDescriptor {
507        crate::schema::PartitionDescriptor {
508            pointer: pointer.into(),
509            interval: crate::schema::PartitionInterval::Monthly,
510            premake: 4,
511            retention: None,
512        }
513    }
514
515    fn only_error(schema: &SchemaDescriptor) -> String {
516        let errors = validate_partitions(schema);
517        assert_eq!(errors.len(), 1, "expected exactly one error, got {errors:#?}");
518        errors[0].to_string()
519    }
520
521    #[test]
522    fn a_valid_partition_passes() {
523        let schema = partitioned(monthly("occurred_at"), vec![ts_prop("occurred_at", false)]);
524        assert!(validate_partitions(&schema).is_empty());
525    }
526
527    #[test]
528    fn a_partition_on_an_abstract_type_is_rejected() {
529        // An abstract type has no table to partition — its fields flatten
530        // into each concrete subtype.
531        let mut schema = partitioned(monthly("occurred_at"), vec![ts_prop("occurred_at", false)]);
532        schema.types[0].abstract_ = true;
533        schema.types[0].materialized = false;
534        assert!(only_error(&schema).contains("abstract"));
535    }
536
537    #[test]
538    fn a_partition_on_an_interface_is_rejected() {
539        // An interface is a view; a view has no storage either.
540        let mut schema = partitioned(monthly("occurred_at"), vec![ts_prop("occurred_at", false)]);
541        schema.types[0].abstract_ = true;
542        schema.types[0].materialized = true;
543        assert!(only_error(&schema).contains("interface"));
544    }
545
546    #[test]
547    fn a_partition_on_an_unknown_pointer_is_rejected() {
548        let schema = partitioned(monthly("nope"), vec![ts_prop("occurred_at", false)]);
549        assert!(only_error(&schema).contains("not a property"));
550    }
551
552    #[test]
553    fn a_partition_on_a_non_temporal_property_is_rejected() {
554        let mut prop = ts_prop("occurred_at", false);
555        prop.pg_type = "text".into();
556        let schema = partitioned(monthly("occurred_at"), vec![prop]);
557        assert!(only_error(&schema).contains("datetime or date"));
558    }
559
560    #[test]
561    fn a_partition_on_an_optional_property_is_rejected() {
562        // PostgreSQL has no range for a NULL key to land in.
563        let schema = partitioned(monthly("occurred_at"), vec![ts_prop("occurred_at", true)]);
564        assert!(only_error(&schema).contains("can never be empty"));
565    }
566
567    #[test]
568    fn premake_zero_is_rejected() {
569        let mut part = monthly("occurred_at");
570        part.premake = 0;
571        let schema = partitioned(part, vec![ts_prop("occurred_at", false)]);
572        assert!(only_error(&schema).contains("premake=0"));
573    }
574
575    #[test]
576    fn date_and_naive_timestamp_keys_are_accepted() {
577        for pg_type in ["date", "timestamp"] {
578            let mut prop = ts_prop("occurred_at", false);
579            prop.pg_type = pg_type.into();
580            let schema = partitioned(monthly("occurred_at"), vec![prop]);
581            assert!(
582                validate_partitions(&schema).is_empty(),
583                "{pg_type} should be a valid partition key"
584            );
585        }
586    }
587
588    #[test]
589    fn retention_renders_as_a_postgres_interval() {
590        use crate::schema::{PartitionDescriptor, PartitionInterval};
591        let with = |interval, retention| {
592            PartitionDescriptor {
593                pointer: "t".into(),
594                interval,
595                premake: 4,
596                retention: Some(retention),
597            }
598            .retention_interval()
599        };
600        assert_eq!(with(PartitionInterval::Daily, 30), Some("30 days".to_string()));
601        assert_eq!(with(PartitionInterval::Monthly, 12), Some("12 months".to_string()));
602        assert_eq!(with(PartitionInterval::Yearly, 7), Some("7 years".to_string()));
603        assert_eq!(monthly("t").retention_interval(), None);
604    }
605
606    fn person_type(computed: Vec<ComputedDescriptor>, properties: Vec<PropertyDescriptor>) -> TypeDescriptor {
607        let mut props = vec![PropertyDescriptor {
608            name: "id".into(),
609            pg_type: "uuid".into(),
610            nullable: false,
611            default_sql: Some("uuidv7()".into()),
612            default_pyql: None,
613            description: None,
614            check_constraints: vec![],
615            is_exclusive: true,
616            is_pk: true,
617            is_readonly: false,
618            rewrites: vec![],
619            tuple_members: None,
620            column_type: None,
621        }];
622        props.extend(properties);
623        TypeDescriptor {
624            name: "Person".into(),
625            module: "default".into(),
626            table: "default_person".into(),
627            abstract_: false,
628            materialized: true,
629            description: None,
630            parents: vec![],
631            interfaces: vec![],
632            bases: vec![],
633            properties: props,
634            links: vec![],
635            multilinks: vec![],
636            computed,
637            constraints: vec![],
638            indexes: vec![],
639            partition: None,
640            vector_indexes: vec![],
641            search_indexes: vec![],
642            triggers: vec![],
643            junction: false,
644            signals: vec![],
645        }
646    }
647
648    fn base_property(name: &str, pg_type: &str) -> PropertyDescriptor {
649        PropertyDescriptor {
650            name: name.into(),
651            pg_type: pg_type.into(),
652            nullable: false,
653            default_sql: None,
654            default_pyql: None,
655            description: None,
656            check_constraints: vec![],
657            is_exclusive: false,
658            is_pk: false,
659            is_readonly: false,
660            rewrites: vec![],
661            tuple_members: None,
662            column_type: None,
663        }
664    }
665
666    fn minimal_schema(types: Vec<TypeDescriptor>, functions: Vec<FunctionDescriptor>) -> SchemaDescriptor {
667        SchemaDescriptor {
668            types,
669            scalars: vec![],
670            enums: vec![],
671            named_tuples: vec![],
672            globals: vec![],
673            functions,
674            aliases: vec![],
675            channels: vec![],
676            ..Default::default()
677        }
678    }
679
680    #[test]
681    fn function_return_type_match_passes() {
682        let fd = FunctionDescriptor {
683            name: "myid".into(),
684            module: "default".into(),
685            params: vec![FunctionParamDescriptor {
686                name: "a".into(),
687                pg_type: "int8".into(),
688            }],
689            return_pg_type: "int8".into(),
690            body: "a".into(),
691            return_is_object: false,
692            return_is_set: false,
693            return_is_polymorphic: false,
694            volatility: "immutable".into(),
695        };
696        let schema = minimal_schema(vec![], vec![fd]);
697        assert!(validate_schema_types(&schema).is_ok());
698    }
699
700    #[test]
701    fn function_return_type_mismatch_rejected() {
702        let fd = FunctionDescriptor {
703            name: "bad".into(),
704            module: "default".into(),
705            params: vec![FunctionParamDescriptor {
706                name: "a".into(),
707                pg_type: "text".into(),
708            }],
709            return_pg_type: "int8".into(),
710            body: "a".into(),
711            return_is_object: false,
712            return_is_set: false,
713            return_is_polymorphic: false,
714            volatility: "immutable".into(),
715        };
716        let schema = minimal_schema(vec![], vec![fd]);
717        let errs = validate_schema_types(&schema).unwrap_err();
718        assert_eq!(errs.len(), 1);
719        let (_, msg, _) = errs[0].class_name_message_position();
720        assert!(msg.contains("bad"), "{msg}");
721        assert!(msg.contains("declared int8"), "{msg}");
722        assert!(msg.contains("produces text"), "{msg}");
723    }
724
725    #[test]
726    fn function_call_body_return_type_is_checked() {
727        // A body that is a stdlib call is typed by the overload that call
728        // resolves to, so a declared type the call cannot produce is caught
729        // here rather than at the first query that runs it. `str_lower`
730        // returns text, not int8.
731        let fd = FunctionDescriptor {
732            name: "caller".into(),
733            module: "default".into(),
734            params: vec![],
735            return_pg_type: "int8".into(),
736            body: "str_lower('X')".into(),
737            return_is_object: false,
738            return_is_set: false,
739            return_is_polymorphic: false,
740            volatility: "immutable".into(),
741        };
742        let schema = minimal_schema(vec![], vec![fd]);
743        let errs = validate_schema_types(&schema).unwrap_err();
744        let msg = errs[0].to_string();
745        assert!(msg.contains("declared int8"), "{msg}");
746        assert!(msg.contains("produces text"), "{msg}");
747    }
748
749    #[test]
750    fn a_function_body_calling_a_user_function_checks_its_return_type() {
751        // The caller's declared type is checked against the *callee's*
752        // declared type — which needs the call itself to carry a type, not
753        // just the stdlib ones.
754        let callee = FunctionDescriptor {
755            name: "gives_text".into(),
756            module: "default".into(),
757            params: vec![],
758            return_pg_type: "text".into(),
759            body: "'x'".into(),
760            return_is_object: false,
761            return_is_set: false,
762            return_is_polymorphic: false,
763            volatility: "immutable".into(),
764        };
765        let caller = FunctionDescriptor {
766            name: "caller".into(),
767            module: "default".into(),
768            params: vec![],
769            return_pg_type: "int8".into(),
770            body: "default::gives_text()".into(),
771            return_is_object: false,
772            return_is_set: false,
773            return_is_polymorphic: false,
774            volatility: "immutable".into(),
775        };
776        let schema = minimal_schema(vec![], vec![callee, caller]);
777        let errs = validate_schema_types(&schema).unwrap_err();
778        let msg = errs[0].to_string();
779        assert!(msg.contains("declared int8"), "{msg}");
780        assert!(msg.contains("produces text"), "{msg}");
781    }
782
783    #[test]
784    fn computed_return_type_match_passes() {
785        let cd = ComputedDescriptor {
786            name: "double_id".into(),
787            expression: ".id".into(),
788            return_type: Some("uuid".into()),
789            link_target: None,
790            link_multi: false,
791        };
792        let td = person_type(vec![cd], vec![]);
793        let schema = minimal_schema(vec![td], vec![]);
794        assert!(validate_schema_types(&schema).is_ok());
795    }
796
797    #[test]
798    fn computed_return_type_mismatch_rejected() {
799        let cd = ComputedDescriptor {
800            name: "bad".into(),
801            expression: ".id".into(),
802            return_type: Some("text".into()),
803            link_target: None,
804            link_multi: false,
805        };
806        let td = person_type(vec![cd], vec![]);
807        let schema = minimal_schema(vec![td], vec![]);
808        let errs = validate_schema_types(&schema).unwrap_err();
809        assert_eq!(errs.len(), 1);
810        let (_, msg, _) = errs[0].class_name_message_position();
811        assert!(msg.contains("Person.bad"), "{msg}");
812    }
813
814    #[test]
815    fn default_type_match_passes() {
816        let mut prop = base_property("score", "int8");
817        prop.default_pyql = Some("1".into());
818        let td = person_type(vec![], vec![prop]);
819        let schema = minimal_schema(vec![td], vec![]);
820        assert!(validate_schema_types(&schema).is_ok());
821    }
822
823    #[test]
824    fn default_type_mismatch_rejected() {
825        let mut prop = base_property("score", "int8");
826        prop.default_pyql = Some("'not a number'".into());
827        let td = person_type(vec![], vec![prop]);
828        let schema = minimal_schema(vec![td], vec![]);
829        let errs = validate_schema_types(&schema).unwrap_err();
830        assert_eq!(errs.len(), 1);
831        let (_, msg, _) = errs[0].class_name_message_position();
832        assert!(msg.contains("Person.score"), "{msg}");
833    }
834
835    #[test]
836    fn a_default_naming_a_function_that_does_not_exist_is_rejected() {
837        // The case this whole hard-fail exists for: a schema converted from
838        // a system whose own spelling was `uuid_generate_v7j` kept the name,
839        // and every consumer of the default dropped it with `.ok()` — so the
840        // column shipped with no DEFAULT at all and the first insert failed
841        // on NOT NULL, a long way from the declaration that caused it.
842        let mut prop = base_property("token", "uuid");
843        prop.default_pyql = Some("std::uuid_generate_v7j()".into());
844        let td = person_type(vec![], vec![prop]);
845        let schema = minimal_schema(vec![td], vec![]);
846        let errs = validate_schema_types(&schema).unwrap_err();
847        let (_, msg, _) = errs[0].class_name_message_position();
848        assert!(msg.contains("Person.token"), "{msg}");
849        assert!(msg.contains("does not exist"), "{msg}");
850    }
851
852    #[test]
853    fn a_default_calling_a_real_function_wrongly_is_rejected() {
854        let mut prop = base_property("name", "text");
855        prop.default_pyql = Some("std::str_lower('A', 'B')".into());
856        let td = person_type(vec![], vec![prop]);
857        let schema = minimal_schema(vec![td], vec![]);
858        let errs = validate_schema_types(&schema).unwrap_err();
859        let (_, msg, _) = errs[0].class_name_message_position();
860        assert!(msg.contains("Person.name"), "{msg}");
861        assert!(msg.contains("takes 1 argument(s), got 2"), "{msg}");
862    }
863
864    #[test]
865    fn a_default_that_is_a_valid_stdlib_call_still_passes() {
866        let mut prop = base_property("token", "uuid");
867        prop.default_pyql = Some("std::uuid_generate_v7()".into());
868        let td = person_type(vec![], vec![prop]);
869        let schema = minimal_schema(vec![td], vec![]);
870        assert!(validate_schema_types(&schema).is_ok());
871    }
872
873    #[test]
874    fn a_computed_calling_a_set_returning_function_declared_single_is_rejected() {
875        let fd = FunctionDescriptor {
876            name: "gives_many".into(),
877            module: "default".into(),
878            params: vec![],
879            return_pg_type: "int8".into(),
880            body: "{1, 2}".into(),
881            return_is_object: false,
882            return_is_set: true,
883            return_is_polymorphic: false,
884            volatility: "immutable".into(),
885        };
886        let cd = ComputedDescriptor {
887            name: "n".into(),
888            expression: "default::gives_many()".into(),
889            return_type: Some("int8".into()),
890            link_target: None,
891            link_multi: false,
892        };
893        let td = person_type(vec![cd], vec![]);
894        let schema = minimal_schema(vec![td], vec![fd]);
895        let errs = validate_schema_types(&schema).unwrap_err();
896        let (_, msg, _) = errs[0].class_name_message_position();
897        assert!(msg.contains("cardinality mismatch"), "{msg}");
898        assert!(msg.contains("gives_many"), "{msg}");
899    }
900
901    #[test]
902    fn a_computed_crossing_a_multilink_declared_single_is_rejected() {
903        // `.friends.name` compiles to `ARRAY(SELECT …)` — a text[] behind a
904        // pointer that declared plain text.
905        let cd = ComputedDescriptor {
906            name: "friend_names".into(),
907            expression: ".friends.name".into(),
908            return_type: Some("text".into()),
909            link_target: None,
910            link_multi: false,
911        };
912        let mut td = person_type(vec![cd], vec![base_property("name", "text")]);
913        td.multilinks = vec![crate::schema::MultiLinkDescriptor {
914            name: "friends".into(),
915            target: "default::Person".into(),
916            through: None,
917            nullable: true,
918            description: None,
919            default_pyql: None,
920            on_delete: vec![],
921            is_exclusive: false,
922        }];
923        let schema = minimal_schema(vec![td], vec![]);
924        let errs = validate_schema_types(&schema).unwrap_err();
925        let (_, msg, _) = errs[0].class_name_message_position();
926        assert!(msg.contains("cardinality mismatch"), "{msg}");
927        assert!(msg.contains("multilink"), "{msg}");
928    }
929
930    #[test]
931    fn the_same_computed_declared_as_an_array_passes() {
932        let cd = ComputedDescriptor {
933            name: "friend_names".into(),
934            expression: ".friends.name".into(),
935            return_type: Some("text[]".into()),
936            link_target: None,
937            link_multi: false,
938        };
939        let mut td = person_type(vec![cd], vec![base_property("name", "text")]);
940        td.multilinks = vec![crate::schema::MultiLinkDescriptor {
941            name: "friends".into(),
942            target: "default::Person".into(),
943            through: None,
944            nullable: true,
945            description: None,
946            default_pyql: None,
947            on_delete: vec![],
948            is_exclusive: false,
949        }];
950        let schema = minimal_schema(vec![td], vec![]);
951        assert!(validate_schema_types(&schema).is_ok());
952    }
953
954    #[test]
955    fn link_default_selecting_an_object_moves_into_the_insert() {
956        // `DEFAULT (SELECT …)` is DDL PostgreSQL refuses to run, so this one
957        // gets no column DEFAULT — the insert applies it instead, which is
958        // how every default reaching for an object is applied.
959        let mut td = person_type(vec![], vec![base_property("name", "text")]);
960        td.links = vec![LinkDescriptor {
961            name: "manager".into(),
962            target: "default::Person".into(),
963            nullable: true,
964            through: None,
965            description: None,
966            default_pyql: Some("(select Person filter .name = 'boss' limit 1)".into()),
967            is_exclusive: false,
968            is_readonly: false,
969            rewrites: vec![],
970            on_delete: vec![],
971        }];
972        let schema = minimal_schema(vec![td], vec![]);
973        validate_schema_types(&schema).expect("an inlined default is not an error");
974        let inlined = crate::ir::inlined_pointer_defaults(&schema.types[0], &schema);
975        assert_eq!(inlined.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["manager"]);
976    }
977
978    #[test]
979    fn link_default_that_is_a_constant_passes() {
980        let mut td = person_type(vec![], vec![base_property("name", "text")]);
981        td.links = vec![LinkDescriptor {
982            name: "manager".into(),
983            target: "default::Person".into(),
984            nullable: true,
985            through: None,
986            description: None,
987            default_pyql: Some("<uuid>'00000000-0000-0000-0000-000000000000'".into()),
988            is_exclusive: false,
989            is_readonly: false,
990            rewrites: vec![],
991            on_delete: vec![],
992        }];
993        let schema = minimal_schema(vec![td], vec![]);
994        assert!(validate_schema_types(&schema).is_ok());
995    }
996
997    #[test]
998    fn rewrite_type_match_passes() {
999        let mut prop = base_property("name", "text");
1000        prop.rewrites = vec![RewriteEntry {
1001            on: 1,
1002            handler: "'unnamed'".into(),
1003        }];
1004        let td = person_type(vec![], vec![prop]);
1005        let schema = minimal_schema(vec![td], vec![]);
1006        assert!(validate_schema_types(&schema).is_ok());
1007    }
1008
1009    #[test]
1010    fn rewrite_type_mismatch_rejected() {
1011        let mut prop = base_property("name", "text");
1012        prop.rewrites = vec![RewriteEntry {
1013            on: 1,
1014            handler: "1".into(),
1015        }];
1016        let td = person_type(vec![], vec![prop]);
1017        let schema = minimal_schema(vec![td], vec![]);
1018        let errs = validate_schema_types(&schema).unwrap_err();
1019        assert_eq!(errs.len(), 1);
1020        let (_, msg, _) = errs[0].class_name_message_position();
1021        assert!(msg.contains("Person.name (rewrite)"), "{msg}");
1022        assert!(msg.contains("expected text"), "{msg}");
1023    }
1024
1025    #[test]
1026    fn trigger_handler_compiles_passes() {
1027        let mut td = person_type(vec![], vec![]);
1028        td.triggers = vec![TriggerDescriptor {
1029            on: 1,
1030            timing: "After".into(),
1031            handler: "select Person".into(),
1032        }];
1033        let schema = minimal_schema(vec![td], vec![]);
1034        assert!(validate_schema_types(&schema).is_ok());
1035    }
1036
1037    #[test]
1038    fn trigger_handler_unknown_field_rejected() {
1039        let mut td = person_type(vec![], vec![]);
1040        td.triggers = vec![TriggerDescriptor {
1041            on: 1,
1042            timing: "After".into(),
1043            handler: "select Person filter .nonexistent_field = 1".into(),
1044        }];
1045        let schema = minimal_schema(vec![td], vec![]);
1046        let errs = validate_schema_types(&schema).unwrap_err();
1047        assert_eq!(errs.len(), 1);
1048    }
1049
1050    #[test]
1051    fn alias_compiles_passes() {
1052        let td = person_type(vec![], vec![]);
1053        let mut schema = minimal_schema(vec![td], vec![]);
1054        schema.aliases = vec![AliasDescriptor {
1055            name: "all_people".into(),
1056            module: "default".into(),
1057            expr: "select Person".into(),
1058        }];
1059        assert!(validate_schema_types(&schema).is_ok());
1060    }
1061
1062    #[test]
1063    fn alias_unknown_type_rejected() {
1064        let mut schema = minimal_schema(vec![], vec![]);
1065        schema.aliases = vec![AliasDescriptor {
1066            name: "bad".into(),
1067            module: "default".into(),
1068            expr: "select NoSuchType".into(),
1069        }];
1070        let errs = validate_schema_types(&schema).unwrap_err();
1071        assert_eq!(errs.len(), 1);
1072    }
1073
1074    fn base_global(name: &str) -> GlobalDescriptor {
1075        GlobalDescriptor {
1076            name: name.into(),
1077            module: "default".into(),
1078            scalar_type: "std::str".into(),
1079            required: false,
1080            default_expr: None,
1081            computed_expr: None,
1082        }
1083    }
1084
1085    #[test]
1086    fn computed_global_compiles_passes() {
1087        let td = person_type(vec![], vec![]);
1088        let mut schema = minimal_schema(vec![td], vec![]);
1089        let mut g = base_global("first_person");
1090        g.computed_expr = Some("select Person".into());
1091        schema.globals = vec![g];
1092        assert!(validate_schema_types(&schema).is_ok());
1093    }
1094
1095    #[test]
1096    fn computed_global_unknown_type_rejected() {
1097        let mut schema = minimal_schema(vec![], vec![]);
1098        let mut g = base_global("bad");
1099        g.computed_expr = Some("select NoSuchType".into());
1100        schema.globals = vec![g];
1101        let errs = validate_schema_types(&schema).unwrap_err();
1102        assert_eq!(errs.len(), 1);
1103    }
1104
1105    #[test]
1106    fn session_global_with_no_computed_expr_is_untouched() {
1107        // A plain (non-computed) global has only a `default_expr`, which is
1108        // always a pre-built raw SQL literal (see `_python_value_to_sql`),
1109        // never PyQL — nothing to compile, so this must never be flagged.
1110        let mut schema = minimal_schema(vec![], vec![]);
1111        let mut g = base_global("current_user_id");
1112        g.default_expr = Some("'not actually pyql, just a sql literal'".into());
1113        schema.globals = vec![g];
1114        assert!(validate_schema_types(&schema).is_ok());
1115    }
1116}