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