Skip to main content

icydb_schema/node/
item.rs

1use crate::prelude::*;
2use std::ops::Not;
3
4///
5/// Item
6///
7/// Canonical schema item descriptor for one scalar, relation, or primitive
8/// field target plus its attached sanitizers and validators.
9///
10
11#[derive(Clone, Debug, Serialize)]
12pub struct Item {
13    target: ItemTarget,
14
15    #[serde(skip_serializing_if = "Option::is_none")]
16    relation: Option<&'static str>,
17
18    #[serde(skip_serializing_if = "Option::is_none")]
19    scale: Option<u32>,
20
21    #[serde(skip_serializing_if = "Option::is_none")]
22    max_len: Option<u32>,
23
24    #[serde(skip_serializing_if = "Option::is_none")]
25    max_bytes: Option<u32>,
26
27    #[serde(skip_serializing_if = "<[_]>::is_empty")]
28    validators: &'static [TypeValidator],
29
30    #[serde(skip_serializing_if = "<[_]>::is_empty")]
31    sanitizers: &'static [TypeSanitizer],
32
33    #[serde(skip_serializing_if = "Not::not")]
34    indirect: bool,
35}
36
37impl Item {
38    #[must_use]
39    #[expect(
40        clippy::too_many_arguments,
41        reason = "schema item construction keeps generated scalar, relation, and validation metadata explicit"
42    )]
43    pub const fn new(
44        target: ItemTarget,
45        relation: Option<&'static str>,
46        scale: Option<u32>,
47        max_len: Option<u32>,
48        max_bytes: Option<u32>,
49        validators: &'static [TypeValidator],
50        sanitizers: &'static [TypeSanitizer],
51        indirect: bool,
52    ) -> Self {
53        Self {
54            target,
55            relation,
56            scale,
57            max_len,
58            max_bytes,
59            validators,
60            sanitizers,
61            indirect,
62        }
63    }
64
65    #[must_use]
66    pub const fn target(&self) -> &ItemTarget {
67        &self.target
68    }
69
70    #[must_use]
71    pub const fn relation(&self) -> Option<&'static str> {
72        self.relation
73    }
74
75    #[must_use]
76    pub const fn scale(&self) -> Option<u32> {
77        self.scale
78    }
79
80    #[must_use]
81    pub const fn max_len(&self) -> Option<u32> {
82        self.max_len
83    }
84
85    #[must_use]
86    pub const fn max_bytes(&self) -> Option<u32> {
87        self.max_bytes
88    }
89
90    #[must_use]
91    pub const fn validators(&self) -> &'static [TypeValidator] {
92        self.validators
93    }
94
95    #[must_use]
96    pub const fn sanitizers(&self) -> &'static [TypeSanitizer] {
97        self.sanitizers
98    }
99
100    #[must_use]
101    pub const fn indirect(&self) -> bool {
102        self.indirect
103    }
104
105    #[must_use]
106    pub const fn is_relation(&self) -> bool {
107        self.relation().is_some()
108    }
109}
110
111impl ValidateNode for Item {
112    fn validate(&self) -> Result<(), ErrorTree> {
113        let mut errs = ErrorTree::new();
114        let schema = schema_read();
115
116        // Phase 1: validate target shape.
117        match self.target() {
118            ItemTarget::Is(path) => {
119                // cannot be an entity
120                if schema.check_node_as::<Entity>(path).is_ok() {
121                    err!(errs, "a non-relation Item cannot reference an Entity");
122                }
123            }
124
125            ItemTarget::Primitive(_) => {}
126        }
127
128        // Phase 2: validate relation target compatibility.
129        if let Some(relation) = self.relation() {
130            match schema.cast_node::<Entity>(relation) {
131                Ok(entity) => {
132                    if entity.primary_key().fields().len() != 1 {
133                        err!(
134                            errs,
135                            "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",
136                            entity.primary_key().fields()
137                        );
138                    } else if let Some(primary_field) = entity.scalar_primary_key_field() {
139                        let relation_target = primary_field.value().item().target();
140
141                        let relation_scale = primary_field.value().item().scale();
142                        let relation_max_len = primary_field.value().item().max_len();
143                        let relation_max_bytes = primary_field.value().item().max_bytes();
144                        if self.target() != relation_target
145                            || self.scale() != relation_scale
146                            || self.max_len() != relation_max_len
147                            || self.max_bytes() != relation_max_bytes
148                        {
149                            err!(
150                                errs,
151                                "relation target type mismatch: expected ({:?}, scale={:?}, max_len={:?}, max_bytes={:?}), found ({:?}, scale={:?}, max_len={:?}, max_bytes={:?})",
152                                relation_target,
153                                relation_scale,
154                                relation_max_len,
155                                relation_max_bytes,
156                                self.target(),
157                                self.scale(),
158                                self.max_len(),
159                                self.max_bytes()
160                            );
161                        }
162                    } else {
163                        let primary_key_field =
164                            entity.primary_key().scalar_field().unwrap_or("<composite>");
165                        err!(
166                            errs,
167                            "relation entity '{relation}' missing primary key field '{0}'",
168                            primary_key_field
169                        );
170                    }
171                }
172                Err(_) => {
173                    err!(errs, "relation entity '{relation}' not found");
174                }
175            }
176        }
177
178        errs.result()
179    }
180}
181
182impl VisitableNode for Item {
183    fn drive<V: Visitor>(&self, v: &mut V) {
184        for node in self.validators() {
185            node.accept(v);
186        }
187    }
188}
189
190///
191/// ItemTarget
192///
193/// Local item target declaration, either by schema path or primitive runtime
194/// kind.
195///
196
197#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
198pub enum ItemTarget {
199    Is(&'static str),
200    Primitive(Primitive),
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::build::schema_write;
207
208    fn primitive_item(primitive: Primitive) -> Item {
209        Item::new(
210            ItemTarget::Primitive(primitive),
211            None,
212            None,
213            None,
214            None,
215            &[],
216            &[],
217            false,
218        )
219    }
220
221    fn relation_item(target_path: &'static str, primitive: Primitive) -> Item {
222        Item::new(
223            ItemTarget::Primitive(primitive),
224            Some(target_path),
225            None,
226            None,
227            None,
228            &[],
229            &[],
230            false,
231        )
232    }
233
234    fn field(ident: &'static str, primitive: Primitive) -> Field {
235        Field::new(
236            ident,
237            Value::new(Cardinality::One, primitive_item(primitive)),
238            None,
239            None,
240            None,
241        )
242    }
243
244    fn insert_entity(
245        module: &'static str,
246        ident: &'static str,
247        pk_fields: &'static [&'static str],
248        fields: &'static [Field],
249    ) -> &'static str {
250        let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
251        schema_write().insert_node(SchemaNode::Entity(Entity::new(
252            Def::new(module, ident),
253            "SchemaItemRelationStore",
254            PrimaryKey::new(pk_fields, PrimaryKeySource::External),
255            None,
256            &[],
257            FieldList::new(fields),
258            Type::new(&[], &[]),
259        )));
260        path
261    }
262
263    #[test]
264    fn relation_to_composite_target_rejects_even_when_first_component_matches() {
265        let fields = Box::leak(
266            vec![
267                field("tenant_id", Primitive::Nat64),
268                field("local_id", Primitive::Nat64),
269            ]
270            .into_boxed_slice(),
271        );
272        let target_path = insert_entity(
273            "schema_item_relation_composite_target",
274            "CompositeTarget",
275            &["tenant_id", "local_id"],
276            fields,
277        );
278
279        let err = relation_item(target_path, Primitive::Nat64)
280            .validate()
281            .expect_err("relation to composite target must fail before first-field matching");
282
283        assert!(
284            err.messages().iter().any(|message| {
285                message.contains("uses composite primary key fields")
286                    && message
287                        .contains("single-field relation targets require a scalar primary key")
288            }),
289            "unexpected relation validation errors: {err}",
290        );
291    }
292
293    #[test]
294    fn scalar_128_bit_relation_targets_validate_at_schema_node_boundary() {
295        for (module, ident, primitive) in [
296            (
297                "schema_item_relation_int128_target",
298                "Int128Target",
299                Primitive::Int128,
300            ),
301            (
302                "schema_item_relation_nat128_target",
303                "Nat128Target",
304                Primitive::Nat128,
305            ),
306        ] {
307            let fields = Box::leak(vec![field("id", primitive)].into_boxed_slice());
308            let target_path = insert_entity(module, ident, &["id"], fields);
309
310            relation_item(target_path, primitive)
311                .validate()
312                .expect("scalar 128-bit relation target should validate");
313        }
314    }
315}