1use std::collections::{BTreeMap, BTreeSet};
4
5use candid::CandidType;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use crate::{
10 ConstraintFragmentKind, ConstraintSourceKey, EntityFragment, EntitySourceKey, FieldFragment,
11 FieldSourceKey, FieldType, IndexSourceKey, MAX_SCHEMA_ASSIGNMENTS, MAX_SCHEMA_CAPABILITIES,
12 MAX_SCHEMA_PROPOSAL_FRAGMENTS, MAX_SCHEMA_REMOVALS, NamedTypeFragment, RelationSourceKey,
13 ScalarLiteral, ScalarType, SchemaContractError, SchemaFragment, SchemaProposalDigest,
14 SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction, SourceRuleOperation,
15 TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment, TypeSourceKey, check_len,
16 encode_schema_fragment, encode_schema_proposal,
17};
18
19#[derive(
21 CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
22)]
23#[repr(transparent)]
24#[serde(transparent)]
25pub struct ProposalContractVersion(u16);
26
27impl ProposalContractVersion {
28 pub const CURRENT: Self = Self(1);
30
31 #[must_use]
33 pub const fn from_raw(value: u16) -> Self {
34 Self(value)
35 }
36
37 #[must_use]
39 pub const fn get(self) -> u16 {
40 self.0
41 }
42}
43
44#[derive(
46 CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
47)]
48#[repr(transparent)]
49#[serde(transparent)]
50pub struct SchemaCapability(u16);
51
52impl SchemaCapability {
53 pub const EXACT_COMPOSITE_TYPES: Self = Self(1);
55 pub const ACCEPTED_CHECKS: Self = Self(2);
57 pub const SECONDARY_INDEXES: Self = Self(3);
59 pub const RESTRICTIVE_RELATIONS: Self = Self(4);
61 pub const INSERT_DEFAULTS: Self = Self(5);
63 pub const GENERATED_VALUES: Self = Self(6);
65 pub const MANAGED_TIMESTAMPS: Self = Self(7);
67
68 #[must_use]
70 pub const fn from_raw(value: u16) -> Self {
71 Self(value)
72 }
73
74 #[must_use]
76 pub const fn get(self) -> u16 {
77 self.0
78 }
79
80 const fn is_supported(self) -> bool {
81 matches!(self.0, 1..=7)
82 }
83}
84
85#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
87pub enum ExpectedAcceptedHead {
88 Empty,
90 Exact {
92 revision: u64,
94 fingerprint: crate::ExpectedSchemaFingerprint,
96 },
97}
98
99impl ExpectedAcceptedHead {
100 const fn validate(&self) -> Result<(), SchemaContractError> {
101 match self {
102 Self::Exact { revision: 0, .. } => Err(SchemaContractError::InvalidReferenceList),
103 Self::Empty | Self::Exact { .. } => Ok(()),
104 }
105 }
106}
107
108#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
110pub struct EntityStoreAssignment {
111 entity: EntitySourceKey,
112 store: TargetStoreIdentity,
113}
114
115impl EntityStoreAssignment {
116 #[must_use]
118 pub const fn new(entity: EntitySourceKey, store: TargetStoreIdentity) -> Self {
119 Self { entity, store }
120 }
121
122 #[must_use]
124 pub const fn entity(&self) -> &EntitySourceKey {
125 &self.entity
126 }
127
128 #[must_use]
130 pub const fn store(&self) -> TargetStoreIdentity {
131 self.store
132 }
133}
134
135#[derive(
137 CandidType, Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
138)]
139pub enum SchemaRemoval {
140 Entity(EntitySourceKey),
142 Field {
144 entity: EntitySourceKey,
146 field: FieldSourceKey,
148 },
149 Type(TypeSourceKey),
151 Constraint {
153 entity: EntitySourceKey,
155 constraint: ConstraintSourceKey,
157 },
158 Index {
160 entity: EntitySourceKey,
162 index: IndexSourceKey,
164 },
165 Relation {
167 entity: EntitySourceKey,
169 relation: RelationSourceKey,
171 },
172}
173
174#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
176pub struct SchemaProposal {
177 version: ProposalContractVersion,
178 capabilities: Vec<SchemaCapability>,
179 target_database: TargetDatabaseIdentity,
180 submission_key: SchemaSubmissionKey,
181 expected_head: ExpectedAcceptedHead,
182 fragments: Vec<SchemaFragment>,
183 assignments: Vec<EntityStoreAssignment>,
184 removals: Vec<SchemaRemoval>,
185}
186
187impl SchemaProposal {
188 #[expect(
199 clippy::too_many_lines,
200 reason = "composition validates and canonicalizes one atomic public envelope"
201 )]
202 pub fn try_compose(
203 mut capabilities: Vec<SchemaCapability>,
204 target_database: TargetDatabaseIdentity,
205 submission_key: SchemaSubmissionKey,
206 expected_head: ExpectedAcceptedHead,
207 mut fragments: Vec<SchemaFragment>,
208 mut assignments: Vec<EntityStoreAssignment>,
209 mut removals: Vec<SchemaRemoval>,
210 ) -> Result<Self, SchemaContractError> {
211 check_len(
212 "proposal capabilities",
213 capabilities.len(),
214 MAX_SCHEMA_CAPABILITIES,
215 )?;
216 check_len(
217 "proposal fragments",
218 fragments.len(),
219 MAX_SCHEMA_PROPOSAL_FRAGMENTS,
220 )?;
221 check_len(
222 "proposal assignments",
223 assignments.len(),
224 MAX_SCHEMA_ASSIGNMENTS,
225 )?;
226 check_len("proposal removals", removals.len(), MAX_SCHEMA_REMOVALS)?;
227 expected_head.validate()?;
228 capabilities.sort_unstable();
229 ensure_no_adjacent_duplicates(&capabilities)?;
230 if capabilities
231 .iter()
232 .any(|capability| !capability.is_supported())
233 {
234 return Err(SchemaContractError::UnsupportedCapability);
235 }
236 for fragment in &fragments {
237 fragment.validate()?;
238 }
239 let mut keyed_fragments = fragments
240 .into_iter()
241 .map(|fragment| encode_schema_fragment(&fragment).map(|bytes| (bytes, fragment)))
242 .collect::<Result<Vec<_>, _>>()?;
243 keyed_fragments.sort_by(|left, right| left.0.cmp(&right.0));
244 fragments = keyed_fragments
245 .into_iter()
246 .map(|(_, fragment)| fragment)
247 .collect();
248 assignments.sort_by(|left, right| left.entity.cmp(&right.entity));
249 ensure_no_adjacent_duplicates_by(&assignments, |assignment| &assignment.entity)?;
250 removals.sort();
251 ensure_no_adjacent_duplicates(&removals)?;
252
253 let mut entity_definitions = BTreeMap::new();
254 let mut type_definitions = BTreeMap::new();
255 let mut field_definitions = BTreeSet::new();
256 let mut constraint_definitions = BTreeSet::new();
257 let mut index_definitions = BTreeSet::new();
258 let mut relation_definitions = BTreeSet::new();
259 let mut entity_names = BTreeSet::new();
260 let mut type_names = BTreeSet::new();
261 for fragment in &fragments {
262 for entity in fragment.entities() {
263 if entity_definitions
264 .insert(entity.source_key().clone(), entity)
265 .is_some()
266 {
267 return Err(SchemaContractError::DuplicateSourceKey);
268 }
269 if !entity_names.insert(entity.name()) {
270 return Err(SchemaContractError::DuplicateName);
271 }
272 for field in entity.fields() {
273 field_definitions
274 .insert((entity.source_key().clone(), field.source_key().clone()));
275 }
276 for constraint in entity.constraints() {
277 constraint_definitions
278 .insert((entity.source_key().clone(), constraint.source_key().clone()));
279 }
280 for index in entity.indexes() {
281 index_definitions
282 .insert((entity.source_key().clone(), index.source_key().clone()));
283 }
284 for relation in entity.relations() {
285 relation_definitions
286 .insert((entity.source_key().clone(), relation.source_key().clone()));
287 }
288 }
289 for r#type in fragment.types() {
290 if type_definitions
291 .insert(r#type.source_key().clone(), r#type)
292 .is_some()
293 {
294 return Err(SchemaContractError::DuplicateSourceKey);
295 }
296 if !type_names.insert(r#type.name()) {
297 return Err(SchemaContractError::DuplicateName);
298 }
299 }
300 }
301 for assignment in &assignments {
302 if !entity_definitions.contains_key(assignment.entity()) {
303 return Err(SchemaContractError::InvalidReferenceList);
304 }
305 }
306 if assignments.len() != entity_definitions.len() {
307 return Err(SchemaContractError::MissingEntityStoreAssignment);
308 }
309 for removal in &removals {
310 let collides = match removal {
311 SchemaRemoval::Entity(entity) => entity_definitions.contains_key(entity),
312 SchemaRemoval::Field { entity, field } => {
313 field_definitions.contains(&(entity.clone(), field.clone()))
314 }
315 SchemaRemoval::Type(r#type) => type_definitions.contains_key(r#type),
316 SchemaRemoval::Constraint { entity, constraint } => {
317 constraint_definitions.contains(&(entity.clone(), constraint.clone()))
318 }
319 SchemaRemoval::Index { entity, index } => {
320 index_definitions.contains(&(entity.clone(), index.clone()))
321 }
322 SchemaRemoval::Relation { entity, relation } => {
323 relation_definitions.contains(&(entity.clone(), relation.clone()))
324 }
325 };
326 if collides {
327 return Err(SchemaContractError::DefinitionRemovalConflict);
328 }
329 }
330 validate_proposal_closure(
331 &expected_head,
332 &entity_definitions,
333 &type_definitions,
334 &removals,
335 )?;
336
337 Ok(Self {
338 version: ProposalContractVersion::CURRENT,
339 capabilities,
340 target_database,
341 submission_key,
342 expected_head,
343 fragments,
344 assignments,
345 removals,
346 })
347 }
348
349 #[must_use]
351 pub const fn version(&self) -> ProposalContractVersion {
352 self.version
353 }
354
355 #[must_use]
357 pub fn capabilities(&self) -> &[SchemaCapability] {
358 &self.capabilities
359 }
360
361 #[must_use]
363 pub const fn target_database(&self) -> TargetDatabaseIdentity {
364 self.target_database
365 }
366
367 #[must_use]
369 pub const fn submission_key(&self) -> &SchemaSubmissionKey {
370 &self.submission_key
371 }
372
373 #[must_use]
375 pub const fn expected_head(&self) -> &ExpectedAcceptedHead {
376 &self.expected_head
377 }
378
379 #[must_use]
381 pub fn fragments(&self) -> &[SchemaFragment] {
382 &self.fragments
383 }
384
385 #[must_use]
387 pub fn assignments(&self) -> &[EntityStoreAssignment] {
388 &self.assignments
389 }
390
391 #[must_use]
393 pub fn removals(&self) -> &[SchemaRemoval] {
394 &self.removals
395 }
396
397 pub fn digest(&self) -> Result<SchemaProposalDigest, SchemaContractError> {
404 let bytes = encode_schema_proposal(self)?;
405 let digest: [u8; 32] = Sha256::digest(bytes).into();
406 Ok(SchemaProposalDigest::from_bytes(digest))
407 }
408
409 pub(crate) fn validate_current(&self) -> Result<(), SchemaContractError> {
410 if self.version != ProposalContractVersion::CURRENT {
411 return Err(SchemaContractError::UnsupportedVersion {
412 found: self.version.get(),
413 supported: ProposalContractVersion::CURRENT.get(),
414 });
415 }
416 let rebuilt = Self::try_compose(
417 self.capabilities.clone(),
418 self.target_database,
419 self.submission_key.clone(),
420 self.expected_head.clone(),
421 self.fragments.clone(),
422 self.assignments.clone(),
423 self.removals.clone(),
424 )?;
425 if rebuilt != *self {
426 return Err(SchemaContractError::NonCanonical);
427 }
428 Ok(())
429 }
430}
431
432#[derive(Default)]
433struct ProposalReferences {
434 types: BTreeSet<TypeSourceKey>,
435 relation_entities: BTreeSet<EntitySourceKey>,
436 relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
437}
438
439fn validate_proposal_closure(
440 expected_head: &ExpectedAcceptedHead,
441 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
442 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
443 removals: &[SchemaRemoval],
444) -> Result<(), SchemaContractError> {
445 let mut references = ProposalReferences::default();
446 for entity in entities.values() {
447 collect_entity_references(entity, types, &mut references)?;
448 validate_local_relation_targets(entity, entities)?;
449 }
450 for r#type in types.values() {
451 collect_named_type_references(r#type, &mut references);
452 }
453 for removal in removals {
454 let removes_reference = match removal {
455 SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
456 SchemaRemoval::Field { entity, field } => references
457 .relation_fields
458 .contains(&(entity.clone(), field.clone())),
459 SchemaRemoval::Type(r#type) => references.types.contains(r#type),
460 SchemaRemoval::Constraint { .. }
461 | SchemaRemoval::Index { .. }
462 | SchemaRemoval::Relation { .. } => false,
463 };
464 if removes_reference {
465 return Err(SchemaContractError::RemovedReference);
466 }
467 }
468 if matches!(expected_head, ExpectedAcceptedHead::Empty)
469 && (references
470 .types
471 .iter()
472 .any(|reference| !types.contains_key(reference))
473 || references
474 .relation_entities
475 .iter()
476 .any(|reference| !entities.contains_key(reference)))
477 {
478 return Err(SchemaContractError::InvalidLocalReference);
479 }
480 Ok(())
481}
482
483fn collect_entity_references(
484 entity: &EntityFragment,
485 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
486 references: &mut ProposalReferences,
487) -> Result<(), SchemaContractError> {
488 for field in entity.fields() {
489 collect_field_references(field, types, references)?;
490 }
491 for relation in entity.relations() {
492 references
493 .relation_entities
494 .insert(relation.target_entity().clone());
495 references.relation_fields.extend(
496 relation
497 .target_fields()
498 .iter()
499 .cloned()
500 .map(|field| (relation.target_entity().clone(), field)),
501 );
502 }
503 for index in entity.indexes() {
504 if let Some(predicate) = index.predicate() {
505 collect_expression_enum_references(predicate, types, references)?;
506 }
507 }
508 for constraint in entity.constraints() {
509 match constraint.kind() {
510 ConstraintFragmentKind::Check(expression) => {
511 collect_expression_enum_references(expression, types, references)?;
512 }
513 ConstraintFragmentKind::TargetedRule(rule) => {
514 references.types.insert(rule.target_type().clone());
515 validate_targeted_rule(entity, rule, types)?;
516 }
517 }
518 }
519 Ok(())
520}
521
522fn validate_targeted_rule(
523 entity: &EntityFragment,
524 rule: &TargetedRuleFragment,
525 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
526) -> Result<(), SchemaContractError> {
527 let root = entity
528 .fields()
529 .iter()
530 .find(|field| field.source_key() == rule.root())
531 .ok_or(SchemaContractError::InvalidLocalReference)?;
532 if types.contains_key(rule.target_type())
533 && !field_type_reaches_target(root.field_type(), rule.target_type(), types)
534 {
535 return Err(SchemaContractError::InvalidRuleTarget);
536 }
537 let Some(target) = types.get(rule.target_type()) else {
538 return Ok(());
539 };
540 let shape = resolve_rule_target_shape(target, types)?;
541 if operation_matches_target(rule.operation(), shape) {
542 Ok(())
543 } else {
544 Err(SchemaContractError::InvalidRuleTarget)
545 }
546}
547
548fn field_type_reaches_target(
549 root: &FieldType,
550 target: &TypeSourceKey,
551 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
552) -> bool {
553 let mut pending = vec![root];
554 let mut visited = BTreeSet::new();
555 while let Some(field_type) = pending.pop() {
556 match field_type {
557 FieldType::Scalar(_) => {}
558 FieldType::List(item) => pending.push(item),
559 FieldType::Named(source) => {
560 if source == target {
561 return true;
562 }
563 if !visited.insert(source) {
564 continue;
565 }
566 if let Some(definition) = types.get(source) {
567 push_named_type_field_types(definition, &mut pending);
568 }
569 }
570 }
571 }
572 false
573}
574
575fn push_named_type_field_types<'types>(
576 r#type: &'types NamedTypeFragment,
577 pending: &mut Vec<&'types FieldType>,
578) {
579 match r#type {
580 NamedTypeFragment::Record(record) => {
581 pending.extend(
582 record
583 .fields()
584 .iter()
585 .map(crate::RecordFieldFragment::field_type),
586 );
587 }
588 NamedTypeFragment::Enum(r#enum) => {
589 pending.extend(
590 r#enum
591 .variants()
592 .iter()
593 .filter_map(|variant| variant.payload()),
594 );
595 }
596 NamedTypeFragment::Newtype { inner, .. }
597 | NamedTypeFragment::List { item: inner, .. }
598 | NamedTypeFragment::Set { item: inner, .. } => pending.push(inner),
599 NamedTypeFragment::Map { key, value, .. } => {
600 pending.push(key);
601 pending.push(value);
602 }
603 NamedTypeFragment::Tuple { members, .. } => {
604 pending.extend(members.iter().map(crate::TupleElementFragment::field_type));
605 }
606 }
607}
608
609#[derive(Clone, Copy)]
610enum RuleTargetShape {
611 Collection,
612 Scalar(ScalarType),
613}
614
615fn resolve_rule_target_shape(
616 target: &NamedTypeFragment,
617 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
618) -> Result<RuleTargetShape, SchemaContractError> {
619 let mut current = target;
620 let mut visited = BTreeSet::new();
621 loop {
622 if !visited.insert(current.source_key()) {
623 return Err(SchemaContractError::InvalidRuleTarget);
624 }
625 match current {
626 NamedTypeFragment::List { .. }
627 | NamedTypeFragment::Set { .. }
628 | NamedTypeFragment::Map { .. } => return Ok(RuleTargetShape::Collection),
629 NamedTypeFragment::Newtype { inner, .. } => match inner {
630 FieldType::Scalar(scalar) => return Ok(RuleTargetShape::Scalar(*scalar)),
631 FieldType::List(_) => return Ok(RuleTargetShape::Collection),
632 FieldType::Named(source) => {
633 current = types
634 .get(source)
635 .copied()
636 .ok_or(SchemaContractError::InvalidRuleTarget)?;
637 }
638 },
639 NamedTypeFragment::Record(_)
640 | NamedTypeFragment::Enum(_)
641 | NamedTypeFragment::Tuple { .. } => {
642 return Err(SchemaContractError::InvalidRuleTarget);
643 }
644 }
645 }
646}
647
648fn operation_matches_target(operation: &SourceRuleOperation, shape: RuleTargetShape) -> bool {
649 match (operation, shape) {
650 (
651 SourceRuleOperation::LengthRangeInclusive { .. },
652 RuleTargetShape::Collection
653 | RuleTargetShape::Scalar(ScalarType::Blob { .. } | ScalarType::Text { .. }),
654 ) => true,
655 (
656 SourceRuleOperation::NumericMinimumInclusive { value },
657 RuleTargetShape::Scalar(scalar),
658 ) => numeric_scalar(scalar) && scalar.accepts_literal(value),
659 (
660 SourceRuleOperation::NumericRangeInclusive { min, max },
661 RuleTargetShape::Scalar(scalar),
662 ) => numeric_scalar(scalar) && scalar.accepts_literal(min) && scalar.accepts_literal(max),
663 _ => false,
664 }
665}
666
667const fn numeric_scalar(scalar: ScalarType) -> bool {
668 matches!(
669 scalar,
670 ScalarType::Decimal { .. }
671 | ScalarType::Float32
672 | ScalarType::Float64
673 | ScalarType::Int8
674 | ScalarType::Int16
675 | ScalarType::Int32
676 | ScalarType::Int64
677 | ScalarType::Int128
678 | ScalarType::IntBig { .. }
679 | ScalarType::Nat8
680 | ScalarType::Nat16
681 | ScalarType::Nat32
682 | ScalarType::Nat64
683 | ScalarType::Nat128
684 | ScalarType::NatBig { .. }
685 )
686}
687
688fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
689 match r#type {
690 NamedTypeFragment::Record(record) => {
691 for field in record.fields() {
692 collect_field_type_reference(field.field_type(), references);
693 }
694 }
695 NamedTypeFragment::Enum(r#enum) => {
696 for variant in r#enum.variants() {
697 if let Some(payload) = variant.payload() {
698 collect_field_type_reference(payload, references);
699 }
700 }
701 }
702 NamedTypeFragment::Newtype { inner, .. }
703 | NamedTypeFragment::List { item: inner, .. }
704 | NamedTypeFragment::Set { item: inner, .. } => {
705 collect_field_type_reference(inner, references);
706 }
707 NamedTypeFragment::Map { key, value, .. } => {
708 collect_field_type_reference(key, references);
709 collect_field_type_reference(value, references);
710 }
711 NamedTypeFragment::Tuple { members, .. } => {
712 for member in members {
713 collect_field_type_reference(member.field_type(), references);
714 }
715 }
716 }
717}
718
719fn collect_field_references(
720 field: &FieldFragment,
721 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
722 references: &mut ProposalReferences,
723) -> Result<(), SchemaContractError> {
724 collect_field_type_reference(field.field_type(), references);
725 if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
726 field.insert_policy()
727 {
728 let FieldType::Named(field_type) = field.field_type() else {
729 return Err(SchemaContractError::LiteralTypeMismatch);
730 };
731 if field_type != enum_type {
732 return Err(SchemaContractError::LiteralTypeMismatch);
733 }
734 collect_enum_literal_reference(enum_type, variant, types, references)?;
735 }
736 Ok(())
737}
738
739fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
740 match field_type {
741 FieldType::List(item) => collect_field_type_reference(item, references),
742 FieldType::Named(reference) => {
743 references.types.insert(reference.clone());
744 }
745 FieldType::Scalar(_) => {}
746 }
747}
748
749fn collect_expression_enum_references(
750 expression: &SourceCheckExpr,
751 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
752 references: &mut ProposalReferences,
753) -> Result<(), SchemaContractError> {
754 for instruction in expression.instructions() {
755 if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
756 instruction
757 {
758 collect_enum_literal_reference(enum_type, variant, types, references)?;
759 }
760 }
761 Ok(())
762}
763
764fn collect_enum_literal_reference(
765 enum_type: &TypeSourceKey,
766 variant: &TypeSourceKey,
767 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
768 references: &mut ProposalReferences,
769) -> Result<(), SchemaContractError> {
770 references.types.insert(enum_type.clone());
771 let Some(local) = types.get(enum_type) else {
772 return Ok(());
773 };
774 let NamedTypeFragment::Enum(local) = local else {
775 return Err(SchemaContractError::InvalidEnumLiteral);
776 };
777 if local
778 .variants()
779 .iter()
780 .all(|candidate| candidate.source_key() != variant)
781 {
782 return Err(SchemaContractError::InvalidEnumLiteral);
783 }
784 Ok(())
785}
786
787fn validate_local_relation_targets(
788 source: &EntityFragment,
789 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
790) -> Result<(), SchemaContractError> {
791 for relation in source.relations() {
792 let Some(target) = entities.get(relation.target_entity()) else {
793 continue;
794 };
795 for (source_key, target_key) in relation.local_fields().iter().zip(relation.target_fields())
796 {
797 let source_field = source
798 .fields()
799 .iter()
800 .find(|field| field.source_key() == source_key)
801 .ok_or(SchemaContractError::InvalidLocalReference)?;
802 let target_field = target
803 .fields()
804 .iter()
805 .find(|field| field.source_key() == target_key)
806 .ok_or(SchemaContractError::InvalidLocalReference)?;
807 let source_type = match source_field.field_type() {
808 FieldType::List(item) => item.as_ref(),
809 field_type => field_type,
810 };
811 if source_type != target_field.field_type() {
812 return Err(SchemaContractError::RelationTypeMismatch);
813 }
814 }
815 }
816 Ok(())
817}
818
819fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
820where
821 T: Eq,
822{
823 if values.windows(2).any(|pair| pair[0] == pair[1]) {
824 return Err(SchemaContractError::DuplicateSourceKey);
825 }
826 Ok(())
827}
828
829fn ensure_no_adjacent_duplicates_by<T, K>(
830 values: &[T],
831 key: impl Fn(&T) -> &K,
832) -> Result<(), SchemaContractError>
833where
834 K: Eq,
835{
836 if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
837 return Err(SchemaContractError::DuplicateSourceKey);
838 }
839 Ok(())
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845 use crate::decode_schema_proposal;
846
847 fn empty_proposal() -> SchemaProposal {
848 SchemaProposal::try_compose(
849 Vec::new(),
850 TargetDatabaseIdentity::from_bytes([1; 32]),
851 SchemaSubmissionKey::try_new("proposal-version-test")
852 .expect("submission key should admit"),
853 ExpectedAcceptedHead::Empty,
854 Vec::new(),
855 Vec::new(),
856 Vec::new(),
857 )
858 .expect("empty proposal should compose")
859 }
860
861 #[test]
862 fn decoded_future_contract_version_fails_typed() {
863 let mut proposal = empty_proposal();
864 proposal.version = ProposalContractVersion::from_raw(2);
865 let bytes = candid::encode_one(proposal).expect("raw future proposal should encode");
866
867 assert_eq!(
868 decode_schema_proposal(&bytes),
869 Err(SchemaContractError::UnsupportedVersion {
870 found: 2,
871 supported: 1,
872 }),
873 );
874 }
875
876 #[test]
877 fn decoded_unknown_capability_fails_typed() {
878 let mut proposal = empty_proposal();
879 proposal.capabilities = vec![SchemaCapability::from_raw(u16::MAX)];
880 let bytes = candid::encode_one(proposal).expect("raw proposal should encode");
881
882 assert_eq!(
883 decode_schema_proposal(&bytes),
884 Err(SchemaContractError::UnsupportedCapability),
885 );
886 }
887}