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    PrimaryKeyNotKeyable { 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    /// Pagination requires an explicit ordering.
89    #[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/// Validate a logical plan using a prebuilt schema surface.
96#[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/// Validate a logical plan against the runtime entity model.
109///
110/// This is the executor-safe entrypoint and must not consult global schema.
111#[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/// Validate a logical plan against schema and plan-level invariants.
125#[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
148/// Validate a logical plan with model-level key values.
149///
150/// Ownership:
151/// - semantic owner for user-facing query validity at planning boundaries
152/// - failures here are user-visible planning failures (`PlanError`)
153///
154/// New user-facing validation rules must be introduced here first, then mirrored
155/// defensively in downstream layers without changing semantics.
156pub(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
175/// Validate plan-level invariants not covered by schema checks.
176fn 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
216/// Validate plans at executor boundaries and surface invariant violations.
217///
218/// Ownership:
219/// - defensive execution-boundary guardrail, not a semantic owner
220/// - must map violations to internal invariant failures, never new user semantics
221///
222/// Any disagreement with logical validation indicates an internal bug and is not
223/// a recoverable user-input condition.
224pub(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
274/// Validate ORDER BY fields against the schema.
275pub(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            // CONTRACT: ORDER BY rejects non-queryable or unordered fields.
285            return Err(PlanError::UnorderableField {
286                field: field.clone(),
287            });
288        }
289    }
290
291    Ok(())
292}
293
294/// Validate ORDER BY fields for executor-only plans.
295///
296/// CONTRACT: executor ordering validation matches planner rules.
297fn validate_executor_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
298    validate_order(schema, order)
299}
300
301// Adapter for key validation and ordering across access-plan representations.
302trait AccessPlanKeyAdapter<K> {
303    /// Validate a key against the entity's primary key type.
304    fn validate_pk_key(
305        &self,
306        schema: &SchemaInfo,
307        model: &EntityModel,
308        key: &K,
309    ) -> Result<(), PlanError>;
310
311    /// Validate a key range and enforce representation-specific ordering rules.
312    fn validate_key_range(
313        &self,
314        schema: &SchemaInfo,
315        model: &EntityModel,
316        start: &K,
317        end: &K,
318    ) -> Result<(), PlanError>;
319}
320
321// Adapter for typed key plans (FieldValue + Ord).
322struct 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
354// Adapter for model-level Value plans (partial ordering).
355struct 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
385// Validate access plans by delegating key checks to the adapter.
386fn 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
404// Validate access paths using representation-specific key semantics.
405fn 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            // Empty key lists are a valid no-op.
415            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
434/// Validate executor-visible access paths.
435///
436/// This ensures keys, ranges, and index prefixes are schema-compatible.
437pub(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
448/// Validate access paths that carry model-level key values.
449pub(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
457/// Validate that a key matches the entity's primary key type.
458fn 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
487// Validate that a model-level key value matches the entity's primary key type.
488fn 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
517/// Validate that an index prefix is valid for execution.
518fn 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/// Map scalar field types to compatible key variants.
558///
559/// Non-scalar and non-queryable field types are intentionally excluded.
560///
561/// TESTS
562///
563
564#[cfg(test)]
565mod tests {
566    // NOTE: Invalid helpers remain only for intentionally invalid schemas.
567    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    // Helper for tests that need the indexed model derived from a typed schema.
621    fn model_with_index() -> &'static EntityModel {
622        <PlanValidateIndexedEntity as EntitySchema>::MODEL
623    }
624
625    #[test]
626    fn model_rejects_missing_primary_key() {
627        // Invalid test scaffolding: models are hand-built to exercise
628        // validation failures that helpers intentionally prevent.
629        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}