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