Skip to main content

icydb_core/db/query/plan/
validate.rs

1//! Query-plan validation at logical and executor boundaries.
2//!
3//! Validation ownership contract:
4//! - `validate_logical_plan_model` owns user-facing query semantics and emits `PlanError`.
5//! - `validate_executor_plan` is defensive: it re-checks owned semantics/invariants before
6//!   execution and must not introduce new user-visible semantics.
7//!
8//! Future rule changes must declare a semantic owner. Defensive re-check layers may mirror
9//! rules, but must not reinterpret semantics or error class intent.
10use super::{AccessPath, AccessPlan, LogicalPlan, OrderSpec};
11use crate::{
12    db::query::predicate::{self, SchemaInfo, coercion::canonical_cmp},
13    error::{ErrorClass, ErrorOrigin, InternalError},
14    model::entity::EntityModel,
15    model::index::IndexModel,
16    traits::{EntityKind, FieldValue},
17    value::Value,
18};
19use thiserror::Error as ThisError;
20
21///
22/// PlanError
23///
24/// Executor-visible validation failures for logical plans.
25///
26/// These errors indicate that a plan cannot be safely executed against the
27/// current schema or entity definition. They are *not* planner bugs.
28///
29
30#[derive(Debug, ThisError)]
31pub enum PlanError {
32    #[error("predicate validation failed: {0}")]
33    PredicateInvalid(#[from] predicate::ValidateError),
34
35    /// ORDER BY references an unknown field.
36    #[error("unknown order field '{field}'")]
37    UnknownOrderField { field: String },
38
39    /// ORDER BY references a field that cannot be ordered.
40    #[error("order field '{field}' is not orderable")]
41    UnorderableField { field: String },
42
43    /// Access plan references an index not declared on the entity.
44    #[error("index '{index}' not found on entity")]
45    IndexNotFound { index: IndexModel },
46
47    /// Index prefix exceeds the number of indexed fields.
48    #[error("index prefix length {prefix_len} exceeds index field count {field_len}")]
49    IndexPrefixTooLong { prefix_len: usize, field_len: usize },
50
51    /// Index prefix must include at least one value.
52    #[error("index prefix must include at least one value")]
53    IndexPrefixEmpty,
54
55    /// Index prefix literal does not match indexed field type.
56    #[error("index prefix value for field '{field}' is incompatible")]
57    IndexPrefixValueMismatch { field: String },
58
59    /// Primary key field exists but is not key-compatible.
60    #[error("primary key field '{field}' is not key-compatible")]
61    PrimaryKeyNotKeyable { field: String },
62
63    /// Supplied key does not match the primary key type.
64    #[error("key '{key:?}' is incompatible with primary key '{field}'")]
65    PrimaryKeyMismatch { field: String, key: Value },
66
67    /// Key range has invalid ordering.
68    #[error("key range start is greater than end")]
69    InvalidKeyRange,
70
71    /// ORDER BY must specify at least one field.
72    #[error("order specification must include at least one field")]
73    EmptyOrderSpec,
74
75    /// Delete plans must not carry pagination.
76    #[error("delete plans must not include pagination")]
77    DeletePlanWithPagination,
78
79    /// Load plans must not carry delete limits.
80    #[error("load plans must not include delete limits")]
81    LoadPlanWithDeleteLimit,
82
83    /// Delete limits require an explicit ordering.
84    #[error("delete limit requires an explicit ordering")]
85    DeleteLimitRequiresOrder,
86
87    /// Pagination requires an explicit ordering.
88    #[error(
89        "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."
90    )]
91    UnorderedPagination,
92}
93
94/// Validate a logical plan with model-level key values.
95///
96/// Ownership:
97/// - semantic owner for user-facing query validity at planning boundaries
98/// - failures here are user-visible planning failures (`PlanError`)
99///
100/// New user-facing validation rules must be introduced here first, then mirrored
101/// defensively in downstream layers without changing semantics.
102pub(crate) fn validate_logical_plan_model(
103    schema: &SchemaInfo,
104    model: &EntityModel,
105    plan: &LogicalPlan<Value>,
106) -> Result<(), PlanError> {
107    if let Some(predicate) = &plan.predicate {
108        predicate::validate(schema, predicate)?;
109    }
110
111    if let Some(order) = &plan.order {
112        validate_order(schema, order)?;
113    }
114
115    validate_access_plan_model(schema, model, &plan.access)?;
116    validate_plan_semantics(plan)?;
117
118    Ok(())
119}
120
121/// Validate plan-level invariants not covered by schema checks.
122fn validate_plan_semantics<K>(plan: &LogicalPlan<K>) -> Result<(), PlanError> {
123    if let Some(order) = &plan.order
124        && order.fields.is_empty()
125    {
126        return Err(PlanError::EmptyOrderSpec);
127    }
128
129    if plan.mode.is_delete() {
130        if plan.page.is_some() {
131            return Err(PlanError::DeletePlanWithPagination);
132        }
133
134        if plan.delete_limit.is_some()
135            && plan
136                .order
137                .as_ref()
138                .is_none_or(|order| order.fields.is_empty())
139        {
140            return Err(PlanError::DeleteLimitRequiresOrder);
141        }
142    }
143
144    if plan.mode.is_load() {
145        if plan.delete_limit.is_some() {
146            return Err(PlanError::LoadPlanWithDeleteLimit);
147        }
148
149        if plan.page.is_some()
150            && plan
151                .order
152                .as_ref()
153                .is_none_or(|order| order.fields.is_empty())
154        {
155            return Err(PlanError::UnorderedPagination);
156        }
157    }
158
159    Ok(())
160}
161
162/// Validate plans at executor boundaries and surface invariant violations.
163///
164/// Ownership:
165/// - defensive execution-boundary guardrail, not a semantic owner
166/// - must map violations to internal invariant failures, never new user semantics
167///
168/// Any disagreement with logical validation indicates an internal bug and is not
169/// a recoverable user-input condition.
170pub(crate) fn validate_executor_plan<E: EntityKind>(
171    plan: &LogicalPlan<E::Key>,
172) -> Result<(), InternalError> {
173    let schema = SchemaInfo::from_entity_model(E::MODEL).map_err(|err| {
174        InternalError::new(
175            ErrorClass::InvariantViolation,
176            ErrorOrigin::Query,
177            format!("entity schema invalid for {}: {err}", E::PATH),
178        )
179    })?;
180
181    if let Some(predicate) = &plan.predicate {
182        predicate::validate(&schema, predicate).map_err(|err| {
183            InternalError::new(
184                ErrorClass::InvariantViolation,
185                ErrorOrigin::Query,
186                err.to_string(),
187            )
188        })?;
189    }
190
191    if let Some(order) = &plan.order {
192        validate_executor_order(&schema, order).map_err(|err| {
193            InternalError::new(
194                ErrorClass::InvariantViolation,
195                ErrorOrigin::Query,
196                err.to_string(),
197            )
198        })?;
199    }
200
201    validate_access_plan(&schema, E::MODEL, &plan.access).map_err(|err| {
202        InternalError::new(
203            ErrorClass::InvariantViolation,
204            ErrorOrigin::Query,
205            err.to_string(),
206        )
207    })?;
208
209    validate_plan_semantics(plan).map_err(|err| {
210        InternalError::new(
211            ErrorClass::InvariantViolation,
212            ErrorOrigin::Query,
213            err.to_string(),
214        )
215    })?;
216
217    Ok(())
218}
219
220/// Validate ORDER BY fields against the schema.
221pub(crate) fn validate_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
222    for (field, _) in &order.fields {
223        let field_type = schema
224            .field(field)
225            .ok_or_else(|| PlanError::UnknownOrderField {
226                field: field.clone(),
227            })?;
228
229        if !field_type.is_orderable() {
230            // CONTRACT: ORDER BY rejects non-queryable or unordered fields.
231            return Err(PlanError::UnorderableField {
232                field: field.clone(),
233            });
234        }
235    }
236
237    Ok(())
238}
239
240/// Validate ORDER BY fields for executor-only plans.
241///
242/// CONTRACT: executor ordering validation matches planner rules.
243fn validate_executor_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
244    validate_order(schema, order)
245}
246
247///
248/// AccessPlanKeyAdapter
249/// Adapter for key validation and ordering across access-plan representations.
250///
251
252trait AccessPlanKeyAdapter<K> {
253    /// Validate a key against the entity's primary key type.
254    fn validate_pk_key(
255        &self,
256        schema: &SchemaInfo,
257        model: &EntityModel,
258        key: &K,
259    ) -> Result<(), PlanError>;
260
261    /// Validate a key range and enforce representation-specific ordering rules.
262    fn validate_key_range(
263        &self,
264        schema: &SchemaInfo,
265        model: &EntityModel,
266        start: &K,
267        end: &K,
268    ) -> Result<(), PlanError>;
269}
270
271///
272/// GenericKeyAdapater
273/// Adapter for typed key plans (FieldValue + Ord)
274///
275
276struct GenericKeyAdapter;
277
278impl<K> AccessPlanKeyAdapter<K> for GenericKeyAdapter
279where
280    K: FieldValue + Ord,
281{
282    fn validate_pk_key(
283        &self,
284        schema: &SchemaInfo,
285        model: &EntityModel,
286        key: &K,
287    ) -> Result<(), PlanError> {
288        validate_pk_key(schema, model, key)
289    }
290
291    fn validate_key_range(
292        &self,
293        schema: &SchemaInfo,
294        model: &EntityModel,
295        start: &K,
296        end: &K,
297    ) -> Result<(), PlanError> {
298        validate_pk_key(schema, model, start)?;
299        validate_pk_key(schema, model, end)?;
300        if start > end {
301            return Err(PlanError::InvalidKeyRange);
302        }
303
304        Ok(())
305    }
306}
307
308///
309/// ValueKeyAdapter
310/// Adapter for model-level Value plans (partial ordering).
311///
312
313struct ValueKeyAdapter;
314
315impl AccessPlanKeyAdapter<Value> for ValueKeyAdapter {
316    fn validate_pk_key(
317        &self,
318        schema: &SchemaInfo,
319        model: &EntityModel,
320        key: &Value,
321    ) -> Result<(), PlanError> {
322        validate_pk_value(schema, model, key)
323    }
324
325    fn validate_key_range(
326        &self,
327        schema: &SchemaInfo,
328        model: &EntityModel,
329        start: &Value,
330        end: &Value,
331    ) -> Result<(), PlanError> {
332        validate_pk_value(schema, model, start)?;
333        validate_pk_value(schema, model, end)?;
334        let ordering = canonical_cmp(start, end);
335        if ordering == std::cmp::Ordering::Greater {
336            return Err(PlanError::InvalidKeyRange);
337        }
338
339        Ok(())
340    }
341}
342
343// Validate access plans by delegating key checks to the adapter.
344fn validate_access_plan_with<K>(
345    schema: &SchemaInfo,
346    model: &EntityModel,
347    access: &AccessPlan<K>,
348    adapter: &impl AccessPlanKeyAdapter<K>,
349) -> Result<(), PlanError> {
350    match access {
351        AccessPlan::Path(path) => validate_access_path_with(schema, model, path, adapter),
352        AccessPlan::Union(children) | AccessPlan::Intersection(children) => {
353            for child in children {
354                validate_access_plan_with(schema, model, child, adapter)?;
355            }
356
357            Ok(())
358        }
359    }
360}
361
362// Validate access paths using representation-specific key semantics.
363fn validate_access_path_with<K>(
364    schema: &SchemaInfo,
365    model: &EntityModel,
366    access: &AccessPath<K>,
367    adapter: &impl AccessPlanKeyAdapter<K>,
368) -> Result<(), PlanError> {
369    match access {
370        AccessPath::ByKey(key) => adapter.validate_pk_key(schema, model, key),
371        AccessPath::ByKeys(keys) => {
372            // Empty key lists are a valid no-op.
373            if keys.is_empty() {
374                return Ok(());
375            }
376            for key in keys {
377                adapter.validate_pk_key(schema, model, key)?;
378            }
379
380            Ok(())
381        }
382        AccessPath::KeyRange { start, end } => {
383            adapter.validate_key_range(schema, model, start, end)
384        }
385        AccessPath::IndexPrefix { index, values } => {
386            validate_index_prefix(schema, model, index, values)
387        }
388        AccessPath::FullScan => Ok(()),
389    }
390}
391
392/// Validate executor-visible access paths.
393///
394/// This ensures keys, ranges, and index prefixes are schema-compatible.
395pub(crate) fn validate_access_plan<K>(
396    schema: &SchemaInfo,
397    model: &EntityModel,
398    access: &AccessPlan<K>,
399) -> Result<(), PlanError>
400where
401    K: FieldValue + Ord,
402{
403    validate_access_plan_with(schema, model, access, &GenericKeyAdapter)
404}
405
406/// Validate access paths that carry model-level key values.
407pub(crate) fn validate_access_plan_model(
408    schema: &SchemaInfo,
409    model: &EntityModel,
410    access: &AccessPlan<Value>,
411) -> Result<(), PlanError> {
412    validate_access_plan_with(schema, model, access, &ValueKeyAdapter)
413}
414
415/// Validate that a key matches the entity's primary key type.
416fn validate_pk_key<K>(schema: &SchemaInfo, model: &EntityModel, key: &K) -> Result<(), PlanError>
417where
418    K: FieldValue,
419{
420    let field = model.primary_key.name;
421
422    let field_type = schema
423        .field(field)
424        .ok_or_else(|| PlanError::PrimaryKeyNotKeyable {
425            field: field.to_string(),
426        })?;
427
428    if !field_type.is_keyable() {
429        return Err(PlanError::PrimaryKeyNotKeyable {
430            field: field.to_string(),
431        });
432    }
433
434    let value = key.to_value();
435    if !predicate::validate::literal_matches_type(&value, field_type) {
436        return Err(PlanError::PrimaryKeyMismatch {
437            field: field.to_string(),
438            key: value,
439        });
440    }
441
442    Ok(())
443}
444
445// Validate that a model-level key value matches the entity's primary key type.
446fn validate_pk_value(
447    schema: &SchemaInfo,
448    model: &EntityModel,
449    key: &Value,
450) -> Result<(), PlanError> {
451    let field = model.primary_key.name;
452
453    let field_type = schema
454        .field(field)
455        .ok_or_else(|| PlanError::PrimaryKeyNotKeyable {
456            field: field.to_string(),
457        })?;
458
459    if !field_type.is_keyable() {
460        return Err(PlanError::PrimaryKeyNotKeyable {
461            field: field.to_string(),
462        });
463    }
464
465    if !predicate::validate::literal_matches_type(key, field_type) {
466        return Err(PlanError::PrimaryKeyMismatch {
467            field: field.to_string(),
468            key: key.clone(),
469        });
470    }
471
472    Ok(())
473}
474
475/// Validate that an index prefix is valid for execution.
476fn validate_index_prefix(
477    schema: &SchemaInfo,
478    model: &EntityModel,
479    index: &IndexModel,
480    values: &[Value],
481) -> Result<(), PlanError> {
482    if !model.indexes.contains(&index) {
483        return Err(PlanError::IndexNotFound { index: *index });
484    }
485
486    if values.is_empty() {
487        return Err(PlanError::IndexPrefixEmpty);
488    }
489
490    if values.len() > index.fields.len() {
491        return Err(PlanError::IndexPrefixTooLong {
492            prefix_len: values.len(),
493            field_len: index.fields.len(),
494        });
495    }
496
497    for (field, value) in index.fields.iter().zip(values.iter()) {
498        let field_type =
499            schema
500                .field(field)
501                .ok_or_else(|| PlanError::IndexPrefixValueMismatch {
502                    field: field.to_string(),
503                })?;
504
505        if !predicate::validate::literal_matches_type(value, field_type) {
506            return Err(PlanError::IndexPrefixValueMismatch {
507                field: field.to_string(),
508            });
509        }
510    }
511
512    Ok(())
513}
514
515/// Map scalar field types to compatible key variants.
516///
517/// Non-scalar and non-queryable field types are intentionally excluded.
518///
519/// TESTS
520///
521
522#[cfg(test)]
523mod tests {
524    // NOTE: Invalid helpers remain only for intentionally invalid schemas.
525    use super::{PlanError, validate_logical_plan_model};
526    use crate::{
527        db::query::{
528            plan::{AccessPath, AccessPlan, LogicalPlan, OrderDirection, OrderSpec, PageSpec},
529            predicate::{SchemaInfo, ValidateError},
530        },
531        model::{
532            entity::EntityModel,
533            field::{EntityFieldKind, EntityFieldModel},
534            index::IndexModel,
535        },
536        test_fixtures::InvalidEntityModelBuilder,
537        traits::EntitySchema,
538        types::Ulid,
539        value::Value,
540    };
541
542    fn field(name: &'static str, kind: EntityFieldKind) -> EntityFieldModel {
543        EntityFieldModel { name, kind }
544    }
545
546    const INDEX_FIELDS: [&str; 1] = ["tag"];
547    const INDEX_MODEL: IndexModel =
548        IndexModel::new("test::idx_tag", "test::IndexStore", &INDEX_FIELDS, false);
549
550    crate::test_entity_schema! {
551        PlanValidateIndexedEntity,
552        id = Ulid,
553        path = "plan_validate::IndexedEntity",
554        entity_name = "IndexedEntity",
555        primary_key = "id",
556        pk_index = 0,
557        fields = [
558            ("id", EntityFieldKind::Ulid),
559            ("tag", EntityFieldKind::Text),
560        ],
561        indexes = [&INDEX_MODEL],
562    }
563
564    crate::test_entity_schema! {
565        PlanValidateListEntity,
566        id = Ulid,
567        path = "plan_validate::ListEntity",
568        entity_name = "ListEntity",
569        primary_key = "id",
570        pk_index = 0,
571        fields = [
572            ("id", EntityFieldKind::Ulid),
573            ("tags", EntityFieldKind::List(&EntityFieldKind::Text)),
574        ],
575        indexes = [],
576    }
577
578    // Helper for tests that need the indexed model derived from a typed schema.
579    fn model_with_index() -> &'static EntityModel {
580        <PlanValidateIndexedEntity as EntitySchema>::MODEL
581    }
582
583    #[test]
584    fn model_rejects_missing_primary_key() {
585        // Invalid test scaffolding: models are hand-built to exercise
586        // validation failures that helpers intentionally prevent.
587        let fields: &'static [EntityFieldModel] =
588            Box::leak(vec![field("id", EntityFieldKind::Ulid)].into_boxed_slice());
589        let missing_pk = Box::leak(Box::new(field("missing", EntityFieldKind::Ulid)));
590
591        let model = InvalidEntityModelBuilder::from_static(
592            "test::Entity",
593            "TestEntity",
594            missing_pk,
595            fields,
596            &[],
597        );
598
599        assert!(matches!(
600            SchemaInfo::from_entity_model(&model),
601            Err(ValidateError::InvalidPrimaryKey { .. })
602        ));
603    }
604
605    #[test]
606    fn model_rejects_duplicate_fields() {
607        let model = InvalidEntityModelBuilder::from_fields(
608            vec![
609                field("dup", EntityFieldKind::Text),
610                field("dup", EntityFieldKind::Text),
611            ],
612            0,
613        );
614
615        assert!(matches!(
616            SchemaInfo::from_entity_model(&model),
617            Err(ValidateError::DuplicateField { .. })
618        ));
619    }
620
621    #[test]
622    fn model_rejects_invalid_primary_key_type() {
623        let model = InvalidEntityModelBuilder::from_fields(
624            vec![field("pk", EntityFieldKind::List(&EntityFieldKind::Text))],
625            0,
626        );
627
628        assert!(matches!(
629            SchemaInfo::from_entity_model(&model),
630            Err(ValidateError::InvalidPrimaryKeyType { .. })
631        ));
632    }
633
634    #[test]
635    fn model_rejects_index_unknown_field() {
636        const INDEX_FIELDS: [&str; 1] = ["missing"];
637        const INDEX_MODEL: IndexModel = IndexModel::new(
638            "test::idx_missing",
639            "test::IndexStore",
640            &INDEX_FIELDS,
641            false,
642        );
643        const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
644
645        let fields: &'static [EntityFieldModel] =
646            Box::leak(vec![field("id", EntityFieldKind::Ulid)].into_boxed_slice());
647        let model = InvalidEntityModelBuilder::from_static(
648            "test::Entity",
649            "TestEntity",
650            &fields[0],
651            fields,
652            &INDEXES,
653        );
654
655        assert!(matches!(
656            SchemaInfo::from_entity_model(&model),
657            Err(ValidateError::IndexFieldUnknown { .. })
658        ));
659    }
660
661    #[test]
662    fn model_rejects_index_non_queryable_field() {
663        const INDEX_FIELDS: [&str; 1] = ["broken"];
664        const INDEX_MODEL: IndexModel =
665            IndexModel::new("test::idx_broken", "test::IndexStore", &INDEX_FIELDS, false);
666        const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
667
668        let fields: &'static [EntityFieldModel] = Box::leak(
669            vec![
670                field("id", EntityFieldKind::Ulid),
671                field("broken", EntityFieldKind::Structured { queryable: false }),
672            ]
673            .into_boxed_slice(),
674        );
675        let model = InvalidEntityModelBuilder::from_static(
676            "test::Entity",
677            "TestEntity",
678            &fields[0],
679            fields,
680            &INDEXES,
681        );
682
683        assert!(matches!(
684            SchemaInfo::from_entity_model(&model),
685            Err(ValidateError::IndexFieldNotQueryable { .. })
686        ));
687    }
688
689    #[test]
690    fn model_rejects_index_map_field_in_0_7_x() {
691        const INDEX_FIELDS: [&str; 1] = ["attributes"];
692        const INDEX_MODEL: IndexModel = IndexModel::new(
693            "test::idx_attributes",
694            "test::IndexStore",
695            &INDEX_FIELDS,
696            false,
697        );
698        const INDEXES: [&IndexModel; 1] = [&INDEX_MODEL];
699
700        let fields: &'static [EntityFieldModel] = Box::leak(
701            vec![
702                field("id", EntityFieldKind::Ulid),
703                field(
704                    "attributes",
705                    EntityFieldKind::Map {
706                        key: &EntityFieldKind::Text,
707                        value: &EntityFieldKind::Uint,
708                    },
709                ),
710            ]
711            .into_boxed_slice(),
712        );
713        let model = InvalidEntityModelBuilder::from_static(
714            "test::Entity",
715            "TestEntity",
716            &fields[0],
717            fields,
718            &INDEXES,
719        );
720
721        assert!(matches!(
722            SchemaInfo::from_entity_model(&model),
723            Err(ValidateError::IndexFieldMapNotQueryable { .. })
724        ));
725    }
726
727    #[test]
728    fn model_rejects_duplicate_index_names() {
729        const INDEX_FIELDS_A: [&str; 1] = ["id"];
730        const INDEX_FIELDS_B: [&str; 1] = ["other"];
731        const INDEX_A: IndexModel = IndexModel::new(
732            "test::dup_index",
733            "test::IndexStore",
734            &INDEX_FIELDS_A,
735            false,
736        );
737        const INDEX_B: IndexModel = IndexModel::new(
738            "test::dup_index",
739            "test::IndexStore",
740            &INDEX_FIELDS_B,
741            false,
742        );
743        const INDEXES: [&IndexModel; 2] = [&INDEX_A, &INDEX_B];
744
745        let fields: &'static [EntityFieldModel] = Box::leak(
746            vec![
747                field("id", EntityFieldKind::Ulid),
748                field("other", EntityFieldKind::Text),
749            ]
750            .into_boxed_slice(),
751        );
752        let model = InvalidEntityModelBuilder::from_static(
753            "test::Entity",
754            "TestEntity",
755            &fields[0],
756            fields,
757            &INDEXES,
758        );
759
760        assert!(matches!(
761            SchemaInfo::from_entity_model(&model),
762            Err(ValidateError::DuplicateIndexName { .. })
763        ));
764    }
765
766    #[test]
767    fn plan_rejects_unorderable_field() {
768        let model = <PlanValidateListEntity as EntitySchema>::MODEL;
769
770        let schema = SchemaInfo::from_entity_model(model).expect("valid model");
771        let plan: LogicalPlan<Value> = LogicalPlan {
772            mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
773            access: AccessPlan::Path(AccessPath::FullScan),
774            predicate: None,
775            order: Some(OrderSpec {
776                fields: vec![("tags".to_string(), OrderDirection::Asc)],
777            }),
778            delete_limit: None,
779            page: None,
780            consistency: crate::db::query::ReadConsistency::MissingOk,
781        };
782
783        let err =
784            validate_logical_plan_model(&schema, model, &plan).expect_err("unorderable field");
785        assert!(matches!(err, PlanError::UnorderableField { .. }));
786    }
787
788    #[test]
789    fn plan_rejects_index_prefix_too_long() {
790        let model = model_with_index();
791        let schema = SchemaInfo::from_entity_model(model).expect("valid model");
792        let plan: LogicalPlan<Value> = LogicalPlan {
793            mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
794            access: AccessPlan::Path(AccessPath::IndexPrefix {
795                index: INDEX_MODEL,
796                values: vec![Value::Text("a".to_string()), Value::Text("b".to_string())],
797            }),
798            predicate: None,
799            order: None,
800            delete_limit: None,
801            page: None,
802            consistency: crate::db::query::ReadConsistency::MissingOk,
803        };
804
805        let err =
806            validate_logical_plan_model(&schema, model, &plan).expect_err("index prefix too long");
807        assert!(matches!(err, PlanError::IndexPrefixTooLong { .. }));
808    }
809
810    #[test]
811    fn plan_rejects_empty_index_prefix() {
812        let model = model_with_index();
813        let schema = SchemaInfo::from_entity_model(model).expect("valid model");
814        let plan: LogicalPlan<Value> = LogicalPlan {
815            mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
816            access: AccessPlan::Path(AccessPath::IndexPrefix {
817                index: INDEX_MODEL,
818                values: vec![],
819            }),
820            predicate: None,
821            order: None,
822            delete_limit: None,
823            page: None,
824            consistency: crate::db::query::ReadConsistency::MissingOk,
825        };
826
827        let err =
828            validate_logical_plan_model(&schema, model, &plan).expect_err("index prefix empty");
829        assert!(matches!(err, PlanError::IndexPrefixEmpty));
830    }
831
832    #[test]
833    fn plan_accepts_model_based_validation() {
834        let model = <PlanValidateIndexedEntity as EntitySchema>::MODEL;
835        let schema = SchemaInfo::from_entity_model(model).expect("valid model");
836        let plan: LogicalPlan<Value> = LogicalPlan {
837            mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
838            access: AccessPlan::Path(AccessPath::ByKey(Value::Ulid(Ulid::nil()))),
839            predicate: None,
840            order: None,
841            delete_limit: None,
842            page: None,
843            consistency: crate::db::query::ReadConsistency::MissingOk,
844        };
845
846        validate_logical_plan_model(&schema, model, &plan).expect("valid plan");
847    }
848
849    #[test]
850    fn plan_rejects_unordered_pagination() {
851        let model = <PlanValidateIndexedEntity as EntitySchema>::MODEL;
852        let schema = SchemaInfo::from_entity_model(model).expect("valid model");
853        let plan: LogicalPlan<Value> = LogicalPlan {
854            mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
855            access: AccessPlan::Path(AccessPath::FullScan),
856            predicate: None,
857            order: None,
858            delete_limit: None,
859            page: Some(PageSpec {
860                limit: Some(10),
861                offset: 2,
862            }),
863            consistency: crate::db::query::ReadConsistency::MissingOk,
864        };
865
866        let err = validate_logical_plan_model(&schema, model, &plan)
867            .expect_err("pagination without ordering must be rejected");
868        assert!(matches!(err, PlanError::UnorderedPagination));
869    }
870
871    #[test]
872    fn plan_accepts_ordered_pagination() {
873        let model = <PlanValidateIndexedEntity as EntitySchema>::MODEL;
874        let schema = SchemaInfo::from_entity_model(model).expect("valid model");
875        let plan: LogicalPlan<Value> = LogicalPlan {
876            mode: crate::db::query::QueryMode::Load(crate::db::query::LoadSpec::new()),
877            access: AccessPlan::Path(AccessPath::FullScan),
878            predicate: None,
879            order: Some(OrderSpec {
880                fields: vec![("id".to_string(), OrderDirection::Asc)],
881            }),
882            delete_limit: None,
883            page: Some(PageSpec {
884                limit: Some(10),
885                offset: 2,
886            }),
887            consistency: crate::db::query::ReadConsistency::MissingOk,
888        };
889
890        validate_logical_plan_model(&schema, model, &plan).expect("ordered pagination is valid");
891    }
892}