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            8,
1390            None,
1391        )));
1392        schema.insert_node(SchemaNode::Store(Store::new_heap(
1393            Def::new("test", "Store"),
1394            "test::Canister",
1395            StoreHeapConfig::new(),
1396        )));
1397        schema.insert_node(SchemaNode::Normalizer(Normalizer::new(Def::new(
1398            "test",
1399            normalizer_name,
1400        ))));
1401        schema.insert_node(SchemaNode::Validator(Validator::new(Def::new(
1402            "test",
1403            validator_name,
1404        ))));
1405        schema.insert_node(SchemaNode::Entity(Entity::new(
1406            Def::new("test", "ApplicationOnly"),
1407            "test::Store",
1408            1,
1409            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1410            &[],
1411            &[],
1412            &[],
1413            FieldList::new(&APPLICATION_FIELDS),
1414            Type::new(normalizers, validators, &[]),
1415        )));
1416        schema.seal().expect("application-only fixture should seal");
1417        schema
1418            .schema_fragment_for_canister("test::Canister")
1419            .expect("application-only fixture should lower")
1420    }
1421
1422    #[test]
1423    fn validator_and_normalizer_edits_do_not_change_database_fragment() {
1424        let before = application_behavior_fragment(
1425            &APPLICATION_NORMALIZERS_A,
1426            &APPLICATION_VALIDATORS_A,
1427            "NormalizeA",
1428            "ValidateA",
1429        );
1430        let after = application_behavior_fragment(
1431            &APPLICATION_NORMALIZERS_B,
1432            &APPLICATION_VALIDATORS_B,
1433            "NormalizeB",
1434            "ValidateB",
1435        );
1436
1437        assert_eq!(before, after);
1438    }
1439
1440    #[test]
1441    fn durable_rules_nested_below_structural_fields_lower_to_nominal_targets() {
1442        let mut schema = Schema::new();
1443        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1444            Def::new("test", "Degrees"),
1445            "Degrees",
1446            Item::new(
1447                ItemTarget::Primitive(Primitive::Nat16),
1448                None,
1449                None,
1450                None,
1451                None,
1452                &[],
1453                &[],
1454                false,
1455            ),
1456            None,
1457            NUMERIC_RULE_TYPE.clone(),
1458        )));
1459        schema.insert_node(SchemaNode::Record(Record::new(
1460            Def::new("test", "Nested"),
1461            "Nested",
1462            FieldList::new(&NESTED_RULE_FIELDS),
1463            EMPTY_TYPE.clone(),
1464        )));
1465
1466        let outer = Field::new(
1467            "nested",
1468            Value::new(
1469                Cardinality::One,
1470                Item::new(
1471                    ItemTarget::Is("test::Nested"),
1472                    None,
1473                    None,
1474                    None,
1475                    None,
1476                    &[],
1477                    &[],
1478                    false,
1479                ),
1480            ),
1481            None,
1482            None,
1483            None,
1484        );
1485        let constraints =
1486            lower_field_rules(&schema, &outer).expect("nested durable rule should lower");
1487        assert_eq!(constraints.len(), 1);
1488        let ConstraintFragmentKind::TargetedRule(rule) = constraints[0].kind() else {
1489            panic!("nested durable rule should use the targeted-rule contract")
1490        };
1491        assert_eq!(rule.root().as_str(), "nested");
1492        assert_eq!(rule.target_type().as_str(), "Degrees");
1493        assert!(matches!(
1494            rule.operation(),
1495            SourceRuleOperation::NumericRangeInclusive { .. }
1496        ));
1497    }
1498
1499    #[test]
1500    fn exact_maximum_and_multiple_of_lower_without_float_reconstruction() {
1501        let mut schema = Schema::new();
1502        for (name, primitive, scale, rules) in [
1503            (
1504                "Counter",
1505                Primitive::Nat64,
1506                None,
1507                NAT_EXACT_RULE_TYPE.clone(),
1508            ),
1509            (
1510                "PriceStep",
1511                Primitive::Decimal,
1512                Some(2),
1513                DECIMAL_EXACT_RULE_TYPE.clone(),
1514            ),
1515        ] {
1516            schema.insert_node(SchemaNode::Newtype(Newtype::new(
1517                Def::new("test", name),
1518                name,
1519                Item::new(
1520                    ItemTarget::Primitive(primitive),
1521                    None,
1522                    scale,
1523                    None,
1524                    None,
1525                    &[],
1526                    &[],
1527                    false,
1528                ),
1529                None,
1530                rules,
1531            )));
1532        }
1533
1534        let field = |name| {
1535            Field::new(
1536                name,
1537                Value::new(
1538                    Cardinality::One,
1539                    Item::new(
1540                        ItemTarget::Is(if name == "counter" {
1541                            "test::Counter"
1542                        } else {
1543                            "test::PriceStep"
1544                        }),
1545                        None,
1546                        None,
1547                        None,
1548                        None,
1549                        &[],
1550                        &[],
1551                        false,
1552                    ),
1553                ),
1554                None,
1555                None,
1556                None,
1557            )
1558        };
1559        let counter = lower_field_rules(&schema, &field("counter"))
1560            .expect("exact integer rules should lower");
1561        assert!(matches!(
1562            counter[0].kind(),
1563            ConstraintFragmentKind::TargetedRule(rule)
1564                if matches!(
1565                    rule.operation(),
1566                    SourceRuleOperation::NumericMaximumInclusive {
1567                        value: ScalarLiteral::Nat(100)
1568                    }
1569                )
1570        ));
1571        assert!(matches!(
1572            counter[1].kind(),
1573            ConstraintFragmentKind::TargetedRule(rule)
1574                if matches!(
1575                    rule.operation(),
1576                    SourceRuleOperation::MultipleOf {
1577                        divisor: ScalarLiteral::Nat(5)
1578                    }
1579                )
1580        ));
1581
1582        let decimal = lower_field_rules(&schema, &field("price"))
1583            .expect("exact decimal multiple should lower");
1584        assert!(matches!(
1585            decimal[0].kind(),
1586            ConstraintFragmentKind::TargetedRule(rule)
1587                if matches!(
1588                    rule.operation(),
1589                    SourceRuleOperation::MultipleOf {
1590                        divisor: ScalarLiteral::Decimal(value)
1591                    } if *value == Decimal::new(25, 2)
1592                )
1593        ));
1594    }
1595
1596    #[test]
1597    fn inexact_decimal_rule_operand_rejects_before_proposal_composition() {
1598        let mut schema = Schema::new();
1599        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1600            Def::new("test", "InexactStep"),
1601            "InexactStep",
1602            Item::new(
1603                ItemTarget::Primitive(Primitive::Decimal),
1604                None,
1605                Some(2),
1606                None,
1607                None,
1608                &[],
1609                &[],
1610                false,
1611            ),
1612            None,
1613            INEXACT_DECIMAL_RULE_TYPE.clone(),
1614        )));
1615        let field = Field::new(
1616            "price",
1617            Value::new(
1618                Cardinality::One,
1619                Item::new(
1620                    ItemTarget::Is("test::InexactStep"),
1621                    None,
1622                    None,
1623                    None,
1624                    None,
1625                    &[],
1626                    &[],
1627                    false,
1628                ),
1629            ),
1630            None,
1631            None,
1632            None,
1633        );
1634        assert!(lower_field_rules(&schema, &field).is_err());
1635    }
1636
1637    static ENTITY_FIELDS: [Field; 5] = [
1638        Field::new(
1639            "id",
1640            Value::new(
1641                Cardinality::One,
1642                Item::new(
1643                    ItemTarget::Primitive(Primitive::Nat64),
1644                    None,
1645                    None,
1646                    None,
1647                    None,
1648                    &[],
1649                    &[],
1650                    false,
1651                ),
1652            ),
1653            None,
1654            None,
1655            None,
1656        ),
1657        Field::new(
1658            "tags",
1659            Value::new(
1660                Cardinality::Many,
1661                Item::new(
1662                    ItemTarget::Primitive(Primitive::Text),
1663                    None,
1664                    None,
1665                    Some(32),
1666                    None,
1667                    &[],
1668                    &[],
1669                    false,
1670                ),
1671            ),
1672            None,
1673            None,
1674            None,
1675        ),
1676        Field::new(
1677            "status",
1678            Value::new(
1679                Cardinality::One,
1680                Item::new(
1681                    ItemTarget::Is("test::Status"),
1682                    None,
1683                    None,
1684                    None,
1685                    None,
1686                    &[],
1687                    &[],
1688                    false,
1689                ),
1690            ),
1691            Some(crate::node::Arg::ConstPath("test::Status::Active")),
1692            None,
1693            None,
1694        ),
1695        Field::new(
1696            "degrees",
1697            Value::new(
1698                Cardinality::One,
1699                Item::new(
1700                    ItemTarget::Is("test::Degrees"),
1701                    None,
1702                    None,
1703                    None,
1704                    None,
1705                    &[],
1706                    &[],
1707                    false,
1708                ),
1709            ),
1710            None,
1711            None,
1712            None,
1713        ),
1714        Field::new(
1715            "label",
1716            Value::new(
1717                Cardinality::One,
1718                Item::new(
1719                    ItemTarget::Is("test::Label"),
1720                    None,
1721                    None,
1722                    None,
1723                    None,
1724                    &[],
1725                    &[],
1726                    false,
1727                ),
1728            ),
1729            None,
1730            None,
1731            None,
1732        ),
1733    ];
1734
1735    #[test]
1736    #[expect(
1737        clippy::too_many_lines,
1738        reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
1739    )]
1740    fn sealed_canister_graph_emits_store_free_database_closure() {
1741        let mut schema = Schema::new();
1742        schema.insert_node(SchemaNode::Canister(Canister::new(
1743            Def::new("test", "Canister"),
1744            "test",
1745            0,
1746            10,
1747            9,
1748            8,
1749            None,
1750        )));
1751        schema.insert_node(SchemaNode::Store(Store::new_heap(
1752            Def::new("test", "Store"),
1753            "test::Canister",
1754            StoreHeapConfig::new(),
1755        )));
1756        schema.insert_node(SchemaNode::Enum(Enum::new(
1757            Def::new("test", "Status"),
1758            "Status",
1759            &STATUS_VARIANTS,
1760            EMPTY_TYPE.clone(),
1761        )));
1762        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1763            Def::new("test", "Degrees"),
1764            "Degrees",
1765            Item::new(
1766                ItemTarget::Primitive(Primitive::Nat16),
1767                None,
1768                None,
1769                None,
1770                None,
1771                &[],
1772                &[],
1773                false,
1774            ),
1775            None,
1776            NUMERIC_RULE_TYPE.clone(),
1777        )));
1778        schema.insert_node(SchemaNode::Newtype(Newtype::new(
1779            Def::new("test", "Label"),
1780            "Label",
1781            Item::new(
1782                ItemTarget::Primitive(Primitive::Text),
1783                None,
1784                None,
1785                None,
1786                None,
1787                &[],
1788                &[],
1789                false,
1790            ),
1791            None,
1792            LENGTH_RULE_TYPE.clone(),
1793        )));
1794        schema.insert_node(SchemaNode::Entity(Entity::new(
1795            Def::new("test", "Task"),
1796            "test::Store",
1797            1,
1798            PrimaryKey::new(&["id"], PrimaryKeySource::External),
1799            &[],
1800            &[],
1801            &[],
1802            FieldList::new(&ENTITY_FIELDS),
1803            EMPTY_TYPE.clone(),
1804        )));
1805        schema.seal().expect("fixture graph should seal");
1806
1807        let fragment = schema
1808            .schema_fragment_for_canister("test::Canister")
1809            .expect("sealed database closure should lower");
1810
1811        assert_eq!(fragment.entities().len(), 1);
1812        assert_eq!(fragment.types().len(), 3);
1813        let fields = fragment.entities()[0].fields();
1814        assert!(matches!(
1815            fields
1816                .iter()
1817                .find(|field| field.name().as_str() == "tags")
1818                .map(icydb_schema::FieldFragment::field_type),
1819            Some(FieldType::List(item))
1820                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
1821        ));
1822        assert!(matches!(
1823            fields
1824                .iter()
1825                .find(|field| field.name().as_str() == "status")
1826                .map(icydb_schema::FieldFragment::insert_policy),
1827            Some(icydb_schema::FieldInsertPolicy::Default(
1828                icydb_schema::ScalarLiteral::EnumUnit { .. }
1829            ))
1830        ));
1831        let NamedTypeFragment::Enum(status) = fragment
1832            .types()
1833            .iter()
1834            .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
1835            .expect("reachable status type should remain an enum")
1836        else {
1837            panic!("reachable status type should remain an enum")
1838        };
1839        assert!(matches!(
1840            status
1841                .variants()
1842                .iter()
1843                .find(|variant| variant.name().as_str() == "Retries")
1844                .and_then(|variant| variant.payload()),
1845            Some(FieldType::List(item))
1846                if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
1847        ));
1848
1849        let constraints = fragment.entities()[0].constraints();
1850        assert_eq!(constraints.len(), 2);
1851        let degrees_source = ConstraintSourceKey::for_targeted_field_rule(
1852            &FieldSourceKey::try_new("degrees").expect("field name"),
1853            &TypeSourceKey::try_new("Degrees").expect("type name"),
1854            &RuleSourceKey::try_new("range").expect("rule name"),
1855        );
1856        let degrees = constraints
1857            .iter()
1858            .find(|constraint| constraint.source_key() == &degrees_source)
1859            .expect("numeric rule should become one field-owned constraint");
1860        let ConstraintFragmentKind::TargetedRule(degrees) = degrees.kind() else {
1861            panic!("numeric rule should use the targeted-rule contract")
1862        };
1863        assert_eq!(degrees.root().as_str(), "degrees");
1864        assert_eq!(degrees.target_type().as_str(), "Degrees");
1865        assert!(matches!(
1866            degrees.operation(),
1867            SourceRuleOperation::NumericRangeInclusive { .. }
1868        ));
1869        let label = constraints
1870            .iter()
1871            .find(|constraint| constraint.source_key() != &degrees_source)
1872            .expect("length rule should become one field-owned constraint");
1873        let ConstraintFragmentKind::TargetedRule(label) = label.kind() else {
1874            panic!("length rule should use the targeted-rule contract")
1875        };
1876        assert_eq!(label.target_type().as_str(), "Label");
1877        assert!(matches!(
1878            label.operation(),
1879            SourceRuleOperation::LengthRangeInclusive { min: 2, max: 40 }
1880        ));
1881    }
1882}