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