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