Skip to main content

icydb_model/
fragment.rs

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