Skip to main content

icydb_schema/
proposal.rs

1//! Canonical database-scoped proposal envelope.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use candid::CandidType;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use crate::{
10    ConstraintFragmentKind, ConstraintSourceKey, EntityFragment, EntitySourceKey, FieldFragment,
11    FieldSourceKey, FieldType, IndexSourceKey, MAX_SCHEMA_ASSIGNMENTS, MAX_SCHEMA_CAPABILITIES,
12    MAX_SCHEMA_PROPOSAL_FRAGMENTS, MAX_SCHEMA_REMOVALS, NamedTypeFragment, RelationSourceKey,
13    ScalarLiteral, ScalarType, SchemaContractError, SchemaFragment, SchemaProposalDigest,
14    SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction, SourceRuleOperation,
15    TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment, TypeSourceKey, check_len,
16    encode_schema_fragment, encode_schema_proposal,
17};
18
19/// Sole maintained proposal contract version.
20#[derive(
21    CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
22)]
23#[repr(transparent)]
24#[serde(transparent)]
25pub struct ProposalContractVersion(u16);
26
27impl ProposalContractVersion {
28    /// Current pre-1.0 hard-cut proposal contract version.
29    pub const CURRENT: Self = Self(1);
30
31    /// Construct a version token for decoding and incompatibility tests.
32    #[must_use]
33    pub const fn from_raw(value: u16) -> Self {
34        Self(value)
35    }
36
37    /// Return the raw version value.
38    #[must_use]
39    pub const fn get(self) -> u16 {
40        self.0
41    }
42}
43
44/// Feature required by one proposal.
45#[derive(
46    CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
47)]
48#[repr(transparent)]
49#[serde(transparent)]
50pub struct SchemaCapability(u16);
51
52impl SchemaCapability {
53    /// Exact composite record and enum contracts.
54    pub const EXACT_COMPOSITE_TYPES: Self = Self(1);
55    /// Accepted row-local constraints.
56    pub const ACCEPTED_CHECKS: Self = Self(2);
57    /// Secondary indexes.
58    pub const SECONDARY_INDEXES: Self = Self(3);
59    /// Restrictive relations.
60    pub const RESTRICTIVE_RELATIONS: Self = Self(4);
61    /// Accepted database defaults.
62    pub const INSERT_DEFAULTS: Self = Self(5);
63    /// Generated values.
64    pub const GENERATED_VALUES: Self = Self(6);
65    /// Managed created/updated timestamps.
66    pub const MANAGED_TIMESTAMPS: Self = Self(7);
67
68    /// Construct a raw token for incompatibility testing and transport.
69    #[must_use]
70    pub const fn from_raw(value: u16) -> Self {
71        Self(value)
72    }
73
74    /// Return the raw capability number.
75    #[must_use]
76    pub const fn get(self) -> u16 {
77        self.0
78    }
79
80    const fn is_supported(self) -> bool {
81        matches!(self.0, 1..=7)
82    }
83}
84
85/// Expected accepted-schema head used for optimistic application.
86#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
87pub enum ExpectedAcceptedHead {
88    /// The target database has no accepted schema.
89    Empty,
90    /// The target must match this exact accepted head.
91    Exact {
92        /// Nonzero accepted-schema revision.
93        revision: u64,
94        /// Opaque accepted-schema fingerprint.
95        fingerprint: crate::ExpectedSchemaFingerprint,
96    },
97}
98
99impl ExpectedAcceptedHead {
100    const fn validate(&self) -> Result<(), SchemaContractError> {
101        match self {
102            Self::Exact { revision: 0, .. } => Err(SchemaContractError::InvalidReferenceList),
103            Self::Empty | Self::Exact { .. } => Ok(()),
104        }
105    }
106}
107
108/// Explicit entity-to-store routing in the target database.
109#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
110pub struct EntityStoreAssignment {
111    entity: EntitySourceKey,
112    store: TargetStoreIdentity,
113}
114
115impl EntityStoreAssignment {
116    /// Construct one opaque routing assignment.
117    #[must_use]
118    pub const fn new(entity: EntitySourceKey, store: TargetStoreIdentity) -> Self {
119        Self { entity, store }
120    }
121
122    /// Borrow the routed entity source key.
123    #[must_use]
124    pub const fn entity(&self) -> &EntitySourceKey {
125        &self.entity
126    }
127
128    /// Return the opaque target-store identity.
129    #[must_use]
130    pub const fn store(&self) -> TargetStoreIdentity {
131        self.store
132    }
133}
134
135/// Explicit hard-cut removal operation.
136#[derive(
137    CandidType, Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
138)]
139pub enum SchemaRemoval {
140    /// Remove an entity.
141    Entity(EntitySourceKey),
142    /// Remove one field from an entity.
143    Field {
144        /// Owning entity.
145        entity: EntitySourceKey,
146        /// Field identity.
147        field: FieldSourceKey,
148    },
149    /// Remove a named type.
150    Type(TypeSourceKey),
151    /// Remove one accepted constraint.
152    Constraint {
153        /// Owning entity.
154        entity: EntitySourceKey,
155        /// Constraint identity.
156        constraint: ConstraintSourceKey,
157    },
158    /// Remove one index.
159    Index {
160        /// Owning entity.
161        entity: EntitySourceKey,
162        /// Index identity.
163        index: IndexSourceKey,
164    },
165    /// Remove one relation.
166    Relation {
167        /// Owning entity.
168        entity: EntitySourceKey,
169        /// Relation identity.
170        relation: RelationSourceKey,
171    },
172}
173
174/// Canonical current-form database-scoped proposal.
175#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
176pub struct SchemaProposal {
177    version: ProposalContractVersion,
178    capabilities: Vec<SchemaCapability>,
179    target_database: TargetDatabaseIdentity,
180    submission_key: SchemaSubmissionKey,
181    expected_head: ExpectedAcceptedHead,
182    fragments: Vec<SchemaFragment>,
183    assignments: Vec<EntityStoreAssignment>,
184    removals: Vec<SchemaRemoval>,
185}
186
187impl SchemaProposal {
188    /// Compose the public transport form from already selected fragments.
189    ///
190    /// This validates contract-local closure only. IcyDB still treats the
191    /// result as untrusted and resolves target ownership, accepted references,
192    /// capabilities, and catalog-native mutation semantics during application.
193    ///
194    /// # Errors
195    ///
196    /// Returns a typed contract error for bounds, duplicate definitions,
197    /// ambiguous routing, removal conflicts, or malformed nested data.
198    #[expect(
199        clippy::too_many_lines,
200        reason = "composition validates and canonicalizes one atomic public envelope"
201    )]
202    pub fn try_compose(
203        mut capabilities: Vec<SchemaCapability>,
204        target_database: TargetDatabaseIdentity,
205        submission_key: SchemaSubmissionKey,
206        expected_head: ExpectedAcceptedHead,
207        mut fragments: Vec<SchemaFragment>,
208        mut assignments: Vec<EntityStoreAssignment>,
209        mut removals: Vec<SchemaRemoval>,
210    ) -> Result<Self, SchemaContractError> {
211        check_len(
212            "proposal capabilities",
213            capabilities.len(),
214            MAX_SCHEMA_CAPABILITIES,
215        )?;
216        check_len(
217            "proposal fragments",
218            fragments.len(),
219            MAX_SCHEMA_PROPOSAL_FRAGMENTS,
220        )?;
221        check_len(
222            "proposal assignments",
223            assignments.len(),
224            MAX_SCHEMA_ASSIGNMENTS,
225        )?;
226        check_len("proposal removals", removals.len(), MAX_SCHEMA_REMOVALS)?;
227        expected_head.validate()?;
228        capabilities.sort_unstable();
229        ensure_no_adjacent_duplicates(&capabilities)?;
230        if capabilities
231            .iter()
232            .any(|capability| !capability.is_supported())
233        {
234            return Err(SchemaContractError::UnsupportedCapability);
235        }
236        for fragment in &fragments {
237            fragment.validate()?;
238        }
239        let mut keyed_fragments = fragments
240            .into_iter()
241            .map(|fragment| encode_schema_fragment(&fragment).map(|bytes| (bytes, fragment)))
242            .collect::<Result<Vec<_>, _>>()?;
243        keyed_fragments.sort_by(|left, right| left.0.cmp(&right.0));
244        fragments = keyed_fragments
245            .into_iter()
246            .map(|(_, fragment)| fragment)
247            .collect();
248        assignments.sort_by(|left, right| left.entity.cmp(&right.entity));
249        ensure_no_adjacent_duplicates_by(&assignments, |assignment| &assignment.entity)?;
250        removals.sort();
251        ensure_no_adjacent_duplicates(&removals)?;
252
253        let mut entity_definitions = BTreeMap::new();
254        let mut type_definitions = BTreeMap::new();
255        let mut field_definitions = BTreeSet::new();
256        let mut constraint_definitions = BTreeSet::new();
257        let mut index_definitions = BTreeSet::new();
258        let mut relation_definitions = BTreeSet::new();
259        let mut entity_names = BTreeSet::new();
260        let mut type_names = BTreeSet::new();
261        for fragment in &fragments {
262            for entity in fragment.entities() {
263                if entity_definitions
264                    .insert(entity.source_key().clone(), entity)
265                    .is_some()
266                {
267                    return Err(SchemaContractError::DuplicateSourceKey);
268                }
269                if !entity_names.insert(entity.name()) {
270                    return Err(SchemaContractError::DuplicateName);
271                }
272                for field in entity.fields() {
273                    field_definitions
274                        .insert((entity.source_key().clone(), field.source_key().clone()));
275                }
276                for constraint in entity.constraints() {
277                    constraint_definitions
278                        .insert((entity.source_key().clone(), constraint.source_key().clone()));
279                }
280                for index in entity.indexes() {
281                    index_definitions
282                        .insert((entity.source_key().clone(), index.source_key().clone()));
283                }
284                for relation in entity.relations() {
285                    relation_definitions
286                        .insert((entity.source_key().clone(), relation.source_key().clone()));
287                }
288            }
289            for r#type in fragment.types() {
290                if type_definitions
291                    .insert(r#type.source_key().clone(), r#type)
292                    .is_some()
293                {
294                    return Err(SchemaContractError::DuplicateSourceKey);
295                }
296                if !type_names.insert(r#type.name()) {
297                    return Err(SchemaContractError::DuplicateName);
298                }
299            }
300        }
301        for assignment in &assignments {
302            if !entity_definitions.contains_key(assignment.entity()) {
303                return Err(SchemaContractError::InvalidReferenceList);
304            }
305        }
306        if assignments.len() != entity_definitions.len() {
307            return Err(SchemaContractError::MissingEntityStoreAssignment);
308        }
309        for removal in &removals {
310            let collides = match removal {
311                SchemaRemoval::Entity(entity) => entity_definitions.contains_key(entity),
312                SchemaRemoval::Field { entity, field } => {
313                    field_definitions.contains(&(entity.clone(), field.clone()))
314                }
315                SchemaRemoval::Type(r#type) => type_definitions.contains_key(r#type),
316                SchemaRemoval::Constraint { entity, constraint } => {
317                    constraint_definitions.contains(&(entity.clone(), constraint.clone()))
318                }
319                SchemaRemoval::Index { entity, index } => {
320                    index_definitions.contains(&(entity.clone(), index.clone()))
321                }
322                SchemaRemoval::Relation { entity, relation } => {
323                    relation_definitions.contains(&(entity.clone(), relation.clone()))
324                }
325            };
326            if collides {
327                return Err(SchemaContractError::DefinitionRemovalConflict);
328            }
329        }
330        validate_proposal_closure(
331            &expected_head,
332            &entity_definitions,
333            &type_definitions,
334            &removals,
335        )?;
336
337        Ok(Self {
338            version: ProposalContractVersion::CURRENT,
339            capabilities,
340            target_database,
341            submission_key,
342            expected_head,
343            fragments,
344            assignments,
345            removals,
346        })
347    }
348
349    /// Return the contract version.
350    #[must_use]
351    pub const fn version(&self) -> ProposalContractVersion {
352        self.version
353    }
354
355    /// Borrow required capabilities in canonical order.
356    #[must_use]
357    pub fn capabilities(&self) -> &[SchemaCapability] {
358        &self.capabilities
359    }
360
361    /// Return the target database identity.
362    #[must_use]
363    pub const fn target_database(&self) -> TargetDatabaseIdentity {
364        self.target_database
365    }
366
367    /// Borrow the submission key.
368    #[must_use]
369    pub const fn submission_key(&self) -> &SchemaSubmissionKey {
370        &self.submission_key
371    }
372
373    /// Borrow the optimistic accepted-head condition.
374    #[must_use]
375    pub const fn expected_head(&self) -> &ExpectedAcceptedHead {
376        &self.expected_head
377    }
378
379    /// Borrow reusable fragments.
380    #[must_use]
381    pub fn fragments(&self) -> &[SchemaFragment] {
382        &self.fragments
383    }
384
385    /// Borrow canonical entity-to-store assignments.
386    #[must_use]
387    pub fn assignments(&self) -> &[EntityStoreAssignment] {
388        &self.assignments
389    }
390
391    /// Borrow explicit removals.
392    #[must_use]
393    pub fn removals(&self) -> &[SchemaRemoval] {
394        &self.removals
395    }
396
397    /// Compute the canonical proposal digest.
398    ///
399    /// # Errors
400    ///
401    /// Returns a typed encoding error if the proposal no longer satisfies the
402    /// current bounded contract.
403    pub fn digest(&self) -> Result<SchemaProposalDigest, SchemaContractError> {
404        let bytes = encode_schema_proposal(self)?;
405        let digest: [u8; 32] = Sha256::digest(bytes).into();
406        Ok(SchemaProposalDigest::from_bytes(digest))
407    }
408
409    pub(crate) fn validate_current(&self) -> Result<(), SchemaContractError> {
410        if self.version != ProposalContractVersion::CURRENT {
411            return Err(SchemaContractError::UnsupportedVersion {
412                found: self.version.get(),
413                supported: ProposalContractVersion::CURRENT.get(),
414            });
415        }
416        let rebuilt = Self::try_compose(
417            self.capabilities.clone(),
418            self.target_database,
419            self.submission_key.clone(),
420            self.expected_head.clone(),
421            self.fragments.clone(),
422            self.assignments.clone(),
423            self.removals.clone(),
424        )?;
425        if rebuilt != *self {
426            return Err(SchemaContractError::NonCanonical);
427        }
428        Ok(())
429    }
430}
431
432#[derive(Default)]
433struct ProposalReferences {
434    types: BTreeSet<TypeSourceKey>,
435    relation_entities: BTreeSet<EntitySourceKey>,
436    relation_fields: BTreeSet<(EntitySourceKey, FieldSourceKey)>,
437}
438
439fn validate_proposal_closure(
440    expected_head: &ExpectedAcceptedHead,
441    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
442    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
443    removals: &[SchemaRemoval],
444) -> Result<(), SchemaContractError> {
445    let mut references = ProposalReferences::default();
446    for entity in entities.values() {
447        collect_entity_references(entity, types, &mut references)?;
448        validate_local_relation_targets(entity, entities)?;
449    }
450    for r#type in types.values() {
451        collect_named_type_references(r#type, &mut references);
452    }
453    for removal in removals {
454        let removes_reference = match removal {
455            SchemaRemoval::Entity(entity) => references.relation_entities.contains(entity),
456            SchemaRemoval::Field { entity, field } => references
457                .relation_fields
458                .contains(&(entity.clone(), field.clone())),
459            SchemaRemoval::Type(r#type) => references.types.contains(r#type),
460            SchemaRemoval::Constraint { .. }
461            | SchemaRemoval::Index { .. }
462            | SchemaRemoval::Relation { .. } => false,
463        };
464        if removes_reference {
465            return Err(SchemaContractError::RemovedReference);
466        }
467    }
468    if matches!(expected_head, ExpectedAcceptedHead::Empty)
469        && (references
470            .types
471            .iter()
472            .any(|reference| !types.contains_key(reference))
473            || references
474                .relation_entities
475                .iter()
476                .any(|reference| !entities.contains_key(reference)))
477    {
478        return Err(SchemaContractError::InvalidLocalReference);
479    }
480    Ok(())
481}
482
483fn collect_entity_references(
484    entity: &EntityFragment,
485    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
486    references: &mut ProposalReferences,
487) -> Result<(), SchemaContractError> {
488    for field in entity.fields() {
489        collect_field_references(field, types, references)?;
490    }
491    for relation in entity.relations() {
492        references
493            .relation_entities
494            .insert(relation.target_entity().clone());
495        references.relation_fields.extend(
496            relation
497                .target_fields()
498                .iter()
499                .cloned()
500                .map(|field| (relation.target_entity().clone(), field)),
501        );
502    }
503    for index in entity.indexes() {
504        if let Some(predicate) = index.predicate() {
505            collect_expression_enum_references(predicate, types, references)?;
506        }
507    }
508    for constraint in entity.constraints() {
509        match constraint.kind() {
510            ConstraintFragmentKind::Check(expression) => {
511                collect_expression_enum_references(expression, types, references)?;
512            }
513            ConstraintFragmentKind::TargetedRule(rule) => {
514                references.types.insert(rule.target_type().clone());
515                validate_targeted_rule(entity, rule, types)?;
516            }
517        }
518    }
519    Ok(())
520}
521
522fn validate_targeted_rule(
523    entity: &EntityFragment,
524    rule: &TargetedRuleFragment,
525    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
526) -> Result<(), SchemaContractError> {
527    let root = entity
528        .fields()
529        .iter()
530        .find(|field| field.source_key() == rule.root())
531        .ok_or(SchemaContractError::InvalidLocalReference)?;
532    if types.contains_key(rule.target_type())
533        && !field_type_reaches_target(root.field_type(), rule.target_type(), types)
534    {
535        return Err(SchemaContractError::InvalidRuleTarget);
536    }
537    let Some(target) = types.get(rule.target_type()) else {
538        return Ok(());
539    };
540    let shape = resolve_rule_target_shape(target, types)?;
541    if operation_matches_target(rule.operation(), shape) {
542        Ok(())
543    } else {
544        Err(SchemaContractError::InvalidRuleTarget)
545    }
546}
547
548fn field_type_reaches_target(
549    root: &FieldType,
550    target: &TypeSourceKey,
551    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
552) -> bool {
553    let mut pending = vec![root];
554    let mut visited = BTreeSet::new();
555    while let Some(field_type) = pending.pop() {
556        match field_type {
557            FieldType::Scalar(_) => {}
558            FieldType::List(item) => pending.push(item),
559            FieldType::Named(source) => {
560                if source == target {
561                    return true;
562                }
563                if !visited.insert(source) {
564                    continue;
565                }
566                if let Some(definition) = types.get(source) {
567                    push_named_type_field_types(definition, &mut pending);
568                }
569            }
570        }
571    }
572    false
573}
574
575fn push_named_type_field_types<'types>(
576    r#type: &'types NamedTypeFragment,
577    pending: &mut Vec<&'types FieldType>,
578) {
579    match r#type {
580        NamedTypeFragment::Record(record) => {
581            pending.extend(
582                record
583                    .fields()
584                    .iter()
585                    .map(crate::RecordFieldFragment::field_type),
586            );
587        }
588        NamedTypeFragment::Enum(r#enum) => {
589            pending.extend(
590                r#enum
591                    .variants()
592                    .iter()
593                    .filter_map(|variant| variant.payload()),
594            );
595        }
596        NamedTypeFragment::Newtype { inner, .. }
597        | NamedTypeFragment::List { item: inner, .. }
598        | NamedTypeFragment::Set { item: inner, .. } => pending.push(inner),
599        NamedTypeFragment::Map { key, value, .. } => {
600            pending.push(key);
601            pending.push(value);
602        }
603        NamedTypeFragment::Tuple { members, .. } => {
604            pending.extend(members.iter().map(crate::TupleElementFragment::field_type));
605        }
606    }
607}
608
609#[derive(Clone, Copy)]
610enum RuleTargetShape {
611    Collection,
612    Scalar(ScalarType),
613}
614
615fn resolve_rule_target_shape(
616    target: &NamedTypeFragment,
617    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
618) -> Result<RuleTargetShape, SchemaContractError> {
619    let mut current = target;
620    let mut visited = BTreeSet::new();
621    loop {
622        if !visited.insert(current.source_key()) {
623            return Err(SchemaContractError::InvalidRuleTarget);
624        }
625        match current {
626            NamedTypeFragment::List { .. }
627            | NamedTypeFragment::Set { .. }
628            | NamedTypeFragment::Map { .. } => return Ok(RuleTargetShape::Collection),
629            NamedTypeFragment::Newtype { inner, .. } => match inner {
630                FieldType::Scalar(scalar) => return Ok(RuleTargetShape::Scalar(*scalar)),
631                FieldType::List(_) => return Ok(RuleTargetShape::Collection),
632                FieldType::Named(source) => {
633                    current = types
634                        .get(source)
635                        .copied()
636                        .ok_or(SchemaContractError::InvalidRuleTarget)?;
637                }
638            },
639            NamedTypeFragment::Record(_)
640            | NamedTypeFragment::Enum(_)
641            | NamedTypeFragment::Tuple { .. } => {
642                return Err(SchemaContractError::InvalidRuleTarget);
643            }
644        }
645    }
646}
647
648fn operation_matches_target(operation: &SourceRuleOperation, shape: RuleTargetShape) -> bool {
649    match (operation, shape) {
650        (
651            SourceRuleOperation::LengthRangeInclusive { .. },
652            RuleTargetShape::Collection
653            | RuleTargetShape::Scalar(ScalarType::Blob { .. } | ScalarType::Text { .. }),
654        ) => true,
655        (
656            SourceRuleOperation::NumericMinimumInclusive { value },
657            RuleTargetShape::Scalar(scalar),
658        ) => numeric_scalar(scalar) && scalar.accepts_literal(value),
659        (
660            SourceRuleOperation::NumericRangeInclusive { min, max },
661            RuleTargetShape::Scalar(scalar),
662        ) => numeric_scalar(scalar) && scalar.accepts_literal(min) && scalar.accepts_literal(max),
663        _ => false,
664    }
665}
666
667const fn numeric_scalar(scalar: ScalarType) -> bool {
668    matches!(
669        scalar,
670        ScalarType::Decimal { .. }
671            | ScalarType::Float32
672            | ScalarType::Float64
673            | ScalarType::Int8
674            | ScalarType::Int16
675            | ScalarType::Int32
676            | ScalarType::Int64
677            | ScalarType::Int128
678            | ScalarType::IntBig { .. }
679            | ScalarType::Nat8
680            | ScalarType::Nat16
681            | ScalarType::Nat32
682            | ScalarType::Nat64
683            | ScalarType::Nat128
684            | ScalarType::NatBig { .. }
685    )
686}
687
688fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
689    match r#type {
690        NamedTypeFragment::Record(record) => {
691            for field in record.fields() {
692                collect_field_type_reference(field.field_type(), references);
693            }
694        }
695        NamedTypeFragment::Enum(r#enum) => {
696            for variant in r#enum.variants() {
697                if let Some(payload) = variant.payload() {
698                    collect_field_type_reference(payload, references);
699                }
700            }
701        }
702        NamedTypeFragment::Newtype { inner, .. }
703        | NamedTypeFragment::List { item: inner, .. }
704        | NamedTypeFragment::Set { item: inner, .. } => {
705            collect_field_type_reference(inner, references);
706        }
707        NamedTypeFragment::Map { key, value, .. } => {
708            collect_field_type_reference(key, references);
709            collect_field_type_reference(value, references);
710        }
711        NamedTypeFragment::Tuple { members, .. } => {
712            for member in members {
713                collect_field_type_reference(member.field_type(), references);
714            }
715        }
716    }
717}
718
719fn collect_field_references(
720    field: &FieldFragment,
721    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
722    references: &mut ProposalReferences,
723) -> Result<(), SchemaContractError> {
724    collect_field_type_reference(field.field_type(), references);
725    if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
726        field.insert_policy()
727    {
728        let FieldType::Named(field_type) = field.field_type() else {
729            return Err(SchemaContractError::LiteralTypeMismatch);
730        };
731        if field_type != enum_type {
732            return Err(SchemaContractError::LiteralTypeMismatch);
733        }
734        collect_enum_literal_reference(enum_type, variant, types, references)?;
735    }
736    Ok(())
737}
738
739fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
740    match field_type {
741        FieldType::List(item) => collect_field_type_reference(item, references),
742        FieldType::Named(reference) => {
743            references.types.insert(reference.clone());
744        }
745        FieldType::Scalar(_) => {}
746    }
747}
748
749fn collect_expression_enum_references(
750    expression: &SourceCheckExpr,
751    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
752    references: &mut ProposalReferences,
753) -> Result<(), SchemaContractError> {
754    for instruction in expression.instructions() {
755        if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
756            instruction
757        {
758            collect_enum_literal_reference(enum_type, variant, types, references)?;
759        }
760    }
761    Ok(())
762}
763
764fn collect_enum_literal_reference(
765    enum_type: &TypeSourceKey,
766    variant: &TypeSourceKey,
767    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
768    references: &mut ProposalReferences,
769) -> Result<(), SchemaContractError> {
770    references.types.insert(enum_type.clone());
771    let Some(local) = types.get(enum_type) else {
772        return Ok(());
773    };
774    let NamedTypeFragment::Enum(local) = local else {
775        return Err(SchemaContractError::InvalidEnumLiteral);
776    };
777    if local
778        .variants()
779        .iter()
780        .all(|candidate| candidate.source_key() != variant)
781    {
782        return Err(SchemaContractError::InvalidEnumLiteral);
783    }
784    Ok(())
785}
786
787fn validate_local_relation_targets(
788    source: &EntityFragment,
789    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
790) -> Result<(), SchemaContractError> {
791    for relation in source.relations() {
792        let Some(target) = entities.get(relation.target_entity()) else {
793            continue;
794        };
795        for (source_key, target_key) in relation.local_fields().iter().zip(relation.target_fields())
796        {
797            let source_field = source
798                .fields()
799                .iter()
800                .find(|field| field.source_key() == source_key)
801                .ok_or(SchemaContractError::InvalidLocalReference)?;
802            let target_field = target
803                .fields()
804                .iter()
805                .find(|field| field.source_key() == target_key)
806                .ok_or(SchemaContractError::InvalidLocalReference)?;
807            let source_type = match source_field.field_type() {
808                FieldType::List(item) => item.as_ref(),
809                field_type => field_type,
810            };
811            if source_type != target_field.field_type() {
812                return Err(SchemaContractError::RelationTypeMismatch);
813            }
814        }
815    }
816    Ok(())
817}
818
819fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
820where
821    T: Eq,
822{
823    if values.windows(2).any(|pair| pair[0] == pair[1]) {
824        return Err(SchemaContractError::DuplicateSourceKey);
825    }
826    Ok(())
827}
828
829fn ensure_no_adjacent_duplicates_by<T, K>(
830    values: &[T],
831    key: impl Fn(&T) -> &K,
832) -> Result<(), SchemaContractError>
833where
834    K: Eq,
835{
836    if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
837        return Err(SchemaContractError::DuplicateSourceKey);
838    }
839    Ok(())
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use crate::decode_schema_proposal;
846
847    fn empty_proposal() -> SchemaProposal {
848        SchemaProposal::try_compose(
849            Vec::new(),
850            TargetDatabaseIdentity::from_bytes([1; 32]),
851            SchemaSubmissionKey::try_new("proposal-version-test")
852                .expect("submission key should admit"),
853            ExpectedAcceptedHead::Empty,
854            Vec::new(),
855            Vec::new(),
856            Vec::new(),
857        )
858        .expect("empty proposal should compose")
859    }
860
861    #[test]
862    fn decoded_future_contract_version_fails_typed() {
863        let mut proposal = empty_proposal();
864        proposal.version = ProposalContractVersion::from_raw(2);
865        let bytes = candid::encode_one(proposal).expect("raw future proposal should encode");
866
867        assert_eq!(
868            decode_schema_proposal(&bytes),
869            Err(SchemaContractError::UnsupportedVersion {
870                found: 2,
871                supported: 1,
872            }),
873        );
874    }
875
876    #[test]
877    fn decoded_unknown_capability_fails_typed() {
878        let mut proposal = empty_proposal();
879        proposal.capabilities = vec![SchemaCapability::from_raw(u16::MAX)];
880        let bytes = candid::encode_one(proposal).expect("raw proposal should encode");
881
882        assert_eq!(
883            decode_schema_proposal(&bytes),
884            Err(SchemaContractError::UnsupportedCapability),
885        );
886    }
887}