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