Skip to main content

icydb_schema/node/
entity.rs

1use crate::prelude::*;
2use std::any::Any;
3
4///
5/// Entity
6///
7
8#[derive(Clone, Debug, Serialize)]
9pub struct Entity {
10    def: Def,
11    store: &'static str,
12    schema_version: u32,
13    primary_key: PrimaryKey,
14
15    #[serde(skip_serializing_if = "Option::is_none")]
16    name: Option<&'static str>,
17
18    #[serde(skip_serializing_if = "<[_]>::is_empty")]
19    indexes: &'static [Index],
20
21    #[serde(skip_serializing_if = "<[_]>::is_empty")]
22    relations: &'static [RelationEdge],
23
24    fields: FieldList,
25    ty: Type,
26}
27
28impl Entity {
29    #[must_use]
30    #[expect(
31        clippy::too_many_arguments,
32        reason = "schema entity construction keeps store, key, index, relation, field, and type metadata explicit"
33    )]
34    pub const fn new(
35        def: Def,
36        store: &'static str,
37        schema_version: u32,
38        primary_key: PrimaryKey,
39        name: Option<&'static str>,
40        indexes: &'static [Index],
41        relations: &'static [RelationEdge],
42        fields: FieldList,
43        ty: Type,
44    ) -> Self {
45        Self {
46            def,
47            store,
48            schema_version,
49            primary_key,
50            name,
51            indexes,
52            relations,
53            fields,
54            ty,
55        }
56    }
57
58    #[must_use]
59    pub const fn def(&self) -> &Def {
60        &self.def
61    }
62
63    #[must_use]
64    pub const fn store(&self) -> &'static str {
65        self.store
66    }
67
68    #[must_use]
69    pub const fn schema_version(&self) -> u32 {
70        self.schema_version
71    }
72
73    #[must_use]
74    pub const fn primary_key(&self) -> &PrimaryKey {
75        &self.primary_key
76    }
77
78    #[must_use]
79    pub const fn name(&self) -> Option<&'static str> {
80        self.name
81    }
82
83    #[must_use]
84    pub const fn indexes(&self) -> &'static [Index] {
85        self.indexes
86    }
87
88    #[must_use]
89    pub const fn relations(&self) -> &'static [RelationEdge] {
90        self.relations
91    }
92
93    #[must_use]
94    pub const fn fields(&self) -> &FieldList {
95        &self.fields
96    }
97
98    #[must_use]
99    pub const fn ty(&self) -> &Type {
100        &self.ty
101    }
102
103    /// Return the scalar primary key field if this entity uses a scalar
104    /// primary-key contract.
105    #[must_use]
106    pub fn scalar_primary_key_field(&self) -> Option<&Field> {
107        self.fields().get(self.primary_key().scalar_field()?)
108    }
109
110    /// Resolve the entity name used for schema identity.
111    #[must_use]
112    pub fn resolved_name(&self) -> &'static str {
113        self.name().unwrap_or_else(|| self.def().ident())
114    }
115
116    fn validate_relation_storage_policy(&self, errs: &mut ErrorTree) {
117        for field in self.fields().fields() {
118            if let Some(target) = field.value().item().relation() {
119                self.validate_relation_target_storage_policy(errs, field.ident(), target);
120            }
121        }
122
123        for relation in self.relations() {
124            self.validate_relation_target_storage_policy(errs, relation.ident(), relation.target());
125        }
126    }
127
128    fn validate_relation_target_storage_policy(
129        &self,
130        errs: &mut ErrorTree,
131        relation_name: &str,
132        target_path: &str,
133    ) {
134        let schema = schema_read();
135        let Ok(source_store) = schema.cast_node::<Store>(self.store()) else {
136            return;
137        };
138        let Ok(target) = schema.cast_node::<Self>(target_path) else {
139            return;
140        };
141        let Ok(target_store) = schema.cast_node::<Store>(target.store()) else {
142            return;
143        };
144
145        if source_store.is_stable_storage() && target_store.is_heap_storage() {
146            err!(
147                errs,
148                "relation '{}' from stable store '{}' to heap target store '{}' is not supported in 0.169; stable stores cannot own referential integrity against volatile heap targets",
149                relation_name,
150                self.store(),
151                target.store(),
152            );
153        }
154    }
155}
156
157impl MacroNode for Entity {
158    fn as_any(&self) -> &dyn Any {
159        self
160    }
161}
162
163impl ValidateNode for Entity {
164    fn validate(&self) -> Result<(), ErrorTree> {
165        let mut errs = ErrorTree::new();
166        let schema = schema_read();
167
168        if self.schema_version() == 0 {
169            err!(errs, "entity schema_version must be a positive integer");
170        }
171
172        // store
173        match schema.cast_node::<Store>(self.store()) {
174            Ok(_) => {}
175            Err(e) => errs.add(e),
176        }
177
178        for relation in self.relations() {
179            if let Err(e) = relation.validate_for_source(self) {
180                errs.merge_for(relation.ident(), e);
181            }
182        }
183        self.validate_relation_storage_policy(&mut errs);
184
185        errs.result()
186    }
187}
188
189impl VisitableNode for Entity {
190    fn route_key(&self) -> String {
191        self.def().path()
192    }
193
194    fn drive<V: Visitor>(&self, v: &mut V) {
195        self.def().accept(v);
196        self.fields().accept(v);
197        self.ty().accept(v);
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::build::schema_write;
205
206    fn primitive_item(primitive: Primitive) -> Item {
207        Item::new(
208            ItemTarget::Primitive(primitive),
209            None,
210            None,
211            None,
212            None,
213            &[],
214            &[],
215            false,
216        )
217    }
218
219    fn relation_item(primitive: Primitive, target: &'static str) -> Item {
220        Item::new(
221            ItemTarget::Primitive(primitive),
222            Some(target),
223            None,
224            None,
225            None,
226            &[],
227            &[],
228            false,
229        )
230    }
231
232    fn field(ident: &'static str, primitive: Primitive) -> Field {
233        Field::new(
234            ident,
235            Value::new(Cardinality::One, primitive_item(primitive)),
236            None,
237            None,
238            None,
239        )
240    }
241
242    fn relation_field(ident: &'static str, primitive: Primitive, target: &'static str) -> Field {
243        Field::new(
244            ident,
245            Value::new(Cardinality::One, relation_item(primitive, target)),
246            None,
247            None,
248            None,
249        )
250    }
251
252    fn store(path: &'static str) -> Store {
253        Store::new_stable(
254            Def::new("schema_entity_relation_edge", "Store"),
255            "STORE",
256            "schema_entity_relation_edge_store",
257            path,
258            StoreStableMemoryConfig::new(110, 111, 112),
259        )
260    }
261
262    fn stable_store_in_module(module: &'static str, ident: &'static str) -> Store {
263        Store::new_stable(
264            Def::new(module, ident),
265            "STORE",
266            "schema_entity_relation_edge_store",
267            "schema_entity_relation_edge_store",
268            StoreStableMemoryConfig::new(120, 121, 122),
269        )
270    }
271
272    fn heap_store_in_module(module: &'static str, ident: &'static str) -> Store {
273        Store::new_heap(
274            Def::new(module, ident),
275            "HEAP_STORE",
276            "schema_entity_relation_edge_heap_store",
277            "schema_entity_relation_edge_heap_store",
278            StoreHeapConfig::new(),
279        )
280    }
281
282    fn entity(
283        ident: &'static str,
284        store_path: &'static str,
285        pk_fields: &'static [&'static str],
286        relations: &'static [RelationEdge],
287        fields: &'static [Field],
288    ) -> Entity {
289        entity_in_module(
290            "schema_entity_relation_edge",
291            ident,
292            pk_fields,
293            store_path,
294            relations,
295            fields,
296        )
297    }
298
299    fn entity_in_module(
300        module: &'static str,
301        ident: &'static str,
302        pk_fields: &'static [&'static str],
303        store_path: &'static str,
304        relations: &'static [RelationEdge],
305        fields: &'static [Field],
306    ) -> Entity {
307        Entity::new(
308            Def::new(module, ident),
309            store_path,
310            1,
311            PrimaryKey::new(pk_fields, PrimaryKeySource::External),
312            None,
313            &[],
314            relations,
315            FieldList::new(fields),
316            Type::new(&[], &[]),
317        )
318    }
319
320    #[test]
321    fn entity_validation_checks_owned_relation_edges() {
322        let store_path = "schema_entity_relation_edge::Store";
323        schema_write().insert_node(SchemaNode::Store(store(store_path)));
324        let target_fields = Box::leak(
325            vec![
326                field("tenant_id", Primitive::Nat64),
327                field("id", Primitive::Ulid),
328            ]
329            .into_boxed_slice(),
330        );
331        schema_write().insert_node(SchemaNode::Entity(entity(
332            "User",
333            store_path,
334            &["tenant_id", "id"],
335            &[],
336            target_fields,
337        )));
338
339        let source_fields = Box::leak(
340            vec![
341                field("author_tenant_id", Primitive::Nat64),
342                field("author_id", Primitive::Ulid),
343            ]
344            .into_boxed_slice(),
345        );
346        let source_relations = Box::leak(
347            vec![RelationEdge::new(
348                "author",
349                "schema_entity_relation_edge::User",
350                &["author_tenant_id", "author_id"],
351            )]
352            .into_boxed_slice(),
353        );
354        let source = entity(
355            "Post",
356            store_path,
357            &["author_id"],
358            source_relations,
359            source_fields,
360        );
361
362        source
363            .validate()
364            .expect("entity-owned matching relation edge should validate");
365    }
366
367    #[test]
368    fn entity_validation_rejects_zero_schema_version() {
369        let store_path = "schema_entity_relation_edge::Store";
370        schema_write().insert_node(SchemaNode::Store(store(store_path)));
371        let fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
372        let mut source = entity("Versioned", store_path, &["id"], &[], fields);
373        source.schema_version = 0;
374
375        let err = source
376            .validate()
377            .expect_err("zero schema_version should fail schema node validation");
378        assert!(
379            err.to_string()
380                .contains("entity schema_version must be a positive integer"),
381            "unexpected schema_version validation error: {err}",
382        );
383    }
384
385    #[test]
386    fn entity_validation_rejects_stable_source_relation_field_to_heap_target() {
387        let module = "schema_entity_relation_field_stable_to_heap";
388        let source_store_path = "schema_entity_relation_field_stable_to_heap::StableStore";
389        let target_store_path = "schema_entity_relation_field_stable_to_heap::HeapStore";
390        let target_path = "schema_entity_relation_field_stable_to_heap::User";
391        schema_write().insert_node(SchemaNode::Store(stable_store_in_module(
392            module,
393            "StableStore",
394        )));
395        schema_write().insert_node(SchemaNode::Store(heap_store_in_module(module, "HeapStore")));
396        schema_write().insert_node(SchemaNode::Entity(entity_in_module(
397            module,
398            "User",
399            &["id"],
400            target_store_path,
401            &[],
402            Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice()),
403        )));
404
405        let source = entity_in_module(
406            module,
407            "Post",
408            &["id"],
409            source_store_path,
410            &[],
411            Box::leak(
412                vec![
413                    field("id", Primitive::Ulid),
414                    relation_field("author_id", Primitive::Ulid, target_path),
415                ]
416                .into_boxed_slice(),
417            ),
418        );
419
420        let err = source
421            .validate()
422            .expect_err("stable source relation into heap target should reject");
423        assert_eq!(err.messages().len(), 1);
424        assert!(err.children().is_empty());
425    }
426
427    #[test]
428    fn entity_validation_allows_heap_source_relation_field_to_heap_target() {
429        let module = "schema_entity_relation_field_heap_to_heap";
430        let store_path = "schema_entity_relation_field_heap_to_heap::HeapStore";
431        let target_path = "schema_entity_relation_field_heap_to_heap::User";
432        schema_write().insert_node(SchemaNode::Store(heap_store_in_module(module, "HeapStore")));
433        schema_write().insert_node(SchemaNode::Entity(entity_in_module(
434            module,
435            "User",
436            &["id"],
437            store_path,
438            &[],
439            Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice()),
440        )));
441
442        let source = entity_in_module(
443            module,
444            "Post",
445            &["id"],
446            store_path,
447            &[],
448            Box::leak(
449                vec![
450                    field("id", Primitive::Ulid),
451                    relation_field("author_id", Primitive::Ulid, target_path),
452                ]
453                .into_boxed_slice(),
454            ),
455        );
456
457        source
458            .validate()
459            .expect("heap source relation into heap target should keep live validation semantics");
460    }
461
462    #[test]
463    fn entity_validation_rejects_stable_source_relation_edge_to_heap_target() {
464        let module = "schema_entity_relation_edge_stable_to_heap";
465        let source_store_path = "schema_entity_relation_edge_stable_to_heap::StableStore";
466        let target_store_path = "schema_entity_relation_edge_stable_to_heap::HeapStore";
467        schema_write().insert_node(SchemaNode::Store(stable_store_in_module(
468            module,
469            "StableStore",
470        )));
471        schema_write().insert_node(SchemaNode::Store(heap_store_in_module(module, "HeapStore")));
472        let target_fields = Box::leak(
473            vec![
474                field("tenant_id", Primitive::Nat64),
475                field("id", Primitive::Ulid),
476            ]
477            .into_boxed_slice(),
478        );
479        schema_write().insert_node(SchemaNode::Entity(entity_in_module(
480            module,
481            "User",
482            &["tenant_id", "id"],
483            target_store_path,
484            &[],
485            target_fields,
486        )));
487
488        let source_relations = Box::leak(
489            vec![RelationEdge::new(
490                "author",
491                "schema_entity_relation_edge_stable_to_heap::User",
492                &["author_tenant_id", "author_id"],
493            )]
494            .into_boxed_slice(),
495        );
496        let source = entity_in_module(
497            module,
498            "Post",
499            &["id"],
500            source_store_path,
501            source_relations,
502            Box::leak(
503                vec![
504                    field("id", Primitive::Ulid),
505                    field("author_tenant_id", Primitive::Nat64),
506                    field("author_id", Primitive::Ulid),
507                ]
508                .into_boxed_slice(),
509            ),
510        );
511
512        let err = source
513            .validate()
514            .expect_err("stable source relation edge into heap target should reject");
515        assert_eq!(err.messages().len(), 1);
516        assert!(err.children().is_empty());
517    }
518
519    #[test]
520    fn entity_validation_reports_relation_edge_errors_under_relation_name() {
521        let store_path = "schema_entity_relation_edge_error::Store";
522        schema_write().insert_node(SchemaNode::Store(Store::new_stable(
523            Def::new("schema_entity_relation_edge_error", "Store"),
524            "STORE",
525            "schema_entity_relation_edge_error_store",
526            store_path,
527            StoreStableMemoryConfig::new(113, 114, 115),
528        )));
529        let target_fields = Box::leak(
530            vec![
531                field("tenant_id", Primitive::Nat64),
532                field("id", Primitive::Ulid),
533            ]
534            .into_boxed_slice(),
535        );
536        schema_write().insert_node(SchemaNode::Entity(entity(
537            "User",
538            store_path,
539            &["tenant_id", "id"],
540            &[],
541            target_fields,
542        )));
543
544        let source_fields = Box::leak(vec![field("author_id", Primitive::Ulid)].into_boxed_slice());
545        let source_relations = Box::leak(
546            vec![RelationEdge::new(
547                "author",
548                "schema_entity_relation_edge_error::User",
549                &["author_id"],
550            )]
551            .into_boxed_slice(),
552        );
553        let source = entity(
554            "BrokenPost",
555            store_path,
556            &["author_id"],
557            source_relations,
558            source_fields,
559        );
560
561        let err = source
562            .validate()
563            .expect_err("entity validation should reject invalid relation edge");
564
565        assert!(
566            err.children().contains_key("author"),
567            "relation edge errors should be nested under relation name: {err}",
568        );
569    }
570}