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::{CompareOp, SchemaInfo, ValidateError, validate},
18        query::plan::{
19            AccessPlannedQuery, FieldSlot, GroupAggregateSpec, GroupHavingSpec, GroupHavingSymbol,
20            GroupSpec, LoadSpec, LogicalPlan, OrderSpec, QueryMode, 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    /// HAVING requires GROUP BY grouped plan shape.
138    #[error("HAVING is only supported for GROUP BY queries in this release")]
139    HavingRequiresGroupBy,
140
141    /// Grouped validation entrypoint received a scalar logical plan.
142    #[error("group query validation requires grouped logical plan variant")]
143    GroupedLogicalPlanRequired,
144
145    /// GROUP BY requires at least one declared grouping field.
146    #[error("group specification must include at least one group field")]
147    EmptyGroupFields,
148
149    /// Global DISTINCT aggregate shapes without GROUP BY are restricted.
150    #[error(
151        "global DISTINCT aggregate without GROUP BY must declare exactly one DISTINCT field-target aggregate in this release"
152    )]
153    GlobalDistinctAggregateShapeUnsupported,
154
155    /// GROUP BY requires at least one aggregate terminal.
156    #[error("group specification must include at least one aggregate terminal")]
157    EmptyAggregates,
158
159    /// GROUP BY references an unknown group field.
160    #[error("unknown group field '{field}'")]
161    UnknownGroupField { field: String },
162
163    /// GROUP BY must not repeat the same resolved group slot.
164    #[error("group specification has duplicate group key: '{field}'")]
165    DuplicateGroupField { field: String },
166
167    /// GROUP BY v1 does not accept DISTINCT unless adjacency eligibility is explicit.
168    #[error(
169        "grouped DISTINCT requires adjacency-based ordered-group eligibility proof in this release"
170    )]
171    DistinctAdjacencyEligibilityRequired,
172
173    /// GROUP BY ORDER BY shape must start with grouped-key prefix.
174    #[error("grouped ORDER BY must start with GROUP BY key prefix in this release")]
175    OrderPrefixNotAlignedWithGroupKeys,
176
177    /// GROUP BY ORDER BY requires an explicit LIMIT in grouped v1.
178    #[error("grouped ORDER BY requires LIMIT in this release")]
179    OrderRequiresLimit,
180
181    /// HAVING with DISTINCT is deferred until grouped DISTINCT support expands.
182    #[error("grouped HAVING with DISTINCT is not supported in this release")]
183    DistinctHavingUnsupported,
184
185    /// HAVING currently supports compare operators only.
186    #[error("grouped HAVING clause at index={index} uses unsupported operator: {op}")]
187    HavingUnsupportedCompareOp { index: usize, op: String },
188
189    /// HAVING group-field symbols must reference declared grouped keys.
190    #[error("grouped HAVING clause at index={index} references non-group field '{field}'")]
191    HavingNonGroupFieldReference { index: usize, field: String },
192
193    /// HAVING aggregate references must resolve to declared grouped terminals.
194    #[error(
195        "grouped HAVING clause at index={index} references aggregate index {aggregate_index} but aggregate_count={aggregate_count}"
196    )]
197    HavingAggregateIndexOutOfBounds {
198        index: usize,
199        aggregate_index: usize,
200        aggregate_count: usize,
201    },
202
203    /// DISTINCT grouped terminal kinds are intentionally conservative in v1.
204    #[error(
205        "grouped DISTINCT aggregate at index={index} uses unsupported kind '{kind}' in this release"
206    )]
207    DistinctAggregateKindUnsupported { index: usize, kind: String },
208
209    /// DISTINCT over grouped field-target terminals is deferred with field-target support.
210    #[error(
211        "grouped DISTINCT aggregate at index={index} cannot target field '{field}' in this release: found {kind}"
212    )]
213    DistinctAggregateFieldTargetUnsupported {
214        index: usize,
215        kind: String,
216        field: String,
217    },
218
219    /// Aggregate target fields must resolve in the model schema.
220    #[error("unknown grouped aggregate target field at index={index}: '{field}'")]
221    UnknownAggregateTargetField { index: usize, field: String },
222
223    /// Global DISTINCT SUM requires a numeric field target.
224    #[error(
225        "global DISTINCT SUM aggregate target field at index={index} is not numeric: '{field}'"
226    )]
227    GlobalDistinctSumTargetNotNumeric { index: usize, field: String },
228
229    /// Field-target grouped terminals are not enabled in grouped execution v1.
230    #[error(
231        "grouped aggregate at index={index} cannot target field '{field}' in this release: found {kind}"
232    )]
233    FieldTargetAggregatesUnsupported {
234        index: usize,
235        kind: String,
236        field: String,
237    },
238}
239
240///
241/// CursorOrderPlanShapeError
242///
243/// Logical cursor-order plan-shape failures used by cursor/runtime boundary adapters.
244///
245#[derive(Clone, Copy, Debug, Eq, PartialEq)]
246pub(crate) enum CursorOrderPlanShapeError {
247    MissingExplicitOrder,
248    EmptyOrderSpec,
249}
250
251///
252/// IntentKeyAccessKind
253///
254/// Key-access shape used by intent policy validation.
255///
256#[derive(Clone, Copy, Debug, Eq, PartialEq)]
257pub(crate) enum IntentKeyAccessKind {
258    Single,
259    Many,
260    Only,
261}
262
263///
264/// IntentKeyAccessPolicyViolation
265///
266/// Logical key-access policy violations at query-intent boundaries.
267///
268#[derive(Clone, Copy, Debug, Eq, PartialEq)]
269pub(crate) enum IntentKeyAccessPolicyViolation {
270    KeyAccessConflict,
271    ByIdsWithPredicate,
272    OnlyWithPredicate,
273}
274
275///
276/// IntentTerminalPolicyViolation
277///
278/// Intent-level terminal compatibility violations.
279///
280#[derive(Clone, Copy, Debug, Eq, PartialEq)]
281pub(crate) enum IntentTerminalPolicyViolation {
282    GroupedFieldTargetExtremaUnsupported,
283}
284
285///
286/// FluentLoadPolicyViolation
287///
288/// Fluent load-entry policy violations.
289///
290#[derive(Clone, Copy, Debug, Eq, PartialEq)]
291pub(crate) enum FluentLoadPolicyViolation {
292    CursorRequiresPagedExecution,
293    GroupedRequiresExecuteGrouped,
294    CursorRequiresOrder,
295    CursorRequiresLimit,
296}
297
298impl From<ValidateError> for PlanError {
299    fn from(err: ValidateError) -> Self {
300        Self::PredicateInvalid(Box::new(err))
301    }
302}
303
304impl From<OrderPlanError> for PlanError {
305    fn from(err: OrderPlanError) -> Self {
306        Self::Order(Box::new(err))
307    }
308}
309
310impl From<AccessPlanError> for PlanError {
311    fn from(err: AccessPlanError) -> Self {
312        Self::Access(Box::new(err))
313    }
314}
315
316impl From<PolicyPlanError> for PlanError {
317    fn from(err: PolicyPlanError) -> Self {
318        Self::Policy(Box::new(err))
319    }
320}
321
322impl From<CursorPlanError> for PlanError {
323    fn from(err: CursorPlanError) -> Self {
324        Self::Cursor(Box::new(err))
325    }
326}
327
328impl From<GroupPlanError> for PlanError {
329    fn from(err: GroupPlanError) -> Self {
330        Self::Group(Box::new(err))
331    }
332}
333
334/// Validate a logical plan with model-level key values.
335///
336/// Ownership:
337/// - semantic owner for user-facing query validity at planning boundaries
338/// - failures here are user-visible planning failures (`PlanError`)
339///
340/// New user-facing validation rules must be introduced here first, then mirrored
341/// defensively in downstream layers without changing semantics.
342pub(crate) fn validate_query_semantics(
343    schema: &SchemaInfo,
344    model: &EntityModel,
345    plan: &AccessPlannedQuery<Value>,
346) -> Result<(), PlanError> {
347    let logical = plan.scalar_plan();
348
349    validate_plan_core(
350        schema,
351        model,
352        logical,
353        plan,
354        validate_order,
355        |schema, model, plan| {
356            validate_access_structure_model_shared(schema, model, &plan.access)
357                .map_err(PlanError::from)
358        },
359    )?;
360
361    Ok(())
362}
363
364/// Validate grouped query semantics for one grouped plan wrapper.
365///
366/// Ownership:
367/// - semantic owner for GROUP BY wrapper validation
368/// - failures here are user-visible planning failures (`PlanError`)
369pub(crate) fn validate_group_query_semantics(
370    schema: &SchemaInfo,
371    model: &EntityModel,
372    plan: &AccessPlannedQuery<Value>,
373) -> Result<(), PlanError> {
374    let (logical, group, having) = match &plan.logical {
375        LogicalPlan::Grouped(grouped) => (&grouped.scalar, &grouped.group, grouped.having.as_ref()),
376        LogicalPlan::Scalar(_) => {
377            return Err(PlanError::from(GroupPlanError::GroupedLogicalPlanRequired));
378        }
379    };
380
381    validate_plan_core(
382        schema,
383        model,
384        logical,
385        plan,
386        validate_order,
387        |schema, model, plan| {
388            validate_access_structure_model_shared(schema, model, &plan.access)
389                .map_err(PlanError::from)
390        },
391    )?;
392    if group.group_fields.is_empty() && having.is_some() {
393        return Err(PlanError::from(
394            GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
395        ));
396    }
397    validate_grouped_distinct_and_order_policy(logical, group, having.is_some())?;
398    validate_group_spec(schema, model, group)?;
399    validate_grouped_having_policy(group, having)?;
400
401    Ok(())
402}
403
404// Validate grouped DISTINCT + ORDER BY policy gates for grouped v1 hardening.
405fn validate_grouped_distinct_and_order_policy(
406    logical: &ScalarPlan,
407    group: &GroupSpec,
408    has_having: bool,
409) -> Result<(), PlanError> {
410    if logical.distinct && has_having {
411        return Err(PlanError::from(GroupPlanError::DistinctHavingUnsupported));
412    }
413    if logical.distinct {
414        return Err(PlanError::from(
415            GroupPlanError::DistinctAdjacencyEligibilityRequired,
416        ));
417    }
418
419    let Some(order) = logical.order.as_ref() else {
420        return Ok(());
421    };
422    if logical.page.as_ref().and_then(|page| page.limit).is_none() {
423        return Err(PlanError::from(GroupPlanError::OrderRequiresLimit));
424    }
425    if order_prefix_aligned_with_group_fields(order, group.group_fields.as_slice()) {
426        return Ok(());
427    }
428
429    Err(PlanError::from(
430        GroupPlanError::OrderPrefixNotAlignedWithGroupKeys,
431    ))
432}
433
434// Validate grouped HAVING policy gates and grouped-symbol references.
435fn validate_grouped_having_policy(
436    group: &GroupSpec,
437    having: Option<&GroupHavingSpec>,
438) -> Result<(), PlanError> {
439    let Some(having) = having else {
440        return Ok(());
441    };
442
443    for (index, clause) in having.clauses().iter().enumerate() {
444        if !having_compare_op_supported(clause.op()) {
445            return Err(PlanError::from(
446                GroupPlanError::HavingUnsupportedCompareOp {
447                    index,
448                    op: format!("{:?}", clause.op()),
449                },
450            ));
451        }
452
453        match clause.symbol() {
454            GroupHavingSymbol::GroupField(field_slot) => {
455                if !group
456                    .group_fields
457                    .iter()
458                    .any(|group_field| group_field.index() == field_slot.index())
459                {
460                    return Err(PlanError::from(
461                        GroupPlanError::HavingNonGroupFieldReference {
462                            index,
463                            field: field_slot.field().to_string(),
464                        },
465                    ));
466                }
467            }
468            GroupHavingSymbol::AggregateIndex(aggregate_index) => {
469                if *aggregate_index >= group.aggregates.len() {
470                    return Err(PlanError::from(
471                        GroupPlanError::HavingAggregateIndexOutOfBounds {
472                            index,
473                            aggregate_index: *aggregate_index,
474                            aggregate_count: group.aggregates.len(),
475                        },
476                    ));
477                }
478            }
479        }
480    }
481
482    Ok(())
483}
484
485const fn having_compare_op_supported(op: CompareOp) -> bool {
486    matches!(
487        op,
488        CompareOp::Eq
489            | CompareOp::Ne
490            | CompareOp::Lt
491            | CompareOp::Lte
492            | CompareOp::Gt
493            | CompareOp::Gte
494    )
495}
496
497// Return true when ORDER BY starts with GROUP BY key fields in declaration order.
498fn order_prefix_aligned_with_group_fields(order: &OrderSpec, group_fields: &[FieldSlot]) -> bool {
499    if order.fields.len() < group_fields.len() {
500        return false;
501    }
502
503    group_fields
504        .iter()
505        .zip(order.fields.iter())
506        .all(|(group_field, (order_field, _))| order_field == group_field.field())
507}
508
509/// Validate one grouped declarative spec against schema-level field surface.
510pub(crate) fn validate_group_spec(
511    schema: &SchemaInfo,
512    model: &EntityModel,
513    group: &GroupSpec,
514) -> Result<(), PlanError> {
515    if group.group_fields.is_empty() {
516        if group.aggregates.iter().any(GroupAggregateSpec::distinct) {
517            validate_global_distinct_aggregate_without_group_keys(schema, group)?;
518            return Ok(());
519        }
520
521        return Err(PlanError::from(GroupPlanError::EmptyGroupFields));
522    }
523    if group.aggregates.is_empty() {
524        return Err(PlanError::from(GroupPlanError::EmptyAggregates));
525    }
526
527    let mut seen_group_slots = BTreeSet::<usize>::new();
528    for field_slot in &group.group_fields {
529        if model.fields.get(field_slot.index()).is_none() {
530            return Err(PlanError::from(GroupPlanError::UnknownGroupField {
531                field: field_slot.field().to_string(),
532            }));
533        }
534        if !seen_group_slots.insert(field_slot.index()) {
535            return Err(PlanError::from(GroupPlanError::DuplicateGroupField {
536                field: field_slot.field().to_string(),
537            }));
538        }
539    }
540
541    for (index, aggregate) in group.aggregates.iter().enumerate() {
542        if aggregate.distinct() && !aggregate.kind().supports_grouped_distinct_v1() {
543            return Err(PlanError::from(
544                GroupPlanError::DistinctAggregateKindUnsupported {
545                    index,
546                    kind: format!("{:?}", aggregate.kind()),
547                },
548            ));
549        }
550
551        let Some(target_field) = aggregate.target_field.as_ref() else {
552            continue;
553        };
554        if aggregate.distinct() {
555            return Err(PlanError::from(
556                GroupPlanError::DistinctAggregateFieldTargetUnsupported {
557                    index,
558                    kind: format!("{:?}", aggregate.kind()),
559                    field: target_field.clone(),
560                },
561            ));
562        }
563        if schema.field(target_field).is_none() {
564            return Err(PlanError::from(
565                GroupPlanError::UnknownAggregateTargetField {
566                    index,
567                    field: target_field.clone(),
568                },
569            ));
570        }
571        return Err(PlanError::from(
572            GroupPlanError::FieldTargetAggregatesUnsupported {
573                index,
574                kind: format!("{:?}", aggregate.kind()),
575                field: target_field.clone(),
576            },
577        ));
578    }
579
580    Ok(())
581}
582
583// Validate the restricted global DISTINCT aggregate shape (`GROUP BY` omitted).
584fn validate_global_distinct_aggregate_without_group_keys(
585    schema: &SchemaInfo,
586    group: &GroupSpec,
587) -> Result<(), PlanError> {
588    if group.aggregates.len() != 1 {
589        return Err(PlanError::from(
590            GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
591        ));
592    }
593    let aggregate = &group.aggregates[0];
594    if !aggregate.distinct() {
595        return Err(PlanError::from(
596            GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
597        ));
598    }
599    if !aggregate
600        .kind()
601        .supports_global_distinct_without_group_keys()
602    {
603        return Err(PlanError::from(
604            GroupPlanError::DistinctAggregateKindUnsupported {
605                index: 0,
606                kind: format!("{:?}", aggregate.kind()),
607            },
608        ));
609    }
610
611    let Some(target_field) = aggregate.target_field() else {
612        return Err(PlanError::from(
613            GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
614        ));
615    };
616    let Some(field_type) = schema.field(target_field) else {
617        return Err(PlanError::from(
618            GroupPlanError::UnknownAggregateTargetField {
619                index: 0,
620                field: target_field.to_string(),
621            },
622        ));
623    };
624    if aggregate.kind().is_sum() && !field_type.supports_numeric_coercion() {
625        return Err(PlanError::from(
626            GroupPlanError::GlobalDistinctSumTargetNotNumeric {
627                index: 0,
628                field: target_field.to_string(),
629            },
630        ));
631    }
632
633    Ok(())
634}
635
636// Shared logical plan validation core owned by planner semantics.
637fn validate_plan_core<K, FOrder, FAccess>(
638    schema: &SchemaInfo,
639    model: &EntityModel,
640    logical: &ScalarPlan,
641    plan: &AccessPlannedQuery<K>,
642    validate_order_fn: FOrder,
643    validate_access_fn: FAccess,
644) -> Result<(), PlanError>
645where
646    FOrder: Fn(&SchemaInfo, &OrderSpec) -> Result<(), PlanError>,
647    FAccess: Fn(&SchemaInfo, &EntityModel, &AccessPlannedQuery<K>) -> Result<(), PlanError>,
648{
649    if let Some(predicate) = &logical.predicate {
650        validate(schema, predicate)?;
651    }
652
653    if let Some(order) = &logical.order {
654        validate_order_fn(schema, order)?;
655        validate_no_duplicate_non_pk_order_fields(model, order)?;
656        validate_primary_key_tie_break(model, order)?;
657    }
658
659    validate_access_fn(schema, model, plan)?;
660    validate_plan_shape(&plan.logical)?;
661
662    Ok(())
663}
664// ORDER validation ownership contract:
665// - This module owns ORDER semantic validation (field existence/orderability/tie-break).
666// - ORDER canonicalization (primary-key tie-break insertion) is performed at the
667//   intent boundary via `canonicalize_order_spec` before plan validation.
668// - Shape-policy checks (for example empty ORDER, pagination/order coupling) are owned here.
669// - Executor/runtime layers may defend execution preconditions only.
670
671/// Return true when an ORDER BY exists and contains at least one field.
672#[must_use]
673pub(crate) fn has_explicit_order(order: Option<&OrderSpec>) -> bool {
674    order.is_some_and(|order| !order.fields.is_empty())
675}
676
677/// Return true when an ORDER BY exists but is empty.
678#[must_use]
679pub(crate) fn has_empty_order(order: Option<&OrderSpec>) -> bool {
680    order.is_some_and(|order| order.fields.is_empty())
681}
682
683/// Validate order-shape rules shared across intent and logical plan boundaries.
684pub(crate) fn validate_order_shape(order: Option<&OrderSpec>) -> Result<(), PolicyPlanError> {
685    if has_empty_order(order) {
686        return Err(PolicyPlanError::EmptyOrderSpec);
687    }
688
689    Ok(())
690}
691
692/// Validate intent-level plan-shape rules derived from query mode + order.
693pub(crate) fn validate_intent_plan_shape(
694    mode: QueryMode,
695    order: Option<&OrderSpec>,
696) -> Result<(), PolicyPlanError> {
697    validate_order_shape(order)?;
698
699    let has_order = has_explicit_order(order);
700    if matches!(mode, QueryMode::Delete(spec) if spec.limit.is_some()) && !has_order {
701        return Err(PolicyPlanError::DeleteLimitRequiresOrder);
702    }
703
704    Ok(())
705}
706
707/// Validate cursor-pagination readiness for a load-spec + ordering pair.
708pub(crate) const fn validate_cursor_paging_requirements(
709    has_order: bool,
710    spec: LoadSpec,
711) -> Result<(), CursorPagingPolicyError> {
712    if !has_order {
713        return Err(CursorPagingPolicyError::CursorRequiresOrder);
714    }
715    if spec.limit.is_none() {
716        return Err(CursorPagingPolicyError::CursorRequiresLimit);
717    }
718
719    Ok(())
720}
721
722/// Validate cursor-order shape and return the logical order contract when present.
723pub(crate) const fn validate_cursor_order_plan_shape(
724    order: Option<&OrderSpec>,
725    require_explicit_order: bool,
726) -> Result<Option<&OrderSpec>, CursorOrderPlanShapeError> {
727    let Some(order) = order else {
728        if require_explicit_order {
729            return Err(CursorOrderPlanShapeError::MissingExplicitOrder);
730        }
731
732        return Ok(None);
733    };
734
735    if order.fields.is_empty() {
736        return Err(CursorOrderPlanShapeError::EmptyOrderSpec);
737    }
738
739    Ok(Some(order))
740}
741
742/// Resolve one grouped field into a stable field slot.
743pub(crate) fn resolve_group_field_slot(
744    model: &EntityModel,
745    field: &str,
746) -> Result<FieldSlot, PlanError> {
747    FieldSlot::resolve(model, field).ok_or_else(|| {
748        PlanError::from(GroupPlanError::UnknownGroupField {
749            field: field.to_string(),
750        })
751    })
752}
753
754/// Validate intent key-access policy before planning.
755pub(crate) const fn validate_intent_key_access_policy(
756    key_access_conflict: bool,
757    key_access_kind: Option<IntentKeyAccessKind>,
758    has_predicate: bool,
759) -> Result<(), IntentKeyAccessPolicyViolation> {
760    if key_access_conflict {
761        return Err(IntentKeyAccessPolicyViolation::KeyAccessConflict);
762    }
763
764    match key_access_kind {
765        Some(IntentKeyAccessKind::Many) if has_predicate => {
766            Err(IntentKeyAccessPolicyViolation::ByIdsWithPredicate)
767        }
768        Some(IntentKeyAccessKind::Only) if has_predicate => {
769            Err(IntentKeyAccessPolicyViolation::OnlyWithPredicate)
770        }
771        Some(
772            IntentKeyAccessKind::Single | IntentKeyAccessKind::Many | IntentKeyAccessKind::Only,
773        )
774        | None => Ok(()),
775    }
776}
777
778/// Validate grouped field-target terminal compatibility at intent boundaries.
779pub(crate) const fn validate_grouped_field_target_extrema_policy()
780-> Result<(), IntentTerminalPolicyViolation> {
781    Err(IntentTerminalPolicyViolation::GroupedFieldTargetExtremaUnsupported)
782}
783
784/// Validate fluent non-paged load entry policy.
785pub(crate) const fn validate_fluent_non_paged_mode(
786    has_cursor_token: bool,
787    has_grouping: bool,
788) -> Result<(), FluentLoadPolicyViolation> {
789    if has_cursor_token {
790        return Err(FluentLoadPolicyViolation::CursorRequiresPagedExecution);
791    }
792    if has_grouping {
793        return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
794    }
795
796    Ok(())
797}
798
799/// Validate fluent paged load entry policy.
800pub(crate) fn validate_fluent_paged_mode(
801    has_grouping: bool,
802    has_explicit_order: bool,
803    spec: Option<LoadSpec>,
804) -> Result<(), FluentLoadPolicyViolation> {
805    if has_grouping {
806        return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
807    }
808
809    let Some(spec) = spec else {
810        return Ok(());
811    };
812
813    validate_cursor_paging_requirements(has_explicit_order, spec).map_err(|err| match err {
814        CursorPagingPolicyError::CursorRequiresOrder => {
815            FluentLoadPolicyViolation::CursorRequiresOrder
816        }
817        CursorPagingPolicyError::CursorRequiresLimit => {
818            FluentLoadPolicyViolation::CursorRequiresLimit
819        }
820    })
821}
822
823/// Validate mode/order/pagination invariants for one logical plan.
824pub(crate) fn validate_plan_shape(plan: &LogicalPlan) -> Result<(), PolicyPlanError> {
825    let grouped = matches!(plan, LogicalPlan::Grouped(_));
826    let plan = match plan {
827        LogicalPlan::Scalar(plan) => plan,
828        LogicalPlan::Grouped(plan) => &plan.scalar,
829    };
830    validate_order_shape(plan.order.as_ref())?;
831
832    let has_order = has_explicit_order(plan.order.as_ref());
833    if plan.delete_limit.is_some() && !has_order {
834        return Err(PolicyPlanError::DeleteLimitRequiresOrder);
835    }
836
837    match plan.mode {
838        QueryMode::Delete(_) => {
839            if plan.page.is_some() {
840                return Err(PolicyPlanError::DeletePlanWithPagination);
841            }
842        }
843        QueryMode::Load(_) => {
844            if plan.delete_limit.is_some() {
845                return Err(PolicyPlanError::LoadPlanWithDeleteLimit);
846            }
847            // GROUP BY v1 uses canonical grouped key ordering when ORDER BY is
848            // omitted, so grouped pagination remains deterministic without an
849            // explicit sort clause.
850            if plan.page.is_some() && !has_order && !grouped {
851                return Err(PolicyPlanError::UnorderedPagination);
852            }
853        }
854    }
855
856    Ok(())
857}
858
859/// Validate ORDER BY fields against the schema.
860pub(crate) fn validate_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
861    for (field, _) in &order.fields {
862        let field_type = schema
863            .field(field)
864            .ok_or_else(|| OrderPlanError::UnknownField {
865                field: field.clone(),
866            })
867            .map_err(PlanError::from)?;
868
869        if !field_type.is_orderable() {
870            // CONTRACT: ORDER BY rejects non-queryable or unordered fields.
871            return Err(PlanError::from(OrderPlanError::UnorderableField {
872                field: field.clone(),
873            }));
874        }
875    }
876
877    Ok(())
878}
879
880/// Reject duplicate non-primary-key fields in ORDER BY.
881pub(crate) fn validate_no_duplicate_non_pk_order_fields(
882    model: &EntityModel,
883    order: &OrderSpec,
884) -> Result<(), PlanError> {
885    let mut seen = BTreeSet::new();
886    let pk_field = model.primary_key.name;
887
888    for (field, _) in &order.fields {
889        if field == pk_field {
890            continue;
891        }
892        if !seen.insert(field.as_str()) {
893            return Err(PlanError::from(OrderPlanError::DuplicateOrderField {
894                field: field.clone(),
895            }));
896        }
897    }
898
899    Ok(())
900}
901
902// Ordered plans must include exactly one terminal primary-key field so ordering is total and
903// deterministic across explain, fingerprint, and executor comparison paths.
904pub(crate) fn validate_primary_key_tie_break(
905    model: &EntityModel,
906    order: &OrderSpec,
907) -> Result<(), PlanError> {
908    if order.fields.is_empty() {
909        return Ok(());
910    }
911
912    let pk_field = model.primary_key.name;
913    let pk_count = order
914        .fields
915        .iter()
916        .filter(|(field, _)| field == pk_field)
917        .count();
918    let trailing_pk = order
919        .fields
920        .last()
921        .is_some_and(|(field, _)| field == pk_field);
922
923    if pk_count == 1 && trailing_pk {
924        Ok(())
925    } else {
926        Err(PlanError::from(OrderPlanError::MissingPrimaryKeyTieBreak {
927            field: pk_field.to_string(),
928        }))
929    }
930}