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