Skip to main content

icydb_model/
fragment.rs

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