1use crate::prelude::*;
2
3#[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}
15
16impl<'a> RelationComponentContract<'a> {
17 pub(crate) const fn from_field(field: &'a Field) -> Self {
18 Self::from_item(field.value().item())
19 }
20
21 pub(crate) const fn from_item(item: &'a Item) -> Self {
22 Self {
23 target: item.target(),
24 scale: item.scale(),
25 max_len: item.max_len(),
26 }
27 }
28
29 pub(crate) const fn target(&self) -> &'a ItemTarget {
30 self.target
31 }
32
33 pub(crate) const fn scale(&self) -> Option<u32> {
34 self.scale
35 }
36
37 pub(crate) const fn max_len(&self) -> Option<u32> {
38 self.max_len
39 }
40
41 pub(crate) fn mismatches(self, other: Self) -> bool {
42 self != other
43 }
44}
45
46#[derive(Clone, Debug, Serialize)]
56pub struct RelationEdge {
57 name: &'static str,
58 target: &'static str,
59 local_fields: &'static [&'static str],
60}
61
62impl RelationEdge {
63 #[must_use]
66 pub const fn new(
67 name: &'static str,
68 target: &'static str,
69 local_fields: &'static [&'static str],
70 ) -> Self {
71 Self {
72 name,
73 target,
74 local_fields,
75 }
76 }
77
78 #[must_use]
80 pub const fn name(&self) -> &'static str {
81 self.name
82 }
83
84 #[must_use]
86 pub const fn target(&self) -> &'static str {
87 self.target
88 }
89
90 #[must_use]
92 pub const fn local_fields(&self) -> &'static [&'static str] {
93 self.local_fields
94 }
95
96 pub fn validate_for_source(&self, source: &Entity) -> Result<(), ErrorTree> {
99 let schema = schema_read();
100
101 match schema.cast_node::<Entity>(self.target()) {
102 Ok(target) => self.validate_against_entities(source, target),
103 Err(_) => Err(ErrorTree::from(format!(
104 "relation edge '{}' target entity '{}' not found",
105 self.name(),
106 self.target()
107 ))),
108 }
109 }
110
111 pub fn validate_against_entities(
113 &self,
114 source: &Entity,
115 target: &Entity,
116 ) -> Result<(), ErrorTree> {
117 let mut errs = ErrorTree::new();
118 let target_fields = target.primary_key().fields();
119
120 if self.local_fields().is_empty() {
121 err!(
122 errs,
123 "relation edge '{}' must declare at least one local field",
124 self.name()
125 );
126 }
127
128 if self.local_fields().len() != target_fields.len() {
129 err!(
130 errs,
131 "relation edge '{}' arity mismatch: local fields {:?} target primary key fields {:?}",
132 self.name(),
133 self.local_fields(),
134 target_fields,
135 );
136 return errs.result();
137 }
138
139 let mut local_component_cardinality = None;
140 for (index, (local_name, target_name)) in self
141 .local_fields()
142 .iter()
143 .zip(target_fields.iter())
144 .enumerate()
145 {
146 let Some(local_field) = source.fields().get(local_name) else {
147 err!(
148 errs,
149 "relation edge '{}' local field '{}' not found",
150 self.name(),
151 local_name
152 );
153 continue;
154 };
155 let Some(target_field) = target.fields().get(target_name) else {
156 err!(
157 errs,
158 "relation edge '{}' target primary key field '{}' not found",
159 self.name(),
160 target_name
161 );
162 continue;
163 };
164
165 if !self.validate_local_component_shape(
166 &mut errs,
167 local_name,
168 local_field,
169 &mut local_component_cardinality,
170 ) {
171 continue;
172 }
173
174 self.validate_component_contract(
175 &mut errs,
176 index,
177 local_name,
178 local_field,
179 target_name,
180 target_field,
181 );
182 }
183
184 errs.result()
185 }
186
187 fn validate_local_component_shape(
188 &self,
189 errs: &mut ErrorTree,
190 local_name: &str,
191 local_field: &Field,
192 local_component_cardinality: &mut Option<Cardinality>,
193 ) -> bool {
194 let local_cardinality = local_field.value().cardinality();
195 if local_cardinality == Cardinality::Many {
196 err!(
197 errs,
198 "relation edge '{}' local field '{}' cannot have many cardinality",
199 self.name(),
200 local_name
201 );
202 return false;
203 }
204 match *local_component_cardinality {
205 Some(expected) if expected != local_cardinality => {
206 err!(
207 errs,
208 "relation edge '{}' local field '{}' cardinality mismatch: all local component fields must be required or all optional",
209 self.name(),
210 local_name
211 );
212 return false;
213 }
214 Some(_) => {}
215 None => *local_component_cardinality = Some(local_cardinality),
216 }
217
218 if local_field.generated().is_some() {
219 err!(
220 errs,
221 "relation edge '{}' local field '{}' is generated and cannot be a relation component",
222 self.name(),
223 local_name
224 );
225 return false;
226 }
227
228 true
229 }
230
231 fn validate_component_contract(
232 &self,
233 errs: &mut ErrorTree,
234 index: usize,
235 local_name: &str,
236 local_field: &Field,
237 target_name: &str,
238 target_field: &Field,
239 ) {
240 let expected = RelationComponentContract::from_field(target_field);
241 if !target_primary_key_component_is_admissible(expected) {
242 err!(
243 errs,
244 "relation edge '{}' target primary key field '{}' uses non-admissible component {:?}",
245 self.name(),
246 target_name,
247 expected.target(),
248 );
249 return;
250 }
251
252 let actual = RelationComponentContract::from_field(local_field);
253 if expected.mismatches(actual) {
254 err!(
255 errs,
256 "relation edge '{}' component {index} type mismatch: local field '{}' has ({:?}, scale={:?}, max_len={:?}); target field '{}' requires ({:?}, scale={:?}, max_len={:?})",
257 self.name(),
258 local_name,
259 actual.target(),
260 actual.scale(),
261 actual.max_len(),
262 target_name,
263 expected.target(),
264 expected.scale(),
265 expected.max_len(),
266 );
267 }
268 }
269}
270
271const fn target_primary_key_component_is_admissible(
272 contract: RelationComponentContract<'_>,
273) -> bool {
274 match contract.target() {
275 ItemTarget::Primitive(primitive) => primitive.is_primary_key_component_encodable(),
276 ItemTarget::Is(_) => false,
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::build::schema_write;
284
285 fn primitive_item(primitive: Primitive) -> Item {
286 Item::new(
287 ItemTarget::Primitive(primitive),
288 None,
289 None,
290 None,
291 &[],
292 &[],
293 false,
294 )
295 }
296
297 fn item_with_metadata(primitive: Primitive, scale: Option<u32>, max_len: Option<u32>) -> Item {
298 Item::new(
299 ItemTarget::Primitive(primitive),
300 None,
301 scale,
302 max_len,
303 &[],
304 &[],
305 false,
306 )
307 }
308
309 fn field(ident: &'static str, primitive: Primitive) -> Field {
310 field_with_item(ident, primitive_item(primitive))
311 }
312
313 fn generated_field(ident: &'static str, primitive: Primitive) -> Field {
314 Field::new(
315 ident,
316 Value::new(Cardinality::One, primitive_item(primitive)),
317 None,
318 Some(FieldGeneration::Insert(Arg::FuncPath(
319 "generate_relation_component",
320 ))),
321 None,
322 )
323 }
324
325 fn field_with_item(ident: &'static str, item: Item) -> Field {
326 Field::new(ident, Value::new(Cardinality::One, item), None, None, None)
327 }
328
329 fn optional_field(ident: &'static str, primitive: Primitive) -> Field {
330 Field::new(
331 ident,
332 Value::new(Cardinality::Opt, primitive_item(primitive)),
333 None,
334 None,
335 None,
336 )
337 }
338
339 fn entity(
340 module: &'static str,
341 ident: &'static str,
342 pk_fields: &'static [&'static str],
343 fields: &'static [Field],
344 ) -> Entity {
345 Entity::new(
346 Def::new(module, ident),
347 "RelationEdgeStore",
348 1,
349 PrimaryKey::new(pk_fields, PrimaryKeySource::External),
350 &[],
351 &[],
352 &[],
353 FieldList::new(fields),
354 Type::new(&[], &[], &[]),
355 )
356 }
357
358 fn insert_entity(
359 module: &'static str,
360 ident: &'static str,
361 pk_fields: &'static [&'static str],
362 fields: &'static [Field],
363 ) -> (&'static str, Entity) {
364 let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
365 let entity = entity(module, ident, pk_fields, fields);
366 schema_write().insert_node(SchemaNode::Entity(entity.clone()));
367 (path, entity)
368 }
369
370 #[test]
371 fn relation_edge_accepts_ordered_composite_target_tuple() {
372 let source_fields = Box::leak(
373 vec![
374 field("author_tenant_id", Primitive::Nat64),
375 field("author_user_id", Primitive::Ulid),
376 ]
377 .into_boxed_slice(),
378 );
379 let target_fields = Box::leak(
380 vec![
381 field("tenant_id", Primitive::Nat64),
382 field("user_id", Primitive::Ulid),
383 ]
384 .into_boxed_slice(),
385 );
386 let source = entity(
387 "schema_relation_edge_accepts_tuple",
388 "Post",
389 &["author_user_id"],
390 source_fields,
391 );
392 let target = entity(
393 "schema_relation_edge_accepts_tuple",
394 "User",
395 &["tenant_id", "user_id"],
396 target_fields,
397 );
398
399 RelationEdge::new(
400 "author",
401 "schema_relation_edge_accepts_tuple::User",
402 &["author_tenant_id", "author_user_id"],
403 )
404 .validate_against_entities(&source, &target)
405 .expect("matching ordered composite relation tuple should validate");
406 }
407
408 #[test]
409 fn relation_edge_rejects_scalar_local_field_for_composite_target() {
410 let source_fields =
411 Box::leak(vec![field("author_user_id", Primitive::Ulid)].into_boxed_slice());
412 let target_fields = Box::leak(
413 vec![
414 field("tenant_id", Primitive::Nat64),
415 field("user_id", Primitive::Ulid),
416 ]
417 .into_boxed_slice(),
418 );
419 let source = entity(
420 "schema_relation_edge_rejects_scalar_for_composite",
421 "Post",
422 &["author_user_id"],
423 source_fields,
424 );
425 let target = entity(
426 "schema_relation_edge_rejects_scalar_for_composite",
427 "User",
428 &["tenant_id", "user_id"],
429 target_fields,
430 );
431
432 let err = RelationEdge::new(
433 "author",
434 "schema_relation_edge_rejects_scalar_for_composite::User",
435 &["author_user_id"],
436 )
437 .validate_against_entities(&source, &target)
438 .expect_err("scalar local component must not validate as composite target tuple");
439
440 assert!(
441 err.messages()
442 .iter()
443 .any(|message| message.contains("arity mismatch")),
444 "unexpected relation edge validation errors: {err}",
445 );
446 }
447
448 #[test]
449 fn relation_edge_rejects_wrong_component_order() {
450 let source_fields = Box::leak(
451 vec![
452 field("author_tenant_id", Primitive::Nat64),
453 field("author_user_id", Primitive::Ulid),
454 ]
455 .into_boxed_slice(),
456 );
457 let target_fields = Box::leak(
458 vec![
459 field("tenant_id", Primitive::Nat64),
460 field("user_id", Primitive::Ulid),
461 ]
462 .into_boxed_slice(),
463 );
464 let source = entity(
465 "schema_relation_edge_rejects_order",
466 "Post",
467 &["author_user_id"],
468 source_fields,
469 );
470 let target = entity(
471 "schema_relation_edge_rejects_order",
472 "User",
473 &["tenant_id", "user_id"],
474 target_fields,
475 );
476
477 let err = RelationEdge::new(
478 "author",
479 "schema_relation_edge_rejects_order::User",
480 &["author_user_id", "author_tenant_id"],
481 )
482 .validate_against_entities(&source, &target)
483 .expect_err("local tuple order must match target primary-key order");
484
485 assert!(
486 err.messages()
487 .iter()
488 .any(|message| message.contains("component 0 type mismatch")),
489 "unexpected relation edge validation errors: {err}",
490 );
491 }
492
493 #[test]
494 fn relation_edge_rejects_missing_local_component_field() {
495 let source_fields =
496 Box::leak(vec![field("author_tenant_id", Primitive::Nat64)].into_boxed_slice());
497 let target_fields = Box::leak(
498 vec![
499 field("tenant_id", Primitive::Nat64),
500 field("user_id", Primitive::Ulid),
501 ]
502 .into_boxed_slice(),
503 );
504 let source = entity(
505 "schema_relation_edge_rejects_missing_local",
506 "Post",
507 &["author_tenant_id"],
508 source_fields,
509 );
510 let target = entity(
511 "schema_relation_edge_rejects_missing_local",
512 "User",
513 &["tenant_id", "user_id"],
514 target_fields,
515 );
516
517 let err = RelationEdge::new(
518 "author",
519 "schema_relation_edge_rejects_missing_local::User",
520 &["author_tenant_id", "author_user_id"],
521 )
522 .validate_against_entities(&source, &target)
523 .expect_err("missing local tuple component should reject");
524
525 assert!(
526 err.messages()
527 .iter()
528 .any(|message| message.contains("local field 'author_user_id' not found")),
529 "unexpected relation edge validation errors: {err}",
530 );
531 }
532
533 #[test]
534 fn relation_edge_rejects_non_admissible_target_primary_key_component() {
535 let source_fields =
536 Box::leak(vec![field("author_score", Primitive::Float64)].into_boxed_slice());
537 let target_fields = Box::leak(vec![field("score", Primitive::Float64)].into_boxed_slice());
538 let source = entity(
539 "schema_relation_edge_rejects_float64_target",
540 "Post",
541 &["author_score"],
542 source_fields,
543 );
544 let target = entity(
545 "schema_relation_edge_rejects_float64_target",
546 "User",
547 &["score"],
548 target_fields,
549 );
550
551 let err = RelationEdge::new(
552 "author",
553 "schema_relation_edge_rejects_float64_target::User",
554 &["author_score"],
555 )
556 .validate_against_entities(&source, &target)
557 .expect_err("float64 target primary key component should reject");
558
559 assert!(
560 err.messages()
561 .iter()
562 .any(|message| message.contains("non-admissible component")),
563 "unexpected relation edge validation errors: {err}",
564 );
565 }
566
567 #[test]
568 fn relation_edge_rejects_generated_local_component_field() {
569 let source_fields =
570 Box::leak(vec![generated_field("author_id", Primitive::Ulid)].into_boxed_slice());
571 let target_fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
572 let source = entity(
573 "schema_relation_edge_rejects_generated_local",
574 "Post",
575 &["author_id"],
576 source_fields,
577 );
578 let target = entity(
579 "schema_relation_edge_rejects_generated_local",
580 "User",
581 &["id"],
582 target_fields,
583 );
584
585 let err = RelationEdge::new(
586 "author",
587 "schema_relation_edge_rejects_generated_local::User",
588 &["author_id"],
589 )
590 .validate_against_entities(&source, &target)
591 .expect_err("generated local component field should reject");
592
593 assert!(
594 err.messages()
595 .iter()
596 .any(|message| message.contains("is generated")),
597 "unexpected relation edge validation errors: {err}",
598 );
599 }
600
601 #[test]
602 fn relation_edge_rejects_mixed_local_component_cardinality() {
603 let source_fields = Box::leak(
604 vec![
605 field("author_tenant_id", Primitive::Nat64),
606 optional_field("author_user_id", Primitive::Ulid),
607 ]
608 .into_boxed_slice(),
609 );
610 let target_fields = Box::leak(
611 vec![
612 field("tenant_id", Primitive::Nat64),
613 field("user_id", Primitive::Ulid),
614 ]
615 .into_boxed_slice(),
616 );
617 let source = entity(
618 "schema_relation_edge_rejects_mixed_cardinality",
619 "Post",
620 &["author_tenant_id"],
621 source_fields,
622 );
623 let target = entity(
624 "schema_relation_edge_rejects_mixed_cardinality",
625 "User",
626 &["tenant_id", "user_id"],
627 target_fields,
628 );
629
630 let err = RelationEdge::new(
631 "author",
632 "schema_relation_edge_rejects_mixed_cardinality::User",
633 &["author_tenant_id", "author_user_id"],
634 )
635 .validate_against_entities(&source, &target)
636 .expect_err("mixed local tuple cardinality should reject");
637
638 assert!(
639 err.messages()
640 .iter()
641 .any(|message| message.contains("cardinality mismatch")),
642 "unexpected relation edge validation errors: {err}",
643 );
644 }
645
646 #[test]
647 fn relation_edge_validate_for_source_uses_schema_target_lookup() {
648 let source_fields = Box::leak(vec![field("author_id", Primitive::Ulid)].into_boxed_slice());
649 let target_fields = Box::leak(vec![field("id", Primitive::Ulid)].into_boxed_slice());
650 let source = entity(
651 "schema_relation_edge_lookup",
652 "Post",
653 &["author_id"],
654 source_fields,
655 );
656 let (target_path, _) = insert_entity(
657 "schema_relation_edge_lookup",
658 "User",
659 &["id"],
660 target_fields,
661 );
662
663 RelationEdge::new("author", target_path, &["author_id"])
664 .validate_for_source(&source)
665 .expect("schema target lookup should validate matching scalar edge");
666 }
667
668 #[test]
669 fn relation_edge_component_contract_preserves_bounds() {
670 let expected = field_with_item("body", item_with_metadata(Primitive::Text, None, Some(64)));
671 let same = field_with_item(
672 "body_copy",
673 item_with_metadata(Primitive::Text, None, Some(64)),
674 );
675 let wrong = field_with_item(
676 "body_short",
677 item_with_metadata(Primitive::Text, None, Some(32)),
678 );
679
680 let expected = RelationComponentContract::from_field(&expected);
681 assert!(!expected.mismatches(RelationComponentContract::from_field(&same)));
682 assert!(expected.mismatches(RelationComponentContract::from_field(&wrong)));
683 }
684}