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 element_type(&self) -> Option<&Self> {
193 match self {
194 Self::List(inner) | Self::Set(inner) => Some(inner),
195 _ => None,
196 }
197 }
198
199 #[must_use]
200 pub fn map_types(&self) -> Option<(&Self, &Self)> {
201 match self {
202 Self::Map { key, value } => Some((key.as_ref(), value.as_ref())),
203 _ => None,
204 }
205 }
206
207 #[must_use]
208 pub const fn is_orderable(&self) -> bool {
209 match self {
210 Self::Scalar(inner) => inner.is_orderable(),
211 _ => false,
212 }
213 }
214
215 #[must_use]
216 pub const fn is_keyable(&self) -> bool {
217 matches!(
218 self,
219 Self::Scalar(
220 ScalarType::Account
221 | ScalarType::Int
222 | ScalarType::Principal
223 | ScalarType::Subaccount
224 | ScalarType::Timestamp
225 | ScalarType::Uint
226 | ScalarType::Ulid
227 | ScalarType::Unit
228 )
229 )
230 }
231}
232
233fn validate_index_fields(
234 fields: &BTreeMap<String, FieldType>,
235 indexes: &[&IndexModel],
236) -> Result<(), ValidateError> {
237 let mut seen_names = BTreeSet::new();
238 for index in indexes {
239 if seen_names.contains(index.name) {
240 return Err(ValidateError::DuplicateIndexName {
241 name: index.name.to_string(),
242 });
243 }
244 seen_names.insert(index.name);
245
246 let mut seen = BTreeSet::new();
247 for field in index.fields {
248 if !fields.contains_key(*field) {
249 return Err(ValidateError::IndexFieldUnknown {
250 index: **index,
251 field: (*field).to_string(),
252 });
253 }
254 if seen.contains(*field) {
255 return Err(ValidateError::IndexFieldDuplicate {
256 index: **index,
257 field: (*field).to_string(),
258 });
259 }
260 seen.insert(*field);
261
262 let field_type = fields
263 .get(*field)
264 .expect("index field existence checked above");
265 if matches!(field_type, FieldType::Unsupported) {
268 return Err(ValidateError::IndexFieldUnsupported {
269 index: **index,
270 field: (*field).to_string(),
271 });
272 }
273 }
274 }
275
276 Ok(())
277}
278
279#[derive(Clone, Debug)]
287pub struct SchemaInfo {
288 fields: BTreeMap<String, FieldType>,
289}
290
291impl SchemaInfo {
292 #[must_use]
293 #[expect(dead_code)]
294 pub(crate) fn new(fields: impl IntoIterator<Item = (String, FieldType)>) -> Self {
295 Self {
296 fields: fields.into_iter().collect(),
297 }
298 }
299
300 #[must_use]
301 pub(crate) fn field(&self, name: &str) -> Option<&FieldType> {
302 self.fields.get(name)
303 }
304
305 #[must_use]
306 pub fn from_entity_schema(entity: &Entity, schema: &Schema) -> Self {
307 let fields = entity
308 .fields
309 .fields
310 .iter()
311 .map(|field| {
312 let ty = field_type_from_value(&field.value, schema);
313 (field.ident.to_string(), ty)
314 })
315 .collect::<BTreeMap<_, _>>();
316
317 Self { fields }
318 }
319
320 pub fn from_entity_model(model: &EntityModel) -> Result<Self, ValidateError> {
321 let entity_name = EntityName::try_from_str(model.entity_name).map_err(|err| {
323 ValidateError::InvalidEntityName {
324 name: model.entity_name.to_string(),
325 source: err,
326 }
327 })?;
328
329 if !model
330 .fields
331 .iter()
332 .any(|field| std::ptr::eq(field, model.primary_key))
333 {
334 return Err(ValidateError::InvalidPrimaryKey {
335 field: model.primary_key.name.to_string(),
336 });
337 }
338
339 let mut fields = BTreeMap::new();
340 for field in model.fields {
341 if fields.contains_key(field.name) {
342 return Err(ValidateError::DuplicateField {
343 field: field.name.to_string(),
344 });
345 }
346 let ty = field_type_from_model_kind(&field.kind);
347 fields.insert(field.name.to_string(), ty);
348 }
349
350 let pk_field_type = fields
351 .get(model.primary_key.name)
352 .expect("primary key verified above");
353 if !pk_field_type.is_keyable() {
354 return Err(ValidateError::InvalidPrimaryKeyType {
355 field: model.primary_key.name.to_string(),
356 });
357 }
358
359 validate_index_fields(&fields, model.indexes)?;
360 for index in model.indexes {
361 IndexName::try_from_parts(&entity_name, index.fields).map_err(|err| {
362 ValidateError::InvalidIndexName {
363 index: **index,
364 source: err,
365 }
366 })?;
367 }
368
369 Ok(Self { fields })
370 }
371}
372
373#[derive(Debug, thiserror::Error)]
375pub enum ValidateError {
376 #[error("invalid entity name '{name}': {source}")]
377 InvalidEntityName {
378 name: String,
379 #[source]
380 source: EntityNameError,
381 },
382
383 #[error("invalid index name for '{index}': {source}")]
384 InvalidIndexName {
385 index: IndexModel,
386 #[source]
387 source: IndexNameError,
388 },
389
390 #[error("unknown field '{field}'")]
391 UnknownField { field: String },
392
393 #[error("unsupported field type for '{field}'")]
394 UnsupportedFieldType { field: String },
395
396 #[error("duplicate field '{field}'")]
397 DuplicateField { field: String },
398
399 #[error("primary key '{field}' not present in entity fields")]
400 InvalidPrimaryKey { field: String },
401
402 #[error("primary key '{field}' has an unsupported type")]
403 InvalidPrimaryKeyType { field: String },
404
405 #[error("index '{index}' references unknown field '{field}'")]
406 IndexFieldUnknown { index: IndexModel, field: String },
407
408 #[error("index '{index}' references unsupported field '{field}'")]
409 IndexFieldUnsupported { index: IndexModel, field: String },
410
411 #[error("index '{index}' repeats field '{field}'")]
412 IndexFieldDuplicate { index: IndexModel, field: String },
413
414 #[error("duplicate index name '{name}'")]
415 DuplicateIndexName { name: String },
416
417 #[error("operator {op} is not valid for field '{field}'")]
418 InvalidOperator { field: String, op: String },
419
420 #[error("coercion {coercion:?} is not valid for field '{field}'")]
421 InvalidCoercion { field: String, coercion: CoercionId },
422
423 #[error("invalid literal for field '{field}': {message}")]
424 InvalidLiteral { field: String, message: String },
425
426 #[error("schema unavailable: {0}")]
427 SchemaUnavailable(String),
428}
429
430pub fn validate(schema: &SchemaInfo, predicate: &Predicate) -> Result<(), ValidateError> {
431 match predicate {
432 Predicate::True | Predicate::False => Ok(()),
433 Predicate::And(children) | Predicate::Or(children) => {
434 for child in children {
435 validate(schema, child)?;
436 }
437 Ok(())
438 }
439 Predicate::Not(inner) => validate(schema, inner),
440 Predicate::Compare(cmp) => validate_compare(schema, cmp),
441 Predicate::IsNull { field } | Predicate::IsMissing { field } => {
442 ensure_field(schema, field).map(|_| ())
443 }
444 Predicate::IsEmpty { field } => {
445 let field_type = ensure_field(schema, field)?;
446 if field_type.is_text() || field_type.is_collection() {
447 Ok(())
448 } else {
449 Err(ValidateError::InvalidOperator {
450 field: field.clone(),
451 op: "is_empty".to_string(),
452 })
453 }
454 }
455 Predicate::IsNotEmpty { field } => {
456 let field_type = ensure_field(schema, field)?;
457 if field_type.is_text() || field_type.is_collection() {
458 Ok(())
459 } else {
460 Err(ValidateError::InvalidOperator {
461 field: field.clone(),
462 op: "is_not_empty".to_string(),
463 })
464 }
465 }
466 Predicate::MapContainsKey {
467 field,
468 key,
469 coercion,
470 } => validate_map_key(schema, field, key, coercion),
471 Predicate::MapContainsValue {
472 field,
473 value,
474 coercion,
475 } => validate_map_value(schema, field, value, coercion),
476 Predicate::MapContainsEntry {
477 field,
478 key,
479 value,
480 coercion,
481 } => validate_map_entry(schema, field, key, value, coercion),
482 }
483}
484
485pub fn validate_model(model: &EntityModel, predicate: &Predicate) -> Result<(), ValidateError> {
486 let schema = SchemaInfo::from_entity_model(model)?;
487 validate(&schema, predicate)
488}
489
490fn validate_compare(schema: &SchemaInfo, cmp: &ComparePredicate) -> Result<(), ValidateError> {
491 let field_type = ensure_field(schema, &cmp.field)?;
492
493 match cmp.op {
494 CompareOp::Eq | CompareOp::Ne => {
495 validate_eq_ne(&cmp.field, field_type, &cmp.value, &cmp.coercion)
496 }
497 CompareOp::Lt | CompareOp::Lte | CompareOp::Gt | CompareOp::Gte => {
498 validate_ordering(&cmp.field, field_type, &cmp.value, &cmp.coercion, cmp.op)
499 }
500 CompareOp::In | CompareOp::NotIn => {
501 validate_in(&cmp.field, field_type, &cmp.value, &cmp.coercion)
502 }
503 CompareOp::AnyIn | CompareOp::AllIn => {
504 validate_any_all_in(&cmp.field, field_type, &cmp.value, &cmp.coercion)
505 }
506 CompareOp::Contains => validate_contains(&cmp.field, field_type, &cmp.value, &cmp.coercion),
507 CompareOp::StartsWith | CompareOp::EndsWith => {
508 validate_text_op(&cmp.field, field_type, &cmp.value, &cmp.coercion, cmp.op)
509 }
510 }
511}
512
513fn validate_eq_ne(
514 field: &str,
515 field_type: &FieldType,
516 value: &Value,
517 coercion: &CoercionSpec,
518) -> Result<(), ValidateError> {
519 if field_type.is_list_like() {
520 ensure_list_literal(field, value, field_type)?;
521 } else if field_type.is_map() {
522 ensure_map_literal(field, value, field_type)?;
523 } else if matches!(value, Value::List(_)) {
524 return Err(ValidateError::InvalidLiteral {
525 field: field.to_string(),
526 message: "expected scalar literal".to_string(),
527 });
528 }
529
530 ensure_coercion(field, field_type, value, coercion)
531}
532
533fn validate_ordering(
534 field: &str,
535 field_type: &FieldType,
536 value: &Value,
537 coercion: &CoercionSpec,
538 op: CompareOp,
539) -> Result<(), ValidateError> {
540 if matches!(coercion.id, CoercionId::CollectionElement) {
541 return Err(ValidateError::InvalidCoercion {
542 field: field.to_string(),
543 coercion: coercion.id,
544 });
545 }
546
547 if !field_type.is_orderable() {
548 return Err(ValidateError::InvalidOperator {
549 field: field.to_string(),
550 op: format!("{op:?}"),
551 });
552 }
553
554 if matches!(value, Value::List(_)) {
555 return Err(ValidateError::InvalidLiteral {
556 field: field.to_string(),
557 message: "expected scalar literal".to_string(),
558 });
559 }
560
561 ensure_coercion(field, field_type, value, coercion)
562}
563
564fn validate_in(
565 field: &str,
566 field_type: &FieldType,
567 value: &Value,
568 coercion: &CoercionSpec,
569) -> Result<(), ValidateError> {
570 if field_type.is_collection() {
571 return Err(ValidateError::InvalidOperator {
572 field: field.to_string(),
573 op: format!("{:?}", CompareOp::In),
574 });
575 }
576
577 let Value::List(items) = value else {
578 return Err(ValidateError::InvalidLiteral {
579 field: field.to_string(),
580 message: "expected list literal".to_string(),
581 });
582 };
583
584 for item in items {
585 ensure_coercion(field, field_type, item, coercion)?;
586 }
587
588 Ok(())
589}
590
591fn validate_any_all_in(
592 field: &str,
593 field_type: &FieldType,
594 value: &Value,
595 coercion: &CoercionSpec,
596) -> Result<(), ValidateError> {
597 let element_type = field_type
598 .element_type()
599 .ok_or_else(|| ValidateError::InvalidOperator {
600 field: field.to_string(),
601 op: format!("{:?}", CompareOp::AnyIn),
602 })?;
603
604 let Value::List(items) = value else {
605 return Err(ValidateError::InvalidLiteral {
606 field: field.to_string(),
607 message: "expected list literal".to_string(),
608 });
609 };
610
611 for item in items {
612 ensure_coercion(field, element_type, item, coercion)?;
613 }
614
615 Ok(())
616}
617
618fn validate_contains(
619 field: &str,
620 field_type: &FieldType,
621 value: &Value,
622 coercion: &CoercionSpec,
623) -> Result<(), ValidateError> {
624 if field_type.is_text() {
625 if !matches!(coercion.id, CoercionId::Strict | CoercionId::TextCasefold) {
626 return Err(ValidateError::InvalidCoercion {
627 field: field.to_string(),
628 coercion: coercion.id,
629 });
630 }
631 if !matches!(value, Value::Text(_)) {
632 return Err(ValidateError::InvalidLiteral {
633 field: field.to_string(),
634 message: "expected text literal".to_string(),
635 });
636 }
637
638 return ensure_coercion(field, field_type, value, coercion);
639 }
640
641 let element_type = field_type
642 .element_type()
643 .ok_or_else(|| ValidateError::InvalidOperator {
644 field: field.to_string(),
645 op: format!("{:?}", CompareOp::Contains),
646 })?;
647
648 ensure_coercion(field, element_type, value, coercion)
649}
650
651fn validate_text_op(
652 field: &str,
653 field_type: &FieldType,
654 value: &Value,
655 coercion: &CoercionSpec,
656 op: CompareOp,
657) -> Result<(), ValidateError> {
658 if !field_type.is_text() {
659 return Err(ValidateError::InvalidOperator {
660 field: field.to_string(),
661 op: format!("{op:?}"),
662 });
663 }
664
665 if !matches!(coercion.id, CoercionId::Strict | CoercionId::TextCasefold) {
666 return Err(ValidateError::InvalidCoercion {
667 field: field.to_string(),
668 coercion: coercion.id,
669 });
670 }
671
672 if !matches!(value, Value::Text(_)) {
673 return Err(ValidateError::InvalidLiteral {
674 field: field.to_string(),
675 message: "expected text literal".to_string(),
676 });
677 }
678
679 ensure_coercion(field, field_type, value, coercion)
680}
681
682fn validate_map_key(
683 schema: &SchemaInfo,
684 field: &str,
685 key: &Value,
686 coercion: &CoercionSpec,
687) -> Result<(), ValidateError> {
688 let field_type = ensure_field(schema, field)?;
689 let (key_type, _) = field_type
690 .map_types()
691 .ok_or_else(|| ValidateError::InvalidOperator {
692 field: field.to_string(),
693 op: "map_contains_key".to_string(),
694 })?;
695
696 ensure_coercion(field, key_type, key, coercion)
697}
698
699fn validate_map_value(
700 schema: &SchemaInfo,
701 field: &str,
702 value: &Value,
703 coercion: &CoercionSpec,
704) -> Result<(), ValidateError> {
705 let field_type = ensure_field(schema, field)?;
706 let (_, value_type) = field_type
707 .map_types()
708 .ok_or_else(|| ValidateError::InvalidOperator {
709 field: field.to_string(),
710 op: "map_contains_value".to_string(),
711 })?;
712
713 ensure_coercion(field, value_type, value, coercion)
714}
715
716fn validate_map_entry(
717 schema: &SchemaInfo,
718 field: &str,
719 key: &Value,
720 value: &Value,
721 coercion: &CoercionSpec,
722) -> Result<(), ValidateError> {
723 let field_type = ensure_field(schema, field)?;
724 let (key_type, value_type) =
725 field_type
726 .map_types()
727 .ok_or_else(|| ValidateError::InvalidOperator {
728 field: field.to_string(),
729 op: "map_contains_entry".to_string(),
730 })?;
731
732 ensure_coercion(field, key_type, key, coercion)?;
733 ensure_coercion(field, value_type, value, coercion)?;
734
735 Ok(())
736}
737
738fn ensure_field<'a>(schema: &'a SchemaInfo, field: &str) -> Result<&'a FieldType, ValidateError> {
739 let field_type = schema
740 .field(field)
741 .ok_or_else(|| ValidateError::UnknownField {
742 field: field.to_string(),
743 })?;
744
745 if matches!(field_type, FieldType::Unsupported) {
746 return Err(ValidateError::UnsupportedFieldType {
747 field: field.to_string(),
748 });
749 }
750
751 Ok(field_type)
752}
753
754fn ensure_coercion(
755 field: &str,
756 field_type: &FieldType,
757 literal: &Value,
758 coercion: &CoercionSpec,
759) -> Result<(), ValidateError> {
760 let left_family = field_type
761 .family()
762 .ok_or_else(|| ValidateError::UnsupportedFieldType {
763 field: field.to_string(),
764 })?;
765 let right_family = literal.family();
766
767 if !supports_coercion(left_family, right_family, coercion.id) {
768 return Err(ValidateError::InvalidCoercion {
769 field: field.to_string(),
770 coercion: coercion.id,
771 });
772 }
773
774 if matches!(
775 coercion.id,
776 CoercionId::Strict | CoercionId::CollectionElement
777 ) && !literal_matches_type(literal, field_type)
778 {
779 return Err(ValidateError::InvalidLiteral {
780 field: field.to_string(),
781 message: "literal type does not match field type".to_string(),
782 });
783 }
784
785 Ok(())
786}
787
788fn ensure_list_literal(
789 field: &str,
790 literal: &Value,
791 field_type: &FieldType,
792) -> Result<(), ValidateError> {
793 if !literal_matches_type(literal, field_type) {
794 return Err(ValidateError::InvalidLiteral {
795 field: field.to_string(),
796 message: "list literal does not match field element type".to_string(),
797 });
798 }
799
800 Ok(())
801}
802
803fn ensure_map_literal(
804 field: &str,
805 literal: &Value,
806 field_type: &FieldType,
807) -> Result<(), ValidateError> {
808 if !literal_matches_type(literal, field_type) {
809 return Err(ValidateError::InvalidLiteral {
810 field: field.to_string(),
811 message: "map literal does not match field key/value types".to_string(),
812 });
813 }
814
815 Ok(())
816}
817
818pub(crate) fn literal_matches_type(literal: &Value, field_type: &FieldType) -> bool {
819 match field_type {
820 FieldType::Scalar(inner) => inner.matches_value(literal),
821 FieldType::List(element) | FieldType::Set(element) => match literal {
822 Value::List(items) => items.iter().all(|item| literal_matches_type(item, element)),
823 _ => false,
824 },
825 FieldType::Map { key, value } => match literal {
826 Value::List(entries) => entries.iter().all(|entry| match entry {
827 Value::List(pair) if pair.len() == 2 => {
828 literal_matches_type(&pair[0], key) && literal_matches_type(&pair[1], value)
829 }
830 _ => false,
831 }),
832 _ => false,
833 },
834 FieldType::Unsupported => false,
835 }
836}
837
838fn field_type_from_value(value: &SValue, schema: &Schema) -> FieldType {
839 let base = field_type_from_item(&value.item, schema);
840
841 match value.cardinality {
842 Cardinality::Many => FieldType::List(Box::new(base)),
843 Cardinality::One | Cardinality::Opt => base,
844 }
845}
846
847fn field_type_from_item(item: &Item, schema: &Schema) -> FieldType {
848 match &item.target {
849 ItemTarget::Primitive(prim) => FieldType::Scalar(scalar_from_primitive(*prim)),
850 ItemTarget::Is(path) => {
851 if schema.cast_node::<Enum>(path).is_ok() {
852 return FieldType::Scalar(ScalarType::Enum);
853 }
854 if let Ok(node) = schema.cast_node::<Newtype>(path) {
855 return field_type_from_item(&node.item, schema);
856 }
857 if let Ok(node) = schema.cast_node::<List>(path) {
858 return FieldType::List(Box::new(field_type_from_item(&node.item, schema)));
859 }
860 if let Ok(node) = schema.cast_node::<Set>(path) {
861 return FieldType::Set(Box::new(field_type_from_item(&node.item, schema)));
862 }
863 if let Ok(node) = schema.cast_node::<Map>(path) {
864 let key = field_type_from_item(&node.key, schema);
865 let value = field_type_from_value(&node.value, schema);
866 return FieldType::Map {
867 key: Box::new(key),
868 value: Box::new(value),
869 };
870 }
871 if schema.cast_node::<Record>(path).is_ok() {
872 return FieldType::Unsupported;
873 }
874 if schema.cast_node::<Tuple>(path).is_ok() {
875 return FieldType::Unsupported;
876 }
877
878 FieldType::Unsupported
879 }
880 }
881}
882
883const fn scalar_from_primitive(prim: Primitive) -> ScalarType {
884 match prim {
885 Primitive::Account => ScalarType::Account,
886 Primitive::Blob => ScalarType::Blob,
887 Primitive::Bool => ScalarType::Bool,
888 Primitive::Date => ScalarType::Date,
889 Primitive::Decimal => ScalarType::Decimal,
890 Primitive::Duration => ScalarType::Duration,
891 Primitive::E8s => ScalarType::E8s,
892 Primitive::E18s => ScalarType::E18s,
893 Primitive::Float32 => ScalarType::Float32,
894 Primitive::Float64 => ScalarType::Float64,
895 Primitive::Int => ScalarType::IntBig,
896 Primitive::Int8 | Primitive::Int16 | Primitive::Int32 | Primitive::Int64 => ScalarType::Int,
897 Primitive::Int128 => ScalarType::Int128,
898 Primitive::Nat => ScalarType::UintBig,
899 Primitive::Nat8 | Primitive::Nat16 | Primitive::Nat32 | Primitive::Nat64 => {
900 ScalarType::Uint
901 }
902 Primitive::Nat128 => ScalarType::Uint128,
903 Primitive::Principal => ScalarType::Principal,
904 Primitive::Subaccount => ScalarType::Subaccount,
905 Primitive::Text => ScalarType::Text,
906 Primitive::Timestamp => ScalarType::Timestamp,
907 Primitive::Ulid => ScalarType::Ulid,
908 Primitive::Unit => ScalarType::Unit,
909 }
910}
911
912fn field_type_from_model_kind(kind: &EntityFieldKind) -> FieldType {
913 match kind {
914 EntityFieldKind::Account => FieldType::Scalar(ScalarType::Account),
915 EntityFieldKind::Blob => FieldType::Scalar(ScalarType::Blob),
916 EntityFieldKind::Bool => FieldType::Scalar(ScalarType::Bool),
917 EntityFieldKind::Date => FieldType::Scalar(ScalarType::Date),
918 EntityFieldKind::Decimal => FieldType::Scalar(ScalarType::Decimal),
919 EntityFieldKind::Duration => FieldType::Scalar(ScalarType::Duration),
920 EntityFieldKind::Enum => FieldType::Scalar(ScalarType::Enum),
921 EntityFieldKind::E8s => FieldType::Scalar(ScalarType::E8s),
922 EntityFieldKind::E18s => FieldType::Scalar(ScalarType::E18s),
923 EntityFieldKind::Float32 => FieldType::Scalar(ScalarType::Float32),
924 EntityFieldKind::Float64 => FieldType::Scalar(ScalarType::Float64),
925 EntityFieldKind::Int => FieldType::Scalar(ScalarType::Int),
926 EntityFieldKind::Int128 => FieldType::Scalar(ScalarType::Int128),
927 EntityFieldKind::IntBig => FieldType::Scalar(ScalarType::IntBig),
928 EntityFieldKind::Principal => FieldType::Scalar(ScalarType::Principal),
929 EntityFieldKind::Subaccount => FieldType::Scalar(ScalarType::Subaccount),
930 EntityFieldKind::Text => FieldType::Scalar(ScalarType::Text),
931 EntityFieldKind::Timestamp => FieldType::Scalar(ScalarType::Timestamp),
932 EntityFieldKind::Uint => FieldType::Scalar(ScalarType::Uint),
933 EntityFieldKind::Uint128 => FieldType::Scalar(ScalarType::Uint128),
934 EntityFieldKind::UintBig => FieldType::Scalar(ScalarType::UintBig),
935 EntityFieldKind::Ulid => FieldType::Scalar(ScalarType::Ulid),
936 EntityFieldKind::Unit => FieldType::Scalar(ScalarType::Unit),
937 EntityFieldKind::List(inner) => {
938 FieldType::List(Box::new(field_type_from_model_kind(inner)))
939 }
940 EntityFieldKind::Set(inner) => FieldType::Set(Box::new(field_type_from_model_kind(inner))),
941 EntityFieldKind::Map { key, value } => FieldType::Map {
942 key: Box::new(field_type_from_model_kind(key)),
943 value: Box::new(field_type_from_model_kind(value)),
944 },
945 EntityFieldKind::Unsupported => FieldType::Unsupported,
946 }
947}
948
949impl fmt::Display for FieldType {
950 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
951 match self {
952 Self::Scalar(inner) => write!(f, "{inner:?}"),
953 Self::List(inner) => write!(f, "List<{inner}>"),
954 Self::Set(inner) => write!(f, "Set<{inner}>"),
955 Self::Map { key, value } => write!(f, "Map<{key}, {value}>"),
956 Self::Unsupported => write!(f, "Unsupported"),
957 }
958 }
959}
960
961#[cfg(test)]
966mod tests {
967 use super::{ValidateError, validate_model};
968 use crate::{
969 db::query::{
970 builder::{eq, eq_ci, is_empty, is_not_empty, lt, map_contains_entry},
971 predicate::{CoercionId, Predicate},
972 },
973 model::{
974 entity::EntityModel,
975 field::{EntityFieldKind, EntityFieldModel},
976 index::IndexModel,
977 },
978 types::Ulid,
979 };
980
981 fn field(name: &'static str, kind: EntityFieldKind) -> EntityFieldModel {
982 EntityFieldModel { name, kind }
983 }
984
985 fn model_with_fields(fields: Vec<EntityFieldModel>, pk_index: usize) -> EntityModel {
986 let fields: &'static [EntityFieldModel] = Box::leak(fields.into_boxed_slice());
987 let primary_key = &fields[pk_index];
988 let indexes: &'static [&'static IndexModel] = &[];
989
990 EntityModel {
991 path: "test::Entity",
992 entity_name: "TestEntity",
993 primary_key,
994 fields,
995 indexes,
996 }
997 }
998
999 #[test]
1000 fn validate_model_accepts_scalars_and_coercions() {
1001 let model = model_with_fields(
1002 vec![
1003 field("id", EntityFieldKind::Ulid),
1004 field("email", EntityFieldKind::Text),
1005 field("age", EntityFieldKind::Uint),
1006 field("created_at", EntityFieldKind::Timestamp),
1007 field("active", EntityFieldKind::Bool),
1008 ],
1009 0,
1010 );
1011
1012 let predicate = Predicate::And(vec![
1013 eq("id", Ulid::nil()),
1014 eq_ci("email", "User@example.com"),
1015 lt("age", 30u32),
1016 ]);
1017
1018 assert!(validate_model(&model, &predicate).is_ok());
1019 }
1020
1021 #[test]
1022 fn validate_model_accepts_collections_and_map_contains() {
1023 let model = model_with_fields(
1024 vec![
1025 field("id", EntityFieldKind::Ulid),
1026 field("tags", EntityFieldKind::List(&EntityFieldKind::Text)),
1027 field(
1028 "principals",
1029 EntityFieldKind::Set(&EntityFieldKind::Principal),
1030 ),
1031 field(
1032 "attributes",
1033 EntityFieldKind::Map {
1034 key: &EntityFieldKind::Text,
1035 value: &EntityFieldKind::Uint,
1036 },
1037 ),
1038 ],
1039 0,
1040 );
1041
1042 let predicate = Predicate::And(vec![
1043 is_empty("tags"),
1044 is_not_empty("principals"),
1045 map_contains_entry("attributes", "k", 1u64, CoercionId::Strict),
1046 ]);
1047
1048 assert!(validate_model(&model, &predicate).is_ok());
1049
1050 let bad = map_contains_entry("attributes", "k", 1u64, CoercionId::TextCasefold);
1051 assert!(matches!(
1052 validate_model(&model, &bad),
1053 Err(ValidateError::InvalidCoercion { .. })
1054 ));
1055 }
1056
1057 #[test]
1058 fn validate_model_rejects_unsupported_fields() {
1059 let model = model_with_fields(
1060 vec![
1061 field("id", EntityFieldKind::Ulid),
1062 field("broken", EntityFieldKind::Unsupported),
1063 ],
1064 0,
1065 );
1066 let predicate = eq("broken", 1u64);
1067
1068 assert!(matches!(
1069 validate_model(&model, &predicate),
1070 Err(ValidateError::UnsupportedFieldType { field }) if field == "broken"
1071 ));
1072 }
1073}