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