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 mut pending: Vec<_> = entity
617 .fields()
618 .iter()
619 .map(FieldFragment::field_type)
620 .collect();
621 let mut reachable = BTreeSet::new();
622 while let Some(field_type) = pending.pop() {
623 match field_type {
624 FieldType::Scalar(_) => {}
625 FieldType::List(item) => pending.push(item),
626 FieldType::Named(source) => {
627 if reachable.insert(source)
628 && let Some(definition) = types.get(source)
629 {
630 push_named_type_field_types(definition, &mut pending);
631 }
632 }
633 }
634 }
635 for constraint in entity.constraints() {
638 if let ConstraintFragmentKind::TargetedRule(rule) = constraint.kind() {
639 reachable.insert(rule.target_type());
640 }
641 }
642 reachable
643}
644
645fn migration_target_type(
646 transition: &crate::EntityMigration,
647 accepted_before: &TypeSourceKey,
648) -> TypeSourceKey {
649 transition
650 .renames()
651 .iter()
652 .find_map(|rename| match rename {
653 SchemaMigrationRename::NamedType { from, to } if from == accepted_before => {
654 Some(to.clone())
655 }
656 _ => None,
657 })
658 .unwrap_or_else(|| accepted_before.clone())
659}
660
661fn validate_migration_transform_target(
662 transform: &SchemaMigrationTransform,
663 entity: &EntityFragment,
664 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
665) -> Result<(), SchemaContractError> {
666 let target = entity
667 .fields()
668 .iter()
669 .find(|field| field.source_key() == transform.target())
670 .ok_or(SchemaContractError::InvalidMigrationReference)?;
671 match transform {
672 SchemaMigrationTransform::Fill { literal, .. }
673 | SchemaMigrationTransform::Coalesce { literal, .. } => {
674 validate_migration_literal_target(literal, target, types)
675 }
676 SchemaMigrationTransform::CheckedCast {
677 target: target_scalar,
678 ..
679 } if target.field_type() == &FieldType::Scalar(*target_scalar) => Ok(()),
680 SchemaMigrationTransform::Copy { .. } => Ok(()),
681 SchemaMigrationTransform::CheckedCast { .. } => {
682 Err(SchemaContractError::InvalidMigrationTransform)
683 }
684 }
685}
686
687fn validate_migration_literal_target(
688 literal: &ScalarLiteral,
689 target: &FieldFragment,
690 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
691) -> Result<(), SchemaContractError> {
692 let valid = match (target.field_type(), literal) {
693 (FieldType::Scalar(scalar), literal) => scalar.accepts_literal(literal),
694 (FieldType::Named(target_type), ScalarLiteral::EnumUnit { enum_type, variant })
695 if target_type == enum_type =>
696 {
697 matches!(
698 types.get(enum_type),
699 Some(NamedTypeFragment::Enum(target_enum))
700 if target_enum
701 .variants()
702 .iter()
703 .any(|candidate| candidate.source_key() == variant)
704 )
705 }
706 (FieldType::List(_) | FieldType::Named(_), _) => false,
707 };
708 if valid {
709 Ok(())
710 } else {
711 Err(SchemaContractError::LiteralTypeMismatch)
712 }
713}
714
715#[derive(Default)]
716struct ProposalReferences {
717 types: BTreeSet<TypeSourceKey>,
718 relation_entities: BTreeSet<EntitySourceKey>,
719 relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
720}
721
722pub(crate) fn validate_proposal_closure(
723 expected_head: &ExpectedAcceptedHead,
724 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
725 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
726 removals: &[SchemaRemoval],
727) -> Result<(), SchemaContractError> {
728 let mut references = ProposalReferences::default();
729 for entity in entities.values() {
730 collect_entity_references(entity, types, &mut references)?;
731 validate_local_relation_targets(entity, entities, types)?;
732 }
733 for r#type in types.values() {
734 collect_named_type_references(r#type, &mut references);
735 }
736 for removal in removals {
737 let removes_reference = match removal {
738 SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
739 SchemaRemoval::Field { entity, field } => references
740 .relation_fields
741 .contains(&(entity.clone(), field.clone())),
742 SchemaRemoval::Type(r#type) => references.types.contains(r#type),
743 SchemaRemoval::Constraint { .. }
744 | SchemaRemoval::Index { .. }
745 | SchemaRemoval::Relation { .. } => false,
746 };
747 if removes_reference {
748 return Err(SchemaContractError::RemovedReference);
749 }
750 }
751 if matches!(expected_head, ExpectedAcceptedHead::Empty)
752 && (references
753 .types
754 .iter()
755 .any(|reference| !types.contains_key(reference))
756 || references
757 .relation_entities
758 .iter()
759 .any(|reference| !entities.contains_key(reference)))
760 {
761 return Err(SchemaContractError::InvalidLocalReference);
762 }
763 Ok(())
764}
765
766fn collect_entity_references(
767 entity: &EntityFragment,
768 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
769 references: &mut ProposalReferences,
770) -> Result<(), SchemaContractError> {
771 for field in entity.fields() {
772 collect_field_references(field, types, references)?;
773 }
774 for relation in entity.relations() {
775 references
776 .relation_entities
777 .insert(relation.target_entity().clone());
778 references.relation_fields.extend(
779 relation
780 .target_fields()
781 .iter()
782 .cloned()
783 .map(|field| (relation.target_entity().clone(), field)),
784 );
785 if let RelationSourceFragment::Nested { steps, .. } = relation.source() {
786 references
787 .types
788 .extend(steps.iter().filter_map(|step| match step {
789 RelationPathStepFragment::EnterNamed { r#type } => Some(r#type.clone()),
790 RelationPathStepFragment::RecordMember { record, .. } => Some(record.clone()),
791 RelationPathStepFragment::EnumVariantPayload { r#enum, .. } => {
792 Some(r#enum.clone())
793 }
794 RelationPathStepFragment::OptionalSome
795 | RelationPathStepFragment::ListItems
796 | RelationPathStepFragment::SetItems
797 | RelationPathStepFragment::MapValues => None,
798 }));
799 }
800 }
801 for index in entity.indexes() {
802 if let Some(predicate) = index.predicate() {
803 collect_expression_enum_references(predicate, types, references)?;
804 }
805 }
806 for constraint in entity.constraints() {
807 match constraint.kind() {
808 ConstraintFragmentKind::Check(expression) => {
809 collect_expression_enum_references(expression, types, references)?;
810 }
811 ConstraintFragmentKind::TargetedRule(rule) => {
812 references.types.insert(rule.target_type().clone());
813 validate_targeted_rule(entity, rule, types)?;
814 }
815 }
816 }
817 Ok(())
818}
819
820fn validate_targeted_rule(
821 entity: &EntityFragment,
822 rule: &TargetedRuleFragment,
823 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
824) -> Result<(), SchemaContractError> {
825 let root = entity
826 .fields()
827 .iter()
828 .find(|field| field.source_key() == rule.root())
829 .ok_or(SchemaContractError::InvalidLocalReference)?;
830 if types.contains_key(rule.target_type())
831 && !field_type_reaches_target(root.field_type(), rule.target_type(), types)
832 {
833 return Err(SchemaContractError::InvalidRuleTarget);
834 }
835 let Some(target) = types.get(rule.target_type()) else {
836 return Ok(());
837 };
838 let shape = resolve_rule_target_shape(target, types)?;
839 if operation_matches_target(rule.operation(), shape) {
840 Ok(())
841 } else {
842 Err(SchemaContractError::InvalidRuleTarget)
843 }
844}
845
846fn field_type_reaches_target(
847 root: &FieldType,
848 target: &TypeSourceKey,
849 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
850) -> bool {
851 let mut pending = vec![root];
852 let mut visited = BTreeSet::new();
853 while let Some(field_type) = pending.pop() {
854 match field_type {
855 FieldType::Scalar(_) => {}
856 FieldType::List(item) => pending.push(item),
857 FieldType::Named(source) => {
858 if source == target {
859 return true;
860 }
861 if !visited.insert(source) {
862 continue;
863 }
864 if let Some(definition) = types.get(source) {
865 push_named_type_field_types(definition, &mut pending);
866 }
867 }
868 }
869 }
870 false
871}
872
873fn push_named_type_field_types<'types>(
874 r#type: &'types NamedTypeFragment,
875 pending: &mut Vec<&'types FieldType>,
876) {
877 match r#type {
878 NamedTypeFragment::Record(record) => {
879 pending.extend(
880 record
881 .fields()
882 .iter()
883 .map(crate::RecordFieldFragment::field_type),
884 );
885 }
886 NamedTypeFragment::Enum(r#enum) => {
887 pending.extend(
888 r#enum
889 .variants()
890 .iter()
891 .filter_map(|variant| variant.payload()),
892 );
893 }
894 NamedTypeFragment::Newtype { inner, .. }
895 | NamedTypeFragment::List { item: inner, .. }
896 | NamedTypeFragment::Set { item: inner, .. } => pending.push(inner),
897 NamedTypeFragment::Map { key, value, .. } => {
898 pending.push(key);
899 pending.push(value);
900 }
901 NamedTypeFragment::Tuple { members, .. } => {
902 pending.extend(members.iter().map(crate::TupleElementFragment::field_type));
903 }
904 }
905}
906
907#[derive(Clone, Copy)]
908enum RuleTargetShape {
909 Collection,
910 Scalar(ScalarType),
911}
912
913fn resolve_rule_target_shape(
914 target: &NamedTypeFragment,
915 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
916) -> Result<RuleTargetShape, SchemaContractError> {
917 let mut current = target;
918 let mut visited = BTreeSet::new();
919 loop {
920 if !visited.insert(current.source_key()) {
921 return Err(SchemaContractError::InvalidRuleTarget);
922 }
923 match current {
924 NamedTypeFragment::List { .. }
925 | NamedTypeFragment::Set { .. }
926 | NamedTypeFragment::Map { .. } => return Ok(RuleTargetShape::Collection),
927 NamedTypeFragment::Newtype { inner, .. } => match inner {
928 FieldType::Scalar(scalar) => return Ok(RuleTargetShape::Scalar(*scalar)),
929 FieldType::List(_) => return Ok(RuleTargetShape::Collection),
930 FieldType::Named(source) => {
931 current = types
932 .get(source)
933 .copied()
934 .ok_or(SchemaContractError::InvalidRuleTarget)?;
935 }
936 },
937 NamedTypeFragment::Record(_)
938 | NamedTypeFragment::Enum(_)
939 | NamedTypeFragment::Tuple { .. } => {
940 return Err(SchemaContractError::InvalidRuleTarget);
941 }
942 }
943 }
944}
945
946fn operation_matches_target(operation: &SourceRuleOperation, shape: RuleTargetShape) -> bool {
947 match (operation, shape) {
948 (
949 SourceRuleOperation::LengthRangeInclusive { .. },
950 RuleTargetShape::Collection
951 | RuleTargetShape::Scalar(ScalarType::Blob { .. } | ScalarType::Text { .. }),
952 ) => true,
953 (
954 SourceRuleOperation::NumericMaximumInclusive { value }
955 | SourceRuleOperation::NumericMinimumInclusive { value },
956 RuleTargetShape::Scalar(scalar),
957 ) => numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, value),
958 (
959 SourceRuleOperation::NumericRangeInclusive { min, max },
960 RuleTargetShape::Scalar(scalar),
961 ) => {
962 numeric_scalar(scalar)
963 && numeric_rule_literal_matches(scalar, min)
964 && numeric_rule_literal_matches(scalar, max)
965 }
966 (SourceRuleOperation::MultipleOf { divisor }, RuleTargetShape::Scalar(scalar)) => {
967 exact_numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, divisor)
968 }
969 _ => false,
970 }
971}
972
973fn numeric_rule_literal_matches(scalar: ScalarType, literal: &crate::ScalarLiteral) -> bool {
974 if let (ScalarType::Decimal { scale }, crate::ScalarLiteral::Decimal(value)) = (scalar, literal)
975 {
976 let value = value.normalize();
977 return value.scale() <= scale && value.scale_to_integer(scale).is_some();
978 }
979 scalar.accepts_literal(literal)
980}
981
982const fn exact_numeric_scalar(scalar: ScalarType) -> bool {
983 matches!(
984 scalar,
985 ScalarType::Decimal { .. }
986 | ScalarType::Int8
987 | ScalarType::Int16
988 | ScalarType::Int32
989 | ScalarType::Int64
990 | ScalarType::Int128
991 | ScalarType::Nat8
992 | ScalarType::Nat16
993 | ScalarType::Nat32
994 | ScalarType::Nat64
995 | ScalarType::Nat128
996 )
997}
998
999const fn numeric_scalar(scalar: ScalarType) -> bool {
1000 matches!(
1001 scalar,
1002 ScalarType::Decimal { .. }
1003 | ScalarType::Float32
1004 | ScalarType::Float64
1005 | ScalarType::Int8
1006 | ScalarType::Int16
1007 | ScalarType::Int32
1008 | ScalarType::Int64
1009 | ScalarType::Int128
1010 | ScalarType::Nat8
1011 | ScalarType::Nat16
1012 | ScalarType::Nat32
1013 | ScalarType::Nat64
1014 | ScalarType::Nat128
1015 | ScalarType::U256
1016 )
1017}
1018
1019fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
1020 match r#type {
1021 NamedTypeFragment::Record(record) => {
1022 for field in record.fields() {
1023 collect_field_type_reference(field.field_type(), references);
1024 }
1025 }
1026 NamedTypeFragment::Enum(r#enum) => {
1027 for variant in r#enum.variants() {
1028 if let Some(payload) = variant.payload() {
1029 collect_field_type_reference(payload, references);
1030 }
1031 }
1032 }
1033 NamedTypeFragment::Newtype { inner, .. }
1034 | NamedTypeFragment::List { item: inner, .. }
1035 | NamedTypeFragment::Set { item: inner, .. } => {
1036 collect_field_type_reference(inner, references);
1037 }
1038 NamedTypeFragment::Map { key, value, .. } => {
1039 collect_field_type_reference(key, references);
1040 collect_field_type_reference(value, references);
1041 }
1042 NamedTypeFragment::Tuple { members, .. } => {
1043 for member in members {
1044 collect_field_type_reference(member.field_type(), references);
1045 }
1046 }
1047 }
1048}
1049
1050#[cfg(test)]
1051mod u256_tests {
1052 use super::*;
1053 use crate::{ScalarLiteral, U256};
1054
1055 #[test]
1056 fn u256_targeted_ranges_admit_while_multiple_of_remains_deferred() {
1057 let shape = RuleTargetShape::Scalar(ScalarType::U256);
1058 assert!(operation_matches_target(
1059 &SourceRuleOperation::NumericRangeInclusive {
1060 min: ScalarLiteral::U256(U256::ZERO),
1061 max: ScalarLiteral::U256(U256::MAX),
1062 },
1063 shape,
1064 ));
1065 assert!(!operation_matches_target(
1066 &SourceRuleOperation::MultipleOf {
1067 divisor: ScalarLiteral::U256(U256::ONE),
1068 },
1069 shape,
1070 ));
1071 }
1072}
1073
1074fn collect_field_references(
1075 field: &FieldFragment,
1076 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1077 references: &mut ProposalReferences,
1078) -> Result<(), SchemaContractError> {
1079 collect_field_type_reference(field.field_type(), references);
1080 if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
1081 field.insert_policy()
1082 {
1083 let FieldType::Named(field_type) = field.field_type() else {
1084 return Err(SchemaContractError::LiteralTypeMismatch);
1085 };
1086 if field_type != enum_type {
1087 return Err(SchemaContractError::LiteralTypeMismatch);
1088 }
1089 collect_enum_literal_reference(enum_type, variant, types, references)?;
1090 }
1091 Ok(())
1092}
1093
1094fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
1095 match field_type {
1096 FieldType::List(item) => collect_field_type_reference(item, references),
1097 FieldType::Named(reference) => {
1098 references.types.insert(reference.clone());
1099 }
1100 FieldType::Scalar(_) => {}
1101 }
1102}
1103
1104fn collect_expression_enum_references(
1105 expression: &SourceCheckExpr,
1106 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1107 references: &mut ProposalReferences,
1108) -> Result<(), SchemaContractError> {
1109 for instruction in expression.instructions() {
1110 if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
1111 instruction
1112 {
1113 collect_enum_literal_reference(enum_type, variant, types, references)?;
1114 }
1115 }
1116 Ok(())
1117}
1118
1119fn collect_enum_literal_reference(
1120 enum_type: &TypeSourceKey,
1121 variant: &TypeSourceKey,
1122 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1123 references: &mut ProposalReferences,
1124) -> Result<(), SchemaContractError> {
1125 references.types.insert(enum_type.clone());
1126 let Some(local) = types.get(enum_type) else {
1127 return Ok(());
1128 };
1129 let NamedTypeFragment::Enum(local) = local else {
1130 return Err(SchemaContractError::InvalidEnumLiteral);
1131 };
1132 if local
1133 .variants()
1134 .iter()
1135 .all(|candidate| candidate.source_key() != variant)
1136 {
1137 return Err(SchemaContractError::InvalidEnumLiteral);
1138 }
1139 Ok(())
1140}
1141
1142fn validate_local_relation_targets(
1143 source: &EntityFragment,
1144 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
1145 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
1146) -> Result<(), SchemaContractError> {
1147 for relation in source.relations() {
1148 let Some(target) = entities.get(relation.target_entity()) else {
1149 continue;
1150 };
1151 match relation.source() {
1152 RelationSourceFragment::Direct { fields } => {
1153 for (source_key, target_key) in fields.iter().zip(relation.target_fields()) {
1154 let source_field = source
1155 .fields()
1156 .iter()
1157 .find(|field| field.source_key() == source_key)
1158 .ok_or(SchemaContractError::InvalidLocalReference)?;
1159 let target_field = target
1160 .fields()
1161 .iter()
1162 .find(|field| field.source_key() == target_key)
1163 .ok_or(SchemaContractError::InvalidLocalReference)?;
1164 let source_type = match source_field.field_type() {
1165 FieldType::List(item) => item.as_ref(),
1166 field_type => field_type,
1167 };
1168 if source_type != target_field.field_type() {
1169 return Err(SchemaContractError::RelationTypeMismatch);
1170 }
1171 }
1172 }
1173 RelationSourceFragment::Nested { .. } => {
1174 let terminal = validate_nested_relation_path(source, relation, types)?;
1175 let target_key = relation
1176 .target_fields()
1177 .first()
1178 .ok_or(SchemaContractError::InvalidReferenceList)?;
1179 let target_field = target
1180 .fields()
1181 .iter()
1182 .find(|field| field.source_key() == target_key)
1183 .ok_or(SchemaContractError::InvalidLocalReference)?;
1184 if terminal != target_field.field_type() {
1185 return Err(SchemaContractError::RelationTypeMismatch);
1186 }
1187 }
1188 }
1189 }
1190 Ok(())
1191}
1192
1193fn validate_nested_relation_path<'schema>(
1194 source: &'schema EntityFragment,
1195 relation: &RelationFragment,
1196 types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
1197) -> Result<&'schema FieldType, SchemaContractError> {
1198 let RelationSourceFragment::Nested { root, steps } = relation.source() else {
1199 return Err(SchemaContractError::RelationTypeMismatch);
1200 };
1201 let root = source
1202 .fields()
1203 .iter()
1204 .find(|field| field.source_key() == root)
1205 .ok_or(SchemaContractError::InvalidLocalReference)?;
1206 let mut kind = root.field_type();
1207 let mut nullable = root.nullable();
1208 let mut entered = None;
1209 for step in steps {
1210 match step {
1211 RelationPathStepFragment::OptionalSome => {
1212 if !nullable || entered.is_some() {
1213 return Err(SchemaContractError::RelationTypeMismatch);
1214 }
1215 nullable = false;
1216 }
1217 RelationPathStepFragment::EnterNamed { r#type } => {
1218 let FieldType::Named(named) = kind else {
1219 return Err(SchemaContractError::RelationTypeMismatch);
1220 };
1221 if nullable || entered.is_some() || named != r#type {
1222 return Err(SchemaContractError::RelationTypeMismatch);
1223 }
1224 match types.get(named).copied() {
1225 Some(
1226 NamedTypeFragment::Record(_)
1227 | NamedTypeFragment::Enum(_)
1228 | NamedTypeFragment::List { .. }
1229 | NamedTypeFragment::Set { .. }
1230 | NamedTypeFragment::Map { .. },
1231 ) => {
1232 entered = Some(named);
1233 }
1234 Some(NamedTypeFragment::Newtype { inner, .. }) => {
1235 kind = inner;
1236 }
1237 Some(NamedTypeFragment::Tuple { .. }) | None => {
1238 return Err(SchemaContractError::RelationTypeMismatch);
1239 }
1240 }
1241 }
1242 RelationPathStepFragment::RecordMember { record, field } => {
1243 if nullable || entered != Some(record) {
1244 return Err(SchemaContractError::RelationTypeMismatch);
1245 }
1246 let Some(NamedTypeFragment::Record(definition)) = types.get(record).copied() else {
1247 return Err(SchemaContractError::RelationTypeMismatch);
1248 };
1249 let member = definition
1250 .fields()
1251 .iter()
1252 .find(|member| member.source_key() == field)
1253 .ok_or(SchemaContractError::InvalidLocalReference)?;
1254 kind = member.field_type();
1255 nullable = member.nullable();
1256 entered = None;
1257 }
1258 RelationPathStepFragment::EnumVariantPayload { r#enum, variant } => {
1259 if nullable || entered != Some(r#enum) {
1260 return Err(SchemaContractError::RelationTypeMismatch);
1261 }
1262 let Some(NamedTypeFragment::Enum(definition)) = types.get(r#enum).copied() else {
1263 return Err(SchemaContractError::RelationTypeMismatch);
1264 };
1265 let payload = definition
1266 .variants()
1267 .iter()
1268 .find(|candidate| candidate.source_key() == variant)
1269 .and_then(EnumVariantFragment::payload)
1270 .ok_or(SchemaContractError::InvalidLocalReference)?;
1271 kind = payload;
1272 nullable = false;
1273 entered = None;
1274 }
1275 RelationPathStepFragment::ListItems
1276 | RelationPathStepFragment::SetItems
1277 | RelationPathStepFragment::MapValues => {
1278 if nullable {
1279 return Err(SchemaContractError::RelationTypeMismatch);
1280 }
1281 kind = relation_collection_step_output(kind, entered.take(), step, types)?;
1282 nullable = false;
1283 }
1284 }
1285 }
1286 if nullable || entered.is_some() || !matches!(kind, FieldType::Scalar(_)) {
1287 return Err(SchemaContractError::RelationTypeMismatch);
1288 }
1289 Ok(kind)
1290}
1291
1292fn relation_collection_step_output<'schema>(
1293 kind: &'schema FieldType,
1294 entered: Option<&TypeSourceKey>,
1295 step: &RelationPathStepFragment,
1296 types: &BTreeMap<TypeSourceKey, &'schema NamedTypeFragment>,
1297) -> Result<&'schema FieldType, SchemaContractError> {
1298 match (step, entered, kind) {
1299 (RelationPathStepFragment::ListItems, None, FieldType::List(item)) => Ok(item),
1300 (RelationPathStepFragment::ListItems, Some(named), FieldType::Named(current))
1301 if named == current =>
1302 {
1303 let Some(NamedTypeFragment::List { item, .. }) = types.get(current).copied() else {
1304 return Err(SchemaContractError::RelationTypeMismatch);
1305 };
1306 Ok(item)
1307 }
1308 (RelationPathStepFragment::SetItems, Some(named), FieldType::Named(current))
1309 if named == current =>
1310 {
1311 let Some(NamedTypeFragment::Set { item, .. }) = types.get(current).copied() else {
1312 return Err(SchemaContractError::RelationTypeMismatch);
1313 };
1314 Ok(item)
1315 }
1316 (RelationPathStepFragment::MapValues, Some(named), FieldType::Named(current))
1317 if named == current =>
1318 {
1319 let Some(NamedTypeFragment::Map { value, .. }) = types.get(current).copied() else {
1320 return Err(SchemaContractError::RelationTypeMismatch);
1321 };
1322 Ok(value)
1323 }
1324 _ => Err(SchemaContractError::RelationTypeMismatch),
1325 }
1326}
1327
1328fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
1329where
1330 T: Eq,
1331{
1332 if values.windows(2).any(|pair| pair[0] == pair[1]) {
1333 return Err(SchemaContractError::DuplicateSourceKey);
1334 }
1335 Ok(())
1336}
1337
1338fn ensure_no_adjacent_duplicates_by<T, K>(
1339 values: &[T],
1340 key: impl Fn(&T) -> &K,
1341) -> Result<(), SchemaContractError>
1342where
1343 K: Eq,
1344{
1345 if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
1346 return Err(SchemaContractError::DuplicateSourceKey);
1347 }
1348 Ok(())
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353 use super::*;
1354 use crate::decode_schema_proposal;
1355 use std::str::FromStr;
1356
1357 fn empty_proposal_with(capabilities: Vec<SchemaCapability>) -> SchemaProposal {
1358 SchemaProposal::try_compose(
1359 capabilities,
1360 TargetDatabaseIdentity::from_bytes([1; 32]),
1361 SchemaSubmissionKey::try_new("proposal-version-test")
1362 .expect("submission key should admit"),
1363 ExpectedAcceptedHead::Empty,
1364 Vec::new(),
1365 Vec::new(),
1366 Vec::new(),
1367 None,
1368 )
1369 .expect("empty proposal should compose")
1370 }
1371
1372 #[test]
1373 fn decoded_future_contract_version_fails_typed() {
1374 let proposal = empty_proposal_with(Vec::new());
1375 let mut bytes = crate::encode_schema_proposal(&proposal).expect("proposal should encode");
1376 bytes[5..7].copy_from_slice(&2_u16.to_be_bytes());
1377
1378 assert_eq!(
1379 decode_schema_proposal(&bytes),
1380 Err(SchemaContractError::UnsupportedVersion {
1381 found: 2,
1382 supported: 1,
1383 }),
1384 );
1385 }
1386
1387 #[test]
1388 fn decoded_unknown_capability_fails_typed() {
1389 let proposal = empty_proposal_with(vec![SchemaCapability::EXACT_COMPOSITE_TYPES]);
1390 let mut bytes = crate::encode_schema_proposal(&proposal).expect("proposal should encode");
1391 bytes[11..13].copy_from_slice(&u16::MAX.to_be_bytes());
1392
1393 assert_eq!(
1394 decode_schema_proposal(&bytes),
1395 Err(SchemaContractError::UnsupportedCapability),
1396 );
1397 }
1398
1399 #[test]
1400 fn targeted_numeric_operations_require_exact_target_literal_admission() {
1401 let decimal = |value| {
1402 crate::ScalarLiteral::Decimal(
1403 crate::Decimal::from_str(value).expect("decimal fixture should parse"),
1404 )
1405 };
1406 assert!(operation_matches_target(
1407 &SourceRuleOperation::MultipleOf {
1408 divisor: decimal("0.25"),
1409 },
1410 RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1411 ));
1412 assert!(!operation_matches_target(
1413 &SourceRuleOperation::MultipleOf {
1414 divisor: decimal("0.251"),
1415 },
1416 RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
1417 ));
1418 assert!(!operation_matches_target(
1419 &SourceRuleOperation::MultipleOf {
1420 divisor: crate::ScalarLiteral::Float64(
1421 crate::Float64::try_new(2.0).expect("finite float"),
1422 ),
1423 },
1424 RuleTargetShape::Scalar(ScalarType::Float64),
1425 ));
1426 assert!(operation_matches_target(
1427 &SourceRuleOperation::NumericMaximumInclusive {
1428 value: crate::ScalarLiteral::Float64(
1429 crate::Float64::try_new(2.0).expect("finite float"),
1430 ),
1431 },
1432 RuleTargetShape::Scalar(ScalarType::Float64),
1433 ));
1434 }
1435}