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, SchemaMigrationPlan,
14 SchemaMigrationRename, SchemaMigrationTransform, SchemaProposalDigest, SchemaSubmissionKey,
15 SourceCheckExpr, SourceCheckInstruction, SourceRuleOperation, TargetDatabaseIdentity,
16 TargetStoreIdentity, TargetedRuleFragment, TypeSourceKey, check_len, encode_schema_fragment,
17 encode_schema_proposal,
18};
19
20#[derive(
22 CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
23)]
24#[repr(transparent)]
25#[serde(transparent)]
26pub struct ProposalContractVersion(u16);
27
28impl ProposalContractVersion {
29 pub const CURRENT: Self = Self(2);
31
32 #[must_use]
34 pub const fn from_raw(value: u16) -> Self {
35 Self(value)
36 }
37
38 #[must_use]
40 pub const fn get(self) -> u16 {
41 self.0
42 }
43}
44
45#[derive(
47 CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
48)]
49#[repr(transparent)]
50#[serde(transparent)]
51pub struct SchemaCapability(u16);
52
53impl SchemaCapability {
54 pub const EXACT_COMPOSITE_TYPES: Self = Self(1);
56 pub const ACCEPTED_CHECKS: Self = Self(2);
58 pub const SECONDARY_INDEXES: Self = Self(3);
60 pub const RESTRICTIVE_RELATIONS: Self = Self(4);
62 pub const INSERT_DEFAULTS: Self = Self(5);
64 pub const GENERATED_VALUES: Self = Self(6);
66 pub const MANAGED_TIMESTAMPS: Self = Self(7);
68 pub const VERSIONED_MIGRATIONS: Self = Self(8);
70
71 #[must_use]
73 pub const fn from_raw(value: u16) -> Self {
74 Self(value)
75 }
76
77 #[must_use]
79 pub const fn get(self) -> u16 {
80 self.0
81 }
82
83 const fn is_supported(self) -> bool {
84 matches!(self.0, 1..=8)
85 }
86}
87
88#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90pub enum ExpectedAcceptedHead {
91 Empty,
93 Exact {
95 revision: u64,
97 fingerprint: crate::ExpectedSchemaFingerprint,
99 },
100}
101
102impl ExpectedAcceptedHead {
103 const fn validate(&self) -> Result<(), SchemaContractError> {
104 match self {
105 Self::Exact { revision: 0, .. } => Err(SchemaContractError::InvalidReferenceList),
106 Self::Empty | Self::Exact { .. } => Ok(()),
107 }
108 }
109}
110
111#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct EntityStoreAssignment {
114 entity: EntitySourceKey,
115 store: TargetStoreIdentity,
116}
117
118impl EntityStoreAssignment {
119 #[must_use]
121 pub const fn new(entity: EntitySourceKey, store: TargetStoreIdentity) -> Self {
122 Self { entity, store }
123 }
124
125 #[must_use]
127 pub const fn entity(&self) -> &EntitySourceKey {
128 &self.entity
129 }
130
131 #[must_use]
133 pub const fn store(&self) -> TargetStoreIdentity {
134 self.store
135 }
136}
137
138#[derive(
140 CandidType, Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
141)]
142pub enum SchemaRemoval {
143 Entity(EntitySourceKey),
145 Field {
147 entity: EntitySourceKey,
149 field: FieldSourceKey,
151 },
152 Type(TypeSourceKey),
154 Constraint {
156 entity: EntitySourceKey,
158 constraint: ConstraintSourceKey,
160 },
161 Index {
163 entity: EntitySourceKey,
165 index: IndexSourceKey,
167 },
168 Relation {
170 entity: EntitySourceKey,
172 relation: RelationSourceKey,
174 },
175}
176
177#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179pub struct SchemaProposal {
180 version: ProposalContractVersion,
181 capabilities: Vec<SchemaCapability>,
182 target_database: TargetDatabaseIdentity,
183 submission_key: SchemaSubmissionKey,
184 expected_head: ExpectedAcceptedHead,
185 fragments: Vec<SchemaFragment>,
186 assignments: Vec<EntityStoreAssignment>,
187 removals: Vec<SchemaRemoval>,
188 migration: Option<SchemaMigrationPlan>,
189}
190
191impl SchemaProposal {
192 #[expect(
203 clippy::too_many_arguments,
204 clippy::too_many_lines,
205 reason = "composition receives and validates one complete atomic public envelope"
206 )]
207 pub fn try_compose(
208 mut capabilities: Vec<SchemaCapability>,
209 target_database: TargetDatabaseIdentity,
210 submission_key: SchemaSubmissionKey,
211 expected_head: ExpectedAcceptedHead,
212 mut fragments: Vec<SchemaFragment>,
213 mut assignments: Vec<EntityStoreAssignment>,
214 mut removals: Vec<SchemaRemoval>,
215 migration: Option<SchemaMigrationPlan>,
216 ) -> Result<Self, SchemaContractError> {
217 check_len(
218 "proposal capabilities",
219 capabilities.len(),
220 MAX_SCHEMA_CAPABILITIES,
221 )?;
222 check_len(
223 "proposal fragments",
224 fragments.len(),
225 MAX_SCHEMA_PROPOSAL_FRAGMENTS,
226 )?;
227 check_len(
228 "proposal assignments",
229 assignments.len(),
230 MAX_SCHEMA_ASSIGNMENTS,
231 )?;
232 check_len("proposal removals", removals.len(), MAX_SCHEMA_REMOVALS)?;
233 expected_head.validate()?;
234 capabilities.sort_unstable();
235 ensure_no_adjacent_duplicates(&capabilities)?;
236 if capabilities
237 .iter()
238 .any(|capability| !capability.is_supported())
239 {
240 return Err(SchemaContractError::UnsupportedCapability);
241 }
242 let declares_migration_capability = capabilities
243 .binary_search(&SchemaCapability::VERSIONED_MIGRATIONS)
244 .is_ok();
245 if declares_migration_capability != migration.is_some() {
246 return Err(SchemaContractError::InvalidMigrationPlan);
247 }
248 if let Some(plan) = &migration {
249 plan.validate()?;
250 }
251 for fragment in &fragments {
252 fragment.validate()?;
253 }
254 let mut keyed_fragments = fragments
255 .into_iter()
256 .map(|fragment| encode_schema_fragment(&fragment).map(|bytes| (bytes, fragment)))
257 .collect::<Result<Vec<_>, _>>()?;
258 keyed_fragments.sort_unstable_by(|left, right| left.0.cmp(&right.0));
261 fragments = keyed_fragments
262 .into_iter()
263 .map(|(_, fragment)| fragment)
264 .collect();
265 assignments.sort_unstable_by(|left, right| left.entity.cmp(&right.entity));
266 ensure_no_adjacent_duplicates_by(&assignments, |assignment| &assignment.entity)?;
267 removals.sort_unstable();
268 ensure_no_adjacent_duplicates(&removals)?;
269
270 let mut entity_definitions = BTreeMap::new();
271 let mut type_definitions = BTreeMap::new();
272 let mut field_definitions = BTreeSet::new();
273 let mut constraint_definitions = BTreeSet::new();
274 let mut index_definitions = BTreeSet::new();
275 let mut relation_definitions = BTreeSet::new();
276 let mut entity_names = BTreeSet::new();
277 let mut type_names = BTreeSet::new();
278 for fragment in &fragments {
279 for entity in fragment.entities() {
280 if entity_definitions
281 .insert(entity.source_key().clone(), entity)
282 .is_some()
283 {
284 return Err(SchemaContractError::DuplicateSourceKey);
285 }
286 if !entity_names.insert(entity.name()) {
287 return Err(SchemaContractError::DuplicateName);
288 }
289 for field in entity.fields() {
290 field_definitions
291 .insert((entity.source_key().clone(), field.source_key().clone()));
292 }
293 for constraint in entity.constraints() {
294 constraint_definitions
295 .insert((entity.source_key().clone(), constraint.source_key().clone()));
296 }
297 for index in entity.indexes() {
298 index_definitions
299 .insert((entity.source_key().clone(), index.source_key().clone()));
300 }
301 for relation in entity.relations() {
302 relation_definitions
303 .insert((entity.source_key().clone(), relation.source_key().clone()));
304 }
305 }
306 for r#type in fragment.types() {
307 if type_definitions
308 .insert(r#type.source_key().clone(), r#type)
309 .is_some()
310 {
311 return Err(SchemaContractError::DuplicateSourceKey);
312 }
313 if !type_names.insert(r#type.name()) {
314 return Err(SchemaContractError::DuplicateName);
315 }
316 }
317 }
318 for assignment in &assignments {
319 if !entity_definitions.contains_key(assignment.entity()) {
320 return Err(SchemaContractError::InvalidReferenceList);
321 }
322 }
323 if assignments.len() != entity_definitions.len() {
324 return Err(SchemaContractError::MissingEntityStoreAssignment);
325 }
326 for removal in &removals {
327 let collides = match removal {
328 SchemaRemoval::Entity(entity) => entity_definitions.contains_key(entity),
329 SchemaRemoval::Field { entity, field } => {
330 field_definitions.contains(&(entity.clone(), field.clone()))
331 }
332 SchemaRemoval::Type(r#type) => type_definitions.contains_key(r#type),
333 SchemaRemoval::Constraint { entity, constraint } => {
334 constraint_definitions.contains(&(entity.clone(), constraint.clone()))
335 }
336 SchemaRemoval::Index { entity, index } => {
337 index_definitions.contains(&(entity.clone(), index.clone()))
338 }
339 SchemaRemoval::Relation { entity, relation } => {
340 relation_definitions.contains(&(entity.clone(), relation.clone()))
341 }
342 };
343 if collides {
344 return Err(SchemaContractError::DefinitionRemovalConflict);
345 }
346 }
347 validate_proposal_closure(
348 &expected_head,
349 &entity_definitions,
350 &type_definitions,
351 &removals,
352 )?;
353 if let Some(plan) = &migration {
354 validate_migration_plan(plan, &entity_definitions, &type_definitions)?;
355 }
356
357 Ok(Self {
358 version: ProposalContractVersion::CURRENT,
359 capabilities,
360 target_database,
361 submission_key,
362 expected_head,
363 fragments,
364 assignments,
365 removals,
366 migration,
367 })
368 }
369
370 #[must_use]
372 pub const fn version(&self) -> ProposalContractVersion {
373 self.version
374 }
375
376 #[must_use]
378 pub fn capabilities(&self) -> &[SchemaCapability] {
379 &self.capabilities
380 }
381
382 #[must_use]
384 pub const fn target_database(&self) -> TargetDatabaseIdentity {
385 self.target_database
386 }
387
388 #[must_use]
390 pub const fn submission_key(&self) -> &SchemaSubmissionKey {
391 &self.submission_key
392 }
393
394 #[must_use]
396 pub const fn expected_head(&self) -> &ExpectedAcceptedHead {
397 &self.expected_head
398 }
399
400 #[must_use]
402 pub fn fragments(&self) -> &[SchemaFragment] {
403 &self.fragments
404 }
405
406 #[must_use]
408 pub fn assignments(&self) -> &[EntityStoreAssignment] {
409 &self.assignments
410 }
411
412 #[must_use]
414 pub fn removals(&self) -> &[SchemaRemoval] {
415 &self.removals
416 }
417
418 #[must_use]
420 pub const fn migration(&self) -> Option<&SchemaMigrationPlan> {
421 self.migration.as_ref()
422 }
423
424 pub fn digest(&self) -> Result<SchemaProposalDigest, SchemaContractError> {
431 let bytes = encode_schema_proposal(self)?;
432 let digest: [u8; 32] = Sha256::digest(bytes).into();
433 Ok(SchemaProposalDigest::from_bytes(digest))
434 }
435
436 pub(crate) fn validate_current(&self) -> Result<(), SchemaContractError> {
437 if self.version != ProposalContractVersion::CURRENT {
438 return Err(SchemaContractError::UnsupportedVersion {
439 found: self.version.get(),
440 supported: ProposalContractVersion::CURRENT.get(),
441 });
442 }
443 let rebuilt = Self::try_compose(
444 self.capabilities.clone(),
445 self.target_database,
446 self.submission_key.clone(),
447 self.expected_head.clone(),
448 self.fragments.clone(),
449 self.assignments.clone(),
450 self.removals.clone(),
451 self.migration.clone(),
452 )?;
453 if rebuilt != *self {
454 return Err(SchemaContractError::NonCanonical);
455 }
456 Ok(())
457 }
458}
459
460fn validate_migration_plan(
461 plan: &SchemaMigrationPlan,
462 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
463 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
464) -> Result<(), SchemaContractError> {
465 for transition in plan.transitions() {
466 let entity = entities
467 .get(transition.entity())
468 .copied()
469 .ok_or(SchemaContractError::InvalidMigrationReference)?;
470 if transition.from().get().checked_add(1) != Some(entity.version().get()) {
471 return Err(SchemaContractError::MigrationVersionGap);
472 }
473 for rename in transition.renames() {
474 validate_migration_rename_target(rename, transition, entity, types)?;
475 }
476 for transform in transition.transforms() {
477 validate_migration_transform_target(transform, entity, types)?;
478 }
479 }
480 validate_shared_type_transition_closure(plan, entities, types)?;
481 Ok(())
482}
483
484fn validate_shared_type_transition_closure(
485 plan: &SchemaMigrationPlan,
486 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
487 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
488) -> Result<(), SchemaContractError> {
489 for declaring_transition in plan.transitions() {
490 for rename in declaring_transition.renames() {
491 let Some(target_type) = shared_rename_target_type(declaring_transition, rename) else {
492 continue;
493 };
494 for (entity_source, entity) in entities {
495 if !entity_reaches_named_type(entity, &target_type, types) {
496 continue;
497 }
498 let repeats_rename = plan.transitions().iter().any(|transition| {
499 transition.entity() == entity_source && transition.renames().contains(rename)
500 });
501 if !repeats_rename {
502 return Err(SchemaContractError::InvalidMigrationReference);
503 }
504 }
505 }
506 }
507 Ok(())
508}
509
510fn shared_rename_target_type(
511 transition: &crate::EntityMigration,
512 rename: &SchemaMigrationRename,
513) -> Option<TypeSourceKey> {
514 match rename {
515 SchemaMigrationRename::NamedType { to, .. } => Some(to.clone()),
516 SchemaMigrationRename::EnumVariant { named_type, .. }
517 | SchemaMigrationRename::RecordField { named_type, .. } => {
518 Some(migration_target_type(transition, named_type))
519 }
520 SchemaMigrationRename::Field { .. }
521 | SchemaMigrationRename::Relation { .. }
522 | SchemaMigrationRename::Constraint { .. }
523 | SchemaMigrationRename::Rule { .. } => None,
524 }
525}
526
527fn validate_migration_rename_target(
528 rename: &SchemaMigrationRename,
529 transition: &crate::EntityMigration,
530 entity: &EntityFragment,
531 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
532) -> Result<(), SchemaContractError> {
533 let valid = match rename {
534 SchemaMigrationRename::Field { to, .. } => {
535 entity.fields().iter().any(|field| field.source_key() == to)
536 }
537 SchemaMigrationRename::NamedType { to, .. } => entity_reaches_named_type(entity, to, types),
538 SchemaMigrationRename::EnumVariant { named_type, to, .. } => {
539 let target_type = migration_target_type(transition, named_type);
540 entity_reaches_named_type(entity, &target_type, types)
541 && matches!(
542 types.get(&target_type),
543 Some(NamedTypeFragment::Enum(target))
544 if target.variants().iter().any(|variant| variant.source_key() == to)
545 )
546 }
547 SchemaMigrationRename::RecordField { named_type, to, .. } => {
548 let target_type = migration_target_type(transition, named_type);
549 entity_reaches_named_type(entity, &target_type, types)
550 && matches!(
551 types.get(&target_type),
552 Some(NamedTypeFragment::Record(target))
553 if target.fields().iter().any(|field| field.source_key() == to)
554 )
555 }
556 SchemaMigrationRename::Relation { to, .. } => entity
557 .relations()
558 .iter()
559 .any(|relation| relation.source_key() == to),
560 SchemaMigrationRename::Constraint { to, .. } => entity
561 .constraints()
562 .iter()
563 .any(|constraint| constraint.source_key() == to),
564 SchemaMigrationRename::Rule { named_type, to, .. } => {
565 let target_type = migration_target_type(transition, named_type);
566 entity.constraints().iter().any(|constraint| {
567 matches!(
568 constraint.kind(),
569 ConstraintFragmentKind::TargetedRule(rule)
570 if rule.target_type() == &target_type && rule.rule() == to
571 )
572 })
573 }
574 };
575 if valid {
576 Ok(())
577 } else {
578 Err(SchemaContractError::InvalidMigrationReference)
579 }
580}
581
582fn entity_reaches_named_type(
583 entity: &EntityFragment,
584 target: &TypeSourceKey,
585 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
586) -> bool {
587 let mut seen = BTreeSet::new();
588 entity
589 .fields()
590 .iter()
591 .any(|field| field_type_reaches_named_type(field.field_type(), target, types, &mut seen))
592 || entity.constraints().iter().any(|constraint| {
593 matches!(
594 constraint.kind(),
595 ConstraintFragmentKind::TargetedRule(rule) if rule.target_type() == target
596 )
597 })
598}
599
600fn field_type_reaches_named_type(
601 field_type: &FieldType,
602 target: &TypeSourceKey,
603 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
604 seen: &mut BTreeSet<TypeSourceKey>,
605) -> bool {
606 match field_type {
607 FieldType::Scalar(_) => false,
608 FieldType::List(inner) => field_type_reaches_named_type(inner, target, types, seen),
609 FieldType::Named(source) if source == target => true,
610 FieldType::Named(source) if !seen.insert(source.clone()) => false,
611 FieldType::Named(source) => match types.get(source) {
612 Some(NamedTypeFragment::Record(record)) => record.fields().iter().any(|field| {
613 field_type_reaches_named_type(field.field_type(), target, types, seen)
614 }),
615 Some(NamedTypeFragment::Enum(r#enum)) => r#enum.variants().iter().any(|variant| {
616 variant.payload().is_some_and(|payload| {
617 field_type_reaches_named_type(payload, target, types, seen)
618 })
619 }),
620 Some(
621 NamedTypeFragment::Newtype { inner, .. }
622 | NamedTypeFragment::List { item: inner, .. }
623 | NamedTypeFragment::Set { item: inner, .. },
624 ) => field_type_reaches_named_type(inner, target, types, seen),
625 Some(NamedTypeFragment::Map { key, value, .. }) => {
626 field_type_reaches_named_type(key, target, types, seen)
627 || field_type_reaches_named_type(value, target, types, seen)
628 }
629 Some(NamedTypeFragment::Tuple { members, .. }) => members.iter().any(|member| {
630 field_type_reaches_named_type(member.field_type(), target, types, seen)
631 }),
632 None => false,
633 },
634 }
635}
636
637fn migration_target_type(
638 transition: &crate::EntityMigration,
639 accepted_before: &TypeSourceKey,
640) -> TypeSourceKey {
641 transition
642 .renames()
643 .iter()
644 .find_map(|rename| match rename {
645 SchemaMigrationRename::NamedType { from, to } if from == accepted_before => {
646 Some(to.clone())
647 }
648 _ => None,
649 })
650 .unwrap_or_else(|| accepted_before.clone())
651}
652
653fn validate_migration_transform_target(
654 transform: &SchemaMigrationTransform,
655 entity: &EntityFragment,
656 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
657) -> Result<(), SchemaContractError> {
658 let target = entity
659 .fields()
660 .iter()
661 .find(|field| field.source_key() == transform.target())
662 .ok_or(SchemaContractError::InvalidMigrationReference)?;
663 match transform {
664 SchemaMigrationTransform::Fill { literal, .. }
665 | SchemaMigrationTransform::Coalesce { literal, .. } => {
666 validate_migration_literal_target(literal, target, types)
667 }
668 SchemaMigrationTransform::CheckedCast {
669 target: target_scalar,
670 ..
671 } if target.field_type() == &FieldType::Scalar(*target_scalar) => Ok(()),
672 SchemaMigrationTransform::Copy { .. } => Ok(()),
673 SchemaMigrationTransform::CheckedCast { .. } => {
674 Err(SchemaContractError::InvalidMigrationTransform)
675 }
676 }
677}
678
679fn validate_migration_literal_target(
680 literal: &ScalarLiteral,
681 target: &FieldFragment,
682 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
683) -> Result<(), SchemaContractError> {
684 let valid = match (target.field_type(), literal) {
685 (FieldType::Scalar(scalar), literal) => scalar.accepts_literal(literal),
686 (FieldType::Named(target_type), ScalarLiteral::EnumUnit { enum_type, variant })
687 if target_type == enum_type =>
688 {
689 matches!(
690 types.get(enum_type),
691 Some(NamedTypeFragment::Enum(target_enum))
692 if target_enum
693 .variants()
694 .iter()
695 .any(|candidate| candidate.source_key() == variant)
696 )
697 }
698 (FieldType::List(_) | FieldType::Named(_), _) => false,
699 };
700 if valid {
701 Ok(())
702 } else {
703 Err(SchemaContractError::LiteralTypeMismatch)
704 }
705}
706
707#[derive(Default)]
708struct ProposalReferences {
709 types: BTreeSet<TypeSourceKey>,
710 relation_entities: BTreeSet<EntitySourceKey>,
711 relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
712}
713
714fn validate_proposal_closure(
715 expected_head: &ExpectedAcceptedHead,
716 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
717 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
718 removals: &[SchemaRemoval],
719) -> Result<(), SchemaContractError> {
720 let mut references = ProposalReferences::default();
721 for entity in entities.values() {
722 collect_entity_references(entity, types, &mut references)?;
723 validate_local_relation_targets(entity, entities)?;
724 }
725 for r#type in types.values() {
726 collect_named_type_references(r#type, &mut references);
727 }
728 for removal in removals {
729 let removes_reference = match removal {
730 SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
731 SchemaRemoval::Field { entity, field } => references
732 .relation_fields
733 .contains(&(entity.clone(), field.clone())),
734 SchemaRemoval::Type(r#type) => references.types.contains(r#type),
735 SchemaRemoval::Constraint { .. }
736 | SchemaRemoval::Index { .. }
737 | SchemaRemoval::Relation { .. } => false,
738 };
739 if removes_reference {
740 return Err(SchemaContractError::RemovedReference);
741 }
742 }
743 if matches!(expected_head, ExpectedAcceptedHead::Empty)
744 && (references
745 .types
746 .iter()
747 .any(|reference| !types.contains_key(reference))
748 || references
749 .relation_entities
750 .iter()
751 .any(|reference| !entities.contains_key(reference)))
752 {
753 return Err(SchemaContractError::InvalidLocalReference);
754 }
755 Ok(())
756}
757
758fn collect_entity_references(
759 entity: &EntityFragment,
760 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
761 references: &mut ProposalReferences,
762) -> Result<(), SchemaContractError> {
763 for field in entity.fields() {
764 collect_field_references(field, types, references)?;
765 }
766 for relation in entity.relations() {
767 references
768 .relation_entities
769 .insert(relation.target_entity().clone());
770 references.relation_fields.extend(
771 relation
772 .target_fields()
773 .iter()
774 .cloned()
775 .map(|field| (relation.target_entity().clone(), field)),
776 );
777 }
778 for index in entity.indexes() {
779 if let Some(predicate) = index.predicate() {
780 collect_expression_enum_references(predicate, types, references)?;
781 }
782 }
783 for constraint in entity.constraints() {
784 match constraint.kind() {
785 ConstraintFragmentKind::Check(expression) => {
786 collect_expression_enum_references(expression, types, references)?;
787 }
788 ConstraintFragmentKind::TargetedRule(rule) => {
789 references.types.insert(rule.target_type().clone());
790 validate_targeted_rule(entity, rule, types)?;
791 }
792 }
793 }
794 Ok(())
795}
796
797fn validate_targeted_rule(
798 entity: &EntityFragment,
799 rule: &TargetedRuleFragment,
800 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
801) -> Result<(), SchemaContractError> {
802 let root = entity
803 .fields()
804 .iter()
805 .find(|field| field.source_key() == rule.root())
806 .ok_or(SchemaContractError::InvalidLocalReference)?;
807 if types.contains_key(rule.target_type())
808 && !field_type_reaches_target(root.field_type(), rule.target_type(), types)
809 {
810 return Err(SchemaContractError::InvalidRuleTarget);
811 }
812 let Some(target) = types.get(rule.target_type()) else {
813 return Ok(());
814 };
815 let shape = resolve_rule_target_shape(target, types)?;
816 if operation_matches_target(rule.operation(), shape) {
817 Ok(())
818 } else {
819 Err(SchemaContractError::InvalidRuleTarget)
820 }
821}
822
823fn field_type_reaches_target(
824 root: &FieldType,
825 target: &TypeSourceKey,
826 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
827) -> bool {
828 let mut pending = vec![root];
829 let mut visited = BTreeSet::new();
830 while let Some(field_type) = pending.pop() {
831 match field_type {
832 FieldType::Scalar(_) => {}
833 FieldType::List(item) => pending.push(item),
834 FieldType::Named(source) => {
835 if source == target {
836 return true;
837 }
838 if !visited.insert(source) {
839 continue;
840 }
841 if let Some(definition) = types.get(source) {
842 push_named_type_field_types(definition, &mut pending);
843 }
844 }
845 }
846 }
847 false
848}
849
850fn push_named_type_field_types<'types>(
851 r#type: &'types NamedTypeFragment,
852 pending: &mut Vec<&'types FieldType>,
853) {
854 match r#type {
855 NamedTypeFragment::Record(record) => {
856 pending.extend(
857 record
858 .fields()
859 .iter()
860 .map(crate::RecordFieldFragment::field_type),
861 );
862 }
863 NamedTypeFragment::Enum(r#enum) => {
864 pending.extend(
865 r#enum
866 .variants()
867 .iter()
868 .filter_map(|variant| variant.payload()),
869 );
870 }
871 NamedTypeFragment::Newtype { inner, .. }
872 | NamedTypeFragment::List { item: inner, .. }
873 | NamedTypeFragment::Set { item: inner, .. } => pending.push(inner),
874 NamedTypeFragment::Map { key, value, .. } => {
875 pending.push(key);
876 pending.push(value);
877 }
878 NamedTypeFragment::Tuple { members, .. } => {
879 pending.extend(members.iter().map(crate::TupleElementFragment::field_type));
880 }
881 }
882}
883
884#[derive(Clone, Copy)]
885enum RuleTargetShape {
886 Collection,
887 Scalar(ScalarType),
888}
889
890fn resolve_rule_target_shape(
891 target: &NamedTypeFragment,
892 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
893) -> Result<RuleTargetShape, SchemaContractError> {
894 let mut current = target;
895 let mut visited = BTreeSet::new();
896 loop {
897 if !visited.insert(current.source_key()) {
898 return Err(SchemaContractError::InvalidRuleTarget);
899 }
900 match current {
901 NamedTypeFragment::List { .. }
902 | NamedTypeFragment::Set { .. }
903 | NamedTypeFragment::Map { .. } => return Ok(RuleTargetShape::Collection),
904 NamedTypeFragment::Newtype { inner, .. } => match inner {
905 FieldType::Scalar(scalar) => return Ok(RuleTargetShape::Scalar(*scalar)),
906 FieldType::List(_) => return Ok(RuleTargetShape::Collection),
907 FieldType::Named(source) => {
908 current = types
909 .get(source)
910 .copied()
911 .ok_or(SchemaContractError::InvalidRuleTarget)?;
912 }
913 },
914 NamedTypeFragment::Record(_)
915 | NamedTypeFragment::Enum(_)
916 | NamedTypeFragment::Tuple { .. } => {
917 return Err(SchemaContractError::InvalidRuleTarget);
918 }
919 }
920 }
921}
922
923fn operation_matches_target(operation: &SourceRuleOperation, shape: RuleTargetShape) -> bool {
924 match (operation, shape) {
925 (
926 SourceRuleOperation::LengthRangeInclusive { .. },
927 RuleTargetShape::Collection
928 | RuleTargetShape::Scalar(ScalarType::Blob { .. } | ScalarType::Text { .. }),
929 ) => true,
930 (
931 SourceRuleOperation::NumericMaximumInclusive { value }
932 | SourceRuleOperation::NumericMinimumInclusive { value },
933 RuleTargetShape::Scalar(scalar),
934 ) => numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, value),
935 (
936 SourceRuleOperation::NumericRangeInclusive { min, max },
937 RuleTargetShape::Scalar(scalar),
938 ) => {
939 numeric_scalar(scalar)
940 && numeric_rule_literal_matches(scalar, min)
941 && numeric_rule_literal_matches(scalar, max)
942 }
943 (SourceRuleOperation::MultipleOf { divisor }, RuleTargetShape::Scalar(scalar)) => {
944 exact_numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, divisor)
945 }
946 _ => false,
947 }
948}
949
950fn numeric_rule_literal_matches(scalar: ScalarType, literal: &crate::ScalarLiteral) -> bool {
951 if let (ScalarType::Decimal { scale }, crate::ScalarLiteral::Decimal(value)) = (scalar, literal)
952 {
953 let value = value.normalize();
954 return value.scale() <= scale && value.scale_to_integer(scale).is_some();
955 }
956 scalar.accepts_literal(literal)
957}
958
959const fn exact_numeric_scalar(scalar: ScalarType) -> bool {
960 matches!(
961 scalar,
962 ScalarType::Decimal { .. }
963 | ScalarType::Int8
964 | ScalarType::Int16
965 | ScalarType::Int32
966 | ScalarType::Int64
967 | ScalarType::Int128
968 | ScalarType::IntBig { .. }
969 | ScalarType::Nat8
970 | ScalarType::Nat16
971 | ScalarType::Nat32
972 | ScalarType::Nat64
973 | ScalarType::Nat128
974 | ScalarType::NatBig { .. }
975 )
976}
977
978const fn numeric_scalar(scalar: ScalarType) -> bool {
979 matches!(
980 scalar,
981 ScalarType::Decimal { .. }
982 | ScalarType::Float32
983 | ScalarType::Float64
984 | ScalarType::Int8
985 | ScalarType::Int16
986 | ScalarType::Int32
987 | ScalarType::Int64
988 | ScalarType::Int128
989 | ScalarType::IntBig { .. }
990 | ScalarType::Nat8
991 | ScalarType::Nat16
992 | ScalarType::Nat32
993 | ScalarType::Nat64
994 | ScalarType::Nat128
995 | ScalarType::NatBig { .. }
996 )
997}
998
999fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
1000 match r#type {
1001 NamedTypeFragment::Record(record) => {
1002 for field in record.fields() {
1003 collect_field_type_reference(field.field_type(), references);
1004 }
1005 }
1006 NamedTypeFragment::Enum(r#enum) => {
1007 for variant in r#enum.variants() {
1008 if let Some(payload) = variant.payload() {
1009 collect_field_type_reference(payload, references);
1010 }
1011 }
1012 }
1013 NamedTypeFragment::Newtype { inner, .. }
1014 | NamedTypeFragment::List { item: inner, .. }
1015 | NamedTypeFragment::Set { item: inner, .. } => {
1016 collect_field_type_reference(inner, references);
1017 }
1018 NamedTypeFragment::Map { key, value, .. } => {
1019 collect_field_type_reference(key, references);
1020 collect_field_type_reference(value, references);
1021 }
1022 NamedTypeFragment::Tuple { members, .. } => {
1023 for member in members {
1024 collect_field_type_reference(member.field_type(), references);
1025 }
1026 }
1027 }
1028}
1029
1030fn collect_field_references(
1031 field: &FieldFragment,
1032 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1033 references: &mut ProposalReferences,
1034) -> Result<(), SchemaContractError> {
1035 collect_field_type_reference(field.field_type(), references);
1036 if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
1037 field.insert_policy()
1038 {
1039 let FieldType::Named(field_type) = field.field_type() else {
1040 return Err(SchemaContractError::LiteralTypeMismatch);
1041 };
1042 if field_type != enum_type {
1043 return Err(SchemaContractError::LiteralTypeMismatch);
1044 }
1045 collect_enum_literal_reference(enum_type, variant, types, references)?;
1046 }
1047 Ok(())
1048}
1049
1050fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
1051 match field_type {
1052 FieldType::List(item) => collect_field_type_reference(item, references),
1053 FieldType::Named(reference) => {
1054 references.types.insert(reference.clone());
1055 }
1056 FieldType::Scalar(_) => {}
1057 }
1058}
1059
1060fn collect_expression_enum_references(
1061 expression: &SourceCheckExpr,
1062 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1063 references: &mut ProposalReferences,
1064) -> Result<(), SchemaContractError> {
1065 for instruction in expression.instructions() {
1066 if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
1067 instruction
1068 {
1069 collect_enum_literal_reference(enum_type, variant, types, references)?;
1070 }
1071 }
1072 Ok(())
1073}
1074
1075fn collect_enum_literal_reference(
1076 enum_type: &TypeSourceKey,
1077 variant: &TypeSourceKey,
1078 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1079 references: &mut ProposalReferences,
1080) -> Result<(), SchemaContractError> {
1081 references.types.insert(enum_type.clone());
1082 let Some(local) = types.get(enum_type) else {
1083 return Ok(());
1084 };
1085 let NamedTypeFragment::Enum(local) = local else {
1086 return Err(SchemaContractError::InvalidEnumLiteral);
1087 };
1088 if local
1089 .variants()
1090 .iter()
1091 .all(|candidate| candidate.source_key() != variant)
1092 {
1093 return Err(SchemaContractError::InvalidEnumLiteral);
1094 }
1095 Ok(())
1096}
1097
1098fn validate_local_relation_targets(
1099 source: &EntityFragment,
1100 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
1101) -> Result<(), SchemaContractError> {
1102 for relation in source.relations() {
1103 let Some(target) = entities.get(relation.target_entity()) else {
1104 continue;
1105 };
1106 for (source_key, target_key) in relation.local_fields().iter().zip(relation.target_fields())
1107 {
1108 let source_field = source
1109 .fields()
1110 .iter()
1111 .find(|field| field.source_key() == source_key)
1112 .ok_or(SchemaContractError::InvalidLocalReference)?;
1113 let target_field = target
1114 .fields()
1115 .iter()
1116 .find(|field| field.source_key() == target_key)
1117 .ok_or(SchemaContractError::InvalidLocalReference)?;
1118 let source_type = match source_field.field_type() {
1119 FieldType::List(item) => item.as_ref(),
1120 field_type => field_type,
1121 };
1122 if source_type != target_field.field_type() {
1123 return Err(SchemaContractError::RelationTypeMismatch);
1124 }
1125 }
1126 }
1127 Ok(())
1128}
1129
1130fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
1131where
1132 T: Eq,
1133{
1134 if values.windows(2).any(|pair| pair[0] == pair[1]) {
1135 return Err(SchemaContractError::DuplicateSourceKey);
1136 }
1137 Ok(())
1138}
1139
1140fn ensure_no_adjacent_duplicates_by<T, K>(
1141 values: &[T],
1142 key: impl Fn(&T) -> &K,
1143) -> Result<(), SchemaContractError>
1144where
1145 K: Eq,
1146{
1147 if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
1148 return Err(SchemaContractError::DuplicateSourceKey);
1149 }
1150 Ok(())
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155 use super::*;
1156 use crate::decode_schema_proposal;
1157 use std::str::FromStr;
1158
1159 fn empty_proposal() -> SchemaProposal {
1160 SchemaProposal::try_compose(
1161 Vec::new(),
1162 TargetDatabaseIdentity::from_bytes([1; 32]),
1163 SchemaSubmissionKey::try_new("proposal-version-test")
1164 .expect("submission key should admit"),
1165 ExpectedAcceptedHead::Empty,
1166 Vec::new(),
1167 Vec::new(),
1168 Vec::new(),
1169 None,
1170 )
1171 .expect("empty proposal should compose")
1172 }
1173
1174 #[test]
1175 fn decoded_future_contract_version_fails_typed() {
1176 let mut proposal = empty_proposal();
1177 proposal.version = ProposalContractVersion::from_raw(3);
1178 let bytes = candid::encode_one(proposal).expect("raw future proposal should encode");
1179
1180 assert_eq!(
1181 decode_schema_proposal(&bytes),
1182 Err(SchemaContractError::UnsupportedVersion {
1183 found: 3,
1184 supported: 2,
1185 }),
1186 );
1187 }
1188
1189 #[test]
1190 fn decoded_unknown_capability_fails_typed() {
1191 let mut proposal = empty_proposal();
1192 proposal.capabilities = vec![SchemaCapability::from_raw(u16::MAX)];
1193 let bytes = candid::encode_one(proposal).expect("raw proposal should encode");
1194
1195 assert_eq!(
1196 decode_schema_proposal(&bytes),
1197 Err(SchemaContractError::UnsupportedCapability),
1198 );
1199 }
1200
1201 #[test]
1202 fn targeted_numeric_operations_require_exact_target_literal_admission() {
1203 let decimal = |value| {
1204 crate::ScalarLiteral::Decimal(
1205 crate::Decimal::from_str(value).expect("decimal fixture should parse"),
1206 )
1207 };
1208 assert!(operation_matches_target(
1209 &SourceRuleOperation::MultipleOf {
1210 divisor: decimal("0.25"),
1211 },
1212 RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1213 ));
1214 assert!(!operation_matches_target(
1215 &SourceRuleOperation::MultipleOf {
1216 divisor: decimal("0.251"),
1217 },
1218 RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1219 ));
1220 assert!(!operation_matches_target(
1221 &SourceRuleOperation::MultipleOf {
1222 divisor: crate::ScalarLiteral::Float64(
1223 crate::Float64::try_new(2.0).expect("finite float"),
1224 ),
1225 },
1226 RuleTargetShape::Scalar(ScalarType::Float64),
1227 ));
1228 assert!(operation_matches_target(
1229 &SourceRuleOperation::NumericMaximumInclusive {
1230 value: crate::ScalarLiteral::Float64(
1231 crate::Float64::try_new(2.0).expect("finite float"),
1232 ),
1233 },
1234 RuleTargetShape::Scalar(ScalarType::Float64),
1235 ));
1236 }
1237}