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