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            1,
364            PrimaryKey::new(pk_fields, PrimaryKeySource::External),
365            None,
366            &[],
367            &[],
368            FieldList::new(fields),
369            Type::new(&[], &[]),
370        )
371    }
372
373    fn insert_entity(
374        module: &'static str,
375        ident: &'static str,
376        pk_fields: &'static [&'static str],
377        fields: &'static [Field],
378    ) -> (&'static str, Entity) {
379        let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
380        let entity = entity(module, ident, pk_fields, fields);
381        schema_write().insert_node(SchemaNode::Entity(entity.clone()));
382        (path, entity)
383    }
384
385    #[test]
386    fn relation_edge_accepts_ordered_composite_target_tuple() {
387        let source_fields = Box::leak(
388            vec![
389                field("author_tenant_id", Primitive::Nat64),
390                field("author_user_id", Primitive::Ulid),
391            ]
392            .into_boxed_slice(),
393        );
394        let target_fields = Box::leak(
395            vec![
396                field("tenant_id", Primitive::Nat64),
397                field("user_id", Primitive::Ulid),
398            ]
399            .into_boxed_slice(),
400        );
401        let source = entity(
402            "schema_relation_edge_accepts_tuple",
403            "Post",
404            &["author_user_id"],
405            source_fields,
406        );
407        let target = entity(
408            "schema_relation_edge_accepts_tuple",
409            "User",
410            &["tenant_id", "user_id"],
411            target_fields,
412        );
413
414        RelationEdge::new(
415            "author",
416            "schema_relation_edge_accepts_tuple::User",
417            &["author_tenant_id", "author_user_id"],
418        )
419        .validate_against_entities(&source, &target)
420        .expect("matching ordered composite relation tuple should validate");
421    }
422
423    #[test]
424    fn relation_edge_rejects_scalar_local_field_for_composite_target() {
425        let source_fields =
426            Box::leak(vec![field("author_user_id", Primitive::Ulid)].into_boxed_slice());
427        let target_fields = Box::leak(
428            vec![
429                field("tenant_id", Primitive::Nat64),
430                field("user_id", Primitive::Ulid),
431            ]
432            .into_boxed_slice(),
433        );
434        let source = entity(
435            "schema_relation_edge_rejects_scalar_for_composite",
436            "Post",
437            &["author_user_id"],
438            source_fields,
439        );
440        let target = entity(
441            "schema_relation_edge_rejects_scalar_for_composite",
442            "User",
443            &["tenant_id", "user_id"],
444            target_fields,
445        );
446
447        let err = RelationEdge::new(
448            "author",
449            "schema_relation_edge_rejects_scalar_for_composite::User",
450            &["author_user_id"],
451        )
452        .validate_against_entities(&source, &target)
453        .expect_err("scalar local component must not validate as composite target tuple");
454
455        assert!(
456            err.messages()
457                .iter()
458                .any(|message| message.contains("arity mismatch")),
459            "unexpected relation edge validation errors: {err}",
460        );
461    }
462
463    #[test]
464    fn relation_edge_rejects_wrong_component_order() {
465        let source_fields = Box::leak(
466            vec![
467                field("author_tenant_id", Primitive::Nat64),
468                field("author_user_id", Primitive::Ulid),
469            ]
470            .into_boxed_slice(),
471        );
472        let target_fields = Box::leak(
473            vec![
474                field("tenant_id", Primitive::Nat64),
475                field("user_id", Primitive::Ulid),
476            ]
477            .into_boxed_slice(),
478        );
479        let source = entity(
480            "schema_relation_edge_rejects_order",
481            "Post",
482            &["author_user_id"],
483            source_fields,
484        );
485        let target = entity(
486            "schema_relation_edge_rejects_order",
487            "User",
488            &["tenant_id", "user_id"],
489            target_fields,
490        );
491
492        let err = RelationEdge::new(
493            "author",
494            "schema_relation_edge_rejects_order::User",
495            &["author_user_id", "author_tenant_id"],
496        )
497        .validate_against_entities(&source, &target)
498        .expect_err("local tuple order must match target primary-key order");
499
500        assert!(
501            err.messages()
502                .iter()
503                .any(|message| message.contains("component 0 type mismatch")),
504            "unexpected relation edge validation errors: {err}",
505        );
506    }
507
508    #[test]
509    fn relation_edge_rejects_missing_local_component_field() {
510        let source_fields =
511            Box::leak(vec![field("author_tenant_id", Primitive::Nat64)].into_boxed_slice());
512        let target_fields = Box::leak(
513            vec![
514                field("tenant_id", Primitive::Nat64),
515                field("user_id", Primitive::Ulid),
516            ]
517            .into_boxed_slice(),
518        );
519        let source = entity(
520            "schema_relation_edge_rejects_missing_local",
521            "Post",
522            &["author_tenant_id"],
523            source_fields,
524        );
525        let target = entity(
526            "schema_relation_edge_rejects_missing_local",
527            "User",
528            &["tenant_id", "user_id"],
529            target_fields,
530        );
531
532        let err = RelationEdge::new(
533            "author",
534            "schema_relation_edge_rejects_missing_local::User",
535            &["author_tenant_id", "author_user_id"],
536        )
537        .validate_against_entities(&source, &target)
538        .expect_err("missing local tuple component should reject");
539
540        assert!(
541            err.messages()
542                .iter()
543                .any(|message| message.contains("local field 'author_user_id' not found")),
544            "unexpected relation edge validation errors: {err}",
545        );
546    }
547
548    #[test]
549    fn relation_edge_rejects_non_admissible_target_primary_key_component() {
550        let source_fields =
551            Box::leak(vec![field("author_score", Primitive::IntBig)].into_boxed_slice());
552        let target_fields = Box::leak(vec![field("score", Primitive::IntBig)].into_boxed_slice());
553        let source = entity(
554            "schema_relation_edge_rejects_int_big_target",
555            "Post",
556            &["author_score"],
557            source_fields,
558        );
559        let target = entity(
560            "schema_relation_edge_rejects_int_big_target",
561            "User",
562            &["score"],
563            target_fields,
564        );
565
566        let err = RelationEdge::new(
567            "author",
568            "schema_relation_edge_rejects_int_big_target::User",
569            &["author_score"],
570        )
571        .validate_against_entities(&source, &target)
572        .expect_err("int_big target primary key component should reject");
573
574        assert!(
575            err.messages()
576                .iter()
577                .any(|message| message.contains("non-admissible component")),
578            "unexpected relation edge validation errors: {err}",
579        );
580    }
581
582    #[test]
583    fn relation_edge_rejects_generated_local_component_field() {
584        let source_fields =
585            Box::leak(vec![generated_field("author_id", Primitive::Ulid)].into_boxed_slice());
586        let target_fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
587        let source = entity(
588            "schema_relation_edge_rejects_generated_local",
589            "Post",
590            &["author_id"],
591            source_fields,
592        );
593        let target = entity(
594            "schema_relation_edge_rejects_generated_local",
595            "User",
596            &["id"],
597            target_fields,
598        );
599
600        let err = RelationEdge::new(
601            "author",
602            "schema_relation_edge_rejects_generated_local::User",
603            &["author_id"],
604        )
605        .validate_against_entities(&source, &target)
606        .expect_err("generated local component field should reject");
607
608        assert!(
609            err.messages()
610                .iter()
611                .any(|message| message.contains("is generated")),
612            "unexpected relation edge validation errors: {err}",
613        );
614    }
615
616    #[test]
617    fn relation_edge_rejects_mixed_local_component_cardinality() {
618        let source_fields = Box::leak(
619            vec![
620                field("author_tenant_id", Primitive::Nat64),
621                optional_field("author_user_id", Primitive::Ulid),
622            ]
623            .into_boxed_slice(),
624        );
625        let target_fields = Box::leak(
626            vec![
627                field("tenant_id", Primitive::Nat64),
628                field("user_id", Primitive::Ulid),
629            ]
630            .into_boxed_slice(),
631        );
632        let source = entity(
633            "schema_relation_edge_rejects_mixed_cardinality",
634            "Post",
635            &["author_tenant_id"],
636            source_fields,
637        );
638        let target = entity(
639            "schema_relation_edge_rejects_mixed_cardinality",
640            "User",
641            &["tenant_id", "user_id"],
642            target_fields,
643        );
644
645        let err = RelationEdge::new(
646            "author",
647            "schema_relation_edge_rejects_mixed_cardinality::User",
648            &["author_tenant_id", "author_user_id"],
649        )
650        .validate_against_entities(&source, &target)
651        .expect_err("mixed local tuple cardinality should reject");
652
653        assert!(
654            err.messages()
655                .iter()
656                .any(|message| message.contains("cardinality mismatch")),
657            "unexpected relation edge validation errors: {err}",
658        );
659    }
660
661    #[test]
662    fn relation_edge_validate_for_source_uses_schema_target_lookup() {
663        let source_fields = Box::leak(vec![field("author_id", Primitive::Ulid)].into_boxed_slice());
664        let target_fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
665        let source = entity(
666            "schema_relation_edge_lookup",
667            "Post",
668            &["author_id"],
669            source_fields,
670        );
671        let (target_path, _) = insert_entity(
672            "schema_relation_edge_lookup",
673            "User",
674            &["id"],
675            target_fields,
676        );
677
678        RelationEdge::new("author", target_path, &["author_id"])
679            .validate_for_source(&source)
680            .expect("schema target lookup should validate matching scalar edge");
681    }
682
683    #[test]
684    fn relation_edge_component_contract_preserves_bounds() {
685        let expected = field_with_item(
686            "body",
687            item_with_metadata(Primitive::Text, None, Some(64), None),
688        );
689        let same = field_with_item(
690            "body_copy",
691            item_with_metadata(Primitive::Text, None, Some(64), None),
692        );
693        let wrong = field_with_item(
694            "body_short",
695            item_with_metadata(Primitive::Text, None, Some(32), None),
696        );
697
698        let expected = RelationComponentContract::from_field(&expected);
699        assert!(!expected.mismatches(RelationComponentContract::from_field(&same)));
700        assert!(expected.mismatches(RelationComponentContract::from_field(&wrong)));
701    }
702}