1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::{
9 ConstraintFragmentKind, ConstraintSourceKey, EntityFragment, EntitySourceKey,
10 EnumVariantFragment, FieldFragment, FieldSourceKey, FieldType, IndexSourceKey,
11 MAX_SCHEMA_ASSIGNMENTS, MAX_SCHEMA_CAPABILITIES, MAX_SCHEMA_PROPOSAL_FRAGMENTS,
12 MAX_SCHEMA_REMOVALS, NamedTypeFragment, RelationFragment, RelationPathStepFragment,
13 RelationSourceFragment, RelationSourceKey, ScalarLiteral, ScalarType, SchemaContractError,
14 SchemaFragment, SchemaMigrationPlan, SchemaMigrationRename, SchemaMigrationTransform,
15 SchemaProposalDigest, SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction,
16 SourceRuleOperation, TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment,
17 TypeSourceKey, check_len, encode_schema_fragment, 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(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84#[cfg_attr(not(target_family = "wasm"), derive(ic_wire::Wire))]
85pub enum ExpectedAcceptedHead {
86 Empty,
88 Exact {
90 revision: u64,
92 fingerprint: crate::ExpectedSchemaFingerprint,
94 },
95}
96
97impl ExpectedAcceptedHead {
98 const fn validate(&self) -> Result<(), SchemaContractError> {
99 match self {
100 Self::Exact { revision: 0, .. } => Err(SchemaContractError::InvalidReferenceList),
101 Self::Empty | Self::Exact { .. } => Ok(()),
102 }
103 }
104}
105
106#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct EntityStoreAssignment {
109 entity: EntitySourceKey,
110 store: TargetStoreIdentity,
111}
112
113impl EntityStoreAssignment {
114 #[must_use]
116 pub const fn new(entity: EntitySourceKey, store: TargetStoreIdentity) -> Self {
117 Self { entity, store }
118 }
119
120 #[must_use]
122 pub const fn entity(&self) -> &EntitySourceKey {
123 &self.entity
124 }
125
126 #[must_use]
128 pub const fn store(&self) -> TargetStoreIdentity {
129 self.store
130 }
131}
132
133#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
135pub enum SchemaRemoval {
136 Entity(EntitySourceKey),
138 Field {
140 entity: EntitySourceKey,
142 field: FieldSourceKey,
144 },
145 Type(TypeSourceKey),
147 Constraint {
149 entity: EntitySourceKey,
151 constraint: ConstraintSourceKey,
153 },
154 Index {
156 entity: EntitySourceKey,
158 index: IndexSourceKey,
160 },
161 Relation {
163 entity: EntitySourceKey,
165 relation: RelationSourceKey,
167 },
168}
169
170#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct SchemaProposal {
173 version: ProposalContractVersion,
174 capabilities: Vec<SchemaCapability>,
175 target_database: TargetDatabaseIdentity,
176 submission_key: SchemaSubmissionKey,
177 expected_head: ExpectedAcceptedHead,
178 fragments: Vec<SchemaFragment>,
179 assignments: Vec<EntityStoreAssignment>,
180 removals: Vec<SchemaRemoval>,
181 migration: Option<SchemaMigrationPlan>,
182}
183
184impl SchemaProposal {
185 #[expect(
196 clippy::too_many_arguments,
197 clippy::too_many_lines,
198 reason = "composition receives and validates one complete atomic public envelope"
199 )]
200 pub fn try_compose(
201 mut capabilities: Vec<SchemaCapability>,
202 target_database: TargetDatabaseIdentity,
203 submission_key: SchemaSubmissionKey,
204 expected_head: ExpectedAcceptedHead,
205 mut fragments: Vec<SchemaFragment>,
206 mut assignments: Vec<EntityStoreAssignment>,
207 mut removals: Vec<SchemaRemoval>,
208 migration: Option<SchemaMigrationPlan>,
209 ) -> Result<Self, SchemaContractError> {
210 check_len(
211 "proposal capabilities",
212 capabilities.len(),
213 MAX_SCHEMA_CAPABILITIES,
214 )?;
215 check_len(
216 "proposal fragments",
217 fragments.len(),
218 MAX_SCHEMA_PROPOSAL_FRAGMENTS,
219 )?;
220 check_len(
221 "proposal assignments",
222 assignments.len(),
223 MAX_SCHEMA_ASSIGNMENTS,
224 )?;
225 check_len("proposal removals", removals.len(), MAX_SCHEMA_REMOVALS)?;
226 expected_head.validate()?;
227 crate::compact_sort_unstable_by(&mut capabilities, Ord::cmp);
228 ensure_no_adjacent_duplicates(&capabilities)?;
229 if capabilities
230 .iter()
231 .any(|capability| !capability.is_supported())
232 {
233 return Err(SchemaContractError::UnsupportedCapability);
234 }
235 let declares_migration_capability = capabilities
236 .binary_search(&SchemaCapability::VERSIONED_MIGRATIONS)
237 .is_ok();
238 if declares_migration_capability != migration.is_some() {
239 return Err(SchemaContractError::InvalidMigrationPlan);
240 }
241 if let Some(plan) = &migration {
242 plan.validate()?;
243 }
244 for fragment in &fragments {
245 fragment.validate()?;
246 }
247 let mut keyed_fragments = fragments
248 .into_iter()
249 .map(|fragment| encode_schema_fragment(&fragment).map(|bytes| (bytes, fragment)))
250 .collect::<Result<Vec<_>, _>>()?;
251 crate::compact_sort_unstable_by(&mut keyed_fragments, |left, right| left.0.cmp(&right.0));
254 fragments = keyed_fragments
255 .into_iter()
256 .map(|(_, fragment)| fragment)
257 .collect();
258 crate::compact_sort_unstable_by(&mut assignments, |left, right| {
259 left.entity.cmp(&right.entity)
260 });
261 ensure_no_adjacent_duplicates_by(&assignments, |assignment| &assignment.entity)?;
262 crate::compact_sort_unstable_by(&mut removals, Ord::cmp);
263 ensure_no_adjacent_duplicates(&removals)?;
264
265 let mut entity_definitions = BTreeMap::new();
266 let mut type_definitions = BTreeMap::new();
267 let mut field_definitions = BTreeSet::new();
268 let mut constraint_definitions = BTreeSet::new();
269 let mut index_definitions = BTreeSet::new();
270 let mut relation_definitions = BTreeSet::new();
271 let mut entity_names = BTreeSet::new();
272 let mut type_names = BTreeSet::new();
273 for fragment in &fragments {
274 for entity in fragment.entities() {
275 if entity_definitions
276 .insert(entity.source_key().clone(), entity)
277 .is_some()
278 {
279 return Err(SchemaContractError::DuplicateSourceKey);
280 }
281 if !entity_names.insert(entity.name()) {
282 return Err(SchemaContractError::DuplicateName);
283 }
284 for field in entity.fields() {
285 field_definitions
286 .insert((entity.source_key().clone(), field.source_key().clone()));
287 }
288 for constraint in entity.constraints() {
289 constraint_definitions
290 .insert((entity.source_key().clone(), constraint.source_key().clone()));
291 }
292 for index in entity.indexes() {
293 index_definitions
294 .insert((entity.source_key().clone(), index.source_key().clone()));
295 }
296 for relation in entity.relations() {
297 relation_definitions
298 .insert((entity.source_key().clone(), relation.source_key().clone()));
299 }
300 }
301 for r#type in fragment.types() {
302 if type_definitions
303 .insert(r#type.source_key().clone(), r#type)
304 .is_some()
305 {
306 return Err(SchemaContractError::DuplicateSourceKey);
307 }
308 if !type_names.insert(r#type.name()) {
309 return Err(SchemaContractError::DuplicateName);
310 }
311 }
312 }
313 for assignment in &assignments {
314 if !entity_definitions.contains_key(assignment.entity()) {
315 return Err(SchemaContractError::InvalidReferenceList);
316 }
317 }
318 if assignments.len() != entity_definitions.len() {
319 return Err(SchemaContractError::MissingEntityStoreAssignment);
320 }
321 for removal in &removals {
322 let collides = match removal {
323 SchemaRemoval::Entity(entity) => entity_definitions.contains_key(entity),
324 SchemaRemoval::Field { entity, field } => {
325 field_definitions.contains(&(entity.clone(), field.clone()))
326 }
327 SchemaRemoval::Type(r#type) => type_definitions.contains_key(r#type),
328 SchemaRemoval::Constraint { entity, constraint } => {
329 constraint_definitions.contains(&(entity.clone(), constraint.clone()))
330 }
331 SchemaRemoval::Index { entity, index } => {
332 index_definitions.contains(&(entity.clone(), index.clone()))
333 }
334 SchemaRemoval::Relation { entity, relation } => {
335 relation_definitions.contains(&(entity.clone(), relation.clone()))
336 }
337 };
338 if collides {
339 return Err(SchemaContractError::DefinitionRemovalConflict);
340 }
341 }
342 validate_proposal_closure(
343 &expected_head,
344 &entity_definitions,
345 &type_definitions,
346 &removals,
347 )?;
348 if let Some(plan) = &migration {
349 validate_migration_plan(plan, &entity_definitions, &type_definitions)?;
350 }
351
352 Ok(Self {
353 version: ProposalContractVersion::CURRENT,
354 capabilities,
355 target_database,
356 submission_key,
357 expected_head,
358 fragments,
359 assignments,
360 removals,
361 migration,
362 })
363 }
364
365 #[must_use]
367 pub const fn version(&self) -> ProposalContractVersion {
368 self.version
369 }
370
371 #[must_use]
373 pub fn capabilities(&self) -> &[SchemaCapability] {
374 &self.capabilities
375 }
376
377 #[must_use]
379 pub const fn target_database(&self) -> TargetDatabaseIdentity {
380 self.target_database
381 }
382
383 #[must_use]
385 pub const fn submission_key(&self) -> &SchemaSubmissionKey {
386 &self.submission_key
387 }
388
389 #[must_use]
391 pub const fn expected_head(&self) -> &ExpectedAcceptedHead {
392 &self.expected_head
393 }
394
395 #[must_use]
397 pub fn fragments(&self) -> &[SchemaFragment] {
398 &self.fragments
399 }
400
401 #[must_use]
403 pub fn assignments(&self) -> &[EntityStoreAssignment] {
404 &self.assignments
405 }
406
407 #[must_use]
409 pub fn removals(&self) -> &[SchemaRemoval] {
410 &self.removals
411 }
412
413 #[must_use]
415 pub const fn migration(&self) -> Option<&SchemaMigrationPlan> {
416 self.migration.as_ref()
417 }
418
419 pub fn digest(&self) -> Result<SchemaProposalDigest, SchemaContractError> {
426 let bytes = encode_schema_proposal(self)?;
427 let digest: [u8; 32] = Sha256::digest(bytes).into();
428 Ok(SchemaProposalDigest::from_bytes(digest))
429 }
430
431 pub(crate) fn validate_current(&self) -> Result<(), SchemaContractError> {
432 if self.version != ProposalContractVersion::CURRENT {
433 return Err(SchemaContractError::UnsupportedVersion {
434 found: self.version.get(),
435 supported: ProposalContractVersion::CURRENT.get(),
436 });
437 }
438 let rebuilt = Self::try_compose(
439 self.capabilities.clone(),
440 self.target_database,
441 self.submission_key.clone(),
442 self.expected_head.clone(),
443 self.fragments.clone(),
444 self.assignments.clone(),
445 self.removals.clone(),
446 self.migration.clone(),
447 )?;
448 if rebuilt != *self {
449 return Err(SchemaContractError::NonCanonical);
450 }
451 Ok(())
452 }
453}
454
455pub(crate) fn validate_migration_plan(
456 plan: &SchemaMigrationPlan,
457 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
458 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
459) -> Result<(), SchemaContractError> {
460 for transition in plan.transitions() {
461 let entity = entities
462 .get(transition.entity())
463 .copied()
464 .ok_or(SchemaContractError::InvalidMigrationReference)?;
465 if transition.from().get().checked_add(1) != Some(entity.version().get()) {
466 return Err(SchemaContractError::MigrationVersionGap);
467 }
468 let reachable = if transition.renames().iter().any(|rename| {
469 matches!(
470 rename,
471 SchemaMigrationRename::NamedType { .. }
472 | SchemaMigrationRename::EnumVariant { .. }
473 | SchemaMigrationRename::RecordField { .. }
474 )
475 }) {
476 entity_named_types(entity, types)
477 } else {
478 BTreeSet::new()
479 };
480 for rename in transition.renames() {
481 validate_migration_rename_target(rename, transition, entity, types, &reachable)?;
482 }
483 for transform in transition.transforms() {
484 validate_migration_transform_target(transform, entity, types)?;
485 }
486 }
487 validate_shared_type_transition_closure(plan, entities, types)?;
488 Ok(())
489}
490
491fn validate_shared_type_transition_closure(
492 plan: &SchemaMigrationPlan,
493 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
494 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
495) -> Result<(), SchemaContractError> {
496 let mut required = BTreeMap::<_, BTreeSet<_>>::new();
500 for transition in plan.transitions() {
501 for rename in transition.renames() {
502 if let Some(target) = shared_rename_target_type(transition, rename) {
503 required
504 .entry(target)
505 .or_default()
506 .insert(rename.sort_key());
507 }
508 }
509 }
510 if required.is_empty() {
511 return Ok(());
512 }
513 for (source, entity) in entities {
514 let transition = plan
515 .transitions()
516 .binary_search_by(|transition| transition.entity().cmp(source))
517 .ok()
518 .map(|index| &plan.transitions()[index]);
519 for target in entity_named_types(entity, types) {
521 let Some(renames) = required.get(target) else {
522 continue;
523 };
524 let transition = transition.ok_or(SchemaContractError::InvalidMigrationReference)?;
525 for rename in renames {
526 if transition
527 .renames()
528 .binary_search_by(|candidate| candidate.sort_key().cmp(rename))
529 .is_err()
530 {
531 return Err(SchemaContractError::InvalidMigrationReference);
532 }
533 }
534 }
535 }
536 Ok(())
537}
538
539fn shared_rename_target_type(
540 transition: &crate::EntityMigration,
541 rename: &SchemaMigrationRename,
542) -> Option<TypeSourceKey> {
543 match rename {
544 SchemaMigrationRename::NamedType { to, .. } => Some(to.clone()),
545 SchemaMigrationRename::EnumVariant { named_type, .. }
546 | SchemaMigrationRename::RecordField { named_type, .. } => {
547 Some(migration_target_type(transition, named_type))
548 }
549 SchemaMigrationRename::Field { .. }
550 | SchemaMigrationRename::Relation { .. }
551 | SchemaMigrationRename::Constraint { .. }
552 | SchemaMigrationRename::Rule { .. } => None,
553 }
554}
555
556fn validate_migration_rename_target(
557 rename: &SchemaMigrationRename,
558 transition: &crate::EntityMigration,
559 entity: &EntityFragment,
560 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
561 reachable: &BTreeSet<&TypeSourceKey>,
562) -> Result<(), SchemaContractError> {
563 let valid = match rename {
564 SchemaMigrationRename::Field { to, .. } => {
565 entity.fields().iter().any(|field| field.source_key() == to)
566 }
567 SchemaMigrationRename::NamedType { to, .. } => reachable.contains(to),
568 SchemaMigrationRename::EnumVariant { named_type, to, .. } => {
569 let target_type = migration_target_type(transition, named_type);
570 reachable.contains(&target_type)
571 && matches!(
572 types.get(&target_type),
573 Some(NamedTypeFragment::Enum(target))
574 if target.variants().iter().any(|variant| variant.source_key() == to)
575 )
576 }
577 SchemaMigrationRename::RecordField { named_type, to, .. } => {
578 let target_type = migration_target_type(transition, named_type);
579 reachable.contains(&target_type)
580 && matches!(
581 types.get(&target_type),
582 Some(NamedTypeFragment::Record(target))
583 if target.fields().iter().any(|field| field.source_key() == to)
584 )
585 }
586 SchemaMigrationRename::Relation { to, .. } => entity
587 .relations()
588 .iter()
589 .any(|relation| relation.source_key() == to),
590 SchemaMigrationRename::Constraint { to, .. } => entity
591 .constraints()
592 .iter()
593 .any(|constraint| constraint.source_key() == to),
594 SchemaMigrationRename::Rule { named_type, to, .. } => {
595 let target_type = migration_target_type(transition, named_type);
596 entity.constraints().iter().any(|constraint| {
597 matches!(
598 constraint.kind(),
599 ConstraintFragmentKind::TargetedRule(rule)
600 if rule.target_type() == &target_type && rule.rule() == to
601 )
602 })
603 }
604 };
605 if valid {
606 Ok(())
607 } else {
608 Err(SchemaContractError::InvalidMigrationReference)
609 }
610}
611
612fn entity_named_types<'schema>(
613 entity: &'schema EntityFragment,
614 types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
615) -> BTreeSet<&'schema TypeSourceKey> {
616 let pending = entity
617 .fields()
618 .iter()
619 .map(FieldFragment::field_type)
620 .collect();
621 let mut reachable = BTreeSet::new();
622 walk_named_field_types(pending, types, &mut reachable, |_| false);
623 for constraint in entity.constraints() {
626 if let ConstraintFragmentKind::TargetedRule(rule) = constraint.kind() {
627 reachable.insert(rule.target_type());
628 }
629 }
630 reachable
631}
632
633fn migration_target_type(
634 transition: &crate::EntityMigration,
635 accepted_before: &TypeSourceKey,
636) -> TypeSourceKey {
637 transition
638 .renames()
639 .iter()
640 .find_map(|rename| match rename {
641 SchemaMigrationRename::NamedType { from, to } if from == accepted_before => {
642 Some(to.clone())
643 }
644 _ => None,
645 })
646 .unwrap_or_else(|| accepted_before.clone())
647}
648
649fn validate_migration_transform_target(
650 transform: &SchemaMigrationTransform,
651 entity: &EntityFragment,
652 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
653) -> Result<(), SchemaContractError> {
654 let target = entity
655 .fields()
656 .iter()
657 .find(|field| field.source_key() == transform.target())
658 .ok_or(SchemaContractError::InvalidMigrationReference)?;
659 match transform {
660 SchemaMigrationTransform::Fill { literal, .. }
661 | SchemaMigrationTransform::Coalesce { literal, .. } => {
662 validate_migration_literal_target(literal, target, types)
663 }
664 SchemaMigrationTransform::CheckedCast {
665 target: target_scalar,
666 ..
667 } if target.field_type() == &FieldType::Scalar(*target_scalar) => Ok(()),
668 SchemaMigrationTransform::Copy { .. } => Ok(()),
669 SchemaMigrationTransform::CheckedCast { .. } => {
670 Err(SchemaContractError::InvalidMigrationTransform)
671 }
672 }
673}
674
675fn validate_migration_literal_target(
676 literal: &ScalarLiteral,
677 target: &FieldFragment,
678 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
679) -> Result<(), SchemaContractError> {
680 let valid = match (target.field_type(), literal) {
681 (FieldType::Scalar(scalar), literal) => scalar.accepts_literal(literal),
682 (FieldType::Named(target_type), ScalarLiteral::EnumUnit { enum_type, variant })
683 if target_type == enum_type =>
684 {
685 matches!(
686 types.get(enum_type),
687 Some(NamedTypeFragment::Enum(target_enum))
688 if target_enum
689 .variants()
690 .iter()
691 .any(|candidate| candidate.source_key() == variant)
692 )
693 }
694 (FieldType::List(_) | FieldType::Named(_), _) => false,
695 };
696 if valid {
697 Ok(())
698 } else {
699 Err(SchemaContractError::LiteralTypeMismatch)
700 }
701}
702
703#[derive(Default)]
704struct ProposalReferences {
705 types: BTreeSet<TypeSourceKey>,
706 relation_entities: BTreeSet<EntitySourceKey>,
707 relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
708}
709
710pub(crate) fn validate_proposal_closure(
711 expected_head: &ExpectedAcceptedHead,
712 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
713 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
714 removals: &[SchemaRemoval],
715) -> Result<(), SchemaContractError> {
716 let mut references = ProposalReferences::default();
717 for entity in entities.values() {
718 collect_entity_references(entity, types, &mut references)?;
719 validate_local_relation_targets(entity, entities, types)?;
720 }
721 for r#type in types.values() {
722 collect_named_type_references(r#type, &mut references);
723 }
724 for removal in removals {
725 let removes_reference = match removal {
726 SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
727 SchemaRemoval::Field { entity, field } => references
728 .relation_fields
729 .contains(&(entity.clone(), field.clone())),
730 SchemaRemoval::Type(r#type) => references.types.contains(r#type),
731 SchemaRemoval::Constraint { .. }
732 | SchemaRemoval::Index { .. }
733 | SchemaRemoval::Relation { .. } => false,
734 };
735 if removes_reference {
736 return Err(SchemaContractError::RemovedReference);
737 }
738 }
739 if matches!(expected_head, ExpectedAcceptedHead::Empty)
740 && (references
741 .types
742 .iter()
743 .any(|reference| !types.contains_key(reference))
744 || references
745 .relation_entities
746 .iter()
747 .any(|reference| !entities.contains_key(reference)))
748 {
749 return Err(SchemaContractError::InvalidLocalReference);
750 }
751 Ok(())
752}
753
754fn collect_entity_references(
755 entity: &EntityFragment,
756 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
757 references: &mut ProposalReferences,
758) -> Result<(), SchemaContractError> {
759 for field in entity.fields() {
760 collect_field_references(field, types, references)?;
761 }
762 for relation in entity.relations() {
763 references
764 .relation_entities
765 .insert(relation.target_entity().clone());
766 references.relation_fields.extend(
767 relation
768 .target_fields()
769 .iter()
770 .cloned()
771 .map(|field| (relation.target_entity().clone(), field)),
772 );
773 if let RelationSourceFragment::Nested { steps, .. } = relation.source() {
774 references
775 .types
776 .extend(steps.iter().filter_map(|step| match step {
777 RelationPathStepFragment::EnterNamed { r#type } => Some(r#type.clone()),
778 RelationPathStepFragment::RecordMember { record, .. } => Some(record.clone()),
779 RelationPathStepFragment::EnumVariantPayload { r#enum, .. } => {
780 Some(r#enum.clone())
781 }
782 RelationPathStepFragment::OptionalSome
783 | RelationPathStepFragment::ListItems
784 | RelationPathStepFragment::SetItems
785 | RelationPathStepFragment::MapValues => None,
786 }));
787 }
788 }
789 for index in entity.indexes() {
790 if let Some(predicate) = index.predicate() {
791 collect_expression_enum_references(predicate, types, references)?;
792 }
793 }
794 for constraint in entity.constraints() {
795 match constraint.kind() {
796 ConstraintFragmentKind::Check(expression) => {
797 collect_expression_enum_references(expression, types, references)?;
798 }
799 ConstraintFragmentKind::TargetedRule(rule) => {
800 references.types.insert(rule.target_type().clone());
801 validate_targeted_rule(entity, rule, types)?;
802 }
803 }
804 }
805 Ok(())
806}
807
808fn validate_targeted_rule(
809 entity: &EntityFragment,
810 rule: &TargetedRuleFragment,
811 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
812) -> Result<(), SchemaContractError> {
813 let root = entity
814 .fields()
815 .iter()
816 .find(|field| field.source_key() == rule.root())
817 .ok_or(SchemaContractError::InvalidLocalReference)?;
818 if types.contains_key(rule.target_type())
819 && !field_type_reaches_target(root.field_type(), rule.target_type(), types)
820 {
821 return Err(SchemaContractError::InvalidRuleTarget);
822 }
823 let Some(target) = types.get(rule.target_type()) else {
824 return Ok(());
825 };
826 let shape = resolve_rule_target_shape(target, types)?;
827 if operation_matches_target(rule.operation(), shape) {
828 Ok(())
829 } else {
830 Err(SchemaContractError::InvalidRuleTarget)
831 }
832}
833
834fn field_type_reaches_target(
835 root: &FieldType,
836 target: &TypeSourceKey,
837 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
838) -> bool {
839 walk_named_field_types(vec![root], types, &mut BTreeSet::new(), |source| {
840 source == target
841 })
842}
843
844fn walk_named_field_types<'a>(
847 mut pending: Vec<&'a FieldType>,
848 types: &BTreeMap<TypeSourceKey, &'a NamedTypeFragment>,
849 visited: &mut BTreeSet<&'a TypeSourceKey>,
850 mut stop_at: impl FnMut(&'a TypeSourceKey) -> bool,
851) -> bool {
852 while let Some(field_type) = pending.pop() {
853 match field_type {
854 FieldType::Scalar(_) => {}
855 FieldType::List(item) => pending.push(item),
856 FieldType::Named(source) => {
857 if !visited.insert(source) {
858 continue;
859 }
860 if stop_at(source) {
861 return true;
862 }
863 if let Some(definition) = types.get(source) {
864 push_named_type_field_types(definition, &mut pending);
865 }
866 }
867 }
868 }
869 false
870}
871
872fn push_named_type_field_types<'types>(
873 r#type: &'types NamedTypeFragment,
874 pending: &mut Vec<&'types FieldType>,
875) {
876 match r#type {
877 NamedTypeFragment::Record(record) => {
878 pending.extend(
879 record
880 .fields()
881 .iter()
882 .map(crate::RecordFieldFragment::field_type),
883 );
884 }
885 NamedTypeFragment::Enum(r#enum) => {
886 pending.extend(
887 r#enum
888 .variants()
889 .iter()
890 .filter_map(|variant| variant.payload()),
891 );
892 }
893 NamedTypeFragment::Newtype { inner, .. }
894 | NamedTypeFragment::List { item: inner, .. }
895 | NamedTypeFragment::Set { item: inner, .. } => pending.push(inner),
896 NamedTypeFragment::Map { key, value, .. } => {
897 pending.push(key);
898 pending.push(value);
899 }
900 NamedTypeFragment::Tuple { members, .. } => {
901 pending.extend(members.iter().map(crate::TupleElementFragment::field_type));
902 }
903 }
904}
905
906#[derive(Clone, Copy)]
907enum RuleTargetShape {
908 Collection,
909 Scalar(ScalarType),
910}
911
912fn resolve_rule_target_shape(
913 target: &NamedTypeFragment,
914 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
915) -> Result<RuleTargetShape, SchemaContractError> {
916 let mut current = target;
917 let mut visited = BTreeSet::new();
918 loop {
919 if !visited.insert(current.source_key()) {
920 return Err(SchemaContractError::InvalidRuleTarget);
921 }
922 match current {
923 NamedTypeFragment::List { .. }
924 | NamedTypeFragment::Set { .. }
925 | NamedTypeFragment::Map { .. } => return Ok(RuleTargetShape::Collection),
926 NamedTypeFragment::Newtype { inner, .. } => match inner {
927 FieldType::Scalar(scalar) => return Ok(RuleTargetShape::Scalar(*scalar)),
928 FieldType::List(_) => return Ok(RuleTargetShape::Collection),
929 FieldType::Named(source) => {
930 current = types
931 .get(source)
932 .copied()
933 .ok_or(SchemaContractError::InvalidRuleTarget)?;
934 }
935 },
936 NamedTypeFragment::Record(_)
937 | NamedTypeFragment::Enum(_)
938 | NamedTypeFragment::Tuple { .. } => {
939 return Err(SchemaContractError::InvalidRuleTarget);
940 }
941 }
942 }
943}
944
945fn operation_matches_target(operation: &SourceRuleOperation, shape: RuleTargetShape) -> bool {
946 match (operation, shape) {
947 (
948 SourceRuleOperation::LengthRangeInclusive { .. },
949 RuleTargetShape::Collection
950 | RuleTargetShape::Scalar(ScalarType::Blob { .. } | ScalarType::Text { .. }),
951 ) => true,
952 (
953 SourceRuleOperation::NumericMaximumInclusive { value }
954 | SourceRuleOperation::NumericMinimumInclusive { value },
955 RuleTargetShape::Scalar(scalar),
956 ) => numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, value),
957 (
958 SourceRuleOperation::NumericRangeInclusive { min, max },
959 RuleTargetShape::Scalar(scalar),
960 ) => {
961 numeric_scalar(scalar)
962 && numeric_rule_literal_matches(scalar, min)
963 && numeric_rule_literal_matches(scalar, max)
964 }
965 (SourceRuleOperation::MultipleOf { divisor }, RuleTargetShape::Scalar(scalar)) => {
966 exact_numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, divisor)
967 }
968 _ => false,
969 }
970}
971
972fn numeric_rule_literal_matches(scalar: ScalarType, literal: &crate::ScalarLiteral) -> bool {
973 if let (ScalarType::Decimal { scale }, crate::ScalarLiteral::Decimal(value)) = (scalar, literal)
974 {
975 let value = value.normalize();
976 return value.scale() <= scale && value.scale_to_integer(scale).is_some();
977 }
978 scalar.accepts_literal(literal)
979}
980
981const fn exact_numeric_scalar(scalar: ScalarType) -> bool {
982 matches!(
983 scalar,
984 ScalarType::Decimal { .. }
985 | ScalarType::Int8
986 | ScalarType::Int16
987 | ScalarType::Int32
988 | ScalarType::Int64
989 | ScalarType::Int128
990 | ScalarType::Nat8
991 | ScalarType::Nat16
992 | ScalarType::Nat32
993 | ScalarType::Nat64
994 | ScalarType::Nat128
995 )
996}
997
998const fn numeric_scalar(scalar: ScalarType) -> bool {
999 exact_numeric_scalar(scalar)
1000 || matches!(
1001 scalar,
1002 ScalarType::Float32 | ScalarType::Float64 | ScalarType::U256
1003 )
1004}
1005
1006fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
1007 match r#type {
1008 NamedTypeFragment::Record(record) => {
1009 for field in record.fields() {
1010 collect_field_type_reference(field.field_type(), references);
1011 }
1012 }
1013 NamedTypeFragment::Enum(r#enum) => {
1014 for variant in r#enum.variants() {
1015 if let Some(payload) = variant.payload() {
1016 collect_field_type_reference(payload, references);
1017 }
1018 }
1019 }
1020 NamedTypeFragment::Newtype { inner, .. }
1021 | NamedTypeFragment::List { item: inner, .. }
1022 | NamedTypeFragment::Set { item: inner, .. } => {
1023 collect_field_type_reference(inner, references);
1024 }
1025 NamedTypeFragment::Map { key, value, .. } => {
1026 collect_field_type_reference(key, references);
1027 collect_field_type_reference(value, references);
1028 }
1029 NamedTypeFragment::Tuple { members, .. } => {
1030 for member in members {
1031 collect_field_type_reference(member.field_type(), references);
1032 }
1033 }
1034 }
1035}
1036
1037#[cfg(test)]
1038mod u256_tests {
1039 use super::*;
1040 use crate::{ScalarLiteral, U256};
1041
1042 #[test]
1043 fn u256_targeted_ranges_admit_while_multiple_of_remains_deferred() {
1044 let shape = RuleTargetShape::Scalar(ScalarType::U256);
1045 assert!(operation_matches_target(
1046 &SourceRuleOperation::NumericRangeInclusive {
1047 min: ScalarLiteral::U256(U256::ZERO),
1048 max: ScalarLiteral::U256(U256::MAX),
1049 },
1050 shape,
1051 ));
1052 assert!(!operation_matches_target(
1053 &SourceRuleOperation::MultipleOf {
1054 divisor: ScalarLiteral::U256(U256::ONE),
1055 },
1056 shape,
1057 ));
1058 }
1059}
1060
1061fn collect_field_references(
1062 field: &FieldFragment,
1063 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1064 references: &mut ProposalReferences,
1065) -> Result<(), SchemaContractError> {
1066 collect_field_type_reference(field.field_type(), references);
1067 if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
1068 field.insert_policy()
1069 {
1070 let FieldType::Named(field_type) = field.field_type() else {
1071 return Err(SchemaContractError::LiteralTypeMismatch);
1072 };
1073 if field_type != enum_type {
1074 return Err(SchemaContractError::LiteralTypeMismatch);
1075 }
1076 collect_enum_literal_reference(enum_type, variant, types, references)?;
1077 }
1078 Ok(())
1079}
1080
1081fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
1082 match field_type {
1083 FieldType::List(item) => collect_field_type_reference(item, references),
1084 FieldType::Named(reference) => {
1085 references.types.insert(reference.clone());
1086 }
1087 FieldType::Scalar(_) => {}
1088 }
1089}
1090
1091fn collect_expression_enum_references(
1092 expression: &SourceCheckExpr,
1093 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1094 references: &mut ProposalReferences,
1095) -> Result<(), SchemaContractError> {
1096 for instruction in expression.instructions() {
1097 if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
1098 instruction
1099 {
1100 collect_enum_literal_reference(enum_type, variant, types, references)?;
1101 }
1102 }
1103 Ok(())
1104}
1105
1106fn collect_enum_literal_reference(
1107 enum_type: &TypeSourceKey,
1108 variant: &TypeSourceKey,
1109 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1110 references: &mut ProposalReferences,
1111) -> Result<(), SchemaContractError> {
1112 references.types.insert(enum_type.clone());
1113 let Some(local) = types.get(enum_type) else {
1114 return Ok(());
1115 };
1116 let NamedTypeFragment::Enum(local) = local else {
1117 return Err(SchemaContractError::InvalidEnumLiteral);
1118 };
1119 if local
1120 .variants()
1121 .iter()
1122 .all(|candidate| candidate.source_key() != variant)
1123 {
1124 return Err(SchemaContractError::InvalidEnumLiteral);
1125 }
1126 Ok(())
1127}
1128
1129fn validate_local_relation_targets(
1130 source: &EntityFragment,
1131 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
1132 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1133) -> Result<(), SchemaContractError> {
1134 for relation in source.relations() {
1135 let Some(target) = entities.get(relation.target_entity()) else {
1136 continue;
1137 };
1138 match relation.source() {
1139 RelationSourceFragment::Direct { fields } => {
1140 for (source_key, target_key) in fields.iter().zip(relation.target_fields()) {
1141 let source_field = source
1142 .fields()
1143 .iter()
1144 .find(|field| field.source_key() == source_key)
1145 .ok_or(SchemaContractError::InvalidLocalReference)?;
1146 let target_field = target
1147 .fields()
1148 .iter()
1149 .find(|field| field.source_key() == target_key)
1150 .ok_or(SchemaContractError::InvalidLocalReference)?;
1151 let source_type = match source_field.field_type() {
1152 FieldType::List(item) => item.as_ref(),
1153 field_type => field_type,
1154 };
1155 if source_type != target_field.field_type() {
1156 return Err(SchemaContractError::RelationTypeMismatch);
1157 }
1158 }
1159 }
1160 RelationSourceFragment::Nested { .. } => {
1161 let terminal = validate_nested_relation_path(source, relation, types)?;
1162 let target_key = relation
1163 .target_fields()
1164 .first()
1165 .ok_or(SchemaContractError::InvalidReferenceList)?;
1166 let target_field = target
1167 .fields()
1168 .iter()
1169 .find(|field| field.source_key() == target_key)
1170 .ok_or(SchemaContractError::InvalidLocalReference)?;
1171 if terminal != target_field.field_type() {
1172 return Err(SchemaContractError::RelationTypeMismatch);
1173 }
1174 }
1175 }
1176 }
1177 Ok(())
1178}
1179
1180fn validate_nested_relation_path<'schema>(
1181 source: &'schema EntityFragment,
1182 relation: &RelationFragment,
1183 types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
1184) -> Result<&'schema FieldType, SchemaContractError> {
1185 let RelationSourceFragment::Nested { root, steps } = relation.source() else {
1186 return Err(SchemaContractError::RelationTypeMismatch);
1187 };
1188 let root = source
1189 .fields()
1190 .iter()
1191 .find(|field| field.source_key() == root)
1192 .ok_or(SchemaContractError::InvalidLocalReference)?;
1193 let mut kind = root.field_type();
1194 let mut nullable = root.nullable();
1195 let mut entered = None;
1196 for step in steps {
1197 match step {
1198 RelationPathStepFragment::OptionalSome => {
1199 if !nullable || entered.is_some() {
1200 return Err(SchemaContractError::RelationTypeMismatch);
1201 }
1202 nullable = false;
1203 }
1204 RelationPathStepFragment::EnterNamed { r#type } => {
1205 let FieldType::Named(named) = kind else {
1206 return Err(SchemaContractError::RelationTypeMismatch);
1207 };
1208 if nullable || entered.is_some() || named != r#type {
1209 return Err(SchemaContractError::RelationTypeMismatch);
1210 }
1211 match types.get(named).copied() {
1212 Some(
1213 NamedTypeFragment::Record(_)
1214 | NamedTypeFragment::Enum(_)
1215 | NamedTypeFragment::List { .. }
1216 | NamedTypeFragment::Set { .. }
1217 | NamedTypeFragment::Map { .. },
1218 ) => {
1219 entered = Some(named);
1220 }
1221 Some(NamedTypeFragment::Newtype { inner, .. }) => {
1222 kind = inner;
1223 }
1224 Some(NamedTypeFragment::Tuple { .. }) | None => {
1225 return Err(SchemaContractError::RelationTypeMismatch);
1226 }
1227 }
1228 }
1229 RelationPathStepFragment::RecordMember { record, field } => {
1230 if nullable || entered != Some(record) {
1231 return Err(SchemaContractError::RelationTypeMismatch);
1232 }
1233 let Some(NamedTypeFragment::Record(definition)) = types.get(record).copied() else {
1234 return Err(SchemaContractError::RelationTypeMismatch);
1235 };
1236 let member = definition
1237 .fields()
1238 .iter()
1239 .find(|member| member.source_key() == field)
1240 .ok_or(SchemaContractError::InvalidLocalReference)?;
1241 kind = member.field_type();
1242 nullable = member.nullable();
1243 entered = None;
1244 }
1245 RelationPathStepFragment::EnumVariantPayload { r#enum, variant } => {
1246 if nullable || entered != Some(r#enum) {
1247 return Err(SchemaContractError::RelationTypeMismatch);
1248 }
1249 let Some(NamedTypeFragment::Enum(definition)) = types.get(r#enum).copied() else {
1250 return Err(SchemaContractError::RelationTypeMismatch);
1251 };
1252 let payload = definition
1253 .variants()
1254 .iter()
1255 .find(|candidate| candidate.source_key() == variant)
1256 .and_then(EnumVariantFragment::payload)
1257 .ok_or(SchemaContractError::InvalidLocalReference)?;
1258 kind = payload;
1259 nullable = false;
1260 entered = None;
1261 }
1262 RelationPathStepFragment::ListItems
1263 | RelationPathStepFragment::SetItems
1264 | RelationPathStepFragment::MapValues => {
1265 if nullable {
1266 return Err(SchemaContractError::RelationTypeMismatch);
1267 }
1268 kind = relation_collection_step_output(kind, entered.take(), step, types)?;
1269 nullable = false;
1270 }
1271 }
1272 }
1273 if nullable || entered.is_some() || !matches!(kind, FieldType::Scalar(_)) {
1274 return Err(SchemaContractError::RelationTypeMismatch);
1275 }
1276 Ok(kind)
1277}
1278
1279fn relation_collection_step_output<'schema>(
1280 kind: &'schema FieldType,
1281 entered: Option<&TypeSourceKey>,
1282 step: &RelationPathStepFragment,
1283 types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
1284) -> Result<&'schema FieldType, SchemaContractError> {
1285 match (step, entered, kind) {
1286 (RelationPathStepFragment::ListItems, None, FieldType::List(item)) => Ok(item),
1287 (RelationPathStepFragment::ListItems, Some(named), FieldType::Named(current))
1288 if named == current =>
1289 {
1290 let Some(NamedTypeFragment::List { item, .. }) = types.get(current).copied() else {
1291 return Err(SchemaContractError::RelationTypeMismatch);
1292 };
1293 Ok(item)
1294 }
1295 (RelationPathStepFragment::SetItems, Some(named), FieldType::Named(current))
1296 if named == current =>
1297 {
1298 let Some(NamedTypeFragment::Set { item, .. }) = types.get(current).copied() else {
1299 return Err(SchemaContractError::RelationTypeMismatch);
1300 };
1301 Ok(item)
1302 }
1303 (RelationPathStepFragment::MapValues, Some(named), FieldType::Named(current))
1304 if named == current =>
1305 {
1306 let Some(NamedTypeFragment::Map { value, .. }) = types.get(current).copied() else {
1307 return Err(SchemaContractError::RelationTypeMismatch);
1308 };
1309 Ok(value)
1310 }
1311 _ => Err(SchemaContractError::RelationTypeMismatch),
1312 }
1313}
1314
1315fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
1316where
1317 T: Eq,
1318{
1319 if values.windows(2).any(|pair| pair[0] == pair[1]) {
1320 return Err(SchemaContractError::DuplicateSourceKey);
1321 }
1322 Ok(())
1323}
1324
1325fn ensure_no_adjacent_duplicates_by<T, K>(
1326 values: &[T],
1327 key: impl Fn(&T) -> &K,
1328) -> Result<(), SchemaContractError>
1329where
1330 K: Eq,
1331{
1332 if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
1333 return Err(SchemaContractError::DuplicateSourceKey);
1334 }
1335 Ok(())
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340 use super::*;
1341 use crate::decode_schema_proposal;
1342 use std::str::FromStr;
1343
1344 fn empty_proposal_with(capabilities: Vec<SchemaCapability>) -> SchemaProposal {
1345 SchemaProposal::try_compose(
1346 capabilities,
1347 TargetDatabaseIdentity::from_bytes([1; 32]),
1348 SchemaSubmissionKey::try_new("proposal-version-test")
1349 .expect("submission key should admit"),
1350 ExpectedAcceptedHead::Empty,
1351 Vec::new(),
1352 Vec::new(),
1353 Vec::new(),
1354 None,
1355 )
1356 .expect("empty proposal should compose")
1357 }
1358
1359 #[test]
1360 fn decoded_future_contract_version_fails_typed() {
1361 let proposal = empty_proposal_with(Vec::new());
1362 let mut bytes = crate::encode_schema_proposal(&proposal).expect("proposal should encode");
1363 bytes[5..7].copy_from_slice(&2_u16.to_be_bytes());
1364
1365 assert_eq!(
1366 decode_schema_proposal(&bytes),
1367 Err(SchemaContractError::UnsupportedVersion {
1368 found: 2,
1369 supported: 1,
1370 }),
1371 );
1372 }
1373
1374 #[test]
1375 fn decoded_unknown_capability_fails_typed() {
1376 let proposal = empty_proposal_with(vec![SchemaCapability::EXACT_COMPOSITE_TYPES]);
1377 let mut bytes = crate::encode_schema_proposal(&proposal).expect("proposal should encode");
1378 bytes[11..13].copy_from_slice(&u16::MAX.to_be_bytes());
1379
1380 assert_eq!(
1381 decode_schema_proposal(&bytes),
1382 Err(SchemaContractError::UnsupportedCapability),
1383 );
1384 }
1385
1386 #[test]
1387 fn targeted_numeric_operations_require_exact_target_literal_admission() {
1388 let decimal = |value| {
1389 crate::ScalarLiteral::Decimal(
1390 crate::Decimal::from_str(value).expect("decimal fixture should parse"),
1391 )
1392 };
1393 assert!(operation_matches_target(
1394 &SourceRuleOperation::MultipleOf {
1395 divisor: decimal("0.25"),
1396 },
1397 RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1398 ));
1399 assert!(!operation_matches_target(
1400 &SourceRuleOperation::MultipleOf {
1401 divisor: decimal("0.251"),
1402 },
1403 RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1404 ));
1405 assert!(!operation_matches_target(
1406 &SourceRuleOperation::MultipleOf {
1407 divisor: crate::ScalarLiteral::Float64(
1408 crate::Float64::try_new(2.0).expect("finite float"),
1409 ),
1410 },
1411 RuleTargetShape::Scalar(ScalarType::Float64),
1412 ));
1413 assert!(operation_matches_target(
1414 &SourceRuleOperation::NumericMaximumInclusive {
1415 value: crate::ScalarLiteral::Float64(
1416 crate::Float64::try_new(2.0).expect("finite float"),
1417 ),
1418 },
1419 RuleTargetShape::Scalar(ScalarType::Float64),
1420 ));
1421 }
1422}