1use 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#[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#[derive(Debug, ThisError)]
66pub enum OrderPlanError {
67 #[error("unknown order field '{field}'")]
69 UnknownField { field: String },
70
71 #[error("order field '{field}' is not orderable")]
73 UnorderableField { field: String },
74
75 #[error("order field '{field}' appears multiple times")]
77 DuplicateOrderField { field: String },
78
79 #[error("order specification must end with primary key '{field}' as deterministic tie-break")]
81 MissingPrimaryKeyTieBreak { field: String },
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)]
91pub enum PolicyPlanError {
92 #[error("order specification must include at least one field")]
94 EmptyOrderSpec,
95
96 #[error("delete plans must not include pagination")]
98 DeletePlanWithPagination,
99
100 #[error("load plans must not include delete limits")]
102 LoadPlanWithDeleteLimit,
103
104 #[error("delete limit requires an explicit ordering")]
106 DeleteLimitRequiresOrder,
107
108 #[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#[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#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
136pub enum GroupPlanError {
137 #[error("HAVING is only supported for GROUP BY queries in this release")]
139 HavingRequiresGroupBy,
140
141 #[error("group query validation requires grouped logical plan variant")]
143 GroupedLogicalPlanRequired,
144
145 #[error("group specification must include at least one group field")]
147 EmptyGroupFields,
148
149 #[error(
151 "global DISTINCT aggregate without GROUP BY must declare exactly one DISTINCT field-target aggregate in this release"
152 )]
153 GlobalDistinctAggregateShapeUnsupported,
154
155 #[error("group specification must include at least one aggregate terminal")]
157 EmptyAggregates,
158
159 #[error("unknown group field '{field}'")]
161 UnknownGroupField { field: String },
162
163 #[error("group specification has duplicate group key: '{field}'")]
165 DuplicateGroupField { field: String },
166
167 #[error(
169 "grouped DISTINCT requires adjacency-based ordered-group eligibility proof in this release"
170 )]
171 DistinctAdjacencyEligibilityRequired,
172
173 #[error("grouped ORDER BY must start with GROUP BY key prefix in this release")]
175 OrderPrefixNotAlignedWithGroupKeys,
176
177 #[error("grouped ORDER BY requires LIMIT in this release")]
179 OrderRequiresLimit,
180
181 #[error("grouped HAVING with DISTINCT is not supported in this release")]
183 DistinctHavingUnsupported,
184
185 #[error("grouped HAVING clause at index={index} uses unsupported operator: {op}")]
187 HavingUnsupportedCompareOp { index: usize, op: String },
188
189 #[error("grouped HAVING clause at index={index} references non-group field '{field}'")]
191 HavingNonGroupFieldReference { index: usize, field: String },
192
193 #[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 #[error(
205 "grouped DISTINCT aggregate at index={index} uses unsupported kind '{kind}' in this release"
206 )]
207 DistinctAggregateKindUnsupported { index: usize, kind: String },
208
209 #[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 #[error("unknown grouped aggregate target field at index={index}: '{field}'")]
221 UnknownAggregateTargetField { index: usize, field: String },
222
223 #[error(
225 "global DISTINCT SUM aggregate target field at index={index} is not numeric: '{field}'"
226 )]
227 GlobalDistinctSumTargetNotNumeric { index: usize, field: String },
228
229 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
246pub(crate) enum CursorOrderPlanShapeError {
247 MissingExplicitOrder,
248 EmptyOrderSpec,
249}
250
251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
257pub(crate) enum IntentKeyAccessKind {
258 Single,
259 Many,
260 Only,
261}
262
263#[derive(Clone, Copy, Debug, Eq, PartialEq)]
269pub(crate) enum IntentKeyAccessPolicyViolation {
270 KeyAccessConflict,
271 ByIdsWithPredicate,
272 OnlyWithPredicate,
273}
274
275#[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
324pub(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
354pub(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
389fn 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
408fn 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
422fn validate_group_cursor_constraints(
424 logical: &ScalarPlan,
425 group: &GroupSpec,
426) -> Result<(), PlanError> {
427 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
444fn 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
461fn 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
503fn 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
535fn 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
547fn 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
595fn 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
636fn 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
689fn 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#[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#[must_use]
732pub(crate) fn has_empty_order(order: Option<&OrderSpec>) -> bool {
733 order.is_some_and(|order| order.fields.is_empty())
734}
735
736pub(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
745pub(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
760pub(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
775pub(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
795pub(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
807pub(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
831pub(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
846pub(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
870pub(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 if plan.page.is_some() && !has_order && !grouped {
898 return Err(PolicyPlanError::UnorderedPagination);
899 }
900 }
901 }
902
903 Ok(())
904}
905
906pub(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 return Err(PlanError::from(OrderPlanError::UnorderableField {
919 field: field.clone(),
920 }));
921 }
922 }
923
924 Ok(())
925}
926
927pub(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
949pub(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}