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