Skip to main content

icydb_schema/node/
item.rs

1use super::relation::RelationComponentContract;
2use crate::prelude::*;
3use std::ops::Not;
4
5///
6/// Item
7///
8/// Canonical schema item descriptor for one scalar, relation, or primitive
9/// field target plus its attached sanitizers and validators.
10///
11
12#[derive(Clone, Debug, Serialize)]
13pub struct Item {
14    target: ItemTarget,
15
16    #[serde(skip_serializing_if = "Option::is_none")]
17    relation: Option<&'static str>,
18
19    #[serde(skip_serializing_if = "Option::is_none")]
20    scale: Option<u32>,
21
22    #[serde(skip_serializing_if = "Option::is_none")]
23    max_len: Option<u32>,
24
25    #[serde(skip_serializing_if = "Option::is_none")]
26    max_bytes: Option<u32>,
27
28    #[serde(skip_serializing_if = "<[_]>::is_empty")]
29    validators: &'static [TypeValidator],
30
31    #[serde(skip_serializing_if = "<[_]>::is_empty")]
32    sanitizers: &'static [TypeSanitizer],
33
34    #[serde(skip_serializing_if = "Not::not")]
35    indirect: bool,
36}
37
38impl Item {
39    #[must_use]
40    #[expect(
41        clippy::too_many_arguments,
42        reason = "schema item construction keeps generated scalar, relation, and validation metadata explicit"
43    )]
44    pub const fn new(
45        target: ItemTarget,
46        relation: Option<&'static str>,
47        scale: Option<u32>,
48        max_len: Option<u32>,
49        max_bytes: Option<u32>,
50        validators: &'static [TypeValidator],
51        sanitizers: &'static [TypeSanitizer],
52        indirect: bool,
53    ) -> Self {
54        Self {
55            target,
56            relation,
57            scale,
58            max_len,
59            max_bytes,
60            validators,
61            sanitizers,
62            indirect,
63        }
64    }
65
66    #[must_use]
67    pub const fn target(&self) -> &ItemTarget {
68        &self.target
69    }
70
71    #[must_use]
72    pub const fn relation(&self) -> Option<&'static str> {
73        self.relation
74    }
75
76    #[must_use]
77    pub const fn scale(&self) -> Option<u32> {
78        self.scale
79    }
80
81    #[must_use]
82    pub const fn max_len(&self) -> Option<u32> {
83        self.max_len
84    }
85
86    #[must_use]
87    pub const fn max_bytes(&self) -> Option<u32> {
88        self.max_bytes
89    }
90
91    #[must_use]
92    pub const fn validators(&self) -> &'static [TypeValidator] {
93        self.validators
94    }
95
96    #[must_use]
97    pub const fn sanitizers(&self) -> &'static [TypeSanitizer] {
98        self.sanitizers
99    }
100
101    #[must_use]
102    pub const fn indirect(&self) -> bool {
103        self.indirect
104    }
105
106    #[must_use]
107    pub const fn is_relation(&self) -> bool {
108        self.relation().is_some()
109    }
110}
111
112impl ValidateNode for Item {
113    fn validate(&self) -> Result<(), ErrorTree> {
114        let mut errs = ErrorTree::new();
115        let schema = schema_read();
116
117        // Phase 1: validate target shape.
118        match self.target() {
119            ItemTarget::Is(path) => {
120                // cannot be an entity
121                if schema.check_node_as::<Entity>(path).is_ok() {
122                    err!(errs, "a non-relation Item cannot reference an Entity");
123                }
124            }
125
126            ItemTarget::Primitive(_) => {}
127        }
128
129        // Phase 2: validate relation target compatibility.
130        if let Some(relation) = self.relation() {
131            match schema.cast_node::<Entity>(relation) {
132                Ok(entity) => {
133                    if entity.primary_key().fields().len() != 1 {
134                        err!(
135                            errs,
136                            "relation entity '{relation}' uses composite primary key fields {:?}; single-field relation targets require a scalar primary key; use ordered relation tuple metadata for composite targets",
137                            entity.primary_key().fields()
138                        );
139                    } else if let Some(primary_field) = entity.scalar_primary_key_field() {
140                        let expected = RelationComponentContract::from_field(primary_field);
141                        let actual = RelationComponentContract::from_item(self);
142                        if expected.mismatches(actual) {
143                            err!(
144                                errs,
145                                "relation target type mismatch: expected ({:?}, scale={:?}, max_len={:?}, max_bytes={:?}), found ({:?}, scale={:?}, max_len={:?}, max_bytes={:?})",
146                                expected.target(),
147                                expected.scale(),
148                                expected.max_len(),
149                                expected.max_bytes(),
150                                actual.target(),
151                                actual.scale(),
152                                actual.max_len(),
153                                actual.max_bytes(),
154                            );
155                        }
156                    } else {
157                        let primary_key_field =
158                            entity.primary_key().scalar_field().unwrap_or("<composite>");
159                        err!(
160                            errs,
161                            "relation entity '{relation}' missing primary key field '{0}'",
162                            primary_key_field
163                        );
164                    }
165                }
166                Err(_) => {
167                    err!(errs, "relation entity '{relation}' not found");
168                }
169            }
170        }
171
172        errs.result()
173    }
174}
175
176impl VisitableNode for Item {
177    fn drive<V: Visitor>(&self, v: &mut V) {
178        for node in self.validators() {
179            node.accept(v);
180        }
181    }
182}
183
184///
185/// ItemTarget
186///
187/// Local item target declaration, either by schema path or primitive runtime
188/// kind.
189///
190
191#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
192pub enum ItemTarget {
193    Is(&'static str),
194    Primitive(Primitive),
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::build::schema_write;
201
202    fn primitive_item(primitive: Primitive) -> Item {
203        Item::new(
204            ItemTarget::Primitive(primitive),
205            None,
206            None,
207            None,
208            None,
209            &[],
210            &[],
211            false,
212        )
213    }
214
215    fn relation_item(target_path: &'static str, primitive: Primitive) -> Item {
216        Item::new(
217            ItemTarget::Primitive(primitive),
218            Some(target_path),
219            None,
220            None,
221            None,
222            &[],
223            &[],
224            false,
225        )
226    }
227
228    fn field(ident: &'static str, primitive: Primitive) -> Field {
229        Field::new(
230            ident,
231            Value::new(Cardinality::One, primitive_item(primitive)),
232            None,
233            None,
234            None,
235        )
236    }
237
238    fn item_with_metadata(
239        primitive: Primitive,
240        scale: Option<u32>,
241        max_len: Option<u32>,
242        max_bytes: Option<u32>,
243    ) -> Item {
244        Item::new(
245            ItemTarget::Primitive(primitive),
246            None,
247            scale,
248            max_len,
249            max_bytes,
250            &[],
251            &[],
252            false,
253        )
254    }
255
256    fn insert_entity(
257        module: &'static str,
258        ident: &'static str,
259        pk_fields: &'static [&'static str],
260        fields: &'static [Field],
261    ) -> &'static str {
262        let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
263        schema_write().insert_node(SchemaNode::Entity(Entity::new(
264            Def::new(module, ident),
265            "SchemaItemRelationStore",
266            PrimaryKey::new(pk_fields, PrimaryKeySource::External),
267            None,
268            &[],
269            &[],
270            FieldList::new(fields),
271            Type::new(&[], &[]),
272        )));
273        path
274    }
275
276    #[test]
277    fn relation_to_composite_target_rejects_even_when_first_component_matches() {
278        let fields = Box::leak(
279            vec![
280                field("tenant_id", Primitive::Nat64),
281                field("local_id", Primitive::Nat64),
282            ]
283            .into_boxed_slice(),
284        );
285        let target_path = insert_entity(
286            "schema_item_relation_composite_target",
287            "CompositeTarget",
288            &["tenant_id", "local_id"],
289            fields,
290        );
291
292        let err = relation_item(target_path, Primitive::Nat64)
293            .validate()
294            .expect_err("relation to composite target must fail before first-field matching");
295
296        assert!(
297            err.messages().iter().any(|message| {
298                message.contains("uses composite primary key fields")
299                    && message
300                        .contains("single-field relation targets require a scalar primary key")
301            }),
302            "unexpected relation validation errors: {err}",
303        );
304    }
305
306    #[test]
307    fn scalar_128_bit_relation_targets_validate_at_schema_node_boundary() {
308        for (module, ident, primitive) in [
309            (
310                "schema_item_relation_int128_target",
311                "Int128Target",
312                Primitive::Int128,
313            ),
314            (
315                "schema_item_relation_nat128_target",
316                "Nat128Target",
317                Primitive::Nat128,
318            ),
319        ] {
320            let fields = Box::leak(vec![field("id", primitive)].into_boxed_slice());
321            let target_path = insert_entity(module, ident, &["id"], fields);
322
323            relation_item(target_path, primitive)
324                .validate()
325                .expect("scalar 128-bit relation target should validate");
326        }
327    }
328
329    #[test]
330    fn scalar_relation_target_descriptor_compares_type_and_bounds() {
331        for (primitive, expected_metadata, wrong_metadata) in [
332            (
333                Primitive::Decimal,
334                (Some(4), None, None),
335                (Some(2), None, None),
336            ),
337            (
338                Primitive::Text,
339                (None, Some(64), None),
340                (None, Some(32), None),
341            ),
342            (
343                Primitive::IntBig,
344                (None, None, Some(32)),
345                (None, None, Some(16)),
346            ),
347        ] {
348            let expected = item_with_metadata(
349                primitive,
350                expected_metadata.0,
351                expected_metadata.1,
352                expected_metadata.2,
353            );
354            let same = item_with_metadata(
355                primitive,
356                expected_metadata.0,
357                expected_metadata.1,
358                expected_metadata.2,
359            );
360            let wrong_bounds = item_with_metadata(
361                primitive,
362                wrong_metadata.0,
363                wrong_metadata.1,
364                wrong_metadata.2,
365            );
366            let wrong_target = item_with_metadata(
367                Primitive::Nat64,
368                expected_metadata.0,
369                expected_metadata.1,
370                expected_metadata.2,
371            );
372
373            let expected = RelationComponentContract::from_item(&expected);
374            assert!(!expected.mismatches(RelationComponentContract::from_item(&same)));
375            assert!(expected.mismatches(RelationComponentContract::from_item(&wrong_bounds)));
376            assert!(expected.mismatches(RelationComponentContract::from_item(&wrong_target)));
377        }
378    }
379
380    #[test]
381    fn scalar_relation_target_validation_rejects_mismatched_scalar_kind() {
382        let fields = Box::leak(vec![field("id", Primitive::Nat64)].into_boxed_slice());
383        let target_path = insert_entity(
384            "schema_item_relation_scalar_target_mismatch",
385            "Nat64Target",
386            &["id"],
387            fields,
388        );
389
390        let err = relation_item(target_path, Primitive::Int64)
391            .validate()
392            .expect_err("mismatched scalar relation target should reject");
393
394        assert!(
395            err.messages()
396                .iter()
397                .any(|message| message.contains("relation target type mismatch")),
398            "unexpected relation validation errors: {err}",
399        );
400    }
401
402    #[test]
403    fn scalar_relation_target_validation_accepts_matching_scalar_kind() {
404        let fields = Box::leak(vec![field("id", Primitive::Nat64)].into_boxed_slice());
405        let target_path = insert_entity(
406            "schema_item_relation_scalar_target_match",
407            "Nat64Target",
408            &["id"],
409            fields,
410        );
411
412        relation_item(target_path, Primitive::Nat64)
413            .validate()
414            .expect("matching scalar relation target should validate");
415    }
416
417    #[test]
418    fn scalar_relation_target_from_field_preserves_metadata_descriptor() {
419        let field = Field::new(
420            "id",
421            Value::new(
422                Cardinality::One,
423                item_with_metadata(Primitive::Text, None, Some(64), None),
424            ),
425            None,
426            None,
427            None,
428        );
429
430        let descriptor = RelationComponentContract::from_field(&field);
431        assert_eq!(descriptor.target(), &ItemTarget::Primitive(Primitive::Text));
432        assert_eq!(descriptor.scale(), None);
433        assert_eq!(descriptor.max_len(), Some(64));
434        assert_eq!(descriptor.max_bytes(), None);
435    }
436}