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 as ProposalSourceRuleOperation, Subaccount, TargetedRuleFragment,
22    Timestamp, TupleElementFragment, 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, 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    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<ProposalSourceRuleOperation, FragmentLoweringError> {
476    let length_bound = |value: &RuleNumber| {
477        rule_integer_u128(value)
478            .and_then(|value| u64::try_from(value).ok())
479            .ok_or_else(|| {
480                FragmentLoweringError::InvalidReference(format!(
481                    "rule '{}' has an invalid length bound",
482                    rule.name()
483                ))
484            })
485    };
486
487    let operation = match rule.operation() {
488        SourceRuleAuthoringOperation::NumericMinimumInclusive { value } => {
489            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
490            else {
491                return Err(invalid_rule_target(rule));
492            };
493            let value = lower_rule_numeric_literal(primitive, item, value)
494                .ok_or_else(|| invalid_rule_target(rule))?;
495            ProposalSourceRuleOperation::NumericMinimumInclusive { value }
496        }
497        SourceRuleAuthoringOperation::NumericMaximumInclusive { value } => {
498            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
499            else {
500                return Err(invalid_rule_target(rule));
501            };
502            let value = lower_rule_numeric_literal(primitive, item, value)
503                .ok_or_else(|| invalid_rule_target(rule))?;
504            ProposalSourceRuleOperation::NumericMaximumInclusive { value }
505        }
506        SourceRuleAuthoringOperation::NumericRangeInclusive { min, max } => {
507            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
508            else {
509                return Err(invalid_rule_target(rule));
510            };
511            let literal = |value: &RuleNumber| {
512                lower_rule_numeric_literal(primitive, item, value)
513                    .ok_or_else(|| invalid_rule_target(rule))
514            };
515            ProposalSourceRuleOperation::NumericRangeInclusive {
516                min: literal(min)?,
517                max: literal(max)?,
518            }
519        }
520        SourceRuleAuthoringOperation::LengthRangeInclusive { min, max } => {
521            let shape = resolve_rule_value_shape(schema, target)?;
522            if !matches!(
523                shape,
524                RuleValueShape::Collection
525                    | RuleValueShape::Scalar(Primitive::Blob | Primitive::Text, _)
526            ) {
527                return Err(invalid_rule_target(rule));
528            }
529            ProposalSourceRuleOperation::LengthRangeInclusive {
530                min: length_bound(min)?,
531                max: length_bound(max)?,
532            }
533        }
534        SourceRuleAuthoringOperation::MultipleOf { divisor } => {
535            let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
536            else {
537                return Err(invalid_rule_target(rule));
538            };
539            let divisor = lower_rule_numeric_literal(primitive, item, divisor)
540                .ok_or_else(|| invalid_rule_target(rule))?;
541            ProposalSourceRuleOperation::MultipleOf { divisor }
542        }
543    };
544    Ok(operation)
545}
546
547fn lower_rule_numeric_literal(
548    primitive: Primitive,
549    item: &Item,
550    value: &RuleNumber,
551) -> Option<ScalarLiteral> {
552    match primitive {
553        Primitive::Decimal => rule_decimal(value)
554            .and_then(|value| exact_decimal_at_scale(value, item.scale().unwrap_or(0)))
555            .map(ScalarLiteral::Decimal),
556        Primitive::Float32 => match value {
557            RuleNumber::Float32(value) => Float32::try_new(*value).map(ScalarLiteral::Float32),
558            RuleNumber::Integer(_) | RuleNumber::Decimal(_) | RuleNumber::Float64(_) => None,
559        },
560        Primitive::Float64 => match value {
561            RuleNumber::Decimal(value) => value
562                .parse::<f64>()
563                .ok()
564                .and_then(Float64::try_new)
565                .map(ScalarLiteral::Float64),
566            RuleNumber::Float64(value) => Float64::try_new(*value).map(ScalarLiteral::Float64),
567            RuleNumber::Integer(_) | RuleNumber::Float32(_) => None,
568        },
569        Primitive::Int8
570        | Primitive::Int16
571        | Primitive::Int32
572        | Primitive::Int64
573        | Primitive::Int128 => rule_integer_i128(value).map(ScalarLiteral::Int),
574        Primitive::IntBig => rule_integer_text(value)
575            .and_then(|value| IntBig::from_str(value).ok())
576            .map(ScalarLiteral::IntBig),
577        Primitive::Nat8
578        | Primitive::Nat16
579        | Primitive::Nat32
580        | Primitive::Nat64
581        | Primitive::Nat128 => rule_integer_u128(value).map(ScalarLiteral::Nat),
582        Primitive::NatBig => rule_integer_text(value)
583            .and_then(|value| NatBig::from_str(value).ok())
584            .map(ScalarLiteral::NatBig),
585        Primitive::Account
586        | Primitive::Blob
587        | Primitive::Bool
588        | Primitive::Date
589        | Primitive::Duration
590        | Primitive::Principal
591        | Primitive::Subaccount
592        | Primitive::Text
593        | Primitive::Timestamp
594        | Primitive::Ulid
595        | Primitive::Unit => None,
596    }
597}
598
599const fn rule_integer_text(value: &RuleNumber) -> Option<&str> {
600    let RuleNumber::Integer(value) = value else {
601        return None;
602    };
603    Some(value)
604}
605
606fn rule_integer_i128(value: &RuleNumber) -> Option<i128> {
607    rule_integer_text(value)?.parse().ok()
608}
609
610fn rule_integer_u128(value: &RuleNumber) -> Option<u128> {
611    rule_integer_text(value)?.parse().ok()
612}
613
614fn rule_decimal(value: &RuleNumber) -> Option<Decimal> {
615    match value {
616        RuleNumber::Integer(value) | RuleNumber::Decimal(value) => Decimal::from_str(value).ok(),
617        RuleNumber::Float32(_) | RuleNumber::Float64(_) => None,
618    }
619}
620
621fn exact_decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
622    let value = value.normalize();
623    value
624        .scale_to_integer(scale)
625        .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale))
626}
627
628#[derive(Clone, Copy)]
629enum RuleValueShape<'schema> {
630    Collection,
631    Scalar(Primitive, &'schema Item),
632}
633
634fn resolve_rule_value_shape<'schema>(
635    schema: &'schema Schema,
636    mut target: &'schema SchemaNode,
637) -> Result<RuleValueShape<'schema>, FragmentLoweringError> {
638    let mut visited = BTreeSet::new();
639    loop {
640        let source = named_type_name(target)
641            .ok_or_else(|| FragmentLoweringError::InvalidReference("non-type rule".to_string()))?;
642        if !visited.insert(source) {
643            return Err(FragmentLoweringError::InvalidReference(format!(
644                "durable-rule target cycle at '{source}'"
645            )));
646        }
647        match target {
648            SchemaNode::List(_) | SchemaNode::Map(_) | SchemaNode::Set(_) => {
649                return Ok(RuleValueShape::Collection);
650            }
651            SchemaNode::Newtype(newtype) => match newtype.item().target() {
652                ItemTarget::Primitive(primitive) => {
653                    return Ok(RuleValueShape::Scalar(*primitive, newtype.item()));
654                }
655                ItemTarget::Is(path) => {
656                    target = schema
657                        .get_node(path)
658                        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
659                }
660            },
661            SchemaNode::Record(_) | SchemaNode::Enum(_) | SchemaNode::Tuple(_) => {
662                return Err(FragmentLoweringError::InvalidReference(format!(
663                    "durable-rule target '{source}' has no supported scalar or collection value"
664                )));
665            }
666            SchemaNode::Canister(_)
667            | SchemaNode::Entity(_)
668            | SchemaNode::Normalizer(_)
669            | SchemaNode::Store(_)
670            | SchemaNode::Validator(_) => {
671                return Err(FragmentLoweringError::InvalidReference(
672                    "non-type durable-rule target".to_string(),
673                ));
674            }
675        }
676    }
677}
678
679fn invalid_rule_target(rule: &SourceRule) -> FragmentLoweringError {
680    FragmentLoweringError::InvalidReference(format!(
681        "durable rule '{}' does not match its nominal target",
682        rule.name()
683    ))
684}
685
686fn entity_field_source_key(
687    entity: &Entity,
688    field_name: &str,
689) -> Result<FieldSourceKey, FragmentLoweringError> {
690    let field = entity
691        .fields()
692        .get(field_name)
693        .ok_or_else(|| FragmentLoweringError::InvalidReference(field_name.to_string()))?;
694    FieldSourceKey::try_new(field.name()).map_err(Into::into)
695}
696
697// -----------------------------------------------------------------------------
698// Reachable named-type closure
699// -----------------------------------------------------------------------------
700
701fn lower_reachable_types(
702    schema: &Schema,
703    mut pending: Vec<String>,
704) -> Result<Vec<NamedTypeFragment>, FragmentLoweringError> {
705    let mut lowered = BTreeMap::new();
706    while let Some(path) = pending.pop() {
707        let node = schema
708            .get_node(path.as_str())
709            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
710        let source_key = named_type_name(node)
711            .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
712        if lowered.contains_key(source_key) {
713            continue;
714        }
715        let fragment = lower_named_type(schema, node, &mut pending)?;
716        lowered.insert(source_key.to_string(), fragment);
717    }
718    Ok(lowered.into_values().collect())
719}
720
721const fn named_type_name(node: &crate::node::SchemaNode) -> Option<&str> {
722    match node {
723        crate::node::SchemaNode::Enum(node) => Some(node.name()),
724        crate::node::SchemaNode::List(node) => Some(node.name()),
725        crate::node::SchemaNode::Map(node) => Some(node.name()),
726        crate::node::SchemaNode::Newtype(node) => Some(node.name()),
727        crate::node::SchemaNode::Record(node) => Some(node.name()),
728        crate::node::SchemaNode::Set(node) => Some(node.name()),
729        crate::node::SchemaNode::Tuple(node) => Some(node.name()),
730        crate::node::SchemaNode::Canister(_)
731        | crate::node::SchemaNode::Entity(_)
732        | crate::node::SchemaNode::Normalizer(_)
733        | crate::node::SchemaNode::Store(_)
734        | crate::node::SchemaNode::Validator(_) => None,
735    }
736}
737
738fn lower_named_type(
739    schema: &Schema,
740    node: &crate::node::SchemaNode,
741    pending: &mut Vec<String>,
742) -> Result<NamedTypeFragment, FragmentLoweringError> {
743    match node {
744        crate::node::SchemaNode::Record(record) => lower_record(schema, record, pending),
745        crate::node::SchemaNode::Enum(r#enum) => lower_enum(schema, r#enum, pending),
746        crate::node::SchemaNode::Newtype(newtype) => Ok(NamedTypeFragment::newtype(
747            SchemaName::try_new(newtype.name())?,
748            lower_item_type(schema, newtype.item(), pending)?,
749        )),
750        crate::node::SchemaNode::List(list) => lower_list(schema, list, pending),
751        crate::node::SchemaNode::Set(set) => lower_set(schema, set, pending),
752        crate::node::SchemaNode::Map(map) => lower_map(schema, map, pending),
753        crate::node::SchemaNode::Tuple(tuple) => lower_tuple(schema, tuple, pending),
754        crate::node::SchemaNode::Canister(_)
755        | crate::node::SchemaNode::Entity(_)
756        | crate::node::SchemaNode::Normalizer(_)
757        | crate::node::SchemaNode::Store(_)
758        | crate::node::SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
759            "non-type graph node".to_string(),
760        )),
761    }
762}
763
764fn lower_record(
765    schema: &Schema,
766    record: &Record,
767    pending: &mut Vec<String>,
768) -> Result<NamedTypeFragment, FragmentLoweringError> {
769    let fields = record
770        .fields()
771        .fields()
772        .iter()
773        .map(|field| {
774            Ok(RecordFieldFragment::new(
775                SchemaName::try_new(field.name())?,
776                lower_value_type(schema, field.value(), pending)?,
777                field.value().cardinality() == Cardinality::Opt,
778            ))
779        })
780        .collect::<Result<Vec<_>, FragmentLoweringError>>()?;
781    Ok(NamedTypeFragment::Record(RecordTypeFragment::try_new(
782        SchemaName::try_new(record.name())?,
783        fields,
784    )?))
785}
786
787fn lower_enum(
788    schema: &Schema,
789    r#enum: &Enum,
790    pending: &mut Vec<String>,
791) -> Result<NamedTypeFragment, FragmentLoweringError> {
792    let variants = r#enum
793        .variants()
794        .iter()
795        .map(|variant| {
796            let name = SchemaName::try_new(variant.name())?;
797            match variant.value() {
798                Some(value) if value.cardinality() == Cardinality::Opt => {
799                    Err(FragmentLoweringError::UnsupportedCardinality(format!(
800                        "{}::{}",
801                        r#enum.def().path(),
802                        variant.name()
803                    )))
804                }
805                Some(value) => Ok(EnumVariantFragment::with_payload(
806                    name,
807                    lower_value_type(schema, value, pending)?,
808                )),
809                None => Ok(EnumVariantFragment::new(name)),
810            }
811        })
812        .collect::<Result<Vec<_>, _>>()?;
813    Ok(NamedTypeFragment::Enum(EnumTypeFragment::try_new(
814        SchemaName::try_new(r#enum.name())?,
815        variants,
816    )?))
817}
818
819fn lower_list(
820    schema: &Schema,
821    list: &List,
822    pending: &mut Vec<String>,
823) -> Result<NamedTypeFragment, FragmentLoweringError> {
824    Ok(NamedTypeFragment::list(
825        SchemaName::try_new(list.name())?,
826        lower_item_type(schema, list.item(), pending)?,
827    ))
828}
829
830fn lower_set(
831    schema: &Schema,
832    set: &Set,
833    pending: &mut Vec<String>,
834) -> Result<NamedTypeFragment, FragmentLoweringError> {
835    Ok(NamedTypeFragment::set(
836        SchemaName::try_new(set.name())?,
837        lower_item_type(schema, set.item(), pending)?,
838    ))
839}
840
841fn lower_map(
842    schema: &Schema,
843    map: &Map,
844    pending: &mut Vec<String>,
845) -> Result<NamedTypeFragment, FragmentLoweringError> {
846    if map.value().cardinality() == Cardinality::Opt {
847        return Err(FragmentLoweringError::UnsupportedCardinality(
848            map.def().path(),
849        ));
850    }
851    Ok(NamedTypeFragment::map(
852        SchemaName::try_new(map.name())?,
853        lower_item_type(schema, map.key(), pending)?,
854        lower_value_type(schema, map.value(), pending)?,
855    ))
856}
857
858fn lower_tuple(
859    schema: &Schema,
860    tuple: &Tuple,
861    pending: &mut Vec<String>,
862) -> Result<NamedTypeFragment, FragmentLoweringError> {
863    let members = tuple
864        .values()
865        .iter()
866        .map(|value| {
867            Ok::<_, FragmentLoweringError>(TupleElementFragment::new(
868                lower_value_type(schema, value, pending)?,
869                value.cardinality() == Cardinality::Opt,
870            ))
871        })
872        .collect::<Result<Vec<_>, _>>()?;
873    Ok(NamedTypeFragment::tuple(
874        SchemaName::try_new(tuple.name())?,
875        members,
876    ))
877}
878
879// -----------------------------------------------------------------------------
880// Exact field contracts
881// -----------------------------------------------------------------------------
882
883fn lower_value_type(
884    schema: &Schema,
885    value: &Value,
886    pending: &mut Vec<String>,
887) -> Result<FieldType, FragmentLoweringError> {
888    let item = lower_item_type(schema, value.item(), pending)?;
889    Ok(if value.cardinality() == Cardinality::Many {
890        FieldType::List(Box::new(item))
891    } else {
892        item
893    })
894}
895
896fn lower_item_type(
897    schema: &Schema,
898    item: &Item,
899    pending: &mut Vec<String>,
900) -> Result<FieldType, FragmentLoweringError> {
901    match item.target() {
902        ItemTarget::Is(path) => {
903            pending.push((*path).to_string());
904            Ok(FieldType::Named(TypeSourceKey::try_new(
905                type_source_key_for_path(schema, path)?,
906            )?))
907        }
908        ItemTarget::Primitive(primitive) => {
909            Ok(FieldType::Scalar(lower_scalar_type(*primitive, item)))
910        }
911    }
912}
913
914fn type_source_key_for_path<'schema>(
915    schema: &'schema Schema,
916    path: &str,
917) -> Result<&'schema str, FragmentLoweringError> {
918    let source = schema
919        .get_node(path)
920        .and_then(named_type_name)
921        .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
922    Ok(source)
923}
924
925fn lower_scalar_type(primitive: Primitive, item: &Item) -> ScalarType {
926    match primitive {
927        Primitive::Account => ScalarType::Account,
928        Primitive::Blob => ScalarType::Blob {
929            max_len: item.max_len(),
930        },
931        Primitive::Bool => ScalarType::Bool,
932        Primitive::Date => ScalarType::Date,
933        Primitive::Decimal => ScalarType::Decimal {
934            scale: item.scale().unwrap_or(0),
935        },
936        Primitive::Duration => ScalarType::Duration,
937        Primitive::Float32 => ScalarType::Float32,
938        Primitive::Float64 => ScalarType::Float64,
939        Primitive::Int8 => ScalarType::Int8,
940        Primitive::Int16 => ScalarType::Int16,
941        Primitive::Int32 => ScalarType::Int32,
942        Primitive::Int64 => ScalarType::Int64,
943        Primitive::Int128 => ScalarType::Int128,
944        Primitive::IntBig => ScalarType::IntBig {
945            max_bytes: item.max_bytes().unwrap_or(DEFAULT_BIG_INT_MAX_BYTES),
946        },
947        Primitive::Nat8 => ScalarType::Nat8,
948        Primitive::Nat16 => ScalarType::Nat16,
949        Primitive::Nat32 => ScalarType::Nat32,
950        Primitive::Nat64 => ScalarType::Nat64,
951        Primitive::Nat128 => ScalarType::Nat128,
952        Primitive::NatBig => ScalarType::NatBig {
953            max_bytes: item.max_bytes().unwrap_or(DEFAULT_BIG_INT_MAX_BYTES),
954        },
955        Primitive::Principal => ScalarType::Principal,
956        Primitive::Subaccount => ScalarType::Subaccount,
957        Primitive::Text => ScalarType::Text {
958            max_len: item.max_len(),
959        },
960        Primitive::Timestamp => ScalarType::Timestamp,
961        Primitive::Ulid => ScalarType::Ulid,
962        Primitive::Unit => ScalarType::Unit,
963    }
964}
965
966// -----------------------------------------------------------------------------
967// Authored database defaults
968// -----------------------------------------------------------------------------
969
970fn lower_default(
971    schema: &Schema,
972    field: &Field,
973    default: &Arg,
974) -> Result<ScalarLiteral, FragmentLoweringError> {
975    if let ItemTarget::Is(path) = field.value().item().target() {
976        let Arg::ConstPath(default_path) = default else {
977            return Err(FragmentLoweringError::InvalidDefault(
978                field.name().to_string(),
979            ));
980        };
981        let variant = default_path.rsplit("::").next().unwrap_or(default_path);
982        return schema
983            .enum_unit_literal(path, variant)
984            .map_err(FragmentLoweringError::from);
985    }
986    let ItemTarget::Primitive(primitive) = field.value().item().target() else {
987        return Err(FragmentLoweringError::InvalidDefault(
988            field.name().to_string(),
989        ));
990    };
991    lower_scalar_default(*primitive, field.value().item(), default)
992        .ok_or_else(|| FragmentLoweringError::InvalidDefault(field.name().to_string()))
993}
994
995fn lower_scalar_default(primitive: Primitive, item: &Item, default: &Arg) -> Option<ScalarLiteral> {
996    if default_constructor_is_zero(default) {
997        return zero_scalar_literal(primitive, item);
998    }
999    match (primitive, default) {
1000        (Primitive::Account, Arg::String(value)) => {
1001            Account::from_str(value).ok().map(ScalarLiteral::Account)
1002        }
1003        (Primitive::Blob, Arg::String(value)) => Blob::try_new(value.as_bytes().to_vec())
1004            .ok()
1005            .map(ScalarLiteral::Blob),
1006        (Primitive::Bool, Arg::Bool(value)) => Some(ScalarLiteral::Bool(*value)),
1007        (Primitive::Date, Arg::String(value)) => Date::parse(value).map(ScalarLiteral::Date),
1008        (Primitive::Date, Arg::Number(value)) => arg_i128(value)
1009            .and_then(|value| i32::try_from(value).ok())
1010            .map(Date::from_days_since_epoch)
1011            .map(ScalarLiteral::Date),
1012        (Primitive::Decimal, Arg::String(value)) => Decimal::from_str(value)
1013            .ok()
1014            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1015            .map(ScalarLiteral::Decimal),
1016        (Primitive::Decimal, Arg::Number(value)) => arg_decimal(value)
1017            .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1018            .map(ScalarLiteral::Decimal),
1019        (Primitive::Duration, Arg::String(value)) => Duration::parse_flexible(value)
1020            .ok()
1021            .map(ScalarLiteral::Duration),
1022        (Primitive::Duration, Arg::Number(value)) => arg_u128(value)
1023            .and_then(|value| u64::try_from(value).ok())
1024            .map(Duration::from_millis)
1025            .map(ScalarLiteral::Duration),
1026        (Primitive::Float32, Arg::Number(ArgNumber::Float32(value))) => {
1027            Float32::try_new(*value).map(ScalarLiteral::Float32)
1028        }
1029        (Primitive::Float64, Arg::Number(ArgNumber::Float64(value))) => {
1030            Float64::try_new(*value).map(ScalarLiteral::Float64)
1031        }
1032        (
1033            Primitive::Int8
1034            | Primitive::Int16
1035            | Primitive::Int32
1036            | Primitive::Int64
1037            | Primitive::Int128,
1038            Arg::Number(value),
1039        ) => arg_i128(value).map(ScalarLiteral::Int),
1040        (Primitive::IntBig, Arg::Number(value)) => arg_i128(value)
1041            .map(|value| value.to_string())
1042            .and_then(|value| IntBig::from_str(value.as_str()).ok())
1043            .map(ScalarLiteral::IntBig),
1044        (Primitive::IntBig, Arg::String(value)) => {
1045            IntBig::from_str(value).ok().map(ScalarLiteral::IntBig)
1046        }
1047        (
1048            Primitive::Nat8
1049            | Primitive::Nat16
1050            | Primitive::Nat32
1051            | Primitive::Nat64
1052            | Primitive::Nat128,
1053            Arg::Number(value),
1054        ) => arg_u128(value).map(ScalarLiteral::Nat),
1055        (Primitive::NatBig, Arg::Number(value)) => arg_u128(value)
1056            .map(|value| value.to_string())
1057            .and_then(|value| NatBig::from_str(value.as_str()).ok())
1058            .map(ScalarLiteral::NatBig),
1059        (Primitive::NatBig, Arg::String(value)) => {
1060            NatBig::from_str(value).ok().map(ScalarLiteral::NatBig)
1061        }
1062        (Primitive::Principal, Arg::String(value)) => Principal::from_str(value)
1063            .ok()
1064            .map(ScalarLiteral::Principal),
1065        (Primitive::Subaccount, Arg::String(value)) => parse_subaccount(value)
1066            .map(Subaccount::from_array)
1067            .map(ScalarLiteral::Subaccount),
1068        (Primitive::Text, Arg::String(value)) => Some(ScalarLiteral::Text((*value).to_string())),
1069        (Primitive::Timestamp, Arg::String(value)) => Timestamp::parse_flexible(value)
1070            .ok()
1071            .map(ScalarLiteral::Timestamp),
1072        (Primitive::Timestamp, Arg::Number(value)) => arg_i128(value)
1073            .and_then(|value| i64::try_from(value).ok())
1074            .map(Timestamp::from_millis)
1075            .map(ScalarLiteral::Timestamp),
1076        (Primitive::Ulid, Arg::String(value)) => {
1077            Ulid::from_str(value).ok().map(ScalarLiteral::Ulid)
1078        }
1079        (Primitive::Unit, Arg::ConstPath(path)) if path.ends_with("Unit") => {
1080            Some(ScalarLiteral::Unit(Unit))
1081        }
1082        _ => None,
1083    }
1084}
1085
1086fn default_constructor_is_zero(default: &Arg) -> bool {
1087    let Arg::FuncPath(path) = default else {
1088        return false;
1089    };
1090    path.ends_with("::default")
1091        || path.ends_with("::new")
1092        || path.ends_with("::EPOCH")
1093        || path.ends_with("::nil")
1094}
1095
1096fn zero_scalar_literal(primitive: Primitive, item: &Item) -> Option<ScalarLiteral> {
1097    match primitive {
1098        Primitive::Blob => Blob::try_new(Vec::new()).ok().map(ScalarLiteral::Blob),
1099        Primitive::Bool => Some(ScalarLiteral::Bool(false)),
1100        Primitive::Date => Some(ScalarLiteral::Date(Date::EPOCH)),
1101        Primitive::Decimal => Decimal::try_from_i128_with_scale(0, item.scale().unwrap_or(0))
1102            .map(ScalarLiteral::Decimal),
1103        Primitive::Duration => Some(ScalarLiteral::Duration(Duration::ZERO)),
1104        Primitive::Float32 => Float32::try_new(0.0).map(ScalarLiteral::Float32),
1105        Primitive::Float64 => Float64::try_new(0.0).map(ScalarLiteral::Float64),
1106        Primitive::Int8
1107        | Primitive::Int16
1108        | Primitive::Int32
1109        | Primitive::Int64
1110        | Primitive::Int128 => Some(ScalarLiteral::Int(0)),
1111        Primitive::IntBig => IntBig::from_str("0").ok().map(ScalarLiteral::IntBig),
1112        Primitive::Nat8
1113        | Primitive::Nat16
1114        | Primitive::Nat32
1115        | Primitive::Nat64
1116        | Primitive::Nat128 => Some(ScalarLiteral::Nat(0)),
1117        Primitive::NatBig => NatBig::from_str("0").ok().map(ScalarLiteral::NatBig),
1118        Primitive::Text => Some(ScalarLiteral::Text(String::new())),
1119        Primitive::Timestamp => Some(ScalarLiteral::Timestamp(Timestamp::EPOCH)),
1120        Primitive::Ulid => Some(ScalarLiteral::Ulid(Ulid::nil())),
1121        Primitive::Unit => Some(ScalarLiteral::Unit(Unit)),
1122        Primitive::Account | Primitive::Principal | Primitive::Subaccount => None,
1123    }
1124}
1125
1126// -----------------------------------------------------------------------------
1127// Literal conversion helpers
1128// -----------------------------------------------------------------------------
1129
1130fn decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
1131    match value.scale().cmp(&scale) {
1132        std::cmp::Ordering::Equal => Some(value),
1133        std::cmp::Ordering::Less => value
1134            .scale_to_integer(scale)
1135            .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale)),
1136        std::cmp::Ordering::Greater => Some(value.round_dp(scale)),
1137    }
1138}
1139
1140fn arg_i128(value: &ArgNumber) -> Option<i128> {
1141    match value {
1142        ArgNumber::Int8(value) => Some(i128::from(*value)),
1143        ArgNumber::Int16(value) => Some(i128::from(*value)),
1144        ArgNumber::Int32(value) => Some(i128::from(*value)),
1145        ArgNumber::Int64(value) => Some(i128::from(*value)),
1146        ArgNumber::Int128(value) => Some(*value),
1147        ArgNumber::Nat8(value) => Some(i128::from(*value)),
1148        ArgNumber::Nat16(value) => Some(i128::from(*value)),
1149        ArgNumber::Nat32(value) => Some(i128::from(*value)),
1150        ArgNumber::Nat64(value) => Some(i128::from(*value)),
1151        ArgNumber::Nat128(value) => i128::try_from(*value).ok(),
1152        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1153    }
1154}
1155
1156fn arg_u128(value: &ArgNumber) -> Option<u128> {
1157    match value {
1158        ArgNumber::Int8(value) => u128::try_from(*value).ok(),
1159        ArgNumber::Int16(value) => u128::try_from(*value).ok(),
1160        ArgNumber::Int32(value) => u128::try_from(*value).ok(),
1161        ArgNumber::Int64(value) => u128::try_from(*value).ok(),
1162        ArgNumber::Int128(value) => u128::try_from(*value).ok(),
1163        ArgNumber::Nat8(value) => Some(u128::from(*value)),
1164        ArgNumber::Nat16(value) => Some(u128::from(*value)),
1165        ArgNumber::Nat32(value) => Some(u128::from(*value)),
1166        ArgNumber::Nat64(value) => Some(u128::from(*value)),
1167        ArgNumber::Nat128(value) => Some(*value),
1168        ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1169    }
1170}
1171
1172fn arg_decimal(value: &ArgNumber) -> Option<Decimal> {
1173    match value {
1174        ArgNumber::Float32(value) => Decimal::from_f32_lossy(*value),
1175        ArgNumber::Float64(value) => Decimal::from_f64_lossy(*value),
1176        _ => arg_i128(value).and_then(Decimal::from_i128),
1177    }
1178}
1179
1180fn parse_subaccount(value: &str) -> Option<[u8; 32]> {
1181    if value.len() != 64 {
1182        return None;
1183    }
1184    let mut bytes = [0; 32];
1185    for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
1186        let text = std::str::from_utf8(chunk).ok()?;
1187        bytes[index] = u8::from_str_radix(text, 16).ok()?;
1188    }
1189    Some(bytes)
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use icydb_schema::{
1195        ConstraintFragmentKind, ConstraintSourceKey, Decimal, FieldSourceKey, FieldType,
1196        NamedTypeFragment, RuleSourceKey, ScalarLiteral, ScalarType, SourceRuleOperation,
1197        TypeSourceKey,
1198    };
1199
1200    use super::{Schema, lower_field_rules};
1201    use crate::{
1202        node::{
1203            Args, Canister, Def, Entity, Enum, EnumVariant, Field, FieldList, Item, ItemTarget,
1204            Newtype, Normalizer, PrimaryKey, PrimaryKeySource, Record, RuleNumber, SchemaNode,
1205            SourceRule, SourceRuleAuthoringOperation, Store, StoreHeapConfig, Type, TypeNormalizer,
1206            TypeValidator, Validator, Value,
1207        },
1208        types::{Cardinality, Primitive},
1209    };
1210
1211    static EMPTY_TYPE: Type = Type::new(&[], &[], &[]);
1212    static APPLICATION_FIELDS: [Field; 1] = [Field::new(
1213        "id",
1214        Value::new(
1215            Cardinality::One,
1216            Item::new(
1217                ItemTarget::Primitive(Primitive::Nat64),
1218                None,
1219                None,
1220                None,
1221                None,
1222                &[],
1223                &[],
1224                false,
1225            ),
1226        ),
1227        None,
1228        None,
1229        None,
1230    )];
1231    static APPLICATION_NORMALIZERS_A: [TypeNormalizer; 1] =
1232        [TypeNormalizer::new("test::NormalizeA", Args(&[]))];
1233    static APPLICATION_NORMALIZERS_B: [TypeNormalizer; 1] =
1234        [TypeNormalizer::new("test::NormalizeB", Args(&[]))];
1235    static APPLICATION_VALIDATORS_A: [TypeValidator; 1] =
1236        [TypeValidator::new("test::ValidateA", Args(&[]))];
1237    static APPLICATION_VALIDATORS_B: [TypeValidator; 1] =
1238        [TypeValidator::new("test::ValidateB", Args(&[]))];
1239    static NUMERIC_RULES: [SourceRule; 1] = [SourceRule::new(
1240        "range",
1241        SourceRuleAuthoringOperation::NumericRangeInclusive {
1242            min: RuleNumber::Integer("0"),
1243            max: RuleNumber::Integer("360"),
1244        },
1245    )];
1246    static NUMERIC_RULE_TYPE: Type = Type::new(&[], &[], &NUMERIC_RULES);
1247    static LENGTH_RULES: [SourceRule; 1] = [SourceRule::new(
1248        "length",
1249        SourceRuleAuthoringOperation::LengthRangeInclusive {
1250            min: RuleNumber::Integer("2"),
1251            max: RuleNumber::Integer("40"),
1252        },
1253    )];
1254    static LENGTH_RULE_TYPE: Type = Type::new(&[], &[], &LENGTH_RULES);
1255    static NAT_EXACT_RULES: [SourceRule; 2] = [
1256        SourceRule::new(
1257            "maximum",
1258            SourceRuleAuthoringOperation::NumericMaximumInclusive {
1259                value: RuleNumber::Integer("100"),
1260            },
1261        ),
1262        SourceRule::new(
1263            "step",
1264            SourceRuleAuthoringOperation::MultipleOf {
1265                divisor: RuleNumber::Integer("5"),
1266            },
1267        ),
1268    ];
1269    static NAT_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &NAT_EXACT_RULES);
1270    static DECIMAL_EXACT_RULES: [SourceRule; 1] = [SourceRule::new(
1271        "step",
1272        SourceRuleAuthoringOperation::MultipleOf {
1273            divisor: RuleNumber::Decimal("0.25"),
1274        },
1275    )];
1276    static DECIMAL_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &DECIMAL_EXACT_RULES);
1277    static INEXACT_DECIMAL_RULES: [SourceRule; 1] = [SourceRule::new(
1278        "step",
1279        SourceRuleAuthoringOperation::MultipleOf {
1280            divisor: RuleNumber::Decimal("0.251"),
1281        },
1282    )];
1283    static INEXACT_DECIMAL_RULE_TYPE: Type = Type::new(&[], &[], &INEXACT_DECIMAL_RULES);
1284    static NESTED_RULE_FIELDS: [Field; 1] = [Field::new(
1285        "degrees",
1286        Value::new(
1287            Cardinality::One,
1288            Item::new(
1289                ItemTarget::Is("test::Degrees"),
1290                None,
1291                None,
1292                None,
1293                None,
1294                &[],
1295                &[],
1296                false,
1297            ),
1298        ),
1299        None,
1300        None,
1301        None,
1302    )];
1303    static STATUS_VARIANTS: [EnumVariant; 2] = [
1304        EnumVariant::new("Active", None),
1305        EnumVariant::new(
1306            "Retries",
1307            Some(Value::new(
1308                Cardinality::Many,
1309                Item::new(
1310                    ItemTarget::Primitive(Primitive::Nat16),
1311                    None,
1312                    None,
1313                    None,
1314                    None,
1315                    &[],
1316                    &[],
1317                    false,
1318                ),
1319            )),
1320        ),
1321    ];
1322
1323    fn application_behavior_fragment(
1324        normalizers: &'static [TypeNormalizer],
1325        validators: &'static [TypeValidator],
1326        normalizer_name: &'static str,
1327        validator_name: &'static str,
1328    ) -> icydb_schema::SchemaFragment {
1329        let mut schema = Schema::new();
1330        schema.insert_node(SchemaNode::Canister(Canister::new(
1331            Def::new("test", "Canister"),
1332            "test",
1333            0,
1334            10,
1335            9,
1336            8,
1337        )));
1338        schema.insert_node(SchemaNode::Store(Store::new_heap(
1339            Def::new("test", "Store"),
1340            "test::Canister",
1341            StoreHeapConfig::new(),
1342        )));
1343        schema.insert_node(SchemaNode::Normalizer(Normalizer::new(Def::new(
1344            "test",
1345            normalizer_name,
1346        ))));
1347        schema.insert_node(SchemaNode::Validator(Validator::new(Def::new(
1348            "test",
1349            validator_name,
1350        ))));
1351        schema.insert_node(SchemaNode::Entity(Entity::new(
1352            Def::new("test", "ApplicationOnly"),
1353            "test::Store",
1354            1,
1355            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1356            &[],
1357            &[],
1358            &[],
1359            FieldList::new(&APPLICATION_FIELDS),
1360            Type::new(normalizers, validators, &[]),
1361        )));
1362        schema.seal().expect("application-only fixture should seal");
1363        schema
1364            .schema_fragment_for_canister("test::Canister")
1365            .expect("application-only fixture should lower")
1366    }
1367
1368    #[test]
1369    fn validator_and_normalizer_edits_do_not_change_database_fragment() {
1370        let before = application_behavior_fragment(
1371            &APPLICATION_NORMALIZERS_A,
1372            &APPLICATION_VALIDATORS_A,
1373            "NormalizeA",
1374            "ValidateA",
1375        );
1376        let after = application_behavior_fragment(
1377            &APPLICATION_NORMALIZERS_B,
1378            &APPLICATION_VALIDATORS_B,
1379            "NormalizeB",
1380            "ValidateB",
1381        );
1382
1383        assert_eq!(before, after);
1384    }
1385
1386    #[test]
1387    fn durable_rules_nested_below_structural_fields_lower_to_nominal_targets() {
1388        let mut schema = Schema::new();
1389        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1390            Def::new("test", "Degrees"),
1391            "Degrees",
1392            Item::new(
1393                ItemTarget::Primitive(Primitive::Nat16),
1394                None,
1395                None,
1396                None,
1397                None,
1398                &[],
1399                &[],
1400                false,
1401            ),
1402            None,
1403            NUMERIC_RULE_TYPE.clone(),
1404        )));
1405        schema.insert_node(SchemaNode::Record(Record::new(
1406            Def::new("test", "Nested"),
1407            "Nested",
1408            FieldList::new(&NESTED_RULE_FIELDS),
1409            EMPTY_TYPE.clone(),
1410        )));
1411
1412        let outer = Field::new(
1413            "nested",
1414            Value::new(
1415                Cardinality::One,
1416                Item::new(
1417                    ItemTarget::Is("test::Nested"),
1418                    None,
1419                    None,
1420                    None,
1421                    None,
1422                    &[],
1423                    &[],
1424                    false,
1425                ),
1426            ),
1427            None,
1428            None,
1429            None,
1430        );
1431        let constraints =
1432            lower_field_rules(&schema, &outer).expect("nested durable rule should lower");
1433        assert_eq!(constraints.len(), 1);
1434        let ConstraintFragmentKind::TargetedRule(rule) = constraints[0].kind() else {
1435            panic!("nested durable rule should use the targeted-rule contract")
1436        };
1437        assert_eq!(rule.root().as_str(), "nested");
1438        assert_eq!(rule.target_type().as_str(), "Degrees");
1439        assert!(matches!(
1440            rule.operation(),
1441            SourceRuleOperation::NumericRangeInclusive { .. }
1442        ));
1443    }
1444
1445    #[test]
1446    fn exact_maximum_and_multiple_of_lower_without_float_reconstruction() {
1447        let mut schema = Schema::new();
1448        for (name, primitive, scale, rules) in [
1449            (
1450                "Counter",
1451                Primitive::Nat64,
1452                None,
1453                NAT_EXACT_RULE_TYPE.clone(),
1454            ),
1455            (
1456                "PriceStep",
1457                Primitive::Decimal,
1458                Some(2),
1459                DECIMAL_EXACT_RULE_TYPE.clone(),
1460            ),
1461        ] {
1462            schema.insert_node(SchemaNode::Newtype(Newtype::new(
1463                Def::new("test", name),
1464                name,
1465                Item::new(
1466                    ItemTarget::Primitive(primitive),
1467                    None,
1468                    scale,
1469                    None,
1470                    None,
1471                    &[],
1472                    &[],
1473                    false,
1474                ),
1475                None,
1476                rules,
1477            )));
1478        }
1479
1480        let field = |name| {
1481            Field::new(
1482                name,
1483                Value::new(
1484                    Cardinality::One,
1485                    Item::new(
1486                        ItemTarget::Is(if name == "counter" {
1487                            "test::Counter"
1488                        } else {
1489                            "test::PriceStep"
1490                        }),
1491                        None,
1492                        None,
1493                        None,
1494                        None,
1495                        &[],
1496                        &[],
1497                        false,
1498                    ),
1499                ),
1500                None,
1501                None,
1502                None,
1503            )
1504        };
1505        let counter = lower_field_rules(&schema, &field("counter"))
1506            .expect("exact integer rules should lower");
1507        assert!(matches!(
1508            counter[0].kind(),
1509            ConstraintFragmentKind::TargetedRule(rule)
1510                if matches!(
1511                    rule.operation(),
1512                    SourceRuleOperation::NumericMaximumInclusive {
1513                        value: ScalarLiteral::Nat(100)
1514                    }
1515                )
1516        ));
1517        assert!(matches!(
1518            counter[1].kind(),
1519            ConstraintFragmentKind::TargetedRule(rule)
1520                if matches!(
1521                    rule.operation(),
1522                    SourceRuleOperation::MultipleOf {
1523                        divisor: ScalarLiteral::Nat(5)
1524                    }
1525                )
1526        ));
1527
1528        let decimal = lower_field_rules(&schema, &field("price"))
1529            .expect("exact decimal multiple should lower");
1530        assert!(matches!(
1531            decimal[0].kind(),
1532            ConstraintFragmentKind::TargetedRule(rule)
1533                if matches!(
1534                    rule.operation(),
1535                    SourceRuleOperation::MultipleOf {
1536                        divisor: ScalarLiteral::Decimal(value)
1537                    } if *value == Decimal::new(25, 2)
1538                )
1539        ));
1540    }
1541
1542    #[test]
1543    fn inexact_decimal_rule_operand_rejects_before_proposal_composition() {
1544        let mut schema = Schema::new();
1545        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1546            Def::new("test", "InexactStep"),
1547            "InexactStep",
1548            Item::new(
1549                ItemTarget::Primitive(Primitive::Decimal),
1550                None,
1551                Some(2),
1552                None,
1553                None,
1554                &[],
1555                &[],
1556                false,
1557            ),
1558            None,
1559            INEXACT_DECIMAL_RULE_TYPE.clone(),
1560        )));
1561        let field = Field::new(
1562            "price",
1563            Value::new(
1564                Cardinality::One,
1565                Item::new(
1566                    ItemTarget::Is("test::InexactStep"),
1567                    None,
1568                    None,
1569                    None,
1570                    None,
1571                    &[],
1572                    &[],
1573                    false,
1574                ),
1575            ),
1576            None,
1577            None,
1578            None,
1579        );
1580        assert!(lower_field_rules(&schema, &field).is_err());
1581    }
1582
1583    static ENTITY_FIELDS: [Field; 5] = [
1584        Field::new(
1585            "id",
1586            Value::new(
1587                Cardinality::One,
1588                Item::new(
1589                    ItemTarget::Primitive(Primitive::Nat64),
1590                    None,
1591                    None,
1592                    None,
1593                    None,
1594                    &[],
1595                    &[],
1596                    false,
1597                ),
1598            ),
1599            None,
1600            None,
1601            None,
1602        ),
1603        Field::new(
1604            "tags",
1605            Value::new(
1606                Cardinality::Many,
1607                Item::new(
1608                    ItemTarget::Primitive(Primitive::Text),
1609                    None,
1610                    None,
1611                    Some(32),
1612                    None,
1613                    &[],
1614                    &[],
1615                    false,
1616                ),
1617            ),
1618            None,
1619            None,
1620            None,
1621        ),
1622        Field::new(
1623            "status",
1624            Value::new(
1625                Cardinality::One,
1626                Item::new(
1627                    ItemTarget::Is("test::Status"),
1628                    None,
1629                    None,
1630                    None,
1631                    None,
1632                    &[],
1633                    &[],
1634                    false,
1635                ),
1636            ),
1637            Some(crate::node::Arg::ConstPath("test::Status::Active")),
1638            None,
1639            None,
1640        ),
1641        Field::new(
1642            "degrees",
1643            Value::new(
1644                Cardinality::One,
1645                Item::new(
1646                    ItemTarget::Is("test::Degrees"),
1647                    None,
1648                    None,
1649                    None,
1650                    None,
1651                    &[],
1652                    &[],
1653                    false,
1654                ),
1655            ),
1656            None,
1657            None,
1658            None,
1659        ),
1660        Field::new(
1661            "label",
1662            Value::new(
1663                Cardinality::One,
1664                Item::new(
1665                    ItemTarget::Is("test::Label"),
1666                    None,
1667                    None,
1668                    None,
1669                    None,
1670                    &[],
1671                    &[],
1672                    false,
1673                ),
1674            ),
1675            None,
1676            None,
1677            None,
1678        ),
1679    ];
1680
1681    #[test]
1682    #[expect(
1683        clippy::too_many_lines,
1684        reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
1685    )]
1686    fn sealed_canister_graph_emits_store_free_database_closure() {
1687        let mut schema = Schema::new();
1688        schema.insert_node(SchemaNode::Canister(Canister::new(
1689            Def::new("test", "Canister"),
1690            "test",
1691            0,
1692            10,
1693            9,
1694            8,
1695        )));
1696        schema.insert_node(SchemaNode::Store(Store::new_heap(
1697            Def::new("test", "Store"),
1698            "test::Canister",
1699            StoreHeapConfig::new(),
1700        )));
1701        schema.insert_node(SchemaNode::Enum(Enum::new(
1702            Def::new("test", "Status"),
1703            "Status",
1704            &STATUS_VARIANTS,
1705            EMPTY_TYPE.clone(),
1706        )));
1707        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1708            Def::new("test", "Degrees"),
1709            "Degrees",
1710            Item::new(
1711                ItemTarget::Primitive(Primitive::Nat16),
1712                None,
1713                None,
1714                None,
1715                None,
1716                &[],
1717                &[],
1718                false,
1719            ),
1720            None,
1721            NUMERIC_RULE_TYPE.clone(),
1722        )));
1723        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1724            Def::new("test", "Label"),
1725            "Label",
1726            Item::new(
1727                ItemTarget::Primitive(Primitive::Text),
1728                None,
1729                None,
1730                None,
1731                None,
1732                &[],
1733                &[],
1734                false,
1735            ),
1736            None,
1737            LENGTH_RULE_TYPE.clone(),
1738        )));
1739        schema.insert_node(SchemaNode::Entity(Entity::new(
1740            Def::new("test", "Task"),
1741            "test::Store",
1742            1,
1743            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1744            &[],
1745            &[],
1746            &[],
1747            FieldList::new(&ENTITY_FIELDS),
1748            EMPTY_TYPE.clone(),
1749        )));
1750        schema.seal().expect("fixture graph should seal");
1751
1752        let fragment = schema
1753            .schema_fragment_for_canister("test::Canister")
1754            .expect("sealed database closure should lower");
1755
1756        assert_eq!(fragment.entities().len(), 1);
1757        assert_eq!(fragment.types().len(), 3);
1758        let fields = fragment.entities()[0].fields();
1759        assert!(matches!(
1760            fields
1761                .iter()
1762                .find(|field| field.name().as_str() == "tags")
1763                .map(icydb_schema::FieldFragment::field_type),
1764            Some(FieldType::List(item))
1765                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
1766        ));
1767        assert!(matches!(
1768            fields
1769                .iter()
1770                .find(|field| field.name().as_str() == "status")
1771                .map(icydb_schema::FieldFragment::insert_policy),
1772            Some(icydb_schema::FieldInsertPolicy::Default(
1773                icydb_schema::ScalarLiteral::EnumUnit { .. }
1774            ))
1775        ));
1776        let NamedTypeFragment::Enum(status) = fragment
1777            .types()
1778            .iter()
1779            .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
1780            .expect("reachable status type should remain an enum")
1781        else {
1782            panic!("reachable status type should remain an enum")
1783        };
1784        assert!(matches!(
1785            status
1786                .variants()
1787                .iter()
1788                .find(|variant| variant.name().as_str() == "Retries")
1789                .and_then(|variant| variant.payload()),
1790            Some(FieldType::List(item))
1791                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
1792        ));
1793
1794        let constraints = fragment.entities()[0].constraints();
1795        assert_eq!(constraints.len(), 2);
1796        let degrees_source = ConstraintSourceKey::for_targeted_field_rule(
1797            &FieldSourceKey::try_new("degrees").expect("field name"),
1798            &TypeSourceKey::try_new("Degrees").expect("type name"),
1799            &RuleSourceKey::try_new("range").expect("rule name"),
1800        );
1801        let degrees = constraints
1802            .iter()
1803            .find(|constraint| constraint.source_key() == &degrees_source)
1804            .expect("numeric rule should become one field-owned constraint");
1805        let ConstraintFragmentKind::TargetedRule(degrees) = degrees.kind() else {
1806            panic!("numeric rule should use the targeted-rule contract")
1807        };
1808        assert_eq!(degrees.root().as_str(), "degrees");
1809        assert_eq!(degrees.target_type().as_str(), "Degrees");
1810        assert!(matches!(
1811            degrees.operation(),
1812            SourceRuleOperation::NumericRangeInclusive { .. }
1813        ));
1814        let label = constraints
1815            .iter()
1816            .find(|constraint| constraint.source_key() != &degrees_source)
1817            .expect("length rule should become one field-owned constraint");
1818        let ConstraintFragmentKind::TargetedRule(label) = label.kind() else {
1819            panic!("length rule should use the targeted-rule contract")
1820        };
1821        assert_eq!(label.target_type().as_str(), "Label");
1822        assert!(matches!(
1823            label.operation(),
1824            SourceRuleOperation::LengthRangeInclusive { min: 2, max: 40 }
1825        ));
1826    }
1827}