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::NumericMaximumInclusive { value }
657            | SourceRuleOperation::NumericMinimumInclusive { value },
658            RuleTargetShape::Scalar(scalar),
659        ) => numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, value),
660        (
661            SourceRuleOperation::NumericRangeInclusive { min, max },
662            RuleTargetShape::Scalar(scalar),
663        ) => {
664            numeric_scalar(scalar)
665                && numeric_rule_literal_matches(scalar, min)
666                && numeric_rule_literal_matches(scalar, max)
667        }
668        (SourceRuleOperation::MultipleOf { divisor }, RuleTargetShape::Scalar(scalar)) => {
669            exact_numeric_scalar(scalar) && numeric_rule_literal_matches(scalar, divisor)
670        }
671        _ => false,
672    }
673}
674
675fn numeric_rule_literal_matches(scalar: ScalarType, literal: &crate::ScalarLiteral) -> bool {
676    if let (ScalarType::Decimal { scale }, crate::ScalarLiteral::Decimal(value)) = (scalar, literal)
677    {
678        let value = value.normalize();
679        return value.scale() <= scale && value.scale_to_integer(scale).is_some();
680    }
681    scalar.accepts_literal(literal)
682}
683
684const fn exact_numeric_scalar(scalar: ScalarType) -> bool {
685    matches!(
686        scalar,
687        ScalarType::Decimal { .. }
688            | ScalarType::Int8
689            | ScalarType::Int16
690            | ScalarType::Int32
691            | ScalarType::Int64
692            | ScalarType::Int128
693            | ScalarType::IntBig { .. }
694            | ScalarType::Nat8
695            | ScalarType::Nat16
696            | ScalarType::Nat32
697            | ScalarType::Nat64
698            | ScalarType::Nat128
699            | ScalarType::NatBig { .. }
700    )
701}
702
703const fn numeric_scalar(scalar: ScalarType) -> bool {
704    matches!(
705        scalar,
706        ScalarType::Decimal { .. }
707            | ScalarType::Float32
708            | ScalarType::Float64
709            | ScalarType::Int8
710            | ScalarType::Int16
711            | ScalarType::Int32
712            | ScalarType::Int64
713            | ScalarType::Int128
714            | ScalarType::IntBig { .. }
715            | ScalarType::Nat8
716            | ScalarType::Nat16
717            | ScalarType::Nat32
718            | ScalarType::Nat64
719            | ScalarType::Nat128
720            | ScalarType::NatBig { .. }
721    )
722}
723
724fn collect_named_type_references(r#type: &NamedTypeFragment, references: &mut ProposalReferences) {
725    match r#type {
726        NamedTypeFragment::Record(record) => {
727            for field in record.fields() {
728                collect_field_type_reference(field.field_type(), references);
729            }
730        }
731        NamedTypeFragment::Enum(r#enum) => {
732            for variant in r#enum.variants() {
733                if let Some(payload) = variant.payload() {
734                    collect_field_type_reference(payload, references);
735                }
736            }
737        }
738        NamedTypeFragment::Newtype { inner, .. }
739        | NamedTypeFragment::List { item: inner, .. }
740        | NamedTypeFragment::Set { item: inner, .. } => {
741            collect_field_type_reference(inner, references);
742        }
743        NamedTypeFragment::Map { key, value, .. } => {
744            collect_field_type_reference(key, references);
745            collect_field_type_reference(value, references);
746        }
747        NamedTypeFragment::Tuple { members, .. } => {
748            for member in members {
749                collect_field_type_reference(member.field_type(), references);
750            }
751        }
752    }
753}
754
755fn collect_field_references(
756    field: &FieldFragment,
757    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
758    references: &mut ProposalReferences,
759) -> Result<(), SchemaContractError> {
760    collect_field_type_reference(field.field_type(), references);
761    if let crate::FieldInsertPolicy::Default(ScalarLiteral::EnumUnit { enum_type, variant }) =
762        field.insert_policy()
763    {
764        let FieldType::Named(field_type) = field.field_type() else {
765            return Err(SchemaContractError::LiteralTypeMismatch);
766        };
767        if field_type != enum_type {
768            return Err(SchemaContractError::LiteralTypeMismatch);
769        }
770        collect_enum_literal_reference(enum_type, variant, types, references)?;
771    }
772    Ok(())
773}
774
775fn collect_field_type_reference(field_type: &FieldType, references: &mut ProposalReferences) {
776    match field_type {
777        FieldType::List(item) => collect_field_type_reference(item, references),
778        FieldType::Named(reference) => {
779            references.types.insert(reference.clone());
780        }
781        FieldType::Scalar(_) => {}
782    }
783}
784
785fn collect_expression_enum_references(
786    expression: &SourceCheckExpr,
787    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
788    references: &mut ProposalReferences,
789) -> Result<(), SchemaContractError> {
790    for instruction in expression.instructions() {
791        if let SourceCheckInstruction::Literal(ScalarLiteral::EnumUnit { enum_type, variant }) =
792            instruction
793        {
794            collect_enum_literal_reference(enum_type, variant, types, references)?;
795        }
796    }
797    Ok(())
798}
799
800fn collect_enum_literal_reference(
801    enum_type: &TypeSourceKey,
802    variant: &TypeSourceKey,
803    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
804    references: &mut ProposalReferences,
805) -> Result<(), SchemaContractError> {
806    references.types.insert(enum_type.clone());
807    let Some(local) = types.get(enum_type) else {
808        return Ok(());
809    };
810    let NamedTypeFragment::Enum(local) = local else {
811        return Err(SchemaContractError::InvalidEnumLiteral);
812    };
813    if local
814        .variants()
815        .iter()
816        .all(|candidate| candidate.source_key() != variant)
817    {
818        return Err(SchemaContractError::InvalidEnumLiteral);
819    }
820    Ok(())
821}
822
823fn validate_local_relation_targets(
824    source: &EntityFragment,
825    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
826) -> Result<(), SchemaContractError> {
827    for relation in source.relations() {
828        let Some(target) = entities.get(relation.target_entity()) else {
829            continue;
830        };
831        for (source_key, target_key) in relation.local_fields().iter().zip(relation.target_fields())
832        {
833            let source_field = source
834                .fields()
835                .iter()
836                .find(|field| field.source_key() == source_key)
837                .ok_or(SchemaContractError::InvalidLocalReference)?;
838            let target_field = target
839                .fields()
840                .iter()
841                .find(|field| field.source_key() == target_key)
842                .ok_or(SchemaContractError::InvalidLocalReference)?;
843            let source_type = match source_field.field_type() {
844                FieldType::List(item) => item.as_ref(),
845                field_type => field_type,
846            };
847            if source_type != target_field.field_type() {
848                return Err(SchemaContractError::RelationTypeMismatch);
849            }
850        }
851    }
852    Ok(())
853}
854
855fn ensure_no_adjacent_duplicates<T>(values: &[T]) -> Result<(), SchemaContractError>
856where
857    T: Eq,
858{
859    if values.windows(2).any(|pair| pair[0] == pair[1]) {
860        return Err(SchemaContractError::DuplicateSourceKey);
861    }
862    Ok(())
863}
864
865fn ensure_no_adjacent_duplicates_by<T, K>(
866    values: &[T],
867    key: impl Fn(&T) -> &K,
868) -> Result<(), SchemaContractError>
869where
870    K: Eq,
871{
872    if values.windows(2).any(|pair| key(&pair[0]) == key(&pair[1])) {
873        return Err(SchemaContractError::DuplicateSourceKey);
874    }
875    Ok(())
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use crate::decode_schema_proposal;
882    use std::str::FromStr;
883
884    fn empty_proposal() -> SchemaProposal {
885        SchemaProposal::try_compose(
886            Vec::new(),
887            TargetDatabaseIdentity::from_bytes([1; 32]),
888            SchemaSubmissionKey::try_new("proposal-version-test")
889                .expect("submission key should admit"),
890            ExpectedAcceptedHead::Empty,
891            Vec::new(),
892            Vec::new(),
893            Vec::new(),
894        )
895        .expect("empty proposal should compose")
896    }
897
898    #[test]
899    fn decoded_future_contract_version_fails_typed() {
900        let mut proposal = empty_proposal();
901        proposal.version = ProposalContractVersion::from_raw(2);
902        let bytes = candid::encode_one(proposal).expect("raw future proposal should encode");
903
904        assert_eq!(
905            decode_schema_proposal(&bytes),
906            Err(SchemaContractError::UnsupportedVersion {
907                found: 2,
908                supported: 1,
909            }),
910        );
911    }
912
913    #[test]
914    fn decoded_unknown_capability_fails_typed() {
915        let mut proposal = empty_proposal();
916        proposal.capabilities = vec![SchemaCapability::from_raw(u16::MAX)];
917        let bytes = candid::encode_one(proposal).expect("raw proposal should encode");
918
919        assert_eq!(
920            decode_schema_proposal(&bytes),
921            Err(SchemaContractError::UnsupportedCapability),
922        );
923    }
924
925    #[test]
926    fn targeted_numeric_operations_require_exact_target_literal_admission() {
927        let decimal = |value| {
928            crate::ScalarLiteral::Decimal(
929                crate::Decimal::from_str(value).expect("decimal fixture should parse"),
930            )
931        };
932        assert!(operation_matches_target(
933            &SourceRuleOperation::MultipleOf {
934                divisor: decimal("0.25"),
935            },
936            RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
937        ));
938        assert!(!operation_matches_target(
939            &SourceRuleOperation::MultipleOf {
940                divisor: decimal("0.251"),
941            },
942            RuleTargetShape::Scalar(ScalarType::Decimal { scale: 2 }),
943        ));
944        assert!(!operation_matches_target(
945            &SourceRuleOperation::MultipleOf {
946                divisor: crate::ScalarLiteral::Float64(
947                    crate::Float64::try_new(2.0).expect("finite float"),
948                ),
949            },
950            RuleTargetShape::Scalar(ScalarType::Float64),
951        ));
952        assert!(operation_matches_target(
953            &SourceRuleOperation::NumericMaximumInclusive {
954                value: crate::ScalarLiteral::Float64(
955                    crate::Float64::try_new(2.0).expect("finite float"),
956                ),
957            },
958            RuleTargetShape::Scalar(ScalarType::Float64),
959        ));
960    }
961}