Skip to main content

saya_types/contract/
binding.rs

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/// Verdict of validating a knowledge item's schema binding against live table metadata.
10///
11/// This is strictly a binary verdict: either all schema dependencies required
12/// by the business fact exist and satisfy semantic type expectations (`Valid`),
13/// or a required dependency is missing or contradicted (`Invalid`).
14///
15/// Prior whole-table fingerprint systems used a three-way verdict with `NeedsReview`
16/// whenever unreferenced columns changed, which caused false alarms ("crying wolf")
17/// and eroded trust in persistent knowledge.
18#[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    /// True when the binding satisfies all live schema dependencies.
27    pub const fn is_valid(self) -> bool {
28        matches!(self, Self::Valid)
29    }
30
31    /// True when any live schema dependency is missing or contradicted.
32    pub const fn is_invalid(self) -> bool {
33        matches!(self, Self::Invalid)
34    }
35}
36
37/// Semantic type requirement placed on a depended-upon column.
38///
39/// Rather than snapshotting exact connector type strings (which breaks on
40/// harmless type widenings like `VARCHAR(20)` -> `VARCHAR(50)` or `INT` -> `BIGINT`),
41/// bindings record the semantic capability the fact requires.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
43#[serde(rename_all = "snake_case")]
44pub enum ColumnRequirement {
45    /// The column must exist in the live table, with any data type.
46    #[default]
47    Exists,
48    /// The column must exist and possess a temporal type (date, time, timestamp).
49    Time,
50    /// The column must exist and possess a numeric type (int, float, decimal, etc.).
51    Numeric,
52}
53
54/// Structural dependencies a piece of knowledge requires from the database schema.
55///
56/// A fact depends only on what it actually uses:
57/// - Table-level knowledge (`TableDescription`, `TableAlias`, `TableGrain`) depends
58///   only on the table existing.
59/// - Column-scoped knowledge (`ColumnDescription`, `ColumnRole`, `TableDefaultTime`)
60///   depends on the specific named column existing and optionally satisfying a
61///   semantic type constraint ([`ColumnRequirement`]).
62#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(tag = "type", rename_all = "snake_case")]
64pub enum SchemaBinding {
65    /// Depends only on the table existing in the schema.
66    Table,
67    /// Depends on a named column existing and satisfying `requirement`.
68    Column {
69        column: String,
70        #[serde(default)]
71        requirement: ColumnRequirement,
72    },
73}
74
75impl SchemaBinding {
76    /// Derives the structural schema binding for a paired `(KnowledgeSlot, ClaimPayload)`.
77    ///
78    /// Table-level slots (`TableDescription`, `TableAlias`, `TableGrain`) yield
79    /// `SchemaBinding::Table`.
80    /// Column-level slots yield `SchemaBinding::Column` with appropriate semantic
81    /// `ColumnRequirement`:
82    /// - `TableDefaultTime` and `ColumnRole::Timestamp` require `ColumnRequirement::Time`.
83    /// - `ColumnRole::Measure` requires `ColumnRequirement::Numeric`.
84    /// - `ColumnDescription` and other `ColumnRole` variants require `ColumnRequirement::Exists`.
85    ///
86    /// Returns `None` if the slot and payload types disagree, if column names mismatch,
87    /// or if the payload is not slot-bound (e.g. `ClaimPayload::Relationship`).
88    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    /// Validates this binding against a live table definition.
127    ///
128    /// Evaluates whether the referenced table and column dependencies are
129    /// satisfied. Matching column names is case-insensitive to align with SQL
130    /// catalog conventions across database dialects.
131    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    /// Validates this binding against a table found inside a [`SchemaTree`].
168    ///
169    /// Looks up the table at `(catalog, schema, table)` using case-insensitive
170    /// resolution via [`SchemaTree::find_table`]. If found, validates against
171    /// that live table; if not found, returns [`BindingValidity::Invalid`].
172    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
186/// Validates a schema binding against an optional live table reference.
187///
188/// If the table does not exist (`None`), any binding is `Invalid`.
189/// If the table exists (`Some(table)`), delegates to [`SchemaBinding::validate`].
190pub 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    /// Spec Test 1 (Regression): Table with `return_date: timestamp`; binding is
225    /// `Column { column: "return_date", requirement: Time }`.
226    /// Adding an unrelated `notes: text` column to the table must leave the binding `Valid`.
227    #[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    /// Spec Test 2: Dropping the depended-upon column marks the binding `Invalid`.
243    #[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    /// Spec Test 3: Retyping a temporal column to `text` marks a `Time` binding `Invalid`.
258    #[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    /// Spec Test 4: `SchemaBinding::Table` is unaffected by adding, removing, or retyping columns.
270    #[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    /// Spec Test 5: `Column` with `Exists` requirement becomes `Invalid` only when that column
292    /// is dropped; dropping another unrelated column leaves it `Valid`.
293    #[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        // Dropping unrelated column `created_at` leaves `tier_code` valid.
307        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        // Dropping `tier_code` itself marks it invalid.
314        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    /// Spec Test 8: `validate_table` with `None` yields `Invalid` for `Table` and `Column` bindings alike.
322    #[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    /// Spec Test 7: Verifies JSON serialization and deserialization for `Table`, `Column`
363    /// with `Exists`, `Time`, and `Numeric` requirements.
364    #[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        // Test explicit tagged JSON shape
390        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        // Deserializing column without requirement field defaults to Exists
404        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    /// Chunk 2 test: TableDescription, TableAlias, and TableGrain derive SchemaBinding::Table.
510    #[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    /// Chunk 2 test: TableDefaultTime derives SchemaBinding::Column with ColumnRequirement::Time.
535    #[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    /// Chunk 2 test: ColumnDescription derives SchemaBinding::Column with ColumnRequirement::Exists.
549    #[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    /// Chunk 2 test: ColumnRole matrix derivations for all role variants.
565    #[test]
566    fn test_derive_column_role_matrix() {
567        // Timestamp -> Time
568        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        // Measure -> Numeric
582        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        // Identifier, Dimension, Sensitive -> Exists
596        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    /// Chunk 2 test: Slot/payload kind mismatch returns None.
617    #[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    /// Chunk 2 test: Column name disagreement between slot and payload returns None.
642    #[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    /// Chunk 2 test: ClaimPayload::Relationship has no corresponding slot and returns None.
659    #[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    /// Spec Test 6: Derived binding end-to-end validation over live tables.
693    #[test]
694    fn test_derived_binding_end_to_end_validation() {
695        // 1. TableGrain -> derive -> validate
696        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        // 2. DefaultTimeColumn -> derive -> validate
703        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        // 3. Measure ColumnRole -> derive -> validate
726        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    /// Cross-Dialect Matrix Test: Verifies temporal types across Postgres, MySQL, Snowflake, DuckDB, SQLite.
747    #[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            // Postgres
756            "timestamp with time zone",
757            "timestamp without time zone",
758            "timestamptz",
759            "date",
760            "time",
761            "timestamp",
762            // MySQL
763            "TIMESTAMP",
764            "DATETIME",
765            "DATE",
766            "TIME",
767            // Snowflake
768            "TIMESTAMP_NTZ",
769            "TIMESTAMP_LTZ",
770            "TIMESTAMP_TZ",
771            "DATE",
772            "TIME",
773            // DuckDB
774            "TIMESTAMP",
775            "DATE",
776            "TIME",
777            // SQLite
778            "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    /// Cross-Dialect Matrix Test: Verifies numeric types across Postgres, MySQL, Snowflake, DuckDB, SQLite.
793    #[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            // Postgres
802            "integer",
803            "bigint",
804            "smallint",
805            "bigserial",
806            "serial",
807            "numeric",
808            "numeric(10,2)",
809            "double precision",
810            "real",
811            // MySQL
812            "INT",
813            "BIGINT",
814            "TINYINT",
815            "DECIMAL(10,2)",
816            "FLOAT",
817            "DOUBLE",
818            // Snowflake
819            "NUMBER(38,0)",
820            "FLOAT",
821            "INT",
822            // DuckDB
823            "HUGEINT",
824            "INTEGER",
825            "DECIMAL(18,3)",
826            "DOUBLE",
827            // SQLite
828            "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    /// Cross-Dialect Matrix Test: Verifies non-temporal types are Invalid for ColumnRequirement::Time.
843    #[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    /// Tree Resolution Test: Tests case-insensitive catalog/schema/table resolution in SchemaTree.
876    #[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        // Exact match
895        assert_eq!(
896            binding.validate_in_tree(&tree, "MainDb", "Public", "Rental"),
897            BindingValidity::Valid
898        );
899
900        // Case-insensitive match on all 3 components
901        assert_eq!(
902            binding.validate_in_tree(&tree, "maindb", "public", "rental"),
903            BindingValidity::Valid
904        );
905
906        // Missing catalog
907        assert_eq!(
908            binding.validate_in_tree(&tree, "other_db", "public", "rental"),
909            BindingValidity::Invalid
910        );
911
912        // Missing schema
913        assert_eq!(
914            binding.validate_in_tree(&tree, "maindb", "other_schema", "rental"),
915            BindingValidity::Invalid
916        );
917
918        // Missing table
919        assert_eq!(
920            binding.validate_in_tree(&tree, "maindb", "public", "customer"),
921            BindingValidity::Invalid
922        );
923    }
924
925    // Property Test Generators
926    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        /// Core Invariant: Appending an unrelated column with a distinct name
985        /// NEVER changes the validation verdict across arbitrary generated tables.
986        #[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            // Ensure the new column name is distinct from what the binding depends upon
995            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        /// Dropping the referenced column from any table ALWAYS marks a Column binding Invalid.
1013        #[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            // Remove all columns that match the target column (case-insensitively)
1025            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        /// Arbitrary SchemaBinding round-trips identically through JSON serde.
1032        #[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}