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