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