Skip to main content

icydb_core/db/query/plan/
validate.rs

1//! Query-plan validation for planner-owned logical semantics.
2//!
3//! Validation ownership contract:
4//! - `validate_query_semantics` owns user-facing query semantics and emits `PlanError`.
5//! - executor-boundary defensive checks live in `db::executor::plan_validate`.
6//!
7//! Future rule changes must declare a semantic owner. Defensive re-check layers may mirror
8//! rules, but must not reinterpret semantics or error class intent.
9
10use crate::{
11    db::{
12        access::{
13            AccessPlanError,
14            validate_access_structure_model as validate_access_structure_model_shared,
15        },
16        cursor::CursorPlanError,
17        predicate::{SchemaInfo, ValidateError, validate},
18        query::plan::{
19            AccessPlannedQuery, FieldSlot, GroupSpec, LoadSpec, LogicalPlan, OrderSpec, QueryMode,
20            ScalarPlan,
21        },
22    },
23    model::entity::EntityModel,
24    value::Value,
25};
26use std::collections::BTreeSet;
27use thiserror::Error as ThisError;
28
29///
30/// PlanError
31///
32/// Executor-visible validation failures for logical plans.
33///
34/// These errors indicate that a plan cannot be safely executed against the
35/// current schema or entity definition. They are *not* planner bugs.
36///
37
38#[derive(Debug, ThisError)]
39pub enum PlanError {
40    #[error("predicate validation failed: {0}")]
41    PredicateInvalid(Box<ValidateError>),
42
43    #[error("{0}")]
44    Order(Box<OrderPlanError>),
45
46    #[error("{0}")]
47    Access(Box<AccessPlanError>),
48
49    #[error("{0}")]
50    Policy(Box<PolicyPlanError>),
51
52    #[error("{0}")]
53    Cursor(Box<CursorPlanError>),
54
55    #[error("{0}")]
56    Group(Box<GroupPlanError>),
57}
58
59///
60/// OrderPlanError
61///
62/// ORDER BY-specific validation failures.
63///
64
65#[derive(Debug, ThisError)]
66pub enum OrderPlanError {
67    /// ORDER BY references an unknown field.
68    #[error("unknown order field '{field}'")]
69    UnknownField { field: String },
70
71    /// ORDER BY references a field that cannot be ordered.
72    #[error("order field '{field}' is not orderable")]
73    UnorderableField { field: String },
74
75    /// ORDER BY references the same non-primary-key field multiple times.
76    #[error("order field '{field}' appears multiple times")]
77    DuplicateOrderField { field: String },
78
79    /// Ordered plans must terminate with the primary-key tie-break.
80    #[error("order specification must end with primary key '{field}' as deterministic tie-break")]
81    MissingPrimaryKeyTieBreak { field: String },
82}
83
84///
85/// PolicyPlanError
86///
87/// Plan-shape policy failures.
88///
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)]
91pub enum PolicyPlanError {
92    /// ORDER BY must specify at least one field.
93    #[error("order specification must include at least one field")]
94    EmptyOrderSpec,
95
96    /// Delete plans must not carry pagination.
97    #[error("delete plans must not include pagination")]
98    DeletePlanWithPagination,
99
100    /// Load plans must not carry delete limits.
101    #[error("load plans must not include delete limits")]
102    LoadPlanWithDeleteLimit,
103
104    /// Delete limits require an explicit ordering.
105    #[error("delete limit requires an explicit ordering")]
106    DeleteLimitRequiresOrder,
107
108    /// Pagination requires an explicit ordering.
109    #[error(
110        "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."
111    )]
112    UnorderedPagination,
113}
114
115///
116/// CursorPagingPolicyError
117///
118/// Cursor pagination readiness errors shared by intent/fluent entry surfaces.
119///
120#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)]
121pub enum CursorPagingPolicyError {
122    #[error("cursor pagination requires an explicit ordering")]
123    CursorRequiresOrder,
124
125    #[error("cursor pagination requires a limit")]
126    CursorRequiresLimit,
127}
128
129///
130/// GroupPlanError
131///
132/// GROUP BY wrapper validation failures owned by query planning.
133///
134
135#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
136pub enum GroupPlanError {
137    /// Grouped validation entrypoint received a scalar logical plan.
138    #[error("group query validation requires grouped logical plan variant")]
139    GroupedLogicalPlanRequired,
140
141    /// GROUP BY requires at least one declared grouping field.
142    #[error("group specification must include at least one group field")]
143    EmptyGroupFields,
144
145    /// GROUP BY requires at least one aggregate terminal.
146    #[error("group specification must include at least one aggregate terminal")]
147    EmptyAggregates,
148
149    /// GROUP BY references an unknown group field.
150    #[error("unknown group field '{field}'")]
151    UnknownGroupField { field: String },
152
153    /// GROUP BY must not repeat the same resolved group slot.
154    #[error("group specification has duplicate group key: '{field}'")]
155    DuplicateGroupField { field: String },
156
157    /// Aggregate target fields must resolve in the model schema.
158    #[error("unknown grouped aggregate target field at index={index}: '{field}'")]
159    UnknownAggregateTargetField { index: usize, field: String },
160
161    /// Field-target grouped terminals are not enabled in grouped execution v1.
162    #[error(
163        "grouped aggregate at index={index} cannot target field '{field}' in this release: found {kind}"
164    )]
165    FieldTargetAggregatesUnsupported {
166        index: usize,
167        kind: String,
168        field: String,
169    },
170}
171
172///
173/// CursorOrderPlanShapeError
174///
175/// Logical cursor-order plan-shape failures used by cursor/runtime boundary adapters.
176///
177#[derive(Clone, Copy, Debug, Eq, PartialEq)]
178pub(crate) enum CursorOrderPlanShapeError {
179    MissingExplicitOrder,
180    EmptyOrderSpec,
181}
182
183///
184/// IntentKeyAccessKind
185///
186/// Key-access shape used by intent policy validation.
187///
188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
189pub(crate) enum IntentKeyAccessKind {
190    Single,
191    Many,
192    Only,
193}
194
195///
196/// IntentKeyAccessPolicyViolation
197///
198/// Logical key-access policy violations at query-intent boundaries.
199///
200#[derive(Clone, Copy, Debug, Eq, PartialEq)]
201pub(crate) enum IntentKeyAccessPolicyViolation {
202    KeyAccessConflict,
203    ByIdsWithPredicate,
204    OnlyWithPredicate,
205}
206
207///
208/// IntentTerminalPolicyViolation
209///
210/// Intent-level terminal compatibility violations.
211///
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub(crate) enum IntentTerminalPolicyViolation {
214    GroupedFieldTargetExtremaUnsupported,
215}
216
217///
218/// FluentLoadPolicyViolation
219///
220/// Fluent load-entry policy violations.
221///
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub(crate) enum FluentLoadPolicyViolation {
224    CursorRequiresPagedExecution,
225    GroupedRequiresExecuteGrouped,
226    CursorRequiresOrder,
227    CursorRequiresLimit,
228}
229
230impl From<ValidateError> for PlanError {
231    fn from(err: ValidateError) -> Self {
232        Self::PredicateInvalid(Box::new(err))
233    }
234}
235
236impl From<OrderPlanError> for PlanError {
237    fn from(err: OrderPlanError) -> Self {
238        Self::Order(Box::new(err))
239    }
240}
241
242impl From<AccessPlanError> for PlanError {
243    fn from(err: AccessPlanError) -> Self {
244        Self::Access(Box::new(err))
245    }
246}
247
248impl From<PolicyPlanError> for PlanError {
249    fn from(err: PolicyPlanError) -> Self {
250        Self::Policy(Box::new(err))
251    }
252}
253
254impl From<CursorPlanError> for PlanError {
255    fn from(err: CursorPlanError) -> Self {
256        Self::Cursor(Box::new(err))
257    }
258}
259
260impl From<GroupPlanError> for PlanError {
261    fn from(err: GroupPlanError) -> Self {
262        Self::Group(Box::new(err))
263    }
264}
265
266/// Validate a logical plan with model-level key values.
267///
268/// Ownership:
269/// - semantic owner for user-facing query validity at planning boundaries
270/// - failures here are user-visible planning failures (`PlanError`)
271///
272/// New user-facing validation rules must be introduced here first, then mirrored
273/// defensively in downstream layers without changing semantics.
274pub(crate) fn validate_query_semantics(
275    schema: &SchemaInfo,
276    model: &EntityModel,
277    plan: &AccessPlannedQuery<Value>,
278) -> Result<(), PlanError> {
279    let logical = plan.scalar_plan();
280
281    validate_plan_core(
282        schema,
283        model,
284        logical,
285        plan,
286        validate_order,
287        |schema, model, plan| {
288            validate_access_structure_model_shared(schema, model, &plan.access)
289                .map_err(PlanError::from)
290        },
291    )?;
292
293    Ok(())
294}
295
296/// Validate grouped query semantics for one grouped plan wrapper.
297///
298/// Ownership:
299/// - semantic owner for GROUP BY wrapper validation
300/// - failures here are user-visible planning failures (`PlanError`)
301pub(crate) fn validate_group_query_semantics(
302    schema: &SchemaInfo,
303    model: &EntityModel,
304    plan: &AccessPlannedQuery<Value>,
305) -> Result<(), PlanError> {
306    let logical = plan.scalar_plan();
307    let group = match &plan.logical {
308        LogicalPlan::Grouped(grouped) => &grouped.group,
309        LogicalPlan::Scalar(_) => {
310            return Err(PlanError::from(GroupPlanError::GroupedLogicalPlanRequired));
311        }
312    };
313
314    validate_plan_core(
315        schema,
316        model,
317        logical,
318        plan,
319        validate_order,
320        |schema, model, plan| {
321            validate_access_structure_model_shared(schema, model, &plan.access)
322                .map_err(PlanError::from)
323        },
324    )?;
325    validate_group_spec(schema, model, group)?;
326
327    Ok(())
328}
329
330/// Validate one grouped declarative spec against schema-level field surface.
331pub(crate) fn validate_group_spec(
332    schema: &SchemaInfo,
333    model: &EntityModel,
334    group: &GroupSpec,
335) -> Result<(), PlanError> {
336    if group.group_fields.is_empty() {
337        return Err(PlanError::from(GroupPlanError::EmptyGroupFields));
338    }
339    if group.aggregates.is_empty() {
340        return Err(PlanError::from(GroupPlanError::EmptyAggregates));
341    }
342
343    let mut seen_group_slots = BTreeSet::<usize>::new();
344    for field_slot in &group.group_fields {
345        if model.fields.get(field_slot.index()).is_none() {
346            return Err(PlanError::from(GroupPlanError::UnknownGroupField {
347                field: field_slot.field().to_string(),
348            }));
349        }
350        if !seen_group_slots.insert(field_slot.index()) {
351            return Err(PlanError::from(GroupPlanError::DuplicateGroupField {
352                field: field_slot.field().to_string(),
353            }));
354        }
355    }
356
357    for (index, aggregate) in group.aggregates.iter().enumerate() {
358        let Some(target_field) = aggregate.target_field.as_ref() else {
359            continue;
360        };
361        if schema.field(target_field).is_none() {
362            return Err(PlanError::from(
363                GroupPlanError::UnknownAggregateTargetField {
364                    index,
365                    field: target_field.clone(),
366                },
367            ));
368        }
369        return Err(PlanError::from(
370            GroupPlanError::FieldTargetAggregatesUnsupported {
371                index,
372                kind: format!("{:?}", aggregate.kind),
373                field: target_field.clone(),
374            },
375        ));
376    }
377
378    Ok(())
379}
380
381// Shared logical plan validation core owned by planner semantics.
382fn validate_plan_core<K, FOrder, FAccess>(
383    schema: &SchemaInfo,
384    model: &EntityModel,
385    logical: &ScalarPlan,
386    plan: &AccessPlannedQuery<K>,
387    validate_order_fn: FOrder,
388    validate_access_fn: FAccess,
389) -> Result<(), PlanError>
390where
391    FOrder: Fn(&SchemaInfo, &OrderSpec) -> Result<(), PlanError>,
392    FAccess: Fn(&SchemaInfo, &EntityModel, &AccessPlannedQuery<K>) -> Result<(), PlanError>,
393{
394    if let Some(predicate) = &logical.predicate {
395        validate(schema, predicate)?;
396    }
397
398    if let Some(order) = &logical.order {
399        validate_order_fn(schema, order)?;
400        validate_no_duplicate_non_pk_order_fields(model, order)?;
401        validate_primary_key_tie_break(model, order)?;
402    }
403
404    validate_access_fn(schema, model, plan)?;
405    validate_plan_shape(&plan.logical)?;
406
407    Ok(())
408}
409// ORDER validation ownership contract:
410// - This module owns ORDER semantic validation (field existence/orderability/tie-break).
411// - ORDER canonicalization (primary-key tie-break insertion) is performed at the
412//   intent boundary via `canonicalize_order_spec` before plan validation.
413// - Shape-policy checks (for example empty ORDER, pagination/order coupling) are owned here.
414// - Executor/runtime layers may defend execution preconditions only.
415
416/// Return true when an ORDER BY exists and contains at least one field.
417#[must_use]
418pub(crate) fn has_explicit_order(order: Option<&OrderSpec>) -> bool {
419    order.is_some_and(|order| !order.fields.is_empty())
420}
421
422/// Return true when an ORDER BY exists but is empty.
423#[must_use]
424pub(crate) fn has_empty_order(order: Option<&OrderSpec>) -> bool {
425    order.is_some_and(|order| order.fields.is_empty())
426}
427
428/// Validate order-shape rules shared across intent and logical plan boundaries.
429pub(crate) fn validate_order_shape(order: Option<&OrderSpec>) -> Result<(), PolicyPlanError> {
430    if has_empty_order(order) {
431        return Err(PolicyPlanError::EmptyOrderSpec);
432    }
433
434    Ok(())
435}
436
437/// Validate intent-level plan-shape rules derived from query mode + order.
438pub(crate) fn validate_intent_plan_shape(
439    mode: QueryMode,
440    order: Option<&OrderSpec>,
441) -> Result<(), PolicyPlanError> {
442    validate_order_shape(order)?;
443
444    let has_order = has_explicit_order(order);
445    if matches!(mode, QueryMode::Delete(spec) if spec.limit.is_some()) && !has_order {
446        return Err(PolicyPlanError::DeleteLimitRequiresOrder);
447    }
448
449    Ok(())
450}
451
452/// Validate cursor-pagination readiness for a load-spec + ordering pair.
453pub(crate) const fn validate_cursor_paging_requirements(
454    has_order: bool,
455    spec: LoadSpec,
456) -> Result<(), CursorPagingPolicyError> {
457    if !has_order {
458        return Err(CursorPagingPolicyError::CursorRequiresOrder);
459    }
460    if spec.limit.is_none() {
461        return Err(CursorPagingPolicyError::CursorRequiresLimit);
462    }
463
464    Ok(())
465}
466
467/// Validate cursor-order shape and return the logical order contract when present.
468pub(crate) const fn validate_cursor_order_plan_shape(
469    order: Option<&OrderSpec>,
470    require_explicit_order: bool,
471) -> Result<Option<&OrderSpec>, CursorOrderPlanShapeError> {
472    let Some(order) = order else {
473        if require_explicit_order {
474            return Err(CursorOrderPlanShapeError::MissingExplicitOrder);
475        }
476
477        return Ok(None);
478    };
479
480    if order.fields.is_empty() {
481        return Err(CursorOrderPlanShapeError::EmptyOrderSpec);
482    }
483
484    Ok(Some(order))
485}
486
487/// Resolve one grouped field into a stable field slot.
488pub(crate) fn resolve_group_field_slot(
489    model: &EntityModel,
490    field: &str,
491) -> Result<FieldSlot, PlanError> {
492    FieldSlot::resolve(model, field).ok_or_else(|| {
493        PlanError::from(GroupPlanError::UnknownGroupField {
494            field: field.to_string(),
495        })
496    })
497}
498
499/// Validate intent key-access policy before planning.
500pub(crate) const fn validate_intent_key_access_policy(
501    key_access_conflict: bool,
502    key_access_kind: Option<IntentKeyAccessKind>,
503    has_predicate: bool,
504) -> Result<(), IntentKeyAccessPolicyViolation> {
505    if key_access_conflict {
506        return Err(IntentKeyAccessPolicyViolation::KeyAccessConflict);
507    }
508
509    match key_access_kind {
510        Some(IntentKeyAccessKind::Many) if has_predicate => {
511            Err(IntentKeyAccessPolicyViolation::ByIdsWithPredicate)
512        }
513        Some(IntentKeyAccessKind::Only) if has_predicate => {
514            Err(IntentKeyAccessPolicyViolation::OnlyWithPredicate)
515        }
516        Some(
517            IntentKeyAccessKind::Single | IntentKeyAccessKind::Many | IntentKeyAccessKind::Only,
518        )
519        | None => Ok(()),
520    }
521}
522
523/// Validate grouped field-target terminal compatibility at intent boundaries.
524pub(crate) const fn validate_grouped_field_target_extrema_policy()
525-> Result<(), IntentTerminalPolicyViolation> {
526    Err(IntentTerminalPolicyViolation::GroupedFieldTargetExtremaUnsupported)
527}
528
529/// Validate fluent non-paged load entry policy.
530pub(crate) const fn validate_fluent_non_paged_mode(
531    has_cursor_token: bool,
532    has_grouping: bool,
533) -> Result<(), FluentLoadPolicyViolation> {
534    if has_cursor_token {
535        return Err(FluentLoadPolicyViolation::CursorRequiresPagedExecution);
536    }
537    if has_grouping {
538        return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
539    }
540
541    Ok(())
542}
543
544/// Validate fluent paged load entry policy.
545pub(crate) fn validate_fluent_paged_mode(
546    has_grouping: bool,
547    has_explicit_order: bool,
548    spec: Option<LoadSpec>,
549) -> Result<(), FluentLoadPolicyViolation> {
550    if has_grouping {
551        return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
552    }
553
554    let Some(spec) = spec else {
555        return Ok(());
556    };
557
558    validate_cursor_paging_requirements(has_explicit_order, spec).map_err(|err| match err {
559        CursorPagingPolicyError::CursorRequiresOrder => {
560            FluentLoadPolicyViolation::CursorRequiresOrder
561        }
562        CursorPagingPolicyError::CursorRequiresLimit => {
563            FluentLoadPolicyViolation::CursorRequiresLimit
564        }
565    })
566}
567
568/// Validate mode/order/pagination invariants for one logical plan.
569pub(crate) fn validate_plan_shape(plan: &LogicalPlan) -> Result<(), PolicyPlanError> {
570    let grouped = matches!(plan, LogicalPlan::Grouped(_));
571    let plan = match plan {
572        LogicalPlan::Scalar(plan) => plan,
573        LogicalPlan::Grouped(plan) => &plan.scalar,
574    };
575    validate_order_shape(plan.order.as_ref())?;
576
577    let has_order = has_explicit_order(plan.order.as_ref());
578    if plan.delete_limit.is_some() && !has_order {
579        return Err(PolicyPlanError::DeleteLimitRequiresOrder);
580    }
581
582    match plan.mode {
583        QueryMode::Delete(_) => {
584            if plan.page.is_some() {
585                return Err(PolicyPlanError::DeletePlanWithPagination);
586            }
587        }
588        QueryMode::Load(_) => {
589            if plan.delete_limit.is_some() {
590                return Err(PolicyPlanError::LoadPlanWithDeleteLimit);
591            }
592            // GROUP BY v1 uses canonical grouped key ordering when ORDER BY is
593            // omitted, so grouped pagination remains deterministic without an
594            // explicit sort clause.
595            if plan.page.is_some() && !has_order && !grouped {
596                return Err(PolicyPlanError::UnorderedPagination);
597            }
598        }
599    }
600
601    Ok(())
602}
603
604/// Validate ORDER BY fields against the schema.
605pub(crate) fn validate_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
606    for (field, _) in &order.fields {
607        let field_type = schema
608            .field(field)
609            .ok_or_else(|| OrderPlanError::UnknownField {
610                field: field.clone(),
611            })
612            .map_err(PlanError::from)?;
613
614        if !field_type.is_orderable() {
615            // CONTRACT: ORDER BY rejects non-queryable or unordered fields.
616            return Err(PlanError::from(OrderPlanError::UnorderableField {
617                field: field.clone(),
618            }));
619        }
620    }
621
622    Ok(())
623}
624
625/// Reject duplicate non-primary-key fields in ORDER BY.
626pub(crate) fn validate_no_duplicate_non_pk_order_fields(
627    model: &EntityModel,
628    order: &OrderSpec,
629) -> Result<(), PlanError> {
630    let mut seen = BTreeSet::new();
631    let pk_field = model.primary_key.name;
632
633    for (field, _) in &order.fields {
634        if field == pk_field {
635            continue;
636        }
637        if !seen.insert(field.as_str()) {
638            return Err(PlanError::from(OrderPlanError::DuplicateOrderField {
639                field: field.clone(),
640            }));
641        }
642    }
643
644    Ok(())
645}
646
647// Ordered plans must include exactly one terminal primary-key field so ordering is total and
648// deterministic across explain, fingerprint, and executor comparison paths.
649pub(crate) fn validate_primary_key_tie_break(
650    model: &EntityModel,
651    order: &OrderSpec,
652) -> Result<(), PlanError> {
653    if order.fields.is_empty() {
654        return Ok(());
655    }
656
657    let pk_field = model.primary_key.name;
658    let pk_count = order
659        .fields
660        .iter()
661        .filter(|(field, _)| field == pk_field)
662        .count();
663    let trailing_pk = order
664        .fields
665        .last()
666        .is_some_and(|(field, _)| field == pk_field);
667
668    if pk_count == 1 && trailing_pk {
669        Ok(())
670    } else {
671        Err(PlanError::from(OrderPlanError::MissingPrimaryKeyTieBreak {
672            field: pk_field.to_string(),
673        }))
674    }
675}