Skip to main content

icydb_model/
fragment.rs

1//! Module: fragment
2//!
3//! Responsibility: lower one sealed host graph into a store-free database closure.
4//!
5//! Does not own: proposal routing, accepted identity, deployment configuration, or persistence.
6//!
7//! Boundary: converts compiler-authored logical facts into bounded public schema fragments.
8
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    str::FromStr,
12};
13
14use icydb_schema::{
15    Account, Blob, ConstraintFragment, Date, Decimal, DeclaredEntityVersion, Duration,
16    EntityFragment, EntitySourceKey, EnumTypeFragment, EnumVariantFragment, FieldFragment,
17    FieldInsertPolicy, FieldManagementPolicy, FieldSourceKey, FieldType, Float32, Float64,
18    IndexFragment, IndexKeyFragment, NamedTypeFragment, Principal, RecordFieldFragment,
19    RecordTypeFragment, RelationDeleteAction, RelationFragment, RelationPathStepFragment,
20    RelationSourceFragment, RuleSourceKey, ScalarLiteral, ScalarType, SchemaContractError,
21    SchemaFragment, SchemaName, SourceRuleOperation as ProposalSourceRuleOperation, Subaccount,
22    TargetedRuleFragment, Timestamp, TupleElementFragment, TypeSourceKey, U256, Ulid, Unit,
23};
24use thiserror::Error;
25
26use crate::{
27    node::{
28        Arg, ArgNumber, Canister, CheckConstraint, Entity, Enum, Field, FieldWriteManagement,
29        Index, IndexExpression, IndexKeyItem, IndexKeyItemsRef, Item, ItemTarget, List, Map,
30        Record, RelationEdge, RuleNumber, Schema, SchemaNode, Set, SourceRule,
31        SourceRuleAuthoringOperation, Store, Tuple, Value,
32    },
33    types::{Cardinality, Primitive},
34};
35
36/// Failure while projecting one validated host graph into public fragments.
37
38#[derive(Debug, Error)]
39pub enum FragmentLoweringError {
40    /// A selected canister has no registered stores.
41    #[error("schema canister has no registered stores: {0}")]
42    CanisterHasNoStores(String),
43
44    /// The selected canister path is not registered.
45    #[error("schema canister path is not registered: {0}")]
46    CanisterNotFound(String),
47
48    /// The public bounded proposal contract rejected the projection.
49    #[error(transparent)]
50    Contract(#[from] SchemaContractError),
51
52    /// Fragment projection requires the immutable post-validation graph.
53    #[error("schema graph must be sealed before fragment lowering")]
54    GraphNotSealed,
55
56    /// A declared default cannot be represented by the public proposal atom.
57    #[error("schema field default cannot be lowered: {0}")]
58    InvalidDefault(String),
59
60    /// One graph reference no longer resolves to the expected node kind.
61    #[error("schema fragment reference is invalid: {0}")]
62    InvalidReference(String),
63
64    /// One authored value cardinality has no accepted proposal representation.
65    #[error("schema value cardinality is unsupported at {0}")]
66    UnsupportedCardinality(String),
67}
68
69// -----------------------------------------------------------------------------
70// Database closure
71// -----------------------------------------------------------------------------
72
73impl Schema {
74    /// Lower every persisted entity belonging to one canister, plus its exact
75    /// reachable named-type and relation closure, into one store-free fragment.
76    ///
77    /// Store assignment remains a later proposal-composition concern.
78    ///
79    /// # Errors
80    ///
81    /// Returns a typed error when the graph is not sealed, the selected
82    /// canister/store closure is incomplete, or one authored fact cannot be
83    /// represented by the bounded public proposal contract.
84    pub fn schema_fragment_for_canister(
85        &self,
86        canister_path: &str,
87    ) -> Result<SchemaFragment, FragmentLoweringError> {
88        if !self.is_sealed() {
89            return Err(FragmentLoweringError::GraphNotSealed);
90        }
91        self.cast_node::<Canister>(canister_path)
92            .map_err(|_| FragmentLoweringError::CanisterNotFound(canister_path.to_string()))?;
93        let stores = self
94            .filter_nodes::<Store>(|store| store.canister() == canister_path)
95            .map(|(path, _)| path.to_string())
96            .collect::<BTreeSet<_>>();
97        if stores.is_empty() {
98            return Err(FragmentLoweringError::CanisterHasNoStores(
99                canister_path.to_string(),
100            ));
101        }
102
103        let entities = self
104            .get_nodes::<Entity>()
105            .filter(|(_, entity)| stores.contains(entity.store()))
106            .map(|(_, entity)| entity)
107            .collect::<Vec<_>>();
108        let selected_entities = entities
109            .iter()
110            .map(|entity| entity.def().path())
111            .collect::<BTreeSet<_>>();
112        for entity in &entities {
113            ensure_relation_targets_in_database(self, entity, &selected_entities)?;
114        }
115
116        let mut pending_types = Vec::new();
117        let entity_fragments = entities
118            .iter()
119            .map(|entity| lower_entity(self, entity, &mut pending_types))
120            .collect::<Result<Vec<_>, _>>()?;
121        let types = lower_reachable_types(self, pending_types)?;
122
123        SchemaFragment::try_new(entity_fragments, types).map_err(Into::into)
124    }
125}
126
127fn ensure_relation_targets_in_database(
128    schema: &Schema,
129    entity: &Entity,
130    selected_entities: &BTreeSet<String>,
131) -> Result<(), FragmentLoweringError> {
132    let mut targets = entity
133        .relations()
134        .iter()
135        .map(RelationEdge::target)
136        .collect::<Vec<_>>();
137    for field in entity.fields().fields() {
138        collect_relation_targets(
139            schema,
140            field.value().item(),
141            &mut BTreeSet::new(),
142            &mut targets,
143        )?;
144    }
145    for target in targets {
146        schema
147            .cast_node::<Entity>(target)
148            .map_err(|_| FragmentLoweringError::InvalidReference(target.to_string()))?;
149        if !selected_entities.contains(target) {
150            return Err(FragmentLoweringError::InvalidReference(format!(
151                "relation target '{target}' is outside the selected database"
152            )));
153        }
154    }
155    Ok(())
156}
157
158fn collect_relation_targets<'schema>(
159    schema: &'schema Schema,
160    item: &'schema Item,
161    visiting: &mut BTreeSet<String>,
162    targets: &mut Vec<&'schema str>,
163) -> Result<(), FragmentLoweringError> {
164    if let Some(target) = item.relation() {
165        targets.push(target);
166    }
167    let ItemTarget::Is(path) = item.target() else {
168        return Ok(());
169    };
170    if !visiting.insert((*path).to_string()) {
171        return Ok(());
172    }
173    let node = schema
174        .get_node(path)
175        .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?;
176    match node {
177        SchemaNode::Newtype(value) => {
178            collect_relation_targets(schema, value.item(), visiting, targets)?;
179        }
180        SchemaNode::Record(value) => {
181            for field in value.fields().fields() {
182                collect_relation_targets(schema, field.value().item(), visiting, targets)?;
183            }
184        }
185        SchemaNode::Enum(value) => {
186            for variant in value.variants() {
187                if let Some(value) = variant.value() {
188                    collect_relation_targets(schema, value.item(), visiting, targets)?;
189                }
190            }
191        }
192        SchemaNode::List(value) => {
193            collect_relation_targets(schema, value.item(), visiting, targets)?;
194        }
195        SchemaNode::Set(value) => {
196            collect_relation_targets(schema, value.item(), visiting, targets)?;
197        }
198        SchemaNode::Map(value) => {
199            collect_relation_targets(schema, value.key(), visiting, targets)?;
200            collect_relation_targets(schema, value.value().item(), visiting, targets)?;
201        }
202        SchemaNode::Tuple(value) => {
203            for member in value.values() {
204                collect_relation_targets(schema, member.item(), visiting, targets)?;
205            }
206        }
207        SchemaNode::Canister(_)
208        | SchemaNode::Entity(_)
209        | SchemaNode::Normalizer(_)
210        | SchemaNode::Store(_)
211        | SchemaNode::Validator(_) => {}
212    }
213    visiting.remove(*path);
214    Ok(())
215}
216
217fn lower_entity(
218    schema: &Schema,
219    entity: &Entity,
220    pending_types: &mut Vec<String>,
221) -> Result<EntityFragment, FragmentLoweringError> {
222    let fields = entity
223        .fields()
224        .fields()
225        .iter()
226        .map(|field| lower_entity_field(schema, field, pending_types))
227        .collect::<Result<Vec<_>, _>>()?;
228    let primary_key = entity
229        .primary_key()
230        .fields()
231        .iter()
232        .map(|name| entity_field_source_key(entity, name))
233        .collect::<Result<Vec<_>, _>>()?;
234    let indexes = entity
235        .indexes()
236        .iter()
237        .map(|index| lower_index(schema, entity, index))
238        .collect::<Result<Vec<_>, _>>()?;
239    let mut relations = entity
240        .fields()
241        .fields()
242        .iter()
243        .filter(|field| field.value().item().relation().is_some())
244        .map(|field| lower_scalar_relation(schema, entity, field))
245        .collect::<Result<Vec<_>, _>>()?;
246    for field in entity.fields().fields() {
247        relations.extend(lower_nested_relations(schema, entity, field)?);
248    }
249    relations.extend(
250        entity
251            .relations()
252            .iter()
253            .map(|relation| lower_composite_relation(schema, entity, relation))
254            .collect::<Result<Vec<_>, _>>()?,
255    );
256    let mut constraints = entity
257        .constraints()
258        .iter()
259        .map(|constraint| lower_constraint(schema, constraint))
260        .collect::<Result<Vec<_>, _>>()?;
261    for field in entity.fields().fields() {
262        constraints.extend(lower_field_rules(schema, field)?);
263    }
264
265    EntityFragment::try_new(
266        SchemaName::try_new(entity.name())?,
267        DeclaredEntityVersion::try_new(entity.schema_version())?,
268        fields,
269        primary_key,
270        indexes,
271        relations,
272        constraints,
273    )
274    .map_err(Into::into)
275}
276
277struct NestedRelationCandidate<'schema> {
278    path_name: String,
279    steps: Vec<RelationPathStepFragment>,
280    target: &'schema str,
281}
282
283fn lower_nested_relations(
284    schema: &Schema,
285    entity: &Entity,
286    root: &Field,
287) -> Result<Vec<RelationFragment>, FragmentLoweringError> {
288    if root.value().item().relation().is_some() {
289        return Ok(Vec::new());
290    }
291    let mut steps = Vec::new();
292    if root.value().cardinality() == Cardinality::Opt {
293        steps.push(RelationPathStepFragment::OptionalSome);
294    } else if root.value().cardinality() == Cardinality::Many {
295        steps.push(RelationPathStepFragment::ListItems);
296    }
297    let mut candidates = Vec::new();
298    collect_nested_relation_candidates(
299        schema,
300        root.value().item(),
301        root.name().to_string(),
302        steps,
303        &mut BTreeSet::new(),
304        &mut candidates,
305    )?;
306    candidates
307        .into_iter()
308        .map(|candidate| {
309            let target = schema.cast_node::<Entity>(candidate.target).map_err(|_| {
310                FragmentLoweringError::InvalidReference(candidate.target.to_string())
311            })?;
312            RelationFragment::try_new(
313                SchemaName::try_new(candidate.path_name)?,
314                RelationSourceFragment::Nested {
315                    root: entity_field_source_key(entity, root.name())?,
316                    steps: candidate.steps,
317                },
318                EntitySourceKey::try_new(target.name())?,
319                target
320                    .primary_key()
321                    .fields()
322                    .iter()
323                    .map(|field| entity_field_source_key(target, field))
324                    .collect::<Result<Vec<_>, _>>()?,
325                RelationDeleteAction::Restrict,
326            )
327            .map_err(Into::into)
328        })
329        .collect()
330}
331
332#[expect(
333    clippy::too_many_lines,
334    reason = "one bounded recursive walk keeps every admitted structural node and exclusion adjacent"
335)]
336fn collect_nested_relation_candidates<'schema>(
337    schema: &'schema Schema,
338    item: &'schema Item,
339    path_name: String,
340    steps: Vec<RelationPathStepFragment>,
341    visiting: &mut BTreeSet<String>,
342    candidates: &mut Vec<NestedRelationCandidate<'schema>>,
343) -> Result<(), FragmentLoweringError> {
344    if let Some(target) = item.relation() {
345        let ItemTarget::Primitive(_) = item.target() else {
346            return Err(FragmentLoweringError::InvalidReference(path_name));
347        };
348        candidates.push(NestedRelationCandidate {
349            path_name,
350            steps,
351            target,
352        });
353        return Ok(());
354    }
355    let ItemTarget::Is(path) = item.target() else {
356        return Ok(());
357    };
358    if !visiting.insert((*path).to_string()) {
359        // A relation source is one finite path through a value. Stop at a
360        // recursive edge: any finite relation-bearing sibling was already
361        // visited, while a relation reachable only by cycling has no bounded
362        // single-valued source path to lower.
363        return Ok(());
364    }
365    let node = schema
366        .get_node(path)
367        .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?;
368    let type_key = TypeSourceKey::try_new(
369        named_type_name(node)
370            .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?,
371    )?;
372    let mut entered = steps;
373    entered.push(RelationPathStepFragment::EnterNamed {
374        r#type: type_key.clone(),
375    });
376    match node {
377        SchemaNode::Newtype(value) => collect_nested_relation_candidates(
378            schema,
379            value.item(),
380            path_name,
381            entered,
382            visiting,
383            candidates,
384        )?,
385        SchemaNode::Record(value) => {
386            for field in value.fields().fields() {
387                let mut member_steps = entered.clone();
388                member_steps.push(RelationPathStepFragment::RecordMember {
389                    record: type_key.clone(),
390                    field: FieldSourceKey::try_new(field.name())?,
391                });
392                match field.value().cardinality() {
393                    Cardinality::One => {}
394                    Cardinality::Opt => {
395                        member_steps.push(RelationPathStepFragment::OptionalSome);
396                    }
397                    Cardinality::Many => {
398                        member_steps.push(RelationPathStepFragment::ListItems);
399                    }
400                }
401                collect_nested_relation_candidates(
402                    schema,
403                    field.value().item(),
404                    format!("{path_name}.{}", field.name()),
405                    member_steps,
406                    visiting,
407                    candidates,
408                )?;
409            }
410        }
411        SchemaNode::Enum(value) => {
412            for variant in value.variants() {
413                let Some(payload) = variant.value() else {
414                    continue;
415                };
416                let mut variant_steps = entered.clone();
417                variant_steps.push(RelationPathStepFragment::EnumVariantPayload {
418                    r#enum: type_key.clone(),
419                    variant: TypeSourceKey::try_new(variant.name())?,
420                });
421                match payload.cardinality() {
422                    Cardinality::One => {}
423                    Cardinality::Opt => {
424                        variant_steps.push(RelationPathStepFragment::OptionalSome);
425                    }
426                    Cardinality::Many => {
427                        variant_steps.push(RelationPathStepFragment::ListItems);
428                    }
429                }
430                collect_nested_relation_candidates(
431                    schema,
432                    payload.item(),
433                    format!("{path_name}.{}", variant.name()),
434                    variant_steps,
435                    visiting,
436                    candidates,
437                )?;
438            }
439        }
440        SchemaNode::List(list) => {
441            let mut item_steps = entered;
442            item_steps.push(RelationPathStepFragment::ListItems);
443            collect_nested_relation_candidates(
444                schema,
445                list.item(),
446                format!("{path_name}.items"),
447                item_steps,
448                visiting,
449                candidates,
450            )?;
451        }
452        SchemaNode::Set(set) => {
453            let mut item_steps = entered;
454            item_steps.push(RelationPathStepFragment::SetItems);
455            collect_nested_relation_candidates(
456                schema,
457                set.item(),
458                format!("{path_name}.items"),
459                item_steps,
460                visiting,
461                candidates,
462            )?;
463        }
464        SchemaNode::Map(map) => {
465            let mut value_steps = entered;
466            value_steps.push(RelationPathStepFragment::MapValues);
467            match map.value().cardinality() {
468                Cardinality::One => {}
469                Cardinality::Opt => {
470                    value_steps.push(RelationPathStepFragment::OptionalSome);
471                }
472                Cardinality::Many => {
473                    value_steps.push(RelationPathStepFragment::ListItems);
474                }
475            }
476            collect_nested_relation_candidates(
477                schema,
478                map.value().item(),
479                format!("{path_name}.values"),
480                value_steps,
481                visiting,
482                candidates,
483            )?;
484        }
485        SchemaNode::Tuple(_) => {
486            if item_reaches_relation(schema, item, &mut BTreeSet::new())? {
487                return Err(FragmentLoweringError::UnsupportedCardinality(path_name));
488            }
489        }
490        SchemaNode::Canister(_)
491        | SchemaNode::Entity(_)
492        | SchemaNode::Normalizer(_)
493        | SchemaNode::Store(_)
494        | SchemaNode::Validator(_) => {
495            return Err(FragmentLoweringError::InvalidReference((*path).to_string()));
496        }
497    }
498    visiting.remove(*path);
499    Ok(())
500}
501
502fn item_reaches_relation(
503    schema: &Schema,
504    item: &Item,
505    visiting: &mut BTreeSet<String>,
506) -> Result<bool, FragmentLoweringError> {
507    if item.relation().is_some() {
508        return Ok(true);
509    }
510    let mut targets = Vec::new();
511    collect_relation_targets(schema, item, visiting, &mut targets)?;
512    Ok(!targets.is_empty())
513}
514
515fn lower_entity_field(
516    schema: &Schema,
517    field: &Field,
518    pending_types: &mut Vec<String>,
519) -> Result<FieldFragment, FragmentLoweringError> {
520    let field_type = lower_value_type(schema, field.value(), pending_types)?;
521    let nullable = field.value().cardinality() == Cardinality::Opt;
522    let insert_policy = if field.generated().is_some() {
523        FieldInsertPolicy::Generated
524    } else if let Some(default) = field.default() {
525        FieldInsertPolicy::Default(lower_default(schema, field, default)?)
526    } else if nullable {
527        FieldInsertPolicy::Nullable
528    } else {
529        FieldInsertPolicy::Required
530    };
531    let management = match field.write_management() {
532        Some(FieldWriteManagement::CreatedAt) => Some(FieldManagementPolicy::CreatedAt),
533        Some(FieldWriteManagement::UpdatedAt) => Some(FieldManagementPolicy::UpdatedAt),
534        None => None,
535    };
536    Ok(FieldFragment::new(
537        SchemaName::try_new(field.name())?,
538        field_type,
539        nullable,
540        insert_policy,
541        management,
542    ))
543}
544
545fn lower_index(
546    schema: &Schema,
547    entity: &Entity,
548    index: &Index,
549) -> Result<IndexFragment, FragmentLoweringError> {
550    let key = match index.key_items() {
551        IndexKeyItemsRef::Fields(fields) => fields
552            .iter()
553            .map(|field| entity_field_source_key(entity, field).map(IndexKeyFragment::Field))
554            .collect::<Result<Vec<_>, _>>()?,
555        IndexKeyItemsRef::Items(items) => items
556            .iter()
557            .map(|item| lower_index_key(entity, item))
558            .collect::<Result<Vec<_>, _>>()?,
559    };
560    IndexFragment::try_new(
561        SchemaName::try_new(index.name())?,
562        key,
563        index.is_unique(),
564        index.source_predicate(schema)?,
565    )
566    .map_err(Into::into)
567}
568
569fn lower_index_key(
570    entity: &Entity,
571    item: &IndexKeyItem,
572) -> Result<IndexKeyFragment, FragmentLoweringError> {
573    let field = entity_field_source_key(entity, item.field())?;
574    Ok(match item {
575        IndexKeyItem::Field(_) => IndexKeyFragment::Field(field),
576        IndexKeyItem::Expression(IndexExpression::Lower(_)) => IndexKeyFragment::Lower(field),
577        IndexKeyItem::Expression(IndexExpression::Upper(_)) => IndexKeyFragment::Upper(field),
578        IndexKeyItem::Expression(IndexExpression::Trim(_)) => IndexKeyFragment::Trim(field),
579        IndexKeyItem::Expression(IndexExpression::LowerTrim(_)) => {
580            IndexKeyFragment::LowerTrim(field)
581        }
582        IndexKeyItem::Expression(IndexExpression::Date(_)) => IndexKeyFragment::Date(field),
583        IndexKeyItem::Expression(IndexExpression::Year(_)) => IndexKeyFragment::Year(field),
584        IndexKeyItem::Expression(IndexExpression::Month(_)) => IndexKeyFragment::Month(field),
585        IndexKeyItem::Expression(IndexExpression::Day(_)) => IndexKeyFragment::Day(field),
586    })
587}
588
589fn lower_scalar_relation(
590    schema: &Schema,
591    entity: &Entity,
592    field: &Field,
593) -> Result<RelationFragment, FragmentLoweringError> {
594    let target_path = field
595        .value()
596        .item()
597        .relation()
598        .ok_or_else(|| FragmentLoweringError::InvalidReference(field.name().to_string()))?;
599    let target = schema
600        .cast_node::<Entity>(target_path)
601        .map_err(|_| FragmentLoweringError::InvalidReference(target_path.to_string()))?;
602    RelationFragment::try_new(
603        SchemaName::try_new(field.name())?,
604        RelationSourceFragment::direct(vec![entity_field_source_key(entity, field.name())?]),
605        EntitySourceKey::try_new(target.name())?,
606        target
607            .primary_key()
608            .fields()
609            .iter()
610            .map(|field| entity_field_source_key(target, field))
611            .collect::<Result<Vec<_>, _>>()?,
612        RelationDeleteAction::Restrict,
613    )
614    .map_err(Into::into)
615}
616
617fn lower_composite_relation(
618    schema: &Schema,
619    entity: &Entity,
620    relation: &RelationEdge,
621) -> Result<RelationFragment, FragmentLoweringError> {
622    let target = schema
623        .cast_node::<Entity>(relation.target())
624        .map_err(|_| FragmentLoweringError::InvalidReference(relation.target().to_string()))?;
625    RelationFragment::try_new(
626        SchemaName::try_new(relation.name())?,
627        RelationSourceFragment::direct(
628            relation
629                .local_fields()
630                .iter()
631                .map(|field| entity_field_source_key(entity, field))
632                .collect::<Result<Vec<_>, _>>()?,
633        ),
634        EntitySourceKey::try_new(target.name())?,
635        target
636            .primary_key()
637            .fields()
638            .iter()
639            .map(|field| entity_field_source_key(target, field))
640            .collect::<Result<Vec<_>, _>>()?,
641        RelationDeleteAction::Restrict,
642    )
643    .map_err(Into::into)
644}
645
646fn lower_constraint(
647    schema: &Schema,
648    constraint: &CheckConstraint,
649) -> Result<ConstraintFragment, FragmentLoweringError> {
650    Ok(ConstraintFragment::check(
651        SchemaName::try_new(constraint.name())?,
652        constraint.source_expression(schema)?,
653    ))
654}
655
656fn lower_field_rules(
657    schema: &Schema,
658    field: &Field,
659) -> Result<Vec<ConstraintFragment>, FragmentLoweringError> {
660    let field_source = FieldSourceKey::try_new(field.name())?;
661    reachable_source_rules(schema, field.value().item())?
662        .into_iter()
663        .map(|(target_type, rule, target)| {
664            let operation = lower_source_rule_operation(schema, target, rule)?;
665            Ok(ConstraintFragment::targeted_rule(
666                TargetedRuleFragment::new(
667                    field_source.clone(),
668                    target_type,
669                    SchemaName::try_new(rule.name())?,
670                    operation,
671                ),
672            ))
673        })
674        .collect()
675}
676
677type ReachableSourceRule<'schema> = (
678    TypeSourceKey,
679    &'schema SourceRule,
680    &'schema crate::node::SchemaNode,
681);
682
683fn reachable_source_rules<'schema>(
684    schema: &'schema Schema,
685    item: &Item,
686) -> Result<Vec<ReachableSourceRule<'schema>>, FragmentLoweringError> {
687    let mut pending = Vec::new();
688    push_item_reference(item, &mut pending);
689    let mut visited = BTreeSet::new();
690    let mut rules = BTreeMap::new();
691    while let Some(path) = pending.pop() {
692        let node = schema
693            .get_node(path.as_str())
694            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
695        let target_type = TypeSourceKey::try_new(
696            named_type_name(node)
697                .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?,
698        )?;
699        if !visited.insert(target_type.clone()) {
700            continue;
701        }
702        for rule in schema_node_type(node)?.rules() {
703            let key = (target_type.clone(), RuleSourceKey::try_new(rule.name())?);
704            if rules.insert(key, (rule, node)).is_some() {
705                return Err(FragmentLoweringError::InvalidReference(format!(
706                    "duplicate durable rule '{}' on type '{}'",
707                    rule.name(),
708                    target_type
709                )));
710            }
711        }
712        push_schema_node_references(node, &mut pending);
713    }
714    Ok(rules
715        .into_iter()
716        .map(|((target_type, _), (rule, node))| (target_type, rule, node))
717        .collect())
718}
719
720fn schema_node_type(node: &SchemaNode) -> Result<&crate::node::Type, FragmentLoweringError> {
721    match node {
722        SchemaNode::Newtype(node) => Ok(node.ty()),
723        SchemaNode::Record(node) => Ok(node.ty()),
724        SchemaNode::Enum(node) => Ok(node.ty()),
725        SchemaNode::List(node) => Ok(node.ty()),
726        SchemaNode::Map(node) => Ok(node.ty()),
727        SchemaNode::Set(node) => Ok(node.ty()),
728        SchemaNode::Tuple(node) => Ok(node.ty()),
729        SchemaNode::Canister(_)
730        | SchemaNode::Entity(_)
731        | SchemaNode::Normalizer(_)
732        | SchemaNode::Store(_)
733        | SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
734            "durable-rule target is not a named type".to_string(),
735        )),
736    }
737}
738
739fn push_schema_node_references(node: &SchemaNode, pending: &mut Vec<String>) {
740    match node {
741        SchemaNode::Newtype(newtype) => push_item_reference(newtype.item(), pending),
742        SchemaNode::Record(record) => {
743            for field in record.fields().fields() {
744                push_item_reference(field.value().item(), pending);
745            }
746        }
747        SchemaNode::Enum(r#enum) => {
748            for value in r#enum
749                .variants()
750                .iter()
751                .filter_map(crate::node::EnumVariant::value)
752            {
753                push_item_reference(value.item(), pending);
754            }
755        }
756        SchemaNode::List(list) => push_item_reference(list.item(), pending),
757        SchemaNode::Map(map) => {
758            push_item_reference(map.key(), pending);
759            push_item_reference(map.value().item(), pending);
760        }
761        SchemaNode::Set(set) => push_item_reference(set.item(), pending),
762        SchemaNode::Tuple(tuple) => {
763            for value in tuple.values() {
764                push_item_reference(value.item(), pending);
765            }
766        }
767        SchemaNode::Canister(_)
768        | SchemaNode::Entity(_)
769        | SchemaNode::Normalizer(_)
770        | SchemaNode::Store(_)
771        | SchemaNode::Validator(_) => {}
772    }
773}
774
775fn push_item_reference(item: &Item, pending: &mut Vec<String>) {
776    if let ItemTarget::Is(path) = item.target() {
777        pending.push((*path).to_string());
778    }
779}
780
781fn lower_source_rule_operation(
782    schema: &Schema,
783    target: &SchemaNode,
784    rule: &SourceRule,
785) -> Result<ProposalSourceRuleOperation, FragmentLoweringError> {
786    let length_bound = |value: &RuleNumber| {
787        rule_integer_u128(value)
788            .and_then(|value| u64::try_from(value).ok())
789            .ok_or_else(|| {
790                FragmentLoweringError::InvalidReference(format!(
791                    "rule '{}' has an invalid length bound",
792                    rule.name()
793                ))
794            })
795    };
796
797    let operation = match rule.operation() {
798        SourceRuleAuthoringOperation::NumericMinimumInclusive { value } => {
799            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
800            else {
801                return Err(invalid_rule_target(rule));
802            };
803            let value = lower_rule_numeric_literal(primitive, item, value)
804                .ok_or_else(|| invalid_rule_target(rule))?;
805            ProposalSourceRuleOperation::NumericMinimumInclusive { value }
806        }
807        SourceRuleAuthoringOperation::NumericMaximumInclusive { value } => {
808            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
809            else {
810                return Err(invalid_rule_target(rule));
811            };
812            let value = lower_rule_numeric_literal(primitive, item, value)
813                .ok_or_else(|| invalid_rule_target(rule))?;
814            ProposalSourceRuleOperation::NumericMaximumInclusive { value }
815        }
816        SourceRuleAuthoringOperation::NumericRangeInclusive { min, max } => {
817            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
818            else {
819                return Err(invalid_rule_target(rule));
820            };
821            let literal = |value: &RuleNumber| {
822                lower_rule_numeric_literal(primitive, item, value)
823                    .ok_or_else(|| invalid_rule_target(rule))
824            };
825            ProposalSourceRuleOperation::NumericRangeInclusive {
826                min: literal(min)?,
827                max: literal(max)?,
828            }
829        }
830        SourceRuleAuthoringOperation::LengthRangeInclusive { min, max } => {
831            let shape = resolve_rule_value_shape(schema, target)?;
832            if !matches!(
833                shape,
834                RuleValueShape::Collection
835                    | RuleValueShape::Scalar(Primitive::Blob | Primitive::Text, _)
836            ) {
837                return Err(invalid_rule_target(rule));
838            }
839            ProposalSourceRuleOperation::LengthRangeInclusive {
840                min: length_bound(min)?,
841                max: length_bound(max)?,
842            }
843        }
844        SourceRuleAuthoringOperation::MultipleOf { divisor } => {
845            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
846            else {
847                return Err(invalid_rule_target(rule));
848            };
849            let divisor = lower_rule_numeric_literal(primitive, item, divisor)
850                .ok_or_else(|| invalid_rule_target(rule))?;
851            ProposalSourceRuleOperation::MultipleOf { divisor }
852        }
853    };
854    Ok(operation)
855}
856
857fn lower_rule_numeric_literal(
858    primitive: Primitive,
859    item: &Item,
860    value: &RuleNumber,
861) -> Option<ScalarLiteral> {
862    match primitive {
863        Primitive::Decimal => rule_decimal(value)
864            .and_then(|value| exact_decimal_at_scale(value, item.scale().unwrap_or(0)))
865            .map(ScalarLiteral::Decimal),
866        Primitive::Float32 => match value {
867            RuleNumber::Float32(value) => Float32::try_new(*value).map(ScalarLiteral::Float32),
868            RuleNumber::Integer(_) | RuleNumber::Decimal(_) | RuleNumber::Float64(_) => None,
869        },
870        Primitive::Float64 => match value {
871            RuleNumber::Decimal(value) => value
872                .parse::<f64>()
873                .ok()
874                .and_then(Float64::try_new)
875                .map(ScalarLiteral::Float64),
876            RuleNumber::Float64(value) => Float64::try_new(*value).map(ScalarLiteral::Float64),
877            RuleNumber::Integer(_) | RuleNumber::Float32(_) => None,
878        },
879        Primitive::Int8
880        | Primitive::Int16
881        | Primitive::Int32
882        | Primitive::Int64
883        | Primitive::Int128 => rule_integer_i128(value).map(ScalarLiteral::Int),
884        Primitive::Nat8
885        | Primitive::Nat16
886        | Primitive::Nat32
887        | Primitive::Nat64
888        | Primitive::Nat128 => rule_integer_u128(value).map(ScalarLiteral::Nat),
889        Primitive::U256 => rule_integer_text(value)
890            .and_then(|value| U256::from_str(value).ok())
891            .map(ScalarLiteral::U256),
892        Primitive::Account
893        | Primitive::Blob
894        | Primitive::Bool
895        | Primitive::Date
896        | Primitive::Duration
897        | Primitive::Principal
898        | Primitive::Subaccount
899        | Primitive::Text
900        | Primitive::Timestamp
901        | Primitive::Ulid
902        | Primitive::Unit => None,
903    }
904}
905
906const fn rule_integer_text(value: &RuleNumber) -> Option<&str> {
907    let RuleNumber::Integer(value) = value else {
908        return None;
909    };
910    Some(value)
911}
912
913fn rule_integer_i128(value: &RuleNumber) -> Option<i128> {
914    rule_integer_text(value)?.parse().ok()
915}
916
917fn rule_integer_u128(value: &RuleNumber) -> Option<u128> {
918    rule_integer_text(value)?.parse().ok()
919}
920
921fn rule_decimal(value: &RuleNumber) -> Option<Decimal> {
922    match value {
923        RuleNumber::Integer(value) | RuleNumber::Decimal(value) => Decimal::from_str(value).ok(),
924        RuleNumber::Float32(_) | RuleNumber::Float64(_) => None,
925    }
926}
927
928fn exact_decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
929    let value = value.normalize();
930    value
931        .scale_to_integer(scale)
932        .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale))
933}
934
935#[derive(Clone, Copy)]
936enum RuleValueShape<'schema> {
937    Collection,
938    Scalar(Primitive, &'schema Item),
939}
940
941fn resolve_rule_value_shape<'schema>(
942    schema: &'schema Schema,
943    mut target: &'schema SchemaNode,
944) -> Result<RuleValueShape<'schema>, FragmentLoweringError> {
945    let mut visited = BTreeSet::new();
946    loop {
947        let source = named_type_name(target)
948            .ok_or_else(|| FragmentLoweringError::InvalidReference("non-type rule".to_string()))?;
949        if !visited.insert(source) {
950            return Err(FragmentLoweringError::InvalidReference(format!(
951                "durable-rule target cycle at '{source}'"
952            )));
953        }
954        match target {
955            SchemaNode::List(_) | SchemaNode::Map(_) | SchemaNode::Set(_) => {
956                return Ok(RuleValueShape::Collection);
957            }
958            SchemaNode::Newtype(newtype) => match newtype.item().target() {
959                ItemTarget::Primitive(primitive) => {
960                    return Ok(RuleValueShape::Scalar(*primitive, newtype.item()));
961                }
962                ItemTarget::Is(path) => {
963                    target = schema
964                        .get_node(path)
965                        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
966                }
967            },
968            SchemaNode::Record(_) | SchemaNode::Enum(_) | SchemaNode::Tuple(_) => {
969                return Err(FragmentLoweringError::InvalidReference(format!(
970                    "durable-rule target '{source}' has no supported scalar or collection value"
971                )));
972            }
973            SchemaNode::Canister(_)
974            | SchemaNode::Entity(_)
975            | SchemaNode::Normalizer(_)
976            | SchemaNode::Store(_)
977            | SchemaNode::Validator(_) => {
978                return Err(FragmentLoweringError::InvalidReference(
979                    "non-type durable-rule target".to_string(),
980                ));
981            }
982        }
983    }
984}
985
986fn invalid_rule_target(rule: &SourceRule) -> FragmentLoweringError {
987    FragmentLoweringError::InvalidReference(format!(
988        "durable rule '{}' does not match its nominal target",
989        rule.name()
990    ))
991}
992
993fn entity_field_source_key(
994    entity: &Entity,
995    field_name: &str,
996) -> Result<FieldSourceKey, FragmentLoweringError> {
997    let field = entity
998        .fields()
999        .get(field_name)
1000        .ok_or_else(|| FragmentLoweringError::InvalidReference(field_name.to_string()))?;
1001    FieldSourceKey::try_new(field.name()).map_err(Into::into)
1002}
1003
1004// -----------------------------------------------------------------------------
1005// Reachable named-type closure
1006// -----------------------------------------------------------------------------
1007
1008fn lower_reachable_types(
1009    schema: &Schema,
1010    mut pending: Vec<String>,
1011) -> Result<Vec<NamedTypeFragment>, FragmentLoweringError> {
1012    let mut lowered = BTreeMap::new();
1013    while let Some(path) = pending.pop() {
1014        let node = schema
1015            .get_node(path.as_str())
1016            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
1017        let source_key = named_type_name(node)
1018            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
1019        if lowered.contains_key(source_key) {
1020            continue;
1021        }
1022        let fragment = lower_named_type(schema, node, &mut pending)?;
1023        lowered.insert(source_key.to_string(), fragment);
1024    }
1025    Ok(lowered.into_values().collect())
1026}
1027
1028const fn named_type_name(node: &crate::node::SchemaNode) -> Option<&str> {
1029    match node {
1030        crate::node::SchemaNode::Enum(node) => Some(node.name()),
1031        crate::node::SchemaNode::List(node) => Some(node.name()),
1032        crate::node::SchemaNode::Map(node) => Some(node.name()),
1033        crate::node::SchemaNode::Newtype(node) => Some(node.name()),
1034        crate::node::SchemaNode::Record(node) => Some(node.name()),
1035        crate::node::SchemaNode::Set(node) => Some(node.name()),
1036        crate::node::SchemaNode::Tuple(node) => Some(node.name()),
1037        crate::node::SchemaNode::Canister(_)
1038        | crate::node::SchemaNode::Entity(_)
1039        | crate::node::SchemaNode::Normalizer(_)
1040        | crate::node::SchemaNode::Store(_)
1041        | crate::node::SchemaNode::Validator(_) => None,
1042    }
1043}
1044
1045fn lower_named_type(
1046    schema: &Schema,
1047    node: &crate::node::SchemaNode,
1048    pending: &mut Vec<String>,
1049) -> Result<NamedTypeFragment, FragmentLoweringError> {
1050    match node {
1051        crate::node::SchemaNode::Record(record) => lower_record(schema, record, pending),
1052        crate::node::SchemaNode::Enum(r#enum) => lower_enum(schema, r#enum, pending),
1053        crate::node::SchemaNode::Newtype(newtype) => Ok(NamedTypeFragment::newtype(
1054            SchemaName::try_new(newtype.name())?,
1055            lower_item_type(schema, newtype.item(), pending)?,
1056        )),
1057        crate::node::SchemaNode::List(list) => lower_list(schema, list, pending),
1058        crate::node::SchemaNode::Set(set) => lower_set(schema, set, pending),
1059        crate::node::SchemaNode::Map(map) => lower_map(schema, map, pending),
1060        crate::node::SchemaNode::Tuple(tuple) => lower_tuple(schema, tuple, pending),
1061        crate::node::SchemaNode::Canister(_)
1062        | crate::node::SchemaNode::Entity(_)
1063        | crate::node::SchemaNode::Normalizer(_)
1064        | crate::node::SchemaNode::Store(_)
1065        | crate::node::SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
1066            "non-type graph node".to_string(),
1067        )),
1068    }
1069}
1070
1071fn lower_record(
1072    schema: &Schema,
1073    record: &Record,
1074    pending: &mut Vec<String>,
1075) -> Result<NamedTypeFragment, FragmentLoweringError> {
1076    let fields = record
1077        .fields()
1078        .fields()
1079        .iter()
1080        .map(|field| {
1081            Ok(RecordFieldFragment::new(
1082                SchemaName::try_new(field.name())?,
1083                lower_value_type(schema, field.value(), pending)?,
1084                field.value().cardinality() == Cardinality::Opt,
1085            ))
1086        })
1087        .collect::<Result<Vec<_>, FragmentLoweringError>>()?;
1088    Ok(NamedTypeFragment::Record(RecordTypeFragment::try_new(
1089        SchemaName::try_new(record.name())?,
1090        fields,
1091    )?))
1092}
1093
1094fn lower_enum(
1095    schema: &Schema,
1096    r#enum: &Enum,
1097    pending: &mut Vec<String>,
1098) -> Result<NamedTypeFragment, FragmentLoweringError> {
1099    let variants = r#enum
1100        .variants()
1101        .iter()
1102        .map(|variant| {
1103            let name = SchemaName::try_new(variant.name())?;
1104            match variant.value() {
1105                Some(value) if value.cardinality() == Cardinality::Opt => {
1106                    Err(FragmentLoweringError::UnsupportedCardinality(format!(
1107                        "{}::{}",
1108                        r#enum.def().path(),
1109                        variant.name()
1110                    )))
1111                }
1112                Some(value) => Ok(EnumVariantFragment::with_payload(
1113                    name,
1114                    lower_value_type(schema, value, pending)?,
1115                )),
1116                None => Ok(EnumVariantFragment::new(name)),
1117            }
1118        })
1119        .collect::<Result<Vec<_>, _>>()?;
1120    Ok(NamedTypeFragment::Enum(EnumTypeFragment::try_new(
1121        SchemaName::try_new(r#enum.name())?,
1122        variants,
1123    )?))
1124}
1125
1126fn lower_list(
1127    schema: &Schema,
1128    list: &List,
1129    pending: &mut Vec<String>,
1130) -> Result<NamedTypeFragment, FragmentLoweringError> {
1131    Ok(NamedTypeFragment::list(
1132        SchemaName::try_new(list.name())?,
1133        lower_item_type(schema, list.item(), pending)?,
1134    ))
1135}
1136
1137fn lower_set(
1138    schema: &Schema,
1139    set: &Set,
1140    pending: &mut Vec<String>,
1141) -> Result<NamedTypeFragment, FragmentLoweringError> {
1142    Ok(NamedTypeFragment::set(
1143        SchemaName::try_new(set.name())?,
1144        lower_item_type(schema, set.item(), pending)?,
1145    ))
1146}
1147
1148fn lower_map(
1149    schema: &Schema,
1150    map: &Map,
1151    pending: &mut Vec<String>,
1152) -> Result<NamedTypeFragment, FragmentLoweringError> {
1153    if map.value().cardinality() == Cardinality::Opt {
1154        return Err(FragmentLoweringError::UnsupportedCardinality(
1155            map.def().path(),
1156        ));
1157    }
1158    Ok(NamedTypeFragment::map(
1159        SchemaName::try_new(map.name())?,
1160        lower_item_type(schema, map.key(), pending)?,
1161        lower_value_type(schema, map.value(), pending)?,
1162    ))
1163}
1164
1165fn lower_tuple(
1166    schema: &Schema,
1167    tuple: &Tuple,
1168    pending: &mut Vec<String>,
1169) -> Result<NamedTypeFragment, FragmentLoweringError> {
1170    let members = tuple
1171        .values()
1172        .iter()
1173        .map(|value| {
1174            Ok::<_, FragmentLoweringError>(TupleElementFragment::new(
1175                lower_value_type(schema, value, pending)?,
1176                value.cardinality() == Cardinality::Opt,
1177            ))
1178        })
1179        .collect::<Result<Vec<_>, _>>()?;
1180    Ok(NamedTypeFragment::tuple(
1181        SchemaName::try_new(tuple.name())?,
1182        members,
1183    ))
1184}
1185
1186// -----------------------------------------------------------------------------
1187// Exact field contracts
1188// -----------------------------------------------------------------------------
1189
1190fn lower_value_type(
1191    schema: &Schema,
1192    value: &Value,
1193    pending: &mut Vec<String>,
1194) -> Result<FieldType, FragmentLoweringError> {
1195    let item = lower_item_type(schema, value.item(), pending)?;
1196    Ok(if value.cardinality() == Cardinality::Many {
1197        FieldType::List(Box::new(item))
1198    } else {
1199        item
1200    })
1201}
1202
1203fn lower_item_type(
1204    schema: &Schema,
1205    item: &Item,
1206    pending: &mut Vec<String>,
1207) -> Result<FieldType, FragmentLoweringError> {
1208    match item.target() {
1209        ItemTarget::Is(path) => {
1210            pending.push((*path).to_string());
1211            Ok(FieldType::Named(TypeSourceKey::try_new(
1212                type_source_key_for_path(schema, path)?,
1213            )?))
1214        }
1215        ItemTarget::Primitive(primitive) => {
1216            Ok(FieldType::Scalar(lower_scalar_type(*primitive, item)))
1217        }
1218    }
1219}
1220
1221fn type_source_key_for_path<'schema>(
1222    schema: &'schema Schema,
1223    path: &str,
1224) -> Result<&'schema str, FragmentLoweringError> {
1225    let source = schema
1226        .get_node(path)
1227        .and_then(named_type_name)
1228        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
1229    Ok(source)
1230}
1231
1232fn lower_scalar_type(primitive: Primitive, item: &Item) -> ScalarType {
1233    match primitive {
1234        Primitive::Account => ScalarType::Account,
1235        Primitive::Blob => ScalarType::Blob {
1236            max_len: item.max_len(),
1237        },
1238        Primitive::Bool => ScalarType::Bool,
1239        Primitive::Date => ScalarType::Date,
1240        Primitive::Decimal => ScalarType::Decimal {
1241            scale: item.scale().unwrap_or(0),
1242        },
1243        Primitive::Duration => ScalarType::Duration,
1244        Primitive::Float32 => ScalarType::Float32,
1245        Primitive::Float64 => ScalarType::Float64,
1246        Primitive::Int8 => ScalarType::Int8,
1247        Primitive::Int16 => ScalarType::Int16,
1248        Primitive::Int32 => ScalarType::Int32,
1249        Primitive::Int64 => ScalarType::Int64,
1250        Primitive::Int128 => ScalarType::Int128,
1251        Primitive::Nat8 => ScalarType::Nat8,
1252        Primitive::Nat16 => ScalarType::Nat16,
1253        Primitive::Nat32 => ScalarType::Nat32,
1254        Primitive::Nat64 => ScalarType::Nat64,
1255        Primitive::Nat128 => ScalarType::Nat128,
1256        Primitive::U256 => ScalarType::U256,
1257        Primitive::Principal => ScalarType::Principal,
1258        Primitive::Subaccount => ScalarType::Subaccount,
1259        Primitive::Text => ScalarType::Text {
1260            max_len: item.max_len(),
1261        },
1262        Primitive::Timestamp => ScalarType::Timestamp,
1263        Primitive::Ulid => ScalarType::Ulid,
1264        Primitive::Unit => ScalarType::Unit,
1265    }
1266}
1267
1268// -----------------------------------------------------------------------------
1269// Authored database defaults
1270// -----------------------------------------------------------------------------
1271
1272fn lower_default(
1273    schema: &Schema,
1274    field: &Field,
1275    default: &Arg,
1276) -> Result<ScalarLiteral, FragmentLoweringError> {
1277    if let ItemTarget::Is(path) = field.value().item().target() {
1278        let Arg::ConstPath(default_path) = default else {
1279            return Err(FragmentLoweringError::InvalidDefault(
1280                field.name().to_string(),
1281            ));
1282        };
1283        let variant = default_path.rsplit("::").next().unwrap_or(default_path);
1284        return schema
1285            .enum_unit_literal(path, variant)
1286            .map_err(FragmentLoweringError::from);
1287    }
1288    let ItemTarget::Primitive(primitive) = field.value().item().target() else {
1289        return Err(FragmentLoweringError::InvalidDefault(
1290            field.name().to_string(),
1291        ));
1292    };
1293    lower_scalar_default(*primitive, field.value().item(), default)
1294        .ok_or_else(|| FragmentLoweringError::InvalidDefault(field.name().to_string()))
1295}
1296
1297fn lower_scalar_default(primitive: Primitive, item: &Item, default: &Arg) -> Option<ScalarLiteral> {
1298    if default_constructor_is_zero(default) {
1299        return zero_scalar_literal(primitive, item);
1300    }
1301    match (primitive, default) {
1302        (Primitive::Account, Arg::String(value)) => {
1303            Account::from_str(value).ok().map(ScalarLiteral::Account)
1304        }
1305        (Primitive::Blob, Arg::String(value)) => lower_blob_default(value),
1306        (Primitive::Bool, Arg::Bool(value)) => Some(ScalarLiteral::Bool(*value)),
1307        (Primitive::Date, Arg::String(value)) => Date::parse(value).map(ScalarLiteral::Date),
1308        (Primitive::Date, Arg::Number(value)) => arg_i128(value)
1309            .and_then(|value| i32::try_from(value).ok())
1310            .and_then(Date::try_from_days_since_epoch)
1311            .map(ScalarLiteral::Date),
1312        (Primitive::Decimal, Arg::String(value)) => Decimal::from_str(value)
1313            .ok()
1314            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1315            .map(ScalarLiteral::Decimal),
1316        (Primitive::Decimal, Arg::Number(value)) => arg_decimal(value)
1317            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1318            .map(ScalarLiteral::Decimal),
1319        (Primitive::Duration, Arg::String(value)) => Duration::parse_flexible(value)
1320            .ok()
1321            .map(ScalarLiteral::Duration),
1322        (Primitive::Duration, Arg::Number(value)) => arg_u128(value)
1323            .and_then(|value| u64::try_from(value).ok())
1324            .map(Duration::from_millis)
1325            .map(ScalarLiteral::Duration),
1326        (Primitive::Float32, Arg::Number(ArgNumber::Float32(value))) => {
1327            Float32::try_new(*value).map(ScalarLiteral::Float32)
1328        }
1329        (Primitive::Float64, Arg::Number(ArgNumber::Float64(value))) => {
1330            Float64::try_new(*value).map(ScalarLiteral::Float64)
1331        }
1332        (
1333            Primitive::Int8
1334            | Primitive::Int16
1335            | Primitive::Int32
1336            | Primitive::Int64
1337            | Primitive::Int128,
1338            Arg::Number(value),
1339        ) => arg_i128(value).map(ScalarLiteral::Int),
1340        (
1341            Primitive::Nat8
1342            | Primitive::Nat16
1343            | Primitive::Nat32
1344            | Primitive::Nat64
1345            | Primitive::Nat128,
1346            Arg::Number(value),
1347        ) => arg_u128(value).map(ScalarLiteral::Nat),
1348        (Primitive::U256, Arg::Number(value)) => {
1349            arg_u128(value).map(U256::from).map(ScalarLiteral::U256)
1350        }
1351        (Primitive::U256, Arg::String(value)) => {
1352            U256::from_str(value).ok().map(ScalarLiteral::U256)
1353        }
1354        (Primitive::Principal, Arg::String(value)) => Principal::from_str(value)
1355            .ok()
1356            .map(ScalarLiteral::Principal),
1357        (Primitive::Subaccount, Arg::String(value)) => Subaccount::from_hex(value)
1358            .ok()
1359            .map(ScalarLiteral::Subaccount),
1360        (Primitive::Text, Arg::String(value)) => Some(ScalarLiteral::Text((*value).to_string())),
1361        (Primitive::Timestamp, Arg::String(value)) => Timestamp::parse_flexible(value)
1362            .ok()
1363            .map(ScalarLiteral::Timestamp),
1364        (Primitive::Timestamp, Arg::Number(value)) => arg_i128(value)
1365            .and_then(|value| i64::try_from(value).ok())
1366            .map(Timestamp::from_millis)
1367            .map(ScalarLiteral::Timestamp),
1368        (Primitive::Ulid, Arg::String(value)) => {
1369            Ulid::from_str(value).ok().map(ScalarLiteral::Ulid)
1370        }
1371        (Primitive::Unit, Arg::ConstPath(path)) if path.ends_with("Unit") => {
1372            Some(ScalarLiteral::Unit(Unit))
1373        }
1374        _ => None,
1375    }
1376}
1377
1378fn default_constructor_is_zero(default: &Arg) -> bool {
1379    let Arg::FuncPath(path) = default else {
1380        return false;
1381    };
1382    path.ends_with("::default")
1383        || path.ends_with("::new")
1384        || path.ends_with("::EPOCH")
1385        || path.ends_with("::nil")
1386}
1387
1388fn zero_scalar_literal(primitive: Primitive, item: &Item) -> Option<ScalarLiteral> {
1389    match primitive {
1390        Primitive::Blob => Some(ScalarLiteral::Blob(Blob::default())),
1391        Primitive::Bool => Some(ScalarLiteral::Bool(false)),
1392        Primitive::Date => Some(ScalarLiteral::Date(Date::EPOCH)),
1393        Primitive::Decimal => Decimal::try_from_i128_with_scale(0, item.scale().unwrap_or(0))
1394            .map(ScalarLiteral::Decimal),
1395        Primitive::Duration => Some(ScalarLiteral::Duration(Duration::ZERO)),
1396        Primitive::Float32 => Float32::try_new(0.0).map(ScalarLiteral::Float32),
1397        Primitive::Float64 => Float64::try_new(0.0).map(ScalarLiteral::Float64),
1398        Primitive::Int8
1399        | Primitive::Int16
1400        | Primitive::Int32
1401        | Primitive::Int64
1402        | Primitive::Int128 => Some(ScalarLiteral::Int(0)),
1403        Primitive::Nat8
1404        | Primitive::Nat16
1405        | Primitive::Nat32
1406        | Primitive::Nat64
1407        | Primitive::Nat128 => Some(ScalarLiteral::Nat(0)),
1408        Primitive::U256 => Some(ScalarLiteral::U256(U256::ZERO)),
1409        Primitive::Text => Some(ScalarLiteral::Text(String::new())),
1410        Primitive::Timestamp => Some(ScalarLiteral::Timestamp(Timestamp::EPOCH)),
1411        Primitive::Ulid => Some(ScalarLiteral::Ulid(Ulid::nil())),
1412        Primitive::Unit => Some(ScalarLiteral::Unit(Unit)),
1413        Primitive::Account | Primitive::Principal | Primitive::Subaccount => None,
1414    }
1415}
1416
1417fn lower_blob_default(value: &str) -> Option<ScalarLiteral> {
1418    Blob::from_hex_literal(value).ok().map(ScalarLiteral::Blob)
1419}
1420
1421// -----------------------------------------------------------------------------
1422// Literal conversion helpers
1423// -----------------------------------------------------------------------------
1424
1425fn decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
1426    match value.scale().cmp(&scale) {
1427        std::cmp::Ordering::Equal => Some(value),
1428        std::cmp::Ordering::Less => value
1429            .scale_to_integer(scale)
1430            .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale)),
1431        std::cmp::Ordering::Greater => Some(value.round_dp(scale)),
1432    }
1433}
1434
1435fn arg_i128(value: &ArgNumber) -> Option<i128> {
1436    match value {
1437        ArgNumber::Int8(value) => Some(i128::from(*value)),
1438        ArgNumber::Int16(value) => Some(i128::from(*value)),
1439        ArgNumber::Int32(value) => Some(i128::from(*value)),
1440        ArgNumber::Int64(value) => Some(i128::from(*value)),
1441        ArgNumber::Int128(value) => Some(*value),
1442        ArgNumber::Nat8(value) => Some(i128::from(*value)),
1443        ArgNumber::Nat16(value) => Some(i128::from(*value)),
1444        ArgNumber::Nat32(value) => Some(i128::from(*value)),
1445        ArgNumber::Nat64(value) => Some(i128::from(*value)),
1446        ArgNumber::Nat128(value) => i128::try_from(*value).ok(),
1447        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1448    }
1449}
1450
1451fn arg_u128(value: &ArgNumber) -> Option<u128> {
1452    match value {
1453        ArgNumber::Int8(value) => u128::try_from(*value).ok(),
1454        ArgNumber::Int16(value) => u128::try_from(*value).ok(),
1455        ArgNumber::Int32(value) => u128::try_from(*value).ok(),
1456        ArgNumber::Int64(value) => u128::try_from(*value).ok(),
1457        ArgNumber::Int128(value) => u128::try_from(*value).ok(),
1458        ArgNumber::Nat8(value) => Some(u128::from(*value)),
1459        ArgNumber::Nat16(value) => Some(u128::from(*value)),
1460        ArgNumber::Nat32(value) => Some(u128::from(*value)),
1461        ArgNumber::Nat64(value) => Some(u128::from(*value)),
1462        ArgNumber::Nat128(value) => Some(*value),
1463        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1464    }
1465}
1466
1467fn arg_decimal(value: &ArgNumber) -> Option<Decimal> {
1468    match value {
1469        ArgNumber::Float32(value) => Decimal::from_f32_lossy(*value),
1470        ArgNumber::Float64(value) => Decimal::from_f64_lossy(*value),
1471        _ => arg_i128(value).and_then(Decimal::from_i128),
1472    }
1473}
1474
1475#[cfg(test)]
1476mod tests {
1477    use icydb_schema::{
1478        ConstraintFragmentKind, ConstraintSourceKey, Decimal, FieldSourceKey, FieldType,
1479        MAX_PROPOSAL_LITERAL_BYTES, NamedTypeFragment, RuleSourceKey, ScalarLiteral, ScalarType,
1480        SourceRuleOperation, TypeSourceKey,
1481    };
1482
1483    use super::{
1484        Schema, lower_blob_default, lower_field_rules, lower_nested_relations, lower_scalar_default,
1485    };
1486    use crate::{
1487        node::{
1488            Arg, Args, Canister, Def, Entity, Enum, EnumVariant, Field, FieldList, Item,
1489            ItemTarget, Newtype, Normalizer, PrimaryKey, PrimaryKeySource, Record, RuleNumber,
1490            SchemaNode, SourceRule, SourceRuleAuthoringOperation, Store, StoreHeapConfig, Type,
1491            TypeNormalizer, TypeValidator, Validator, Value,
1492        },
1493        types::{Cardinality, Primitive},
1494    };
1495
1496    #[test]
1497    fn blob_defaults_and_migration_fills_share_exact_bytes_and_refusals() {
1498        for text in ["", "00", "deadbeef", "DeAdBeEf", "ff00"] {
1499            let default = lower_blob_default(text).unwrap();
1500            let fill = crate::node::migration_literal_from_text("blob", text).unwrap();
1501            assert_eq!(default, fill);
1502        }
1503        assert_eq!(
1504            lower_blob_default("deadbeef"),
1505            Some(ScalarLiteral::Blob(icydb_schema::Blob::from(vec![
1506                0xde, 0xad, 0xbe, 0xef
1507            ])))
1508        );
1509        for text in ["a", "zz", "+1", "0x00", " 00", "é", "aé0"] {
1510            assert!(lower_blob_default(text).is_none());
1511            assert_eq!(
1512                crate::node::migration_literal_from_text("blob", text),
1513                Err(icydb_schema::SchemaContractError::InvalidLiteral)
1514            );
1515        }
1516        let maximum = "ff".repeat(MAX_PROPOSAL_LITERAL_BYTES);
1517        assert_eq!(
1518            lower_blob_default(&maximum),
1519            Some(crate::node::migration_literal_from_text("blob", &maximum).unwrap())
1520        );
1521        let oversized = format!("{maximum}00");
1522        assert!(lower_blob_default(&oversized).is_none());
1523        assert_eq!(
1524            crate::node::migration_literal_from_text("blob", &oversized),
1525            Err(icydb_schema::SchemaContractError::InvalidLiteral)
1526        );
1527        let subaccount = "aB".repeat(32);
1528        assert!(crate::node::migration_literal_from_text("subaccount", &subaccount).is_ok());
1529        for text in [
1530            format!("0x{subaccount}"),
1531            "00".repeat(31),
1532            "00".repeat(33),
1533            "gg".repeat(32),
1534            "+1".repeat(32),
1535        ] {
1536            assert_eq!(
1537                crate::node::migration_literal_from_text("subaccount", &text),
1538                Err(icydb_schema::SchemaContractError::InvalidLiteral)
1539            );
1540        }
1541    }
1542
1543    #[test]
1544    fn blob_default_lowering_enforces_the_proposal_literal_bound() {
1545        let maximum = "ab".repeat(MAX_PROPOSAL_LITERAL_BYTES);
1546        let oversized = "ab".repeat(MAX_PROPOSAL_LITERAL_BYTES + 1);
1547
1548        assert_eq!(
1549            lower_blob_default(&maximum).and_then(|literal| match literal {
1550                ScalarLiteral::Blob(value) => Some(value.len()),
1551                _ => None,
1552            }),
1553            Some(MAX_PROPOSAL_LITERAL_BYTES),
1554        );
1555        assert_eq!(lower_blob_default(&oversized), None);
1556    }
1557
1558    #[test]
1559    fn duration_default_lowering_rejects_suffixed_overflow() {
1560        let item = Item::new(
1561            ItemTarget::Primitive(Primitive::Duration),
1562            None,
1563            None,
1564            None,
1565            &[],
1566            &[],
1567            false,
1568        );
1569
1570        assert_eq!(
1571            lower_scalar_default(
1572                Primitive::Duration,
1573                &item,
1574                &Arg::String("18446744073709551615ms"),
1575            ),
1576            Some(ScalarLiteral::Duration(icydb_schema::Duration::MAX)),
1577        );
1578        assert_eq!(
1579            lower_scalar_default(
1580                Primitive::Duration,
1581                &item,
1582                &Arg::String("18446744073709552s"),
1583            ),
1584            None,
1585        );
1586    }
1587
1588    static EMPTY_TYPE: Type = Type::new(&[], &[], &[]);
1589    static APPLICATION_FIELDS: [Field; 1] = [Field::new(
1590        "id",
1591        Value::new(
1592            Cardinality::One,
1593            Item::new(
1594                ItemTarget::Primitive(Primitive::Nat64),
1595                None,
1596                None,
1597                None,
1598                &[],
1599                &[],
1600                false,
1601            ),
1602        ),
1603        None,
1604        None,
1605        None,
1606    )];
1607    static APPLICATION_NORMALIZERS_A: [TypeNormalizer; 1] =
1608        [TypeNormalizer::new("test::NormalizeA", Args(&[]))];
1609    static APPLICATION_NORMALIZERS_B: [TypeNormalizer; 1] =
1610        [TypeNormalizer::new("test::NormalizeB", Args(&[]))];
1611    static APPLICATION_VALIDATORS_A: [TypeValidator; 1] =
1612        [TypeValidator::new("test::ValidateA", Args(&[]))];
1613    static APPLICATION_VALIDATORS_B: [TypeValidator; 1] =
1614        [TypeValidator::new("test::ValidateB", Args(&[]))];
1615    static NUMERIC_RULES: [SourceRule; 1] = [SourceRule::new(
1616        "range",
1617        SourceRuleAuthoringOperation::NumericRangeInclusive {
1618            min: RuleNumber::Integer("0"),
1619            max: RuleNumber::Integer("360"),
1620        },
1621    )];
1622    static NUMERIC_RULE_TYPE: Type = Type::new(&[], &[], &NUMERIC_RULES);
1623    static LENGTH_RULES: [SourceRule; 1] = [SourceRule::new(
1624        "length",
1625        SourceRuleAuthoringOperation::LengthRangeInclusive {
1626            min: RuleNumber::Integer("2"),
1627            max: RuleNumber::Integer("40"),
1628        },
1629    )];
1630    static LENGTH_RULE_TYPE: Type = Type::new(&[], &[], &LENGTH_RULES);
1631    static NAT_EXACT_RULES: [SourceRule; 2] = [
1632        SourceRule::new(
1633            "maximum",
1634            SourceRuleAuthoringOperation::NumericMaximumInclusive {
1635                value: RuleNumber::Integer("100"),
1636            },
1637        ),
1638        SourceRule::new(
1639            "step",
1640            SourceRuleAuthoringOperation::MultipleOf {
1641                divisor: RuleNumber::Integer("5"),
1642            },
1643        ),
1644    ];
1645    static NAT_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &NAT_EXACT_RULES);
1646    static DECIMAL_EXACT_RULES: [SourceRule; 1] = [SourceRule::new(
1647        "step",
1648        SourceRuleAuthoringOperation::MultipleOf {
1649            divisor: RuleNumber::Decimal("0.25"),
1650        },
1651    )];
1652    static DECIMAL_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &DECIMAL_EXACT_RULES);
1653    static INEXACT_DECIMAL_RULES: [SourceRule; 1] = [SourceRule::new(
1654        "step",
1655        SourceRuleAuthoringOperation::MultipleOf {
1656            divisor: RuleNumber::Decimal("0.251"),
1657        },
1658    )];
1659    static INEXACT_DECIMAL_RULE_TYPE: Type = Type::new(&[], &[], &INEXACT_DECIMAL_RULES);
1660    static NESTED_RULE_FIELDS: [Field; 1] = [Field::new(
1661        "degrees",
1662        Value::new(
1663            Cardinality::One,
1664            Item::new(
1665                ItemTarget::Is("test::Degrees"),
1666                None,
1667                None,
1668                None,
1669                &[],
1670                &[],
1671                false,
1672            ),
1673        ),
1674        None,
1675        None,
1676        None,
1677    )];
1678    static STATUS_VARIANTS: [EnumVariant; 2] = [
1679        EnumVariant::new("Active", None),
1680        EnumVariant::new(
1681            "Retries",
1682            Some(Value::new(
1683                Cardinality::Many,
1684                Item::new(
1685                    ItemTarget::Primitive(Primitive::Nat16),
1686                    None,
1687                    None,
1688                    None,
1689                    &[],
1690                    &[],
1691                    false,
1692                ),
1693            )),
1694        ),
1695    ];
1696    static RECURSIVE_RECORD_FIELDS: [Field; 1] = [Field::new(
1697        "next",
1698        Value::new(
1699            Cardinality::Opt,
1700            Item::new(
1701                ItemTarget::Is("test::Recursive"),
1702                None,
1703                None,
1704                None,
1705                &[],
1706                &[],
1707                false,
1708            ),
1709        ),
1710        None,
1711        None,
1712        None,
1713    )];
1714    static RECURSIVE_ENTITY_FIELDS: [Field; 1] = [Field::new(
1715        "root",
1716        Value::new(
1717            Cardinality::One,
1718            Item::new(
1719                ItemTarget::Is("test::Recursive"),
1720                None,
1721                None,
1722                None,
1723                &[],
1724                &[],
1725                false,
1726            ),
1727        ),
1728        None,
1729        None,
1730        None,
1731    )];
1732
1733    #[test]
1734    fn recursive_structural_type_without_a_finite_relation_path_is_ignored() {
1735        let mut schema = Schema::new();
1736        schema.insert_node(SchemaNode::Record(Record::new(
1737            Def::new("test", "Recursive"),
1738            "Recursive",
1739            FieldList::new(&RECURSIVE_RECORD_FIELDS),
1740            EMPTY_TYPE.clone(),
1741        )));
1742        let entity = Entity::new(
1743            Def::new("test", "RecursiveOwner"),
1744            "test::Store",
1745            1,
1746            PrimaryKey::new(&["root"], PrimaryKeySource::External),
1747            &[],
1748            &[],
1749            &[],
1750            FieldList::new(&RECURSIVE_ENTITY_FIELDS),
1751            EMPTY_TYPE.clone(),
1752        );
1753
1754        assert!(
1755            lower_nested_relations(&schema, &entity, &RECURSIVE_ENTITY_FIELDS[0])
1756                .expect("recursive structural types without finite relations should lower")
1757                .is_empty(),
1758        );
1759    }
1760
1761    fn application_behavior_fragment(
1762        normalizers: &'static [TypeNormalizer],
1763        validators: &'static [TypeValidator],
1764        normalizer_name: &'static str,
1765        validator_name: &'static str,
1766    ) -> icydb_schema::SchemaFragment {
1767        let mut schema = Schema::new();
1768        schema.insert_node(SchemaNode::Canister(Canister::new(
1769            Def::new("test", "Canister"),
1770            "test",
1771            None,
1772        )));
1773        schema.insert_node(SchemaNode::Store(Store::new_heap(
1774            Def::new("test", "Store"),
1775            "test::Canister",
1776            StoreHeapConfig::new(),
1777        )));
1778        schema.insert_node(SchemaNode::Normalizer(Normalizer::new(Def::new(
1779            "test",
1780            normalizer_name,
1781        ))));
1782        schema.insert_node(SchemaNode::Validator(Validator::new(Def::new(
1783            "test",
1784            validator_name,
1785        ))));
1786        schema.insert_node(SchemaNode::Entity(Entity::new(
1787            Def::new("test", "ApplicationOnly"),
1788            "test::Store",
1789            1,
1790            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1791            &[],
1792            &[],
1793            &[],
1794            FieldList::new(&APPLICATION_FIELDS),
1795            Type::new(normalizers, validators, &[]),
1796        )));
1797        schema.seal().expect("application-only fixture should seal");
1798        schema
1799            .schema_fragment_for_canister("test::Canister")
1800            .expect("application-only fixture should lower")
1801    }
1802
1803    #[test]
1804    fn validator_and_normalizer_edits_do_not_change_database_fragment() {
1805        let before = application_behavior_fragment(
1806            &APPLICATION_NORMALIZERS_A,
1807            &APPLICATION_VALIDATORS_A,
1808            "NormalizeA",
1809            "ValidateA",
1810        );
1811        let after = application_behavior_fragment(
1812            &APPLICATION_NORMALIZERS_B,
1813            &APPLICATION_VALIDATORS_B,
1814            "NormalizeB",
1815            "ValidateB",
1816        );
1817
1818        assert_eq!(before, after);
1819    }
1820
1821    #[test]
1822    fn durable_rules_nested_below_structural_fields_lower_to_nominal_targets() {
1823        let mut schema = Schema::new();
1824        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1825            Def::new("test", "Degrees"),
1826            "Degrees",
1827            Item::new(
1828                ItemTarget::Primitive(Primitive::Nat16),
1829                None,
1830                None,
1831                None,
1832                &[],
1833                &[],
1834                false,
1835            ),
1836            None,
1837            NUMERIC_RULE_TYPE.clone(),
1838        )));
1839        schema.insert_node(SchemaNode::Record(Record::new(
1840            Def::new("test", "Nested"),
1841            "Nested",
1842            FieldList::new(&NESTED_RULE_FIELDS),
1843            EMPTY_TYPE.clone(),
1844        )));
1845
1846        let outer = Field::new(
1847            "nested",
1848            Value::new(
1849                Cardinality::One,
1850                Item::new(
1851                    ItemTarget::Is("test::Nested"),
1852                    None,
1853                    None,
1854                    None,
1855                    &[],
1856                    &[],
1857                    false,
1858                ),
1859            ),
1860            None,
1861            None,
1862            None,
1863        );
1864        let constraints =
1865            lower_field_rules(&schema, &outer).expect("nested durable rule should lower");
1866        assert_eq!(constraints.len(), 1);
1867        let ConstraintFragmentKind::TargetedRule(rule) = constraints[0].kind() else {
1868            panic!("nested durable rule should use the targeted-rule contract")
1869        };
1870        assert_eq!(rule.root().as_str(), "nested");
1871        assert_eq!(rule.target_type().as_str(), "Degrees");
1872        assert!(matches!(
1873            rule.operation(),
1874            SourceRuleOperation::NumericRangeInclusive { .. }
1875        ));
1876    }
1877
1878    #[test]
1879    fn exact_maximum_and_multiple_of_lower_without_float_reconstruction() {
1880        let mut schema = Schema::new();
1881        for (name, primitive, scale, rules) in [
1882            (
1883                "Counter",
1884                Primitive::Nat64,
1885                None,
1886                NAT_EXACT_RULE_TYPE.clone(),
1887            ),
1888            (
1889                "PriceStep",
1890                Primitive::Decimal,
1891                Some(2),
1892                DECIMAL_EXACT_RULE_TYPE.clone(),
1893            ),
1894        ] {
1895            schema.insert_node(SchemaNode::Newtype(Newtype::new(
1896                Def::new("test", name),
1897                name,
1898                Item::new(
1899                    ItemTarget::Primitive(primitive),
1900                    None,
1901                    scale,
1902                    None,
1903                    &[],
1904                    &[],
1905                    false,
1906                ),
1907                None,
1908                rules,
1909            )));
1910        }
1911
1912        let field = |name| {
1913            Field::new(
1914                name,
1915                Value::new(
1916                    Cardinality::One,
1917                    Item::new(
1918                        ItemTarget::Is(if name == "counter" {
1919                            "test::Counter"
1920                        } else {
1921                            "test::PriceStep"
1922                        }),
1923                        None,
1924                        None,
1925                        None,
1926                        &[],
1927                        &[],
1928                        false,
1929                    ),
1930                ),
1931                None,
1932                None,
1933                None,
1934            )
1935        };
1936        let counter = lower_field_rules(&schema, &field("counter"))
1937            .expect("exact integer rules should lower");
1938        assert!(matches!(
1939            counter[0].kind(),
1940            ConstraintFragmentKind::TargetedRule(rule)
1941                if matches!(
1942                    rule.operation(),
1943                    SourceRuleOperation::NumericMaximumInclusive {
1944                        value: ScalarLiteral::Nat(100)
1945                    }
1946                )
1947        ));
1948        assert!(matches!(
1949            counter[1].kind(),
1950            ConstraintFragmentKind::TargetedRule(rule)
1951                if matches!(
1952                    rule.operation(),
1953                    SourceRuleOperation::MultipleOf {
1954                        divisor: ScalarLiteral::Nat(5)
1955                    }
1956                )
1957        ));
1958
1959        let decimal = lower_field_rules(&schema, &field("price"))
1960            .expect("exact decimal multiple should lower");
1961        assert!(matches!(
1962            decimal[0].kind(),
1963            ConstraintFragmentKind::TargetedRule(rule)
1964                if matches!(
1965                    rule.operation(),
1966                    SourceRuleOperation::MultipleOf {
1967                        divisor: ScalarLiteral::Decimal(value)
1968                    } if *value == Decimal::new(25, 2)
1969                )
1970        ));
1971    }
1972
1973    #[test]
1974    fn inexact_decimal_rule_operand_rejects_before_proposal_composition() {
1975        let mut schema = Schema::new();
1976        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1977            Def::new("test", "InexactStep"),
1978            "InexactStep",
1979            Item::new(
1980                ItemTarget::Primitive(Primitive::Decimal),
1981                None,
1982                Some(2),
1983                None,
1984                &[],
1985                &[],
1986                false,
1987            ),
1988            None,
1989            INEXACT_DECIMAL_RULE_TYPE.clone(),
1990        )));
1991        let field = Field::new(
1992            "price",
1993            Value::new(
1994                Cardinality::One,
1995                Item::new(
1996                    ItemTarget::Is("test::InexactStep"),
1997                    None,
1998                    None,
1999                    None,
2000                    &[],
2001                    &[],
2002                    false,
2003                ),
2004            ),
2005            None,
2006            None,
2007            None,
2008        );
2009        assert!(lower_field_rules(&schema, &field).is_err());
2010    }
2011
2012    static ENTITY_FIELDS: [Field; 5] = [
2013        Field::new(
2014            "id",
2015            Value::new(
2016                Cardinality::One,
2017                Item::new(
2018                    ItemTarget::Primitive(Primitive::Nat64),
2019                    None,
2020                    None,
2021                    None,
2022                    &[],
2023                    &[],
2024                    false,
2025                ),
2026            ),
2027            None,
2028            None,
2029            None,
2030        ),
2031        Field::new(
2032            "tags",
2033            Value::new(
2034                Cardinality::Many,
2035                Item::new(
2036                    ItemTarget::Primitive(Primitive::Text),
2037                    None,
2038                    None,
2039                    Some(32),
2040                    &[],
2041                    &[],
2042                    false,
2043                ),
2044            ),
2045            None,
2046            None,
2047            None,
2048        ),
2049        Field::new(
2050            "status",
2051            Value::new(
2052                Cardinality::One,
2053                Item::new(
2054                    ItemTarget::Is("test::Status"),
2055                    None,
2056                    None,
2057                    None,
2058                    &[],
2059                    &[],
2060                    false,
2061                ),
2062            ),
2063            Some(crate::node::Arg::ConstPath("test::Status::Active")),
2064            None,
2065            None,
2066        ),
2067        Field::new(
2068            "degrees",
2069            Value::new(
2070                Cardinality::One,
2071                Item::new(
2072                    ItemTarget::Is("test::Degrees"),
2073                    None,
2074                    None,
2075                    None,
2076                    &[],
2077                    &[],
2078                    false,
2079                ),
2080            ),
2081            None,
2082            None,
2083            None,
2084        ),
2085        Field::new(
2086            "label",
2087            Value::new(
2088                Cardinality::One,
2089                Item::new(
2090                    ItemTarget::Is("test::Label"),
2091                    None,
2092                    None,
2093                    None,
2094                    &[],
2095                    &[],
2096                    false,
2097                ),
2098            ),
2099            None,
2100            None,
2101            None,
2102        ),
2103    ];
2104
2105    #[test]
2106    #[expect(
2107        clippy::too_many_lines,
2108        reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
2109    )]
2110    fn sealed_canister_graph_emits_store_free_database_closure() {
2111        let mut schema = Schema::new();
2112        schema.insert_node(SchemaNode::Canister(Canister::new(
2113            Def::new("test", "Canister"),
2114            "test",
2115            None,
2116        )));
2117        schema.insert_node(SchemaNode::Store(Store::new_heap(
2118            Def::new("test", "Store"),
2119            "test::Canister",
2120            StoreHeapConfig::new(),
2121        )));
2122        schema.insert_node(SchemaNode::Enum(Enum::new(
2123            Def::new("test", "Status"),
2124            "Status",
2125            &STATUS_VARIANTS,
2126            EMPTY_TYPE.clone(),
2127        )));
2128        schema.insert_node(SchemaNode::Newtype(Newtype::new(
2129            Def::new("test", "Degrees"),
2130            "Degrees",
2131            Item::new(
2132                ItemTarget::Primitive(Primitive::Nat16),
2133                None,
2134                None,
2135                None,
2136                &[],
2137                &[],
2138                false,
2139            ),
2140            None,
2141            NUMERIC_RULE_TYPE.clone(),
2142        )));
2143        schema.insert_node(SchemaNode::Newtype(Newtype::new(
2144            Def::new("test", "Label"),
2145            "Label",
2146            Item::new(
2147                ItemTarget::Primitive(Primitive::Text),
2148                None,
2149                None,
2150                None,
2151                &[],
2152                &[],
2153                false,
2154            ),
2155            None,
2156            LENGTH_RULE_TYPE.clone(),
2157        )));
2158        schema.insert_node(SchemaNode::Entity(Entity::new(
2159            Def::new("test", "Task"),
2160            "test::Store",
2161            1,
2162            PrimaryKey::new(&["id"], PrimaryKeySource::External),
2163            &[],
2164            &[],
2165            &[],
2166            FieldList::new(&ENTITY_FIELDS),
2167            EMPTY_TYPE.clone(),
2168        )));
2169        schema.seal().expect("fixture graph should seal");
2170
2171        let fragment = schema
2172            .schema_fragment_for_canister("test::Canister")
2173            .expect("sealed database closure should lower");
2174
2175        assert_eq!(fragment.entities().len(), 1);
2176        assert_eq!(fragment.types().len(), 3);
2177        let fields = fragment.entities()[0].fields();
2178        assert!(matches!(
2179            fields
2180                .iter()
2181                .find(|field| field.name().as_str() == "tags")
2182                .map(icydb_schema::FieldFragment::field_type),
2183            Some(FieldType::List(item))
2184                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
2185        ));
2186        assert!(matches!(
2187            fields
2188                .iter()
2189                .find(|field| field.name().as_str() == "status")
2190                .map(icydb_schema::FieldFragment::insert_policy),
2191            Some(icydb_schema::FieldInsertPolicy::Default(
2192                icydb_schema::ScalarLiteral::EnumUnit { .. }
2193            ))
2194        ));
2195        let NamedTypeFragment::Enum(status) = fragment
2196            .types()
2197            .iter()
2198            .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
2199            .expect("reachable status type should remain an enum")
2200        else {
2201            panic!("reachable status type should remain an enum")
2202        };
2203        assert!(matches!(
2204            status
2205                .variants()
2206                .iter()
2207                .find(|variant| variant.name().as_str() == "Retries")
2208                .and_then(|variant| variant.payload()),
2209            Some(FieldType::List(item))
2210                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
2211        ));
2212
2213        let constraints = fragment.entities()[0].constraints();
2214        assert_eq!(constraints.len(), 2);
2215        let degrees_source = ConstraintSourceKey::for_targeted_field_rule(
2216            &FieldSourceKey::try_new("degrees").expect("field name"),
2217            &TypeSourceKey::try_new("Degrees").expect("type name"),
2218            &RuleSourceKey::try_new("range").expect("rule name"),
2219        );
2220        let degrees = constraints
2221            .iter()
2222            .find(|constraint| constraint.source_key() == &degrees_source)
2223            .expect("numeric rule should become one field-owned constraint");
2224        let ConstraintFragmentKind::TargetedRule(degrees) = degrees.kind() else {
2225            panic!("numeric rule should use the targeted-rule contract")
2226        };
2227        assert_eq!(degrees.root().as_str(), "degrees");
2228        assert_eq!(degrees.target_type().as_str(), "Degrees");
2229        assert!(matches!(
2230            degrees.operation(),
2231            SourceRuleOperation::NumericRangeInclusive { .. }
2232        ));
2233        let label = constraints
2234            .iter()
2235            .find(|constraint| constraint.source_key() != &degrees_source)
2236            .expect("length rule should become one field-owned constraint");
2237        let ConstraintFragmentKind::TargetedRule(label) = label.kind() else {
2238            panic!("length rule should use the targeted-rule contract")
2239        };
2240        assert_eq!(label.target_type().as_str(), "Label");
2241        assert!(matches!(
2242            label.operation(),
2243            SourceRuleOperation::LengthRangeInclusive { min: 2, max: 40 }
2244        ));
2245    }
2246}