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