1use crate::prelude::*;
2use std::any::Any;
3
4#[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 #[must_use]
106 pub fn scalar_primary_key_field(&self) -> Option<&Field> {
107 self.fields().get(self.primary_key().scalar_field()?)
108 }
109
110 #[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 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}