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