Skip to main content

icydb_model/node/
relation.rs

1use crate::prelude::*;
2
3///
4/// RelationComponentContract
5///
6/// Schema-side type contract for one relation key component.
7///
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub(crate) struct RelationComponentContract<'a> {
11    target: &'a ItemTarget,
12    scale: Option<u32>,
13    max_len: Option<u32>,
14}
15
16impl<'a> RelationComponentContract<'a> {
17    pub(crate) const fn from_field(field: &'a Field) -> Self {
18        Self::from_item(field.value().item())
19    }
20
21    pub(crate) const fn from_item(item: &'a Item) -> Self {
22        Self {
23            target: item.target(),
24            scale: item.scale(),
25            max_len: item.max_len(),
26        }
27    }
28
29    pub(crate) const fn target(&self) -> &'a ItemTarget {
30        self.target
31    }
32
33    pub(crate) const fn scale(&self) -> Option<u32> {
34        self.scale
35    }
36
37    pub(crate) const fn max_len(&self) -> Option<u32> {
38        self.max_len
39    }
40
41    pub(crate) fn mismatches(self, other: Self) -> bool {
42        self != other
43    }
44}
45
46///
47/// RelationEdge
48///
49/// Schema-side relation edge declaration over one or more local component
50/// fields. Runtime acceptance still owns durable field IDs and slots; this
51/// helper proves arity/order/kind compatibility before a tuple relation shape
52/// can be admitted.
53///
54
55#[derive(Clone, Debug, Serialize)]
56pub struct RelationEdge {
57    name: &'static str,
58    target: &'static str,
59    local_fields: &'static [&'static str],
60}
61
62impl RelationEdge {
63    /// Build one relation-edge declaration from a relation name, target entity
64    /// path, and ordered local component fields.
65    #[must_use]
66    pub const fn new(
67        name: &'static str,
68        target: &'static str,
69        local_fields: &'static [&'static str],
70    ) -> Self {
71        Self {
72            name,
73            target,
74            local_fields,
75        }
76    }
77
78    /// Borrow the relation-edge name used by diagnostics.
79    #[must_use]
80    pub const fn name(&self) -> &'static str {
81        self.name
82    }
83
84    /// Borrow the target entity path.
85    #[must_use]
86    pub const fn target(&self) -> &'static str {
87        self.target
88    }
89
90    /// Borrow ordered local source fields that map to the target primary key.
91    #[must_use]
92    pub const fn local_fields(&self) -> &'static [&'static str] {
93        self.local_fields
94    }
95
96    /// Validate this edge against one source entity and the target entity
97    /// stored in the current schema graph.
98    pub fn validate_for_source(&self, source: &Entity) -> Result<(), ErrorTree> {
99        let schema = schema_read();
100
101        match schema.cast_node::<Entity>(self.target()) {
102            Ok(target) => self.validate_against_entities(source, target),
103            Err(_) => Err(ErrorTree::from(format!(
104                "relation edge '{}' target entity '{}' not found",
105                self.name(),
106                self.target()
107            ))),
108        }
109    }
110
111    /// Validate this edge against explicit source and target entity metadata.
112    pub fn validate_against_entities(
113        &self,
114        source: &Entity,
115        target: &Entity,
116    ) -> Result<(), ErrorTree> {
117        let mut errs = ErrorTree::new();
118        let target_fields = target.primary_key().fields();
119
120        if self.local_fields().is_empty() {
121            err!(
122                errs,
123                "relation edge '{}' must declare at least one local field",
124                self.name()
125            );
126        }
127
128        if self.local_fields().len() != target_fields.len() {
129            err!(
130                errs,
131                "relation edge '{}' arity mismatch: local fields {:?} target primary key fields {:?}",
132                self.name(),
133                self.local_fields(),
134                target_fields,
135            );
136            return errs.result();
137        }
138
139        let mut local_component_cardinality = None;
140        for (index, (local_name, target_name)) in self
141            .local_fields()
142            .iter()
143            .zip(target_fields.iter())
144            .enumerate()
145        {
146            let Some(local_field) = source.fields().get(local_name) else {
147                err!(
148                    errs,
149                    "relation edge '{}' local field '{}' not found",
150                    self.name(),
151                    local_name
152                );
153                continue;
154            };
155            let Some(target_field) = target.fields().get(target_name) else {
156                err!(
157                    errs,
158                    "relation edge '{}' target primary key field '{}' not found",
159                    self.name(),
160                    target_name
161                );
162                continue;
163            };
164
165            if !self.validate_local_component_shape(
166                &mut errs,
167                local_name,
168                local_field,
169                &mut local_component_cardinality,
170            ) {
171                continue;
172            }
173
174            self.validate_component_contract(
175                &mut errs,
176                index,
177                local_name,
178                local_field,
179                target_name,
180                target_field,
181            );
182        }
183
184        errs.result()
185    }
186
187    fn validate_local_component_shape(
188        &self,
189        errs: &mut ErrorTree,
190        local_name: &str,
191        local_field: &Field,
192        local_component_cardinality: &mut Option<Cardinality>,
193    ) -> bool {
194        let local_cardinality = local_field.value().cardinality();
195        if local_cardinality == Cardinality::Many {
196            err!(
197                errs,
198                "relation edge '{}' local field '{}' cannot have many cardinality",
199                self.name(),
200                local_name
201            );
202            return false;
203        }
204        match *local_component_cardinality {
205            Some(expected) if expected != local_cardinality => {
206                err!(
207                    errs,
208                    "relation edge '{}' local field '{}' cardinality mismatch: all local component fields must be required or all optional",
209                    self.name(),
210                    local_name
211                );
212                return false;
213            }
214            Some(_) => {}
215            None => *local_component_cardinality = Some(local_cardinality),
216        }
217
218        if local_field.generated().is_some() {
219            err!(
220                errs,
221                "relation edge '{}' local field '{}' is generated and cannot be a relation component",
222                self.name(),
223                local_name
224            );
225            return false;
226        }
227
228        true
229    }
230
231    fn validate_component_contract(
232        &self,
233        errs: &mut ErrorTree,
234        index: usize,
235        local_name: &str,
236        local_field: &Field,
237        target_name: &str,
238        target_field: &Field,
239    ) {
240        let expected = RelationComponentContract::from_field(target_field);
241        if !target_primary_key_component_is_admissible(expected) {
242            err!(
243                errs,
244                "relation edge '{}' target primary key field '{}' uses non-admissible component {:?}",
245                self.name(),
246                target_name,
247                expected.target(),
248            );
249            return;
250        }
251
252        let actual = RelationComponentContract::from_field(local_field);
253        if expected.mismatches(actual) {
254            err!(
255                errs,
256                "relation edge '{}' component {index} type mismatch: local field '{}' has ({:?}, scale={:?}, max_len={:?}); target field '{}' requires ({:?}, scale={:?}, max_len={:?})",
257                self.name(),
258                local_name,
259                actual.target(),
260                actual.scale(),
261                actual.max_len(),
262                target_name,
263                expected.target(),
264                expected.scale(),
265                expected.max_len(),
266            );
267        }
268    }
269}
270
271const fn target_primary_key_component_is_admissible(
272    contract: RelationComponentContract<'_>,
273) -> bool {
274    match contract.target() {
275        ItemTarget::Primitive(primitive) => primitive.is_primary_key_component_encodable(),
276        ItemTarget::Is(_) => false,
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::build::schema_write;
284
285    fn primitive_item(primitive: Primitive) -> Item {
286        Item::new(
287            ItemTarget::Primitive(primitive),
288            None,
289            None,
290            None,
291            &[],
292            &[],
293            false,
294        )
295    }
296
297    fn item_with_metadata(primitive: Primitive, scale: Option<u32>, max_len: Option<u32>) -> Item {
298        Item::new(
299            ItemTarget::Primitive(primitive),
300            None,
301            scale,
302            max_len,
303            &[],
304            &[],
305            false,
306        )
307    }
308
309    fn field(ident: &'static str, primitive: Primitive) -> Field {
310        field_with_item(ident, primitive_item(primitive))
311    }
312
313    fn generated_field(ident: &'static str, primitive: Primitive) -> Field {
314        Field::new(
315            ident,
316            Value::new(Cardinality::One, primitive_item(primitive)),
317            None,
318            Some(FieldGeneration::Insert(Arg::FuncPath(
319                "generate_relation_component",
320            ))),
321            None,
322        )
323    }
324
325    fn field_with_item(ident: &'static str, item: Item) -> Field {
326        Field::new(ident, Value::new(Cardinality::One, item), None, None, None)
327    }
328
329    fn optional_field(ident: &'static str, primitive: Primitive) -> Field {
330        Field::new(
331            ident,
332            Value::new(Cardinality::Opt, primitive_item(primitive)),
333            None,
334            None,
335            None,
336        )
337    }
338
339    fn entity(
340        module: &'static str,
341        ident: &'static str,
342        pk_fields: &'static [&'static str],
343        fields: &'static [Field],
344    ) -> Entity {
345        Entity::new(
346            Def::new(module, ident),
347            "RelationEdgeStore",
348            1,
349            PrimaryKey::new(pk_fields, PrimaryKeySource::External),
350            &[],
351            &[],
352            &[],
353            FieldList::new(fields),
354            Type::new(&[], &[], &[]),
355        )
356    }
357
358    fn insert_entity(
359        module: &'static str,
360        ident: &'static str,
361        pk_fields: &'static [&'static str],
362        fields: &'static [Field],
363    ) -> (&'static str, Entity) {
364        let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
365        let entity = entity(module, ident, pk_fields, fields);
366        schema_write().insert_node(SchemaNode::Entity(entity.clone()));
367        (path, entity)
368    }
369
370    #[test]
371    fn relation_edge_accepts_ordered_composite_target_tuple() {
372        let source_fields = Box::leak(
373            vec![
374                field("author_tenant_id", Primitive::Nat64),
375                field("author_user_id", Primitive::Ulid),
376            ]
377            .into_boxed_slice(),
378        );
379        let target_fields = Box::leak(
380            vec![
381                field("tenant_id", Primitive::Nat64),
382                field("user_id", Primitive::Ulid),
383            ]
384            .into_boxed_slice(),
385        );
386        let source = entity(
387            "schema_relation_edge_accepts_tuple",
388            "Post",
389            &["author_user_id"],
390            source_fields,
391        );
392        let target = entity(
393            "schema_relation_edge_accepts_tuple",
394            "User",
395            &["tenant_id", "user_id"],
396            target_fields,
397        );
398
399        RelationEdge::new(
400            "author",
401            "schema_relation_edge_accepts_tuple::User",
402            &["author_tenant_id", "author_user_id"],
403        )
404        .validate_against_entities(&source, &target)
405        .expect("matching ordered composite relation tuple should validate");
406    }
407
408    #[test]
409    fn relation_edge_rejects_scalar_local_field_for_composite_target() {
410        let source_fields =
411            Box::leak(vec![field("author_user_id", Primitive::Ulid)].into_boxed_slice());
412        let target_fields = Box::leak(
413            vec![
414                field("tenant_id", Primitive::Nat64),
415                field("user_id", Primitive::Ulid),
416            ]
417            .into_boxed_slice(),
418        );
419        let source = entity(
420            "schema_relation_edge_rejects_scalar_for_composite",
421            "Post",
422            &["author_user_id"],
423            source_fields,
424        );
425        let target = entity(
426            "schema_relation_edge_rejects_scalar_for_composite",
427            "User",
428            &["tenant_id", "user_id"],
429            target_fields,
430        );
431
432        let err = RelationEdge::new(
433            "author",
434            "schema_relation_edge_rejects_scalar_for_composite::User",
435            &["author_user_id"],
436        )
437        .validate_against_entities(&source, &target)
438        .expect_err("scalar local component must not validate as composite target tuple");
439
440        assert!(
441            err.messages()
442                .iter()
443                .any(|message| message.contains("arity mismatch")),
444            "unexpected relation edge validation errors: {err}",
445        );
446    }
447
448    #[test]
449    fn relation_edge_rejects_wrong_component_order() {
450        let source_fields = Box::leak(
451            vec![
452                field("author_tenant_id", Primitive::Nat64),
453                field("author_user_id", Primitive::Ulid),
454            ]
455            .into_boxed_slice(),
456        );
457        let target_fields = Box::leak(
458            vec![
459                field("tenant_id", Primitive::Nat64),
460                field("user_id", Primitive::Ulid),
461            ]
462            .into_boxed_slice(),
463        );
464        let source = entity(
465            "schema_relation_edge_rejects_order",
466            "Post",
467            &["author_user_id"],
468            source_fields,
469        );
470        let target = entity(
471            "schema_relation_edge_rejects_order",
472            "User",
473            &["tenant_id", "user_id"],
474            target_fields,
475        );
476
477        let err = RelationEdge::new(
478            "author",
479            "schema_relation_edge_rejects_order::User",
480            &["author_user_id", "author_tenant_id"],
481        )
482        .validate_against_entities(&source, &target)
483        .expect_err("local tuple order must match target primary-key order");
484
485        assert!(
486            err.messages()
487                .iter()
488                .any(|message| message.contains("component 0 type mismatch")),
489            "unexpected relation edge validation errors: {err}",
490        );
491    }
492
493    #[test]
494    fn relation_edge_rejects_missing_local_component_field() {
495        let source_fields =
496            Box::leak(vec![field("author_tenant_id", Primitive::Nat64)].into_boxed_slice());
497        let target_fields = Box::leak(
498            vec![
499                field("tenant_id", Primitive::Nat64),
500                field("user_id", Primitive::Ulid),
501            ]
502            .into_boxed_slice(),
503        );
504        let source = entity(
505            "schema_relation_edge_rejects_missing_local",
506            "Post",
507            &["author_tenant_id"],
508            source_fields,
509        );
510        let target = entity(
511            "schema_relation_edge_rejects_missing_local",
512            "User",
513            &["tenant_id", "user_id"],
514            target_fields,
515        );
516
517        let err = RelationEdge::new(
518            "author",
519            "schema_relation_edge_rejects_missing_local::User",
520            &["author_tenant_id", "author_user_id"],
521        )
522        .validate_against_entities(&source, &target)
523        .expect_err("missing local tuple component should reject");
524
525        assert!(
526            err.messages()
527                .iter()
528                .any(|message| message.contains("local field 'author_user_id' not found")),
529            "unexpected relation edge validation errors: {err}",
530        );
531    }
532
533    #[test]
534    fn relation_edge_rejects_non_admissible_target_primary_key_component() {
535        let source_fields =
536            Box::leak(vec![field("author_score", Primitive::Float64)].into_boxed_slice());
537        let target_fields = Box::leak(vec![field("score", Primitive::Float64)].into_boxed_slice());
538        let source = entity(
539            "schema_relation_edge_rejects_float64_target",
540            "Post",
541            &["author_score"],
542            source_fields,
543        );
544        let target = entity(
545            "schema_relation_edge_rejects_float64_target",
546            "User",
547            &["score"],
548            target_fields,
549        );
550
551        let err = RelationEdge::new(
552            "author",
553            "schema_relation_edge_rejects_float64_target::User",
554            &["author_score"],
555        )
556        .validate_against_entities(&source, &target)
557        .expect_err("float64 target primary key component should reject");
558
559        assert!(
560            err.messages()
561                .iter()
562                .any(|message| message.contains("non-admissible component")),
563            "unexpected relation edge validation errors: {err}",
564        );
565    }
566
567    #[test]
568    fn relation_edge_rejects_generated_local_component_field() {
569        let source_fields =
570            Box::leak(vec![generated_field("author_id", Primitive::Ulid)].into_boxed_slice());
571        let target_fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
572        let source = entity(
573            "schema_relation_edge_rejects_generated_local",
574            "Post",
575            &["author_id"],
576            source_fields,
577        );
578        let target = entity(
579            "schema_relation_edge_rejects_generated_local",
580            "User",
581            &["id"],
582            target_fields,
583        );
584
585        let err = RelationEdge::new(
586            "author",
587            "schema_relation_edge_rejects_generated_local::User",
588            &["author_id"],
589        )
590        .validate_against_entities(&source, &target)
591        .expect_err("generated local component field should reject");
592
593        assert!(
594            err.messages()
595                .iter()
596                .any(|message| message.contains("is generated")),
597            "unexpected relation edge validation errors: {err}",
598        );
599    }
600
601    #[test]
602    fn relation_edge_rejects_mixed_local_component_cardinality() {
603        let source_fields = Box::leak(
604            vec![
605                field("author_tenant_id", Primitive::Nat64),
606                optional_field("author_user_id", Primitive::Ulid),
607            ]
608            .into_boxed_slice(),
609        );
610        let target_fields = Box::leak(
611            vec![
612                field("tenant_id", Primitive::Nat64),
613                field("user_id", Primitive::Ulid),
614            ]
615            .into_boxed_slice(),
616        );
617        let source = entity(
618            "schema_relation_edge_rejects_mixed_cardinality",
619            "Post",
620            &["author_tenant_id"],
621            source_fields,
622        );
623        let target = entity(
624            "schema_relation_edge_rejects_mixed_cardinality",
625            "User",
626            &["tenant_id", "user_id"],
627            target_fields,
628        );
629
630        let err = RelationEdge::new(
631            "author",
632            "schema_relation_edge_rejects_mixed_cardinality::User",
633            &["author_tenant_id", "author_user_id"],
634        )
635        .validate_against_entities(&source, &target)
636        .expect_err("mixed local tuple cardinality should reject");
637
638        assert!(
639            err.messages()
640                .iter()
641                .any(|message| message.contains("cardinality mismatch")),
642            "unexpected relation edge validation errors: {err}",
643        );
644    }
645
646    #[test]
647    fn relation_edge_validate_for_source_uses_schema_target_lookup() {
648        let source_fields = Box::leak(vec![field("author_id", Primitive::Ulid)].into_boxed_slice());
649        let target_fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
650        let source = entity(
651            "schema_relation_edge_lookup",
652            "Post",
653            &["author_id"],
654            source_fields,
655        );
656        let (target_path, _) = insert_entity(
657            "schema_relation_edge_lookup",
658            "User",
659            &["id"],
660            target_fields,
661        );
662
663        RelationEdge::new("author", target_path, &["author_id"])
664            .validate_for_source(&source)
665            .expect("schema target lookup should validate matching scalar edge");
666    }
667
668    #[test]
669    fn relation_edge_component_contract_preserves_bounds() {
670        let expected = field_with_item("body", item_with_metadata(Primitive::Text, None, Some(64)));
671        let same = field_with_item(
672            "body_copy",
673            item_with_metadata(Primitive::Text, None, Some(64)),
674        );
675        let wrong = field_with_item(
676            "body_short",
677            item_with_metadata(Primitive::Text, None, Some(32)),
678        );
679
680        let expected = RelationComponentContract::from_field(&expected);
681        assert!(!expected.mismatches(RelationComponentContract::from_field(&same)));
682        assert!(expected.mismatches(RelationComponentContract::from_field(&wrong)));
683    }
684}