1use 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#[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 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
697fn 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
879fn 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
966fn 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
1131fn 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() == °rees_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() != °rees_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}