1use std::collections::{BTreeMap, BTreeSet};
4
5use candid::CandidType;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use crate::{
10 ConstraintSourceKey, EntityFragment, EntitySourceKey, FieldFragment, FieldSourceKey, FieldType,
11 IndexSourceKey, MAX_SCHEMA_ASSIGNMENTS, MAX_SCHEMA_CAPABILITIES, MAX_SCHEMA_PROPOSAL_FRAGMENTS,
12 MAX_SCHEMA_REMOVALS, NamedTypeFragment, RelationSourceKey, ScalarLiteral, SchemaContractError,
13 SchemaFragment, SchemaProposalDigest, SchemaSubmissionKey, SourceCheckExpr,
14 SourceCheckInstruction, TargetDatabaseIdentity, TargetStoreIdentity, TypeSourceKey, check_len,
15 encode_schema_fragment, encode_schema_proposal,
16};
17
18#[derive(
20 CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
21)]
22#[repr(transparent)]
23#[serde(transparent)]
24pub struct ProposalContractVersion(u16);
25
26impl ProposalContractVersion {
27 pub const CURRENT: Self = Self(1);
29
30 #[must_use]
32 pub const fn from_raw(value: u16) -> Self {
33 Self(value)
34 }
35
36 #[must_use]
38 pub const fn get(self) -> u16 {
39 self.0
40 }
41}
42
43#[derive(
45 CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
46)]
47#[repr(transparent)]
48#[serde(transparent)]
49pub struct SchemaCapability(u16);
50
51impl SchemaCapability {
52 pub const EXACT_COMPOSITE_TYPES: Self = Self(1);
54 pub const ACCEPTED_CHECKS: Self = Self(2);
56 pub const SECONDARY_INDEXES: Self = Self(3);
58 pub const RESTRICTIVE_RELATIONS: Self = Self(4);
60 pub const INSERT_DEFAULTS: Self = Self(5);
62 pub const GENERATED_VALUES: Self = Self(6);
64 pub const MANAGED_TIMESTAMPS: Self = Self(7);
66
67 #[must_use]
69 pub const fn from_raw(value: u16) -> Self {
70 Self(value)
71 }
72
73 #[must_use]
75 pub const fn get(self) -> u16 {
76 self.0
77 }
78
79 const fn is_supported(self) -> bool {
80 matches!(self.0, 1..=7)
81 }
82}
83
84#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86pub enum ExpectedAcceptedHead {
87 Empty,
89 Exact {
91 revision: u64,
93 fingerprint: crate::ExpectedSchemaFingerprint,
95 },
96}
97
98impl ExpectedAcceptedHead {
99 const fn validate(&self) -> Result<(), SchemaContractError> {
100 match self {
101 Self::Exact { revision: 0, .. } => Err(SchemaContractError::InvalidReferenceList),
102 Self::Empty | Self::Exact { .. } => Ok(()),
103 }
104 }
105}
106
107#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub struct EntityStoreAssignment {
110 entity: EntitySourceKey,
111 store: TargetStoreIdentity,
112}
113
114impl EntityStoreAssignment {
115 #[must_use]
117 pub const fn new(entity: EntitySourceKey, store: TargetStoreIdentity) -> Self {
118 Self { entity, store }
119 }
120
121 #[must_use]
123 pub const fn entity(&self) -> &EntitySourceKey {
124 &self.entity
125 }
126
127 #[must_use]
129 pub const fn store(&self) -> TargetStoreIdentity {
130 self.store
131 }
132}
133
134#[derive(
136 CandidType, Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
137)]
138pub enum SchemaRemoval {
139 Entity(EntitySourceKey),
141 Field {
143 entity: EntitySourceKey,
145 field: FieldSourceKey,
147 },
148 Type(TypeSourceKey),
150 Constraint {
152 entity: EntitySourceKey,
154 constraint: ConstraintSourceKey,
156 },
157 Index {
159 entity: EntitySourceKey,
161 index: IndexSourceKey,
163 },
164 Relation {
166 entity: EntitySourceKey,
168 relation: RelationSourceKey,
170 },
171}
172
173#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
175pub struct SchemaProposal {
176 version: ProposalContractVersion,
177 capabilities: Vec<SchemaCapability>,
178 target_database: TargetDatabaseIdentity,
179 submission_key: SchemaSubmissionKey,
180 expected_head: ExpectedAcceptedHead,
181 fragments: Vec<SchemaFragment>,
182 assignments: Vec<EntityStoreAssignment>,
183 removals: Vec<SchemaRemoval>,
184}
185
186impl SchemaProposal {
187 #[expect(
198 clippy::too_many_lines,
199 reason = "composition validates and canonicalizes one atomic public envelope"
200 )]
201 pub fn try_compose(
202 mut capabilities: Vec<SchemaCapability>,
203 target_database: TargetDatabaseIdentity,
204 submission_key: SchemaSubmissionKey,
205 expected_head: ExpectedAcceptedHead,
206 mut fragments: Vec<SchemaFragment>,
207 mut assignments: Vec<EntityStoreAssignment>,
208 mut removals: Vec<SchemaRemoval>,
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 capabilities.sort_unstable();
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 for fragment in &fragments {
236 fragment.validate()?;
237 }
238 let mut keyed_fragments = fragments
239 .into_iter()
240 .map(|fragment| encode_schema_fragment(&fragment).map(|bytes| (bytes, fragment)))
241 .collect::<Result<Vec<_>, _>>()?;
242 keyed_fragments.sort_by(|left, right| left.0.cmp(&right.0));
243 fragments = keyed_fragments
244 .into_iter()
245 .map(|(_, fragment)| fragment)
246 .collect();
247 assignments.sort_by(|left, right| left.entity.cmp(&right.entity));
248 ensure_no_adjacent_duplicates_by(&assignments, |assignment| &assignment.entity)?;
249 removals.sort();
250 ensure_no_adjacent_duplicates(&removals)?;
251
252 let mut entity_definitions = BTreeMap::new();
253 let mut type_definitions = BTreeMap::new();
254 let mut field_definitions = BTreeSet::new();
255 let mut constraint_definitions = BTreeSet::new();
256 let mut index_definitions = BTreeSet::new();
257 let mut relation_definitions = BTreeSet::new();
258 let mut entity_names = BTreeSet::new();
259 let mut type_names = BTreeSet::new();
260 for fragment in &fragments {
261 for entity in fragment.entities() {
262 if entity_definitions
263 .insert(entity.source_key().clone(), entity)
264 .is_some()
265 {
266 return Err(SchemaContractError::DuplicateSourceKey);
267 }
268 if !entity_names.insert(entity.name()) {
269 return Err(SchemaContractError::DuplicateEditableName);
270 }
271 for field in entity.fields() {
272 field_definitions
273 .insert((entity.source_key().clone(), field.source_key().clone()));
274 }
275 for constraint in entity.constraints() {
276 constraint_definitions
277 .insert((entity.source_key().clone(), constraint.source_key().clone()));
278 }
279 for index in entity.indexes() {
280 index_definitions
281 .insert((entity.source_key().clone(), index.source_key().clone()));
282 }
283 for relation in entity.relations() {
284 relation_definitions
285 .insert((entity.source_key().clone(), relation.source_key().clone()));
286 }
287 }
288 for r#type in fragment.types() {
289 if type_definitions
290 .insert(r#type.source_key().clone(), r#type)
291 .is_some()
292 {
293 return Err(SchemaContractError::DuplicateSourceKey);
294 }
295 if !type_names.insert(r#type.name()) {
296 return Err(SchemaContractError::DuplicateEditableName);
297 }
298 }
299 }
300 for assignment in &assignments {
301 if !entity_definitions.contains_key(assignment.entity()) {
302 return Err(SchemaContractError::InvalidReferenceList);
303 }
304 }
305 if assignments.len() != entity_definitions.len() {
306 return Err(SchemaContractError::MissingEntityStoreAssignment);
307 }
308 for removal in &removals {
309 let collides = match removal {
310 SchemaRemoval::Entity(entity) => entity_definitions.contains_key(entity),
311 SchemaRemoval::Field { entity, field } => {
312 field_definitions.contains(&(entity.clone(), field.clone()))
313 }
314 SchemaRemoval::Type(r#type) => type_definitions.contains_key(r#type),
315 SchemaRemoval::Constraint { entity, constraint } => {
316 constraint_definitions.contains(&(entity.clone(), constraint.clone()))
317 }
318 SchemaRemoval::Index { entity, index } => {
319 index_definitions.contains(&(entity.clone(), index.clone()))
320 }
321 SchemaRemoval::Relation { entity, relation } => {
322 relation_definitions.contains(&(entity.clone(), relation.clone()))
323 }
324 };
325 if collides {
326 return Err(SchemaContractError::DefinitionRemovalConflict);
327 }
328 }
329 validate_proposal_closure(
330 &expected_head,
331 &entity_definitions,
332 &type_definitions,
333 &removals,
334 )?;
335
336 Ok(Self {
337 version: ProposalContractVersion::CURRENT,
338 capabilities,
339 target_database,
340 submission_key,
341 expected_head,
342 fragments,
343 assignments,
344 removals,
345 })
346 }
347
348 #[must_use]
350 pub const fn version(&self) -> ProposalContractVersion {
351 self.version
352 }
353
354 #[must_use]
356 pub fn capabilities(&self) -> &[SchemaCapability] {
357 &self.capabilities
358 }
359
360 #[must_use]
362 pub const fn target_database(&self) -> TargetDatabaseIdentity {
363 self.target_database
364 }
365
366 #[must_use]
368 pub const fn submission_key(&self) -> &SchemaSubmissionKey {
369 &self.submission_key
370 }
371
372 #[must_use]
374 pub const fn expected_head(&self) -> &ExpectedAcceptedHead {
375 &self.expected_head
376 }
377
378 #[must_use]
380 pub fn fragments(&self) -> &[SchemaFragment] {
381 &self.fragments
382 }
383
384 #[must_use]
386 pub fn assignments(&self) -> &[EntityStoreAssignment] {
387 &self.assignments
388 }
389
390 #[must_use]
392 pub fn removals(&self) -> &[SchemaRemoval] {
393 &self.removals
394 }
395
396 pub fn digest(&self) -> Result<SchemaProposalDigest, SchemaContractError> {
403 let bytes = encode_schema_proposal(self)?;
404 let digest: [u8; 32] = Sha256::digest(bytes).into();
405 Ok(SchemaProposalDigest::from_bytes(digest))
406 }
407
408 pub(crate) fn validate_current(&self) -> Result<(), SchemaContractError> {
409 if self.version != ProposalContractVersion::CURRENT {
410 return Err(SchemaContractError::UnsupportedVersion {
411 found: self.version.get(),
412 supported: ProposalContractVersion::CURRENT.get(),
413 });
414 }
415 let rebuilt = Self::try_compose(
416 self.capabilities.clone(),
417 self.target_database,
418 self.submission_key.clone(),
419 self.expected_head.clone(),
420 self.fragments.clone(),
421 self.assignments.clone(),
422 self.removals.clone(),
423 )?;
424 if rebuilt != *self {
425 return Err(SchemaContractError::NonCanonical);
426 }
427 Ok(())
428 }
429}
430
431#[derive(Default)]
432struct ProposalReferences {
433 types: BTreeSet<TypeSourceKey>,
434 relation_entities: BTreeSet<EntitySourceKey>,
435 relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
436}
437
438fn validate_proposal_closure(
439 expected_head: &ExpectedAcceptedHead,
440 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
441 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
442 removals: &[SchemaRemoval],
443) -> Result<(), SchemaContractError> {
444 let mut references = ProposalReferences::default();
445 for entity in entities.values() {
446 collect_entity_references(entity, types, &mut references)?;
447 validate_local_relation_targets(entity, entities)?;
448 }
449 for r#type in types.values() {
450 collect_named_type_references(r#type, &mut references);
451 }
452 for removal in removals {
453 let removes_reference = match removal {
454 SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
455 SchemaRemoval::Field { entity, field } => references
456 .relation_fields
457 .contains(&(entity.clone(), field.clone())),
458 SchemaRemoval::Type(r#type) => references.types.contains(r#type),
459 SchemaRemoval::Constraint { .. }
460 | SchemaRemoval::Index { .. }
461 | SchemaRemoval::Relation { .. } => false,
462 };
463 if removes_reference {
464 return Err(SchemaContractError::RemovedReference);
465 }
466 }
467 if matches!(expected_head, ExpectedAcceptedHead::Empty)
468 && (references
469 .types
470 .iter()
471 .any(|reference| !types.contains_key(reference))
472 || references
473 .relation_entities
474 .iter()
475 .any(|reference| !entities.contains_key(reference)))
476 {
477 return Err(SchemaContractError::InvalidLocalReference);
478 }
479 Ok(())
480}
481
482fn collect_entity_references(
483 entity: &EntityFragment,
484 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
485 references: &mut ProposalReferences,
486) -> Result<(), SchemaContractError> {
487 for field in entity.fields() {
488 collect_field_references(field, types, references)?;
489 }
490 for relation in entity.relations() {
491 references
492 .relation_entities
493 .insert(relation.target_entity().clone());
494 references.relation_fields.extend(
495 relation
496 .target_fields()
497 .iter()
498 .cloned()
499 .map(|field| (relation.target_entity().clone(), field)),
500 );
501 }
502 for index in entity.indexes() {
503 if let Some(predicate) = index.predicate() {
504 collect_expression_enum_references(predicate, types, references)?;
505 }
506 }
507 for constraint in entity.constraints() {
508 collect_expression_enum_references(constraint.expression(), types, references)?;
509 }
510 Ok(())
511}
512
513fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
514 match r#type {
515 NamedTypeFragment::Record(record) => {
516 for field in record.fields() {
517 collect_field_type_reference(field.field_type(), references);
518 }
519 }
520 NamedTypeFragment::Enum(r#enum) => {
521 for variant in r#enum.variants() {
522 if let Some(payload) = variant.payload() {
523 collect_field_type_reference(payload, references);
524 }
525 }
526 }
527 NamedTypeFragment::Newtype { inner, .. }
528 | NamedTypeFragment::List { item: inner, .. }
529 | NamedTypeFragment::Set { item: inner, .. } => {
530 collect_field_type_reference(inner, references);
531 }
532 NamedTypeFragment::Map { key, value, .. } => {
533 collect_field_type_reference(key, references);
534 collect_field_type_reference(value, references);
535 }
536 NamedTypeFragment::Tuple { members, .. } => {
537 for member in members {
538 collect_field_type_reference(member.field_type(), references);
539 }
540 }
541 }
542}
543
544fn collect_field_references(
545 field: &FieldFragment,
546 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
547 references: &mut ProposalReferences,
548) -> Result<(), SchemaContractError> {
549 collect_field_type_reference(field.field_type(), references);
550 if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
551 field.insert_policy()
552 {
553 let FieldType::Named(field_type) = field.field_type() else {
554 return Err(SchemaContractError::LiteralTypeMismatch);
555 };
556 if field_type != enum_type {
557 return Err(SchemaContractError::LiteralTypeMismatch);
558 }
559 collect_enum_literal_reference(enum_type, variant, types, references)?;
560 }
561 Ok(())
562}
563
564fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
565 match field_type {
566 FieldType::List(item) => collect_field_type_reference(item, references),
567 FieldType::Named(reference) => {
568 references.types.insert(reference.clone());
569 }
570 FieldType::Scalar(_) => {}
571 }
572}
573
574fn collect_expression_enum_references(
575 expression: &SourceCheckExpr,
576 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
577 references: &mut ProposalReferences,
578) -> Result<(), SchemaContractError> {
579 for instruction in expression.instructions() {
580 if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
581 instruction
582 {
583 collect_enum_literal_reference(enum_type, variant, types, references)?;
584 }
585 }
586 Ok(())
587}
588
589fn collect_enum_literal_reference(
590 enum_type: &TypeSourceKey,
591 variant: &TypeSourceKey,
592 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
593 references: &mut ProposalReferences,
594) -> Result<(), SchemaContractError> {
595 references.types.insert(enum_type.clone());
596 let Some(local) = types.get(enum_type) else {
597 return Ok(());
598 };
599 let NamedTypeFragment::Enum(local) = local else {
600 return Err(SchemaContractError::InvalidEnumLiteral);
601 };
602 if local
603 .variants()
604 .iter()
605 .all(|candidate| candidate.source_key() != variant)
606 {
607 return Err(SchemaContractError::InvalidEnumLiteral);
608 }
609 Ok(())
610}
611
612fn validate_local_relation_targets(
613 source: &EntityFragment,
614 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
615) -> Result<(), SchemaContractError> {
616 for relation in source.relations() {
617 let Some(target) = entities.get(relation.target_entity()) else {
618 continue;
619 };
620 for (source_key, target_key) in relation.local_fields().iter().zip(relation.target_fields())
621 {
622 let source_field = source
623 .fields()
624 .iter()
625 .find(|field| field.source_key() == source_key)
626 .ok_or(SchemaContractError::InvalidLocalReference)?;
627 let target_field = target
628 .fields()
629 .iter()
630 .find(|field| field.source_key() == target_key)
631 .ok_or(SchemaContractError::InvalidLocalReference)?;
632 let source_type = match source_field.field_type() {
633 FieldType::List(item) => item.as_ref(),
634 field_type => field_type,
635 };
636 if source_type != target_field.field_type() {
637 return Err(SchemaContractError::RelationTypeMismatch);
638 }
639 }
640 }
641 Ok(())
642}
643
644fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
645where
646 T: Eq,
647{
648 if values.windows(2).any(|pair| pair[0] == pair[1]) {
649 return Err(SchemaContractError::DuplicateSourceKey);
650 }
651 Ok(())
652}
653
654fn ensure_no_adjacent_duplicates_by<T, K>(
655 values: &[T],
656 key: impl Fn(&T) -> &K,
657) -> Result<(), SchemaContractError>
658where
659 K: Eq,
660{
661 if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
662 return Err(SchemaContractError::DuplicateSourceKey);
663 }
664 Ok(())
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use crate::decode_schema_proposal;
671
672 fn empty_proposal() -> SchemaProposal {
673 SchemaProposal::try_compose(
674 Vec::new(),
675 TargetDatabaseIdentity::from_bytes([1; 32]),
676 SchemaSubmissionKey::try_new("proposal-version-test")
677 .expect("submission key should admit"),
678 ExpectedAcceptedHead::Empty,
679 Vec::new(),
680 Vec::new(),
681 Vec::new(),
682 )
683 .expect("empty proposal should compose")
684 }
685
686 #[test]
687 fn decoded_future_contract_version_fails_typed() {
688 let mut proposal = empty_proposal();
689 proposal.version = ProposalContractVersion::from_raw(2);
690 let bytes = candid::encode_one(proposal).expect("raw future proposal should encode");
691
692 assert_eq!(
693 decode_schema_proposal(&bytes),
694 Err(SchemaContractError::UnsupportedVersion {
695 found: 2,
696 supported: 1,
697 }),
698 );
699 }
700
701 #[test]
702 fn decoded_unknown_capability_fails_typed() {
703 let mut proposal = empty_proposal();
704 proposal.capabilities = vec![SchemaCapability::from_raw(u16::MAX)];
705 let bytes = candid::encode_one(proposal).expect("raw proposal should encode");
706
707 assert_eq!(
708 decode_schema_proposal(&bytes),
709 Err(SchemaContractError::UnsupportedCapability),
710 );
711 }
712}