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 IntentTerminalPolicyViolation {
282 GroupedFieldTargetExtremaUnsupported,
283}
284
285#[derive(Clone, Copy, Debug, Eq, PartialEq)]
291pub(crate) enum FluentLoadPolicyViolation {
292 CursorRequiresPagedExecution,
293 GroupedRequiresExecuteGrouped,
294 CursorRequiresOrder,
295 CursorRequiresLimit,
296}
297
298impl From<ValidateError> for PlanError {
299 fn from(err: ValidateError) -> Self {
300 Self::PredicateInvalid(Box::new(err))
301 }
302}
303
304impl From<OrderPlanError> for PlanError {
305 fn from(err: OrderPlanError) -> Self {
306 Self::Order(Box::new(err))
307 }
308}
309
310impl From<AccessPlanError> for PlanError {
311 fn from(err: AccessPlanError) -> Self {
312 Self::Access(Box::new(err))
313 }
314}
315
316impl From<PolicyPlanError> for PlanError {
317 fn from(err: PolicyPlanError) -> Self {
318 Self::Policy(Box::new(err))
319 }
320}
321
322impl From<CursorPlanError> for PlanError {
323 fn from(err: CursorPlanError) -> Self {
324 Self::Cursor(Box::new(err))
325 }
326}
327
328impl From<GroupPlanError> for PlanError {
329 fn from(err: GroupPlanError) -> Self {
330 Self::Group(Box::new(err))
331 }
332}
333
334pub(crate) fn validate_query_semantics(
343 schema: &SchemaInfo,
344 model: &EntityModel,
345 plan: &AccessPlannedQuery<Value>,
346) -> Result<(), PlanError> {
347 let logical = plan.scalar_plan();
348
349 validate_plan_core(
350 schema,
351 model,
352 logical,
353 plan,
354 validate_order,
355 |schema, model, plan| {
356 validate_access_structure_model_shared(schema, model, &plan.access)
357 .map_err(PlanError::from)
358 },
359 )?;
360
361 Ok(())
362}
363
364pub(crate) fn validate_group_query_semantics(
370 schema: &SchemaInfo,
371 model: &EntityModel,
372 plan: &AccessPlannedQuery<Value>,
373) -> Result<(), PlanError> {
374 let (logical, group, having) = match &plan.logical {
375 LogicalPlan::Grouped(grouped) => (&grouped.scalar, &grouped.group, grouped.having.as_ref()),
376 LogicalPlan::Scalar(_) => {
377 return Err(PlanError::from(GroupPlanError::GroupedLogicalPlanRequired));
378 }
379 };
380
381 validate_plan_core(
382 schema,
383 model,
384 logical,
385 plan,
386 validate_order,
387 |schema, model, plan| {
388 validate_access_structure_model_shared(schema, model, &plan.access)
389 .map_err(PlanError::from)
390 },
391 )?;
392 if group.group_fields.is_empty() && having.is_some() {
393 return Err(PlanError::from(
394 GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
395 ));
396 }
397 validate_grouped_distinct_and_order_policy(logical, group, having.is_some())?;
398 validate_group_spec(schema, model, group)?;
399 validate_grouped_having_policy(group, having)?;
400
401 Ok(())
402}
403
404fn validate_grouped_distinct_and_order_policy(
406 logical: &ScalarPlan,
407 group: &GroupSpec,
408 has_having: bool,
409) -> Result<(), PlanError> {
410 if logical.distinct && has_having {
411 return Err(PlanError::from(GroupPlanError::DistinctHavingUnsupported));
412 }
413 if logical.distinct {
414 return Err(PlanError::from(
415 GroupPlanError::DistinctAdjacencyEligibilityRequired,
416 ));
417 }
418
419 let Some(order) = logical.order.as_ref() else {
420 return Ok(());
421 };
422 if logical.page.as_ref().and_then(|page| page.limit).is_none() {
423 return Err(PlanError::from(GroupPlanError::OrderRequiresLimit));
424 }
425 if order_prefix_aligned_with_group_fields(order, group.group_fields.as_slice()) {
426 return Ok(());
427 }
428
429 Err(PlanError::from(
430 GroupPlanError::OrderPrefixNotAlignedWithGroupKeys,
431 ))
432}
433
434fn validate_grouped_having_policy(
436 group: &GroupSpec,
437 having: Option<&GroupHavingSpec>,
438) -> Result<(), PlanError> {
439 let Some(having) = having else {
440 return Ok(());
441 };
442
443 for (index, clause) in having.clauses().iter().enumerate() {
444 if !having_compare_op_supported(clause.op()) {
445 return Err(PlanError::from(
446 GroupPlanError::HavingUnsupportedCompareOp {
447 index,
448 op: format!("{:?}", clause.op()),
449 },
450 ));
451 }
452
453 match clause.symbol() {
454 GroupHavingSymbol::GroupField(field_slot) => {
455 if !group
456 .group_fields
457 .iter()
458 .any(|group_field| group_field.index() == field_slot.index())
459 {
460 return Err(PlanError::from(
461 GroupPlanError::HavingNonGroupFieldReference {
462 index,
463 field: field_slot.field().to_string(),
464 },
465 ));
466 }
467 }
468 GroupHavingSymbol::AggregateIndex(aggregate_index) => {
469 if *aggregate_index >= group.aggregates.len() {
470 return Err(PlanError::from(
471 GroupPlanError::HavingAggregateIndexOutOfBounds {
472 index,
473 aggregate_index: *aggregate_index,
474 aggregate_count: group.aggregates.len(),
475 },
476 ));
477 }
478 }
479 }
480 }
481
482 Ok(())
483}
484
485const fn having_compare_op_supported(op: CompareOp) -> bool {
486 matches!(
487 op,
488 CompareOp::Eq
489 | CompareOp::Ne
490 | CompareOp::Lt
491 | CompareOp::Lte
492 | CompareOp::Gt
493 | CompareOp::Gte
494 )
495}
496
497fn order_prefix_aligned_with_group_fields(order: &OrderSpec, group_fields: &[FieldSlot]) -> bool {
499 if order.fields.len() < group_fields.len() {
500 return false;
501 }
502
503 group_fields
504 .iter()
505 .zip(order.fields.iter())
506 .all(|(group_field, (order_field, _))| order_field == group_field.field())
507}
508
509pub(crate) fn validate_group_spec(
511 schema: &SchemaInfo,
512 model: &EntityModel,
513 group: &GroupSpec,
514) -> Result<(), PlanError> {
515 if group.group_fields.is_empty() {
516 if group.aggregates.iter().any(GroupAggregateSpec::distinct) {
517 validate_global_distinct_aggregate_without_group_keys(schema, group)?;
518 return Ok(());
519 }
520
521 return Err(PlanError::from(GroupPlanError::EmptyGroupFields));
522 }
523 if group.aggregates.is_empty() {
524 return Err(PlanError::from(GroupPlanError::EmptyAggregates));
525 }
526
527 let mut seen_group_slots = BTreeSet::<usize>::new();
528 for field_slot in &group.group_fields {
529 if model.fields.get(field_slot.index()).is_none() {
530 return Err(PlanError::from(GroupPlanError::UnknownGroupField {
531 field: field_slot.field().to_string(),
532 }));
533 }
534 if !seen_group_slots.insert(field_slot.index()) {
535 return Err(PlanError::from(GroupPlanError::DuplicateGroupField {
536 field: field_slot.field().to_string(),
537 }));
538 }
539 }
540
541 for (index, aggregate) in group.aggregates.iter().enumerate() {
542 if aggregate.distinct() && !aggregate.kind().supports_grouped_distinct_v1() {
543 return Err(PlanError::from(
544 GroupPlanError::DistinctAggregateKindUnsupported {
545 index,
546 kind: format!("{:?}", aggregate.kind()),
547 },
548 ));
549 }
550
551 let Some(target_field) = aggregate.target_field.as_ref() else {
552 continue;
553 };
554 if aggregate.distinct() {
555 return Err(PlanError::from(
556 GroupPlanError::DistinctAggregateFieldTargetUnsupported {
557 index,
558 kind: format!("{:?}", aggregate.kind()),
559 field: target_field.clone(),
560 },
561 ));
562 }
563 if schema.field(target_field).is_none() {
564 return Err(PlanError::from(
565 GroupPlanError::UnknownAggregateTargetField {
566 index,
567 field: target_field.clone(),
568 },
569 ));
570 }
571 return Err(PlanError::from(
572 GroupPlanError::FieldTargetAggregatesUnsupported {
573 index,
574 kind: format!("{:?}", aggregate.kind()),
575 field: target_field.clone(),
576 },
577 ));
578 }
579
580 Ok(())
581}
582
583fn validate_global_distinct_aggregate_without_group_keys(
585 schema: &SchemaInfo,
586 group: &GroupSpec,
587) -> Result<(), PlanError> {
588 if group.aggregates.len() != 1 {
589 return Err(PlanError::from(
590 GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
591 ));
592 }
593 let aggregate = &group.aggregates[0];
594 if !aggregate.distinct() {
595 return Err(PlanError::from(
596 GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
597 ));
598 }
599 if !aggregate
600 .kind()
601 .supports_global_distinct_without_group_keys()
602 {
603 return Err(PlanError::from(
604 GroupPlanError::DistinctAggregateKindUnsupported {
605 index: 0,
606 kind: format!("{:?}", aggregate.kind()),
607 },
608 ));
609 }
610
611 let Some(target_field) = aggregate.target_field() else {
612 return Err(PlanError::from(
613 GroupPlanError::GlobalDistinctAggregateShapeUnsupported,
614 ));
615 };
616 let Some(field_type) = schema.field(target_field) else {
617 return Err(PlanError::from(
618 GroupPlanError::UnknownAggregateTargetField {
619 index: 0,
620 field: target_field.to_string(),
621 },
622 ));
623 };
624 if aggregate.kind().is_sum() && !field_type.supports_numeric_coercion() {
625 return Err(PlanError::from(
626 GroupPlanError::GlobalDistinctSumTargetNotNumeric {
627 index: 0,
628 field: target_field.to_string(),
629 },
630 ));
631 }
632
633 Ok(())
634}
635
636fn validate_plan_core<K, FOrder, FAccess>(
638 schema: &SchemaInfo,
639 model: &EntityModel,
640 logical: &ScalarPlan,
641 plan: &AccessPlannedQuery<K>,
642 validate_order_fn: FOrder,
643 validate_access_fn: FAccess,
644) -> Result<(), PlanError>
645where
646 FOrder: Fn(&SchemaInfo, &OrderSpec) -> Result<(), PlanError>,
647 FAccess: Fn(&SchemaInfo, &EntityModel, &AccessPlannedQuery<K>) -> Result<(), PlanError>,
648{
649 if let Some(predicate) = &logical.predicate {
650 validate(schema, predicate)?;
651 }
652
653 if let Some(order) = &logical.order {
654 validate_order_fn(schema, order)?;
655 validate_no_duplicate_non_pk_order_fields(model, order)?;
656 validate_primary_key_tie_break(model, order)?;
657 }
658
659 validate_access_fn(schema, model, plan)?;
660 validate_plan_shape(&plan.logical)?;
661
662 Ok(())
663}
664#[must_use]
673pub(crate) fn has_explicit_order(order: Option<&OrderSpec>) -> bool {
674 order.is_some_and(|order| !order.fields.is_empty())
675}
676
677#[must_use]
679pub(crate) fn has_empty_order(order: Option<&OrderSpec>) -> bool {
680 order.is_some_and(|order| order.fields.is_empty())
681}
682
683pub(crate) fn validate_order_shape(order: Option<&OrderSpec>) -> Result<(), PolicyPlanError> {
685 if has_empty_order(order) {
686 return Err(PolicyPlanError::EmptyOrderSpec);
687 }
688
689 Ok(())
690}
691
692pub(crate) fn validate_intent_plan_shape(
694 mode: QueryMode,
695 order: Option<&OrderSpec>,
696) -> Result<(), PolicyPlanError> {
697 validate_order_shape(order)?;
698
699 let has_order = has_explicit_order(order);
700 if matches!(mode, QueryMode::Delete(spec) if spec.limit.is_some()) && !has_order {
701 return Err(PolicyPlanError::DeleteLimitRequiresOrder);
702 }
703
704 Ok(())
705}
706
707pub(crate) const fn validate_cursor_paging_requirements(
709 has_order: bool,
710 spec: LoadSpec,
711) -> Result<(), CursorPagingPolicyError> {
712 if !has_order {
713 return Err(CursorPagingPolicyError::CursorRequiresOrder);
714 }
715 if spec.limit.is_none() {
716 return Err(CursorPagingPolicyError::CursorRequiresLimit);
717 }
718
719 Ok(())
720}
721
722pub(crate) const fn validate_cursor_order_plan_shape(
724 order: Option<&OrderSpec>,
725 require_explicit_order: bool,
726) -> Result<Option<&OrderSpec>, CursorOrderPlanShapeError> {
727 let Some(order) = order else {
728 if require_explicit_order {
729 return Err(CursorOrderPlanShapeError::MissingExplicitOrder);
730 }
731
732 return Ok(None);
733 };
734
735 if order.fields.is_empty() {
736 return Err(CursorOrderPlanShapeError::EmptyOrderSpec);
737 }
738
739 Ok(Some(order))
740}
741
742pub(crate) fn resolve_group_field_slot(
744 model: &EntityModel,
745 field: &str,
746) -> Result<FieldSlot, PlanError> {
747 FieldSlot::resolve(model, field).ok_or_else(|| {
748 PlanError::from(GroupPlanError::UnknownGroupField {
749 field: field.to_string(),
750 })
751 })
752}
753
754pub(crate) const fn validate_intent_key_access_policy(
756 key_access_conflict: bool,
757 key_access_kind: Option<IntentKeyAccessKind>,
758 has_predicate: bool,
759) -> Result<(), IntentKeyAccessPolicyViolation> {
760 if key_access_conflict {
761 return Err(IntentKeyAccessPolicyViolation::KeyAccessConflict);
762 }
763
764 match key_access_kind {
765 Some(IntentKeyAccessKind::Many) if has_predicate => {
766 Err(IntentKeyAccessPolicyViolation::ByIdsWithPredicate)
767 }
768 Some(IntentKeyAccessKind::Only) if has_predicate => {
769 Err(IntentKeyAccessPolicyViolation::OnlyWithPredicate)
770 }
771 Some(
772 IntentKeyAccessKind::Single | IntentKeyAccessKind::Many | IntentKeyAccessKind::Only,
773 )
774 | None => Ok(()),
775 }
776}
777
778pub(crate) const fn validate_grouped_field_target_extrema_policy()
780-> Result<(), IntentTerminalPolicyViolation> {
781 Err(IntentTerminalPolicyViolation::GroupedFieldTargetExtremaUnsupported)
782}
783
784pub(crate) const fn validate_fluent_non_paged_mode(
786 has_cursor_token: bool,
787 has_grouping: bool,
788) -> Result<(), FluentLoadPolicyViolation> {
789 if has_cursor_token {
790 return Err(FluentLoadPolicyViolation::CursorRequiresPagedExecution);
791 }
792 if has_grouping {
793 return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
794 }
795
796 Ok(())
797}
798
799pub(crate) fn validate_fluent_paged_mode(
801 has_grouping: bool,
802 has_explicit_order: bool,
803 spec: Option<LoadSpec>,
804) -> Result<(), FluentLoadPolicyViolation> {
805 if has_grouping {
806 return Err(FluentLoadPolicyViolation::GroupedRequiresExecuteGrouped);
807 }
808
809 let Some(spec) = spec else {
810 return Ok(());
811 };
812
813 validate_cursor_paging_requirements(has_explicit_order, spec).map_err(|err| match err {
814 CursorPagingPolicyError::CursorRequiresOrder => {
815 FluentLoadPolicyViolation::CursorRequiresOrder
816 }
817 CursorPagingPolicyError::CursorRequiresLimit => {
818 FluentLoadPolicyViolation::CursorRequiresLimit
819 }
820 })
821}
822
823pub(crate) fn validate_plan_shape(plan: &LogicalPlan) -> Result<(), PolicyPlanError> {
825 let grouped = matches!(plan, LogicalPlan::Grouped(_));
826 let plan = match plan {
827 LogicalPlan::Scalar(plan) => plan,
828 LogicalPlan::Grouped(plan) => &plan.scalar,
829 };
830 validate_order_shape(plan.order.as_ref())?;
831
832 let has_order = has_explicit_order(plan.order.as_ref());
833 if plan.delete_limit.is_some() && !has_order {
834 return Err(PolicyPlanError::DeleteLimitRequiresOrder);
835 }
836
837 match plan.mode {
838 QueryMode::Delete(_) => {
839 if plan.page.is_some() {
840 return Err(PolicyPlanError::DeletePlanWithPagination);
841 }
842 }
843 QueryMode::Load(_) => {
844 if plan.delete_limit.is_some() {
845 return Err(PolicyPlanError::LoadPlanWithDeleteLimit);
846 }
847 if plan.page.is_some() && !has_order && !grouped {
851 return Err(PolicyPlanError::UnorderedPagination);
852 }
853 }
854 }
855
856 Ok(())
857}
858
859pub(crate) fn validate_order(schema: &SchemaInfo, order: &OrderSpec) -> Result<(), PlanError> {
861 for (field, _) in &order.fields {
862 let field_type = schema
863 .field(field)
864 .ok_or_else(|| OrderPlanError::UnknownField {
865 field: field.clone(),
866 })
867 .map_err(PlanError::from)?;
868
869 if !field_type.is_orderable() {
870 return Err(PlanError::from(OrderPlanError::UnorderableField {
872 field: field.clone(),
873 }));
874 }
875 }
876
877 Ok(())
878}
879
880pub(crate) fn validate_no_duplicate_non_pk_order_fields(
882 model: &EntityModel,
883 order: &OrderSpec,
884) -> Result<(), PlanError> {
885 let mut seen = BTreeSet::new();
886 let pk_field = model.primary_key.name;
887
888 for (field, _) in &order.fields {
889 if field == pk_field {
890 continue;
891 }
892 if !seen.insert(field.as_str()) {
893 return Err(PlanError::from(OrderPlanError::DuplicateOrderField {
894 field: field.clone(),
895 }));
896 }
897 }
898
899 Ok(())
900}
901
902pub(crate) fn validate_primary_key_tie_break(
905 model: &EntityModel,
906 order: &OrderSpec,
907) -> Result<(), PlanError> {
908 if order.fields.is_empty() {
909 return Ok(());
910 }
911
912 let pk_field = model.primary_key.name;
913 let pk_count = order
914 .fields
915 .iter()
916 .filter(|(field, _)| field == pk_field)
917 .count();
918 let trailing_pk = order
919 .fields
920 .last()
921 .is_some_and(|(field, _)| field == pk_field);
922
923 if pk_count == 1 && trailing_pk {
924 Ok(())
925 } else {
926 Err(PlanError::from(OrderPlanError::MissingPrimaryKeyTieBreak {
927 field: pk_field.to_string(),
928 }))
929 }
930}