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::NumericMaximumInclusive { value }
657 | SourceRuleOperation::NumericMinimumInclusive { value },
658 RuleTargetShape::Scalar(scalar),
659 ) => numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, value),
660 (
661 SourceRuleOperation::NumericRangeInclusive { min, max },
662 RuleTargetShape::Scalar(scalar),
663 ) => {
664 numeric_scalar(scalar)
665 && numeric_rule_literal_matches(scalar, min)
666 && numeric_rule_literal_matches(scalar, max)
667 }
668 (SourceRuleOperation::MultipleOf { divisor }, RuleTargetShape::Scalar(scalar)) => {
669 exact_numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, divisor)
670 }
671 _ => false,
672 }
673}
674
675fn numeric_rule_literal_matches(scalar: ScalarType, literal: &crate::ScalarLiteral) -> bool {
676 if let (ScalarType::Decimal { scale }, crate::ScalarLiteral::Decimal(value)) = (scalar, literal)
677 {
678 let value = value.normalize();
679 return value.scale() <= scale && value.scale_to_integer(scale).is_some();
680 }
681 scalar.accepts_literal(literal)
682}
683
684const fn exact_numeric_scalar(scalar: ScalarType) -> bool {
685 matches!(
686 scalar,
687 ScalarType::Decimal { .. }
688 | ScalarType::Int8
689 | ScalarType::Int16
690 | ScalarType::Int32
691 | ScalarType::Int64
692 | ScalarType::Int128
693 | ScalarType::IntBig { .. }
694 | ScalarType::Nat8
695 | ScalarType::Nat16
696 | ScalarType::Nat32
697 | ScalarType::Nat64
698 | ScalarType::Nat128
699 | ScalarType::NatBig { .. }
700 )
701}
702
703const fn numeric_scalar(scalar: ScalarType) -> bool {
704 matches!(
705 scalar,
706 ScalarType::Decimal { .. }
707 | ScalarType::Float32
708 | ScalarType::Float64
709 | ScalarType::Int8
710 | ScalarType::Int16
711 | ScalarType::Int32
712 | ScalarType::Int64
713 | ScalarType::Int128
714 | ScalarType::IntBig { .. }
715 | ScalarType::Nat8
716 | ScalarType::Nat16
717 | ScalarType::Nat32
718 | ScalarType::Nat64
719 | ScalarType::Nat128
720 | ScalarType::NatBig { .. }
721 )
722}
723
724fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
725 match r#type {
726 NamedTypeFragment::Record(record) => {
727 for field in record.fields() {
728 collect_field_type_reference(field.field_type(), references);
729 }
730 }
731 NamedTypeFragment::Enum(r#enum) => {
732 for variant in r#enum.variants() {
733 if let Some(payload) = variant.payload() {
734 collect_field_type_reference(payload, references);
735 }
736 }
737 }
738 NamedTypeFragment::Newtype { inner, .. }
739 | NamedTypeFragment::List { item: inner, .. }
740 | NamedTypeFragment::Set { item: inner, .. } => {
741 collect_field_type_reference(inner, references);
742 }
743 NamedTypeFragment::Map { key, value, .. } => {
744 collect_field_type_reference(key, references);
745 collect_field_type_reference(value, references);
746 }
747 NamedTypeFragment::Tuple { members, .. } => {
748 for member in members {
749 collect_field_type_reference(member.field_type(), references);
750 }
751 }
752 }
753}
754
755fn collect_field_references(
756 field: &FieldFragment,
757 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
758 references: &mut ProposalReferences,
759) -> Result<(), SchemaContractError> {
760 collect_field_type_reference(field.field_type(), references);
761 if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
762 field.insert_policy()
763 {
764 let FieldType::Named(field_type) = field.field_type() else {
765 return Err(SchemaContractError::LiteralTypeMismatch);
766 };
767 if field_type != enum_type {
768 return Err(SchemaContractError::LiteralTypeMismatch);
769 }
770 collect_enum_literal_reference(enum_type, variant, types, references)?;
771 }
772 Ok(())
773}
774
775fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
776 match field_type {
777 FieldType::List(item) => collect_field_type_reference(item, references),
778 FieldType::Named(reference) => {
779 references.types.insert(reference.clone());
780 }
781 FieldType::Scalar(_) => {}
782 }
783}
784
785fn collect_expression_enum_references(
786 expression: &SourceCheckExpr,
787 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
788 references: &mut ProposalReferences,
789) -> Result<(), SchemaContractError> {
790 for instruction in expression.instructions() {
791 if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
792 instruction
793 {
794 collect_enum_literal_reference(enum_type, variant, types, references)?;
795 }
796 }
797 Ok(())
798}
799
800fn collect_enum_literal_reference(
801 enum_type: &TypeSourceKey,
802 variant: &TypeSourceKey,
803 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
804 references: &mut ProposalReferences,
805) -> Result<(), SchemaContractError> {
806 references.types.insert(enum_type.clone());
807 let Some(local) = types.get(enum_type) else {
808 return Ok(());
809 };
810 let NamedTypeFragment::Enum(local) = local else {
811 return Err(SchemaContractError::InvalidEnumLiteral);
812 };
813 if local
814 .variants()
815 .iter()
816 .all(|candidate| candidate.source_key() != variant)
817 {
818 return Err(SchemaContractError::InvalidEnumLiteral);
819 }
820 Ok(())
821}
822
823fn validate_local_relation_targets(
824 source: &EntityFragment,
825 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
826) -> Result<(), SchemaContractError> {
827 for relation in source.relations() {
828 let Some(target) = entities.get(relation.target_entity()) else {
829 continue;
830 };
831 for (source_key, target_key) in relation.local_fields().iter().zip(relation.target_fields())
832 {
833 let source_field = source
834 .fields()
835 .iter()
836 .find(|field| field.source_key() == source_key)
837 .ok_or(SchemaContractError::InvalidLocalReference)?;
838 let target_field = target
839 .fields()
840 .iter()
841 .find(|field| field.source_key() == target_key)
842 .ok_or(SchemaContractError::InvalidLocalReference)?;
843 let source_type = match source_field.field_type() {
844 FieldType::List(item) => item.as_ref(),
845 field_type => field_type,
846 };
847 if source_type != target_field.field_type() {
848 return Err(SchemaContractError::RelationTypeMismatch);
849 }
850 }
851 }
852 Ok(())
853}
854
855fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
856where
857 T: Eq,
858{
859 if values.windows(2).any(|pair| pair[0] == pair[1]) {
860 return Err(SchemaContractError::DuplicateSourceKey);
861 }
862 Ok(())
863}
864
865fn ensure_no_adjacent_duplicates_by<T, K>(
866 values: &[T],
867 key: impl Fn(&T) -> &K,
868) -> Result<(), SchemaContractError>
869where
870 K: Eq,
871{
872 if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
873 return Err(SchemaContractError::DuplicateSourceKey);
874 }
875 Ok(())
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881 use crate::decode_schema_proposal;
882 use std::str::FromStr;
883
884 fn empty_proposal() -> SchemaProposal {
885 SchemaProposal::try_compose(
886 Vec::new(),
887 TargetDatabaseIdentity::from_bytes([1; 32]),
888 SchemaSubmissionKey::try_new("proposal-version-test")
889 .expect("submission key should admit"),
890 ExpectedAcceptedHead::Empty,
891 Vec::new(),
892 Vec::new(),
893 Vec::new(),
894 )
895 .expect("empty proposal should compose")
896 }
897
898 #[test]
899 fn decoded_future_contract_version_fails_typed() {
900 let mut proposal = empty_proposal();
901 proposal.version = ProposalContractVersion::from_raw(2);
902 let bytes = candid::encode_one(proposal).expect("raw future proposal should encode");
903
904 assert_eq!(
905 decode_schema_proposal(&bytes),
906 Err(SchemaContractError::UnsupportedVersion {
907 found: 2,
908 supported: 1,
909 }),
910 );
911 }
912
913 #[test]
914 fn decoded_unknown_capability_fails_typed() {
915 let mut proposal = empty_proposal();
916 proposal.capabilities = vec![SchemaCapability::from_raw(u16::MAX)];
917 let bytes = candid::encode_one(proposal).expect("raw proposal should encode");
918
919 assert_eq!(
920 decode_schema_proposal(&bytes),
921 Err(SchemaContractError::UnsupportedCapability),
922 );
923 }
924
925 #[test]
926 fn targeted_numeric_operations_require_exact_target_literal_admission() {
927 let decimal = |value| {
928 crate::ScalarLiteral::Decimal(
929 crate::Decimal::from_str(value).expect("decimal fixture should parse"),
930 )
931 };
932 assert!(operation_matches_target(
933 &SourceRuleOperation::MultipleOf {
934 divisor: decimal("0.25"),
935 },
936 RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
937 ));
938 assert!(!operation_matches_target(
939 &SourceRuleOperation::MultipleOf {
940 divisor: decimal("0.251"),
941 },
942 RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
943 ));
944 assert!(!operation_matches_target(
945 &SourceRuleOperation::MultipleOf {
946 divisor: crate::ScalarLiteral::Float64(
947 crate::Float64::try_new(2.0).expect("finite float"),
948 ),
949 },
950 RuleTargetShape::Scalar(ScalarType::Float64),
951 ));
952 assert!(operation_matches_target(
953 &SourceRuleOperation::NumericMaximumInclusive {
954 value: crate::ScalarLiteral::Float64(
955 crate::Float64::try_new(2.0).expect("finite float"),
956 ),
957 },
958 RuleTargetShape::Scalar(ScalarType::Float64),
959 ));
960 }
961}