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