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 PrimaryKeyNotKeyable { 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 #[error(
90 "Unordered pagination is not allowed.\nThis query uses LIMIT or OFFSET without an ORDER BY clause.\nPagination without a total ordering is non-deterministic.\nAdd an explicit order_by(...) to make the query stable."
91 )]
92 UnorderedPagination,
93}
94
95#[cfg(test)]
97pub(crate) fn validate_plan_with_schema_info<K>(
98 schema: &SchemaInfo,
99 model: &EntityModel,
100 plan: &LogicalPlan<K>,
101) -> Result<(), PlanError>
102where
103 K: FieldValue + Ord,
104{
105 validate_logical_plan(schema, model, plan)
106}
107
108#[cfg(test)]
112#[expect(dead_code)]
113pub(crate) fn validate_plan_with_model<K>(
114 plan: &LogicalPlan<K>,
115 model: &EntityModel,
116) -> Result<(), PlanError>
117where
118 K: FieldValue + Ord,
119{
120 let schema = SchemaInfo::from_entity_model(model)?;
121 validate_plan_with_schema_info(&schema, model, plan)
122}
123
124#[cfg(test)]
126pub(crate) fn validate_logical_plan<K>(
127 schema: &SchemaInfo,
128 model: &EntityModel,
129 plan: &LogicalPlan<K>,
130) -> Result<(), PlanError>
131where
132 K: FieldValue + Ord,
133{
134 if let Some(predicate) = &plan.predicate {
135 predicate::validate(schema, predicate)?;
136 }
137
138 if let Some(order) = &plan.order {
139 validate_order(schema, order)?;
140 }
141
142 validate_access_plan(schema, model, &plan.access)?;
143 validate_plan_semantics(plan)?;
144
145 Ok(())
146}
147
148pub(crate) fn validate_logical_plan_model(
157 schema: &SchemaInfo,
158 model: &EntityModel,
159 plan: &LogicalPlan<Value>,
160) -> Result<(), PlanError> {
161 if let Some(predicate) = &plan.predicate {
162 predicate::validate(schema, predicate)?;
163 }
164
165 if let Some(order) = &plan.order {
166 validate_order(schema, order)?;
167 }
168
169 validate_access_plan_model(schema, model, &plan.access)?;
170 validate_plan_semantics(plan)?;
171
172 Ok(())
173}
174
175fn validate_plan_semantics<K>(plan: &LogicalPlan<K>) -> Result<(), PlanError> {
177 if let Some(order) = &plan.order
178 && order.fields.is_empty()
179 {
180 return Err(PlanError::EmptyOrderSpec);
181 }
182
183 if plan.mode.is_delete() {
184 if plan.page.is_some() {
185 return Err(PlanError::DeletePlanWithPagination);
186 }
187
188 if plan.delete_limit.is_some()
189 && plan
190 .order
191 .as_ref()
192 .is_none_or(|order| order.fields.is_empty())
193 {
194 return Err(PlanError::DeleteLimitRequiresOrder);
195 }
196 }
197
198 if plan.mode.is_load() {
199 if plan.delete_limit.is_some() {
200 return Err(PlanError::LoadPlanWithDeleteLimit);
201 }
202
203 if plan.page.is_some()
204 && plan
205 .order
206 .as_ref()
207 .is_none_or(|order| order.fields.is_empty())
208 {
209 return Err(PlanError::UnorderedPagination);
210 }
211 }
212
213 Ok(())
214}
215
216pub(crate) fn validate_executor_plan<E: EntityKind>(
225 plan: &LogicalPlan<E::Key>,
226) -> Result<(), InternalError> {
227 let schema = SchemaInfo::from_entity_model(E::MODEL).map_err(|err| {
228 InternalError::new(
229 ErrorClass::InvariantViolation,
230 ErrorOrigin::Query,
231 format!("entity schema invalid for {}: {err}", E::PATH),
232 )
233 })?;
234
235 if let Some(predicate) = &plan.predicate {
236 predicate::validate(&schema, predicate).map_err(|err| {
237 InternalError::new(
238 ErrorClass::InvariantViolation,
239 ErrorOrigin::Query,
240 err.to_string(),
241 )
242 })?;
243 }
244
245 if let Some(order) = &plan.order {
246 validate_executor_order(&schema, order).map_err(|err| {
247 InternalError::new(
248 ErrorClass::InvariantViolation,
249 ErrorOrigin::Query,
250 err.to_string(),
251 )
252 })?;
253 }
254
255 validate_access_plan(&schema, E::MODEL, &plan.access).map_err(|err| {
256 InternalError::new(
257 ErrorClass::InvariantViolation,
258 ErrorOrigin::Query,
259 err.to_string(),
260 )
261 })?;
262
263 validate_plan_semantics(plan).map_err(|err| {
264 InternalError::new(
265 ErrorClass::InvariantViolation,
266 ErrorOrigin::Query,
267 err.to_string(),
268 )
269 })?;
270
271 Ok(())
272}
273
274pub(crate) fn validate_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
276 for (field, _) in &order.fields {
277 let field_type = schema
278 .field(field)
279 .ok_or_else(|| PlanError::UnknownOrderField {
280 field: field.clone(),
281 })?;
282
283 if !field_type.is_orderable() {
284 return Err(PlanError::UnorderableField {
286 field: field.clone(),
287 });
288 }
289 }
290
291 Ok(())
292}
293
294fn validate_executor_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
298 validate_order(schema, order)
299}
300
301trait AccessPlanKeyAdapter<K> {
303 fn validate_pk_key(
305 &self,
306 schema: &SchemaInfo,
307 model: &EntityModel,
308 key: &K,
309 ) -> Result<(), PlanError>;
310
311 fn validate_key_range(
313 &self,
314 schema: &SchemaInfo,
315 model: &EntityModel,
316 start: &K,
317 end: &K,
318 ) -> Result<(), PlanError>;
319}
320
321struct GenericKeyAdapter;
323
324impl<K> AccessPlanKeyAdapter<K> for GenericKeyAdapter
325where
326 K: FieldValue + Ord,
327{
328 fn validate_pk_key(
329 &self,
330 schema: &SchemaInfo,
331 model: &EntityModel,
332 key: &K,
333 ) -> Result<(), PlanError> {
334 validate_pk_key(schema, model, key)
335 }
336
337 fn validate_key_range(
338 &self,
339 schema: &SchemaInfo,
340 model: &EntityModel,
341 start: &K,
342 end: &K,
343 ) -> Result<(), PlanError> {
344 validate_pk_key(schema, model, start)?;
345 validate_pk_key(schema, model, end)?;
346 if start > end {
347 return Err(PlanError::InvalidKeyRange);
348 }
349
350 Ok(())
351 }
352}
353
354struct ValueKeyAdapter;
356
357impl AccessPlanKeyAdapter<Value> for ValueKeyAdapter {
358 fn validate_pk_key(
359 &self,
360 schema: &SchemaInfo,
361 model: &EntityModel,
362 key: &Value,
363 ) -> Result<(), PlanError> {
364 validate_pk_value(schema, model, key)
365 }
366
367 fn validate_key_range(
368 &self,
369 schema: &SchemaInfo,
370 model: &EntityModel,
371 start: &Value,
372 end: &Value,
373 ) -> Result<(), PlanError> {
374 validate_pk_value(schema, model, start)?;
375 validate_pk_value(schema, model, end)?;
376 let ordering = canonical_cmp(start, end);
377 if ordering == std::cmp::Ordering::Greater {
378 return Err(PlanError::InvalidKeyRange);
379 }
380
381 Ok(())
382 }
383}
384
385fn validate_access_plan_with<K>(
387 schema: &SchemaInfo,
388 model: &EntityModel,
389 access: &AccessPlan<K>,
390 adapter: &impl AccessPlanKeyAdapter<K>,
391) -> Result<(), PlanError> {
392 match access {
393 AccessPlan::Path(path) => validate_access_path_with(schema, model, path, adapter),
394 AccessPlan::Union(children) | AccessPlan::Intersection(children) => {
395 for child in children {
396 validate_access_plan_with(schema, model, child, adapter)?;
397 }
398
399 Ok(())
400 }
401 }
402}
403
404fn validate_access_path_with<K>(
406 schema: &SchemaInfo,
407 model: &EntityModel,
408 access: &AccessPath<K>,
409 adapter: &impl AccessPlanKeyAdapter<K>,
410) -> Result<(), PlanError> {
411 match access {
412 AccessPath::ByKey(key) => adapter.validate_pk_key(schema, model, key),
413 AccessPath::ByKeys(keys) => {
414 if keys.is_empty() {
416 return Ok(());
417 }
418 for key in keys {
419 adapter.validate_pk_key(schema, model, key)?;
420 }
421
422 Ok(())
423 }
424 AccessPath::KeyRange { start, end } => {
425 adapter.validate_key_range(schema, model, start, end)
426 }
427 AccessPath::IndexPrefix { index, values } => {
428 validate_index_prefix(schema, model, index, values)
429 }
430 AccessPath::FullScan => Ok(()),
431 }
432}
433
434pub(crate) fn validate_access_plan<K>(
438 schema: &SchemaInfo,
439 model: &EntityModel,
440 access: &AccessPlan<K>,
441) -> Result<(), PlanError>
442where
443 K: FieldValue + Ord,
444{
445 validate_access_plan_with(schema, model, access, &GenericKeyAdapter)
446}
447
448pub(crate) fn validate_access_plan_model(
450 schema: &SchemaInfo,
451 model: &EntityModel,
452 access: &AccessPlan<Value>,
453) -> Result<(), PlanError> {
454 validate_access_plan_with(schema, model, access, &ValueKeyAdapter)
455}
456
457fn validate_pk_key<K>(schema: &SchemaInfo, model: &EntityModel, key: &K) -> Result<(), PlanError>
459where
460 K: FieldValue,
461{
462 let field = model.primary_key.name;
463
464 let field_type = schema
465 .field(field)
466 .ok_or_else(|| PlanError::PrimaryKeyNotKeyable {
467 field: field.to_string(),
468 })?;
469
470 if !field_type.is_keyable() {
471 return Err(PlanError::PrimaryKeyNotKeyable {
472 field: field.to_string(),
473 });
474 }
475
476 let value = key.to_value();
477 if !predicate::validate::literal_matches_type(&value, field_type) {
478 return Err(PlanError::PrimaryKeyMismatch {
479 field: field.to_string(),
480 key: value,
481 });
482 }
483
484 Ok(())
485}
486
487fn validate_pk_value(
489 schema: &SchemaInfo,
490 model: &EntityModel,
491 key: &Value,
492) -> Result<(), PlanError> {
493 let field = model.primary_key.name;
494
495 let field_type = schema
496 .field(field)
497 .ok_or_else(|| PlanError::PrimaryKeyNotKeyable {
498 field: field.to_string(),
499 })?;
500
501 if !field_type.is_keyable() {
502 return Err(PlanError::PrimaryKeyNotKeyable {
503 field: field.to_string(),
504 });
505 }
506
507 if !predicate::validate::literal_matches_type(key, field_type) {
508 return Err(PlanError::PrimaryKeyMismatch {
509 field: field.to_string(),
510 key: key.clone(),
511 });
512 }
513
514 Ok(())
515}
516
517fn validate_index_prefix(
519 schema: &SchemaInfo,
520 model: &EntityModel,
521 index: &IndexModel,
522 values: &[Value],
523) -> Result<(), PlanError> {
524 if !model.indexes.contains(&index) {
525 return Err(PlanError::IndexNotFound { index: *index });
526 }
527
528 if values.is_empty() {
529 return Err(PlanError::IndexPrefixEmpty);
530 }
531
532 if values.len() > index.fields.len() {
533 return Err(PlanError::IndexPrefixTooLong {
534 prefix_len: values.len(),
535 field_len: index.fields.len(),
536 });
537 }
538
539 for (field, value) in index.fields.iter().zip(values.iter()) {
540 let field_type =
541 schema
542 .field(field)
543 .ok_or_else(|| PlanError::IndexPrefixValueMismatch {
544 field: field.to_string(),
545 })?;
546
547 if !predicate::validate::literal_matches_type(value, field_type) {
548 return Err(PlanError::IndexPrefixValueMismatch {
549 field: field.to_string(),
550 });
551 }
552 }
553
554 Ok(())
555}
556
557#[cfg(test)]
565mod tests {
566 use super::{PlanError, validate_logical_plan_model};
568 use crate::{
569 db::query::{
570 plan::{AccessPath, AccessPlan, LogicalPlan, OrderDirection, OrderSpec, PageSpec},
571 predicate::{SchemaInfo, ValidateError},
572 },
573 model::{
574 entity::EntityModel,
575 field::{EntityFieldKind, EntityFieldModel},
576 index::IndexModel,
577 },
578 test_fixtures::InvalidEntityModelBuilder,
579 traits::EntitySchema,
580 types::Ulid,
581 value::Value,
582 };
583
584 fn field(name: &'static str, kind: EntityFieldKind) -> EntityFieldModel {
585 EntityFieldModel { name, kind }
586 }
587
588 const INDEX_FIELDS: [&str; 1] = ["tag"];
589 const INDEX_MODEL: IndexModel =
590 IndexModel::new("test::idx_tag", "test::IndexStore", &INDEX_FIELDS, false);
591
592 crate::test_entity_schema! {
593 PlanValidateIndexedEntity,
594 id = Ulid,
595 path = "plan_validate::IndexedEntity",
596 entity_name = "IndexedEntity",
597 primary_key = "id",
598 pk_index = 0,
599 fields = [
600 ("id", EntityFieldKind::Ulid),
601 ("tag", EntityFieldKind::Text),
602 ],
603 indexes = [&INDEX_MODEL],
604 }
605
606 crate::test_entity_schema! {
607 PlanValidateListEntity,
608 id = Ulid,
609 path = "plan_validate::ListEntity",
610 entity_name = "ListEntity",
611 primary_key = "id",
612 pk_index = 0,
613 fields = [
614 ("id", EntityFieldKind::Ulid),
615 ("tags", EntityFieldKind::List(&EntityFieldKind::Text)),
616 ],
617 indexes = [],
618 }
619
620 fn model_with_index() -> &'static EntityModel {
622 <PlanValidateIndexedEntity as EntitySchema>::MODEL
623 }
624
625 #[test]
626 fn model_rejects_missing_primary_key() {
627 let fields: &'static [EntityFieldModel] =
630 Box::leak(vec![field("id", EntityFieldKind::Ulid)].into_boxed_slice());
631 let missing_pk = Box::leak(Box::new(field("missing", EntityFieldKind::Ulid)));
632
633 let model = InvalidEntityModelBuilder::from_static(
634 "test::Entity",
635 "TestEntity",
636 missing_pk,
637 fields,
638 &[],
639 );
640
641 assert!(matches!(
642 SchemaInfo::from_entity_model(&model),
643 Err(ValidateError::InvalidPrimaryKey { .. })
644 ));
645 }
646
647 #[test]
648 fn model_rejects_duplicate_fields() {
649 let model = InvalidEntityModelBuilder::from_fields(
650 vec![
651 field("dup", EntityFieldKind::Text),
652 field("dup", EntityFieldKind::Text),
653 ],
654 0,
655 );
656
657 assert!(matches!(
658 SchemaInfo::from_entity_model(&model),
659 Err(ValidateError::DuplicateField { .. })
660 ));
661 }
662
663 #[test]
664 fn model_rejects_invalid_primary_key_type() {
665 let model = InvalidEntityModelBuilder::from_fields(
666 vec![field("pk", EntityFieldKind::List(&EntityFieldKind::Text))],
667 0,
668 );
669
670 assert!(matches!(
671 SchemaInfo::from_entity_model(&model),
672 Err(ValidateError::InvalidPrimaryKeyType { .. })
673 ));
674 }
675
676 #[test]
677 fn model_rejects_index_unknown_field() {
678 const INDEX_FIELDS: [&str; 1] = ["missing"];
679 const INDEX_MODEL: IndexModel = IndexModel::new(
680 "test::idx_missing",
681 "test::IndexStore",
682 &INDEX_FIELDS,
683 false,
684 );
685 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
686
687 let fields: &'static [EntityFieldModel] =
688 Box::leak(vec![field("id", EntityFieldKind::Ulid)].into_boxed_slice());
689 let model = InvalidEntityModelBuilder::from_static(
690 "test::Entity",
691 "TestEntity",
692 &fields[0],
693 fields,
694 &INDEXES,
695 );
696
697 assert!(matches!(
698 SchemaInfo::from_entity_model(&model),
699 Err(ValidateError::IndexFieldUnknown { .. })
700 ));
701 }
702
703 #[test]
704 fn model_rejects_index_non_queryable_field() {
705 const INDEX_FIELDS: [&str; 1] = ["broken"];
706 const INDEX_MODEL: IndexModel =
707 IndexModel::new("test::idx_broken", "test::IndexStore", &INDEX_FIELDS, false);
708 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
709
710 let fields: &'static [EntityFieldModel] = Box::leak(
711 vec![
712 field("id", EntityFieldKind::Ulid),
713 field("broken", EntityFieldKind::Structured { queryable: false }),
714 ]
715 .into_boxed_slice(),
716 );
717 let model = InvalidEntityModelBuilder::from_static(
718 "test::Entity",
719 "TestEntity",
720 &fields[0],
721 fields,
722 &INDEXES,
723 );
724
725 assert!(matches!(
726 SchemaInfo::from_entity_model(&model),
727 Err(ValidateError::IndexFieldNotQueryable { .. })
728 ));
729 }
730
731 #[test]
732 fn model_rejects_index_map_field_in_0_7_x() {
733 const INDEX_FIELDS: [&str; 1] = ["attributes"];
734 const INDEX_MODEL: IndexModel = IndexModel::new(
735 "test::idx_attributes",
736 "test::IndexStore",
737 &INDEX_FIELDS,
738 false,
739 );
740 const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
741
742 let fields: &'static [EntityFieldModel] = Box::leak(
743 vec![
744 field("id", EntityFieldKind::Ulid),
745 field(
746 "attributes",
747 EntityFieldKind::Map {
748 key: &EntityFieldKind::Text,
749 value: &EntityFieldKind::Uint,
750 },
751 ),
752 ]
753 .into_boxed_slice(),
754 );
755 let model = InvalidEntityModelBuilder::from_static(
756 "test::Entity",
757 "TestEntity",
758 &fields[0],
759 fields,
760 &INDEXES,
761 );
762
763 assert!(matches!(
764 SchemaInfo::from_entity_model(&model),
765 Err(ValidateError::IndexFieldMapNotQueryable { .. })
766 ));
767 }
768
769 #[test]
770 fn model_rejects_duplicate_index_names() {
771 const INDEX_FIELDS_A: [&str; 1] = ["id"];
772 const INDEX_FIELDS_B: [&str; 1] = ["other"];
773 const INDEX_A: IndexModel = IndexModel::new(
774 "test::dup_index",
775 "test::IndexStore",
776 &INDEX_FIELDS_A,
777 false,
778 );
779 const INDEX_B: IndexModel = IndexModel::new(
780 "test::dup_index",
781 "test::IndexStore",
782 &INDEX_FIELDS_B,
783 false,
784 );
785 const INDEXES: [&IndexModel; 2] = [&INDEX_A, &INDEX_B];
786
787 let fields: &'static [EntityFieldModel] = Box::leak(
788 vec![
789 field("id", EntityFieldKind::Ulid),
790 field("other", EntityFieldKind::Text),
791 ]
792 .into_boxed_slice(),
793 );
794 let model = InvalidEntityModelBuilder::from_static(
795 "test::Entity",
796 "TestEntity",
797 &fields[0],
798 fields,
799 &INDEXES,
800 );
801
802 assert!(matches!(
803 SchemaInfo::from_entity_model(&model),
804 Err(ValidateError::DuplicateIndexName { .. })
805 ));
806 }
807
808 #[test]
809 fn plan_rejects_unorderable_field() {
810 let model = <PlanValidateListEntity as EntitySchema>::MODEL;
811
812 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
813 let plan: LogicalPlan<Value> = LogicalPlan {
814 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
815 access: AccessPlan::Path(AccessPath::FullScan),
816 predicate: None,
817 order: Some(OrderSpec {
818 fields: vec![("tags".to_string(), OrderDirection::Asc)],
819 }),
820 delete_limit: None,
821 page: None,
822 consistency: crate::db::query::ReadConsistency::MissingOk,
823 };
824
825 let err =
826 validate_logical_plan_model(&schema, model, &plan).expect_err("unorderable field");
827 assert!(matches!(err, PlanError::UnorderableField { .. }));
828 }
829
830 #[test]
831 fn plan_rejects_index_prefix_too_long() {
832 let model = model_with_index();
833 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
834 let plan: LogicalPlan<Value> = LogicalPlan {
835 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
836 access: AccessPlan::Path(AccessPath::IndexPrefix {
837 index: INDEX_MODEL,
838 values: vec![Value::Text("a".to_string()), Value::Text("b".to_string())],
839 }),
840 predicate: None,
841 order: None,
842 delete_limit: None,
843 page: None,
844 consistency: crate::db::query::ReadConsistency::MissingOk,
845 };
846
847 let err =
848 validate_logical_plan_model(&schema, model, &plan).expect_err("index prefix too long");
849 assert!(matches!(err, PlanError::IndexPrefixTooLong { .. }));
850 }
851
852 #[test]
853 fn plan_rejects_empty_index_prefix() {
854 let model = model_with_index();
855 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
856 let plan: LogicalPlan<Value> = LogicalPlan {
857 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
858 access: AccessPlan::Path(AccessPath::IndexPrefix {
859 index: INDEX_MODEL,
860 values: vec![],
861 }),
862 predicate: None,
863 order: None,
864 delete_limit: None,
865 page: None,
866 consistency: crate::db::query::ReadConsistency::MissingOk,
867 };
868
869 let err =
870 validate_logical_plan_model(&schema, model, &plan).expect_err("index prefix empty");
871 assert!(matches!(err, PlanError::IndexPrefixEmpty));
872 }
873
874 #[test]
875 fn plan_accepts_model_based_validation() {
876 let model = <PlanValidateIndexedEntity as EntitySchema>::MODEL;
877 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
878 let plan: LogicalPlan<Value> = LogicalPlan {
879 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
880 access: AccessPlan::Path(AccessPath::ByKey(Value::Ulid(Ulid::nil()))),
881 predicate: None,
882 order: None,
883 delete_limit: None,
884 page: None,
885 consistency: crate::db::query::ReadConsistency::MissingOk,
886 };
887
888 validate_logical_plan_model(&schema, model, &plan).expect("valid plan");
889 }
890
891 #[test]
892 fn plan_rejects_unordered_pagination() {
893 let model = <PlanValidateIndexedEntity as EntitySchema>::MODEL;
894 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
895 let plan: LogicalPlan<Value> = LogicalPlan {
896 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
897 access: AccessPlan::Path(AccessPath::FullScan),
898 predicate: None,
899 order: None,
900 delete_limit: None,
901 page: Some(PageSpec {
902 limit: Some(10),
903 offset: 2,
904 }),
905 consistency: crate::db::query::ReadConsistency::MissingOk,
906 };
907
908 let err = validate_logical_plan_model(&schema, model, &plan)
909 .expect_err("pagination without ordering must be rejected");
910 assert!(matches!(err, PlanError::UnorderedPagination));
911 }
912
913 #[test]
914 fn plan_accepts_ordered_pagination() {
915 let model = <PlanValidateIndexedEntity as EntitySchema>::MODEL;
916 let schema = SchemaInfo::from_entity_model(model).expect("valid model");
917 let plan: LogicalPlan<Value> = LogicalPlan {
918 mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
919 access: AccessPlan::Path(AccessPath::FullScan),
920 predicate: None,
921 order: Some(OrderSpec {
922 fields: vec![("id".to_string(), OrderDirection::Asc)],
923 }),
924 delete_limit: None,
925 page: Some(PageSpec {
926 limit: Some(10),
927 offset: 2,
928 }),
929 consistency: crate::db::query::ReadConsistency::MissingOk,
930 };
931
932 validate_logical_plan_model(&schema, model, &plan).expect("ordered pagination is valid");
933 }
934}