Skip to main content

icydb_model/
fragment.rs

1//! Module: fragment
2//!
3//! Responsibility: lower one sealed host graph into a store-free database closure.
4//!
5//! Does not own: proposal routing, accepted identity, deployment configuration, or persistence.
6//!
7//! Boundary: converts compiler-authored logical facts into bounded public schema fragments.
8
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    str::FromStr,
12};
13
14use icydb_schema::{
15    Account, Blob, ConstraintFragment, DEFAULT_BIG_INT_MAX_BYTES, Date, Decimal, Duration,
16    EntityFragment, EntitySourceKey, EnumTypeFragment, EnumVariantFragment, FieldFragment,
17    FieldInsertPolicy, FieldManagementPolicy, FieldSourceKey, FieldType, Float32, Float64,
18    IndexFragment, IndexKeyFragment, IntBig, NamedTypeFragment, NatBig, Principal,
19    RecordFieldFragment, RecordTypeFragment, RelationDeleteAction, RelationFragment, RuleSourceKey,
20    ScalarLiteral, ScalarType, SchemaContractError, SchemaFragment, SchemaName,
21    SourceRuleOperation, Subaccount, TargetedRuleFragment, Timestamp, TupleElementFragment,
22    TypeSourceKey, 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, Schema, SchemaNode, Set, SourceRule, SourceRuleKind, Store, Tuple,
31        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    for target in entity
133        .fields()
134        .fields()
135        .iter()
136        .filter_map(|field| field.value().item().relation())
137        .chain(entity.relations().iter().map(RelationEdge::target))
138    {
139        schema
140            .cast_node::<Entity>(target)
141            .map_err(|_| FragmentLoweringError::InvalidReference(target.to_string()))?;
142        if !selected_entities.contains(target) {
143            return Err(FragmentLoweringError::InvalidReference(format!(
144                "relation target '{target}' is outside the selected database"
145            )));
146        }
147    }
148    Ok(())
149}
150
151fn lower_entity(
152    schema: &Schema,
153    entity: &Entity,
154    pending_types: &mut Vec<String>,
155) -> Result<EntityFragment, FragmentLoweringError> {
156    let fields = entity
157        .fields()
158        .fields()
159        .iter()
160        .map(|field| lower_entity_field(schema, field, pending_types))
161        .collect::<Result<Vec<_>, _>>()?;
162    let primary_key = entity
163        .primary_key()
164        .fields()
165        .iter()
166        .map(|name| entity_field_source_key(entity, name))
167        .collect::<Result<Vec<_>, _>>()?;
168    let indexes = entity
169        .indexes()
170        .iter()
171        .map(|index| lower_index(schema, entity, index))
172        .collect::<Result<Vec<_>, _>>()?;
173    let mut relations = entity
174        .fields()
175        .fields()
176        .iter()
177        .filter(|field| field.value().item().relation().is_some())
178        .map(|field| lower_scalar_relation(schema, entity, field))
179        .collect::<Result<Vec<_>, _>>()?;
180    relations.extend(
181        entity
182            .relations()
183            .iter()
184            .map(|relation| lower_composite_relation(schema, entity, relation))
185            .collect::<Result<Vec<_>, _>>()?,
186    );
187    let mut constraints = entity
188        .constraints()
189        .iter()
190        .map(|constraint| lower_constraint(schema, constraint))
191        .collect::<Result<Vec<_>, _>>()?;
192    for field in entity.fields().fields() {
193        constraints.extend(lower_field_rules(schema, field)?);
194    }
195
196    EntityFragment::try_new(
197        SchemaName::try_new(entity.name())?,
198        fields,
199        primary_key,
200        indexes,
201        relations,
202        constraints,
203    )
204    .map_err(Into::into)
205}
206
207fn lower_entity_field(
208    schema: &Schema,
209    field: &Field,
210    pending_types: &mut Vec<String>,
211) -> Result<FieldFragment, FragmentLoweringError> {
212    let field_type = lower_value_type(schema, field.value(), pending_types)?;
213    let nullable = field.value().cardinality() == Cardinality::Opt;
214    let insert_policy = if field.generated().is_some() {
215        FieldInsertPolicy::Generated
216    } else if let Some(default) = field.default() {
217        FieldInsertPolicy::Default(lower_default(schema, field, default)?)
218    } else if nullable {
219        FieldInsertPolicy::Nullable
220    } else {
221        FieldInsertPolicy::Required
222    };
223    let management = match field.write_management() {
224        Some(FieldWriteManagement::CreatedAt) => Some(FieldManagementPolicy::CreatedAt),
225        Some(FieldWriteManagement::UpdatedAt) => Some(FieldManagementPolicy::UpdatedAt),
226        None => None,
227    };
228    Ok(FieldFragment::new(
229        SchemaName::try_new(field.name())?,
230        field_type,
231        nullable,
232        insert_policy,
233        management,
234    ))
235}
236
237fn lower_index(
238    schema: &Schema,
239    entity: &Entity,
240    index: &Index,
241) -> Result<IndexFragment, FragmentLoweringError> {
242    let key = match index.key_items() {
243        IndexKeyItemsRef::Fields(fields) => fields
244            .iter()
245            .map(|field| entity_field_source_key(entity, field).map(IndexKeyFragment::Field))
246            .collect::<Result<Vec<_>, _>>()?,
247        IndexKeyItemsRef::Items(items) => items
248            .iter()
249            .map(|item| lower_index_key(entity, item))
250            .collect::<Result<Vec<_>, _>>()?,
251    };
252    IndexFragment::try_new(
253        SchemaName::try_new(index.name())?,
254        key,
255        index.is_unique(),
256        index.source_predicate(schema)?,
257    )
258    .map_err(Into::into)
259}
260
261fn lower_index_key(
262    entity: &Entity,
263    item: &IndexKeyItem,
264) -> Result<IndexKeyFragment, FragmentLoweringError> {
265    let field = entity_field_source_key(entity, item.field())?;
266    Ok(match item {
267        IndexKeyItem::Field(_) => IndexKeyFragment::Field(field),
268        IndexKeyItem::Expression(IndexExpression::Lower(_)) => IndexKeyFragment::Lower(field),
269        IndexKeyItem::Expression(IndexExpression::Upper(_)) => IndexKeyFragment::Upper(field),
270        IndexKeyItem::Expression(IndexExpression::Trim(_)) => IndexKeyFragment::Trim(field),
271        IndexKeyItem::Expression(IndexExpression::LowerTrim(_)) => {
272            IndexKeyFragment::LowerTrim(field)
273        }
274        IndexKeyItem::Expression(IndexExpression::Date(_)) => IndexKeyFragment::Date(field),
275        IndexKeyItem::Expression(IndexExpression::Year(_)) => IndexKeyFragment::Year(field),
276        IndexKeyItem::Expression(IndexExpression::Month(_)) => IndexKeyFragment::Month(field),
277        IndexKeyItem::Expression(IndexExpression::Day(_)) => IndexKeyFragment::Day(field),
278    })
279}
280
281fn lower_scalar_relation(
282    schema: &Schema,
283    entity: &Entity,
284    field: &Field,
285) -> Result<RelationFragment, FragmentLoweringError> {
286    let target_path = field
287        .value()
288        .item()
289        .relation()
290        .ok_or_else(|| FragmentLoweringError::InvalidReference(field.name().to_string()))?;
291    let target = schema
292        .cast_node::<Entity>(target_path)
293        .map_err(|_| FragmentLoweringError::InvalidReference(target_path.to_string()))?;
294    RelationFragment::try_new(
295        SchemaName::try_new(field.name())?,
296        vec![entity_field_source_key(entity, field.name())?],
297        EntitySourceKey::try_new(target.name())?,
298        target
299            .primary_key()
300            .fields()
301            .iter()
302            .map(|field| entity_field_source_key(target, field))
303            .collect::<Result<Vec<_>, _>>()?,
304        RelationDeleteAction::Restrict,
305    )
306    .map_err(Into::into)
307}
308
309fn lower_composite_relation(
310    schema: &Schema,
311    entity: &Entity,
312    relation: &RelationEdge,
313) -> Result<RelationFragment, FragmentLoweringError> {
314    let target = schema
315        .cast_node::<Entity>(relation.target())
316        .map_err(|_| FragmentLoweringError::InvalidReference(relation.target().to_string()))?;
317    RelationFragment::try_new(
318        SchemaName::try_new(relation.name())?,
319        relation
320            .local_fields()
321            .iter()
322            .map(|field| entity_field_source_key(entity, field))
323            .collect::<Result<Vec<_>, _>>()?,
324        EntitySourceKey::try_new(target.name())?,
325        target
326            .primary_key()
327            .fields()
328            .iter()
329            .map(|field| entity_field_source_key(target, field))
330            .collect::<Result<Vec<_>, _>>()?,
331        RelationDeleteAction::Restrict,
332    )
333    .map_err(Into::into)
334}
335
336fn lower_constraint(
337    schema: &Schema,
338    constraint: &CheckConstraint,
339) -> Result<ConstraintFragment, FragmentLoweringError> {
340    Ok(ConstraintFragment::check(
341        SchemaName::try_new(constraint.name())?,
342        constraint.source_expression(schema)?,
343    ))
344}
345
346fn lower_field_rules(
347    schema: &Schema,
348    field: &Field,
349) -> Result<Vec<ConstraintFragment>, FragmentLoweringError> {
350    let field_source = FieldSourceKey::try_new(field.name())?;
351    reachable_source_rules(schema, field.value().item())?
352        .into_iter()
353        .map(|(target_type, rule, target)| {
354            let operation = lower_source_rule_operation(schema, target, rule)?;
355            Ok(ConstraintFragment::targeted_rule(
356                TargetedRuleFragment::new(
357                    field_source.clone(),
358                    target_type,
359                    SchemaName::try_new(rule.name())?,
360                    operation,
361                ),
362            ))
363        })
364        .collect()
365}
366
367type ReachableSourceRule<'schema> = (
368    TypeSourceKey,
369    &'schema SourceRule,
370    &'schema crate::node::SchemaNode,
371);
372
373fn reachable_source_rules<'schema>(
374    schema: &'schema Schema,
375    item: &Item,
376) -> Result<Vec<ReachableSourceRule<'schema>>, FragmentLoweringError> {
377    let mut pending = Vec::new();
378    push_item_reference(item, &mut pending);
379    let mut visited = BTreeSet::new();
380    let mut rules = BTreeMap::new();
381    while let Some(path) = pending.pop() {
382        let node = schema
383            .get_node(path.as_str())
384            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
385        let target_type = TypeSourceKey::try_new(
386            named_type_name(node)
387                .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?,
388        )?;
389        if !visited.insert(target_type.clone()) {
390            continue;
391        }
392        for rule in schema_node_type(node)?.rules() {
393            let key = (target_type.clone(), RuleSourceKey::try_new(rule.name())?);
394            if rules.insert(key, (rule, node)).is_some() {
395                return Err(FragmentLoweringError::InvalidReference(format!(
396                    "duplicate durable rule '{}' on type '{}'",
397                    rule.name(),
398                    target_type
399                )));
400            }
401        }
402        push_schema_node_references(node, &mut pending);
403    }
404    Ok(rules
405        .into_iter()
406        .map(|((target_type, _), (rule, node))| (target_type, rule, node))
407        .collect())
408}
409
410fn schema_node_type(node: &SchemaNode) -> Result<&crate::node::Type, FragmentLoweringError> {
411    match node {
412        SchemaNode::Newtype(node) => Ok(node.ty()),
413        SchemaNode::Record(node) => Ok(node.ty()),
414        SchemaNode::Enum(node) => Ok(node.ty()),
415        SchemaNode::List(node) => Ok(node.ty()),
416        SchemaNode::Map(node) => Ok(node.ty()),
417        SchemaNode::Set(node) => Ok(node.ty()),
418        SchemaNode::Tuple(node) => Ok(node.ty()),
419        SchemaNode::Canister(_)
420        | SchemaNode::Entity(_)
421        | SchemaNode::Normalizer(_)
422        | SchemaNode::Store(_)
423        | SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
424            "durable-rule target is not a named type".to_string(),
425        )),
426    }
427}
428
429fn push_schema_node_references(node: &SchemaNode, pending: &mut Vec<String>) {
430    match node {
431        SchemaNode::Newtype(newtype) => push_item_reference(newtype.item(), pending),
432        SchemaNode::Record(record) => {
433            for field in record.fields().fields() {
434                push_item_reference(field.value().item(), pending);
435            }
436        }
437        SchemaNode::Enum(r#enum) => {
438            for value in r#enum
439                .variants()
440                .iter()
441                .filter_map(crate::node::EnumVariant::value)
442            {
443                push_item_reference(value.item(), pending);
444            }
445        }
446        SchemaNode::List(list) => push_item_reference(list.item(), pending),
447        SchemaNode::Map(map) => {
448            push_item_reference(map.key(), pending);
449            push_item_reference(map.value().item(), pending);
450        }
451        SchemaNode::Set(set) => push_item_reference(set.item(), pending),
452        SchemaNode::Tuple(tuple) => {
453            for value in tuple.values() {
454                push_item_reference(value.item(), pending);
455            }
456        }
457        SchemaNode::Canister(_)
458        | SchemaNode::Entity(_)
459        | SchemaNode::Normalizer(_)
460        | SchemaNode::Store(_)
461        | SchemaNode::Validator(_) => {}
462    }
463}
464
465fn push_item_reference(item: &Item, pending: &mut Vec<String>) {
466    if let ItemTarget::Is(path) = item.target() {
467        pending.push((*path).to_string());
468    }
469}
470
471fn lower_source_rule_operation(
472    schema: &Schema,
473    target: &SchemaNode,
474    rule: &SourceRule,
475) -> Result<SourceRuleOperation, FragmentLoweringError> {
476    let args = rule.args().0;
477    let length_bound = |index: usize| {
478        args.get(index)
479            .and_then(|arg| match arg {
480                Arg::Number(value) => arg_u128(value),
481                _ => None,
482            })
483            .and_then(|value| u64::try_from(value).ok())
484            .ok_or_else(|| {
485                FragmentLoweringError::InvalidReference(format!(
486                    "rule '{}' has an invalid length bound",
487                    rule.name()
488                ))
489            })
490    };
491
492    let operation = match rule.kind() {
493        SourceRuleKind::NumericMinimum => {
494            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
495            else {
496                return Err(invalid_rule_target(rule));
497            };
498            let value = args
499                .first()
500                .and_then(|arg| lower_scalar_default(primitive, item, arg))
501                .ok_or_else(|| invalid_rule_target(rule))?;
502            SourceRuleOperation::NumericMinimumInclusive { value }
503        }
504        SourceRuleKind::NumericRange => {
505            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
506            else {
507                return Err(invalid_rule_target(rule));
508            };
509            let literal = |index: usize| {
510                args.get(index)
511                    .and_then(|arg| lower_scalar_default(primitive, item, arg))
512                    .ok_or_else(|| invalid_rule_target(rule))
513            };
514            SourceRuleOperation::NumericRangeInclusive {
515                min: literal(0)?,
516                max: literal(1)?,
517            }
518        }
519        SourceRuleKind::LengthRange => {
520            let shape = resolve_rule_value_shape(schema, target)?;
521            if !matches!(
522                shape,
523                RuleValueShape::Collection
524                    | RuleValueShape::Scalar(Primitive::Blob | Primitive::Text, _)
525            ) {
526                return Err(invalid_rule_target(rule));
527            }
528            SourceRuleOperation::LengthRangeInclusive {
529                min: length_bound(0)?,
530                max: length_bound(1)?,
531            }
532        }
533    };
534    Ok(operation)
535}
536
537#[derive(Clone, Copy)]
538enum RuleValueShape<'schema> {
539    Collection,
540    Scalar(Primitive, &'schema Item),
541}
542
543fn resolve_rule_value_shape<'schema>(
544    schema: &'schema Schema,
545    mut target: &'schema SchemaNode,
546) -> Result<RuleValueShape<'schema>, FragmentLoweringError> {
547    let mut visited = BTreeSet::new();
548    loop {
549        let source = named_type_name(target)
550            .ok_or_else(|| FragmentLoweringError::InvalidReference("non-type rule".to_string()))?;
551        if !visited.insert(source) {
552            return Err(FragmentLoweringError::InvalidReference(format!(
553                "durable-rule target cycle at '{source}'"
554            )));
555        }
556        match target {
557            SchemaNode::List(_) | SchemaNode::Map(_) | SchemaNode::Set(_) => {
558                return Ok(RuleValueShape::Collection);
559            }
560            SchemaNode::Newtype(newtype) => match newtype.item().target() {
561                ItemTarget::Primitive(primitive) => {
562                    return Ok(RuleValueShape::Scalar(*primitive, newtype.item()));
563                }
564                ItemTarget::Is(path) => {
565                    target = schema
566                        .get_node(path)
567                        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
568                }
569            },
570            SchemaNode::Record(_) | SchemaNode::Enum(_) | SchemaNode::Tuple(_) => {
571                return Err(FragmentLoweringError::InvalidReference(format!(
572                    "durable-rule target '{source}' has no supported scalar or collection value"
573                )));
574            }
575            SchemaNode::Canister(_)
576            | SchemaNode::Entity(_)
577            | SchemaNode::Normalizer(_)
578            | SchemaNode::Store(_)
579            | SchemaNode::Validator(_) => {
580                return Err(FragmentLoweringError::InvalidReference(
581                    "non-type durable-rule target".to_string(),
582                ));
583            }
584        }
585    }
586}
587
588fn invalid_rule_target(rule: &SourceRule) -> FragmentLoweringError {
589    FragmentLoweringError::InvalidReference(format!(
590        "durable rule '{}' does not match its nominal target",
591        rule.name()
592    ))
593}
594
595fn entity_field_source_key(
596    entity: &Entity,
597    field_name: &str,
598) -> Result<FieldSourceKey, FragmentLoweringError> {
599    let field = entity
600        .fields()
601        .get(field_name)
602        .ok_or_else(|| FragmentLoweringError::InvalidReference(field_name.to_string()))?;
603    FieldSourceKey::try_new(field.name()).map_err(Into::into)
604}
605
606// -----------------------------------------------------------------------------
607// Reachable named-type closure
608// -----------------------------------------------------------------------------
609
610fn lower_reachable_types(
611    schema: &Schema,
612    mut pending: Vec<String>,
613) -> Result<Vec<NamedTypeFragment>, FragmentLoweringError> {
614    let mut lowered = BTreeMap::new();
615    while let Some(path) = pending.pop() {
616        let node = schema
617            .get_node(path.as_str())
618            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
619        let source_key = named_type_name(node)
620            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
621        if lowered.contains_key(source_key) {
622            continue;
623        }
624        let fragment = lower_named_type(schema, node, &mut pending)?;
625        lowered.insert(source_key.to_string(), fragment);
626    }
627    Ok(lowered.into_values().collect())
628}
629
630const fn named_type_name(node: &crate::node::SchemaNode) -> Option<&str> {
631    match node {
632        crate::node::SchemaNode::Enum(node) => Some(node.name()),
633        crate::node::SchemaNode::List(node) => Some(node.name()),
634        crate::node::SchemaNode::Map(node) => Some(node.name()),
635        crate::node::SchemaNode::Newtype(node) => Some(node.name()),
636        crate::node::SchemaNode::Record(node) => Some(node.name()),
637        crate::node::SchemaNode::Set(node) => Some(node.name()),
638        crate::node::SchemaNode::Tuple(node) => Some(node.name()),
639        crate::node::SchemaNode::Canister(_)
640        | crate::node::SchemaNode::Entity(_)
641        | crate::node::SchemaNode::Normalizer(_)
642        | crate::node::SchemaNode::Store(_)
643        | crate::node::SchemaNode::Validator(_) => None,
644    }
645}
646
647fn lower_named_type(
648    schema: &Schema,
649    node: &crate::node::SchemaNode,
650    pending: &mut Vec<String>,
651) -> Result<NamedTypeFragment, FragmentLoweringError> {
652    match node {
653        crate::node::SchemaNode::Record(record) => lower_record(schema, record, pending),
654        crate::node::SchemaNode::Enum(r#enum) => lower_enum(schema, r#enum, pending),
655        crate::node::SchemaNode::Newtype(newtype) => Ok(NamedTypeFragment::newtype(
656            SchemaName::try_new(newtype.name())?,
657            lower_item_type(schema, newtype.item(), pending)?,
658        )),
659        crate::node::SchemaNode::List(list) => lower_list(schema, list, pending),
660        crate::node::SchemaNode::Set(set) => lower_set(schema, set, pending),
661        crate::node::SchemaNode::Map(map) => lower_map(schema, map, pending),
662        crate::node::SchemaNode::Tuple(tuple) => lower_tuple(schema, tuple, pending),
663        crate::node::SchemaNode::Canister(_)
664        | crate::node::SchemaNode::Entity(_)
665        | crate::node::SchemaNode::Normalizer(_)
666        | crate::node::SchemaNode::Store(_)
667        | crate::node::SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
668            "non-type graph node".to_string(),
669        )),
670    }
671}
672
673fn lower_record(
674    schema: &Schema,
675    record: &Record,
676    pending: &mut Vec<String>,
677) -> Result<NamedTypeFragment, FragmentLoweringError> {
678    let fields = record
679        .fields()
680        .fields()
681        .iter()
682        .map(|field| {
683            Ok(RecordFieldFragment::new(
684                SchemaName::try_new(field.name())?,
685                lower_value_type(schema, field.value(), pending)?,
686                field.value().cardinality() == Cardinality::Opt,
687            ))
688        })
689        .collect::<Result<Vec<_>, FragmentLoweringError>>()?;
690    Ok(NamedTypeFragment::Record(RecordTypeFragment::try_new(
691        SchemaName::try_new(record.name())?,
692        fields,
693    )?))
694}
695
696fn lower_enum(
697    schema: &Schema,
698    r#enum: &Enum,
699    pending: &mut Vec<String>,
700) -> Result<NamedTypeFragment, FragmentLoweringError> {
701    let variants = r#enum
702        .variants()
703        .iter()
704        .map(|variant| {
705            let name = SchemaName::try_new(variant.name())?;
706            match variant.value() {
707                Some(value) if value.cardinality() == Cardinality::Opt => {
708                    Err(FragmentLoweringError::UnsupportedCardinality(format!(
709                        "{}::{}",
710                        r#enum.def().path(),
711                        variant.name()
712                    )))
713                }
714                Some(value) => Ok(EnumVariantFragment::with_payload(
715                    name,
716                    lower_value_type(schema, value, pending)?,
717                )),
718                None => Ok(EnumVariantFragment::new(name)),
719            }
720        })
721        .collect::<Result<Vec<_>, _>>()?;
722    Ok(NamedTypeFragment::Enum(EnumTypeFragment::try_new(
723        SchemaName::try_new(r#enum.name())?,
724        variants,
725    )?))
726}
727
728fn lower_list(
729    schema: &Schema,
730    list: &List,
731    pending: &mut Vec<String>,
732) -> Result<NamedTypeFragment, FragmentLoweringError> {
733    Ok(NamedTypeFragment::list(
734        SchemaName::try_new(list.name())?,
735        lower_item_type(schema, list.item(), pending)?,
736    ))
737}
738
739fn lower_set(
740    schema: &Schema,
741    set: &Set,
742    pending: &mut Vec<String>,
743) -> Result<NamedTypeFragment, FragmentLoweringError> {
744    Ok(NamedTypeFragment::set(
745        SchemaName::try_new(set.name())?,
746        lower_item_type(schema, set.item(), pending)?,
747    ))
748}
749
750fn lower_map(
751    schema: &Schema,
752    map: &Map,
753    pending: &mut Vec<String>,
754) -> Result<NamedTypeFragment, FragmentLoweringError> {
755    if map.value().cardinality() == Cardinality::Opt {
756        return Err(FragmentLoweringError::UnsupportedCardinality(
757            map.def().path(),
758        ));
759    }
760    Ok(NamedTypeFragment::map(
761        SchemaName::try_new(map.name())?,
762        lower_item_type(schema, map.key(), pending)?,
763        lower_value_type(schema, map.value(), pending)?,
764    ))
765}
766
767fn lower_tuple(
768    schema: &Schema,
769    tuple: &Tuple,
770    pending: &mut Vec<String>,
771) -> Result<NamedTypeFragment, FragmentLoweringError> {
772    let members = tuple
773        .values()
774        .iter()
775        .map(|value| {
776            Ok::<_, FragmentLoweringError>(TupleElementFragment::new(
777                lower_value_type(schema, value, pending)?,
778                value.cardinality() == Cardinality::Opt,
779            ))
780        })
781        .collect::<Result<Vec<_>, _>>()?;
782    Ok(NamedTypeFragment::tuple(
783        SchemaName::try_new(tuple.name())?,
784        members,
785    ))
786}
787
788// -----------------------------------------------------------------------------
789// Exact field contracts
790// -----------------------------------------------------------------------------
791
792fn lower_value_type(
793    schema: &Schema,
794    value: &Value,
795    pending: &mut Vec<String>,
796) -> Result<FieldType, FragmentLoweringError> {
797    let item = lower_item_type(schema, value.item(), pending)?;
798    Ok(if value.cardinality() == Cardinality::Many {
799        FieldType::List(Box::new(item))
800    } else {
801        item
802    })
803}
804
805fn lower_item_type(
806    schema: &Schema,
807    item: &Item,
808    pending: &mut Vec<String>,
809) -> Result<FieldType, FragmentLoweringError> {
810    match item.target() {
811        ItemTarget::Is(path) => {
812            pending.push((*path).to_string());
813            Ok(FieldType::Named(TypeSourceKey::try_new(
814                type_source_key_for_path(schema, path)?,
815            )?))
816        }
817        ItemTarget::Primitive(primitive) => {
818            Ok(FieldType::Scalar(lower_scalar_type(*primitive, item)))
819        }
820    }
821}
822
823fn type_source_key_for_path<'schema>(
824    schema: &'schema Schema,
825    path: &str,
826) -> Result<&'schema str, FragmentLoweringError> {
827    let source = schema
828        .get_node(path)
829        .and_then(named_type_name)
830        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
831    Ok(source)
832}
833
834fn lower_scalar_type(primitive: Primitive, item: &Item) -> ScalarType {
835    match primitive {
836        Primitive::Account => ScalarType::Account,
837        Primitive::Blob => ScalarType::Blob {
838            max_len: item.max_len(),
839        },
840        Primitive::Bool => ScalarType::Bool,
841        Primitive::Date => ScalarType::Date,
842        Primitive::Decimal => ScalarType::Decimal {
843            scale: item.scale().unwrap_or(0),
844        },
845        Primitive::Duration => ScalarType::Duration,
846        Primitive::Float32 => ScalarType::Float32,
847        Primitive::Float64 => ScalarType::Float64,
848        Primitive::Int8 => ScalarType::Int8,
849        Primitive::Int16 => ScalarType::Int16,
850        Primitive::Int32 => ScalarType::Int32,
851        Primitive::Int64 => ScalarType::Int64,
852        Primitive::Int128 => ScalarType::Int128,
853        Primitive::IntBig => ScalarType::IntBig {
854            max_bytes: item.max_bytes().unwrap_or(DEFAULT_BIG_INT_MAX_BYTES),
855        },
856        Primitive::Nat8 => ScalarType::Nat8,
857        Primitive::Nat16 => ScalarType::Nat16,
858        Primitive::Nat32 => ScalarType::Nat32,
859        Primitive::Nat64 => ScalarType::Nat64,
860        Primitive::Nat128 => ScalarType::Nat128,
861        Primitive::NatBig => ScalarType::NatBig {
862            max_bytes: item.max_bytes().unwrap_or(DEFAULT_BIG_INT_MAX_BYTES),
863        },
864        Primitive::Principal => ScalarType::Principal,
865        Primitive::Subaccount => ScalarType::Subaccount,
866        Primitive::Text => ScalarType::Text {
867            max_len: item.max_len(),
868        },
869        Primitive::Timestamp => ScalarType::Timestamp,
870        Primitive::Ulid => ScalarType::Ulid,
871        Primitive::Unit => ScalarType::Unit,
872    }
873}
874
875// -----------------------------------------------------------------------------
876// Authored database defaults
877// -----------------------------------------------------------------------------
878
879fn lower_default(
880    schema: &Schema,
881    field: &Field,
882    default: &Arg,
883) -> Result<ScalarLiteral, FragmentLoweringError> {
884    if let ItemTarget::Is(path) = field.value().item().target() {
885        let Arg::ConstPath(default_path) = default else {
886            return Err(FragmentLoweringError::InvalidDefault(
887                field.name().to_string(),
888            ));
889        };
890        let variant = default_path.rsplit("::").next().unwrap_or(default_path);
891        return schema
892            .enum_unit_literal(path, variant)
893            .map_err(FragmentLoweringError::from);
894    }
895    let ItemTarget::Primitive(primitive) = field.value().item().target() else {
896        return Err(FragmentLoweringError::InvalidDefault(
897            field.name().to_string(),
898        ));
899    };
900    lower_scalar_default(*primitive, field.value().item(), default)
901        .ok_or_else(|| FragmentLoweringError::InvalidDefault(field.name().to_string()))
902}
903
904fn lower_scalar_default(primitive: Primitive, item: &Item, default: &Arg) -> Option<ScalarLiteral> {
905    if default_constructor_is_zero(default) {
906        return zero_scalar_literal(primitive, item);
907    }
908    match (primitive, default) {
909        (Primitive::Account, Arg::String(value)) => {
910            Account::from_str(value).ok().map(ScalarLiteral::Account)
911        }
912        (Primitive::Blob, Arg::String(value)) => Blob::try_new(value.as_bytes().to_vec())
913            .ok()
914            .map(ScalarLiteral::Blob),
915        (Primitive::Bool, Arg::Bool(value)) => Some(ScalarLiteral::Bool(*value)),
916        (Primitive::Date, Arg::String(value)) => Date::parse(value).map(ScalarLiteral::Date),
917        (Primitive::Date, Arg::Number(value)) => arg_i128(value)
918            .and_then(|value| i32::try_from(value).ok())
919            .map(Date::from_days_since_epoch)
920            .map(ScalarLiteral::Date),
921        (Primitive::Decimal, Arg::String(value)) => Decimal::from_str(value)
922            .ok()
923            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
924            .map(ScalarLiteral::Decimal),
925        (Primitive::Decimal, Arg::Number(value)) => arg_decimal(value)
926            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
927            .map(ScalarLiteral::Decimal),
928        (Primitive::Duration, Arg::String(value)) => Duration::parse_flexible(value)
929            .ok()
930            .map(ScalarLiteral::Duration),
931        (Primitive::Duration, Arg::Number(value)) => arg_u128(value)
932            .and_then(|value| u64::try_from(value).ok())
933            .map(Duration::from_millis)
934            .map(ScalarLiteral::Duration),
935        (Primitive::Float32, Arg::Number(ArgNumber::Float32(value))) => {
936            Float32::try_new(*value).map(ScalarLiteral::Float32)
937        }
938        (Primitive::Float64, Arg::Number(ArgNumber::Float64(value))) => {
939            Float64::try_new(*value).map(ScalarLiteral::Float64)
940        }
941        (
942            Primitive::Int8
943            | Primitive::Int16
944            | Primitive::Int32
945            | Primitive::Int64
946            | Primitive::Int128,
947            Arg::Number(value),
948        ) => arg_i128(value).map(ScalarLiteral::Int),
949        (Primitive::IntBig, Arg::Number(value)) => arg_i128(value)
950            .map(|value| value.to_string())
951            .and_then(|value| IntBig::from_str(value.as_str()).ok())
952            .map(ScalarLiteral::IntBig),
953        (Primitive::IntBig, Arg::String(value)) => {
954            IntBig::from_str(value).ok().map(ScalarLiteral::IntBig)
955        }
956        (
957            Primitive::Nat8
958            | Primitive::Nat16
959            | Primitive::Nat32
960            | Primitive::Nat64
961            | Primitive::Nat128,
962            Arg::Number(value),
963        ) => arg_u128(value).map(ScalarLiteral::Nat),
964        (Primitive::NatBig, Arg::Number(value)) => arg_u128(value)
965            .map(|value| value.to_string())
966            .and_then(|value| NatBig::from_str(value.as_str()).ok())
967            .map(ScalarLiteral::NatBig),
968        (Primitive::NatBig, Arg::String(value)) => {
969            NatBig::from_str(value).ok().map(ScalarLiteral::NatBig)
970        }
971        (Primitive::Principal, Arg::String(value)) => Principal::from_str(value)
972            .ok()
973            .map(ScalarLiteral::Principal),
974        (Primitive::Subaccount, Arg::String(value)) => parse_subaccount(value)
975            .map(Subaccount::from_array)
976            .map(ScalarLiteral::Subaccount),
977        (Primitive::Text, Arg::String(value)) => Some(ScalarLiteral::Text((*value).to_string())),
978        (Primitive::Timestamp, Arg::String(value)) => Timestamp::parse_flexible(value)
979            .ok()
980            .map(ScalarLiteral::Timestamp),
981        (Primitive::Timestamp, Arg::Number(value)) => arg_i128(value)
982            .and_then(|value| i64::try_from(value).ok())
983            .map(Timestamp::from_millis)
984            .map(ScalarLiteral::Timestamp),
985        (Primitive::Ulid, Arg::String(value)) => {
986            Ulid::from_str(value).ok().map(ScalarLiteral::Ulid)
987        }
988        (Primitive::Unit, Arg::ConstPath(path)) if path.ends_with("Unit") => {
989            Some(ScalarLiteral::Unit(Unit))
990        }
991        _ => None,
992    }
993}
994
995fn default_constructor_is_zero(default: &Arg) -> bool {
996    let Arg::FuncPath(path) = default else {
997        return false;
998    };
999    path.ends_with("::default")
1000        || path.ends_with("::new")
1001        || path.ends_with("::EPOCH")
1002        || path.ends_with("::nil")
1003}
1004
1005fn zero_scalar_literal(primitive: Primitive, item: &Item) -> Option<ScalarLiteral> {
1006    match primitive {
1007        Primitive::Blob => Blob::try_new(Vec::new()).ok().map(ScalarLiteral::Blob),
1008        Primitive::Bool => Some(ScalarLiteral::Bool(false)),
1009        Primitive::Date => Some(ScalarLiteral::Date(Date::EPOCH)),
1010        Primitive::Decimal => Decimal::try_from_i128_with_scale(0, item.scale().unwrap_or(0))
1011            .map(ScalarLiteral::Decimal),
1012        Primitive::Duration => Some(ScalarLiteral::Duration(Duration::ZERO)),
1013        Primitive::Float32 => Float32::try_new(0.0).map(ScalarLiteral::Float32),
1014        Primitive::Float64 => Float64::try_new(0.0).map(ScalarLiteral::Float64),
1015        Primitive::Int8
1016        | Primitive::Int16
1017        | Primitive::Int32
1018        | Primitive::Int64
1019        | Primitive::Int128 => Some(ScalarLiteral::Int(0)),
1020        Primitive::IntBig => IntBig::from_str("0").ok().map(ScalarLiteral::IntBig),
1021        Primitive::Nat8
1022        | Primitive::Nat16
1023        | Primitive::Nat32
1024        | Primitive::Nat64
1025        | Primitive::Nat128 => Some(ScalarLiteral::Nat(0)),
1026        Primitive::NatBig => NatBig::from_str("0").ok().map(ScalarLiteral::NatBig),
1027        Primitive::Text => Some(ScalarLiteral::Text(String::new())),
1028        Primitive::Timestamp => Some(ScalarLiteral::Timestamp(Timestamp::EPOCH)),
1029        Primitive::Ulid => Some(ScalarLiteral::Ulid(Ulid::nil())),
1030        Primitive::Unit => Some(ScalarLiteral::Unit(Unit)),
1031        Primitive::Account | Primitive::Principal | Primitive::Subaccount => None,
1032    }
1033}
1034
1035// -----------------------------------------------------------------------------
1036// Literal conversion helpers
1037// -----------------------------------------------------------------------------
1038
1039fn decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
1040    match value.scale().cmp(&scale) {
1041        std::cmp::Ordering::Equal => Some(value),
1042        std::cmp::Ordering::Less => value
1043            .scale_to_integer(scale)
1044            .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale)),
1045        std::cmp::Ordering::Greater => Some(value.round_dp(scale)),
1046    }
1047}
1048
1049fn arg_i128(value: &ArgNumber) -> Option<i128> {
1050    match value {
1051        ArgNumber::Int8(value) => Some(i128::from(*value)),
1052        ArgNumber::Int16(value) => Some(i128::from(*value)),
1053        ArgNumber::Int32(value) => Some(i128::from(*value)),
1054        ArgNumber::Int64(value) => Some(i128::from(*value)),
1055        ArgNumber::Int128(value) => Some(*value),
1056        ArgNumber::Nat8(value) => Some(i128::from(*value)),
1057        ArgNumber::Nat16(value) => Some(i128::from(*value)),
1058        ArgNumber::Nat32(value) => Some(i128::from(*value)),
1059        ArgNumber::Nat64(value) => Some(i128::from(*value)),
1060        ArgNumber::Nat128(value) => i128::try_from(*value).ok(),
1061        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1062    }
1063}
1064
1065fn arg_u128(value: &ArgNumber) -> Option<u128> {
1066    match value {
1067        ArgNumber::Int8(value) => u128::try_from(*value).ok(),
1068        ArgNumber::Int16(value) => u128::try_from(*value).ok(),
1069        ArgNumber::Int32(value) => u128::try_from(*value).ok(),
1070        ArgNumber::Int64(value) => u128::try_from(*value).ok(),
1071        ArgNumber::Int128(value) => u128::try_from(*value).ok(),
1072        ArgNumber::Nat8(value) => Some(u128::from(*value)),
1073        ArgNumber::Nat16(value) => Some(u128::from(*value)),
1074        ArgNumber::Nat32(value) => Some(u128::from(*value)),
1075        ArgNumber::Nat64(value) => Some(u128::from(*value)),
1076        ArgNumber::Nat128(value) => Some(*value),
1077        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1078    }
1079}
1080
1081fn arg_decimal(value: &ArgNumber) -> Option<Decimal> {
1082    match value {
1083        ArgNumber::Float32(value) => Decimal::from_f32_lossy(*value),
1084        ArgNumber::Float64(value) => Decimal::from_f64_lossy(*value),
1085        _ => arg_i128(value).and_then(Decimal::from_i128),
1086    }
1087}
1088
1089fn parse_subaccount(value: &str) -> Option<[u8; 32]> {
1090    if value.len() != 64 {
1091        return None;
1092    }
1093    let mut bytes = [0; 32];
1094    for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
1095        let text = std::str::from_utf8(chunk).ok()?;
1096        bytes[index] = u8::from_str_radix(text, 16).ok()?;
1097    }
1098    Some(bytes)
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use icydb_schema::{
1104        ConstraintFragmentKind, ConstraintSourceKey, FieldSourceKey, FieldType, NamedTypeFragment,
1105        RuleSourceKey, ScalarType, SourceRuleOperation, TypeSourceKey,
1106    };
1107
1108    use super::{Schema, lower_field_rules};
1109    use crate::{
1110        node::{
1111            Arg, ArgNumber, Args, Canister, Def, Entity, Enum, EnumVariant, Field, FieldList, Item,
1112            ItemTarget, Newtype, PrimaryKey, PrimaryKeySource, Record, SchemaNode, SourceRule,
1113            SourceRuleKind, Store, StoreHeapConfig, Type, Value,
1114        },
1115        types::{Cardinality, Primitive},
1116    };
1117
1118    static EMPTY_TYPE: Type = Type::new(&[], &[], &[]);
1119    static NUMERIC_RULE_ARGS: [Arg; 2] = [
1120        Arg::Number(ArgNumber::Int32(0)),
1121        Arg::Number(ArgNumber::Int32(360)),
1122    ];
1123    static NUMERIC_RULES: [SourceRule; 1] = [SourceRule::new(
1124        "range",
1125        SourceRuleKind::NumericRange,
1126        Args(&NUMERIC_RULE_ARGS),
1127    )];
1128    static NUMERIC_RULE_TYPE: Type = Type::new(&[], &[], &NUMERIC_RULES);
1129    static LENGTH_RULE_ARGS: [Arg; 2] = [
1130        Arg::Number(ArgNumber::Int32(2)),
1131        Arg::Number(ArgNumber::Int32(40)),
1132    ];
1133    static LENGTH_RULES: [SourceRule; 1] = [SourceRule::new(
1134        "length",
1135        SourceRuleKind::LengthRange,
1136        Args(&LENGTH_RULE_ARGS),
1137    )];
1138    static LENGTH_RULE_TYPE: Type = Type::new(&[], &[], &LENGTH_RULES);
1139    static NESTED_RULE_FIELDS: [Field; 1] = [Field::new(
1140        "degrees",
1141        Value::new(
1142            Cardinality::One,
1143            Item::new(
1144                ItemTarget::Is("test::Degrees"),
1145                None,
1146                None,
1147                None,
1148                None,
1149                &[],
1150                &[],
1151                false,
1152            ),
1153        ),
1154        None,
1155        None,
1156        None,
1157    )];
1158    static STATUS_VARIANTS: [EnumVariant; 2] = [
1159        EnumVariant::new("Active", None),
1160        EnumVariant::new(
1161            "Retries",
1162            Some(Value::new(
1163                Cardinality::Many,
1164                Item::new(
1165                    ItemTarget::Primitive(Primitive::Nat16),
1166                    None,
1167                    None,
1168                    None,
1169                    None,
1170                    &[],
1171                    &[],
1172                    false,
1173                ),
1174            )),
1175        ),
1176    ];
1177
1178    #[test]
1179    fn durable_rules_nested_below_structural_fields_lower_to_nominal_targets() {
1180        let mut schema = Schema::new();
1181        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1182            Def::new("test", "Degrees"),
1183            "Degrees",
1184            Item::new(
1185                ItemTarget::Primitive(Primitive::Nat16),
1186                None,
1187                None,
1188                None,
1189                None,
1190                &[],
1191                &[],
1192                false,
1193            ),
1194            None,
1195            NUMERIC_RULE_TYPE.clone(),
1196        )));
1197        schema.insert_node(SchemaNode::Record(Record::new(
1198            Def::new("test", "Nested"),
1199            "Nested",
1200            FieldList::new(&NESTED_RULE_FIELDS),
1201            EMPTY_TYPE.clone(),
1202        )));
1203
1204        let outer = Field::new(
1205            "nested",
1206            Value::new(
1207                Cardinality::One,
1208                Item::new(
1209                    ItemTarget::Is("test::Nested"),
1210                    None,
1211                    None,
1212                    None,
1213                    None,
1214                    &[],
1215                    &[],
1216                    false,
1217                ),
1218            ),
1219            None,
1220            None,
1221            None,
1222        );
1223        let constraints =
1224            lower_field_rules(&schema, &outer).expect("nested durable rule should lower");
1225        assert_eq!(constraints.len(), 1);
1226        let ConstraintFragmentKind::TargetedRule(rule) = constraints[0].kind() else {
1227            panic!("nested durable rule should use the targeted-rule contract")
1228        };
1229        assert_eq!(rule.root().as_str(), "nested");
1230        assert_eq!(rule.target_type().as_str(), "Degrees");
1231        assert!(matches!(
1232            rule.operation(),
1233            SourceRuleOperation::NumericRangeInclusive { .. }
1234        ));
1235    }
1236
1237    static ENTITY_FIELDS: [Field; 5] = [
1238        Field::new(
1239            "id",
1240            Value::new(
1241                Cardinality::One,
1242                Item::new(
1243                    ItemTarget::Primitive(Primitive::Nat64),
1244                    None,
1245                    None,
1246                    None,
1247                    None,
1248                    &[],
1249                    &[],
1250                    false,
1251                ),
1252            ),
1253            None,
1254            None,
1255            None,
1256        ),
1257        Field::new(
1258            "tags",
1259            Value::new(
1260                Cardinality::Many,
1261                Item::new(
1262                    ItemTarget::Primitive(Primitive::Text),
1263                    None,
1264                    None,
1265                    Some(32),
1266                    None,
1267                    &[],
1268                    &[],
1269                    false,
1270                ),
1271            ),
1272            None,
1273            None,
1274            None,
1275        ),
1276        Field::new(
1277            "status",
1278            Value::new(
1279                Cardinality::One,
1280                Item::new(
1281                    ItemTarget::Is("test::Status"),
1282                    None,
1283                    None,
1284                    None,
1285                    None,
1286                    &[],
1287                    &[],
1288                    false,
1289                ),
1290            ),
1291            Some(crate::node::Arg::ConstPath("test::Status::Active")),
1292            None,
1293            None,
1294        ),
1295        Field::new(
1296            "degrees",
1297            Value::new(
1298                Cardinality::One,
1299                Item::new(
1300                    ItemTarget::Is("test::Degrees"),
1301                    None,
1302                    None,
1303                    None,
1304                    None,
1305                    &[],
1306                    &[],
1307                    false,
1308                ),
1309            ),
1310            None,
1311            None,
1312            None,
1313        ),
1314        Field::new(
1315            "label",
1316            Value::new(
1317                Cardinality::One,
1318                Item::new(
1319                    ItemTarget::Is("test::Label"),
1320                    None,
1321                    None,
1322                    None,
1323                    None,
1324                    &[],
1325                    &[],
1326                    false,
1327                ),
1328            ),
1329            None,
1330            None,
1331            None,
1332        ),
1333    ];
1334
1335    #[test]
1336    #[expect(
1337        clippy::too_many_lines,
1338        reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
1339    )]
1340    fn sealed_canister_graph_emits_store_free_database_closure() {
1341        let mut schema = Schema::new();
1342        schema.insert_node(SchemaNode::Canister(Canister::new(
1343            Def::new("test", "Canister"),
1344            "test",
1345            0,
1346            10,
1347            9,
1348            8,
1349        )));
1350        schema.insert_node(SchemaNode::Store(Store::new_heap(
1351            Def::new("test", "Store"),
1352            "test::Canister",
1353            StoreHeapConfig::new(),
1354        )));
1355        schema.insert_node(SchemaNode::Enum(Enum::new(
1356            Def::new("test", "Status"),
1357            "Status",
1358            &STATUS_VARIANTS,
1359            EMPTY_TYPE.clone(),
1360        )));
1361        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1362            Def::new("test", "Degrees"),
1363            "Degrees",
1364            Item::new(
1365                ItemTarget::Primitive(Primitive::Nat16),
1366                None,
1367                None,
1368                None,
1369                None,
1370                &[],
1371                &[],
1372                false,
1373            ),
1374            None,
1375            NUMERIC_RULE_TYPE.clone(),
1376        )));
1377        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1378            Def::new("test", "Label"),
1379            "Label",
1380            Item::new(
1381                ItemTarget::Primitive(Primitive::Text),
1382                None,
1383                None,
1384                None,
1385                None,
1386                &[],
1387                &[],
1388                false,
1389            ),
1390            None,
1391            LENGTH_RULE_TYPE.clone(),
1392        )));
1393        schema.insert_node(SchemaNode::Entity(Entity::new(
1394            Def::new("test", "Task"),
1395            "test::Store",
1396            1,
1397            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1398            &[],
1399            &[],
1400            &[],
1401            FieldList::new(&ENTITY_FIELDS),
1402            EMPTY_TYPE.clone(),
1403        )));
1404        schema.seal().expect("fixture graph should seal");
1405
1406        let fragment = schema
1407            .schema_fragment_for_canister("test::Canister")
1408            .expect("sealed database closure should lower");
1409
1410        assert_eq!(fragment.entities().len(), 1);
1411        assert_eq!(fragment.types().len(), 3);
1412        let fields = fragment.entities()[0].fields();
1413        assert!(matches!(
1414            fields
1415                .iter()
1416                .find(|field| field.name().as_str() == "tags")
1417                .map(icydb_schema::FieldFragment::field_type),
1418            Some(FieldType::List(item))
1419                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
1420        ));
1421        assert!(matches!(
1422            fields
1423                .iter()
1424                .find(|field| field.name().as_str() == "status")
1425                .map(icydb_schema::FieldFragment::insert_policy),
1426            Some(icydb_schema::FieldInsertPolicy::Default(
1427                icydb_schema::ScalarLiteral::EnumUnit { .. }
1428            ))
1429        ));
1430        let NamedTypeFragment::Enum(status) = fragment
1431            .types()
1432            .iter()
1433            .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
1434            .expect("reachable status type should remain an enum")
1435        else {
1436            panic!("reachable status type should remain an enum")
1437        };
1438        assert!(matches!(
1439            status
1440                .variants()
1441                .iter()
1442                .find(|variant| variant.name().as_str() == "Retries")
1443                .and_then(|variant| variant.payload()),
1444            Some(FieldType::List(item))
1445                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
1446        ));
1447
1448        let constraints = fragment.entities()[0].constraints();
1449        assert_eq!(constraints.len(), 2);
1450        let degrees_source = ConstraintSourceKey::for_targeted_field_rule(
1451            &FieldSourceKey::try_new("degrees").expect("field name"),
1452            &TypeSourceKey::try_new("Degrees").expect("type name"),
1453            &RuleSourceKey::try_new("range").expect("rule name"),
1454        );
1455        let degrees = constraints
1456            .iter()
1457            .find(|constraint| constraint.source_key() == &degrees_source)
1458            .expect("numeric rule should become one field-owned constraint");
1459        let ConstraintFragmentKind::TargetedRule(degrees) = degrees.kind() else {
1460            panic!("numeric rule should use the targeted-rule contract")
1461        };
1462        assert_eq!(degrees.root().as_str(), "degrees");
1463        assert_eq!(degrees.target_type().as_str(), "Degrees");
1464        assert!(matches!(
1465            degrees.operation(),
1466            SourceRuleOperation::NumericRangeInclusive { .. }
1467        ));
1468        let label = constraints
1469            .iter()
1470            .find(|constraint| constraint.source_key() != &degrees_source)
1471            .expect("length rule should become one field-owned constraint");
1472        let ConstraintFragmentKind::TargetedRule(label) = label.kind() else {
1473            panic!("length rule should use the targeted-rule contract")
1474        };
1475        assert_eq!(label.target_type().as_str(), "Label");
1476        assert!(matches!(
1477            label.operation(),
1478            SourceRuleOperation::LengthRangeInclusive { min: 2, max: 40 }
1479        ));
1480    }
1481}