1use super::{AccessPath, AccessPlan, LogicalPlan, OrderSpec};
3use crate::{
4 db::query::predicate::{self, SchemaInfo},
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
261pub(crate) fn validate_access_plan<K>(
265 schema: &SchemaInfo,
266 model: &EntityModel,
267 access: &AccessPlan<K>,
268) -> Result<(), PlanError>
269where
270 K: FieldValue + Ord,
271{
272 match access {
273 AccessPlan::Path(path) => validate_access_path(schema, model, path),
274 AccessPlan::Union(children) | AccessPlan::Intersection(children) => {
275 for child in children {
276 validate_access_plan(schema, model, child)?;
277 }
278 Ok(())
279 }
280 }
281}
282
283pub(crate) fn validate_access_plan_model(
285 schema: &SchemaInfo,
286 model: &EntityModel,
287 access: &AccessPlan<Value>,
288) -> Result<(), PlanError> {
289 match access {
290 AccessPlan::Path(path) => validate_access_path_model(schema, model, path),
291 AccessPlan::Union(children) | AccessPlan::Intersection(children) => {
292 for child in children {
293 validate_access_plan_model(schema, model, child)?;
294 }
295 Ok(())
296 }
297 }
298}
299
300fn validate_access_path<K>(
301 schema: &SchemaInfo,
302 model: &EntityModel,
303 access: &AccessPath<K>,
304) -> Result<(), PlanError>
305where
306 K: FieldValue + Ord,
307{
308 match access {
309 AccessPath::ByKey(key) => validate_pk_key(schema, model, key),
310 AccessPath::ByKeys(keys) => {
311 if keys.is_empty() {
313 return Ok(());
314 }
315 for key in keys {
316 validate_pk_key(schema, model, key)?;
317 }
318 Ok(())
319 }
320 AccessPath::KeyRange { start, end } => {
321 validate_pk_key(schema, model, start)?;
322 validate_pk_key(schema, model, end)?;
323 if start > end {
324 return Err(PlanError::InvalidKeyRange);
325 }
326 Ok(())
327 }
328 AccessPath::IndexPrefix { index, values } => {
329 validate_index_prefix(schema, model, index, values)
330 }
331 AccessPath::FullScan => Ok(()),
332 }
333}
334
335fn validate_access_path_model(
337 schema: &SchemaInfo,
338 model: &EntityModel,
339 access: &AccessPath<Value>,
340) -> Result<(), PlanError> {
341 match access {
342 AccessPath::ByKey(key) => validate_pk_value(schema, model, key),
343 AccessPath::ByKeys(keys) => {
344 if keys.is_empty() {
345 return Ok(());
346 }
347 for key in keys {
348 validate_pk_value(schema, model, key)?;
349 }
350 Ok(())
351 }
352 AccessPath::KeyRange { start, end } => {
353 validate_pk_value(schema, model, start)?;
354 validate_pk_value(schema, model, end)?;
355 let Some(ordering) = start.partial_cmp(end) else {
356 return Err(PlanError::InvalidKeyRange);
357 };
358 if ordering == std::cmp::Ordering::Greater {
359 return Err(PlanError::InvalidKeyRange);
360 }
361 Ok(())
362 }
363 AccessPath::IndexPrefix { index, values } => {
364 validate_index_prefix(schema, model, index, values)
365 }
366 AccessPath::FullScan => Ok(()),
367 }
368}
369
370fn validate_pk_key<K>(schema: &SchemaInfo, model: &EntityModel, key: &K) -> Result<(), PlanError>
372where
373 K: FieldValue,
374{
375 let field = model.primary_key.name;
376
377 let field_type = schema
378 .field(field)
379 .ok_or_else(|| PlanError::PrimaryKeyUnsupported {
380 field: field.to_string(),
381 })?;
382
383 if !field_type.is_keyable() {
384 return Err(PlanError::PrimaryKeyUnsupported {
385 field: field.to_string(),
386 });
387 }
388
389 let value = key.to_value();
390 if !predicate::validate::literal_matches_type(&value, field_type) {
391 return Err(PlanError::PrimaryKeyMismatch {
392 field: field.to_string(),
393 key: value,
394 });
395 }
396
397 Ok(())
398}
399
400fn validate_pk_value(
402 schema: &SchemaInfo,
403 model: &EntityModel,
404 key: &Value,
405) -> Result<(), PlanError> {
406 let field = model.primary_key.name;
407
408 let field_type = schema
409 .field(field)
410 .ok_or_else(|| PlanError::PrimaryKeyUnsupported {
411 field: field.to_string(),
412 })?;
413
414 if !field_type.is_keyable() {
415 return Err(PlanError::PrimaryKeyUnsupported {
416 field: field.to_string(),
417 });
418 }
419
420 if !predicate::validate::literal_matches_type(key, field_type) {
421 return Err(PlanError::PrimaryKeyMismatch {
422 field: field.to_string(),
423 key: key.clone(),
424 });
425 }
426
427 Ok(())
428}
429
430fn validate_index_prefix(
432 schema: &SchemaInfo,
433 model: &EntityModel,
434 index: &IndexModel,
435 values: &[Value],
436) -> Result<(), PlanError> {
437 if !model.indexes.contains(&index) {
438 return Err(PlanError::IndexNotFound { index: *index });
439 }
440
441 if values.is_empty() {
442 return Err(PlanError::IndexPrefixEmpty);
443 }
444
445 if values.len() > index.fields.len() {
446 return Err(PlanError::IndexPrefixTooLong {
447 prefix_len: values.len(),
448 field_len: index.fields.len(),
449 });
450 }
451
452 for (field, value) in index.fields.iter().zip(values.iter()) {
453 let field_type =
454 schema
455 .field(field)
456 .ok_or_else(|| PlanError::IndexPrefixValueMismatch {
457 field: field.to_string(),
458 })?;
459
460 if !predicate::validate::literal_matches_type(value, field_type) {
461 return Err(PlanError::IndexPrefixValueMismatch {
462 field: field.to_string(),
463 });
464 }
465 }
466
467 Ok(())
468}
469
470#[cfg(test)]
478mod tests {
479 use super::{PlanError, validate_logical_plan_model};
480 use crate::{
481 db::query::{
482 plan::{AccessPath, AccessPlan, LogicalPlan, OrderDirection, OrderSpec},
483 predicate::{SchemaInfo, ValidateError},
484 },
485 model::{
486 entity::EntityModel,
487 field::{EntityFieldKind, EntityFieldModel},
488 index::IndexModel,
489 },
490 types::Ulid,
491 value::Value,
492 };
493
494 fn field(name: &'static str, kind: EntityFieldKind) -> EntityFieldModel {
495 EntityFieldModel { name, kind }
496 }
497
498 fn model_with_fields(fields: Vec<EntityFieldModel>, pk_index: usize) -> EntityModel {
499 let fields: &'static [EntityFieldModel] = Box::leak(fields.into_boxed_slice());
500 let primary_key = &fields[pk_index];
501 let indexes: &'static [&'static IndexModel] = &[];
502
503 EntityModel {
504 path: "test::Entity",
505 entity_name: "TestEntity",
506 primary_key,
507 fields,
508 indexes,
509 }
510 }
511
512 const INDEX_FIELDS: [&str; 1] = ["tag"];
513 const INDEX_MODEL: IndexModel =
514 IndexModel::new("test::idx_tag", "test::IndexStore", &INDEX_FIELDS, false);
515 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
516
517 fn model_with_index() -> EntityModel {
518 let fields: &'static [EntityFieldModel] = Box::leak(
519 vec![
520 field("id", EntityFieldKind::Ulid),
521 field("tag", EntityFieldKind::Text),
522 ]
523 .into_boxed_slice(),
524 );
525
526 EntityModel {
527 path: "test::Entity",
528 entity_name: "TestEntity",
529 primary_key: &fields[0],
530 fields,
531 indexes: &INDEXES,
532 }
533 }
534
535 #[test]
536 fn model_rejects_missing_primary_key() {
537 let fields: &'static [EntityFieldModel] =
538 Box::leak(vec![field("id", EntityFieldKind::Ulid)].into_boxed_slice());
539 let missing_pk = Box::leak(Box::new(field("missing", EntityFieldKind::Ulid)));
540
541 let model = EntityModel {
542 path: "test::Entity",
543 entity_name: "TestEntity",
544 primary_key: missing_pk,
545 fields,
546 indexes: &[],
547 };
548
549 assert!(matches!(
550 SchemaInfo::from_entity_model(&model),
551 Err(ValidateError::InvalidPrimaryKey { .. })
552 ));
553 }
554
555 #[test]
556 fn model_rejects_duplicate_fields() {
557 let model = model_with_fields(
558 vec![
559 field("dup", EntityFieldKind::Text),
560 field("dup", EntityFieldKind::Text),
561 ],
562 0,
563 );
564
565 assert!(matches!(
566 SchemaInfo::from_entity_model(&model),
567 Err(ValidateError::DuplicateField { .. })
568 ));
569 }
570
571 #[test]
572 fn model_rejects_invalid_primary_key_type() {
573 let model = model_with_fields(
574 vec![field("pk", EntityFieldKind::List(&EntityFieldKind::Text))],
575 0,
576 );
577
578 assert!(matches!(
579 SchemaInfo::from_entity_model(&model),
580 Err(ValidateError::InvalidPrimaryKeyType { .. })
581 ));
582 }
583
584 #[test]
585 fn model_rejects_index_unknown_field() {
586 const INDEX_FIELDS: [&str; 1] = ["missing"];
587 const INDEX_MODEL: IndexModel = IndexModel::new(
588 "test::idx_missing",
589 "test::IndexStore",
590 &INDEX_FIELDS,
591 false,
592 );
593 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
594
595 let fields: &'static [EntityFieldModel] =
596 Box::leak(vec![field("id", EntityFieldKind::Ulid)].into_boxed_slice());
597 let model = EntityModel {
598 path: "test::Entity",
599 entity_name: "TestEntity",
600 primary_key: &fields[0],
601 fields,
602 indexes: &INDEXES,
603 };
604
605 assert!(matches!(
606 SchemaInfo::from_entity_model(&model),
607 Err(ValidateError::IndexFieldUnknown { .. })
608 ));
609 }
610
611 #[test]
612 fn model_rejects_index_unsupported_field() {
613 const INDEX_FIELDS: [&str; 1] = ["broken"];
614 const INDEX_MODEL: IndexModel =
615 IndexModel::new("test::idx_broken", "test::IndexStore", &INDEX_FIELDS, false);
616 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
617
618 let fields: &'static [EntityFieldModel] = Box::leak(
619 vec![
620 field("id", EntityFieldKind::Ulid),
621 field("broken", EntityFieldKind::Unsupported),
622 ]
623 .into_boxed_slice(),
624 );
625 let model = EntityModel {
626 path: "test::Entity",
627 entity_name: "TestEntity",
628 primary_key: &fields[0],
629 fields,
630 indexes: &INDEXES,
631 };
632
633 assert!(matches!(
634 SchemaInfo::from_entity_model(&model),
635 Err(ValidateError::IndexFieldUnsupported { .. })
636 ));
637 }
638
639 #[test]
640 fn model_rejects_duplicate_index_names() {
641 const INDEX_FIELDS_A: [&str; 1] = ["id"];
642 const INDEX_FIELDS_B: [&str; 1] = ["other"];
643 const INDEX_A: IndexModel = IndexModel::new(
644 "test::dup_index",
645 "test::IndexStore",
646 &INDEX_FIELDS_A,
647 false,
648 );
649 const INDEX_B: IndexModel = IndexModel::new(
650 "test::dup_index",
651 "test::IndexStore",
652 &INDEX_FIELDS_B,
653 false,
654 );
655 const INDEXES: [&IndexModel; 2] = [&INDEX_A, &INDEX_B];
656
657 let fields: &'static [EntityFieldModel] = Box::leak(
658 vec![
659 field("id", EntityFieldKind::Ulid),
660 field("other", EntityFieldKind::Text),
661 ]
662 .into_boxed_slice(),
663 );
664 let model = EntityModel {
665 path: "test::Entity",
666 entity_name: "TestEntity",
667 primary_key: &fields[0],
668 fields,
669 indexes: &INDEXES,
670 };
671
672 assert!(matches!(
673 SchemaInfo::from_entity_model(&model),
674 Err(ValidateError::DuplicateIndexName { .. })
675 ));
676 }
677
678 #[test]
679 fn plan_rejects_unorderable_field() {
680 let model = model_with_fields(
681 vec![
682 field("id", EntityFieldKind::Ulid),
683 field("tags", EntityFieldKind::List(&EntityFieldKind::Text)),
684 ],
685 0,
686 );
687
688 let schema = SchemaInfo::from_entity_model(&model).expect("valid model");
689 let plan: LogicalPlan<Value> = LogicalPlan {
690 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
691 access: AccessPlan::Path(AccessPath::FullScan),
692 predicate: None,
693 order: Some(OrderSpec {
694 fields: vec![("tags".to_string(), OrderDirection::Asc)],
695 }),
696 delete_limit: None,
697 page: None,
698 consistency: crate::db::query::ReadConsistency::MissingOk,
699 };
700
701 let err =
702 validate_logical_plan_model(&schema, &model, &plan).expect_err("unorderable field");
703 assert!(matches!(err, PlanError::UnorderableField { .. }));
704 }
705
706 #[test]
707 fn plan_rejects_index_prefix_too_long() {
708 let model = model_with_index();
709 let schema = SchemaInfo::from_entity_model(&model).expect("valid model");
710 let plan: LogicalPlan<Value> = LogicalPlan {
711 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
712 access: AccessPlan::Path(AccessPath::IndexPrefix {
713 index: INDEX_MODEL,
714 values: vec![Value::Text("a".to_string()), Value::Text("b".to_string())],
715 }),
716 predicate: None,
717 order: None,
718 delete_limit: None,
719 page: None,
720 consistency: crate::db::query::ReadConsistency::MissingOk,
721 };
722
723 let err =
724 validate_logical_plan_model(&schema, &model, &plan).expect_err("index prefix too long");
725 assert!(matches!(err, PlanError::IndexPrefixTooLong { .. }));
726 }
727
728 #[test]
729 fn plan_rejects_empty_index_prefix() {
730 let model = model_with_index();
731 let schema = SchemaInfo::from_entity_model(&model).expect("valid model");
732 let plan: LogicalPlan<Value> = LogicalPlan {
733 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
734 access: AccessPlan::Path(AccessPath::IndexPrefix {
735 index: INDEX_MODEL,
736 values: vec![],
737 }),
738 predicate: None,
739 order: None,
740 delete_limit: None,
741 page: None,
742 consistency: crate::db::query::ReadConsistency::MissingOk,
743 };
744
745 let err =
746 validate_logical_plan_model(&schema, &model, &plan).expect_err("index prefix empty");
747 assert!(matches!(err, PlanError::IndexPrefixEmpty));
748 }
749
750 #[test]
751 fn plan_accepts_model_based_validation() {
752 let model = model_with_fields(vec![field("id", EntityFieldKind::Ulid)], 0);
753 let schema = SchemaInfo::from_entity_model(&model).expect("valid model");
754 let plan: LogicalPlan<Value> = LogicalPlan {
755 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
756 access: AccessPlan::Path(AccessPath::ByKey(Value::Ulid(Ulid::nil()))),
757 predicate: None,
758 order: None,
759 delete_limit: None,
760 page: None,
761 consistency: crate::db::query::ReadConsistency::MissingOk,
762 };
763
764 validate_logical_plan_model(&schema, &model, &plan).expect("valid plan");
765 }
766}