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    value::{CoercionFamily, CoercionFamilyExt, Value},
9};
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    fmt,
13};
14
15///
16/// ScalarType
17///
18/// Internal scalar classification used by predicate validation.
19/// This is deliberately *smaller* than the full schema/type system
20/// and exists only to support:
21/// - coercion rules
22/// - literal compatibility checks
23/// - operator validity (ordering, equality)
24///
25
26#[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
53// Local helpers to expand the scalar registry into match arms.
54macro_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        // Predicate-level ordering gate.
122        // Delegates to registry-backed supports_ordering.
123        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///
162/// FieldType
163///
164/// Reduced runtime type representation used exclusively for predicate validation.
165/// This intentionally drops:
166/// - record structure
167/// - tuple structure
168/// - validator/sanitizer metadata
169///
170
171#[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                // NOTE: Only map field types expose key/value type pairs.
216                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            // TODO(0.8): Lift this temporary guard once map runtime encoding is
279            // standardized end-to-end for query + index semantics.
280            if matches!(field_type, FieldType::Map { .. }) {
281                return Err(ValidateError::IndexFieldMapUnsupported {
282                    index: **index,
283                    field: (*field).to_string(),
284                });
285            }
286            // Indexing is hash-based across all Value variants; only Unsupported is rejected here.
287            // Collisions are detected during unique enforcement and lookups.
288            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///
301/// SchemaInfo
302///
303/// Lightweight, runtime-usable field-type map for one entity.
304/// This is the *only* schema surface the predicate validator depends on.
305///
306
307#[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        // Validate identity constraints before building schema maps.
320        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/// Predicate/schema validation failures, including invalid model contracts.
372#[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
433/// Reject policy-level unsupported features before planning.
434pub 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            // CONTRACT: presence checks are the only predicates allowed on unsupported fields.
478            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
594/// Validate list membership predicates.
595fn 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
617/// Validate collection containment predicates on list/set fields.
618fn 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        // CONTRACT: text substring matching uses TextContains/TextContainsCi only.
626        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        // CONTRACT: case-insensitive coercion never applies to structured values.
644        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
653/// Validate text prefix/suffix comparisons.
654fn 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
670// Ensure a field exists and is a map, returning key/value types.
671fn 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
725/// Validate substring predicates on text fields.
726fn 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
790// Reject unsupported case-insensitive coercions for non-text comparisons.
791fn 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
802// Ensure the literal is text to match text-only operators.
803fn 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
811// Reject list literals when scalar comparisons are required.
812fn 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        // CONTRACT: case-insensitive coercions are text-only.
828        return Err(ValidateError::InvalidCoercion {
829            field: field.to_string(),
830            coercion: coercion.id,
831        });
832    }
833
834    // NOTE:
835    // NumericWiden eligibility is registry-authoritative.
836    // CoercionFamily::Numeric is intentionally NOT sufficient.
837    // This prevents validation/runtime divergence for Date, IntBig, UintBig.
838    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            // NOTE: Unsupported field types never match predicate literals.
929            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///
985/// TESTS
986///
987
988#[cfg(test)]
989mod tests {
990    // NOTE: Invalid helpers remain only for intentionally invalid or unsupported schemas.
991    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    /// Build a registry-driven list of all scalar variants.
1012    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    /// Returns the total count of ScalarType variants.
1028    const SCALAR_TYPE_VARIANT_COUNT: usize = 23;
1029
1030    /// Map each ScalarType variant to a stable index.
1031    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    /// Return every ScalarType variant by index, ensuring exhaustiveness.
1060    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    /// Build a representative value for each scalar variant.
1092    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}