Skip to main content

icydb_core/db/query/predicate/
validate.rs

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///
17/// ScalarType
18///
19/// Internal scalar classification used by predicate validation.
20/// This is deliberately *smaller* than the full schema/type system
21/// and exists only to support:
22/// - coercion rules
23/// - literal compatibility checks
24/// - operator validity (ordering, equality)
25///
26
27#[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
54// Local helpers to expand the scalar registry into match arms.
55macro_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        // Predicate-level ordering gate.
123        // Delegates to registry-backed supports_ordering.
124        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///
163/// FieldType
164///
165/// Reduced runtime type representation used exclusively for predicate validation.
166/// This intentionally drops:
167/// - record structure
168/// - tuple structure
169/// - validator/sanitizer metadata
170///
171
172#[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                // NOTE: Only map field types expose key/value type pairs.
229                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            // TODO(0.8): Lift this temporary guard once map runtime encoding is
292            // standardized end-to-end for query + index semantics.
293            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///
312/// SchemaInfo
313///
314/// Lightweight, runtime-usable field-type map for one entity.
315/// This is the *only* schema surface the predicate validator depends on.
316///
317
318#[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        // Validate identity constraints before building schema maps.
331        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/// Predicate/schema validation failures, including invalid model contracts.
383#[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
444/// Reject policy-level non-queryable features before planning.
445pub 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
597/// Validate list membership predicates.
598fn 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
620/// Validate collection containment predicates on list/set fields.
621fn 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        // CONTRACT: text substring matching uses TextContains/TextContainsCi only.
629        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        // CONTRACT: case-insensitive coercion never applies to structured values.
647        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
656/// Validate text prefix/suffix comparisons.
657fn 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
673// Ensure a field exists and is a map, returning key/value types.
674fn 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
728/// Validate substring predicates on text fields.
729fn 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
782// Reject invalid case-insensitive coercions for non-text comparisons.
783fn 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
794// Ensure the literal is text to match text-only operators.
795fn 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
803// Reject list literals when scalar comparisons are required.
804fn 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        // CONTRACT: case-insensitive coercions are text-only.
820        return Err(ValidateError::InvalidCoercion {
821            field: field.to_string(),
822            coercion: coercion.id,
823        });
824    }
825
826    // NOTE:
827    // NumericWiden eligibility is registry-authoritative.
828    // CoercionFamily::Numeric is intentionally NOT sufficient.
829    // This prevents validation/runtime divergence for Date, IntBig, UintBig.
830    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            // NOTE: non-queryable structured field types never match predicate literals.
921            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///
981/// TESTS
982///
983
984#[cfg(test)]
985mod tests {
986    // NOTE: Invalid helpers remain only for intentionally invalid or non-queryable schemas.
987    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    /// Build a registry-driven list of all scalar variants.
1008    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    /// Returns the total count of ScalarType variants.
1024    const SCALAR_TYPE_VARIANT_COUNT: usize = 23;
1025
1026    /// Map each ScalarType variant to a stable index.
1027    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    /// Return every ScalarType variant by index, ensuring exhaustiveness.
1056    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    /// Build a representative value for each scalar variant.
1088    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}