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