1use 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
59fn 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
108const PARTITIONABLE_PG_TYPES: [&str; 3] = ["timestamptz", "timestamp", "date"];
113
114pub 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 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 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 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
201pub fn validate_schema_types(schema: &SchemaDescriptor) -> Result<(), Vec<PyQLError>> {
208 let mut errors = validate_partitions(schema);
209
210 for fd in &schema.functions {
211 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}