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