Skip to main content

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