Skip to main content

icydb_core/db/query/predicate/
validate.rs

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