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