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#[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#[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 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 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#[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 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#[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 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
496fn 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
519fn 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 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 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
555fn 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
574fn 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
644fn 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 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 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#[cfg(test)]
854mod tests {
855 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}