1use serde::{Deserialize, Serialize};
2
3use crate::contract::claim_enums::ColumnRole;
4use crate::contract::claim_payload::ClaimPayload;
5use crate::contract::slot::KnowledgeSlot;
6use crate::contract::type_classifier::{is_numeric_type, is_temporal_type};
7use crate::schema::{SchemaTree, Table};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BindingValidity {
21 Valid,
22 Invalid,
23}
24
25impl BindingValidity {
26 pub const fn is_valid(self) -> bool {
28 matches!(self, Self::Valid)
29 }
30
31 pub const fn is_invalid(self) -> bool {
33 matches!(self, Self::Invalid)
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
43#[serde(rename_all = "snake_case")]
44pub enum ColumnRequirement {
45 #[default]
47 Exists,
48 Time,
50 Numeric,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(tag = "type", rename_all = "snake_case")]
64pub enum SchemaBinding {
65 Table,
67 Column {
69 column: String,
70 #[serde(default)]
71 requirement: ColumnRequirement,
72 },
73}
74
75impl SchemaBinding {
76 pub fn derive(slot: &KnowledgeSlot, payload: &ClaimPayload) -> Option<Self> {
89 match (slot, payload) {
90 (KnowledgeSlot::TableDescription, ClaimPayload::TableDescription { .. })
91 | (KnowledgeSlot::TableAlias, ClaimPayload::TableAlias { .. })
92 | (KnowledgeSlot::TableGrain, ClaimPayload::TableGrain { .. }) => Some(Self::Table),
93 (KnowledgeSlot::TableDefaultTime, ClaimPayload::DefaultTimeColumn { column, .. }) => {
94 Some(Self::Column {
95 column: column.clone(),
96 requirement: ColumnRequirement::Time,
97 })
98 }
99 (
100 KnowledgeSlot::ColumnDescription { column: slot_col },
101 ClaimPayload::ColumnDescription { column, .. },
102 ) if slot_col == column => Some(Self::Column {
103 column: column.clone(),
104 requirement: ColumnRequirement::Exists,
105 }),
106 (
107 KnowledgeSlot::ColumnRole { column: slot_col },
108 ClaimPayload::ColumnRole { column, role, .. },
109 ) if slot_col == column => {
110 let requirement = match role {
111 ColumnRole::Timestamp => ColumnRequirement::Time,
112 ColumnRole::Measure => ColumnRequirement::Numeric,
113 ColumnRole::Identifier | ColumnRole::Dimension | ColumnRole::Sensitive => {
114 ColumnRequirement::Exists
115 }
116 };
117 Some(Self::Column {
118 column: column.clone(),
119 requirement,
120 })
121 }
122 _ => None,
123 }
124 }
125
126 pub fn validate(&self, table: &Table) -> BindingValidity {
132 match self {
133 Self::Table => BindingValidity::Valid,
134 Self::Column {
135 column,
136 requirement,
137 } => {
138 let Some(col) = table
139 .columns
140 .iter()
141 .find(|c| c.name.eq_ignore_ascii_case(column))
142 else {
143 return BindingValidity::Invalid;
144 };
145
146 match requirement {
147 ColumnRequirement::Exists => BindingValidity::Valid,
148 ColumnRequirement::Time => {
149 if is_temporal_type(&col.data_type) {
150 BindingValidity::Valid
151 } else {
152 BindingValidity::Invalid
153 }
154 }
155 ColumnRequirement::Numeric => {
156 if is_numeric_type(&col.data_type) {
157 BindingValidity::Valid
158 } else {
159 BindingValidity::Invalid
160 }
161 }
162 }
163 }
164 }
165 }
166
167 pub fn validate_in_tree(
173 &self,
174 tree: &SchemaTree,
175 catalog: &str,
176 schema: &str,
177 table: &str,
178 ) -> BindingValidity {
179 match tree.find_table(catalog, schema, table) {
180 Some(t) => self.validate(t),
181 None => BindingValidity::Invalid,
182 }
183 }
184}
185
186pub fn validate_table(binding: &SchemaBinding, table: Option<&Table>) -> BindingValidity {
191 match table {
192 Some(t) => binding.validate(t),
193 None => BindingValidity::Invalid,
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::contract::claim_enums::Cardinality;
201 use crate::contract::identity::{DatabaseObjectKind, DatabaseObjectRef, ProfileIdentity};
202 use crate::schema::{Column, Database, Schema, Table};
203 use proptest::prelude::*;
204
205 fn make_table(name: &str, cols: &[(&str, &str)]) -> Table {
206 Table {
207 name: name.to_string(),
208 columns: cols
209 .iter()
210 .map(|(cname, ctype)| Column {
211 name: (*cname).to_string(),
212 data_type: (*ctype).to_string(),
213 nullable: true,
214 })
215 .collect(),
216 }
217 }
218
219 fn make_target() -> DatabaseObjectRef {
220 let profile = ProfileIdentity::parse(&format!("p-{}", "a".repeat(64))).unwrap();
221 DatabaseObjectRef::new(profile, "c", "s", "t", DatabaseObjectKind::Table).unwrap()
222 }
223
224 #[test]
228 fn test_unrelated_column_addition_leaves_time_binding_valid() {
229 let binding = SchemaBinding::Column {
230 column: "return_date".to_string(),
231 requirement: ColumnRequirement::Time,
232 };
233
234 let initial_table = make_table("rental", &[("return_date", "timestamp")]);
235 assert_eq!(binding.validate(&initial_table), BindingValidity::Valid);
236
237 let modified_table =
238 make_table("rental", &[("return_date", "timestamp"), ("notes", "text")]);
239 assert_eq!(binding.validate(&modified_table), BindingValidity::Valid);
240 }
241
242 #[test]
244 fn test_dropped_default_time_column_is_invalid() {
245 let binding = SchemaBinding::Column {
246 column: "return_date".to_string(),
247 requirement: ColumnRequirement::Time,
248 };
249
250 let table_without_col = make_table("rental", &[("customer_id", "int")]);
251 assert_eq!(
252 binding.validate(&table_without_col),
253 BindingValidity::Invalid
254 );
255 }
256
257 #[test]
259 fn test_retyped_time_column_to_text_is_invalid() {
260 let binding = SchemaBinding::Column {
261 column: "return_date".to_string(),
262 requirement: ColumnRequirement::Time,
263 };
264
265 let retyped_table = make_table("rental", &[("return_date", "text")]);
266 assert_eq!(binding.validate(&retyped_table), BindingValidity::Invalid);
267 }
268
269 #[test]
271 fn test_table_binding_unaffected_by_column_changes() {
272 let binding = SchemaBinding::Table;
273
274 let empty_table = make_table("rental", &[]);
275 assert_eq!(binding.validate(&empty_table), BindingValidity::Valid);
276
277 let table_with_cols = make_table(
278 "rental",
279 &[
280 ("id", "int"),
281 ("rental_date", "timestamp"),
282 ("notes", "text"),
283 ],
284 );
285 assert_eq!(binding.validate(&table_with_cols), BindingValidity::Valid);
286
287 let retyped_table = make_table("rental", &[("id", "varchar")]);
288 assert_eq!(binding.validate(&retyped_table), BindingValidity::Valid);
289 }
290
291 #[test]
294 fn test_column_description_dropped_is_invalid_unrelated_drop_is_valid() {
295 let binding = SchemaBinding::Column {
296 column: "tier_code".to_string(),
297 requirement: ColumnRequirement::Exists,
298 };
299
300 let initial_table = make_table(
301 "customer",
302 &[("tier_code", "varchar"), ("created_at", "timestamp")],
303 );
304 assert_eq!(binding.validate(&initial_table), BindingValidity::Valid);
305
306 let table_unrelated_dropped = make_table("customer", &[("tier_code", "varchar")]);
308 assert_eq!(
309 binding.validate(&table_unrelated_dropped),
310 BindingValidity::Valid
311 );
312
313 let table_target_dropped = make_table("customer", &[("created_at", "timestamp")]);
315 assert_eq!(
316 binding.validate(&table_target_dropped),
317 BindingValidity::Invalid
318 );
319 }
320
321 #[test]
323 fn test_missing_table_is_invalid_for_all_bindings() {
324 let table_binding = SchemaBinding::Table;
325 let column_binding = SchemaBinding::Column {
326 column: "id".to_string(),
327 requirement: ColumnRequirement::Exists,
328 };
329 let time_binding = SchemaBinding::Column {
330 column: "created_at".to_string(),
331 requirement: ColumnRequirement::Time,
332 };
333
334 assert_eq!(
335 validate_table(&table_binding, None),
336 BindingValidity::Invalid
337 );
338 assert_eq!(
339 validate_table(&column_binding, None),
340 BindingValidity::Invalid
341 );
342 assert_eq!(
343 validate_table(&time_binding, None),
344 BindingValidity::Invalid
345 );
346
347 let live_table = make_table("t", &[("id", "int"), ("created_at", "timestamp")]);
348 assert_eq!(
349 validate_table(&table_binding, Some(&live_table)),
350 BindingValidity::Valid
351 );
352 assert_eq!(
353 validate_table(&column_binding, Some(&live_table)),
354 BindingValidity::Valid
355 );
356 assert_eq!(
357 validate_table(&time_binding, Some(&live_table)),
358 BindingValidity::Valid
359 );
360 }
361
362 #[test]
365 fn test_schema_binding_serde_round_trip() {
366 let cases = [
367 SchemaBinding::Table,
368 SchemaBinding::Column {
369 column: "user_id".to_string(),
370 requirement: ColumnRequirement::Exists,
371 },
372 SchemaBinding::Column {
373 column: "created_at".to_string(),
374 requirement: ColumnRequirement::Time,
375 },
376 SchemaBinding::Column {
377 column: "total_amount".to_string(),
378 requirement: ColumnRequirement::Numeric,
379 },
380 ];
381
382 for case in &cases {
383 let json = serde_json::to_string(case).expect("serialization must succeed");
384 let deserialized: SchemaBinding =
385 serde_json::from_str(&json).expect("deserialization must succeed");
386 assert_eq!(case, &deserialized);
387 }
388
389 let table_json = serde_json::to_string(&SchemaBinding::Table).unwrap();
391 assert_eq!(table_json, r#"{"type":"table"}"#);
392
393 let col_time_json = serde_json::to_string(&SchemaBinding::Column {
394 column: "ts".to_string(),
395 requirement: ColumnRequirement::Time,
396 })
397 .unwrap();
398 assert_eq!(
399 col_time_json,
400 r#"{"type":"column","column":"ts","requirement":"time"}"#
401 );
402
403 let default_req_json = r#"{"type":"column","column":"status"}"#;
405 let parsed: SchemaBinding = serde_json::from_str(default_req_json).unwrap();
406 assert_eq!(
407 parsed,
408 SchemaBinding::Column {
409 column: "status".to_string(),
410 requirement: ColumnRequirement::Exists,
411 }
412 );
413 }
414
415 #[test]
416 fn test_column_case_insensitive_matching() {
417 let binding = SchemaBinding::Column {
418 column: "Return_Date".to_string(),
419 requirement: ColumnRequirement::Time,
420 };
421 let table = make_table("rental", &[("return_date", "timestamp")]);
422 assert_eq!(binding.validate(&table), BindingValidity::Valid);
423
424 let binding_lower = SchemaBinding::Column {
425 column: "return_date".to_string(),
426 requirement: ColumnRequirement::Time,
427 };
428 let table_upper = make_table("rental", &[("RETURN_DATE", "TIMESTAMP")]);
429 assert_eq!(binding_lower.validate(&table_upper), BindingValidity::Valid);
430 }
431
432 #[test]
433 fn test_numeric_requirement_validation() {
434 let binding = SchemaBinding::Column {
435 column: "amount".to_string(),
436 requirement: ColumnRequirement::Numeric,
437 };
438
439 for valid_type in [
440 "int",
441 "bigint",
442 "numeric(10,2)",
443 "float",
444 "double precision",
445 "real",
446 "NUMBER(38,0)",
447 "serial",
448 ] {
449 let table = make_table("t", &[("amount", valid_type)]);
450 assert_eq!(
451 binding.validate(&table),
452 BindingValidity::Valid,
453 "type {valid_type} should be valid numeric"
454 );
455 }
456
457 for invalid_type in ["varchar", "text", "boolean", "json", "date", "timestamp"] {
458 let table = make_table("t", &[("amount", invalid_type)]);
459 assert_eq!(
460 binding.validate(&table),
461 BindingValidity::Invalid,
462 "type {invalid_type} should not be valid numeric"
463 );
464 }
465 }
466
467 #[test]
468 fn test_temporal_requirement_validation() {
469 let binding = SchemaBinding::Column {
470 column: "ts".to_string(),
471 requirement: ColumnRequirement::Time,
472 };
473
474 for valid_type in [
475 "timestamp",
476 "timestamp with time zone",
477 "timestamptz",
478 "datetime",
479 "date",
480 "time",
481 "TIMESTAMP_NTZ",
482 ] {
483 let table = make_table("t", &[("ts", valid_type)]);
484 assert_eq!(
485 binding.validate(&table),
486 BindingValidity::Valid,
487 "type {valid_type} should be valid temporal"
488 );
489 }
490
491 for invalid_type in ["int", "bigint", "varchar(255)", "text", "boolean", "json"] {
492 let table = make_table("t", &[("ts", invalid_type)]);
493 assert_eq!(
494 binding.validate(&table),
495 BindingValidity::Invalid,
496 "type {invalid_type} should not be valid temporal"
497 );
498 }
499 }
500
501 #[test]
502 fn test_validity_helpers() {
503 assert!(BindingValidity::Valid.is_valid());
504 assert!(!BindingValidity::Valid.is_invalid());
505 assert!(!BindingValidity::Invalid.is_valid());
506 assert!(BindingValidity::Invalid.is_invalid());
507 }
508
509 #[test]
511 fn test_derive_table_slots() {
512 let desc_slot = KnowledgeSlot::TableDescription;
513 let desc_payload = ClaimPayload::table_description("A table of rentals").unwrap();
514 assert_eq!(
515 SchemaBinding::derive(&desc_slot, &desc_payload),
516 Some(SchemaBinding::Table)
517 );
518
519 let alias_slot = KnowledgeSlot::TableAlias;
520 let alias_payload = ClaimPayload::table_alias("rentals").unwrap();
521 assert_eq!(
522 SchemaBinding::derive(&alias_slot, &alias_payload),
523 Some(SchemaBinding::Table)
524 );
525
526 let grain_slot = KnowledgeSlot::TableGrain;
527 let grain_payload = ClaimPayload::table_grain("one row per rental event", None).unwrap();
528 assert_eq!(
529 SchemaBinding::derive(&grain_slot, &grain_payload),
530 Some(SchemaBinding::Table)
531 );
532 }
533
534 #[test]
536 fn test_derive_default_time() {
537 let slot = KnowledgeSlot::TableDefaultTime;
538 let payload = ClaimPayload::default_time_column("return_date", None).unwrap();
539 assert_eq!(
540 SchemaBinding::derive(&slot, &payload),
541 Some(SchemaBinding::Column {
542 column: "return_date".to_string(),
543 requirement: ColumnRequirement::Time,
544 })
545 );
546 }
547
548 #[test]
550 fn test_derive_column_description() {
551 let slot = KnowledgeSlot::ColumnDescription {
552 column: "tier_code".to_string(),
553 };
554 let payload = ClaimPayload::column_description("tier_code", "Loyalty tier").unwrap();
555 assert_eq!(
556 SchemaBinding::derive(&slot, &payload),
557 Some(SchemaBinding::Column {
558 column: "tier_code".to_string(),
559 requirement: ColumnRequirement::Exists,
560 })
561 );
562 }
563
564 #[test]
566 fn test_derive_column_role_matrix() {
567 let ts_slot = KnowledgeSlot::ColumnRole {
569 column: "created_at".to_string(),
570 };
571 let ts_payload =
572 ClaimPayload::column_role("created_at", ColumnRole::Timestamp, None).unwrap();
573 assert_eq!(
574 SchemaBinding::derive(&ts_slot, &ts_payload),
575 Some(SchemaBinding::Column {
576 column: "created_at".to_string(),
577 requirement: ColumnRequirement::Time,
578 })
579 );
580
581 let measure_slot = KnowledgeSlot::ColumnRole {
583 column: "amount".to_string(),
584 };
585 let measure_payload =
586 ClaimPayload::column_role("amount", ColumnRole::Measure, None).unwrap();
587 assert_eq!(
588 SchemaBinding::derive(&measure_slot, &measure_payload),
589 Some(SchemaBinding::Column {
590 column: "amount".to_string(),
591 requirement: ColumnRequirement::Numeric,
592 })
593 );
594
595 for (col, role) in [
597 ("user_id", ColumnRole::Identifier),
598 ("category", ColumnRole::Dimension),
599 ("ssn", ColumnRole::Sensitive),
600 ] {
601 let role_slot = KnowledgeSlot::ColumnRole {
602 column: col.to_string(),
603 };
604 let role_payload = ClaimPayload::column_role(col, role, None).unwrap();
605 assert_eq!(
606 SchemaBinding::derive(&role_slot, &role_payload),
607 Some(SchemaBinding::Column {
608 column: col.to_string(),
609 requirement: ColumnRequirement::Exists,
610 }),
611 "Role {role:?} must derive ColumnRequirement::Exists"
612 );
613 }
614 }
615
616 #[test]
618 fn test_derive_slot_payload_mismatch_returns_none() {
619 let grain_slot = KnowledgeSlot::TableGrain;
620 let alias_payload = ClaimPayload::table_alias("rentals").unwrap();
621 assert_eq!(SchemaBinding::derive(&grain_slot, &alias_payload), None);
622
623 let desc_slot = KnowledgeSlot::TableDescription;
624 let default_time_payload = ClaimPayload::default_time_column("created_at", None).unwrap();
625 assert_eq!(
626 SchemaBinding::derive(&desc_slot, &default_time_payload),
627 None
628 );
629
630 let col_desc_slot = KnowledgeSlot::ColumnDescription {
631 column: "tier".to_string(),
632 };
633 let col_role_payload =
634 ClaimPayload::column_role("tier", ColumnRole::Identifier, None).unwrap();
635 assert_eq!(
636 SchemaBinding::derive(&col_desc_slot, &col_role_payload),
637 None
638 );
639 }
640
641 #[test]
643 fn test_derive_column_name_mismatch_returns_none() {
644 let role_slot = KnowledgeSlot::ColumnRole {
645 column: "column_a".to_string(),
646 };
647 let role_payload =
648 ClaimPayload::column_role("column_b", ColumnRole::Timestamp, None).unwrap();
649 assert_eq!(SchemaBinding::derive(&role_slot, &role_payload), None);
650
651 let desc_slot = KnowledgeSlot::ColumnDescription {
652 column: "column_a".to_string(),
653 };
654 let desc_payload = ClaimPayload::column_description("column_b", "desc").unwrap();
655 assert_eq!(SchemaBinding::derive(&desc_slot, &desc_payload), None);
656 }
657
658 #[test]
660 fn test_derive_relationship_returns_none() {
661 let target = make_target();
662 let relationship_payload = ClaimPayload::relationship(
663 target,
664 vec!["customer_id".into()],
665 vec!["id".into()],
666 Cardinality::ManyToOne,
667 )
668 .unwrap();
669
670 let slots = [
671 KnowledgeSlot::TableDescription,
672 KnowledgeSlot::TableAlias,
673 KnowledgeSlot::TableGrain,
674 KnowledgeSlot::TableDefaultTime,
675 KnowledgeSlot::ColumnDescription {
676 column: "customer_id".into(),
677 },
678 KnowledgeSlot::ColumnRole {
679 column: "customer_id".into(),
680 },
681 ];
682
683 for slot in &slots {
684 assert_eq!(
685 SchemaBinding::derive(slot, &relationship_payload),
686 None,
687 "Relationship payload must not derive a binding for slot {slot:?}"
688 );
689 }
690 }
691
692 #[test]
694 fn test_derived_binding_end_to_end_validation() {
695 let grain_slot = KnowledgeSlot::TableGrain;
697 let grain_payload = ClaimPayload::table_grain("one row per event", None).unwrap();
698 let grain_binding = SchemaBinding::derive(&grain_slot, &grain_payload).unwrap();
699 let table = make_table("events", &[("id", "int")]);
700 assert_eq!(grain_binding.validate(&table), BindingValidity::Valid);
701
702 let time_slot = KnowledgeSlot::TableDefaultTime;
704 let time_payload = ClaimPayload::default_time_column("event_time", None).unwrap();
705 let time_binding = SchemaBinding::derive(&time_slot, &time_payload).unwrap();
706
707 let valid_time_table = make_table("events", &[("event_time", "timestamptz")]);
708 assert_eq!(
709 time_binding.validate(&valid_time_table),
710 BindingValidity::Valid
711 );
712
713 let invalid_time_table = make_table("events", &[("event_time", "text")]);
714 assert_eq!(
715 time_binding.validate(&invalid_time_table),
716 BindingValidity::Invalid
717 );
718
719 let missing_time_table = make_table("events", &[("other_col", "int")]);
720 assert_eq!(
721 time_binding.validate(&missing_time_table),
722 BindingValidity::Invalid
723 );
724
725 let measure_slot = KnowledgeSlot::ColumnRole {
727 column: "revenue".to_string(),
728 };
729 let measure_payload =
730 ClaimPayload::column_role("revenue", ColumnRole::Measure, None).unwrap();
731 let measure_binding = SchemaBinding::derive(&measure_slot, &measure_payload).unwrap();
732
733 let valid_measure_table = make_table("sales", &[("revenue", "numeric(12,2)")]);
734 assert_eq!(
735 measure_binding.validate(&valid_measure_table),
736 BindingValidity::Valid
737 );
738
739 let invalid_measure_table = make_table("sales", &[("revenue", "varchar(50)")]);
740 assert_eq!(
741 measure_binding.validate(&invalid_measure_table),
742 BindingValidity::Invalid
743 );
744 }
745
746 #[test]
748 fn test_cross_dialect_temporal_types_valid() {
749 let binding = SchemaBinding::Column {
750 column: "event_time".to_string(),
751 requirement: ColumnRequirement::Time,
752 };
753
754 let dialects_temporal_types = [
755 "timestamp with time zone",
757 "timestamp without time zone",
758 "timestamptz",
759 "date",
760 "time",
761 "timestamp",
762 "TIMESTAMP",
764 "DATETIME",
765 "DATE",
766 "TIME",
767 "TIMESTAMP_NTZ",
769 "TIMESTAMP_LTZ",
770 "TIMESTAMP_TZ",
771 "DATE",
772 "TIME",
773 "TIMESTAMP",
775 "DATE",
776 "TIME",
777 "DATETIME",
779 "TIMESTAMP",
780 ];
781
782 for raw_type in dialects_temporal_types {
783 let table = make_table("events", &[("event_time", raw_type)]);
784 assert_eq!(
785 binding.validate(&table),
786 BindingValidity::Valid,
787 "Dialect temporal type '{raw_type}' must be Valid for Time requirement"
788 );
789 }
790 }
791
792 #[test]
794 fn test_cross_dialect_numeric_types_valid() {
795 let binding = SchemaBinding::Column {
796 column: "metric".to_string(),
797 requirement: ColumnRequirement::Numeric,
798 };
799
800 let dialects_numeric_types = [
801 "integer",
803 "bigint",
804 "smallint",
805 "bigserial",
806 "serial",
807 "numeric",
808 "numeric(10,2)",
809 "double precision",
810 "real",
811 "INT",
813 "BIGINT",
814 "TINYINT",
815 "DECIMAL(10,2)",
816 "FLOAT",
817 "DOUBLE",
818 "NUMBER(38,0)",
820 "FLOAT",
821 "INT",
822 "HUGEINT",
824 "INTEGER",
825 "DECIMAL(18,3)",
826 "DOUBLE",
827 "INTEGER",
829 "REAL",
830 ];
831
832 for raw_type in dialects_numeric_types {
833 let table = make_table("metrics", &[("metric", raw_type)]);
834 assert_eq!(
835 binding.validate(&table),
836 BindingValidity::Valid,
837 "Dialect numeric type '{raw_type}' must be Valid for Numeric requirement"
838 );
839 }
840 }
841
842 #[test]
844 fn test_cross_dialect_non_temporal_types_invalid_for_time() {
845 let binding = SchemaBinding::Column {
846 column: "val".to_string(),
847 requirement: ColumnRequirement::Time,
848 };
849
850 let non_temporal_types = [
851 "text",
852 "varchar(255)",
853 "json",
854 "jsonb",
855 "boolean",
856 "int",
857 "bigint",
858 "numeric(10,2)",
859 "blob",
860 "bytea",
861 "uuid",
862 "xml",
863 ];
864
865 for raw_type in non_temporal_types {
866 let table = make_table("t", &[("val", raw_type)]);
867 assert_eq!(
868 binding.validate(&table),
869 BindingValidity::Invalid,
870 "Non-temporal type '{raw_type}' must be Invalid for Time requirement"
871 );
872 }
873 }
874
875 #[test]
877 fn test_validate_in_tree_resolution() {
878 let table = make_table("Rental", &[("Return_Date", "timestamp")]);
879 let tree = SchemaTree {
880 databases: vec![Database {
881 name: "MainDb".to_string(),
882 schemas: vec![Schema {
883 name: "Public".to_string(),
884 tables: vec![table],
885 }],
886 }],
887 };
888
889 let binding = SchemaBinding::Column {
890 column: "return_date".to_string(),
891 requirement: ColumnRequirement::Time,
892 };
893
894 assert_eq!(
896 binding.validate_in_tree(&tree, "MainDb", "Public", "Rental"),
897 BindingValidity::Valid
898 );
899
900 assert_eq!(
902 binding.validate_in_tree(&tree, "maindb", "public", "rental"),
903 BindingValidity::Valid
904 );
905
906 assert_eq!(
908 binding.validate_in_tree(&tree, "other_db", "public", "rental"),
909 BindingValidity::Invalid
910 );
911
912 assert_eq!(
914 binding.validate_in_tree(&tree, "maindb", "other_schema", "rental"),
915 BindingValidity::Invalid
916 );
917
918 assert_eq!(
920 binding.validate_in_tree(&tree, "maindb", "public", "customer"),
921 BindingValidity::Invalid
922 );
923 }
924
925 fn arb_col_name() -> impl Strategy<Value = String> {
927 "[a-z_][a-z0-9_]{0,15}"
928 }
929
930 fn arb_data_type() -> impl Strategy<Value = String> {
931 prop_oneof![
932 Just("timestamp".to_string()),
933 Just("timestamptz".to_string()),
934 Just("date".to_string()),
935 Just("time".to_string()),
936 Just("datetime".to_string()),
937 Just("int".to_string()),
938 Just("bigint".to_string()),
939 Just("numeric(10,2)".to_string()),
940 Just("float".to_string()),
941 Just("text".to_string()),
942 Just("varchar(50)".to_string()),
943 Just("boolean".to_string()),
944 Just("json".to_string()),
945 ]
946 }
947
948 fn arb_column() -> impl Strategy<Value = Column> {
949 (arb_col_name(), arb_data_type(), any::<bool>()).prop_map(|(name, data_type, nullable)| {
950 Column {
951 name,
952 data_type,
953 nullable,
954 }
955 })
956 }
957
958 fn arb_table() -> impl Strategy<Value = Table> {
959 (arb_col_name(), prop::collection::vec(arb_column(), 0..10))
960 .prop_map(|(name, columns)| Table { name, columns })
961 }
962
963 fn arb_column_requirement() -> impl Strategy<Value = ColumnRequirement> {
964 prop_oneof![
965 Just(ColumnRequirement::Exists),
966 Just(ColumnRequirement::Time),
967 Just(ColumnRequirement::Numeric),
968 ]
969 }
970
971 fn arb_schema_binding() -> impl Strategy<Value = SchemaBinding> {
972 prop_oneof![
973 Just(SchemaBinding::Table),
974 (arb_col_name(), arb_column_requirement()).prop_map(|(column, requirement)| {
975 SchemaBinding::Column {
976 column,
977 requirement,
978 }
979 })
980 ]
981 }
982
983 proptest! {
984 #[test]
987 fn prop_unrelated_column_addition_never_alters_validity(
988 table in arb_table(),
989 binding in arb_schema_binding(),
990 new_col_name in arb_col_name(),
991 new_col_type in arb_data_type(),
992 new_col_nullable in any::<bool>(),
993 ) {
994 if let SchemaBinding::Column { ref column, .. } = binding {
996 prop_assume!(!new_col_name.eq_ignore_ascii_case(column));
997 }
998
999 let initial_verdict = binding.validate(&table);
1000
1001 let mut modified_table = table.clone();
1002 modified_table.columns.push(Column {
1003 name: new_col_name,
1004 data_type: new_col_type,
1005 nullable: new_col_nullable,
1006 });
1007
1008 let after_verdict = binding.validate(&modified_table);
1009 prop_assert_eq!(initial_verdict, after_verdict);
1010 }
1011
1012 #[test]
1014 fn prop_dropped_referenced_column_always_invalid(
1015 table in arb_table(),
1016 col_name in arb_col_name(),
1017 requirement in arb_column_requirement(),
1018 ) {
1019 let binding = SchemaBinding::Column {
1020 column: col_name.clone(),
1021 requirement,
1022 };
1023
1024 let mut stripped_table = table.clone();
1026 stripped_table.columns.retain(|c| !c.name.eq_ignore_ascii_case(&col_name));
1027
1028 prop_assert_eq!(binding.validate(&stripped_table), BindingValidity::Invalid);
1029 }
1030
1031 #[test]
1033 fn prop_binding_serde_round_trip(
1034 binding in arb_schema_binding(),
1035 ) {
1036 let serialized = serde_json::to_string(&binding).expect("serialization succeeds");
1037 let deserialized: SchemaBinding = serde_json::from_str(&serialized).expect("deserialization succeeds");
1038 prop_assert_eq!(binding, deserialized);
1039 }
1040 }
1041}