1use super::{AccessPath, AccessPlan, LogicalPlan, OrderSpec};
12use crate::{
13 db::query::predicate::{self, SchemaInfo, coercion::canonical_cmp},
14 error::{ErrorClass, ErrorOrigin, InternalError},
15 model::entity::EntityModel,
16 model::index::IndexModel,
17 traits::{EntityKind, FieldValue},
18 value::Value,
19};
20use thiserror::Error as ThisError;
21
22#[derive(Debug, ThisError)]
32pub enum PlanError {
33 #[error("predicate validation failed: {0}")]
34 PredicateInvalid(#[from] predicate::ValidateError),
35
36 #[error("unknown order field '{field}'")]
38 UnknownOrderField { field: String },
39
40 #[error("order field '{field}' is not orderable")]
42 UnorderableField { field: String },
43
44 #[error("index '{index}' not found on entity")]
46 IndexNotFound { index: IndexModel },
47
48 #[error("index prefix length {prefix_len} exceeds index field count {field_len}")]
50 IndexPrefixTooLong { prefix_len: usize, field_len: usize },
51
52 #[error("index prefix must include at least one value")]
54 IndexPrefixEmpty,
55
56 #[error("index prefix value for field '{field}' is incompatible")]
58 IndexPrefixValueMismatch { field: String },
59
60 #[error("primary key field '{field}' is not key-compatible")]
62 PrimaryKeyUnsupported { field: String },
63
64 #[error("key '{key:?}' is incompatible with primary key '{field}'")]
66 PrimaryKeyMismatch { field: String, key: Value },
67
68 #[error("key range start is greater than end")]
70 InvalidKeyRange,
71
72 #[error("order specification must include at least one field")]
74 EmptyOrderSpec,
75
76 #[error("delete plans must not include pagination")]
78 DeletePlanWithPagination,
79
80 #[error("load plans must not include delete limits")]
82 LoadPlanWithDeleteLimit,
83
84 #[error("delete limit requires an explicit ordering")]
86 DeleteLimitRequiresOrder,
87}
88
89#[cfg(test)]
91pub(crate) fn validate_plan_with_schema_info<K>(
92 schema: &SchemaInfo,
93 model: &EntityModel,
94 plan: &LogicalPlan<K>,
95) -> Result<(), PlanError>
96where
97 K: FieldValue + Ord,
98{
99 validate_logical_plan(schema, model, plan)
100}
101
102#[cfg(test)]
106#[expect(dead_code)]
107pub(crate) fn validate_plan_with_model<K>(
108 plan: &LogicalPlan<K>,
109 model: &EntityModel,
110) -> Result<(), PlanError>
111where
112 K: FieldValue + Ord,
113{
114 let schema = SchemaInfo::from_entity_model(model)?;
115 validate_plan_with_schema_info(&schema, model, plan)
116}
117
118#[cfg(test)]
120pub(crate) fn validate_logical_plan<K>(
121 schema: &SchemaInfo,
122 model: &EntityModel,
123 plan: &LogicalPlan<K>,
124) -> Result<(), PlanError>
125where
126 K: FieldValue + Ord,
127{
128 if let Some(predicate) = &plan.predicate {
129 predicate::validate(schema, predicate)?;
130 }
131
132 if let Some(order) = &plan.order {
133 validate_order(schema, order)?;
134 }
135
136 validate_access_plan(schema, model, &plan.access)?;
137 validate_plan_semantics(plan)?;
138
139 Ok(())
140}
141
142pub(crate) fn validate_logical_plan_model(
151 schema: &SchemaInfo,
152 model: &EntityModel,
153 plan: &LogicalPlan<Value>,
154) -> Result<(), PlanError> {
155 if let Some(predicate) = &plan.predicate {
156 predicate::validate(schema, predicate)?;
157 }
158
159 if let Some(order) = &plan.order {
160 validate_order(schema, order)?;
161 }
162
163 validate_access_plan_model(schema, model, &plan.access)?;
164 validate_plan_semantics(plan)?;
165
166 Ok(())
167}
168
169fn validate_plan_semantics<K>(plan: &LogicalPlan<K>) -> Result<(), PlanError> {
171 if let Some(order) = &plan.order
172 && order.fields.is_empty()
173 {
174 return Err(PlanError::EmptyOrderSpec);
175 }
176
177 if plan.mode.is_delete() {
178 if plan.page.is_some() {
179 return Err(PlanError::DeletePlanWithPagination);
180 }
181
182 if plan.delete_limit.is_some()
183 && plan
184 .order
185 .as_ref()
186 .is_none_or(|order| order.fields.is_empty())
187 {
188 return Err(PlanError::DeleteLimitRequiresOrder);
189 }
190 }
191
192 if plan.mode.is_load() && plan.delete_limit.is_some() {
193 return Err(PlanError::LoadPlanWithDeleteLimit);
194 }
195
196 Ok(())
197}
198
199pub(crate) fn validate_executor_plan<E: EntityKind>(
208 plan: &LogicalPlan<E::Key>,
209) -> Result<(), InternalError> {
210 let schema = SchemaInfo::from_entity_model(E::MODEL).map_err(|err| {
211 InternalError::new(
212 ErrorClass::InvariantViolation,
213 ErrorOrigin::Query,
214 format!("entity schema invalid for {}: {err}", E::PATH),
215 )
216 })?;
217
218 if let Some(predicate) = &plan.predicate {
219 predicate::validate(&schema, predicate).map_err(|err| {
220 InternalError::new(
221 ErrorClass::InvariantViolation,
222 ErrorOrigin::Query,
223 err.to_string(),
224 )
225 })?;
226 }
227
228 if let Some(order) = &plan.order {
229 validate_executor_order(&schema, order).map_err(|err| {
230 InternalError::new(
231 ErrorClass::InvariantViolation,
232 ErrorOrigin::Query,
233 err.to_string(),
234 )
235 })?;
236 }
237
238 validate_access_plan(&schema, E::MODEL, &plan.access).map_err(|err| {
239 InternalError::new(
240 ErrorClass::InvariantViolation,
241 ErrorOrigin::Query,
242 err.to_string(),
243 )
244 })?;
245
246 validate_plan_semantics(plan).map_err(|err| {
247 InternalError::new(
248 ErrorClass::InvariantViolation,
249 ErrorOrigin::Query,
250 err.to_string(),
251 )
252 })?;
253
254 Ok(())
255}
256
257pub(crate) fn validate_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
259 for (field, _) in &order.fields {
260 let field_type = schema
261 .field(field)
262 .ok_or_else(|| PlanError::UnknownOrderField {
263 field: field.clone(),
264 })?;
265
266 if !field_type.is_orderable() {
267 return Err(PlanError::UnorderableField {
269 field: field.clone(),
270 });
271 }
272 }
273
274 Ok(())
275}
276
277fn validate_executor_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
281 validate_order(schema, order)
282}
283
284trait AccessPlanKeyAdapter<K> {
286 fn validate_pk_key(
288 &self,
289 schema: &SchemaInfo,
290 model: &EntityModel,
291 key: &K,
292 ) -> Result<(), PlanError>;
293
294 fn validate_key_range(
296 &self,
297 schema: &SchemaInfo,
298 model: &EntityModel,
299 start: &K,
300 end: &K,
301 ) -> Result<(), PlanError>;
302}
303
304struct GenericKeyAdapter;
306
307impl<K> AccessPlanKeyAdapter<K> for GenericKeyAdapter
308where
309 K: FieldValue + Ord,
310{
311 fn validate_pk_key(
312 &self,
313 schema: &SchemaInfo,
314 model: &EntityModel,
315 key: &K,
316 ) -> Result<(), PlanError> {
317 validate_pk_key(schema, model, key)
318 }
319
320 fn validate_key_range(
321 &self,
322 schema: &SchemaInfo,
323 model: &EntityModel,
324 start: &K,
325 end: &K,
326 ) -> Result<(), PlanError> {
327 validate_pk_key(schema, model, start)?;
328 validate_pk_key(schema, model, end)?;
329 if start > end {
330 return Err(PlanError::InvalidKeyRange);
331 }
332
333 Ok(())
334 }
335}
336
337struct ValueKeyAdapter;
339
340impl AccessPlanKeyAdapter<Value> for ValueKeyAdapter {
341 fn validate_pk_key(
342 &self,
343 schema: &SchemaInfo,
344 model: &EntityModel,
345 key: &Value,
346 ) -> Result<(), PlanError> {
347 validate_pk_value(schema, model, key)
348 }
349
350 fn validate_key_range(
351 &self,
352 schema: &SchemaInfo,
353 model: &EntityModel,
354 start: &Value,
355 end: &Value,
356 ) -> Result<(), PlanError> {
357 validate_pk_value(schema, model, start)?;
358 validate_pk_value(schema, model, end)?;
359 let ordering = canonical_cmp(start, end);
360 if ordering == std::cmp::Ordering::Greater {
361 return Err(PlanError::InvalidKeyRange);
362 }
363
364 Ok(())
365 }
366}
367
368fn validate_access_plan_with<K>(
370 schema: &SchemaInfo,
371 model: &EntityModel,
372 access: &AccessPlan<K>,
373 adapter: &impl AccessPlanKeyAdapter<K>,
374) -> Result<(), PlanError> {
375 match access {
376 AccessPlan::Path(path) => validate_access_path_with(schema, model, path, adapter),
377 AccessPlan::Union(children) | AccessPlan::Intersection(children) => {
378 for child in children {
379 validate_access_plan_with(schema, model, child, adapter)?;
380 }
381
382 Ok(())
383 }
384 }
385}
386
387fn validate_access_path_with<K>(
389 schema: &SchemaInfo,
390 model: &EntityModel,
391 access: &AccessPath<K>,
392 adapter: &impl AccessPlanKeyAdapter<K>,
393) -> Result<(), PlanError> {
394 match access {
395 AccessPath::ByKey(key) => adapter.validate_pk_key(schema, model, key),
396 AccessPath::ByKeys(keys) => {
397 if keys.is_empty() {
399 return Ok(());
400 }
401 for key in keys {
402 adapter.validate_pk_key(schema, model, key)?;
403 }
404
405 Ok(())
406 }
407 AccessPath::KeyRange { start, end } => {
408 adapter.validate_key_range(schema, model, start, end)
409 }
410 AccessPath::IndexPrefix { index, values } => {
411 validate_index_prefix(schema, model, index, values)
412 }
413 AccessPath::FullScan => Ok(()),
414 }
415}
416
417pub(crate) fn validate_access_plan<K>(
421 schema: &SchemaInfo,
422 model: &EntityModel,
423 access: &AccessPlan<K>,
424) -> Result<(), PlanError>
425where
426 K: FieldValue + Ord,
427{
428 validate_access_plan_with(schema, model, access, &GenericKeyAdapter)
429}
430
431pub(crate) fn validate_access_plan_model(
433 schema: &SchemaInfo,
434 model: &EntityModel,
435 access: &AccessPlan<Value>,
436) -> Result<(), PlanError> {
437 validate_access_plan_with(schema, model, access, &ValueKeyAdapter)
438}
439
440fn validate_pk_key<K>(schema: &SchemaInfo, model: &EntityModel, key: &K) -> Result<(), PlanError>
442where
443 K: FieldValue,
444{
445 let field = model.primary_key.name;
446
447 let field_type = schema
448 .field(field)
449 .ok_or_else(|| PlanError::PrimaryKeyUnsupported {
450 field: field.to_string(),
451 })?;
452
453 if !field_type.is_keyable() {
454 return Err(PlanError::PrimaryKeyUnsupported {
455 field: field.to_string(),
456 });
457 }
458
459 let value = key.to_value();
460 if !predicate::validate::literal_matches_type(&value, field_type) {
461 return Err(PlanError::PrimaryKeyMismatch {
462 field: field.to_string(),
463 key: value,
464 });
465 }
466
467 Ok(())
468}
469
470fn validate_pk_value(
472 schema: &SchemaInfo,
473 model: &EntityModel,
474 key: &Value,
475) -> Result<(), PlanError> {
476 let field = model.primary_key.name;
477
478 let field_type = schema
479 .field(field)
480 .ok_or_else(|| PlanError::PrimaryKeyUnsupported {
481 field: field.to_string(),
482 })?;
483
484 if !field_type.is_keyable() {
485 return Err(PlanError::PrimaryKeyUnsupported {
486 field: field.to_string(),
487 });
488 }
489
490 if !predicate::validate::literal_matches_type(key, field_type) {
491 return Err(PlanError::PrimaryKeyMismatch {
492 field: field.to_string(),
493 key: key.clone(),
494 });
495 }
496
497 Ok(())
498}
499
500fn validate_index_prefix(
502 schema: &SchemaInfo,
503 model: &EntityModel,
504 index: &IndexModel,
505 values: &[Value],
506) -> Result<(), PlanError> {
507 if !model.indexes.contains(&index) {
508 return Err(PlanError::IndexNotFound { index: *index });
509 }
510
511 if values.is_empty() {
512 return Err(PlanError::IndexPrefixEmpty);
513 }
514
515 if values.len() > index.fields.len() {
516 return Err(PlanError::IndexPrefixTooLong {
517 prefix_len: values.len(),
518 field_len: index.fields.len(),
519 });
520 }
521
522 for (field, value) in index.fields.iter().zip(values.iter()) {
523 let field_type =
524 schema
525 .field(field)
526 .ok_or_else(|| PlanError::IndexPrefixValueMismatch {
527 field: field.to_string(),
528 })?;
529
530 if !predicate::validate::literal_matches_type(value, field_type) {
531 return Err(PlanError::IndexPrefixValueMismatch {
532 field: field.to_string(),
533 });
534 }
535 }
536
537 Ok(())
538}
539
540#[cfg(test)]
548mod tests {
549 use super::{PlanError, validate_logical_plan_model};
551 use crate::{
552 db::query::{
553 plan::{AccessPath, AccessPlan, LogicalPlan, OrderDirection, OrderSpec},
554 predicate::{SchemaInfo, ValidateError},
555 },
556 model::{
557 entity::EntityModel,
558 field::{EntityFieldKind, EntityFieldModel},
559 index::IndexModel,
560 },
561 test_fixtures::InvalidEntityModelBuilder,
562 traits::EntitySchema,
563 types::Ulid,
564 value::Value,
565 };
566
567 fn field(name: &'static str, kind: EntityFieldKind) -> EntityFieldModel {
568 EntityFieldModel { name, kind }
569 }
570
571 const INDEX_FIELDS: [&str; 1] = ["tag"];
572 const INDEX_MODEL: IndexModel =
573 IndexModel::new("test::idx_tag", "test::IndexStore", &INDEX_FIELDS, false);
574
575 crate::test_entity_schema! {
576 PlanValidateIndexedEntity,
577 id = Ulid,
578 path = "plan_validate::IndexedEntity",
579 entity_name = "IndexedEntity",
580 primary_key = "id",
581 pk_index = 0,
582 fields = [
583 ("id", EntityFieldKind::Ulid),
584 ("tag", EntityFieldKind::Text),
585 ],
586 indexes = [&INDEX_MODEL],
587 }
588
589 crate::test_entity_schema! {
590 PlanValidateListEntity,
591 id = Ulid,
592 path = "plan_validate::ListEntity",
593 entity_name = "ListEntity",
594 primary_key = "id",
595 pk_index = 0,
596 fields = [
597 ("id", EntityFieldKind::Ulid),
598 ("tags", EntityFieldKind::List(&EntityFieldKind::Text)),
599 ],
600 indexes = [],
601 }
602
603 fn model_with_index() -> &'static EntityModel {
605 <PlanValidateIndexedEntity as EntitySchema>::MODEL
606 }
607
608 #[test]
609 fn model_rejects_missing_primary_key() {
610 let fields: &'static [EntityFieldModel] =
613 Box::leak(vec![field("id", EntityFieldKind::Ulid)].into_boxed_slice());
614 let missing_pk = Box::leak(Box::new(field("missing", EntityFieldKind::Ulid)));
615
616 let model = InvalidEntityModelBuilder::from_static(
617 "test::Entity",
618 "TestEntity",
619 missing_pk,
620 fields,
621 &[],
622 );
623
624 assert!(matches!(
625 SchemaInfo::from_entity_model(&model),
626 Err(ValidateError::InvalidPrimaryKey { .. })
627 ));
628 }
629
630 #[test]
631 fn model_rejects_duplicate_fields() {
632 let model = InvalidEntityModelBuilder::from_fields(
633 vec![
634 field("dup", EntityFieldKind::Text),
635 field("dup", EntityFieldKind::Text),
636 ],
637 0,
638 );
639
640 assert!(matches!(
641 SchemaInfo::from_entity_model(&model),
642 Err(ValidateError::DuplicateField { .. })
643 ));
644 }
645
646 #[test]
647 fn model_rejects_invalid_primary_key_type() {
648 let model = InvalidEntityModelBuilder::from_fields(
649 vec![field("pk", EntityFieldKind::List(&EntityFieldKind::Text))],
650 0,
651 );
652
653 assert!(matches!(
654 SchemaInfo::from_entity_model(&model),
655 Err(ValidateError::InvalidPrimaryKeyType { .. })
656 ));
657 }
658
659 #[test]
660 fn model_rejects_index_unknown_field() {
661 const INDEX_FIELDS: [&str; 1] = ["missing"];
662 const INDEX_MODEL: IndexModel = IndexModel::new(
663 "test::idx_missing",
664 "test::IndexStore",
665 &INDEX_FIELDS,
666 false,
667 );
668 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
669
670 let fields: &'static [EntityFieldModel] =
671 Box::leak(vec![field("id", EntityFieldKind::Ulid)].into_boxed_slice());
672 let model = InvalidEntityModelBuilder::from_static(
673 "test::Entity",
674 "TestEntity",
675 &fields[0],
676 fields,
677 &INDEXES,
678 );
679
680 assert!(matches!(
681 SchemaInfo::from_entity_model(&model),
682 Err(ValidateError::IndexFieldUnknown { .. })
683 ));
684 }
685
686 #[test]
687 fn model_rejects_index_unsupported_field() {
688 const INDEX_FIELDS: [&str; 1] = ["broken"];
689 const INDEX_MODEL: IndexModel =
690 IndexModel::new("test::idx_broken", "test::IndexStore", &INDEX_FIELDS, false);
691 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
692
693 let fields: &'static [EntityFieldModel] = Box::leak(
694 vec![
695 field("id", EntityFieldKind::Ulid),
696 field("broken", EntityFieldKind::Unsupported),
697 ]
698 .into_boxed_slice(),
699 );
700 let model = InvalidEntityModelBuilder::from_static(
701 "test::Entity",
702 "TestEntity",
703 &fields[0],
704 fields,
705 &INDEXES,
706 );
707
708 assert!(matches!(
709 SchemaInfo::from_entity_model(&model),
710 Err(ValidateError::IndexFieldUnsupported { .. })
711 ));
712 }
713
714 #[test]
715 fn model_rejects_index_map_field_in_0_7_x() {
716 const INDEX_FIELDS: [&str; 1] = ["attributes"];
717 const INDEX_MODEL: IndexModel = IndexModel::new(
718 "test::idx_attributes",
719 "test::IndexStore",
720 &INDEX_FIELDS,
721 false,
722 );
723 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
724
725 let fields: &'static [EntityFieldModel] = Box::leak(
726 vec![
727 field("id", EntityFieldKind::Ulid),
728 field(
729 "attributes",
730 EntityFieldKind::Map {
731 key: &EntityFieldKind::Text,
732 value: &EntityFieldKind::Uint,
733 },
734 ),
735 ]
736 .into_boxed_slice(),
737 );
738 let model = InvalidEntityModelBuilder::from_static(
739 "test::Entity",
740 "TestEntity",
741 &fields[0],
742 fields,
743 &INDEXES,
744 );
745
746 assert!(matches!(
747 SchemaInfo::from_entity_model(&model),
748 Err(ValidateError::IndexFieldMapUnsupported { .. })
749 ));
750 }
751
752 #[test]
753 fn model_rejects_duplicate_index_names() {
754 const INDEX_FIELDS_A: [&str; 1] = ["id"];
755 const INDEX_FIELDS_B: [&str; 1] = ["other"];
756 const INDEX_A: IndexModel = IndexModel::new(
757 "test::dup_index",
758 "test::IndexStore",
759 &INDEX_FIELDS_A,
760 false,
761 );
762 const INDEX_B: IndexModel = IndexModel::new(
763 "test::dup_index",
764 "test::IndexStore",
765 &INDEX_FIELDS_B,
766 false,
767 );
768 const INDEXES: [&IndexModel; 2] = [&INDEX_A, &INDEX_B];
769
770 let fields: &'static [EntityFieldModel] = Box::leak(
771 vec![
772 field("id", EntityFieldKind::Ulid),
773 field("other", EntityFieldKind::Text),
774 ]
775 .into_boxed_slice(),
776 );
777 let model = InvalidEntityModelBuilder::from_static(
778 "test::Entity",
779 "TestEntity",
780 &fields[0],
781 fields,
782 &INDEXES,
783 );
784
785 assert!(matches!(
786 SchemaInfo::from_entity_model(&model),
787 Err(ValidateError::DuplicateIndexName { .. })
788 ));
789 }
790
791 #[test]
792 fn plan_rejects_unorderable_field() {
793 let model = <PlanValidateListEntity as EntitySchema>::MODEL;
794
795 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
796 let plan: LogicalPlan<Value> = LogicalPlan {
797 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
798 access: AccessPlan::Path(AccessPath::FullScan),
799 predicate: None,
800 order: Some(OrderSpec {
801 fields: vec![("tags".to_string(), OrderDirection::Asc)],
802 }),
803 delete_limit: None,
804 page: None,
805 consistency: crate::db::query::ReadConsistency::MissingOk,
806 };
807
808 let err =
809 validate_logical_plan_model(&schema, model, &plan).expect_err("unorderable field");
810 assert!(matches!(err, PlanError::UnorderableField { .. }));
811 }
812
813 #[test]
814 fn plan_rejects_index_prefix_too_long() {
815 let model = model_with_index();
816 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
817 let plan: LogicalPlan<Value> = LogicalPlan {
818 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
819 access: AccessPlan::Path(AccessPath::IndexPrefix {
820 index: INDEX_MODEL,
821 values: vec![Value::Text("a".to_string()), Value::Text("b".to_string())],
822 }),
823 predicate: None,
824 order: None,
825 delete_limit: None,
826 page: None,
827 consistency: crate::db::query::ReadConsistency::MissingOk,
828 };
829
830 let err =
831 validate_logical_plan_model(&schema, model, &plan).expect_err("index prefix too long");
832 assert!(matches!(err, PlanError::IndexPrefixTooLong { .. }));
833 }
834
835 #[test]
836 fn plan_rejects_empty_index_prefix() {
837 let model = model_with_index();
838 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
839 let plan: LogicalPlan<Value> = LogicalPlan {
840 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
841 access: AccessPlan::Path(AccessPath::IndexPrefix {
842 index: INDEX_MODEL,
843 values: vec![],
844 }),
845 predicate: None,
846 order: None,
847 delete_limit: None,
848 page: None,
849 consistency: crate::db::query::ReadConsistency::MissingOk,
850 };
851
852 let err =
853 validate_logical_plan_model(&schema, model, &plan).expect_err("index prefix empty");
854 assert!(matches!(err, PlanError::IndexPrefixEmpty));
855 }
856
857 #[test]
858 fn plan_accepts_model_based_validation() {
859 let model = <PlanValidateIndexedEntity as EntitySchema>::MODEL;
860 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
861 let plan: LogicalPlan<Value> = LogicalPlan {
862 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
863 access: AccessPlan::Path(AccessPath::ByKey(Value::Ulid(Ulid::nil()))),
864 predicate: None,
865 order: None,
866 delete_limit: None,
867 page: None,
868 consistency: crate::db::query::ReadConsistency::MissingOk,
869 };
870
871 validate_logical_plan_model(&schema, model, &plan).expect("valid plan");
872 }
873}