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