Skip to main content

icydb_core/db/query/predicate/
validate.rs

1use crate::{
2    db::identity::{EntityName, EntityNameError, IndexName, IndexNameError},
3    model::{entity::EntityModel, field::EntityFieldKind, index::IndexModel},
4    value::{Value, ValueFamily, ValueFamilyExt},
5};
6use icydb_schema::{
7    node::{
8        Entity, Enum, Item, ItemTarget, List, Map, Newtype, Record, Schema, Set, Tuple,
9        Value as SValue,
10    },
11    types::{Cardinality, Primitive},
12};
13use std::{
14    collections::{BTreeMap, BTreeSet},
15    fmt,
16};
17
18use super::{
19    ast::{CompareOp, ComparePredicate, Predicate},
20    coercion::{CoercionId, CoercionSpec, supports_coercion},
21};
22
23#[cfg(test)]
24use std::cell::Cell;
25
26#[cfg(test)]
27thread_local! {
28    static SCHEMA_LOOKUP_CALLED: Cell<bool> = const { Cell::new(false) };
29}
30
31#[cfg(test)]
32pub(crate) fn reset_schema_lookup_called() {
33    SCHEMA_LOOKUP_CALLED.with(|flag| flag.set(false));
34}
35
36#[cfg(test)]
37pub(crate) fn schema_lookup_called() -> bool {
38    SCHEMA_LOOKUP_CALLED.with(Cell::get)
39}
40
41///
42/// ScalarType
43///
44/// Internal scalar classification used by predicate validation.
45/// This is deliberately *smaller* than the full schema/type system
46/// and exists only to support:
47/// - coercion rules
48/// - literal compatibility checks
49/// - operator validity (ordering, equality)
50///
51
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub(crate) enum ScalarType {
54    Account,
55    Blob,
56    Bool,
57    Date,
58    Decimal,
59    Duration,
60    Enum,
61    E8s,
62    E18s,
63    Float32,
64    Float64,
65    Int,
66    Int128,
67    IntBig,
68    Principal,
69    Subaccount,
70    Text,
71    Timestamp,
72    Uint,
73    Uint128,
74    UintBig,
75    Ulid,
76    Unit,
77}
78
79impl ScalarType {
80    #[must_use]
81    pub const fn family(&self) -> ValueFamily {
82        match self {
83            Self::Text => ValueFamily::Textual,
84            Self::Ulid | Self::Principal | Self::Account => ValueFamily::Identifier,
85            Self::Enum => ValueFamily::Enum,
86            Self::Blob | Self::Subaccount => ValueFamily::Blob,
87            Self::Bool => ValueFamily::Bool,
88            Self::Unit => ValueFamily::Unit,
89            Self::Date
90            | Self::Decimal
91            | Self::Duration
92            | Self::E8s
93            | Self::E18s
94            | Self::Float32
95            | Self::Float64
96            | Self::Int
97            | Self::Int128
98            | Self::IntBig
99            | Self::Timestamp
100            | Self::Uint
101            | Self::Uint128
102            | Self::UintBig => ValueFamily::Numeric,
103        }
104    }
105
106    #[must_use]
107    pub const fn is_orderable(&self) -> bool {
108        !matches!(self, Self::Blob | Self::Unit)
109    }
110
111    #[must_use]
112    pub const fn matches_value(&self, value: &Value) -> bool {
113        matches!(
114            (self, value),
115            (Self::Account, Value::Account(_))
116                | (Self::Blob, Value::Blob(_))
117                | (Self::Bool, Value::Bool(_))
118                | (Self::Date, Value::Date(_))
119                | (Self::Decimal, Value::Decimal(_))
120                | (Self::Duration, Value::Duration(_))
121                | (Self::Enum, Value::Enum(_))
122                | (Self::E8s, Value::E8s(_))
123                | (Self::E18s, Value::E18s(_))
124                | (Self::Float32, Value::Float32(_))
125                | (Self::Float64, Value::Float64(_))
126                | (Self::Int, Value::Int(_))
127                | (Self::Int128, Value::Int128(_))
128                | (Self::IntBig, Value::IntBig(_))
129                | (Self::Principal, Value::Principal(_))
130                | (Self::Subaccount, Value::Subaccount(_))
131                | (Self::Text, Value::Text(_))
132                | (Self::Timestamp, Value::Timestamp(_))
133                | (Self::Uint, Value::Uint(_))
134                | (Self::Uint128, Value::Uint128(_))
135                | (Self::UintBig, Value::UintBig(_))
136                | (Self::Ulid, Value::Ulid(_))
137                | (Self::Unit, Value::Unit)
138        )
139    }
140}
141
142///
143/// FieldType
144///
145/// Reduced runtime type representation used exclusively for predicate validation.
146/// This intentionally drops:
147/// - record structure
148/// - tuple structure
149/// - validator/sanitizer metadata
150///
151
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub(crate) enum FieldType {
154    Scalar(ScalarType),
155    List(Box<Self>),
156    Set(Box<Self>),
157    Map { key: Box<Self>, value: Box<Self> },
158    Unsupported,
159}
160
161impl FieldType {
162    #[must_use]
163    pub const fn family(&self) -> Option<ValueFamily> {
164        match self {
165            Self::Scalar(inner) => Some(inner.family()),
166            Self::List(_) | Self::Set(_) | Self::Map { .. } => Some(ValueFamily::Collection),
167            Self::Unsupported => None,
168        }
169    }
170
171    #[must_use]
172    pub const fn is_text(&self) -> bool {
173        matches!(self, Self::Scalar(ScalarType::Text))
174    }
175
176    #[must_use]
177    pub const fn is_collection(&self) -> bool {
178        matches!(self, Self::List(_) | Self::Set(_) | Self::Map { .. })
179    }
180
181    #[must_use]
182    pub const fn is_list_like(&self) -> bool {
183        matches!(self, Self::List(_) | Self::Set(_))
184    }
185
186    #[must_use]
187    pub const fn is_map(&self) -> bool {
188        matches!(self, Self::Map { .. })
189    }
190
191    #[must_use]
192    pub fn map_types(&self) -> Option<(&Self, &Self)> {
193        match self {
194            Self::Map { key, value } => Some((key.as_ref(), value.as_ref())),
195            _ => None,
196        }
197    }
198
199    #[must_use]
200    pub const fn is_orderable(&self) -> bool {
201        match self {
202            Self::Scalar(inner) => inner.is_orderable(),
203            _ => false,
204        }
205    }
206
207    #[must_use]
208    pub const fn is_keyable(&self) -> bool {
209        matches!(
210            self,
211            Self::Scalar(
212                ScalarType::Account
213                    | ScalarType::Int
214                    | ScalarType::Principal
215                    | ScalarType::Subaccount
216                    | ScalarType::Timestamp
217                    | ScalarType::Uint
218                    | ScalarType::Ulid
219                    | ScalarType::Unit
220            )
221        )
222    }
223}
224
225fn validate_index_fields(
226    fields: &BTreeMap<String, FieldType>,
227    indexes: &[&IndexModel],
228) -> Result<(), ValidateError> {
229    let mut seen_names = BTreeSet::new();
230    for index in indexes {
231        if seen_names.contains(index.name) {
232            return Err(ValidateError::DuplicateIndexName {
233                name: index.name.to_string(),
234            });
235        }
236        seen_names.insert(index.name);
237
238        let mut seen = BTreeSet::new();
239        for field in index.fields {
240            if !fields.contains_key(*field) {
241                return Err(ValidateError::IndexFieldUnknown {
242                    index: **index,
243                    field: (*field).to_string(),
244                });
245            }
246            if seen.contains(*field) {
247                return Err(ValidateError::IndexFieldDuplicate {
248                    index: **index,
249                    field: (*field).to_string(),
250                });
251            }
252            seen.insert(*field);
253
254            let field_type = fields
255                .get(*field)
256                .expect("index field existence checked above");
257            // Indexing is hash-based across all Value variants; only Unsupported is rejected here.
258            // Collisions are detected during unique enforcement and lookups.
259            if matches!(field_type, FieldType::Unsupported) {
260                return Err(ValidateError::IndexFieldUnsupported {
261                    index: **index,
262                    field: (*field).to_string(),
263                });
264            }
265        }
266    }
267
268    Ok(())
269}
270
271///
272/// SchemaInfo
273///
274/// Lightweight, runtime-usable field-type map for one entity.
275/// This is the *only* schema surface the predicate validator depends on.
276///
277
278#[derive(Clone, Debug)]
279pub struct SchemaInfo {
280    fields: BTreeMap<String, FieldType>,
281}
282
283impl SchemaInfo {
284    #[must_use]
285    #[expect(dead_code)]
286    pub(crate) fn new(fields: impl IntoIterator<Item = (String, FieldType)>) -> Self {
287        Self {
288            fields: fields.into_iter().collect(),
289        }
290    }
291
292    #[must_use]
293    pub(crate) fn field(&self, name: &str) -> Option<&FieldType> {
294        self.fields.get(name)
295    }
296
297    #[must_use]
298    pub fn from_entity_schema(entity: &Entity, schema: &Schema) -> Self {
299        let fields = entity
300            .fields
301            .fields
302            .iter()
303            .map(|field| {
304                let ty = field_type_from_value(&field.value, schema);
305                (field.ident.to_string(), ty)
306            })
307            .collect::<BTreeMap<_, _>>();
308
309        Self { fields }
310    }
311
312    pub fn from_entity_model(model: &EntityModel) -> Result<Self, ValidateError> {
313        // Validate identity constraints before building schema maps.
314        let entity_name = EntityName::try_from_str(model.entity_name).map_err(|err| {
315            ValidateError::InvalidEntityName {
316                name: model.entity_name.to_string(),
317                source: err,
318            }
319        })?;
320
321        if !model
322            .fields
323            .iter()
324            .any(|field| std::ptr::eq(field, model.primary_key))
325        {
326            return Err(ValidateError::InvalidPrimaryKey {
327                field: model.primary_key.name.to_string(),
328            });
329        }
330
331        let mut fields = BTreeMap::new();
332        for field in model.fields {
333            if fields.contains_key(field.name) {
334                return Err(ValidateError::DuplicateField {
335                    field: field.name.to_string(),
336                });
337            }
338            let ty = field_type_from_model_kind(&field.kind);
339            fields.insert(field.name.to_string(), ty);
340        }
341
342        let pk_field_type = fields
343            .get(model.primary_key.name)
344            .expect("primary key verified above");
345        if !pk_field_type.is_keyable() {
346            return Err(ValidateError::InvalidPrimaryKeyType {
347                field: model.primary_key.name.to_string(),
348            });
349        }
350
351        validate_index_fields(&fields, model.indexes)?;
352        for index in model.indexes {
353            IndexName::try_from_parts(&entity_name, index.fields).map_err(|err| {
354                ValidateError::InvalidIndexName {
355                    index: **index,
356                    source: err,
357                }
358            })?;
359        }
360
361        Ok(Self { fields })
362    }
363}
364
365/// Predicate/schema validation failures, including invalid model contracts.
366#[derive(Debug, thiserror::Error)]
367pub enum ValidateError {
368    #[error("invalid entity name '{name}': {source}")]
369    InvalidEntityName {
370        name: String,
371        #[source]
372        source: EntityNameError,
373    },
374
375    #[error("invalid index name for '{index}': {source}")]
376    InvalidIndexName {
377        index: IndexModel,
378        #[source]
379        source: IndexNameError,
380    },
381
382    #[error("unknown field '{field}'")]
383    UnknownField { field: String },
384
385    #[error("unsupported field type for '{field}'")]
386    UnsupportedFieldType { field: String },
387
388    #[error("duplicate field '{field}'")]
389    DuplicateField { field: String },
390
391    #[error("primary key '{field}' not present in entity fields")]
392    InvalidPrimaryKey { field: String },
393
394    #[error("primary key '{field}' has an unsupported type")]
395    InvalidPrimaryKeyType { field: String },
396
397    #[error("index '{index}' references unknown field '{field}'")]
398    IndexFieldUnknown { index: IndexModel, field: String },
399
400    #[error("index '{index}' references unsupported field '{field}'")]
401    IndexFieldUnsupported { index: IndexModel, field: String },
402
403    #[error("index '{index}' repeats field '{field}'")]
404    IndexFieldDuplicate { index: IndexModel, field: String },
405
406    #[error("duplicate index name '{name}'")]
407    DuplicateIndexName { name: String },
408
409    #[error("operator {op} is not valid for field '{field}'")]
410    InvalidOperator { field: String, op: String },
411
412    #[error("coercion {coercion:?} is not valid for field '{field}'")]
413    InvalidCoercion { field: String, coercion: CoercionId },
414
415    #[error("invalid literal for field '{field}': {message}")]
416    InvalidLiteral { field: String, message: String },
417
418    #[error("schema unavailable: {0}")]
419    SchemaUnavailable(String),
420}
421
422pub fn validate(schema: &SchemaInfo, predicate: &Predicate) -> Result<(), ValidateError> {
423    match predicate {
424        Predicate::True | Predicate::False => Ok(()),
425        Predicate::And(children) | Predicate::Or(children) => {
426            for child in children {
427                validate(schema, child)?;
428            }
429            Ok(())
430        }
431        Predicate::Not(inner) => validate(schema, inner),
432        Predicate::Compare(cmp) => validate_compare(schema, cmp),
433        Predicate::IsNull { field } | Predicate::IsMissing { field } => {
434            // CONTRACT: presence checks are the only predicates allowed on unsupported fields.
435            ensure_field_exists(schema, field).map(|_| ())
436        }
437        Predicate::IsEmpty { field } => {
438            let field_type = ensure_field(schema, field)?;
439            if field_type.is_text() || field_type.is_collection() {
440                Ok(())
441            } else {
442                Err(ValidateError::InvalidOperator {
443                    field: field.clone(),
444                    op: "is_empty".to_string(),
445                })
446            }
447        }
448        Predicate::IsNotEmpty { field } => {
449            let field_type = ensure_field(schema, field)?;
450            if field_type.is_text() || field_type.is_collection() {
451                Ok(())
452            } else {
453                Err(ValidateError::InvalidOperator {
454                    field: field.clone(),
455                    op: "is_not_empty".to_string(),
456                })
457            }
458        }
459        Predicate::MapContainsKey {
460            field,
461            key,
462            coercion,
463        } => validate_map_key(schema, field, key, coercion),
464        Predicate::MapContainsValue {
465            field,
466            value,
467            coercion,
468        } => validate_map_value(schema, field, value, coercion),
469        Predicate::MapContainsEntry {
470            field,
471            key,
472            value,
473            coercion,
474        } => validate_map_entry(schema, field, key, value, coercion),
475        Predicate::TextContains { field, value } => {
476            validate_text_contains(schema, field, value, "text_contains")
477        }
478        Predicate::TextContainsCi { field, value } => {
479            validate_text_contains(schema, field, value, "text_contains_ci")
480        }
481    }
482}
483
484pub fn validate_model(model: &EntityModel, predicate: &Predicate) -> Result<(), ValidateError> {
485    let schema = SchemaInfo::from_entity_model(model)?;
486    validate(&schema, predicate)
487}
488
489fn validate_compare(schema: &SchemaInfo, cmp: &ComparePredicate) -> Result<(), ValidateError> {
490    let field_type = ensure_field(schema, &cmp.field)?;
491
492    match cmp.op {
493        CompareOp::Eq | CompareOp::Ne => {
494            validate_eq_ne(&cmp.field, field_type, &cmp.value, &cmp.coercion)
495        }
496        CompareOp::Lt | CompareOp::Lte | CompareOp::Gt | CompareOp::Gte => {
497            validate_ordering(&cmp.field, field_type, &cmp.value, &cmp.coercion, cmp.op)
498        }
499        CompareOp::In => validate_in(&cmp.field, field_type, &cmp.value, &cmp.coercion),
500        CompareOp::NotIn | CompareOp::Contains | CompareOp::StartsWith | CompareOp::EndsWith => {
501            // CONTRACT: legacy comparison ops are rejected for public predicates.
502            Err(ValidateError::InvalidOperator {
503                field: cmp.field.clone(),
504                op: format!("{:?}", cmp.op),
505            })
506        }
507    }
508}
509
510fn validate_eq_ne(
511    field: &str,
512    field_type: &FieldType,
513    value: &Value,
514    coercion: &CoercionSpec,
515) -> Result<(), ValidateError> {
516    if matches!(coercion.id, CoercionId::TextCasefold) && !field_type.is_text() {
517        return Err(ValidateError::InvalidCoercion {
518            field: field.to_string(),
519            coercion: coercion.id,
520        });
521    }
522
523    if field_type.is_list_like() {
524        ensure_list_literal(field, value, field_type)?;
525    } else if field_type.is_map() {
526        ensure_map_literal(field, value, field_type)?;
527    } else if matches!(value, Value::List(_)) {
528        return Err(ValidateError::InvalidLiteral {
529            field: field.to_string(),
530            message: "expected scalar literal".to_string(),
531        });
532    }
533
534    ensure_coercion(field, field_type, value, coercion)
535}
536
537fn validate_ordering(
538    field: &str,
539    field_type: &FieldType,
540    value: &Value,
541    coercion: &CoercionSpec,
542    op: CompareOp,
543) -> Result<(), ValidateError> {
544    if matches!(coercion.id, CoercionId::CollectionElement) {
545        return Err(ValidateError::InvalidCoercion {
546            field: field.to_string(),
547            coercion: coercion.id,
548        });
549    }
550
551    if !field_type.is_orderable() {
552        return Err(ValidateError::InvalidOperator {
553            field: field.to_string(),
554            op: format!("{op:?}"),
555        });
556    }
557
558    if matches!(value, Value::List(_)) {
559        return Err(ValidateError::InvalidLiteral {
560            field: field.to_string(),
561            message: "expected scalar literal".to_string(),
562        });
563    }
564
565    ensure_coercion(field, field_type, value, coercion)
566}
567
568fn validate_in(
569    field: &str,
570    field_type: &FieldType,
571    value: &Value,
572    coercion: &CoercionSpec,
573) -> Result<(), ValidateError> {
574    if matches!(coercion.id, CoercionId::TextCasefold) && !field_type.is_text() {
575        return Err(ValidateError::InvalidCoercion {
576            field: field.to_string(),
577            coercion: coercion.id,
578        });
579    }
580
581    if field_type.is_collection() {
582        return Err(ValidateError::InvalidOperator {
583            field: field.to_string(),
584            op: format!("{:?}", CompareOp::In),
585        });
586    }
587
588    let Value::List(items) = value else {
589        return Err(ValidateError::InvalidLiteral {
590            field: field.to_string(),
591            message: "expected list literal".to_string(),
592        });
593    };
594
595    for item in items {
596        ensure_coercion(field, field_type, item, coercion)?;
597    }
598
599    Ok(())
600}
601
602fn validate_map_key(
603    schema: &SchemaInfo,
604    field: &str,
605    key: &Value,
606    coercion: &CoercionSpec,
607) -> Result<(), ValidateError> {
608    if matches!(coercion.id, CoercionId::TextCasefold) {
609        return Err(ValidateError::InvalidCoercion {
610            field: field.to_string(),
611            coercion: coercion.id,
612        });
613    }
614
615    let field_type = ensure_field(schema, field)?;
616    let (key_type, _) = field_type
617        .map_types()
618        .ok_or_else(|| ValidateError::InvalidOperator {
619            field: field.to_string(),
620            op: "map_contains_key".to_string(),
621        })?;
622
623    ensure_coercion(field, key_type, key, coercion)
624}
625
626fn validate_map_value(
627    schema: &SchemaInfo,
628    field: &str,
629    value: &Value,
630    coercion: &CoercionSpec,
631) -> Result<(), ValidateError> {
632    if matches!(coercion.id, CoercionId::TextCasefold) {
633        return Err(ValidateError::InvalidCoercion {
634            field: field.to_string(),
635            coercion: coercion.id,
636        });
637    }
638
639    let field_type = ensure_field(schema, field)?;
640    let (_, value_type) = field_type
641        .map_types()
642        .ok_or_else(|| ValidateError::InvalidOperator {
643            field: field.to_string(),
644            op: "map_contains_value".to_string(),
645        })?;
646
647    ensure_coercion(field, value_type, value, coercion)
648}
649
650fn validate_map_entry(
651    schema: &SchemaInfo,
652    field: &str,
653    key: &Value,
654    value: &Value,
655    coercion: &CoercionSpec,
656) -> Result<(), ValidateError> {
657    if matches!(coercion.id, CoercionId::TextCasefold) {
658        return Err(ValidateError::InvalidCoercion {
659            field: field.to_string(),
660            coercion: coercion.id,
661        });
662    }
663
664    let field_type = ensure_field(schema, field)?;
665    let (key_type, value_type) =
666        field_type
667            .map_types()
668            .ok_or_else(|| ValidateError::InvalidOperator {
669                field: field.to_string(),
670                op: "map_contains_entry".to_string(),
671            })?;
672
673    ensure_coercion(field, key_type, key, coercion)?;
674    ensure_coercion(field, value_type, value, coercion)?;
675
676    Ok(())
677}
678
679/// Validate substring predicates on text fields.
680fn validate_text_contains(
681    schema: &SchemaInfo,
682    field: &str,
683    value: &Value,
684    op: &str,
685) -> Result<(), ValidateError> {
686    let field_type = ensure_field(schema, field)?;
687    if !field_type.is_text() {
688        return Err(ValidateError::InvalidOperator {
689            field: field.to_string(),
690            op: op.to_string(),
691        });
692    }
693
694    if !matches!(value, Value::Text(_)) {
695        return Err(ValidateError::InvalidLiteral {
696            field: field.to_string(),
697            message: "expected text literal".to_string(),
698        });
699    }
700
701    Ok(())
702}
703
704fn ensure_field<'a>(schema: &'a SchemaInfo, field: &str) -> Result<&'a FieldType, ValidateError> {
705    let field_type = schema
706        .field(field)
707        .ok_or_else(|| ValidateError::UnknownField {
708            field: field.to_string(),
709        })?;
710
711    if matches!(field_type, FieldType::Unsupported) {
712        return Err(ValidateError::UnsupportedFieldType {
713            field: field.to_string(),
714        });
715    }
716
717    Ok(field_type)
718}
719
720fn ensure_field_exists<'a>(
721    schema: &'a SchemaInfo,
722    field: &str,
723) -> Result<&'a FieldType, ValidateError> {
724    schema
725        .field(field)
726        .ok_or_else(|| ValidateError::UnknownField {
727            field: field.to_string(),
728        })
729}
730
731fn ensure_coercion(
732    field: &str,
733    field_type: &FieldType,
734    literal: &Value,
735    coercion: &CoercionSpec,
736) -> Result<(), ValidateError> {
737    let left_family = field_type
738        .family()
739        .ok_or_else(|| ValidateError::UnsupportedFieldType {
740            field: field.to_string(),
741        })?;
742    let right_family = literal.family();
743
744    if !supports_coercion(left_family, right_family, coercion.id) {
745        return Err(ValidateError::InvalidCoercion {
746            field: field.to_string(),
747            coercion: coercion.id,
748        });
749    }
750
751    if matches!(
752        coercion.id,
753        CoercionId::Strict | CoercionId::CollectionElement
754    ) && !literal_matches_type(literal, field_type)
755    {
756        return Err(ValidateError::InvalidLiteral {
757            field: field.to_string(),
758            message: "literal type does not match field type".to_string(),
759        });
760    }
761
762    Ok(())
763}
764
765fn ensure_list_literal(
766    field: &str,
767    literal: &Value,
768    field_type: &FieldType,
769) -> Result<(), ValidateError> {
770    if !literal_matches_type(literal, field_type) {
771        return Err(ValidateError::InvalidLiteral {
772            field: field.to_string(),
773            message: "list literal does not match field element type".to_string(),
774        });
775    }
776
777    Ok(())
778}
779
780fn ensure_map_literal(
781    field: &str,
782    literal: &Value,
783    field_type: &FieldType,
784) -> Result<(), ValidateError> {
785    if !literal_matches_type(literal, field_type) {
786        return Err(ValidateError::InvalidLiteral {
787            field: field.to_string(),
788            message: "map literal does not match field key/value types".to_string(),
789        });
790    }
791
792    Ok(())
793}
794
795pub(crate) fn literal_matches_type(literal: &Value, field_type: &FieldType) -> bool {
796    match field_type {
797        FieldType::Scalar(inner) => inner.matches_value(literal),
798        FieldType::List(element) | FieldType::Set(element) => match literal {
799            Value::List(items) => items.iter().all(|item| literal_matches_type(item, element)),
800            _ => false,
801        },
802        FieldType::Map { key, value } => match literal {
803            Value::List(entries) => entries.iter().all(|entry| match entry {
804                Value::List(pair) if pair.len() == 2 => {
805                    literal_matches_type(&pair[0], key) && literal_matches_type(&pair[1], value)
806                }
807                _ => false,
808            }),
809            _ => false,
810        },
811        FieldType::Unsupported => false,
812    }
813}
814
815fn field_type_from_value(value: &SValue, schema: &Schema) -> FieldType {
816    let base = field_type_from_item(&value.item, schema);
817
818    match value.cardinality {
819        Cardinality::Many => FieldType::List(Box::new(base)),
820        Cardinality::One | Cardinality::Opt => base,
821    }
822}
823
824fn field_type_from_item(item: &Item, schema: &Schema) -> FieldType {
825    match &item.target {
826        ItemTarget::Primitive(prim) => FieldType::Scalar(scalar_from_primitive(*prim)),
827        ItemTarget::Is(path) => {
828            if schema.cast_node::<Enum>(path).is_ok() {
829                return FieldType::Scalar(ScalarType::Enum);
830            }
831            if let Ok(node) = schema.cast_node::<Newtype>(path) {
832                return field_type_from_item(&node.item, schema);
833            }
834            if let Ok(node) = schema.cast_node::<List>(path) {
835                return FieldType::List(Box::new(field_type_from_item(&node.item, schema)));
836            }
837            if let Ok(node) = schema.cast_node::<Set>(path) {
838                return FieldType::Set(Box::new(field_type_from_item(&node.item, schema)));
839            }
840            if let Ok(node) = schema.cast_node::<Map>(path) {
841                let key = field_type_from_item(&node.key, schema);
842                let value = field_type_from_value(&node.value, schema);
843                return FieldType::Map {
844                    key: Box::new(key),
845                    value: Box::new(value),
846                };
847            }
848            if schema.cast_node::<Record>(path).is_ok() {
849                return FieldType::Unsupported;
850            }
851            if schema.cast_node::<Tuple>(path).is_ok() {
852                return FieldType::Unsupported;
853            }
854
855            FieldType::Unsupported
856        }
857    }
858}
859
860const fn scalar_from_primitive(prim: Primitive) -> ScalarType {
861    match prim {
862        Primitive::Account => ScalarType::Account,
863        Primitive::Blob => ScalarType::Blob,
864        Primitive::Bool => ScalarType::Bool,
865        Primitive::Date => ScalarType::Date,
866        Primitive::Decimal => ScalarType::Decimal,
867        Primitive::Duration => ScalarType::Duration,
868        Primitive::E8s => ScalarType::E8s,
869        Primitive::E18s => ScalarType::E18s,
870        Primitive::Float32 => ScalarType::Float32,
871        Primitive::Float64 => ScalarType::Float64,
872        Primitive::Int => ScalarType::IntBig,
873        Primitive::Int8 | Primitive::Int16 | Primitive::Int32 | Primitive::Int64 => ScalarType::Int,
874        Primitive::Int128 => ScalarType::Int128,
875        Primitive::Nat => ScalarType::UintBig,
876        Primitive::Nat8 | Primitive::Nat16 | Primitive::Nat32 | Primitive::Nat64 => {
877            ScalarType::Uint
878        }
879        Primitive::Nat128 => ScalarType::Uint128,
880        Primitive::Principal => ScalarType::Principal,
881        Primitive::Subaccount => ScalarType::Subaccount,
882        Primitive::Text => ScalarType::Text,
883        Primitive::Timestamp => ScalarType::Timestamp,
884        Primitive::Ulid => ScalarType::Ulid,
885        Primitive::Unit => ScalarType::Unit,
886    }
887}
888
889fn field_type_from_model_kind(kind: &EntityFieldKind) -> FieldType {
890    match kind {
891        EntityFieldKind::Account => FieldType::Scalar(ScalarType::Account),
892        EntityFieldKind::Blob => FieldType::Scalar(ScalarType::Blob),
893        EntityFieldKind::Bool => FieldType::Scalar(ScalarType::Bool),
894        EntityFieldKind::Date => FieldType::Scalar(ScalarType::Date),
895        EntityFieldKind::Decimal => FieldType::Scalar(ScalarType::Decimal),
896        EntityFieldKind::Duration => FieldType::Scalar(ScalarType::Duration),
897        EntityFieldKind::Enum => FieldType::Scalar(ScalarType::Enum),
898        EntityFieldKind::E8s => FieldType::Scalar(ScalarType::E8s),
899        EntityFieldKind::E18s => FieldType::Scalar(ScalarType::E18s),
900        EntityFieldKind::Float32 => FieldType::Scalar(ScalarType::Float32),
901        EntityFieldKind::Float64 => FieldType::Scalar(ScalarType::Float64),
902        EntityFieldKind::Int => FieldType::Scalar(ScalarType::Int),
903        EntityFieldKind::Int128 => FieldType::Scalar(ScalarType::Int128),
904        EntityFieldKind::IntBig => FieldType::Scalar(ScalarType::IntBig),
905        EntityFieldKind::Principal => FieldType::Scalar(ScalarType::Principal),
906        EntityFieldKind::Subaccount => FieldType::Scalar(ScalarType::Subaccount),
907        EntityFieldKind::Text => FieldType::Scalar(ScalarType::Text),
908        EntityFieldKind::Timestamp => FieldType::Scalar(ScalarType::Timestamp),
909        EntityFieldKind::Uint => FieldType::Scalar(ScalarType::Uint),
910        EntityFieldKind::Uint128 => FieldType::Scalar(ScalarType::Uint128),
911        EntityFieldKind::UintBig => FieldType::Scalar(ScalarType::UintBig),
912        EntityFieldKind::Ulid => FieldType::Scalar(ScalarType::Ulid),
913        EntityFieldKind::Unit => FieldType::Scalar(ScalarType::Unit),
914        EntityFieldKind::List(inner) => {
915            FieldType::List(Box::new(field_type_from_model_kind(inner)))
916        }
917        EntityFieldKind::Set(inner) => FieldType::Set(Box::new(field_type_from_model_kind(inner))),
918        EntityFieldKind::Map { key, value } => FieldType::Map {
919            key: Box::new(field_type_from_model_kind(key)),
920            value: Box::new(field_type_from_model_kind(value)),
921        },
922        EntityFieldKind::Unsupported => FieldType::Unsupported,
923    }
924}
925
926impl fmt::Display for FieldType {
927    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
928        match self {
929            Self::Scalar(inner) => write!(f, "{inner:?}"),
930            Self::List(inner) => write!(f, "List<{inner}>"),
931            Self::Set(inner) => write!(f, "Set<{inner}>"),
932            Self::Map { key, value } => write!(f, "Map<{key}, {value}>"),
933            Self::Unsupported => write!(f, "Unsupported"),
934        }
935    }
936}
937
938///
939/// TESTS
940///
941
942#[cfg(test)]
943mod tests {
944    use super::{ValidateError, validate_model};
945    use crate::{
946        db::query::{
947            FieldRef,
948            predicate::{CoercionId, Predicate},
949        },
950        model::{
951            entity::EntityModel,
952            field::{EntityFieldKind, EntityFieldModel},
953            index::IndexModel,
954        },
955        types::Ulid,
956    };
957
958    fn field(name: &'static str, kind: EntityFieldKind) -> EntityFieldModel {
959        EntityFieldModel { name, kind }
960    }
961
962    fn model_with_fields(fields: Vec<EntityFieldModel>, pk_index: usize) -> EntityModel {
963        let fields: &'static [EntityFieldModel] = Box::leak(fields.into_boxed_slice());
964        let primary_key = &fields[pk_index];
965        let indexes: &'static [&'static IndexModel] = &[];
966
967        EntityModel {
968            path: "test::Entity",
969            entity_name: "TestEntity",
970            primary_key,
971            fields,
972            indexes,
973        }
974    }
975
976    #[test]
977    fn validate_model_accepts_scalars_and_coercions() {
978        let model = model_with_fields(
979            vec![
980                field("id", EntityFieldKind::Ulid),
981                field("email", EntityFieldKind::Text),
982                field("age", EntityFieldKind::Uint),
983                field("created_at", EntityFieldKind::Timestamp),
984                field("active", EntityFieldKind::Bool),
985            ],
986            0,
987        );
988
989        let predicate = Predicate::And(vec![
990            FieldRef::new("id").eq(Ulid::nil()),
991            FieldRef::new("email").text_eq_ci("User@example.com"),
992            FieldRef::new("age").lt(30u32),
993        ]);
994
995        assert!(validate_model(&model, &predicate).is_ok());
996    }
997
998    #[test]
999    fn validate_model_accepts_collections_and_map_contains() {
1000        let model = model_with_fields(
1001            vec![
1002                field("id", EntityFieldKind::Ulid),
1003                field("tags", EntityFieldKind::List(&EntityFieldKind::Text)),
1004                field(
1005                    "principals",
1006                    EntityFieldKind::Set(&EntityFieldKind::Principal),
1007                ),
1008                field(
1009                    "attributes",
1010                    EntityFieldKind::Map {
1011                        key: &EntityFieldKind::Text,
1012                        value: &EntityFieldKind::Uint,
1013                    },
1014                ),
1015            ],
1016            0,
1017        );
1018
1019        let predicate = Predicate::And(vec![
1020            FieldRef::new("tags").is_empty(),
1021            FieldRef::new("principals").is_not_empty(),
1022            FieldRef::new("attributes").map_contains_entry("k", 1u64, CoercionId::Strict),
1023        ]);
1024
1025        assert!(validate_model(&model, &predicate).is_ok());
1026
1027        let bad =
1028            FieldRef::new("attributes").map_contains_entry("k", 1u64, CoercionId::TextCasefold);
1029
1030        assert!(matches!(
1031            validate_model(&model, &bad),
1032            Err(ValidateError::InvalidCoercion { .. })
1033        ));
1034    }
1035
1036    #[test]
1037    fn validate_model_rejects_unsupported_fields() {
1038        let model = model_with_fields(
1039            vec![
1040                field("id", EntityFieldKind::Ulid),
1041                field("broken", EntityFieldKind::Unsupported),
1042            ],
1043            0,
1044        );
1045
1046        let predicate = FieldRef::new("broken").eq(1u64);
1047
1048        assert!(matches!(
1049            validate_model(&model, &predicate),
1050            Err(ValidateError::UnsupportedFieldType { field }) if field == "broken"
1051        ));
1052    }
1053
1054    #[test]
1055    fn validate_model_accepts_text_contains() {
1056        let model = model_with_fields(
1057            vec![
1058                field("id", EntityFieldKind::Ulid),
1059                field("email", EntityFieldKind::Text),
1060            ],
1061            0,
1062        );
1063
1064        let predicate = FieldRef::new("email").text_contains("example");
1065        assert!(validate_model(&model, &predicate).is_ok());
1066
1067        let predicate = FieldRef::new("email").text_contains_ci("EXAMPLE");
1068        assert!(validate_model(&model, &predicate).is_ok());
1069    }
1070
1071    #[test]
1072    fn validate_model_rejects_text_contains_on_non_text() {
1073        let model = model_with_fields(
1074            vec![
1075                field("id", EntityFieldKind::Ulid),
1076                field("age", EntityFieldKind::Uint),
1077            ],
1078            0,
1079        );
1080
1081        let predicate = FieldRef::new("age").text_contains("1");
1082        assert!(matches!(
1083            validate_model(&model, &predicate),
1084            Err(ValidateError::InvalidOperator { field, op })
1085                if field == "age" && op == "text_contains"
1086        ));
1087    }
1088}