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    validate_group_structure(schema, model, group, having)?;
383    validate_group_policy(schema, logical, group, having)?;
384    validate_group_cursor_constraints(logical, group)?;
385
386    Ok(())
387}
388
389// Validate grouped structural invariants before policy/cursor gates.
390fn validate_group_structure(
391    schema: &SchemaInfo,
392    model: &EntityModel,
393    group: &GroupSpec,
394    having: Option<&GroupHavingSpec>,
395) -> Result<(), PlanError> {
396    if group.group_fields.is_empty() && having.is_some() {
397        return Err(PlanError::from(
398            GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
399        ));
400    }
401
402    validate_group_spec_structure(schema, model, group)?;
403    validate_grouped_having_structure(group, having)?;
404
405    Ok(())
406}
407
408// Validate grouped policy gates independent from structural shape checks.
409fn validate_group_policy(
410    schema: &SchemaInfo,
411    logical: &ScalarPlan,
412    group: &GroupSpec,
413    having: Option<&GroupHavingSpec>,
414) -> Result<(), PlanError> {
415    validate_grouped_distinct_policy(logical, having.is_some())?;
416    validate_grouped_having_policy(having)?;
417    validate_group_spec_policy(schema, group)?;
418
419    Ok(())
420}
421
422// Validate grouped cursor-order constraints in one dedicated gate.
423fn validate_group_cursor_constraints(
424    logical: &ScalarPlan,
425    group: &GroupSpec,
426) -> Result<(), PlanError> {
427    // Grouped pagination/order constraints are cursor-domain policy:
428    // grouped ORDER BY requires LIMIT and must align with grouped-key prefix.
429    let Some(order) = logical.order.as_ref() else {
430        return Ok(());
431    };
432    if logical.page.as_ref().and_then(|page| page.limit).is_none() {
433        return Err(PlanError::from(GroupPlanError::OrderRequiresLimit));
434    }
435    if order_prefix_aligned_with_group_fields(order, group.group_fields.as_slice()) {
436        return Ok(());
437    }
438
439    Err(PlanError::from(
440        GroupPlanError::OrderPrefixNotAlignedWithGroupKeys,
441    ))
442}
443
444// Validate grouped DISTINCT policy gates for grouped v1 hardening.
445fn validate_grouped_distinct_policy(
446    logical: &ScalarPlan,
447    has_having: bool,
448) -> Result<(), PlanError> {
449    if logical.distinct && has_having {
450        return Err(PlanError::from(GroupPlanError::DistinctHavingUnsupported));
451    }
452    if logical.distinct {
453        return Err(PlanError::from(
454            GroupPlanError::DistinctAdjacencyEligibilityRequired,
455        ));
456    }
457
458    Ok(())
459}
460
461// Validate grouped HAVING structural symbol/reference compatibility.
462fn validate_grouped_having_structure(
463    group: &GroupSpec,
464    having: Option<&GroupHavingSpec>,
465) -> Result<(), PlanError> {
466    let Some(having) = having else {
467        return Ok(());
468    };
469
470    for (index, clause) in having.clauses().iter().enumerate() {
471        match clause.symbol() {
472            GroupHavingSymbol::GroupField(field_slot) => {
473                if !group
474                    .group_fields
475                    .iter()
476                    .any(|group_field| group_field.index() == field_slot.index())
477                {
478                    return Err(PlanError::from(
479                        GroupPlanError::HavingNonGroupFieldReference {
480                            index,
481                            field: field_slot.field().to_string(),
482                        },
483                    ));
484                }
485            }
486            GroupHavingSymbol::AggregateIndex(aggregate_index) => {
487                if *aggregate_index >= group.aggregates.len() {
488                    return Err(PlanError::from(
489                        GroupPlanError::HavingAggregateIndexOutOfBounds {
490                            index,
491                            aggregate_index: *aggregate_index,
492                            aggregate_count: group.aggregates.len(),
493                        },
494                    ));
495                }
496            }
497        }
498    }
499
500    Ok(())
501}
502
503// Validate grouped HAVING policy gates and operator support.
504fn validate_grouped_having_policy(having: Option<&GroupHavingSpec>) -> Result<(), PlanError> {
505    let Some(having) = having else {
506        return Ok(());
507    };
508
509    for (index, clause) in having.clauses().iter().enumerate() {
510        if !having_compare_op_supported(clause.op()) {
511            return Err(PlanError::from(
512                GroupPlanError::HavingUnsupportedCompareOp {
513                    index,
514                    op: format!("{:?}", clause.op()),
515                },
516            ));
517        }
518    }
519
520    Ok(())
521}
522
523const fn having_compare_op_supported(op: CompareOp) -> bool {
524    matches!(
525        op,
526        CompareOp::Eq
527            | CompareOp::Ne
528            | CompareOp::Lt
529            | CompareOp::Lte
530            | CompareOp::Gt
531            | CompareOp::Gte
532    )
533}
534
535// Return true when ORDER BY starts with GROUP BY key fields in declaration order.
536fn order_prefix_aligned_with_group_fields(order: &OrderSpec, group_fields: &[FieldSlot]) -> bool {
537    if order.fields.len() < group_fields.len() {
538        return false;
539    }
540
541    group_fields
542        .iter()
543        .zip(order.fields.iter())
544        .all(|(group_field, (order_field, _))| order_field == group_field.field())
545}
546
547// Validate grouped structural declarations against model/schema shape.
548fn validate_group_spec_structure(
549    schema: &SchemaInfo,
550    model: &EntityModel,
551    group: &GroupSpec,
552) -> Result<(), PlanError> {
553    if group.group_fields.is_empty() {
554        if group.aggregates.iter().any(GroupAggregateSpec::distinct) {
555            return Ok(());
556        }
557
558        return Err(PlanError::from(GroupPlanError::EmptyGroupFields));
559    }
560    if group.aggregates.is_empty() {
561        return Err(PlanError::from(GroupPlanError::EmptyAggregates));
562    }
563
564    let mut seen_group_slots = BTreeSet::<usize>::new();
565    for field_slot in &group.group_fields {
566        if model.fields.get(field_slot.index()).is_none() {
567            return Err(PlanError::from(GroupPlanError::UnknownGroupField {
568                field: field_slot.field().to_string(),
569            }));
570        }
571        if !seen_group_slots.insert(field_slot.index()) {
572            return Err(PlanError::from(GroupPlanError::DuplicateGroupField {
573                field: field_slot.field().to_string(),
574            }));
575        }
576    }
577
578    for (index, aggregate) in group.aggregates.iter().enumerate() {
579        let Some(target_field) = aggregate.target_field.as_ref() else {
580            continue;
581        };
582        if schema.field(target_field).is_none() {
583            return Err(PlanError::from(
584                GroupPlanError::UnknownAggregateTargetField {
585                    index,
586                    field: target_field.clone(),
587                },
588            ));
589        }
590    }
591
592    Ok(())
593}
594
595// Validate grouped execution policy over a structurally valid grouped spec.
596fn validate_group_spec_policy(schema: &SchemaInfo, group: &GroupSpec) -> Result<(), PlanError> {
597    if group.group_fields.is_empty() {
598        validate_global_distinct_aggregate_without_group_keys(schema, group)?;
599        return Ok(());
600    }
601
602    for (index, aggregate) in group.aggregates.iter().enumerate() {
603        if aggregate.distinct() && !aggregate.kind().supports_grouped_distinct_v1() {
604            return Err(PlanError::from(
605                GroupPlanError::DistinctAggregateKindUnsupported {
606                    index,
607                    kind: format!("{:?}", aggregate.kind()),
608                },
609            ));
610        }
611
612        let Some(target_field) = aggregate.target_field.as_ref() else {
613            continue;
614        };
615        if aggregate.distinct() {
616            return Err(PlanError::from(
617                GroupPlanError::DistinctAggregateFieldTargetUnsupported {
618                    index,
619                    kind: format!("{:?}", aggregate.kind()),
620                    field: target_field.clone(),
621                },
622            ));
623        }
624        return Err(PlanError::from(
625            GroupPlanError::FieldTargetAggregatesUnsupported {
626                index,
627                kind: format!("{:?}", aggregate.kind()),
628                field: target_field.clone(),
629            },
630        ));
631    }
632
633    Ok(())
634}
635
636// Validate the restricted global DISTINCT aggregate shape (`GROUP BY` omitted).
637fn validate_global_distinct_aggregate_without_group_keys(
638    schema: &SchemaInfo,
639    group: &GroupSpec,
640) -> Result<(), PlanError> {
641    if group.aggregates.len() != 1 {
642        return Err(PlanError::from(
643            GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
644        ));
645    }
646    let aggregate = &group.aggregates[0];
647    if !aggregate.distinct() {
648        return Err(PlanError::from(
649            GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
650        ));
651    }
652    if !aggregate
653        .kind()
654        .supports_global_distinct_without_group_keys()
655    {
656        return Err(PlanError::from(
657            GroupPlanError::DistinctAggregateKindUnsupported {
658                index: 0,
659                kind: format!("{:?}", aggregate.kind()),
660            },
661        ));
662    }
663
664    let Some(target_field) = aggregate.target_field() else {
665        return Err(PlanError::from(
666            GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
667        ));
668    };
669    let Some(field_type) = schema.field(target_field) else {
670        return Err(PlanError::from(
671            GroupPlanError::UnknownAggregateTargetField {
672                index: 0,
673                field: target_field.to_string(),
674            },
675        ));
676    };
677    if aggregate.kind().is_sum() && !field_type.supports_numeric_coercion() {
678        return Err(PlanError::from(
679            GroupPlanError::GlobalDistinctSumTargetNotNumeric {
680                index: 0,
681                field: target_field.to_string(),
682            },
683        ));
684    }
685
686    Ok(())
687}
688
689// Shared logical plan validation core owned by planner semantics.
690fn validate_plan_core<K, FOrder, FAccess>(
691    schema: &SchemaInfo,
692    model: &EntityModel,
693    logical: &ScalarPlan,
694    plan: &AccessPlannedQuery<K>,
695    validate_order_fn: FOrder,
696    validate_access_fn: FAccess,
697) -> Result<(), PlanError>
698where
699    FOrder: Fn(&SchemaInfo, &OrderSpec) -> Result<(), PlanError>,
700    FAccess: Fn(&SchemaInfo, &EntityModel, &AccessPlannedQuery<K>) -> Result<(), PlanError>,
701{
702    if let Some(predicate) = &logical.predicate {
703        validate(schema, predicate)?;
704    }
705
706    if let Some(order) = &logical.order {
707        validate_order_fn(schema, order)?;
708        validate_no_duplicate_non_pk_order_fields(model, order)?;
709        validate_primary_key_tie_break(model, order)?;
710    }
711
712    validate_access_fn(schema, model, plan)?;
713    validate_plan_shape(&plan.logical)?;
714
715    Ok(())
716}
717// ORDER validation ownership contract:
718// - This module owns ORDER semantic validation (field existence/orderability/tie-break).
719// - ORDER canonicalization (primary-key tie-break insertion) is performed at the
720//   intent boundary via `canonicalize_order_spec` before plan validation.
721// - Shape-policy checks (for example empty ORDER, pagination/order coupling) are owned here.
722// - Executor/runtime layers may defend execution preconditions only.
723
724/// Return true when an ORDER BY exists and contains at least one field.
725#[must_use]
726pub(crate) fn has_explicit_order(order: Option<&OrderSpec>) -> bool {
727    order.is_some_and(|order| !order.fields.is_empty())
728}
729
730/// Return true when an ORDER BY exists but is empty.
731#[must_use]
732pub(crate) fn has_empty_order(order: Option<&OrderSpec>) -> bool {
733    order.is_some_and(|order| order.fields.is_empty())
734}
735
736/// Validate order-shape rules shared across intent and logical plan boundaries.
737pub(crate) fn validate_order_shape(order: Option<&OrderSpec>) -> Result<(), PolicyPlanError> {
738    if has_empty_order(order) {
739        return Err(PolicyPlanError::EmptyOrderSpec);
740    }
741
742    Ok(())
743}
744
745/// Validate intent-level plan-shape rules derived from query mode + order.
746pub(crate) fn validate_intent_plan_shape(
747    mode: QueryMode,
748    order: Option<&OrderSpec>,
749) -> Result<(), PolicyPlanError> {
750    validate_order_shape(order)?;
751
752    let has_order = has_explicit_order(order);
753    if matches!(mode, QueryMode::Delete(spec) if spec.limit.is_some()) && !has_order {
754        return Err(PolicyPlanError::DeleteLimitRequiresOrder);
755    }
756
757    Ok(())
758}
759
760/// Validate cursor-pagination readiness for a load-spec + ordering pair.
761pub(crate) const fn validate_cursor_paging_requirements(
762    has_order: bool,
763    spec: LoadSpec,
764) -> Result<(), CursorPagingPolicyError> {
765    if !has_order {
766        return Err(CursorPagingPolicyError::CursorRequiresOrder);
767    }
768    if spec.limit.is_none() {
769        return Err(CursorPagingPolicyError::CursorRequiresLimit);
770    }
771
772    Ok(())
773}
774
775/// Validate cursor-order shape and return the logical order contract when present.
776pub(crate) const fn validate_cursor_order_plan_shape(
777    order: Option<&OrderSpec>,
778    require_explicit_order: bool,
779) -> Result<Option<&OrderSpec>, CursorOrderPlanShapeError> {
780    let Some(order) = order else {
781        if require_explicit_order {
782            return Err(CursorOrderPlanShapeError::MissingExplicitOrder);
783        }
784
785        return Ok(None);
786    };
787
788    if order.fields.is_empty() {
789        return Err(CursorOrderPlanShapeError::EmptyOrderSpec);
790    }
791
792    Ok(Some(order))
793}
794
795/// Resolve one grouped field into a stable field slot.
796pub(crate) fn resolve_group_field_slot(
797    model: &EntityModel,
798    field: &str,
799) -> Result<FieldSlot, PlanError> {
800    FieldSlot::resolve(model, field).ok_or_else(|| {
801        PlanError::from(GroupPlanError::UnknownGroupField {
802            field: field.to_string(),
803        })
804    })
805}
806
807/// Validate intent key-access policy before planning.
808pub(crate) const fn validate_intent_key_access_policy(
809    key_access_conflict: bool,
810    key_access_kind: Option<IntentKeyAccessKind>,
811    has_predicate: bool,
812) -> Result<(), IntentKeyAccessPolicyViolation> {
813    if key_access_conflict {
814        return Err(IntentKeyAccessPolicyViolation::KeyAccessConflict);
815    }
816
817    match key_access_kind {
818        Some(IntentKeyAccessKind::Many) if has_predicate => {
819            Err(IntentKeyAccessPolicyViolation::ByIdsWithPredicate)
820        }
821        Some(IntentKeyAccessKind::Only) if has_predicate => {
822            Err(IntentKeyAccessPolicyViolation::OnlyWithPredicate)
823        }
824        Some(
825            IntentKeyAccessKind::Single | IntentKeyAccessKind::Many | IntentKeyAccessKind::Only,
826        )
827        | None => Ok(()),
828    }
829}
830
831/// Validate fluent non-paged load entry policy.
832pub(crate) const fn validate_fluent_non_paged_mode(
833    has_cursor_token: bool,
834    has_grouping: bool,
835) -> Result<(), FluentLoadPolicyViolation> {
836    if has_cursor_token {
837        return Err(FluentLoadPolicyViolation::CursorRequiresPagedExecution);
838    }
839    if has_grouping {
840        return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
841    }
842
843    Ok(())
844}
845
846/// Validate fluent paged load entry policy.
847pub(crate) fn validate_fluent_paged_mode(
848    has_grouping: bool,
849    has_explicit_order: bool,
850    spec: Option<LoadSpec>,
851) -> Result<(), FluentLoadPolicyViolation> {
852    if has_grouping {
853        return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
854    }
855
856    let Some(spec) = spec else {
857        return Ok(());
858    };
859
860    validate_cursor_paging_requirements(has_explicit_order, spec).map_err(|err| match err {
861        CursorPagingPolicyError::CursorRequiresOrder => {
862            FluentLoadPolicyViolation::CursorRequiresOrder
863        }
864        CursorPagingPolicyError::CursorRequiresLimit => {
865            FluentLoadPolicyViolation::CursorRequiresLimit
866        }
867    })
868}
869
870/// Validate mode/order/pagination invariants for one logical plan.
871pub(crate) fn validate_plan_shape(plan: &LogicalPlan) -> Result<(), PolicyPlanError> {
872    let grouped = matches!(plan, LogicalPlan::Grouped(_));
873    let plan = match plan {
874        LogicalPlan::Scalar(plan) => plan,
875        LogicalPlan::Grouped(plan) => &plan.scalar,
876    };
877    validate_order_shape(plan.order.as_ref())?;
878
879    let has_order = has_explicit_order(plan.order.as_ref());
880    if plan.delete_limit.is_some() && !has_order {
881        return Err(PolicyPlanError::DeleteLimitRequiresOrder);
882    }
883
884    match plan.mode {
885        QueryMode::Delete(_) => {
886            if plan.page.is_some() {
887                return Err(PolicyPlanError::DeletePlanWithPagination);
888            }
889        }
890        QueryMode::Load(_) => {
891            if plan.delete_limit.is_some() {
892                return Err(PolicyPlanError::LoadPlanWithDeleteLimit);
893            }
894            // GROUP BY v1 uses canonical grouped key ordering when ORDER BY is
895            // omitted, so grouped pagination remains deterministic without an
896            // explicit sort clause.
897            if plan.page.is_some() && !has_order && !grouped {
898                return Err(PolicyPlanError::UnorderedPagination);
899            }
900        }
901    }
902
903    Ok(())
904}
905
906/// Validate ORDER BY fields against the schema.
907pub(crate) fn validate_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
908    for (field, _) in &order.fields {
909        let field_type = schema
910            .field(field)
911            .ok_or_else(|| OrderPlanError::UnknownField {
912                field: field.clone(),
913            })
914            .map_err(PlanError::from)?;
915
916        if !field_type.is_orderable() {
917            // CONTRACT: ORDER BY rejects non-queryable or unordered fields.
918            return Err(PlanError::from(OrderPlanError::UnorderableField {
919                field: field.clone(),
920            }));
921        }
922    }
923
924    Ok(())
925}
926
927/// Reject duplicate non-primary-key fields in ORDER BY.
928pub(crate) fn validate_no_duplicate_non_pk_order_fields(
929    model: &EntityModel,
930    order: &OrderSpec,
931) -> Result<(), PlanError> {
932    let mut seen = BTreeSet::new();
933    let pk_field = model.primary_key.name;
934
935    for (field, _) in &order.fields {
936        if field == pk_field {
937            continue;
938        }
939        if !seen.insert(field.as_str()) {
940            return Err(PlanError::from(OrderPlanError::DuplicateOrderField {
941                field: field.clone(),
942            }));
943        }
944    }
945
946    Ok(())
947}
948
949// Ordered plans must include exactly one terminal primary-key field so ordering is total and
950// deterministic across explain, fingerprint, and executor comparison paths.
951pub(crate) fn validate_primary_key_tie_break(
952    model: &EntityModel,
953    order: &OrderSpec,
954) -> Result<(), PlanError> {
955    if order.fields.is_empty() {
956        return Ok(());
957    }
958
959    let pk_field = model.primary_key.name;
960    let pk_count = order
961        .fields
962        .iter()
963        .filter(|(field, _)| field == pk_field)
964        .count();
965    let trailing_pk = order
966        .fields
967        .last()
968        .is_some_and(|(field, _)| field == pk_field);
969
970    if pk_count == 1 && trailing_pk {
971        Ok(())
972    } else {
973        Err(PlanError::from(OrderPlanError::MissingPrimaryKeyTieBreak {
974            field: pk_field.to_string(),
975        }))
976    }
977}