1use std::{
10 collections::{BTreeMap, BTreeSet},
11 str::FromStr,
12};
13
14use icydb_schema::{
15 Account, Blob, ConstraintFragment, Date, Decimal, DeclaredEntityVersion, Duration,
16 EntityFragment, EntitySourceKey, EnumTypeFragment, EnumVariantFragment, FieldFragment,
17 FieldInsertPolicy, FieldManagementPolicy, FieldSourceKey, FieldType, Float32, Float64,
18 IndexFragment, IndexKeyFragment, NamedTypeFragment, Principal, RecordFieldFragment,
19 RecordTypeFragment, RelationDeleteAction, RelationFragment, RelationPathStepFragment,
20 RelationSourceFragment, RuleSourceKey, ScalarLiteral, ScalarType, SchemaContractError,
21 SchemaFragment, SchemaName, SourceRuleOperation as ProposalSourceRuleOperation, Subaccount,
22 TargetedRuleFragment, Timestamp, TupleElementFragment, TypeSourceKey, U256, 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#[derive(Debug, Error)]
39pub enum FragmentLoweringError {
40 #[error("schema canister has no registered stores: {0}")]
42 CanisterHasNoStores(String),
43
44 #[error("schema canister path is not registered: {0}")]
46 CanisterNotFound(String),
47
48 #[error(transparent)]
50 Contract(#[from] SchemaContractError),
51
52 #[error("schema graph must be sealed before fragment lowering")]
54 GraphNotSealed,
55
56 #[error("schema field default cannot be lowered: {0}")]
58 InvalidDefault(String),
59
60 #[error("schema fragment reference is invalid: {0}")]
62 InvalidReference(String),
63
64 #[error("schema value cardinality is unsupported at {0}")]
66 UnsupportedCardinality(String),
67}
68
69impl Schema {
74 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 let mut targets = entity
133 .relations()
134 .iter()
135 .map(RelationEdge::target)
136 .collect::<Vec<_>>();
137 for field in entity.fields().fields() {
138 collect_relation_targets(
139 schema,
140 field.value().item(),
141 &mut BTreeSet::new(),
142 &mut targets,
143 )?;
144 }
145 for target in targets {
146 schema
147 .cast_node::<Entity>(target)
148 .map_err(|_| FragmentLoweringError::InvalidReference(target.to_string()))?;
149 if !selected_entities.contains(target) {
150 return Err(FragmentLoweringError::InvalidReference(format!(
151 "relation target '{target}' is outside the selected database"
152 )));
153 }
154 }
155 Ok(())
156}
157
158fn collect_relation_targets<'schema>(
159 schema: &'schema Schema,
160 item: &'schema Item,
161 visiting: &mut BTreeSet<String>,
162 targets: &mut Vec<&'schema str>,
163) -> Result<(), FragmentLoweringError> {
164 if let Some(target) = item.relation() {
165 targets.push(target);
166 }
167 let ItemTarget::Is(path) = item.target() else {
168 return Ok(());
169 };
170 if !visiting.insert((*path).to_string()) {
171 return Ok(());
172 }
173 let node = schema
174 .get_node(path)
175 .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?;
176 match node {
177 SchemaNode::Newtype(value) => {
178 collect_relation_targets(schema, value.item(), visiting, targets)?;
179 }
180 SchemaNode::Record(value) => {
181 for field in value.fields().fields() {
182 collect_relation_targets(schema, field.value().item(), visiting, targets)?;
183 }
184 }
185 SchemaNode::Enum(value) => {
186 for variant in value.variants() {
187 if let Some(value) = variant.value() {
188 collect_relation_targets(schema, value.item(), visiting, targets)?;
189 }
190 }
191 }
192 SchemaNode::List(value) => {
193 collect_relation_targets(schema, value.item(), visiting, targets)?;
194 }
195 SchemaNode::Set(value) => {
196 collect_relation_targets(schema, value.item(), visiting, targets)?;
197 }
198 SchemaNode::Map(value) => {
199 collect_relation_targets(schema, value.key(), visiting, targets)?;
200 collect_relation_targets(schema, value.value().item(), visiting, targets)?;
201 }
202 SchemaNode::Tuple(value) => {
203 for member in value.values() {
204 collect_relation_targets(schema, member.item(), visiting, targets)?;
205 }
206 }
207 SchemaNode::Canister(_)
208 | SchemaNode::Entity(_)
209 | SchemaNode::Normalizer(_)
210 | SchemaNode::Store(_)
211 | SchemaNode::Validator(_) => {}
212 }
213 visiting.remove(*path);
214 Ok(())
215}
216
217fn lower_entity(
218 schema: &Schema,
219 entity: &Entity,
220 pending_types: &mut Vec<String>,
221) -> Result<EntityFragment, FragmentLoweringError> {
222 let fields = entity
223 .fields()
224 .fields()
225 .iter()
226 .map(|field| lower_entity_field(schema, field, pending_types))
227 .collect::<Result<Vec<_>, _>>()?;
228 let primary_key = entity
229 .primary_key()
230 .fields()
231 .iter()
232 .map(|name| entity_field_source_key(entity, name))
233 .collect::<Result<Vec<_>, _>>()?;
234 let indexes = entity
235 .indexes()
236 .iter()
237 .map(|index| lower_index(schema, entity, index))
238 .collect::<Result<Vec<_>, _>>()?;
239 let mut relations = entity
240 .fields()
241 .fields()
242 .iter()
243 .filter(|field| field.value().item().relation().is_some())
244 .map(|field| lower_scalar_relation(schema, entity, field))
245 .collect::<Result<Vec<_>, _>>()?;
246 for field in entity.fields().fields() {
247 relations.extend(lower_nested_relations(schema, entity, field)?);
248 }
249 relations.extend(
250 entity
251 .relations()
252 .iter()
253 .map(|relation| lower_composite_relation(schema, entity, relation))
254 .collect::<Result<Vec<_>, _>>()?,
255 );
256 let mut constraints = entity
257 .constraints()
258 .iter()
259 .map(|constraint| lower_constraint(schema, constraint))
260 .collect::<Result<Vec<_>, _>>()?;
261 for field in entity.fields().fields() {
262 constraints.extend(lower_field_rules(schema, field)?);
263 }
264
265 EntityFragment::try_new(
266 SchemaName::try_new(entity.name())?,
267 DeclaredEntityVersion::try_new(entity.schema_version())?,
268 fields,
269 primary_key,
270 indexes,
271 relations,
272 constraints,
273 )
274 .map_err(Into::into)
275}
276
277struct NestedRelationCandidate<'schema> {
278 path_name: String,
279 steps: Vec<RelationPathStepFragment>,
280 target: &'schema str,
281}
282
283fn lower_nested_relations(
284 schema: &Schema,
285 entity: &Entity,
286 root: &Field,
287) -> Result<Vec<RelationFragment>, FragmentLoweringError> {
288 if root.value().item().relation().is_some() {
289 return Ok(Vec::new());
290 }
291 let mut steps = Vec::new();
292 if root.value().cardinality() == Cardinality::Opt {
293 steps.push(RelationPathStepFragment::OptionalSome);
294 } else if root.value().cardinality() == Cardinality::Many {
295 steps.push(RelationPathStepFragment::ListItems);
296 }
297 let mut candidates = Vec::new();
298 collect_nested_relation_candidates(
299 schema,
300 root.value().item(),
301 root.name().to_string(),
302 steps,
303 &mut BTreeSet::new(),
304 &mut candidates,
305 )?;
306 candidates
307 .into_iter()
308 .map(|candidate| {
309 lower_relation(schema, candidate.target, &candidate.path_name, || {
310 Ok(RelationSourceFragment::Nested {
311 root: entity_field_source_key(entity, root.name())?,
312 steps: candidate.steps,
313 })
314 })
315 })
316 .collect()
317}
318
319#[expect(
320 clippy::too_many_lines,
321 reason = "one bounded recursive walk keeps every admitted structural node and exclusion adjacent"
322)]
323fn collect_nested_relation_candidates<'schema>(
324 schema: &'schema Schema,
325 item: &'schema Item,
326 path_name: String,
327 steps: Vec<RelationPathStepFragment>,
328 visiting: &mut BTreeSet<String>,
329 candidates: &mut Vec<NestedRelationCandidate<'schema>>,
330) -> Result<(), FragmentLoweringError> {
331 if let Some(target) = item.relation() {
332 let ItemTarget::Primitive(_) = item.target() else {
333 return Err(FragmentLoweringError::InvalidReference(path_name));
334 };
335 candidates.push(NestedRelationCandidate {
336 path_name,
337 steps,
338 target,
339 });
340 return Ok(());
341 }
342 let ItemTarget::Is(path) = item.target() else {
343 return Ok(());
344 };
345 if !visiting.insert((*path).to_string()) {
346 if item_reaches_relation(schema, item, &mut BTreeSet::new())? {
351 return Err(FragmentLoweringError::UnsupportedCardinality(path_name));
352 }
353 return Ok(());
354 }
355 let node = schema
356 .get_node(path)
357 .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?;
358 let type_key = TypeSourceKey::try_new(
359 named_type_name(node)
360 .ok_or_else(|| FragmentLoweringError::InvalidReference((*path).to_string()))?,
361 )?;
362 let mut entered = steps;
363 entered.push(RelationPathStepFragment::EnterNamed {
364 r#type: type_key.clone(),
365 });
366 match node {
367 SchemaNode::Newtype(value) => collect_nested_relation_candidates(
368 schema,
369 value.item(),
370 path_name,
371 entered,
372 visiting,
373 candidates,
374 )?,
375 SchemaNode::Record(value) => {
376 for field in value.fields().fields() {
377 let mut member_steps = entered.clone();
378 member_steps.push(RelationPathStepFragment::RecordMember {
379 record: type_key.clone(),
380 field: FieldSourceKey::try_new(field.name())?,
381 });
382 match field.value().cardinality() {
383 Cardinality::One => {}
384 Cardinality::Opt => {
385 member_steps.push(RelationPathStepFragment::OptionalSome);
386 }
387 Cardinality::Many => {
388 member_steps.push(RelationPathStepFragment::ListItems);
389 }
390 }
391 collect_nested_relation_candidates(
392 schema,
393 field.value().item(),
394 format!("{path_name}.{}", field.name()),
395 member_steps,
396 visiting,
397 candidates,
398 )?;
399 }
400 }
401 SchemaNode::Enum(value) => {
402 for variant in value.variants() {
403 let Some(payload) = variant.value() else {
404 continue;
405 };
406 let mut variant_steps = entered.clone();
407 variant_steps.push(RelationPathStepFragment::EnumVariantPayload {
408 r#enum: type_key.clone(),
409 variant: TypeSourceKey::try_new(variant.name())?,
410 });
411 match payload.cardinality() {
412 Cardinality::One => {}
413 Cardinality::Opt => {
414 variant_steps.push(RelationPathStepFragment::OptionalSome);
415 }
416 Cardinality::Many => {
417 variant_steps.push(RelationPathStepFragment::ListItems);
418 }
419 }
420 collect_nested_relation_candidates(
421 schema,
422 payload.item(),
423 format!("{path_name}.{}", variant.name()),
424 variant_steps,
425 visiting,
426 candidates,
427 )?;
428 }
429 }
430 SchemaNode::List(list) => {
431 let mut item_steps = entered;
432 item_steps.push(RelationPathStepFragment::ListItems);
433 collect_nested_relation_candidates(
434 schema,
435 list.item(),
436 format!("{path_name}.items"),
437 item_steps,
438 visiting,
439 candidates,
440 )?;
441 }
442 SchemaNode::Set(set) => {
443 let mut item_steps = entered;
444 item_steps.push(RelationPathStepFragment::SetItems);
445 collect_nested_relation_candidates(
446 schema,
447 set.item(),
448 format!("{path_name}.items"),
449 item_steps,
450 visiting,
451 candidates,
452 )?;
453 }
454 SchemaNode::Map(map) => {
455 let mut value_steps = entered;
456 value_steps.push(RelationPathStepFragment::MapValues);
457 match map.value().cardinality() {
458 Cardinality::One => {}
459 Cardinality::Opt => {
460 value_steps.push(RelationPathStepFragment::OptionalSome);
461 }
462 Cardinality::Many => {
463 value_steps.push(RelationPathStepFragment::ListItems);
464 }
465 }
466 collect_nested_relation_candidates(
467 schema,
468 map.value().item(),
469 format!("{path_name}.values"),
470 value_steps,
471 visiting,
472 candidates,
473 )?;
474 }
475 SchemaNode::Tuple(_) => {
476 if item_reaches_relation(schema, item, &mut BTreeSet::new())? {
477 return Err(FragmentLoweringError::UnsupportedCardinality(path_name));
478 }
479 }
480 SchemaNode::Canister(_)
481 | SchemaNode::Entity(_)
482 | SchemaNode::Normalizer(_)
483 | SchemaNode::Store(_)
484 | SchemaNode::Validator(_) => {
485 return Err(FragmentLoweringError::InvalidReference((*path).to_string()));
486 }
487 }
488 visiting.remove(*path);
489 Ok(())
490}
491
492fn item_reaches_relation(
493 schema: &Schema,
494 item: &Item,
495 visiting: &mut BTreeSet<String>,
496) -> Result<bool, FragmentLoweringError> {
497 if item.relation().is_some() {
498 return Ok(true);
499 }
500 let mut targets = Vec::new();
501 collect_relation_targets(schema, item, visiting, &mut targets)?;
502 Ok(!targets.is_empty())
503}
504
505fn lower_entity_field(
506 schema: &Schema,
507 field: &Field,
508 pending_types: &mut Vec<String>,
509) -> Result<FieldFragment, FragmentLoweringError> {
510 let field_type = lower_value_type(schema, field.value(), pending_types)?;
511 let nullable = field.value().cardinality() == Cardinality::Opt;
512 let insert_policy = if field.generated().is_some() {
513 FieldInsertPolicy::Generated
514 } else if let Some(default) = field.default() {
515 FieldInsertPolicy::Default(lower_default(schema, field, default)?)
516 } else if nullable {
517 FieldInsertPolicy::Nullable
518 } else {
519 FieldInsertPolicy::Required
520 };
521 let management = match field.write_management() {
522 Some(FieldWriteManagement::CreatedAt) => Some(FieldManagementPolicy::CreatedAt),
523 Some(FieldWriteManagement::UpdatedAt) => Some(FieldManagementPolicy::UpdatedAt),
524 None => None,
525 };
526 Ok(FieldFragment::new(
527 SchemaName::try_new(field.name())?,
528 field_type,
529 nullable,
530 insert_policy,
531 management,
532 ))
533}
534
535fn lower_index(
536 schema: &Schema,
537 entity: &Entity,
538 index: &Index,
539) -> Result<IndexFragment, FragmentLoweringError> {
540 let key = match index.key_items() {
541 IndexKeyItemsRef::Fields(fields) => fields
542 .iter()
543 .map(|field| entity_field_source_key(entity, field).map(IndexKeyFragment::Field))
544 .collect::<Result<Vec<_>, _>>()?,
545 IndexKeyItemsRef::Items(items) => items
546 .iter()
547 .map(|item| lower_index_key(entity, item))
548 .collect::<Result<Vec<_>, _>>()?,
549 };
550 IndexFragment::try_new(
551 SchemaName::try_new(index.name())?,
552 key,
553 index.is_unique(),
554 index.source_predicate(schema)?,
555 )
556 .map_err(Into::into)
557}
558
559fn lower_index_key(
560 entity: &Entity,
561 item: &IndexKeyItem,
562) -> Result<IndexKeyFragment, FragmentLoweringError> {
563 let field = entity_field_source_key(entity, item.field())?;
564 Ok(match item {
565 IndexKeyItem::Field(_) => IndexKeyFragment::Field(field),
566 IndexKeyItem::Expression(IndexExpression::Lower(_)) => IndexKeyFragment::Lower(field),
567 IndexKeyItem::Expression(IndexExpression::Upper(_)) => IndexKeyFragment::Upper(field),
568 IndexKeyItem::Expression(IndexExpression::Trim(_)) => IndexKeyFragment::Trim(field),
569 IndexKeyItem::Expression(IndexExpression::LowerTrim(_)) => {
570 IndexKeyFragment::LowerTrim(field)
571 }
572 IndexKeyItem::Expression(IndexExpression::Date(_)) => IndexKeyFragment::Date(field),
573 IndexKeyItem::Expression(IndexExpression::Year(_)) => IndexKeyFragment::Year(field),
574 IndexKeyItem::Expression(IndexExpression::Month(_)) => IndexKeyFragment::Month(field),
575 IndexKeyItem::Expression(IndexExpression::Day(_)) => IndexKeyFragment::Day(field),
576 })
577}
578
579fn lower_scalar_relation(
580 schema: &Schema,
581 entity: &Entity,
582 field: &Field,
583) -> Result<RelationFragment, FragmentLoweringError> {
584 let target_path = field
585 .value()
586 .item()
587 .relation()
588 .ok_or_else(|| FragmentLoweringError::InvalidReference(field.name().to_string()))?;
589 lower_relation(schema, target_path, field.name(), || {
590 Ok(RelationSourceFragment::direct(vec![
591 entity_field_source_key(entity, field.name())?,
592 ]))
593 })
594}
595
596fn lower_composite_relation(
597 schema: &Schema,
598 entity: &Entity,
599 relation: &RelationEdge,
600) -> Result<RelationFragment, FragmentLoweringError> {
601 lower_relation(schema, relation.target(), relation.name(), || {
602 Ok(RelationSourceFragment::direct(
603 relation
604 .local_fields()
605 .iter()
606 .map(|field| entity_field_source_key(entity, field))
607 .collect::<Result<Vec<_>, _>>()?,
608 ))
609 })
610}
611
612fn lower_relation(
615 schema: &Schema,
616 target_path: &str,
617 name: &str,
618 source: impl FnOnce() -> Result<RelationSourceFragment, FragmentLoweringError>,
619) -> Result<RelationFragment, FragmentLoweringError> {
620 let target = schema
621 .cast_node::<Entity>(target_path)
622 .map_err(|_| FragmentLoweringError::InvalidReference(target_path.to_string()))?;
623 RelationFragment::try_new(
624 SchemaName::try_new(name)?,
625 source()?,
626 EntitySourceKey::try_new(target.name())?,
627 target
628 .primary_key()
629 .fields()
630 .iter()
631 .map(|field| entity_field_source_key(target, field))
632 .collect::<Result<Vec<_>, _>>()?,
633 RelationDeleteAction::Restrict,
634 )
635 .map_err(Into::into)
636}
637
638fn lower_constraint(
639 schema: &Schema,
640 constraint: &CheckConstraint,
641) -> Result<ConstraintFragment, FragmentLoweringError> {
642 Ok(ConstraintFragment::check(
643 SchemaName::try_new(constraint.name())?,
644 constraint.source_expression(schema)?,
645 ))
646}
647
648fn lower_field_rules(
649 schema: &Schema,
650 field: &Field,
651) -> Result<Vec<ConstraintFragment>, FragmentLoweringError> {
652 let field_source = FieldSourceKey::try_new(field.name())?;
653 reachable_source_rules(schema, field.value().item())?
654 .into_iter()
655 .map(|(target_type, rule, target)| {
656 let operation = lower_source_rule_operation(schema, target, rule)?;
657 Ok(ConstraintFragment::targeted_rule(
658 TargetedRuleFragment::new(
659 field_source.clone(),
660 target_type,
661 SchemaName::try_new(rule.name())?,
662 operation,
663 ),
664 ))
665 })
666 .collect()
667}
668
669type ReachableSourceRule<'schema> = (
670 TypeSourceKey,
671 &'schema SourceRule,
672 &'schema crate::node::SchemaNode,
673);
674
675fn reachable_source_rules<'schema>(
676 schema: &'schema Schema,
677 item: &Item,
678) -> Result<Vec<ReachableSourceRule<'schema>>, FragmentLoweringError> {
679 let mut pending = Vec::new();
680 push_item_reference(item, &mut pending);
681 let mut visited = BTreeSet::new();
682 let mut rules = BTreeMap::new();
683 while let Some(path) = pending.pop() {
684 let node = schema
685 .get_node(path.as_str())
686 .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
687 let target_type = TypeSourceKey::try_new(
688 named_type_name(node)
689 .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?,
690 )?;
691 if !visited.insert(target_type.clone()) {
692 continue;
693 }
694 for rule in schema_node_type(node)?.rules() {
695 let key = (target_type.clone(), RuleSourceKey::try_new(rule.name())?);
696 if rules.insert(key, (rule, node)).is_some() {
697 return Err(FragmentLoweringError::InvalidReference(format!(
698 "duplicate durable rule '{}' on type '{}'",
699 rule.name(),
700 target_type
701 )));
702 }
703 }
704 push_schema_node_references(node, &mut pending);
705 }
706 Ok(rules
707 .into_iter()
708 .map(|((target_type, _), (rule, node))| (target_type, rule, node))
709 .collect())
710}
711
712fn schema_node_type(node: &SchemaNode) -> Result<&crate::node::Type, FragmentLoweringError> {
713 match node {
714 SchemaNode::Newtype(node) => Ok(node.ty()),
715 SchemaNode::Record(node) => Ok(node.ty()),
716 SchemaNode::Enum(node) => Ok(node.ty()),
717 SchemaNode::List(node) => Ok(node.ty()),
718 SchemaNode::Map(node) => Ok(node.ty()),
719 SchemaNode::Set(node) => Ok(node.ty()),
720 SchemaNode::Tuple(node) => Ok(node.ty()),
721 SchemaNode::Canister(_)
722 | SchemaNode::Entity(_)
723 | SchemaNode::Normalizer(_)
724 | SchemaNode::Store(_)
725 | SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
726 "durable-rule target is not a named type".to_string(),
727 )),
728 }
729}
730
731fn push_schema_node_references(node: &SchemaNode, pending: &mut Vec<String>) {
732 match node {
733 SchemaNode::Newtype(newtype) => push_item_reference(newtype.item(), pending),
734 SchemaNode::Record(record) => {
735 for field in record.fields().fields() {
736 push_item_reference(field.value().item(), pending);
737 }
738 }
739 SchemaNode::Enum(r#enum) => {
740 for value in r#enum
741 .variants()
742 .iter()
743 .filter_map(crate::node::EnumVariant::value)
744 {
745 push_item_reference(value.item(), pending);
746 }
747 }
748 SchemaNode::List(list) => push_item_reference(list.item(), pending),
749 SchemaNode::Map(map) => {
750 push_item_reference(map.key(), pending);
751 push_item_reference(map.value().item(), pending);
752 }
753 SchemaNode::Set(set) => push_item_reference(set.item(), pending),
754 SchemaNode::Tuple(tuple) => {
755 for value in tuple.values() {
756 push_item_reference(value.item(), pending);
757 }
758 }
759 SchemaNode::Canister(_)
760 | SchemaNode::Entity(_)
761 | SchemaNode::Normalizer(_)
762 | SchemaNode::Store(_)
763 | SchemaNode::Validator(_) => {}
764 }
765}
766
767fn push_item_reference(item: &Item, pending: &mut Vec<String>) {
768 if let ItemTarget::Is(path) = item.target() {
769 pending.push((*path).to_string());
770 }
771}
772
773fn lower_source_rule_operation(
774 schema: &Schema,
775 target: &SchemaNode,
776 rule: &SourceRule,
777) -> Result<ProposalSourceRuleOperation, FragmentLoweringError> {
778 let length_bound = |value: &RuleNumber| {
779 rule_integer_u128(value)
780 .and_then(|value| u64::try_from(value).ok())
781 .ok_or_else(|| {
782 FragmentLoweringError::InvalidReference(format!(
783 "rule '{}' has an invalid length bound",
784 rule.name()
785 ))
786 })
787 };
788
789 let operation = match rule.operation() {
790 SourceRuleAuthoringOperation::NumericMinimumInclusive { value } => {
791 let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
792 else {
793 return Err(invalid_rule_target(rule));
794 };
795 let value = lower_rule_numeric_literal(primitive, item, value)
796 .ok_or_else(|| invalid_rule_target(rule))?;
797 ProposalSourceRuleOperation::NumericMinimumInclusive { value }
798 }
799 SourceRuleAuthoringOperation::NumericMaximumInclusive { value } => {
800 let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
801 else {
802 return Err(invalid_rule_target(rule));
803 };
804 let value = lower_rule_numeric_literal(primitive, item, value)
805 .ok_or_else(|| invalid_rule_target(rule))?;
806 ProposalSourceRuleOperation::NumericMaximumInclusive { value }
807 }
808 SourceRuleAuthoringOperation::NumericRangeInclusive { min, max } => {
809 let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
810 else {
811 return Err(invalid_rule_target(rule));
812 };
813 let literal = |value: &RuleNumber| {
814 lower_rule_numeric_literal(primitive, item, value)
815 .ok_or_else(|| invalid_rule_target(rule))
816 };
817 ProposalSourceRuleOperation::NumericRangeInclusive {
818 min: literal(min)?,
819 max: literal(max)?,
820 }
821 }
822 SourceRuleAuthoringOperation::LengthRangeInclusive { min, max } => {
823 let shape = resolve_rule_value_shape(schema, target)?;
824 if !matches!(
825 shape,
826 RuleValueShape::Collection
827 | RuleValueShape::Scalar(Primitive::Blob | Primitive::Text, _)
828 ) {
829 return Err(invalid_rule_target(rule));
830 }
831 ProposalSourceRuleOperation::LengthRangeInclusive {
832 min: length_bound(min)?,
833 max: length_bound(max)?,
834 }
835 }
836 SourceRuleAuthoringOperation::MultipleOf { divisor } => {
837 let RuleValueShape::Scalar(primitive, item) = resolve_rule_value_shape(schema, target)?
838 else {
839 return Err(invalid_rule_target(rule));
840 };
841 let divisor = lower_rule_numeric_literal(primitive, item, divisor)
842 .ok_or_else(|| invalid_rule_target(rule))?;
843 ProposalSourceRuleOperation::MultipleOf { divisor }
844 }
845 };
846 Ok(operation)
847}
848
849fn lower_rule_numeric_literal(
850 primitive: Primitive,
851 item: &Item,
852 value: &RuleNumber,
853) -> Option<ScalarLiteral> {
854 match primitive {
855 Primitive::Decimal => rule_decimal(value)
856 .and_then(|value| exact_decimal_at_scale(value, item.scale().unwrap_or(0)))
857 .map(ScalarLiteral::Decimal),
858 Primitive::Float32 => match value {
859 RuleNumber::Float32(value) => Float32::try_new(*value).map(ScalarLiteral::Float32),
860 RuleNumber::Integer(_) | RuleNumber::Decimal(_) | RuleNumber::Float64(_) => None,
861 },
862 Primitive::Float64 => match value {
863 RuleNumber::Decimal(value) => value
864 .parse::<f64>()
865 .ok()
866 .and_then(Float64::try_new)
867 .map(ScalarLiteral::Float64),
868 RuleNumber::Float64(value) => Float64::try_new(*value).map(ScalarLiteral::Float64),
869 RuleNumber::Integer(_) | RuleNumber::Float32(_) => None,
870 },
871 Primitive::Int8
872 | Primitive::Int16
873 | Primitive::Int32
874 | Primitive::Int64
875 | Primitive::Int128 => rule_integer_i128(value).map(ScalarLiteral::Int),
876 Primitive::Nat8
877 | Primitive::Nat16
878 | Primitive::Nat32
879 | Primitive::Nat64
880 | Primitive::Nat128 => rule_integer_u128(value).map(ScalarLiteral::Nat),
881 Primitive::U256 => rule_integer_text(value)
882 .and_then(|value| U256::from_str(value).ok())
883 .map(ScalarLiteral::U256),
884 Primitive::Account
885 | Primitive::Blob
886 | Primitive::Bool
887 | Primitive::Date
888 | Primitive::Duration
889 | Primitive::Principal
890 | Primitive::Subaccount
891 | Primitive::Text
892 | Primitive::Timestamp
893 | Primitive::Ulid
894 | Primitive::Unit => None,
895 }
896}
897
898const fn rule_integer_text(value: &RuleNumber) -> Option<&str> {
899 let RuleNumber::Integer(value) = value else {
900 return None;
901 };
902 Some(value)
903}
904
905fn rule_integer_i128(value: &RuleNumber) -> Option<i128> {
906 rule_integer_text(value)?.parse().ok()
907}
908
909fn rule_integer_u128(value: &RuleNumber) -> Option<u128> {
910 rule_integer_text(value)?.parse().ok()
911}
912
913fn rule_decimal(value: &RuleNumber) -> Option<Decimal> {
914 match value {
915 RuleNumber::Integer(value) | RuleNumber::Decimal(value) => Decimal::from_str(value).ok(),
916 RuleNumber::Float32(_) | RuleNumber::Float64(_) => None,
917 }
918}
919
920fn exact_decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
921 let value = value.normalize();
922 value
923 .scale_to_integer(scale)
924 .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale))
925}
926
927#[derive(Clone, Copy)]
928enum RuleValueShape<'schema> {
929 Collection,
930 Scalar(Primitive, &'schema Item),
931}
932
933fn resolve_rule_value_shape<'schema>(
934 schema: &'schema Schema,
935 mut target: &'schema SchemaNode,
936) -> Result<RuleValueShape<'schema>, FragmentLoweringError> {
937 let mut visited = BTreeSet::new();
938 loop {
939 let source = named_type_name(target)
940 .ok_or_else(|| FragmentLoweringError::InvalidReference("non-type rule".to_string()))?;
941 if !visited.insert(source) {
942 return Err(FragmentLoweringError::InvalidReference(format!(
943 "durable-rule target cycle at '{source}'"
944 )));
945 }
946 match target {
947 SchemaNode::List(_) | SchemaNode::Map(_) | SchemaNode::Set(_) => {
948 return Ok(RuleValueShape::Collection);
949 }
950 SchemaNode::Newtype(newtype) => match newtype.item().target() {
951 ItemTarget::Primitive(primitive) => {
952 return Ok(RuleValueShape::Scalar(*primitive, newtype.item()));
953 }
954 ItemTarget::Is(path) => {
955 target = schema
956 .get_node(path)
957 .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
958 }
959 },
960 SchemaNode::Record(_) | SchemaNode::Enum(_) | SchemaNode::Tuple(_) => {
961 return Err(FragmentLoweringError::InvalidReference(format!(
962 "durable-rule target '{source}' has no supported scalar or collection value"
963 )));
964 }
965 SchemaNode::Canister(_)
966 | SchemaNode::Entity(_)
967 | SchemaNode::Normalizer(_)
968 | SchemaNode::Store(_)
969 | SchemaNode::Validator(_) => {
970 return Err(FragmentLoweringError::InvalidReference(
971 "non-type durable-rule target".to_string(),
972 ));
973 }
974 }
975 }
976}
977
978fn invalid_rule_target(rule: &SourceRule) -> FragmentLoweringError {
979 FragmentLoweringError::InvalidReference(format!(
980 "durable rule '{}' does not match its nominal target",
981 rule.name()
982 ))
983}
984
985fn entity_field_source_key(
986 entity: &Entity,
987 field_name: &str,
988) -> Result<FieldSourceKey, FragmentLoweringError> {
989 let field = entity
990 .fields()
991 .get(field_name)
992 .ok_or_else(|| FragmentLoweringError::InvalidReference(field_name.to_string()))?;
993 FieldSourceKey::try_new(field.name()).map_err(Into::into)
994}
995
996fn lower_reachable_types(
1001 schema: &Schema,
1002 mut pending: Vec<String>,
1003) -> Result<Vec<NamedTypeFragment>, FragmentLoweringError> {
1004 let mut lowered = BTreeMap::new();
1005 while let Some(path) = pending.pop() {
1006 let node = schema
1007 .get_node(path.as_str())
1008 .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
1009 let source_key = named_type_name(node)
1010 .ok_or_else(|| FragmentLoweringError::InvalidReference(path.clone()))?;
1011 if lowered.contains_key(source_key) {
1012 continue;
1013 }
1014 let fragment = lower_named_type(schema, node, &mut pending)?;
1015 lowered.insert(source_key.to_string(), fragment);
1016 }
1017 Ok(lowered.into_values().collect())
1018}
1019
1020const fn named_type_name(node: &crate::node::SchemaNode) -> Option<&str> {
1021 match node {
1022 crate::node::SchemaNode::Enum(node) => Some(node.name()),
1023 crate::node::SchemaNode::List(node) => Some(node.name()),
1024 crate::node::SchemaNode::Map(node) => Some(node.name()),
1025 crate::node::SchemaNode::Newtype(node) => Some(node.name()),
1026 crate::node::SchemaNode::Record(node) => Some(node.name()),
1027 crate::node::SchemaNode::Set(node) => Some(node.name()),
1028 crate::node::SchemaNode::Tuple(node) => Some(node.name()),
1029 crate::node::SchemaNode::Canister(_)
1030 | crate::node::SchemaNode::Entity(_)
1031 | crate::node::SchemaNode::Normalizer(_)
1032 | crate::node::SchemaNode::Store(_)
1033 | crate::node::SchemaNode::Validator(_) => None,
1034 }
1035}
1036
1037fn lower_named_type(
1038 schema: &Schema,
1039 node: &crate::node::SchemaNode,
1040 pending: &mut Vec<String>,
1041) -> Result<NamedTypeFragment, FragmentLoweringError> {
1042 match node {
1043 crate::node::SchemaNode::Record(record) => lower_record(schema, record, pending),
1044 crate::node::SchemaNode::Enum(r#enum) => lower_enum(schema, r#enum, pending),
1045 crate::node::SchemaNode::Newtype(newtype) => Ok(NamedTypeFragment::newtype(
1046 SchemaName::try_new(newtype.name())?,
1047 lower_item_type(schema, newtype.item(), pending)?,
1048 )),
1049 crate::node::SchemaNode::List(list) => lower_list(schema, list, pending),
1050 crate::node::SchemaNode::Set(set) => lower_set(schema, set, pending),
1051 crate::node::SchemaNode::Map(map) => lower_map(schema, map, pending),
1052 crate::node::SchemaNode::Tuple(tuple) => lower_tuple(schema, tuple, pending),
1053 crate::node::SchemaNode::Canister(_)
1054 | crate::node::SchemaNode::Entity(_)
1055 | crate::node::SchemaNode::Normalizer(_)
1056 | crate::node::SchemaNode::Store(_)
1057 | crate::node::SchemaNode::Validator(_) => Err(FragmentLoweringError::InvalidReference(
1058 "non-type graph node".to_string(),
1059 )),
1060 }
1061}
1062
1063fn lower_record(
1064 schema: &Schema,
1065 record: &Record,
1066 pending: &mut Vec<String>,
1067) -> Result<NamedTypeFragment, FragmentLoweringError> {
1068 let fields = record
1069 .fields()
1070 .fields()
1071 .iter()
1072 .map(|field| {
1073 Ok(RecordFieldFragment::new(
1074 SchemaName::try_new(field.name())?,
1075 lower_value_type(schema, field.value(), pending)?,
1076 field.value().cardinality() == Cardinality::Opt,
1077 ))
1078 })
1079 .collect::<Result<Vec<_>, FragmentLoweringError>>()?;
1080 Ok(NamedTypeFragment::Record(RecordTypeFragment::try_new(
1081 SchemaName::try_new(record.name())?,
1082 fields,
1083 )?))
1084}
1085
1086fn lower_enum(
1087 schema: &Schema,
1088 r#enum: &Enum,
1089 pending: &mut Vec<String>,
1090) -> Result<NamedTypeFragment, FragmentLoweringError> {
1091 let variants = r#enum
1092 .variants()
1093 .iter()
1094 .map(|variant| {
1095 let name = SchemaName::try_new(variant.name())?;
1096 match variant.value() {
1097 Some(value) if value.cardinality() == Cardinality::Opt => {
1098 Err(FragmentLoweringError::UnsupportedCardinality(format!(
1099 "{}::{}",
1100 r#enum.def().path(),
1101 variant.name()
1102 )))
1103 }
1104 Some(value) => Ok(EnumVariantFragment::with_payload(
1105 name,
1106 lower_value_type(schema, value, pending)?,
1107 )),
1108 None => Ok(EnumVariantFragment::new(name)),
1109 }
1110 })
1111 .collect::<Result<Vec<_>, _>>()?;
1112 Ok(NamedTypeFragment::Enum(EnumTypeFragment::try_new(
1113 SchemaName::try_new(r#enum.name())?,
1114 variants,
1115 )?))
1116}
1117
1118fn lower_list(
1119 schema: &Schema,
1120 list: &List,
1121 pending: &mut Vec<String>,
1122) -> Result<NamedTypeFragment, FragmentLoweringError> {
1123 Ok(NamedTypeFragment::list(
1124 SchemaName::try_new(list.name())?,
1125 lower_item_type(schema, list.item(), pending)?,
1126 ))
1127}
1128
1129fn lower_set(
1130 schema: &Schema,
1131 set: &Set,
1132 pending: &mut Vec<String>,
1133) -> Result<NamedTypeFragment, FragmentLoweringError> {
1134 Ok(NamedTypeFragment::set(
1135 SchemaName::try_new(set.name())?,
1136 lower_item_type(schema, set.item(), pending)?,
1137 ))
1138}
1139
1140fn lower_map(
1141 schema: &Schema,
1142 map: &Map,
1143 pending: &mut Vec<String>,
1144) -> Result<NamedTypeFragment, FragmentLoweringError> {
1145 if map.value().cardinality() == Cardinality::Opt {
1146 return Err(FragmentLoweringError::UnsupportedCardinality(
1147 map.def().path(),
1148 ));
1149 }
1150 Ok(NamedTypeFragment::map(
1151 SchemaName::try_new(map.name())?,
1152 lower_item_type(schema, map.key(), pending)?,
1153 lower_value_type(schema, map.value(), pending)?,
1154 ))
1155}
1156
1157fn lower_tuple(
1158 schema: &Schema,
1159 tuple: &Tuple,
1160 pending: &mut Vec<String>,
1161) -> Result<NamedTypeFragment, FragmentLoweringError> {
1162 let members = tuple
1163 .values()
1164 .iter()
1165 .map(|value| {
1166 Ok::<_, FragmentLoweringError>(TupleElementFragment::new(
1167 lower_value_type(schema, value, pending)?,
1168 value.cardinality() == Cardinality::Opt,
1169 ))
1170 })
1171 .collect::<Result<Vec<_>, _>>()?;
1172 Ok(NamedTypeFragment::tuple(
1173 SchemaName::try_new(tuple.name())?,
1174 members,
1175 ))
1176}
1177
1178fn lower_value_type(
1183 schema: &Schema,
1184 value: &Value,
1185 pending: &mut Vec<String>,
1186) -> Result<FieldType, FragmentLoweringError> {
1187 let item = lower_item_type(schema, value.item(), pending)?;
1188 Ok(if value.cardinality() == Cardinality::Many {
1189 FieldType::List(Box::new(item))
1190 } else {
1191 item
1192 })
1193}
1194
1195fn lower_item_type(
1196 schema: &Schema,
1197 item: &Item,
1198 pending: &mut Vec<String>,
1199) -> Result<FieldType, FragmentLoweringError> {
1200 match item.target() {
1201 ItemTarget::Is(path) => {
1202 pending.push((*path).to_string());
1203 Ok(FieldType::Named(TypeSourceKey::try_new(
1204 type_source_key_for_path(schema, path)?,
1205 )?))
1206 }
1207 ItemTarget::Primitive(primitive) => {
1208 Ok(FieldType::Scalar(lower_scalar_type(*primitive, item)))
1209 }
1210 }
1211}
1212
1213fn type_source_key_for_path<'schema>(
1214 schema: &'schema Schema,
1215 path: &str,
1216) -> Result<&'schema str, FragmentLoweringError> {
1217 let source = schema
1218 .get_node(path)
1219 .and_then(named_type_name)
1220 .ok_or_else(|| FragmentLoweringError::InvalidReference(path.to_string()))?;
1221 Ok(source)
1222}
1223
1224fn lower_scalar_type(primitive: Primitive, item: &Item) -> ScalarType {
1225 match primitive {
1226 Primitive::Account => ScalarType::Account,
1227 Primitive::Blob => ScalarType::Blob {
1228 max_len: item.max_len(),
1229 },
1230 Primitive::Bool => ScalarType::Bool,
1231 Primitive::Date => ScalarType::Date,
1232 Primitive::Decimal => ScalarType::Decimal {
1233 scale: item.scale().unwrap_or(0),
1234 },
1235 Primitive::Duration => ScalarType::Duration,
1236 Primitive::Float32 => ScalarType::Float32,
1237 Primitive::Float64 => ScalarType::Float64,
1238 Primitive::Int8 => ScalarType::Int8,
1239 Primitive::Int16 => ScalarType::Int16,
1240 Primitive::Int32 => ScalarType::Int32,
1241 Primitive::Int64 => ScalarType::Int64,
1242 Primitive::Int128 => ScalarType::Int128,
1243 Primitive::Nat8 => ScalarType::Nat8,
1244 Primitive::Nat16 => ScalarType::Nat16,
1245 Primitive::Nat32 => ScalarType::Nat32,
1246 Primitive::Nat64 => ScalarType::Nat64,
1247 Primitive::Nat128 => ScalarType::Nat128,
1248 Primitive::U256 => ScalarType::U256,
1249 Primitive::Principal => ScalarType::Principal,
1250 Primitive::Subaccount => ScalarType::Subaccount,
1251 Primitive::Text => ScalarType::Text {
1252 max_len: item.max_len(),
1253 },
1254 Primitive::Timestamp => ScalarType::Timestamp,
1255 Primitive::Ulid => ScalarType::Ulid,
1256 Primitive::Unit => ScalarType::Unit,
1257 }
1258}
1259
1260fn lower_default(
1265 schema: &Schema,
1266 field: &Field,
1267 default: &Arg,
1268) -> Result<ScalarLiteral, FragmentLoweringError> {
1269 if let ItemTarget::Is(path) = field.value().item().target() {
1270 let Arg::ConstPath(default_path) = default else {
1271 return Err(FragmentLoweringError::InvalidDefault(
1272 field.name().to_string(),
1273 ));
1274 };
1275 let variant = default_path.rsplit("::").next().unwrap_or(default_path);
1276 return schema
1277 .enum_unit_literal(path, variant)
1278 .map_err(FragmentLoweringError::from);
1279 }
1280 let ItemTarget::Primitive(primitive) = field.value().item().target() else {
1281 return Err(FragmentLoweringError::InvalidDefault(
1282 field.name().to_string(),
1283 ));
1284 };
1285 lower_scalar_default(*primitive, field.value().item(), default)
1286 .ok_or_else(|| FragmentLoweringError::InvalidDefault(field.name().to_string()))
1287}
1288
1289fn lower_scalar_default(primitive: Primitive, item: &Item, default: &Arg) -> Option<ScalarLiteral> {
1290 if default_constructor_is_zero(default) {
1291 return zero_scalar_literal(primitive, item);
1292 }
1293 match (primitive, default) {
1294 (Primitive::Account, Arg::String(value)) => {
1295 Account::from_str(value).ok().map(ScalarLiteral::Account)
1296 }
1297 (Primitive::Blob, Arg::String(value)) => lower_blob_default(value),
1298 (Primitive::Bool, Arg::Bool(value)) => Some(ScalarLiteral::Bool(*value)),
1299 (Primitive::Date, Arg::String(value)) => Date::parse(value).map(ScalarLiteral::Date),
1300 (Primitive::Date, Arg::Number(value)) => arg_i128(value)
1301 .and_then(|value| i32::try_from(value).ok())
1302 .and_then(Date::try_from_days_since_epoch)
1303 .map(ScalarLiteral::Date),
1304 (Primitive::Decimal, Arg::String(value)) => Decimal::from_str(value)
1305 .ok()
1306 .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1307 .map(ScalarLiteral::Decimal),
1308 (Primitive::Decimal, Arg::Number(value)) => arg_decimal(value)
1309 .and_then(|value| decimal_at_scale(value, item.scale().unwrap_or(0)))
1310 .map(ScalarLiteral::Decimal),
1311 (Primitive::Duration, Arg::String(value)) => Duration::parse_flexible(value)
1312 .ok()
1313 .map(ScalarLiteral::Duration),
1314 (Primitive::Duration, Arg::Number(value)) => arg_u128(value)
1315 .and_then(|value| u64::try_from(value).ok())
1316 .map(Duration::from_millis)
1317 .map(ScalarLiteral::Duration),
1318 (Primitive::Float32, Arg::Number(ArgNumber::Float32(value))) => {
1319 Float32::try_new(*value).map(ScalarLiteral::Float32)
1320 }
1321 (Primitive::Float64, Arg::Number(ArgNumber::Float64(value))) => {
1322 Float64::try_new(*value).map(ScalarLiteral::Float64)
1323 }
1324 (
1325 Primitive::Int8
1326 | Primitive::Int16
1327 | Primitive::Int32
1328 | Primitive::Int64
1329 | Primitive::Int128,
1330 Arg::Number(value),
1331 ) => arg_i128(value).map(ScalarLiteral::Int),
1332 (
1333 Primitive::Nat8
1334 | Primitive::Nat16
1335 | Primitive::Nat32
1336 | Primitive::Nat64
1337 | Primitive::Nat128,
1338 Arg::Number(value),
1339 ) => arg_u128(value).map(ScalarLiteral::Nat),
1340 (Primitive::U256, Arg::Number(value)) => {
1341 arg_u128(value).map(U256::from).map(ScalarLiteral::U256)
1342 }
1343 (Primitive::U256, Arg::String(value)) => {
1344 U256::from_str(value).ok().map(ScalarLiteral::U256)
1345 }
1346 (Primitive::Principal, Arg::String(value)) => Principal::from_str(value)
1347 .ok()
1348 .map(ScalarLiteral::Principal),
1349 (Primitive::Subaccount, Arg::String(value)) => Subaccount::from_hex(value)
1350 .ok()
1351 .map(ScalarLiteral::Subaccount),
1352 (Primitive::Text, Arg::String(value)) => Some(ScalarLiteral::Text((*value).to_string())),
1353 (Primitive::Timestamp, Arg::String(value)) => Timestamp::parse_flexible(value)
1354 .ok()
1355 .map(ScalarLiteral::Timestamp),
1356 (Primitive::Timestamp, Arg::Number(value)) => arg_i128(value)
1357 .and_then(|value| i64::try_from(value).ok())
1358 .map(Timestamp::from_millis)
1359 .map(ScalarLiteral::Timestamp),
1360 (Primitive::Ulid, Arg::String(value)) => {
1361 Ulid::from_str(value).ok().map(ScalarLiteral::Ulid)
1362 }
1363 (Primitive::Unit, Arg::ConstPath(path)) if path.ends_with("Unit") => {
1364 Some(ScalarLiteral::Unit(Unit))
1365 }
1366 _ => None,
1367 }
1368}
1369
1370fn default_constructor_is_zero(default: &Arg) -> bool {
1371 let Arg::FuncPath(path) = default else {
1372 return false;
1373 };
1374 path.ends_with("::default")
1375 || path.ends_with("::new")
1376 || path.ends_with("::EPOCH")
1377 || path.ends_with("::nil")
1378}
1379
1380fn zero_scalar_literal(primitive: Primitive, item: &Item) -> Option<ScalarLiteral> {
1381 match primitive {
1382 Primitive::Blob => Some(ScalarLiteral::Blob(Blob::default())),
1383 Primitive::Bool => Some(ScalarLiteral::Bool(false)),
1384 Primitive::Date => Some(ScalarLiteral::Date(Date::EPOCH)),
1385 Primitive::Decimal => Decimal::try_from_i128_with_scale(0, item.scale().unwrap_or(0))
1386 .map(ScalarLiteral::Decimal),
1387 Primitive::Duration => Some(ScalarLiteral::Duration(Duration::ZERO)),
1388 Primitive::Float32 => Float32::try_new(0.0).map(ScalarLiteral::Float32),
1389 Primitive::Float64 => Float64::try_new(0.0).map(ScalarLiteral::Float64),
1390 Primitive::Int8
1391 | Primitive::Int16
1392 | Primitive::Int32
1393 | Primitive::Int64
1394 | Primitive::Int128 => Some(ScalarLiteral::Int(0)),
1395 Primitive::Nat8
1396 | Primitive::Nat16
1397 | Primitive::Nat32
1398 | Primitive::Nat64
1399 | Primitive::Nat128 => Some(ScalarLiteral::Nat(0)),
1400 Primitive::U256 => Some(ScalarLiteral::U256(U256::ZERO)),
1401 Primitive::Text => Some(ScalarLiteral::Text(String::new())),
1402 Primitive::Timestamp => Some(ScalarLiteral::Timestamp(Timestamp::EPOCH)),
1403 Primitive::Ulid => Some(ScalarLiteral::Ulid(Ulid::nil())),
1404 Primitive::Unit => Some(ScalarLiteral::Unit(Unit)),
1405 Primitive::Account | Primitive::Principal | Primitive::Subaccount => None,
1406 }
1407}
1408
1409fn lower_blob_default(value: &str) -> Option<ScalarLiteral> {
1410 Blob::from_hex_literal(value).ok().map(ScalarLiteral::Blob)
1411}
1412
1413fn decimal_at_scale(value: Decimal, scale: u32) -> Option<Decimal> {
1418 match value.scale().cmp(&scale) {
1419 std::cmp::Ordering::Equal => Some(value),
1420 std::cmp::Ordering::Less => value
1421 .scale_to_integer(scale)
1422 .and_then(|mantissa| Decimal::try_from_i128_with_scale(mantissa, scale)),
1423 std::cmp::Ordering::Greater => Some(value.round_dp(scale)),
1424 }
1425}
1426
1427fn arg_i128(value: &ArgNumber) -> Option<i128> {
1428 match value {
1429 ArgNumber::Int8(value) => Some(i128::from(*value)),
1430 ArgNumber::Int16(value) => Some(i128::from(*value)),
1431 ArgNumber::Int32(value) => Some(i128::from(*value)),
1432 ArgNumber::Int64(value) => Some(i128::from(*value)),
1433 ArgNumber::Int128(value) => Some(*value),
1434 ArgNumber::Nat8(value) => Some(i128::from(*value)),
1435 ArgNumber::Nat16(value) => Some(i128::from(*value)),
1436 ArgNumber::Nat32(value) => Some(i128::from(*value)),
1437 ArgNumber::Nat64(value) => Some(i128::from(*value)),
1438 ArgNumber::Nat128(value) => i128::try_from(*value).ok(),
1439 ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1440 }
1441}
1442
1443fn arg_u128(value: &ArgNumber) -> Option<u128> {
1444 match value {
1445 ArgNumber::Int8(value) => u128::try_from(*value).ok(),
1446 ArgNumber::Int16(value) => u128::try_from(*value).ok(),
1447 ArgNumber::Int32(value) => u128::try_from(*value).ok(),
1448 ArgNumber::Int64(value) => u128::try_from(*value).ok(),
1449 ArgNumber::Int128(value) => u128::try_from(*value).ok(),
1450 ArgNumber::Nat8(value) => Some(u128::from(*value)),
1451 ArgNumber::Nat16(value) => Some(u128::from(*value)),
1452 ArgNumber::Nat32(value) => Some(u128::from(*value)),
1453 ArgNumber::Nat64(value) => Some(u128::from(*value)),
1454 ArgNumber::Nat128(value) => Some(*value),
1455 ArgNumber::Float32(_) | ArgNumber::Float64(_) => None,
1456 }
1457}
1458
1459fn arg_decimal(value: &ArgNumber) -> Option<Decimal> {
1460 match value {
1461 ArgNumber::Float32(value) => Decimal::from_f32_lossy(*value),
1462 ArgNumber::Float64(value) => Decimal::from_f64_lossy(*value),
1463 _ => arg_i128(value).and_then(Decimal::from_i128),
1464 }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469 use icydb_schema::{
1470 ConstraintFragmentKind, ConstraintSourceKey, Decimal, FieldSourceKey, FieldType,
1471 MAX_PROPOSAL_LITERAL_BYTES, NamedTypeFragment, RuleSourceKey, ScalarLiteral, ScalarType,
1472 SourceRuleOperation, TypeSourceKey,
1473 };
1474
1475 use super::{
1476 FragmentLoweringError, Schema, lower_blob_default, lower_field_rules,
1477 lower_nested_relations, lower_relation, lower_scalar_default,
1478 };
1479 use crate::{
1480 node::{
1481 Arg, Args, Canister, Def, Entity, Enum, EnumVariant, Field, FieldList, Item,
1482 ItemTarget, Newtype, Normalizer, PrimaryKey, PrimaryKeySource, Record, RuleNumber,
1483 SchemaNode, SourceRule, SourceRuleAuthoringOperation, Store, StoreHeapConfig, Type,
1484 TypeNormalizer, TypeValidator, Validator, Value,
1485 },
1486 types::{Cardinality, Primitive},
1487 };
1488
1489 #[test]
1490 fn relation_finish_preserves_source_and_ordered_target_keys() {
1491 use icydb_schema::{
1492 RelationDeleteAction, RelationPathStepFragment, RelationSourceFragment,
1493 };
1494
1495 for keys in [&["id"][..], &["tags", "id"][..]] {
1496 let mut schema = Schema::new();
1497 schema.insert_node(SchemaNode::Entity(Entity::new(
1498 Def::new("test", "Target"),
1499 "test::Store",
1500 1,
1501 PrimaryKey::new(keys, PrimaryKeySource::External),
1502 &[],
1503 &[],
1504 &[],
1505 FieldList::new(&ENTITY_FIELDS),
1506 EMPTY_TYPE.clone(),
1507 )));
1508 let direct = RelationSourceFragment::direct(
1509 keys.iter()
1510 .map(|key| FieldSourceKey::try_new(*key).unwrap())
1511 .collect(),
1512 );
1513 let mut sources = vec![direct];
1514 if keys.len() == 1 {
1515 sources.push(RelationSourceFragment::Nested {
1516 root: FieldSourceKey::try_new("root").unwrap(),
1517 steps: vec![RelationPathStepFragment::OptionalSome],
1518 });
1519 }
1520 for source in sources {
1521 let relation =
1522 lower_relation(&schema, "test::Target", "owner", || Ok(source.clone()))
1523 .unwrap();
1524 assert_eq!(relation.source(), &source);
1525 assert_eq!(relation.target_entity().as_str(), "Target");
1526 assert_eq!(
1527 relation
1528 .target_fields()
1529 .iter()
1530 .map(FieldSourceKey::as_str)
1531 .collect::<Vec<_>>(),
1532 keys
1533 );
1534 assert_eq!(relation.on_delete(), RelationDeleteAction::Restrict);
1535 }
1536 assert!(matches!(
1537 lower_relation(&schema, "test::Target", "owner", || Ok(
1538 RelationSourceFragment::direct(vec![])
1539 )),
1540 Err(FragmentLoweringError::Contract(_)),
1541 ));
1542 }
1543 }
1544
1545 #[test]
1546 fn relation_target_failure_precedes_source_lowering() {
1547 let result = lower_relation(&Schema::new(), "test::Missing", "owner", || {
1548 panic!("invalid target must reject before source lowering")
1549 });
1550 assert!(
1551 matches!(result, Err(FragmentLoweringError::InvalidReference(path)) if path == "test::Missing")
1552 );
1553 }
1554
1555 #[test]
1556 fn blob_defaults_and_migration_fills_share_exact_bytes_and_refusals() {
1557 for text in ["", "00", "deadbeef", "DeAdBeEf", "ff00"] {
1558 let default = lower_blob_default(text).unwrap();
1559 let fill = crate::node::migration_literal_from_text("blob", text).unwrap();
1560 assert_eq!(default, fill);
1561 }
1562 assert_eq!(
1563 lower_blob_default("deadbeef"),
1564 Some(ScalarLiteral::Blob(icydb_schema::Blob::from(vec![
1565 0xde, 0xad, 0xbe, 0xef
1566 ])))
1567 );
1568 for text in ["a", "zz", "+1", "0x00", " 00", "é", "aé0"] {
1569 assert!(lower_blob_default(text).is_none());
1570 assert_eq!(
1571 crate::node::migration_literal_from_text("blob", text),
1572 Err(icydb_schema::SchemaContractError::InvalidLiteral)
1573 );
1574 }
1575 let maximum = "ff".repeat(MAX_PROPOSAL_LITERAL_BYTES);
1576 assert_eq!(
1577 lower_blob_default(&maximum),
1578 Some(crate::node::migration_literal_from_text("blob", &maximum).unwrap())
1579 );
1580 let oversized = format!("{maximum}00");
1581 assert!(lower_blob_default(&oversized).is_none());
1582 assert_eq!(
1583 crate::node::migration_literal_from_text("blob", &oversized),
1584 Err(icydb_schema::SchemaContractError::InvalidLiteral)
1585 );
1586 let subaccount = "aB".repeat(32);
1587 assert!(crate::node::migration_literal_from_text("subaccount", &subaccount).is_ok());
1588 for text in [
1589 format!("0x{subaccount}"),
1590 "00".repeat(31),
1591 "00".repeat(33),
1592 "gg".repeat(32),
1593 "+1".repeat(32),
1594 ] {
1595 assert_eq!(
1596 crate::node::migration_literal_from_text("subaccount", &text),
1597 Err(icydb_schema::SchemaContractError::InvalidLiteral)
1598 );
1599 }
1600 }
1601
1602 #[test]
1603 fn blob_default_lowering_enforces_the_proposal_literal_bound() {
1604 let maximum = "ab".repeat(MAX_PROPOSAL_LITERAL_BYTES);
1605 let oversized = "ab".repeat(MAX_PROPOSAL_LITERAL_BYTES + 1);
1606
1607 assert_eq!(
1608 lower_blob_default(&maximum).and_then(|literal| match literal {
1609 ScalarLiteral::Blob(value) => Some(value.len()),
1610 _ => None,
1611 }),
1612 Some(MAX_PROPOSAL_LITERAL_BYTES),
1613 );
1614 assert_eq!(lower_blob_default(&oversized), None);
1615 }
1616
1617 #[test]
1618 fn duration_default_lowering_rejects_suffixed_overflow() {
1619 let item = Item::new(
1620 ItemTarget::Primitive(Primitive::Duration),
1621 None,
1622 None,
1623 None,
1624 &[],
1625 &[],
1626 false,
1627 );
1628
1629 assert_eq!(
1630 lower_scalar_default(
1631 Primitive::Duration,
1632 &item,
1633 &Arg::String("18446744073709551615ms"),
1634 ),
1635 Some(ScalarLiteral::Duration(icydb_schema::Duration::MAX)),
1636 );
1637 assert_eq!(
1638 lower_scalar_default(
1639 Primitive::Duration,
1640 &item,
1641 &Arg::String("18446744073709552s"),
1642 ),
1643 None,
1644 );
1645 }
1646
1647 static EMPTY_TYPE: Type = Type::new(&[], &[], &[]);
1648 static APPLICATION_FIELDS: [Field; 1] = [Field::new(
1649 "id",
1650 Value::new(
1651 Cardinality::One,
1652 Item::new(
1653 ItemTarget::Primitive(Primitive::Nat64),
1654 None,
1655 None,
1656 None,
1657 &[],
1658 &[],
1659 false,
1660 ),
1661 ),
1662 None,
1663 None,
1664 None,
1665 )];
1666 static APPLICATION_NORMALIZERS_A: [TypeNormalizer; 1] =
1667 [TypeNormalizer::new("test::NormalizeA", Args(&[]))];
1668 static APPLICATION_NORMALIZERS_B: [TypeNormalizer; 1] =
1669 [TypeNormalizer::new("test::NormalizeB", Args(&[]))];
1670 static APPLICATION_VALIDATORS_A: [TypeValidator; 1] =
1671 [TypeValidator::new("test::ValidateA", Args(&[]))];
1672 static APPLICATION_VALIDATORS_B: [TypeValidator; 1] =
1673 [TypeValidator::new("test::ValidateB", Args(&[]))];
1674 static NUMERIC_RULES: [SourceRule; 1] = [SourceRule::new(
1675 "range",
1676 SourceRuleAuthoringOperation::NumericRangeInclusive {
1677 min: RuleNumber::Integer("0"),
1678 max: RuleNumber::Integer("360"),
1679 },
1680 )];
1681 static NUMERIC_RULE_TYPE: Type = Type::new(&[], &[], &NUMERIC_RULES);
1682 static LENGTH_RULES: [SourceRule; 1] = [SourceRule::new(
1683 "length",
1684 SourceRuleAuthoringOperation::LengthRangeInclusive {
1685 min: RuleNumber::Integer("2"),
1686 max: RuleNumber::Integer("40"),
1687 },
1688 )];
1689 static LENGTH_RULE_TYPE: Type = Type::new(&[], &[], &LENGTH_RULES);
1690 static NAT_EXACT_RULES: [SourceRule; 2] = [
1691 SourceRule::new(
1692 "maximum",
1693 SourceRuleAuthoringOperation::NumericMaximumInclusive {
1694 value: RuleNumber::Integer("100"),
1695 },
1696 ),
1697 SourceRule::new(
1698 "step",
1699 SourceRuleAuthoringOperation::MultipleOf {
1700 divisor: RuleNumber::Integer("5"),
1701 },
1702 ),
1703 ];
1704 static NAT_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &NAT_EXACT_RULES);
1705 static DECIMAL_EXACT_RULES: [SourceRule; 1] = [SourceRule::new(
1706 "step",
1707 SourceRuleAuthoringOperation::MultipleOf {
1708 divisor: RuleNumber::Decimal("0.25"),
1709 },
1710 )];
1711 static DECIMAL_EXACT_RULE_TYPE: Type = Type::new(&[], &[], &DECIMAL_EXACT_RULES);
1712 static INEXACT_DECIMAL_RULES: [SourceRule; 1] = [SourceRule::new(
1713 "step",
1714 SourceRuleAuthoringOperation::MultipleOf {
1715 divisor: RuleNumber::Decimal("0.251"),
1716 },
1717 )];
1718 static INEXACT_DECIMAL_RULE_TYPE: Type = Type::new(&[], &[], &INEXACT_DECIMAL_RULES);
1719 static NESTED_RULE_FIELDS: [Field; 1] = [Field::new(
1720 "degrees",
1721 Value::new(
1722 Cardinality::One,
1723 Item::new(
1724 ItemTarget::Is("test::Degrees"),
1725 None,
1726 None,
1727 None,
1728 &[],
1729 &[],
1730 false,
1731 ),
1732 ),
1733 None,
1734 None,
1735 None,
1736 )];
1737 static STATUS_VARIANTS: [EnumVariant; 2] = [
1738 EnumVariant::new("Active", None),
1739 EnumVariant::new(
1740 "Retries",
1741 Some(Value::new(
1742 Cardinality::Many,
1743 Item::new(
1744 ItemTarget::Primitive(Primitive::Nat16),
1745 None,
1746 None,
1747 None,
1748 &[],
1749 &[],
1750 false,
1751 ),
1752 )),
1753 ),
1754 ];
1755 static RECURSIVE_RECORD_FIELDS: [Field; 1] = [Field::new(
1756 "next",
1757 Value::new(
1758 Cardinality::Opt,
1759 Item::new(
1760 ItemTarget::Is("test::Recursive"),
1761 None,
1762 None,
1763 None,
1764 &[],
1765 &[],
1766 false,
1767 ),
1768 ),
1769 None,
1770 None,
1771 None,
1772 )];
1773 static RECURSIVE_ENTITY_FIELDS: [Field; 1] = [Field::new(
1774 "root",
1775 Value::new(
1776 Cardinality::One,
1777 Item::new(
1778 ItemTarget::Is("test::Recursive"),
1779 None,
1780 None,
1781 None,
1782 &[],
1783 &[],
1784 false,
1785 ),
1786 ),
1787 None,
1788 None,
1789 None,
1790 )];
1791
1792 #[test]
1793 fn recursive_structural_type_without_a_finite_relation_path_is_ignored() {
1794 let mut schema = Schema::new();
1795 schema.insert_node(SchemaNode::Record(Record::new(
1796 Def::new("test", "Recursive"),
1797 "Recursive",
1798 FieldList::new(&RECURSIVE_RECORD_FIELDS),
1799 EMPTY_TYPE.clone(),
1800 )));
1801 let entity = Entity::new(
1802 Def::new("test", "RecursiveOwner"),
1803 "test::Store",
1804 1,
1805 PrimaryKey::new(&["root"], PrimaryKeySource::External),
1806 &[],
1807 &[],
1808 &[],
1809 FieldList::new(&RECURSIVE_ENTITY_FIELDS),
1810 EMPTY_TYPE.clone(),
1811 );
1812
1813 assert!(
1814 lower_nested_relations(&schema, &entity, &RECURSIVE_ENTITY_FIELDS[0])
1815 .expect("recursive structural types without finite relations should lower")
1816 .is_empty(),
1817 );
1818 }
1819
1820 #[test]
1821 fn recursive_relation_sources_reject_in_both_sibling_orders() {
1822 for relation_first in [false, true] {
1823 let relation = Field::new(
1824 "owner",
1825 Value::new(
1826 Cardinality::One,
1827 Item::new(
1828 ItemTarget::Primitive(Primitive::Ulid),
1829 Some("test::User"),
1830 None,
1831 None,
1832 &[],
1833 &[],
1834 false,
1835 ),
1836 ),
1837 None,
1838 None,
1839 None,
1840 );
1841 let next = RECURSIVE_RECORD_FIELDS[0].clone();
1842 let fields = if relation_first {
1843 vec![relation, next]
1844 } else {
1845 vec![next, relation]
1846 };
1847 let mut schema = Schema::new();
1848 schema.insert_node(SchemaNode::Record(Record::new(
1849 Def::new("test", "Recursive"),
1850 "Recursive",
1851 FieldList::new(Box::leak(fields.into_boxed_slice())),
1852 EMPTY_TYPE.clone(),
1853 )));
1854 let entity = Entity::new(
1855 Def::new("test", "RecursiveOwner"),
1856 "test::Store",
1857 1,
1858 PrimaryKey::new(&["root"], PrimaryKeySource::External),
1859 &[],
1860 &[],
1861 &[],
1862 FieldList::new(&RECURSIVE_ENTITY_FIELDS),
1863 EMPTY_TYPE.clone(),
1864 );
1865 assert!(matches!(
1866 lower_nested_relations(&schema, &entity, &RECURSIVE_ENTITY_FIELDS[0]),
1867 Err(FragmentLoweringError::UnsupportedCardinality(_))
1868 ));
1869 }
1870 }
1871
1872 fn application_behavior_fragment(
1873 normalizers: &'static [TypeNormalizer],
1874 validators: &'static [TypeValidator],
1875 normalizer_name: &'static str,
1876 validator_name: &'static str,
1877 ) -> icydb_schema::SchemaFragment {
1878 let mut schema = Schema::new();
1879 schema.insert_node(SchemaNode::Canister(Canister::new(
1880 Def::new("test", "Canister"),
1881 "test",
1882 None,
1883 )));
1884 schema.insert_node(SchemaNode::Store(Store::new_heap(
1885 Def::new("test", "Store"),
1886 "test::Canister",
1887 StoreHeapConfig::new(),
1888 )));
1889 schema.insert_node(SchemaNode::Normalizer(Normalizer::new(Def::new(
1890 "test",
1891 normalizer_name,
1892 ))));
1893 schema.insert_node(SchemaNode::Validator(Validator::new(Def::new(
1894 "test",
1895 validator_name,
1896 ))));
1897 schema.insert_node(SchemaNode::Entity(Entity::new(
1898 Def::new("test", "ApplicationOnly"),
1899 "test::Store",
1900 1,
1901 PrimaryKey::new(&["id"], PrimaryKeySource::External),
1902 &[],
1903 &[],
1904 &[],
1905 FieldList::new(&APPLICATION_FIELDS),
1906 Type::new(normalizers, validators, &[]),
1907 )));
1908 schema.seal().expect("application-only fixture should seal");
1909 schema
1910 .schema_fragment_for_canister("test::Canister")
1911 .expect("application-only fixture should lower")
1912 }
1913
1914 #[test]
1915 fn validator_and_normalizer_edits_do_not_change_database_fragment() {
1916 let before = application_behavior_fragment(
1917 &APPLICATION_NORMALIZERS_A,
1918 &APPLICATION_VALIDATORS_A,
1919 "NormalizeA",
1920 "ValidateA",
1921 );
1922 let after = application_behavior_fragment(
1923 &APPLICATION_NORMALIZERS_B,
1924 &APPLICATION_VALIDATORS_B,
1925 "NormalizeB",
1926 "ValidateB",
1927 );
1928
1929 assert_eq!(before, after);
1930 }
1931
1932 #[test]
1933 fn durable_rules_nested_below_structural_fields_lower_to_nominal_targets() {
1934 let mut schema = Schema::new();
1935 schema.insert_node(SchemaNode::Newtype(Newtype::new(
1936 Def::new("test", "Degrees"),
1937 "Degrees",
1938 Item::new(
1939 ItemTarget::Primitive(Primitive::Nat16),
1940 None,
1941 None,
1942 None,
1943 &[],
1944 &[],
1945 false,
1946 ),
1947 None,
1948 NUMERIC_RULE_TYPE.clone(),
1949 )));
1950 schema.insert_node(SchemaNode::Record(Record::new(
1951 Def::new("test", "Nested"),
1952 "Nested",
1953 FieldList::new(&NESTED_RULE_FIELDS),
1954 EMPTY_TYPE.clone(),
1955 )));
1956
1957 let outer = Field::new(
1958 "nested",
1959 Value::new(
1960 Cardinality::One,
1961 Item::new(
1962 ItemTarget::Is("test::Nested"),
1963 None,
1964 None,
1965 None,
1966 &[],
1967 &[],
1968 false,
1969 ),
1970 ),
1971 None,
1972 None,
1973 None,
1974 );
1975 let constraints =
1976 lower_field_rules(&schema, &outer).expect("nested durable rule should lower");
1977 assert_eq!(constraints.len(), 1);
1978 let ConstraintFragmentKind::TargetedRule(rule) = constraints[0].kind() else {
1979 panic!("nested durable rule should use the targeted-rule contract")
1980 };
1981 assert_eq!(rule.root().as_str(), "nested");
1982 assert_eq!(rule.target_type().as_str(), "Degrees");
1983 assert!(matches!(
1984 rule.operation(),
1985 SourceRuleOperation::NumericRangeInclusive { .. }
1986 ));
1987 }
1988
1989 #[test]
1990 fn exact_maximum_and_multiple_of_lower_without_float_reconstruction() {
1991 let mut schema = Schema::new();
1992 for (name, primitive, scale, rules) in [
1993 (
1994 "Counter",
1995 Primitive::Nat64,
1996 None,
1997 NAT_EXACT_RULE_TYPE.clone(),
1998 ),
1999 (
2000 "PriceStep",
2001 Primitive::Decimal,
2002 Some(2),
2003 DECIMAL_EXACT_RULE_TYPE.clone(),
2004 ),
2005 ] {
2006 schema.insert_node(SchemaNode::Newtype(Newtype::new(
2007 Def::new("test", name),
2008 name,
2009 Item::new(
2010 ItemTarget::Primitive(primitive),
2011 None,
2012 scale,
2013 None,
2014 &[],
2015 &[],
2016 false,
2017 ),
2018 None,
2019 rules,
2020 )));
2021 }
2022
2023 let field = |name| {
2024 Field::new(
2025 name,
2026 Value::new(
2027 Cardinality::One,
2028 Item::new(
2029 ItemTarget::Is(if name == "counter" {
2030 "test::Counter"
2031 } else {
2032 "test::PriceStep"
2033 }),
2034 None,
2035 None,
2036 None,
2037 &[],
2038 &[],
2039 false,
2040 ),
2041 ),
2042 None,
2043 None,
2044 None,
2045 )
2046 };
2047 let counter = lower_field_rules(&schema, &field("counter"))
2048 .expect("exact integer rules should lower");
2049 assert!(matches!(
2050 counter[0].kind(),
2051 ConstraintFragmentKind::TargetedRule(rule)
2052 if matches!(
2053 rule.operation(),
2054 SourceRuleOperation::NumericMaximumInclusive {
2055 value: ScalarLiteral::Nat(100)
2056 }
2057 )
2058 ));
2059 assert!(matches!(
2060 counter[1].kind(),
2061 ConstraintFragmentKind::TargetedRule(rule)
2062 if matches!(
2063 rule.operation(),
2064 SourceRuleOperation::MultipleOf {
2065 divisor: ScalarLiteral::Nat(5)
2066 }
2067 )
2068 ));
2069
2070 let decimal = lower_field_rules(&schema, &field("price"))
2071 .expect("exact decimal multiple should lower");
2072 assert!(matches!(
2073 decimal[0].kind(),
2074 ConstraintFragmentKind::TargetedRule(rule)
2075 if matches!(
2076 rule.operation(),
2077 SourceRuleOperation::MultipleOf {
2078 divisor: ScalarLiteral::Decimal(value)
2079 } if *value == Decimal::new(25, 2)
2080 )
2081 ));
2082 }
2083
2084 #[test]
2085 fn inexact_decimal_rule_operand_rejects_before_proposal_composition() {
2086 let mut schema = Schema::new();
2087 schema.insert_node(SchemaNode::Newtype(Newtype::new(
2088 Def::new("test", "InexactStep"),
2089 "InexactStep",
2090 Item::new(
2091 ItemTarget::Primitive(Primitive::Decimal),
2092 None,
2093 Some(2),
2094 None,
2095 &[],
2096 &[],
2097 false,
2098 ),
2099 None,
2100 INEXACT_DECIMAL_RULE_TYPE.clone(),
2101 )));
2102 let field = Field::new(
2103 "price",
2104 Value::new(
2105 Cardinality::One,
2106 Item::new(
2107 ItemTarget::Is("test::InexactStep"),
2108 None,
2109 None,
2110 None,
2111 &[],
2112 &[],
2113 false,
2114 ),
2115 ),
2116 None,
2117 None,
2118 None,
2119 );
2120 assert!(lower_field_rules(&schema, &field).is_err());
2121 }
2122
2123 static ENTITY_FIELDS: [Field; 5] = [
2124 Field::new(
2125 "id",
2126 Value::new(
2127 Cardinality::One,
2128 Item::new(
2129 ItemTarget::Primitive(Primitive::Nat64),
2130 None,
2131 None,
2132 None,
2133 &[],
2134 &[],
2135 false,
2136 ),
2137 ),
2138 None,
2139 None,
2140 None,
2141 ),
2142 Field::new(
2143 "tags",
2144 Value::new(
2145 Cardinality::Many,
2146 Item::new(
2147 ItemTarget::Primitive(Primitive::Text),
2148 None,
2149 None,
2150 Some(32),
2151 &[],
2152 &[],
2153 false,
2154 ),
2155 ),
2156 None,
2157 None,
2158 None,
2159 ),
2160 Field::new(
2161 "status",
2162 Value::new(
2163 Cardinality::One,
2164 Item::new(
2165 ItemTarget::Is("test::Status"),
2166 None,
2167 None,
2168 None,
2169 &[],
2170 &[],
2171 false,
2172 ),
2173 ),
2174 Some(crate::node::Arg::ConstPath("test::Status::Active")),
2175 None,
2176 None,
2177 ),
2178 Field::new(
2179 "degrees",
2180 Value::new(
2181 Cardinality::One,
2182 Item::new(
2183 ItemTarget::Is("test::Degrees"),
2184 None,
2185 None,
2186 None,
2187 &[],
2188 &[],
2189 false,
2190 ),
2191 ),
2192 None,
2193 None,
2194 None,
2195 ),
2196 Field::new(
2197 "label",
2198 Value::new(
2199 Cardinality::One,
2200 Item::new(
2201 ItemTarget::Is("test::Label"),
2202 None,
2203 None,
2204 None,
2205 &[],
2206 &[],
2207 false,
2208 ),
2209 ),
2210 None,
2211 None,
2212 None,
2213 ),
2214 ];
2215
2216 #[test]
2217 #[expect(
2218 clippy::too_many_lines,
2219 reason = "one graph fixture proves the complete field, type, relation, and durable-rule closure"
2220 )]
2221 fn sealed_canister_graph_emits_store_free_database_closure() {
2222 let mut schema = Schema::new();
2223 schema.insert_node(SchemaNode::Canister(Canister::new(
2224 Def::new("test", "Canister"),
2225 "test",
2226 None,
2227 )));
2228 schema.insert_node(SchemaNode::Store(Store::new_heap(
2229 Def::new("test", "Store"),
2230 "test::Canister",
2231 StoreHeapConfig::new(),
2232 )));
2233 schema.insert_node(SchemaNode::Enum(Enum::new(
2234 Def::new("test", "Status"),
2235 "Status",
2236 &STATUS_VARIANTS,
2237 EMPTY_TYPE.clone(),
2238 )));
2239 schema.insert_node(SchemaNode::Newtype(Newtype::new(
2240 Def::new("test", "Degrees"),
2241 "Degrees",
2242 Item::new(
2243 ItemTarget::Primitive(Primitive::Nat16),
2244 None,
2245 None,
2246 None,
2247 &[],
2248 &[],
2249 false,
2250 ),
2251 None,
2252 NUMERIC_RULE_TYPE.clone(),
2253 )));
2254 schema.insert_node(SchemaNode::Newtype(Newtype::new(
2255 Def::new("test", "Label"),
2256 "Label",
2257 Item::new(
2258 ItemTarget::Primitive(Primitive::Text),
2259 None,
2260 None,
2261 None,
2262 &[],
2263 &[],
2264 false,
2265 ),
2266 None,
2267 LENGTH_RULE_TYPE.clone(),
2268 )));
2269 schema.insert_node(SchemaNode::Entity(Entity::new(
2270 Def::new("test", "Task"),
2271 "test::Store",
2272 1,
2273 PrimaryKey::new(&["id"], PrimaryKeySource::External),
2274 &[],
2275 &[],
2276 &[],
2277 FieldList::new(&ENTITY_FIELDS),
2278 EMPTY_TYPE.clone(),
2279 )));
2280 schema.seal().expect("fixture graph should seal");
2281
2282 let fragment = schema
2283 .schema_fragment_for_canister("test::Canister")
2284 .expect("sealed database closure should lower");
2285
2286 assert_eq!(fragment.entities().len(), 1);
2287 assert_eq!(fragment.types().len(), 3);
2288 let fields = fragment.entities()[0].fields();
2289 assert!(matches!(
2290 fields
2291 .iter()
2292 .find(|field| field.name().as_str() == "tags")
2293 .map(icydb_schema::FieldFragment::field_type),
2294 Some(FieldType::List(item))
2295 if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Text { max_len: Some(32) }))
2296 ));
2297 assert!(matches!(
2298 fields
2299 .iter()
2300 .find(|field| field.name().as_str() == "status")
2301 .map(icydb_schema::FieldFragment::insert_policy),
2302 Some(icydb_schema::FieldInsertPolicy::Default(
2303 icydb_schema::ScalarLiteral::EnumUnit { .. }
2304 ))
2305 ));
2306 let NamedTypeFragment::Enum(status) = fragment
2307 .types()
2308 .iter()
2309 .find(|fragment| matches!(fragment, NamedTypeFragment::Enum(_)))
2310 .expect("reachable status type should remain an enum")
2311 else {
2312 panic!("reachable status type should remain an enum")
2313 };
2314 assert!(matches!(
2315 status
2316 .variants()
2317 .iter()
2318 .find(|variant| variant.name().as_str() == "Retries")
2319 .and_then(|variant| variant.payload()),
2320 Some(FieldType::List(item))
2321 if matches!(item.as_ref(), FieldType::Scalar(ScalarType::Nat16))
2322 ));
2323
2324 let constraints = fragment.entities()[0].constraints();
2325 assert_eq!(constraints.len(), 2);
2326 let degrees_source = ConstraintSourceKey::for_targeted_field_rule(
2327 &FieldSourceKey::try_new("degrees").expect("field name"),
2328 &TypeSourceKey::try_new("Degrees").expect("type name"),
2329 &RuleSourceKey::try_new("range").expect("rule name"),
2330 );
2331 let degrees = constraints
2332 .iter()
2333 .find(|constraint| constraint.source_key() == °rees_source)
2334 .expect("numeric rule should become one field-owned constraint");
2335 let ConstraintFragmentKind::TargetedRule(degrees) = degrees.kind() else {
2336 panic!("numeric rule should use the targeted-rule contract")
2337 };
2338 assert_eq!(degrees.root().as_str(), "degrees");
2339 assert_eq!(degrees.target_type().as_str(), "Degrees");
2340 assert!(matches!(
2341 degrees.operation(),
2342 SourceRuleOperation::NumericRangeInclusive { .. }
2343 ));
2344 let label = constraints
2345 .iter()
2346 .find(|constraint| constraint.source_key() != °rees_source)
2347 .expect("length rule should become one field-owned constraint");
2348 let ConstraintFragmentKind::TargetedRule(label) = label.kind() else {
2349 panic!("length rule should use the targeted-rule contract")
2350 };
2351 assert_eq!(label.target_type().as_str(), "Label");
2352 assert!(matches!(
2353 label.operation(),
2354 SourceRuleOperation::LengthRangeInclusive { min: 2, max: 40 }
2355 ));
2356 }
2357}