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    /// GROUP BY v1 does not accept DISTINCT unless adjacency eligibility is explicit.
158    #[error(
159        "grouped DISTINCT requires adjacency-based ordered-group eligibility proof in this release"
160    )]
161    DistinctAdjacencyEligibilityRequired,
162
163    /// GROUP BY ORDER BY shape must start with grouped-key prefix.
164    #[error("grouped ORDER BY must start with GROUP BY key prefix in this release")]
165    OrderPrefixNotAlignedWithGroupKeys,
166
167    /// Aggregate target fields must resolve in the model schema.
168    #[error("unknown grouped aggregate target field at index={index}: '{field}'")]
169    UnknownAggregateTargetField { index: usize, field: String },
170
171    /// Field-target grouped terminals are not enabled in grouped execution v1.
172    #[error(
173        "grouped aggregate at index={index} cannot target field '{field}' in this release: found {kind}"
174    )]
175    FieldTargetAggregatesUnsupported {
176        index: usize,
177        kind: String,
178        field: String,
179    },
180}
181
182///
183/// CursorOrderPlanShapeError
184///
185/// Logical cursor-order plan-shape failures used by cursor/runtime boundary adapters.
186///
187#[derive(Clone, Copy, Debug, Eq, PartialEq)]
188pub(crate) enum CursorOrderPlanShapeError {
189    MissingExplicitOrder,
190    EmptyOrderSpec,
191}
192
193///
194/// IntentKeyAccessKind
195///
196/// Key-access shape used by intent policy validation.
197///
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199pub(crate) enum IntentKeyAccessKind {
200    Single,
201    Many,
202    Only,
203}
204
205///
206/// IntentKeyAccessPolicyViolation
207///
208/// Logical key-access policy violations at query-intent boundaries.
209///
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub(crate) enum IntentKeyAccessPolicyViolation {
212    KeyAccessConflict,
213    ByIdsWithPredicate,
214    OnlyWithPredicate,
215}
216
217///
218/// IntentTerminalPolicyViolation
219///
220/// Intent-level terminal compatibility violations.
221///
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub(crate) enum IntentTerminalPolicyViolation {
224    GroupedFieldTargetExtremaUnsupported,
225}
226
227///
228/// FluentLoadPolicyViolation
229///
230/// Fluent load-entry policy violations.
231///
232#[derive(Clone, Copy, Debug, Eq, PartialEq)]
233pub(crate) enum FluentLoadPolicyViolation {
234    CursorRequiresPagedExecution,
235    GroupedRequiresExecuteGrouped,
236    CursorRequiresOrder,
237    CursorRequiresLimit,
238}
239
240impl From<ValidateError> for PlanError {
241    fn from(err: ValidateError) -> Self {
242        Self::PredicateInvalid(Box::new(err))
243    }
244}
245
246impl From<OrderPlanError> for PlanError {
247    fn from(err: OrderPlanError) -> Self {
248        Self::Order(Box::new(err))
249    }
250}
251
252impl From<AccessPlanError> for PlanError {
253    fn from(err: AccessPlanError) -> Self {
254        Self::Access(Box::new(err))
255    }
256}
257
258impl From<PolicyPlanError> for PlanError {
259    fn from(err: PolicyPlanError) -> Self {
260        Self::Policy(Box::new(err))
261    }
262}
263
264impl From<CursorPlanError> for PlanError {
265    fn from(err: CursorPlanError) -> Self {
266        Self::Cursor(Box::new(err))
267    }
268}
269
270impl From<GroupPlanError> for PlanError {
271    fn from(err: GroupPlanError) -> Self {
272        Self::Group(Box::new(err))
273    }
274}
275
276/// Validate a logical plan with model-level key values.
277///
278/// Ownership:
279/// - semantic owner for user-facing query validity at planning boundaries
280/// - failures here are user-visible planning failures (`PlanError`)
281///
282/// New user-facing validation rules must be introduced here first, then mirrored
283/// defensively in downstream layers without changing semantics.
284pub(crate) fn validate_query_semantics(
285    schema: &SchemaInfo,
286    model: &EntityModel,
287    plan: &AccessPlannedQuery<Value>,
288) -> Result<(), PlanError> {
289    let logical = plan.scalar_plan();
290
291    validate_plan_core(
292        schema,
293        model,
294        logical,
295        plan,
296        validate_order,
297        |schema, model, plan| {
298            validate_access_structure_model_shared(schema, model, &plan.access)
299                .map_err(PlanError::from)
300        },
301    )?;
302
303    Ok(())
304}
305
306/// Validate grouped query semantics for one grouped plan wrapper.
307///
308/// Ownership:
309/// - semantic owner for GROUP BY wrapper validation
310/// - failures here are user-visible planning failures (`PlanError`)
311pub(crate) fn validate_group_query_semantics(
312    schema: &SchemaInfo,
313    model: &EntityModel,
314    plan: &AccessPlannedQuery<Value>,
315) -> Result<(), PlanError> {
316    let logical = plan.scalar_plan();
317    let group = match &plan.logical {
318        LogicalPlan::Grouped(grouped) => &grouped.group,
319        LogicalPlan::Scalar(_) => {
320            return Err(PlanError::from(GroupPlanError::GroupedLogicalPlanRequired));
321        }
322    };
323
324    validate_plan_core(
325        schema,
326        model,
327        logical,
328        plan,
329        validate_order,
330        |schema, model, plan| {
331            validate_access_structure_model_shared(schema, model, &plan.access)
332                .map_err(PlanError::from)
333        },
334    )?;
335    validate_grouped_distinct_and_order_policy(logical, group)?;
336    validate_group_spec(schema, model, group)?;
337
338    Ok(())
339}
340
341// Validate grouped DISTINCT + ORDER BY policy gates for grouped v1 hardening.
342fn validate_grouped_distinct_and_order_policy(
343    logical: &ScalarPlan,
344    group: &GroupSpec,
345) -> Result<(), PlanError> {
346    if logical.distinct {
347        return Err(PlanError::from(
348            GroupPlanError::DistinctAdjacencyEligibilityRequired,
349        ));
350    }
351
352    let Some(order) = logical.order.as_ref() else {
353        return Ok(());
354    };
355    if order_prefix_aligned_with_group_fields(order, group.group_fields.as_slice()) {
356        return Ok(());
357    }
358
359    Err(PlanError::from(
360        GroupPlanError::OrderPrefixNotAlignedWithGroupKeys,
361    ))
362}
363
364// Return true when ORDER BY starts with GROUP BY key fields in declaration order.
365fn order_prefix_aligned_with_group_fields(order: &OrderSpec, group_fields: &[FieldSlot]) -> bool {
366    if order.fields.len() < group_fields.len() {
367        return false;
368    }
369
370    group_fields
371        .iter()
372        .zip(order.fields.iter())
373        .all(|(group_field, (order_field, _))| order_field == group_field.field())
374}
375
376/// Validate one grouped declarative spec against schema-level field surface.
377pub(crate) fn validate_group_spec(
378    schema: &SchemaInfo,
379    model: &EntityModel,
380    group: &GroupSpec,
381) -> Result<(), PlanError> {
382    if group.group_fields.is_empty() {
383        return Err(PlanError::from(GroupPlanError::EmptyGroupFields));
384    }
385    if group.aggregates.is_empty() {
386        return Err(PlanError::from(GroupPlanError::EmptyAggregates));
387    }
388
389    let mut seen_group_slots = BTreeSet::<usize>::new();
390    for field_slot in &group.group_fields {
391        if model.fields.get(field_slot.index()).is_none() {
392            return Err(PlanError::from(GroupPlanError::UnknownGroupField {
393                field: field_slot.field().to_string(),
394            }));
395        }
396        if !seen_group_slots.insert(field_slot.index()) {
397            return Err(PlanError::from(GroupPlanError::DuplicateGroupField {
398                field: field_slot.field().to_string(),
399            }));
400        }
401    }
402
403    for (index, aggregate) in group.aggregates.iter().enumerate() {
404        let Some(target_field) = aggregate.target_field.as_ref() else {
405            continue;
406        };
407        if schema.field(target_field).is_none() {
408            return Err(PlanError::from(
409                GroupPlanError::UnknownAggregateTargetField {
410                    index,
411                    field: target_field.clone(),
412                },
413            ));
414        }
415        return Err(PlanError::from(
416            GroupPlanError::FieldTargetAggregatesUnsupported {
417                index,
418                kind: format!("{:?}", aggregate.kind),
419                field: target_field.clone(),
420            },
421        ));
422    }
423
424    Ok(())
425}
426
427// Shared logical plan validation core owned by planner semantics.
428fn validate_plan_core<K, FOrder, FAccess>(
429    schema: &SchemaInfo,
430    model: &EntityModel,
431    logical: &ScalarPlan,
432    plan: &AccessPlannedQuery<K>,
433    validate_order_fn: FOrder,
434    validate_access_fn: FAccess,
435) -> Result<(), PlanError>
436where
437    FOrder: Fn(&SchemaInfo, &OrderSpec) -> Result<(), PlanError>,
438    FAccess: Fn(&SchemaInfo, &EntityModel, &AccessPlannedQuery<K>) -> Result<(), PlanError>,
439{
440    if let Some(predicate) = &logical.predicate {
441        validate(schema, predicate)?;
442    }
443
444    if let Some(order) = &logical.order {
445        validate_order_fn(schema, order)?;
446        validate_no_duplicate_non_pk_order_fields(model, order)?;
447        validate_primary_key_tie_break(model, order)?;
448    }
449
450    validate_access_fn(schema, model, plan)?;
451    validate_plan_shape(&plan.logical)?;
452
453    Ok(())
454}
455// ORDER validation ownership contract:
456// - This module owns ORDER semantic validation (field existence/orderability/tie-break).
457// - ORDER canonicalization (primary-key tie-break insertion) is performed at the
458//   intent boundary via `canonicalize_order_spec` before plan validation.
459// - Shape-policy checks (for example empty ORDER, pagination/order coupling) are owned here.
460// - Executor/runtime layers may defend execution preconditions only.
461
462/// Return true when an ORDER BY exists and contains at least one field.
463#[must_use]
464pub(crate) fn has_explicit_order(order: Option<&OrderSpec>) -> bool {
465    order.is_some_and(|order| !order.fields.is_empty())
466}
467
468/// Return true when an ORDER BY exists but is empty.
469#[must_use]
470pub(crate) fn has_empty_order(order: Option<&OrderSpec>) -> bool {
471    order.is_some_and(|order| order.fields.is_empty())
472}
473
474/// Validate order-shape rules shared across intent and logical plan boundaries.
475pub(crate) fn validate_order_shape(order: Option<&OrderSpec>) -> Result<(), PolicyPlanError> {
476    if has_empty_order(order) {
477        return Err(PolicyPlanError::EmptyOrderSpec);
478    }
479
480    Ok(())
481}
482
483/// Validate intent-level plan-shape rules derived from query mode + order.
484pub(crate) fn validate_intent_plan_shape(
485    mode: QueryMode,
486    order: Option<&OrderSpec>,
487) -> Result<(), PolicyPlanError> {
488    validate_order_shape(order)?;
489
490    let has_order = has_explicit_order(order);
491    if matches!(mode, QueryMode::Delete(spec) if spec.limit.is_some()) && !has_order {
492        return Err(PolicyPlanError::DeleteLimitRequiresOrder);
493    }
494
495    Ok(())
496}
497
498/// Validate cursor-pagination readiness for a load-spec + ordering pair.
499pub(crate) const fn validate_cursor_paging_requirements(
500    has_order: bool,
501    spec: LoadSpec,
502) -> Result<(), CursorPagingPolicyError> {
503    if !has_order {
504        return Err(CursorPagingPolicyError::CursorRequiresOrder);
505    }
506    if spec.limit.is_none() {
507        return Err(CursorPagingPolicyError::CursorRequiresLimit);
508    }
509
510    Ok(())
511}
512
513/// Validate cursor-order shape and return the logical order contract when present.
514pub(crate) const fn validate_cursor_order_plan_shape(
515    order: Option<&OrderSpec>,
516    require_explicit_order: bool,
517) -> Result<Option<&OrderSpec>, CursorOrderPlanShapeError> {
518    let Some(order) = order else {
519        if require_explicit_order {
520            return Err(CursorOrderPlanShapeError::MissingExplicitOrder);
521        }
522
523        return Ok(None);
524    };
525
526    if order.fields.is_empty() {
527        return Err(CursorOrderPlanShapeError::EmptyOrderSpec);
528    }
529
530    Ok(Some(order))
531}
532
533/// Resolve one grouped field into a stable field slot.
534pub(crate) fn resolve_group_field_slot(
535    model: &EntityModel,
536    field: &str,
537) -> Result<FieldSlot, PlanError> {
538    FieldSlot::resolve(model, field).ok_or_else(|| {
539        PlanError::from(GroupPlanError::UnknownGroupField {
540            field: field.to_string(),
541        })
542    })
543}
544
545/// Validate intent key-access policy before planning.
546pub(crate) const fn validate_intent_key_access_policy(
547    key_access_conflict: bool,
548    key_access_kind: Option<IntentKeyAccessKind>,
549    has_predicate: bool,
550) -> Result<(), IntentKeyAccessPolicyViolation> {
551    if key_access_conflict {
552        return Err(IntentKeyAccessPolicyViolation::KeyAccessConflict);
553    }
554
555    match key_access_kind {
556        Some(IntentKeyAccessKind::Many) if has_predicate => {
557            Err(IntentKeyAccessPolicyViolation::ByIdsWithPredicate)
558        }
559        Some(IntentKeyAccessKind::Only) if has_predicate => {
560            Err(IntentKeyAccessPolicyViolation::OnlyWithPredicate)
561        }
562        Some(
563            IntentKeyAccessKind::Single | IntentKeyAccessKind::Many | IntentKeyAccessKind::Only,
564        )
565        | None => Ok(()),
566    }
567}
568
569/// Validate grouped field-target terminal compatibility at intent boundaries.
570pub(crate) const fn validate_grouped_field_target_extrema_policy()
571-> Result<(), IntentTerminalPolicyViolation> {
572    Err(IntentTerminalPolicyViolation::GroupedFieldTargetExtremaUnsupported)
573}
574
575/// Validate fluent non-paged load entry policy.
576pub(crate) const fn validate_fluent_non_paged_mode(
577    has_cursor_token: bool,
578    has_grouping: bool,
579) -> Result<(), FluentLoadPolicyViolation> {
580    if has_cursor_token {
581        return Err(FluentLoadPolicyViolation::CursorRequiresPagedExecution);
582    }
583    if has_grouping {
584        return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
585    }
586
587    Ok(())
588}
589
590/// Validate fluent paged load entry policy.
591pub(crate) fn validate_fluent_paged_mode(
592    has_grouping: bool,
593    has_explicit_order: bool,
594    spec: Option<LoadSpec>,
595) -> Result<(), FluentLoadPolicyViolation> {
596    if has_grouping {
597        return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
598    }
599
600    let Some(spec) = spec else {
601        return Ok(());
602    };
603
604    validate_cursor_paging_requirements(has_explicit_order, spec).map_err(|err| match err {
605        CursorPagingPolicyError::CursorRequiresOrder => {
606            FluentLoadPolicyViolation::CursorRequiresOrder
607        }
608        CursorPagingPolicyError::CursorRequiresLimit => {
609            FluentLoadPolicyViolation::CursorRequiresLimit
610        }
611    })
612}
613
614/// Validate mode/order/pagination invariants for one logical plan.
615pub(crate) fn validate_plan_shape(plan: &LogicalPlan) -> Result<(), PolicyPlanError> {
616    let grouped = matches!(plan, LogicalPlan::Grouped(_));
617    let plan = match plan {
618        LogicalPlan::Scalar(plan) => plan,
619        LogicalPlan::Grouped(plan) => &plan.scalar,
620    };
621    validate_order_shape(plan.order.as_ref())?;
622
623    let has_order = has_explicit_order(plan.order.as_ref());
624    if plan.delete_limit.is_some() && !has_order {
625        return Err(PolicyPlanError::DeleteLimitRequiresOrder);
626    }
627
628    match plan.mode {
629        QueryMode::Delete(_) => {
630            if plan.page.is_some() {
631                return Err(PolicyPlanError::DeletePlanWithPagination);
632            }
633        }
634        QueryMode::Load(_) => {
635            if plan.delete_limit.is_some() {
636                return Err(PolicyPlanError::LoadPlanWithDeleteLimit);
637            }
638            // GROUP BY v1 uses canonical grouped key ordering when ORDER BY is
639            // omitted, so grouped pagination remains deterministic without an
640            // explicit sort clause.
641            if plan.page.is_some() && !has_order && !grouped {
642                return Err(PolicyPlanError::UnorderedPagination);
643            }
644        }
645    }
646
647    Ok(())
648}
649
650/// Validate ORDER BY fields against the schema.
651pub(crate) fn validate_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
652    for (field, _) in &order.fields {
653        let field_type = schema
654            .field(field)
655            .ok_or_else(|| OrderPlanError::UnknownField {
656                field: field.clone(),
657            })
658            .map_err(PlanError::from)?;
659
660        if !field_type.is_orderable() {
661            // CONTRACT: ORDER BY rejects non-queryable or unordered fields.
662            return Err(PlanError::from(OrderPlanError::UnorderableField {
663                field: field.clone(),
664            }));
665        }
666    }
667
668    Ok(())
669}
670
671/// Reject duplicate non-primary-key fields in ORDER BY.
672pub(crate) fn validate_no_duplicate_non_pk_order_fields(
673    model: &EntityModel,
674    order: &OrderSpec,
675) -> Result<(), PlanError> {
676    let mut seen = BTreeSet::new();
677    let pk_field = model.primary_key.name;
678
679    for (field, _) in &order.fields {
680        if field == pk_field {
681            continue;
682        }
683        if !seen.insert(field.as_str()) {
684            return Err(PlanError::from(OrderPlanError::DuplicateOrderField {
685                field: field.clone(),
686            }));
687        }
688    }
689
690    Ok(())
691}
692
693// Ordered plans must include exactly one terminal primary-key field so ordering is total and
694// deterministic across explain, fingerprint, and executor comparison paths.
695pub(crate) fn validate_primary_key_tie_break(
696    model: &EntityModel,
697    order: &OrderSpec,
698) -> Result<(), PlanError> {
699    if order.fields.is_empty() {
700        return Ok(());
701    }
702
703    let pk_field = model.primary_key.name;
704    let pk_count = order
705        .fields
706        .iter()
707        .filter(|(field, _)| field == pk_field)
708        .count();
709    let trailing_pk = order
710        .fields
711        .last()
712        .is_some_and(|(field, _)| field == pk_field);
713
714    if pk_count == 1 && trailing_pk {
715        Ok(())
716    } else {
717        Err(PlanError::from(OrderPlanError::MissingPrimaryKeyTieBreak {
718            field: pk_field.to_string(),
719        }))
720    }
721}