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#[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#[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 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#[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 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#[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 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 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
679fn 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#[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}