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