1use super::{
2 ast::{CompareOp, ComparePredicate, Predicate, UnsupportedQueryFeature},
3 coercion::{CoercionId, CoercionSpec, supports_coercion},
4};
5use crate::{
6 db::identity::{EntityName, EntityNameError, IndexName, IndexNameError},
7 model::{entity::EntityModel, field::EntityFieldKind, index::IndexModel},
8 traits::FieldValueKind,
9 value::{CoercionFamily, CoercionFamilyExt, Value},
10};
11use std::{
12 collections::{BTreeMap, BTreeSet},
13 fmt,
14};
15
16#[derive(Clone, Debug, Eq, PartialEq)]
28pub(crate) enum ScalarType {
29 Account,
30 Blob,
31 Bool,
32 Date,
33 Decimal,
34 Duration,
35 Enum,
36 E8s,
37 E18s,
38 Float32,
39 Float64,
40 Int,
41 Int128,
42 IntBig,
43 Principal,
44 Subaccount,
45 Text,
46 Timestamp,
47 Uint,
48 Uint128,
49 UintBig,
50 Ulid,
51 Unit,
52}
53
54macro_rules! scalar_coercion_family_from_registry {
56 ( @args $self:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
57 match $self {
58 $( ScalarType::$scalar => $coercion_family, )*
59 }
60 };
61}
62
63macro_rules! scalar_matches_value_from_registry {
64 ( @args $self:expr, $value:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
65 matches!(
66 ($self, $value),
67 $( (ScalarType::$scalar, $value_pat) )|*
68 )
69 };
70}
71
72macro_rules! scalar_supports_numeric_coercion_from_registry {
73 ( @args $self:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
74 match $self {
75 $( ScalarType::$scalar => $supports_numeric_coercion, )*
76 }
77 };
78}
79
80#[cfg(test)]
81macro_rules! scalar_supports_arithmetic_from_registry {
82 ( @args $self:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
83 match $self {
84 $( ScalarType::$scalar => $supports_arithmetic, )*
85 }
86 };
87}
88
89macro_rules! scalar_is_keyable_from_registry {
90 ( @args $self:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
91 match $self {
92 $( ScalarType::$scalar => $is_keyable, )*
93 }
94 };
95}
96
97#[cfg(test)]
98macro_rules! scalar_supports_equality_from_registry {
99 ( @args $self:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
100 match $self {
101 $( ScalarType::$scalar => $supports_equality, )*
102 }
103 };
104}
105
106macro_rules! scalar_supports_ordering_from_registry {
107 ( @args $self:expr; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
108 match $self {
109 $( ScalarType::$scalar => $supports_ordering, )*
110 }
111 };
112}
113
114impl ScalarType {
115 #[must_use]
116 pub const fn coercion_family(&self) -> CoercionFamily {
117 scalar_registry!(scalar_coercion_family_from_registry, self)
118 }
119
120 #[must_use]
121 pub const fn is_orderable(&self) -> bool {
122 self.supports_ordering()
125 }
126
127 #[must_use]
128 pub const fn matches_value(&self, value: &Value) -> bool {
129 scalar_registry!(scalar_matches_value_from_registry, self, value)
130 }
131
132 #[must_use]
133 pub const fn supports_numeric_coercion(&self) -> bool {
134 scalar_registry!(scalar_supports_numeric_coercion_from_registry, self)
135 }
136
137 #[must_use]
138 #[cfg(test)]
139 #[expect(dead_code)]
140 pub const fn supports_arithmetic(&self) -> bool {
141 scalar_registry!(scalar_supports_arithmetic_from_registry, self)
142 }
143
144 #[must_use]
145 pub const fn is_keyable(&self) -> bool {
146 scalar_registry!(scalar_is_keyable_from_registry, self)
147 }
148
149 #[must_use]
150 #[cfg(test)]
151 #[expect(dead_code)]
152 pub const fn supports_equality(&self) -> bool {
153 scalar_registry!(scalar_supports_equality_from_registry, self)
154 }
155
156 #[must_use]
157 pub const fn supports_ordering(&self) -> bool {
158 scalar_registry!(scalar_supports_ordering_from_registry, self)
159 }
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
173pub(crate) enum FieldType {
174 Scalar(ScalarType),
175 List(Box<Self>),
176 Set(Box<Self>),
177 Map { key: Box<Self>, value: Box<Self> },
178 Structured { queryable: bool },
179}
180
181impl FieldType {
182 #[must_use]
183 pub const fn value_kind(&self) -> FieldValueKind {
184 match self {
185 Self::Scalar(_) => FieldValueKind::Atomic,
186 Self::List(_) | Self::Set(_) => FieldValueKind::Structured { queryable: true },
187 Self::Map { .. } => FieldValueKind::Structured { queryable: false },
188 Self::Structured { queryable } => FieldValueKind::Structured {
189 queryable: *queryable,
190 },
191 }
192 }
193
194 #[must_use]
195 pub const fn coercion_family(&self) -> Option<CoercionFamily> {
196 match self {
197 Self::Scalar(inner) => Some(inner.coercion_family()),
198 Self::List(_) | Self::Set(_) | Self::Map { .. } => Some(CoercionFamily::Collection),
199 Self::Structured { .. } => None,
200 }
201 }
202
203 #[must_use]
204 pub const fn is_text(&self) -> bool {
205 matches!(self, Self::Scalar(ScalarType::Text))
206 }
207
208 #[must_use]
209 pub const fn is_collection(&self) -> bool {
210 matches!(self, Self::List(_) | Self::Set(_) | Self::Map { .. })
211 }
212
213 #[must_use]
214 pub const fn is_list_like(&self) -> bool {
215 matches!(self, Self::List(_) | Self::Set(_))
216 }
217
218 #[must_use]
219 pub const fn is_map(&self) -> bool {
220 matches!(self, Self::Map { .. })
221 }
222
223 #[must_use]
224 pub fn map_types(&self) -> Option<(&Self, &Self)> {
225 match self {
226 Self::Map { key, value } => Some((key.as_ref(), value.as_ref())),
227 _ => {
228 None
230 }
231 }
232 }
233
234 #[must_use]
235 pub const fn is_orderable(&self) -> bool {
236 match self {
237 Self::Scalar(inner) => inner.is_orderable(),
238 _ => false,
239 }
240 }
241
242 #[must_use]
243 pub const fn is_keyable(&self) -> bool {
244 match self {
245 Self::Scalar(inner) => inner.is_keyable(),
246 _ => false,
247 }
248 }
249
250 #[must_use]
251 pub const fn supports_numeric_coercion(&self) -> bool {
252 match self {
253 Self::Scalar(inner) => inner.supports_numeric_coercion(),
254 _ => false,
255 }
256 }
257}
258
259fn validate_index_fields(
260 fields: &BTreeMap<String, FieldType>,
261 indexes: &[&IndexModel],
262) -> Result<(), ValidateError> {
263 let mut seen_names = BTreeSet::new();
264 for index in indexes {
265 if seen_names.contains(index.name) {
266 return Err(ValidateError::DuplicateIndexName {
267 name: index.name.to_string(),
268 });
269 }
270 seen_names.insert(index.name);
271
272 let mut seen = BTreeSet::new();
273 for field in index.fields {
274 if !fields.contains_key(*field) {
275 return Err(ValidateError::IndexFieldUnknown {
276 index: **index,
277 field: (*field).to_string(),
278 });
279 }
280 if seen.contains(*field) {
281 return Err(ValidateError::IndexFieldDuplicate {
282 index: **index,
283 field: (*field).to_string(),
284 });
285 }
286 seen.insert(*field);
287
288 let field_type = fields
289 .get(*field)
290 .expect("index field existence checked above");
291 if matches!(field_type, FieldType::Map { .. }) {
294 return Err(ValidateError::IndexFieldMapNotQueryable {
295 index: **index,
296 field: (*field).to_string(),
297 });
298 }
299 if !field_type.value_kind().is_queryable() {
300 return Err(ValidateError::IndexFieldNotQueryable {
301 index: **index,
302 field: (*field).to_string(),
303 });
304 }
305 }
306 }
307
308 Ok(())
309}
310
311#[derive(Clone, Debug)]
319pub struct SchemaInfo {
320 fields: BTreeMap<String, FieldType>,
321}
322
323impl SchemaInfo {
324 #[must_use]
325 pub(crate) fn field(&self, name: &str) -> Option<&FieldType> {
326 self.fields.get(name)
327 }
328
329 pub fn from_entity_model(model: &EntityModel) -> Result<Self, ValidateError> {
330 let entity_name = EntityName::try_from_str(model.entity_name).map_err(|err| {
332 ValidateError::InvalidEntityName {
333 name: model.entity_name.to_string(),
334 source: err,
335 }
336 })?;
337
338 if !model
339 .fields
340 .iter()
341 .any(|field| std::ptr::eq(field, model.primary_key))
342 {
343 return Err(ValidateError::InvalidPrimaryKey {
344 field: model.primary_key.name.to_string(),
345 });
346 }
347
348 let mut fields = BTreeMap::new();
349 for field in model.fields {
350 if fields.contains_key(field.name) {
351 return Err(ValidateError::DuplicateField {
352 field: field.name.to_string(),
353 });
354 }
355 let ty = field_type_from_model_kind(&field.kind);
356 fields.insert(field.name.to_string(), ty);
357 }
358
359 let pk_field_type = fields
360 .get(model.primary_key.name)
361 .expect("primary key verified above");
362 if !pk_field_type.is_keyable() {
363 return Err(ValidateError::InvalidPrimaryKeyType {
364 field: model.primary_key.name.to_string(),
365 });
366 }
367
368 validate_index_fields(&fields, model.indexes)?;
369 for index in model.indexes {
370 IndexName::try_from_parts(&entity_name, index.fields).map_err(|err| {
371 ValidateError::InvalidIndexName {
372 index: **index,
373 source: err,
374 }
375 })?;
376 }
377
378 Ok(Self { fields })
379 }
380}
381
382#[derive(Debug, thiserror::Error)]
384pub enum ValidateError {
385 #[error("invalid entity name '{name}': {source}")]
386 InvalidEntityName {
387 name: String,
388 #[source]
389 source: EntityNameError,
390 },
391
392 #[error("invalid index name for '{index}': {source}")]
393 InvalidIndexName {
394 index: IndexModel,
395 #[source]
396 source: IndexNameError,
397 },
398
399 #[error("unknown field '{field}'")]
400 UnknownField { field: String },
401
402 #[error("field '{field}' is not queryable")]
403 NonQueryableFieldType { field: String },
404
405 #[error("duplicate field '{field}'")]
406 DuplicateField { field: String },
407
408 #[error("{0}")]
409 UnsupportedQueryFeature(#[from] UnsupportedQueryFeature),
410
411 #[error("primary key '{field}' not present in entity fields")]
412 InvalidPrimaryKey { field: String },
413
414 #[error("primary key '{field}' has a non-keyable type")]
415 InvalidPrimaryKeyType { field: String },
416
417 #[error("index '{index}' references unknown field '{field}'")]
418 IndexFieldUnknown { index: IndexModel, field: String },
419
420 #[error("index '{index}' references non-queryable field '{field}'")]
421 IndexFieldNotQueryable { index: IndexModel, field: String },
422
423 #[error(
424 "index '{index}' references map field '{field}'; map fields are not queryable in icydb 0.7"
425 )]
426 IndexFieldMapNotQueryable { index: IndexModel, field: String },
427
428 #[error("index '{index}' repeats field '{field}'")]
429 IndexFieldDuplicate { index: IndexModel, field: String },
430
431 #[error("duplicate index name '{name}'")]
432 DuplicateIndexName { name: String },
433
434 #[error("operator {op} is not valid for field '{field}'")]
435 InvalidOperator { field: String, op: String },
436
437 #[error("coercion {coercion:?} is not valid for field '{field}'")]
438 InvalidCoercion { field: String, coercion: CoercionId },
439
440 #[error("invalid literal for field '{field}': {message}")]
441 InvalidLiteral { field: String, message: String },
442}
443
444pub fn reject_unsupported_query_features(
446 predicate: &Predicate,
447) -> Result<(), UnsupportedQueryFeature> {
448 match predicate {
449 Predicate::True
450 | Predicate::False
451 | Predicate::Compare(_)
452 | Predicate::IsNull { .. }
453 | Predicate::IsMissing { .. }
454 | Predicate::IsEmpty { .. }
455 | Predicate::IsNotEmpty { .. }
456 | Predicate::TextContains { .. }
457 | Predicate::TextContainsCi { .. } => Ok(()),
458 Predicate::And(children) | Predicate::Or(children) => {
459 for child in children {
460 reject_unsupported_query_features(child)?;
461 }
462
463 Ok(())
464 }
465 Predicate::Not(inner) => reject_unsupported_query_features(inner),
466 Predicate::MapContainsKey { field, .. }
467 | Predicate::MapContainsValue { field, .. }
468 | Predicate::MapContainsEntry { field, .. } => Err(UnsupportedQueryFeature::MapPredicate {
469 field: field.clone(),
470 }),
471 }
472}
473
474pub fn validate(schema: &SchemaInfo, predicate: &Predicate) -> Result<(), ValidateError> {
475 reject_unsupported_query_features(predicate)?;
476
477 match predicate {
478 Predicate::True | Predicate::False => Ok(()),
479 Predicate::And(children) | Predicate::Or(children) => {
480 for child in children {
481 validate(schema, child)?;
482 }
483 Ok(())
484 }
485 Predicate::Not(inner) => validate(schema, inner),
486 Predicate::Compare(cmp) => validate_compare(schema, cmp),
487 Predicate::IsNull { field } | Predicate::IsMissing { field } => {
488 let _field_type = ensure_field(schema, field)?;
489 Ok(())
490 }
491 Predicate::IsEmpty { field } => {
492 let field_type = ensure_field(schema, field)?;
493 if field_type.is_text() || field_type.is_collection() {
494 Ok(())
495 } else {
496 Err(invalid_operator(field, "is_empty"))
497 }
498 }
499 Predicate::IsNotEmpty { field } => {
500 let field_type = ensure_field(schema, field)?;
501 if field_type.is_text() || field_type.is_collection() {
502 Ok(())
503 } else {
504 Err(invalid_operator(field, "is_not_empty"))
505 }
506 }
507 Predicate::MapContainsKey {
508 field,
509 key,
510 coercion,
511 } => validate_map_key(schema, field, key, coercion),
512 Predicate::MapContainsValue {
513 field,
514 value,
515 coercion,
516 } => validate_map_value(schema, field, value, coercion),
517 Predicate::MapContainsEntry {
518 field,
519 key,
520 value,
521 coercion,
522 } => validate_map_entry(schema, field, key, value, coercion),
523 Predicate::TextContains { field, value } => {
524 validate_text_contains(schema, field, value, "text_contains")
525 }
526 Predicate::TextContainsCi { field, value } => {
527 validate_text_contains(schema, field, value, "text_contains_ci")
528 }
529 }
530}
531
532pub fn validate_model(model: &EntityModel, predicate: &Predicate) -> Result<(), ValidateError> {
533 let schema = SchemaInfo::from_entity_model(model)?;
534 validate(&schema, predicate)
535}
536
537fn validate_compare(schema: &SchemaInfo, cmp: &ComparePredicate) -> Result<(), ValidateError> {
538 let field_type = ensure_field(schema, &cmp.field)?;
539
540 match cmp.op {
541 CompareOp::Eq | CompareOp::Ne => {
542 validate_eq_ne(&cmp.field, field_type, &cmp.value, &cmp.coercion)
543 }
544 CompareOp::Lt | CompareOp::Lte | CompareOp::Gt | CompareOp::Gte => {
545 validate_ordering(&cmp.field, field_type, &cmp.value, &cmp.coercion, cmp.op)
546 }
547 CompareOp::In | CompareOp::NotIn => {
548 validate_in(&cmp.field, field_type, &cmp.value, &cmp.coercion, cmp.op)
549 }
550 CompareOp::Contains => validate_contains(&cmp.field, field_type, &cmp.value, &cmp.coercion),
551 CompareOp::StartsWith | CompareOp::EndsWith => {
552 validate_text_compare(&cmp.field, field_type, &cmp.value, &cmp.coercion, cmp.op)
553 }
554 }
555}
556
557fn validate_eq_ne(
558 field: &str,
559 field_type: &FieldType,
560 value: &Value,
561 coercion: &CoercionSpec,
562) -> Result<(), ValidateError> {
563 if field_type.is_list_like() {
564 ensure_list_literal(field, value, field_type)?;
565 } else if field_type.is_map() {
566 ensure_map_literal(field, value, field_type)?;
567 } else {
568 ensure_scalar_literal(field, value)?;
569 }
570
571 ensure_coercion(field, field_type, value, coercion)
572}
573
574fn validate_ordering(
575 field: &str,
576 field_type: &FieldType,
577 value: &Value,
578 coercion: &CoercionSpec,
579 op: CompareOp,
580) -> Result<(), ValidateError> {
581 if matches!(coercion.id, CoercionId::CollectionElement) {
582 return Err(ValidateError::InvalidCoercion {
583 field: field.to_string(),
584 coercion: coercion.id,
585 });
586 }
587
588 if !field_type.is_orderable() {
589 return Err(invalid_operator(field, format!("{op:?}")));
590 }
591
592 ensure_scalar_literal(field, value)?;
593
594 ensure_coercion(field, field_type, value, coercion)
595}
596
597fn validate_in(
599 field: &str,
600 field_type: &FieldType,
601 value: &Value,
602 coercion: &CoercionSpec,
603 op: CompareOp,
604) -> Result<(), ValidateError> {
605 if field_type.is_collection() {
606 return Err(invalid_operator(field, format!("{op:?}")));
607 }
608
609 let Value::List(items) = value else {
610 return Err(invalid_literal(field, "expected list literal"));
611 };
612
613 for item in items {
614 ensure_coercion(field, field_type, item, coercion)?;
615 }
616
617 Ok(())
618}
619
620fn validate_contains(
622 field: &str,
623 field_type: &FieldType,
624 value: &Value,
625 coercion: &CoercionSpec,
626) -> Result<(), ValidateError> {
627 if field_type.is_text() {
628 return Err(invalid_operator(
630 field,
631 format!("{:?}", CompareOp::Contains),
632 ));
633 }
634
635 let element_type = match field_type {
636 FieldType::List(inner) | FieldType::Set(inner) => inner.as_ref(),
637 _ => {
638 return Err(invalid_operator(
639 field,
640 format!("{:?}", CompareOp::Contains),
641 ));
642 }
643 };
644
645 if matches!(coercion.id, CoercionId::TextCasefold) {
646 return Err(ValidateError::InvalidCoercion {
648 field: field.to_string(),
649 coercion: coercion.id,
650 });
651 }
652
653 ensure_coercion(field, element_type, value, coercion)
654}
655
656fn validate_text_compare(
658 field: &str,
659 field_type: &FieldType,
660 value: &Value,
661 coercion: &CoercionSpec,
662 op: CompareOp,
663) -> Result<(), ValidateError> {
664 if !field_type.is_text() {
665 return Err(invalid_operator(field, format!("{op:?}")));
666 }
667
668 ensure_text_literal(field, value)?;
669
670 ensure_coercion(field, field_type, value, coercion)
671}
672
673fn ensure_map_types<'a>(
675 schema: &'a SchemaInfo,
676 field: &str,
677 op: &str,
678) -> Result<(&'a FieldType, &'a FieldType), ValidateError> {
679 let field_type = ensure_field(schema, field)?;
680 field_type
681 .map_types()
682 .ok_or_else(|| invalid_operator(field, op))
683}
684
685fn validate_map_key(
686 schema: &SchemaInfo,
687 field: &str,
688 key: &Value,
689 coercion: &CoercionSpec,
690) -> Result<(), ValidateError> {
691 ensure_no_text_casefold(field, coercion)?;
692
693 let (key_type, _) = ensure_map_types(schema, field, "map_contains_key")?;
694
695 ensure_coercion(field, key_type, key, coercion)
696}
697
698fn validate_map_value(
699 schema: &SchemaInfo,
700 field: &str,
701 value: &Value,
702 coercion: &CoercionSpec,
703) -> Result<(), ValidateError> {
704 ensure_no_text_casefold(field, coercion)?;
705
706 let (_, value_type) = ensure_map_types(schema, field, "map_contains_value")?;
707
708 ensure_coercion(field, value_type, value, coercion)
709}
710
711fn validate_map_entry(
712 schema: &SchemaInfo,
713 field: &str,
714 key: &Value,
715 value: &Value,
716 coercion: &CoercionSpec,
717) -> Result<(), ValidateError> {
718 ensure_no_text_casefold(field, coercion)?;
719
720 let (key_type, value_type) = ensure_map_types(schema, field, "map_contains_entry")?;
721
722 ensure_coercion(field, key_type, key, coercion)?;
723 ensure_coercion(field, value_type, value, coercion)?;
724
725 Ok(())
726}
727
728fn validate_text_contains(
730 schema: &SchemaInfo,
731 field: &str,
732 value: &Value,
733 op: &str,
734) -> Result<(), ValidateError> {
735 let field_type = ensure_field(schema, field)?;
736 if !field_type.is_text() {
737 return Err(invalid_operator(field, op));
738 }
739
740 ensure_text_literal(field, value)?;
741
742 Ok(())
743}
744
745fn ensure_field<'a>(schema: &'a SchemaInfo, field: &str) -> Result<&'a FieldType, ValidateError> {
746 let field_type = schema
747 .field(field)
748 .ok_or_else(|| ValidateError::UnknownField {
749 field: field.to_string(),
750 })?;
751
752 if matches!(field_type, FieldType::Map { .. }) {
753 return Err(UnsupportedQueryFeature::MapPredicate {
754 field: field.to_string(),
755 }
756 .into());
757 }
758
759 if !field_type.value_kind().is_queryable() {
760 return Err(ValidateError::NonQueryableFieldType {
761 field: field.to_string(),
762 });
763 }
764
765 Ok(field_type)
766}
767
768fn invalid_operator(field: &str, op: impl fmt::Display) -> ValidateError {
769 ValidateError::InvalidOperator {
770 field: field.to_string(),
771 op: op.to_string(),
772 }
773}
774
775fn invalid_literal(field: &str, msg: &str) -> ValidateError {
776 ValidateError::InvalidLiteral {
777 field: field.to_string(),
778 message: msg.to_string(),
779 }
780}
781
782fn ensure_no_text_casefold(field: &str, coercion: &CoercionSpec) -> Result<(), ValidateError> {
784 if matches!(coercion.id, CoercionId::TextCasefold) {
785 return Err(ValidateError::InvalidCoercion {
786 field: field.to_string(),
787 coercion: coercion.id,
788 });
789 }
790
791 Ok(())
792}
793
794fn ensure_text_literal(field: &str, value: &Value) -> Result<(), ValidateError> {
796 if !matches!(value, Value::Text(_)) {
797 return Err(invalid_literal(field, "expected text literal"));
798 }
799
800 Ok(())
801}
802
803fn ensure_scalar_literal(field: &str, value: &Value) -> Result<(), ValidateError> {
805 if matches!(value, Value::List(_)) {
806 return Err(invalid_literal(field, "expected scalar literal"));
807 }
808
809 Ok(())
810}
811
812fn ensure_coercion(
813 field: &str,
814 field_type: &FieldType,
815 literal: &Value,
816 coercion: &CoercionSpec,
817) -> Result<(), ValidateError> {
818 if matches!(coercion.id, CoercionId::TextCasefold) && !field_type.is_text() {
819 return Err(ValidateError::InvalidCoercion {
821 field: field.to_string(),
822 coercion: coercion.id,
823 });
824 }
825
826 if matches!(coercion.id, CoercionId::NumericWiden)
831 && (!field_type.supports_numeric_coercion() || !literal.supports_numeric_coercion())
832 {
833 return Err(ValidateError::InvalidCoercion {
834 field: field.to_string(),
835 coercion: coercion.id,
836 });
837 }
838
839 if !matches!(coercion.id, CoercionId::NumericWiden) {
840 let left_family =
841 field_type
842 .coercion_family()
843 .ok_or_else(|| ValidateError::NonQueryableFieldType {
844 field: field.to_string(),
845 })?;
846 let right_family = literal.coercion_family();
847
848 if !supports_coercion(left_family, right_family, coercion.id) {
849 return Err(ValidateError::InvalidCoercion {
850 field: field.to_string(),
851 coercion: coercion.id,
852 });
853 }
854 }
855
856 if matches!(
857 coercion.id,
858 CoercionId::Strict | CoercionId::CollectionElement
859 ) && !literal_matches_type(literal, field_type)
860 {
861 return Err(invalid_literal(
862 field,
863 "literal type does not match field type",
864 ));
865 }
866
867 Ok(())
868}
869
870fn ensure_list_literal(
871 field: &str,
872 literal: &Value,
873 field_type: &FieldType,
874) -> Result<(), ValidateError> {
875 if !literal_matches_type(literal, field_type) {
876 return Err(invalid_literal(
877 field,
878 "list literal does not match field element type",
879 ));
880 }
881
882 Ok(())
883}
884
885fn ensure_map_literal(
886 field: &str,
887 literal: &Value,
888 field_type: &FieldType,
889) -> Result<(), ValidateError> {
890 if !literal_matches_type(literal, field_type) {
891 return Err(invalid_literal(
892 field,
893 "map literal does not match field key/value types",
894 ));
895 }
896
897 Ok(())
898}
899
900pub(crate) fn literal_matches_type(literal: &Value, field_type: &FieldType) -> bool {
901 match field_type {
902 FieldType::Scalar(inner) => inner.matches_value(literal),
903 FieldType::List(element) | FieldType::Set(element) => match literal {
904 Value::List(items) => items.iter().all(|item| literal_matches_type(item, element)),
905 _ => false,
906 },
907 FieldType::Map { key, value } => match literal {
908 Value::Map(entries) => {
909 if Value::validate_map_entries(entries.as_slice()).is_err() {
910 return false;
911 }
912
913 entries.iter().all(|(entry_key, entry_value)| {
914 literal_matches_type(entry_key, key) && literal_matches_type(entry_value, value)
915 })
916 }
917 _ => false,
918 },
919 FieldType::Structured { .. } => {
920 false
922 }
923 }
924}
925
926fn field_type_from_model_kind(kind: &EntityFieldKind) -> FieldType {
927 match kind {
928 EntityFieldKind::Account => FieldType::Scalar(ScalarType::Account),
929 EntityFieldKind::Blob => FieldType::Scalar(ScalarType::Blob),
930 EntityFieldKind::Bool => FieldType::Scalar(ScalarType::Bool),
931 EntityFieldKind::Date => FieldType::Scalar(ScalarType::Date),
932 EntityFieldKind::Decimal => FieldType::Scalar(ScalarType::Decimal),
933 EntityFieldKind::Duration => FieldType::Scalar(ScalarType::Duration),
934 EntityFieldKind::Enum => FieldType::Scalar(ScalarType::Enum),
935 EntityFieldKind::E8s => FieldType::Scalar(ScalarType::E8s),
936 EntityFieldKind::E18s => FieldType::Scalar(ScalarType::E18s),
937 EntityFieldKind::Float32 => FieldType::Scalar(ScalarType::Float32),
938 EntityFieldKind::Float64 => FieldType::Scalar(ScalarType::Float64),
939 EntityFieldKind::Int => FieldType::Scalar(ScalarType::Int),
940 EntityFieldKind::Int128 => FieldType::Scalar(ScalarType::Int128),
941 EntityFieldKind::IntBig => FieldType::Scalar(ScalarType::IntBig),
942 EntityFieldKind::Principal => FieldType::Scalar(ScalarType::Principal),
943 EntityFieldKind::Subaccount => FieldType::Scalar(ScalarType::Subaccount),
944 EntityFieldKind::Text => FieldType::Scalar(ScalarType::Text),
945 EntityFieldKind::Timestamp => FieldType::Scalar(ScalarType::Timestamp),
946 EntityFieldKind::Uint => FieldType::Scalar(ScalarType::Uint),
947 EntityFieldKind::Uint128 => FieldType::Scalar(ScalarType::Uint128),
948 EntityFieldKind::UintBig => FieldType::Scalar(ScalarType::UintBig),
949 EntityFieldKind::Ulid => FieldType::Scalar(ScalarType::Ulid),
950 EntityFieldKind::Unit => FieldType::Scalar(ScalarType::Unit),
951 EntityFieldKind::Ref { key_kind, .. } => field_type_from_model_kind(key_kind),
952 EntityFieldKind::List(inner) => {
953 FieldType::List(Box::new(field_type_from_model_kind(inner)))
954 }
955 EntityFieldKind::Set(inner) => FieldType::Set(Box::new(field_type_from_model_kind(inner))),
956 EntityFieldKind::Map { key, value } => FieldType::Map {
957 key: Box::new(field_type_from_model_kind(key)),
958 value: Box::new(field_type_from_model_kind(value)),
959 },
960 EntityFieldKind::Structured { queryable } => FieldType::Structured {
961 queryable: *queryable,
962 },
963 }
964}
965
966impl fmt::Display for FieldType {
967 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
968 match self {
969 Self::Scalar(inner) => write!(f, "{inner:?}"),
970 Self::List(inner) => write!(f, "List<{inner}>"),
971 Self::Set(inner) => write!(f, "Set<{inner}>"),
972 Self::Map { key, value } => write!(f, "Map<{key}, {value}>"),
973 Self::Structured { queryable } => {
974 write!(f, "Structured<queryable={queryable}>")
975 }
976 }
977 }
978}
979
980#[cfg(test)]
985mod tests {
986 use super::{FieldType, ScalarType, ValidateError, ensure_coercion, validate_model};
988 use crate::{
989 db::query::{
990 FieldRef,
991 predicate::{
992 CoercionId, CoercionSpec, CompareOp, ComparePredicate, Predicate,
993 UnsupportedQueryFeature,
994 },
995 },
996 model::field::{EntityFieldKind, EntityFieldModel},
997 test_fixtures::InvalidEntityModelBuilder,
998 traits::{EntitySchema, FieldValue},
999 types::{
1000 Account, Date, Decimal, Duration, E8s, E18s, Float32, Float64, Int, Int128, Nat,
1001 Nat128, Principal, Subaccount, Timestamp, Ulid,
1002 },
1003 value::{CoercionFamily, Value, ValueEnum},
1004 };
1005 use std::collections::BTreeSet;
1006
1007 fn registry_scalars() -> Vec<ScalarType> {
1009 macro_rules! collect_scalars {
1010 ( @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
1011 vec![ $( ScalarType::$scalar ),* ]
1012 };
1013 ( @args $($ignore:tt)*; @entries $( ($scalar:ident, $coercion_family:expr, $value_pat:pat, is_numeric_value = $is_numeric:expr, supports_numeric_coercion = $supports_numeric_coercion:expr, supports_arithmetic = $supports_arithmetic:expr, supports_equality = $supports_equality:expr, supports_ordering = $supports_ordering:expr, is_keyable = $is_keyable:expr, is_storage_key_encodable = $is_storage_key_encodable:expr) ),* $(,)? ) => {
1014 vec![ $( ScalarType::$scalar ),* ]
1015 };
1016 }
1017
1018 let scalars = scalar_registry!(collect_scalars);
1019
1020 scalars
1021 }
1022
1023 const SCALAR_TYPE_VARIANT_COUNT: usize = 23;
1025
1026 fn scalar_index(scalar: ScalarType) -> usize {
1028 match scalar {
1029 ScalarType::Account => 0,
1030 ScalarType::Blob => 1,
1031 ScalarType::Bool => 2,
1032 ScalarType::Date => 3,
1033 ScalarType::Decimal => 4,
1034 ScalarType::Duration => 5,
1035 ScalarType::Enum => 6,
1036 ScalarType::E8s => 7,
1037 ScalarType::E18s => 8,
1038 ScalarType::Float32 => 9,
1039 ScalarType::Float64 => 10,
1040 ScalarType::Int => 11,
1041 ScalarType::Int128 => 12,
1042 ScalarType::IntBig => 13,
1043 ScalarType::Principal => 14,
1044 ScalarType::Subaccount => 15,
1045 ScalarType::Text => 16,
1046 ScalarType::Timestamp => 17,
1047 ScalarType::Uint => 18,
1048 ScalarType::Uint128 => 19,
1049 ScalarType::UintBig => 20,
1050 ScalarType::Ulid => 21,
1051 ScalarType::Unit => 22,
1052 }
1053 }
1054
1055 fn scalar_from_index(index: usize) -> Option<ScalarType> {
1057 let scalar = match index {
1058 0 => ScalarType::Account,
1059 1 => ScalarType::Blob,
1060 2 => ScalarType::Bool,
1061 3 => ScalarType::Date,
1062 4 => ScalarType::Decimal,
1063 5 => ScalarType::Duration,
1064 6 => ScalarType::Enum,
1065 7 => ScalarType::E8s,
1066 8 => ScalarType::E18s,
1067 9 => ScalarType::Float32,
1068 10 => ScalarType::Float64,
1069 11 => ScalarType::Int,
1070 12 => ScalarType::Int128,
1071 13 => ScalarType::IntBig,
1072 14 => ScalarType::Principal,
1073 15 => ScalarType::Subaccount,
1074 16 => ScalarType::Text,
1075 17 => ScalarType::Timestamp,
1076 18 => ScalarType::Uint,
1077 19 => ScalarType::Uint128,
1078 20 => ScalarType::UintBig,
1079 21 => ScalarType::Ulid,
1080 22 => ScalarType::Unit,
1081 _ => return None,
1082 };
1083
1084 Some(scalar)
1085 }
1086
1087 fn sample_value_for_scalar(scalar: ScalarType) -> Value {
1089 match scalar {
1090 ScalarType::Account => Value::Account(Account::dummy(1)),
1091 ScalarType::Blob => Value::Blob(vec![0u8, 1u8]),
1092 ScalarType::Bool => Value::Bool(true),
1093 ScalarType::Date => Value::Date(Date::EPOCH),
1094 ScalarType::Decimal => Value::Decimal(Decimal::ZERO),
1095 ScalarType::Duration => Value::Duration(Duration::ZERO),
1096 ScalarType::Enum => Value::Enum(ValueEnum::loose("example")),
1097 ScalarType::E8s => Value::E8s(E8s::from_atomic(0)),
1098 ScalarType::E18s => Value::E18s(E18s::from_atomic(0)),
1099 ScalarType::Float32 => {
1100 Value::Float32(Float32::try_new(0.0).expect("Float32 sample should be finite"))
1101 }
1102 ScalarType::Float64 => {
1103 Value::Float64(Float64::try_new(0.0).expect("Float64 sample should be finite"))
1104 }
1105 ScalarType::Int => Value::Int(0),
1106 ScalarType::Int128 => Value::Int128(Int128::from(0i128)),
1107 ScalarType::IntBig => Value::IntBig(Int::from(0i32)),
1108 ScalarType::Principal => Value::Principal(Principal::anonymous()),
1109 ScalarType::Subaccount => Value::Subaccount(Subaccount::dummy(2)),
1110 ScalarType::Text => Value::Text("text".to_string()),
1111 ScalarType::Timestamp => Value::Timestamp(Timestamp::EPOCH),
1112 ScalarType::Uint => Value::Uint(0),
1113 ScalarType::Uint128 => Value::Uint128(Nat128::from(0u128)),
1114 ScalarType::UintBig => Value::UintBig(Nat::from(0u64)),
1115 ScalarType::Ulid => Value::Ulid(Ulid::nil()),
1116 ScalarType::Unit => Value::Unit,
1117 }
1118 }
1119
1120 fn field(name: &'static str, kind: EntityFieldKind) -> EntityFieldModel {
1121 EntityFieldModel { name, kind }
1122 }
1123
1124 crate::test_entity_schema! {
1125 ScalarPredicateEntity,
1126 id = Ulid,
1127 path = "predicate_validate::ScalarEntity",
1128 entity_name = "ScalarEntity",
1129 primary_key = "id",
1130 pk_index = 0,
1131 fields = [
1132 ("id", EntityFieldKind::Ulid),
1133 ("email", EntityFieldKind::Text),
1134 ("age", EntityFieldKind::Uint),
1135 ("created_at", EntityFieldKind::Timestamp),
1136 ("active", EntityFieldKind::Bool),
1137 ],
1138 indexes = [],
1139 }
1140
1141 crate::test_entity_schema! {
1142 CollectionPredicateEntity,
1143 id = Ulid,
1144 path = "predicate_validate::CollectionEntity",
1145 entity_name = "CollectionEntity",
1146 primary_key = "id",
1147 pk_index = 0,
1148 fields = [
1149 ("id", EntityFieldKind::Ulid),
1150 ("tags", EntityFieldKind::List(&EntityFieldKind::Text)),
1151 ("principals", EntityFieldKind::Set(&EntityFieldKind::Principal)),
1152 (
1153 "attributes",
1154 EntityFieldKind::Map {
1155 key: &EntityFieldKind::Text,
1156 value: &EntityFieldKind::Uint,
1157 }
1158 ),
1159 ],
1160 indexes = [],
1161 }
1162
1163 crate::test_entity_schema! {
1164 NumericCoercionPredicateEntity,
1165 id = Ulid,
1166 path = "predicate_validate::NumericCoercionEntity",
1167 entity_name = "NumericCoercionEntity",
1168 primary_key = "id",
1169 pk_index = 0,
1170 fields = [
1171 ("id", EntityFieldKind::Ulid),
1172 ("date", EntityFieldKind::Date),
1173 ("int_big", EntityFieldKind::IntBig),
1174 ("uint_big", EntityFieldKind::UintBig),
1175 ("int_small", EntityFieldKind::Int),
1176 ("uint_small", EntityFieldKind::Uint),
1177 ("decimal", EntityFieldKind::Decimal),
1178 ("e8s", EntityFieldKind::E8s),
1179 ],
1180 indexes = [],
1181 }
1182
1183 #[test]
1184 fn validate_model_accepts_scalars_and_coercions() {
1185 let model = <ScalarPredicateEntity as EntitySchema>::MODEL;
1186
1187 let predicate = Predicate::And(vec![
1188 FieldRef::new("id").eq(Ulid::nil()),
1189 FieldRef::new("email").text_eq_ci("User@example.com"),
1190 FieldRef::new("age").lt(30u32),
1191 ]);
1192
1193 assert!(validate_model(model, &predicate).is_ok());
1194 }
1195
1196 #[test]
1197 fn validate_model_rejects_map_predicates() {
1198 let model = <CollectionPredicateEntity as EntitySchema>::MODEL;
1199
1200 let map_contains_builder =
1201 FieldRef::new("attributes").map_contains_entry("k", 1u64, CoercionId::Strict);
1202 assert!(matches!(
1203 map_contains_builder,
1204 Err(UnsupportedQueryFeature::MapPredicate { field }) if field == "attributes"
1205 ));
1206
1207 let map_contains_predicate = Predicate::MapContainsEntry {
1208 field: "attributes".to_string(),
1209 key: Value::Text("k".to_string()),
1210 value: Value::Uint(1),
1211 coercion: CoercionSpec::new(CoercionId::Strict),
1212 };
1213 assert!(matches!(
1214 validate_model(model, &map_contains_predicate),
1215 Err(ValidateError::UnsupportedQueryFeature(UnsupportedQueryFeature::MapPredicate { field }))
1216 if field == "attributes"
1217 ));
1218
1219 let map_presence = Predicate::IsMissing {
1220 field: "attributes".to_string(),
1221 };
1222 assert!(matches!(
1223 validate_model(model, &map_presence),
1224 Err(ValidateError::UnsupportedQueryFeature(UnsupportedQueryFeature::MapPredicate { field }))
1225 if field == "attributes"
1226 ));
1227 }
1228
1229 #[test]
1230 fn validate_model_accepts_deterministic_set_predicates() {
1231 let model = <CollectionPredicateEntity as EntitySchema>::MODEL;
1232
1233 let predicate = Predicate::Compare(ComparePredicate::with_coercion(
1234 "principals",
1235 CompareOp::Contains,
1236 Principal::anonymous().to_value(),
1237 CoercionId::Strict,
1238 ));
1239
1240 assert!(validate_model(model, &predicate).is_ok());
1241 }
1242
1243 #[test]
1244 fn validate_model_rejects_non_queryable_fields() {
1245 let model = InvalidEntityModelBuilder::from_fields(
1246 vec![
1247 field("id", EntityFieldKind::Ulid),
1248 field("broken", EntityFieldKind::Structured { queryable: false }),
1249 ],
1250 0,
1251 );
1252
1253 let predicate = FieldRef::new("broken").eq(1u64);
1254
1255 assert!(matches!(
1256 validate_model(&model, &predicate),
1257 Err(ValidateError::NonQueryableFieldType { field }) if field == "broken"
1258 ));
1259 }
1260
1261 #[test]
1262 fn validate_model_accepts_text_contains() {
1263 let model = <ScalarPredicateEntity as EntitySchema>::MODEL;
1264
1265 let predicate = FieldRef::new("email").text_contains("example");
1266 assert!(validate_model(model, &predicate).is_ok());
1267
1268 let predicate = FieldRef::new("email").text_contains_ci("EXAMPLE");
1269 assert!(validate_model(model, &predicate).is_ok());
1270 }
1271
1272 #[test]
1273 fn validate_model_rejects_text_contains_on_non_text() {
1274 let model = <ScalarPredicateEntity as EntitySchema>::MODEL;
1275
1276 let predicate = FieldRef::new("age").text_contains("1");
1277 assert!(matches!(
1278 validate_model(model, &predicate),
1279 Err(ValidateError::InvalidOperator { field, op })
1280 if field == "age" && op == "text_contains"
1281 ));
1282 }
1283
1284 #[test]
1285 fn validate_model_rejects_numeric_widen_for_registry_exclusions() {
1286 let model = <NumericCoercionPredicateEntity as EntitySchema>::MODEL;
1287
1288 let date_pred = FieldRef::new("date").lt(1i64);
1289 assert!(matches!(
1290 validate_model(model, &date_pred),
1291 Err(ValidateError::InvalidCoercion { field, coercion })
1292 if field == "date" && coercion == CoercionId::NumericWiden
1293 ));
1294
1295 let int_big_pred = FieldRef::new("int_big").lt(Int::from(1i32));
1296 assert!(matches!(
1297 validate_model(model, &int_big_pred),
1298 Err(ValidateError::InvalidCoercion { field, coercion })
1299 if field == "int_big" && coercion == CoercionId::NumericWiden
1300 ));
1301
1302 let uint_big_pred = FieldRef::new("uint_big").lt(Nat::from(1u64));
1303 assert!(matches!(
1304 validate_model(model, &uint_big_pred),
1305 Err(ValidateError::InvalidCoercion { field, coercion })
1306 if field == "uint_big" && coercion == CoercionId::NumericWiden
1307 ));
1308 }
1309
1310 #[test]
1311 fn validate_model_accepts_numeric_widen_for_registry_allowed_scalars() {
1312 let model = <NumericCoercionPredicateEntity as EntitySchema>::MODEL;
1313 let predicate = Predicate::And(vec![
1314 FieldRef::new("int_small").lt(9u64),
1315 FieldRef::new("uint_small").lt(9i64),
1316 FieldRef::new("decimal").lt(9u64),
1317 FieldRef::new("e8s").lt(9u64),
1318 ]);
1319
1320 assert!(validate_model(model, &predicate).is_ok());
1321 }
1322
1323 #[test]
1324 fn numeric_widen_authority_tracks_registry_flags() {
1325 for scalar in registry_scalars() {
1326 let field_type = FieldType::Scalar(scalar.clone());
1327 let literal = sample_value_for_scalar(scalar.clone());
1328 let expected = scalar.supports_numeric_coercion();
1329 let actual = ensure_coercion(
1330 "value",
1331 &field_type,
1332 &literal,
1333 &CoercionSpec::new(CoercionId::NumericWiden),
1334 )
1335 .is_ok();
1336
1337 assert_eq!(
1338 actual, expected,
1339 "numeric widen drift for scalar {scalar:?}: expected {expected}, got {actual}"
1340 );
1341 }
1342 }
1343
1344 #[test]
1345 fn numeric_widen_is_not_inferred_from_coercion_family() {
1346 let mut numeric_family_with_no_numeric_widen = 0usize;
1347
1348 for scalar in registry_scalars() {
1349 if scalar.coercion_family() != CoercionFamily::Numeric {
1350 continue;
1351 }
1352
1353 let field_type = FieldType::Scalar(scalar.clone());
1354 let literal = sample_value_for_scalar(scalar.clone());
1355 let numeric_widen_allowed = ensure_coercion(
1356 "value",
1357 &field_type,
1358 &literal,
1359 &CoercionSpec::new(CoercionId::NumericWiden),
1360 )
1361 .is_ok();
1362
1363 assert_eq!(
1364 numeric_widen_allowed,
1365 scalar.supports_numeric_coercion(),
1366 "numeric family must not imply numeric widen for scalar {scalar:?}"
1367 );
1368
1369 if !scalar.supports_numeric_coercion() {
1370 numeric_family_with_no_numeric_widen =
1371 numeric_family_with_no_numeric_widen.saturating_add(1);
1372 }
1373 }
1374
1375 assert!(
1376 numeric_family_with_no_numeric_widen > 0,
1377 "expected at least one numeric-family scalar without numeric widen support"
1378 );
1379 }
1380
1381 #[test]
1382 fn scalar_registry_covers_all_variants_exactly_once() {
1383 let scalars = registry_scalars();
1384 let mut names = BTreeSet::new();
1385 let mut seen = [false; SCALAR_TYPE_VARIANT_COUNT];
1386
1387 for scalar in scalars {
1388 let index = scalar_index(scalar.clone());
1389 assert!(!seen[index], "duplicate scalar entry: {scalar:?}");
1390 seen[index] = true;
1391
1392 let name = format!("{scalar:?}");
1393 assert!(names.insert(name.clone()), "duplicate scalar entry: {name}");
1394 }
1395
1396 let mut missing = Vec::new();
1397 for (index, was_seen) in seen.iter().enumerate() {
1398 if !*was_seen {
1399 let scalar = scalar_from_index(index).expect("index is in range");
1400 missing.push(format!("{scalar:?}"));
1401 }
1402 }
1403
1404 assert!(missing.is_empty(), "missing scalar entries: {missing:?}");
1405 assert_eq!(names.len(), SCALAR_TYPE_VARIANT_COUNT);
1406 }
1407
1408 #[test]
1409 fn scalar_keyability_matches_value_storage_key() {
1410 for scalar in registry_scalars() {
1411 let value = sample_value_for_scalar(scalar.clone());
1412 let scalar_keyable = scalar.is_keyable();
1413 let value_keyable = value.as_storage_key().is_some();
1414
1415 assert_eq!(
1416 value_keyable, scalar_keyable,
1417 "Value::as_storage_key drift for scalar {scalar:?}"
1418 );
1419 }
1420 }
1421}