Skip to main content

icydb_core/db/query/plan/
validate.rs

1//! Executor-ready plan validation against a concrete entity schema.
2use 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///
14/// PlanError
15///
16/// Executor-visible validation failures for logical plans.
17///
18/// These errors indicate that a plan cannot be safely executed against the
19/// current schema or entity definition. They are *not* planner bugs.
20///
21
22#[derive(Debug, ThisError)]
23pub enum PlanError {
24    #[error("predicate validation failed: {0}")]
25    PredicateInvalid(#[from] predicate::ValidateError),
26
27    /// ORDER BY references an unknown field.
28    #[error("unknown order field '{field}'")]
29    UnknownOrderField { field: String },
30
31    /// ORDER BY references a field that cannot be ordered.
32    #[error("order field '{field}' is not orderable")]
33    UnorderableField { field: String },
34
35    /// Access plan references an index not declared on the entity.
36    #[error("index '{index}' not found on entity")]
37    IndexNotFound { index: IndexModel },
38
39    /// Index prefix exceeds the number of indexed fields.
40    #[error("index prefix length {prefix_len} exceeds index field count {field_len}")]
41    IndexPrefixTooLong { prefix_len: usize, field_len: usize },
42
43    /// Index prefix must include at least one value.
44    #[error("index prefix must include at least one value")]
45    IndexPrefixEmpty,
46
47    /// Index prefix literal does not match indexed field type.
48    #[error("index prefix value for field '{field}' is incompatible")]
49    IndexPrefixValueMismatch { field: String },
50
51    /// Primary key field exists but is not key-compatible.
52    #[error("primary key field '{field}' is not key-compatible")]
53    PrimaryKeyUnsupported { field: String },
54
55    /// Supplied key does not match the primary key type.
56    #[error("key '{key:?}' is incompatible with primary key '{field}'")]
57    PrimaryKeyMismatch { field: String, key: Value },
58
59    /// Key range has invalid ordering.
60    #[error("key range start is greater than end")]
61    InvalidKeyRange,
62
63    /// ORDER BY must specify at least one field.
64    #[error("order specification must include at least one field")]
65    EmptyOrderSpec,
66
67    /// Delete plans must not carry pagination.
68    #[error("delete plans must not include pagination")]
69    DeletePlanWithPagination,
70
71    /// Load plans must not carry delete limits.
72    #[error("load plans must not include delete limits")]
73    LoadPlanWithDeleteLimit,
74
75    /// Delete limits require an explicit ordering.
76    #[error("delete limit requires an explicit ordering")]
77    DeleteLimitRequiresOrder,
78}
79
80/// Validate a logical plan using a prebuilt schema surface.
81#[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/// Validate a logical plan against the runtime entity model.
94///
95/// This is the executor-safe entrypoint and must not consult global schema.
96#[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/// Validate a logical plan against schema and plan-level invariants.
110#[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
133/// Validate a logical plan with model-level key values.
134pub(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
153/// Validate plan-level invariants not covered by schema checks.
154fn 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
183/// Validate plans at executor boundaries and surface invariant violations.
184pub(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
234/// Validate ORDER BY fields against the schema.
235pub(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            // CONTRACT: ORDER BY rejects unsupported or unordered fields.
245            return Err(PlanError::UnorderableField {
246                field: field.clone(),
247            });
248        }
249    }
250
251    Ok(())
252}
253
254/// Validate ORDER BY fields for executor-only plans.
255///
256/// CONTRACT: executor ordering validation matches planner rules.
257fn validate_executor_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
258    validate_order(schema, order)
259}
260
261// Adapter for key validation and ordering across access-plan representations.
262trait AccessPlanKeyAdapter<K> {
263    /// Validate a key against the entity's primary key type.
264    fn validate_pk_key(
265        &self,
266        schema: &SchemaInfo,
267        model: &EntityModel,
268        key: &K,
269    ) -> Result<(), PlanError>;
270
271    /// Validate a key range and enforce representation-specific ordering rules.
272    fn validate_key_range(
273        &self,
274        schema: &SchemaInfo,
275        model: &EntityModel,
276        start: &K,
277        end: &K,
278    ) -> Result<(), PlanError>;
279}
280
281// Adapter for typed key plans (FieldValue + Ord).
282struct 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
314// Adapter for model-level Value plans (partial ordering).
315struct 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
345// Validate access plans by delegating key checks to the adapter.
346fn 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
364// Validate access paths using representation-specific key semantics.
365fn 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            // Empty key lists are a valid no-op.
375            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
394/// Validate executor-visible access paths.
395///
396/// This ensures keys, ranges, and index prefixes are schema-compatible.
397pub(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
408/// Validate access paths that carry model-level key values.
409pub(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
417/// Validate that a key matches the entity's primary key type.
418fn 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
447// Validate that a model-level key value matches the entity's primary key type.
448fn 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
477/// Validate that an index prefix is valid for execution.
478fn 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/// Map scalar field types to compatible key variants.
518///
519/// Non-scalar and unsupported field types are intentionally excluded.
520///
521/// TESTS
522///
523
524#[cfg(test)]
525mod tests {
526    // NOTE: Invalid helpers remain only for intentionally invalid schemas.
527    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    // Helper for tests that need the indexed model derived from a typed schema.
581    fn model_with_index() -> &'static EntityModel {
582        <PlanValidateIndexedEntity as EntitySchema>::MODEL
583    }
584
585    #[test]
586    fn model_rejects_missing_primary_key() {
587        // Invalid test scaffolding: models are hand-built to exercise
588        // validation failures that helpers intentionally prevent.
589        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}