Skip to main content

icydb_core/db/query/plan/validate/
mod.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
10mod core;
11mod grouped;
12mod order;
13mod policy;
14
15use crate::db::{access::AccessPlanError, cursor::CursorPlanError, predicate::ValidateError};
16use thiserror::Error as ThisError;
17
18pub(crate) use core::{validate_group_query_semantics, validate_query_semantics};
19#[cfg(test)]
20pub(in crate::db::query) use grouped::validate_group_projection_expr_compatibility_for_test;
21pub(crate) use order::validate_order;
22pub(crate) use policy::{
23    has_explicit_order, resolve_group_field_slot, validate_cursor_order_plan_shape,
24    validate_cursor_paging_requirements, validate_fluent_non_paged_mode,
25    validate_fluent_paged_mode, validate_intent_key_access_policy, validate_intent_plan_shape,
26    validate_order_shape,
27};
28
29///
30/// PlanError
31///
32/// Root plan validation taxonomy split by domain axis.
33/// User-shape failures are grouped under `PlanUserError`.
34/// Policy/capability failures are grouped under `PlanPolicyError`.
35/// Cursor continuation failures remain in `CursorPlanError`.
36///
37
38#[derive(Debug, ThisError)]
39pub enum PlanError {
40    #[error("{0}")]
41    User(Box<PlanUserError>),
42
43    #[error("{0}")]
44    Policy(Box<PlanPolicyError>),
45
46    #[error("{0}")]
47    Cursor(Box<CursorPlanError>),
48}
49
50///
51/// PlanUserError
52///
53/// Planner user-shape validation failures independent of continuation cursors.
54/// This axis intentionally excludes runtime routing/execution policy state and
55/// release-gating capability decisions.
56///
57
58#[derive(Debug, ThisError)]
59pub enum PlanUserError {
60    #[error("predicate validation failed: {0}")]
61    PredicateInvalid(Box<ValidateError>),
62
63    #[error("{0}")]
64    Order(Box<OrderPlanError>),
65
66    #[error("{0}")]
67    Access(Box<AccessPlanError>),
68
69    #[error("{0}")]
70    Group(Box<GroupPlanError>),
71
72    #[error("{0}")]
73    Expr(Box<ExprPlanError>),
74}
75
76///
77/// PlanPolicyError
78///
79/// Planner policy/capability validation failures.
80/// This axis captures query-shape constraints that are valid syntactically but
81/// not supported by the current execution policy surface.
82///
83
84#[derive(Debug, ThisError)]
85pub enum PlanPolicyError {
86    #[error("{0}")]
87    Policy(Box<PolicyPlanError>),
88
89    #[error("{0}")]
90    Group(Box<GroupPlanError>),
91}
92
93///
94/// OrderPlanError
95///
96/// ORDER BY-specific validation failures.
97///
98
99#[derive(Debug, ThisError)]
100pub enum OrderPlanError {
101    /// ORDER BY references an unknown field.
102    #[error("unknown order field '{field}'")]
103    UnknownField { field: String },
104
105    /// ORDER BY references a field that cannot be ordered.
106    #[error("order field '{field}' is not orderable")]
107    UnorderableField { field: String },
108
109    /// ORDER BY references the same non-primary-key field multiple times.
110    #[error("order field '{field}' appears multiple times")]
111    DuplicateOrderField { field: String },
112
113    /// Ordered plans must terminate with the primary-key tie-break.
114    #[error("order specification must end with primary key '{field}' as deterministic tie-break")]
115    MissingPrimaryKeyTieBreak { field: String },
116}
117
118///
119/// PolicyPlanError
120///
121/// Plan-shape policy failures.
122///
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)]
125pub enum PolicyPlanError {
126    /// ORDER BY must specify at least one field.
127    #[error("order specification must include at least one field")]
128    EmptyOrderSpec,
129
130    /// Delete plans must not carry pagination.
131    #[error("delete plans must not include pagination")]
132    DeletePlanWithPagination,
133
134    /// Load plans must not carry delete limits.
135    #[error("load plans must not include delete limits")]
136    LoadPlanWithDeleteLimit,
137
138    /// Delete limits require an explicit ordering.
139    #[error("delete limit requires an explicit ordering")]
140    DeleteLimitRequiresOrder,
141
142    /// Pagination requires an explicit ordering.
143    #[error(
144        "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."
145    )]
146    UnorderedPagination,
147}
148
149///
150/// CursorPagingPolicyError
151///
152/// Cursor pagination readiness errors shared by intent/fluent entry surfaces.
153///
154
155#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)]
156pub enum CursorPagingPolicyError {
157    #[error(
158        "{message}",
159        message = CursorPlanError::cursor_requires_order_message()
160    )]
161    CursorRequiresOrder,
162
163    #[error(
164        "{message}",
165        message = CursorPlanError::cursor_requires_limit_message()
166    )]
167    CursorRequiresLimit,
168}
169
170///
171/// GroupPlanError
172///
173/// GROUP BY wrapper validation failures owned by query planning.
174///
175
176#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
177pub enum GroupPlanError {
178    /// HAVING requires GROUP BY grouped plan shape.
179    #[error("HAVING is only supported for GROUP BY queries in this release")]
180    HavingRequiresGroupBy,
181
182    /// Grouped validation entrypoint received a scalar logical plan.
183    #[error("group query validation requires grouped logical plan variant")]
184    GroupedLogicalPlanRequired,
185
186    /// GROUP BY requires at least one declared grouping field.
187    #[error("group specification must include at least one group field")]
188    EmptyGroupFields,
189
190    /// Global DISTINCT aggregate shapes without GROUP BY are restricted.
191    #[error(
192        "global DISTINCT aggregate without GROUP BY must declare exactly one DISTINCT field-target aggregate in this release"
193    )]
194    GlobalDistinctAggregateShapeUnsupported,
195
196    /// GROUP BY requires at least one aggregate terminal.
197    #[error("group specification must include at least one aggregate terminal")]
198    EmptyAggregates,
199
200    /// GROUP BY references an unknown group field.
201    #[error("unknown group field '{field}'")]
202    UnknownGroupField { field: String },
203
204    /// GROUP BY must not repeat the same resolved group slot.
205    #[error("group specification has duplicate group key: '{field}'")]
206    DuplicateGroupField { field: String },
207
208    /// GROUP BY v1 does not accept DISTINCT unless adjacency eligibility is explicit.
209    #[error(
210        "grouped DISTINCT requires adjacency-based ordered-group eligibility proof in this release"
211    )]
212    DistinctAdjacencyEligibilityRequired,
213
214    /// GROUP BY ORDER BY shape must start with grouped-key prefix.
215    #[error("grouped ORDER BY must start with GROUP BY key prefix in this release")]
216    OrderPrefixNotAlignedWithGroupKeys,
217
218    /// GROUP BY ORDER BY requires an explicit LIMIT in grouped v1.
219    #[error("grouped ORDER BY requires LIMIT in this release")]
220    OrderRequiresLimit,
221
222    /// HAVING with DISTINCT is deferred until grouped DISTINCT support expands.
223    #[error("grouped HAVING with DISTINCT is not supported in this release")]
224    DistinctHavingUnsupported,
225
226    /// HAVING currently supports compare operators only.
227    #[error("grouped HAVING clause at index={index} uses unsupported operator: {op}")]
228    HavingUnsupportedCompareOp { index: usize, op: String },
229
230    /// HAVING group-field symbols must reference declared grouped keys.
231    #[error("grouped HAVING clause at index={index} references non-group field '{field}'")]
232    HavingNonGroupFieldReference { index: usize, field: String },
233
234    /// HAVING aggregate references must resolve to declared grouped terminals.
235    #[error(
236        "grouped HAVING clause at index={index} references aggregate index {aggregate_index} but aggregate_count={aggregate_count}"
237    )]
238    HavingAggregateIndexOutOfBounds {
239        index: usize,
240        aggregate_index: usize,
241        aggregate_count: usize,
242    },
243
244    /// DISTINCT grouped terminal kinds are intentionally conservative in v1.
245    #[error(
246        "grouped DISTINCT aggregate at index={index} uses unsupported kind '{kind}' in this release"
247    )]
248    DistinctAggregateKindUnsupported { index: usize, kind: String },
249
250    /// DISTINCT over grouped field-target terminals is deferred with field-target support.
251    #[error(
252        "grouped DISTINCT aggregate at index={index} cannot target field '{field}' in this release: found {kind}"
253    )]
254    DistinctAggregateFieldTargetUnsupported {
255        index: usize,
256        kind: String,
257        field: String,
258    },
259
260    /// Aggregate target fields must resolve in the model schema.
261    #[error("unknown grouped aggregate target field at index={index}: '{field}'")]
262    UnknownAggregateTargetField { index: usize, field: String },
263
264    /// Global DISTINCT SUM requires a numeric field target.
265    #[error(
266        "global DISTINCT SUM aggregate target field at index={index} is not numeric: '{field}'"
267    )]
268    GlobalDistinctSumTargetNotNumeric { index: usize, field: String },
269
270    /// Field-target grouped terminals are not enabled in grouped execution v1.
271    #[error(
272        "grouped aggregate at index={index} cannot target field '{field}' in this release: found {kind}"
273    )]
274    FieldTargetAggregatesUnsupported {
275        index: usize,
276        kind: String,
277        field: String,
278    },
279}
280
281///
282/// ExprPlanError
283///
284/// Expression-spine inference failures owned by planner semantics.
285///
286
287#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
288pub enum ExprPlanError {
289    /// Expression references a field that does not exist in schema.
290    #[error("unknown expression field '{field}'")]
291    UnknownExprField { field: String },
292
293    /// Aggregate terminal requires a numeric target field.
294    #[error("aggregate '{kind}' requires numeric target field '{field}'")]
295    NonNumericAggregateTarget { kind: String, field: String },
296
297    /// Aggregate expression requires an explicit target field.
298    #[error("aggregate '{kind}' requires an explicit target field")]
299    AggregateTargetRequired { kind: String },
300
301    /// Unary operation is incompatible with inferred operand type.
302    #[error("unary operator '{op}' is incompatible with operand type {found}")]
303    InvalidUnaryOperand { op: String, found: String },
304
305    /// Binary operation is incompatible with inferred operand types.
306    #[error("binary operator '{op}' is incompatible with operand types ({left}, {right})")]
307    InvalidBinaryOperands {
308        op: String,
309        left: String,
310        right: String,
311    },
312
313    /// GROUP BY projections must not reference fields outside grouped keys.
314    #[error(
315        "grouped projection expression at index={index} references fields outside GROUP BY keys"
316    )]
317    GroupedProjectionReferencesNonGroupField { index: usize },
318}
319
320///
321/// CursorOrderPlanShapeError
322///
323/// Logical cursor-order plan-shape failures used by cursor/runtime boundary adapters.
324///
325
326#[derive(Clone, Copy, Debug, Eq, PartialEq)]
327pub(crate) enum CursorOrderPlanShapeError {
328    MissingExplicitOrder,
329    EmptyOrderSpec,
330}
331
332///
333/// IntentKeyAccessKind
334///
335/// Key-access shape used by intent policy validation.
336///
337
338#[derive(Clone, Copy, Debug, Eq, PartialEq)]
339pub(crate) enum IntentKeyAccessKind {
340    Single,
341    Many,
342    Only,
343}
344
345///
346/// IntentKeyAccessPolicyViolation
347///
348/// Logical key-access policy violations at query-intent boundaries.
349///
350#[derive(Clone, Copy, Debug, Eq, PartialEq)]
351pub(crate) enum IntentKeyAccessPolicyViolation {
352    KeyAccessConflict,
353    ByIdsWithPredicate,
354    OnlyWithPredicate,
355}
356
357///
358/// FluentLoadPolicyViolation
359///
360/// Fluent load-entry policy violations.
361///
362
363#[derive(Clone, Copy, Debug, Eq, PartialEq)]
364pub(crate) enum FluentLoadPolicyViolation {
365    CursorRequiresPagedExecution,
366    GroupedRequiresExecuteGrouped,
367    CursorRequiresOrder,
368    CursorRequiresLimit,
369}
370
371impl From<ValidateError> for PlanError {
372    fn from(err: ValidateError) -> Self {
373        Self::from(PlanUserError::from(err))
374    }
375}
376
377impl From<OrderPlanError> for PlanError {
378    fn from(err: OrderPlanError) -> Self {
379        Self::from(PlanUserError::from(err))
380    }
381}
382
383impl From<AccessPlanError> for PlanError {
384    fn from(err: AccessPlanError) -> Self {
385        Self::from(PlanUserError::from(err))
386    }
387}
388
389impl From<PolicyPlanError> for PlanError {
390    fn from(err: PolicyPlanError) -> Self {
391        Self::from(PlanPolicyError::from(err))
392    }
393}
394
395impl From<CursorPlanError> for PlanError {
396    fn from(err: CursorPlanError) -> Self {
397        Self::Cursor(Box::new(err))
398    }
399}
400
401impl From<GroupPlanError> for PlanError {
402    fn from(err: GroupPlanError) -> Self {
403        if err.belongs_to_policy_axis() {
404            return Self::from(PlanPolicyError::from(err));
405        }
406
407        Self::from(PlanUserError::from(err))
408    }
409}
410
411impl From<ExprPlanError> for PlanError {
412    fn from(err: ExprPlanError) -> Self {
413        Self::from(PlanUserError::from(err))
414    }
415}
416
417impl From<PlanUserError> for PlanError {
418    fn from(err: PlanUserError) -> Self {
419        Self::User(Box::new(err))
420    }
421}
422
423impl From<PlanPolicyError> for PlanError {
424    fn from(err: PlanPolicyError) -> Self {
425        Self::Policy(Box::new(err))
426    }
427}
428
429impl From<ValidateError> for PlanUserError {
430    fn from(err: ValidateError) -> Self {
431        Self::PredicateInvalid(Box::new(err))
432    }
433}
434
435impl From<OrderPlanError> for PlanUserError {
436    fn from(err: OrderPlanError) -> Self {
437        Self::Order(Box::new(err))
438    }
439}
440
441impl From<AccessPlanError> for PlanUserError {
442    fn from(err: AccessPlanError) -> Self {
443        Self::Access(Box::new(err))
444    }
445}
446
447impl From<GroupPlanError> for PlanUserError {
448    fn from(err: GroupPlanError) -> Self {
449        Self::Group(Box::new(err))
450    }
451}
452
453impl From<ExprPlanError> for PlanUserError {
454    fn from(err: ExprPlanError) -> Self {
455        Self::Expr(Box::new(err))
456    }
457}
458
459impl From<PolicyPlanError> for PlanPolicyError {
460    fn from(err: PolicyPlanError) -> Self {
461        Self::Policy(Box::new(err))
462    }
463}
464
465impl From<GroupPlanError> for PlanPolicyError {
466    fn from(err: GroupPlanError) -> Self {
467        Self::Group(Box::new(err))
468    }
469}
470
471impl GroupPlanError {
472    // Group-plan variants that represent release-gating/capability constraints
473    // are classified under the policy axis to keep user-shape and policy
474    // domains separated at the top-level `PlanError`.
475    const fn belongs_to_policy_axis(&self) -> bool {
476        matches!(
477            self,
478            Self::GlobalDistinctAggregateShapeUnsupported
479                | Self::DistinctAdjacencyEligibilityRequired
480                | Self::OrderPrefixNotAlignedWithGroupKeys
481                | Self::OrderRequiresLimit
482                | Self::DistinctHavingUnsupported
483                | Self::HavingUnsupportedCompareOp { .. }
484                | Self::DistinctAggregateKindUnsupported { .. }
485                | Self::DistinctAggregateFieldTargetUnsupported { .. }
486                | Self::FieldTargetAggregatesUnsupported { .. }
487        )
488    }
489}