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, 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#[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("group specification must include at least one aggregate terminal")]
151 EmptyAggregates,
152
153 #[error("unknown group field '{field}'")]
155 UnknownGroupField { field: String },
156
157 #[error("group specification has duplicate group key: '{field}'")]
159 DuplicateGroupField { field: String },
160
161 #[error(
163 "grouped DISTINCT requires adjacency-based ordered-group eligibility proof in this release"
164 )]
165 DistinctAdjacencyEligibilityRequired,
166
167 #[error("grouped ORDER BY must start with GROUP BY key prefix in this release")]
169 OrderPrefixNotAlignedWithGroupKeys,
170
171 #[error("grouped HAVING with DISTINCT is not supported in this release")]
173 DistinctHavingUnsupported,
174
175 #[error("grouped HAVING clause at index={index} uses unsupported operator: {op}")]
177 HavingUnsupportedCompareOp { index: usize, op: String },
178
179 #[error("grouped HAVING clause at index={index} references non-group field '{field}'")]
181 HavingNonGroupFieldReference { index: usize, field: String },
182
183 #[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 #[error("unknown grouped aggregate target field at index={index}: '{field}'")]
195 UnknownAggregateTargetField { index: usize, field: String },
196
197 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
214pub(crate) enum CursorOrderPlanShapeError {
215 MissingExplicitOrder,
216 EmptyOrderSpec,
217}
218
219#[derive(Clone, Copy, Debug, Eq, PartialEq)]
225pub(crate) enum IntentKeyAccessKind {
226 Single,
227 Many,
228 Only,
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub(crate) enum IntentKeyAccessPolicyViolation {
238 KeyAccessConflict,
239 ByIdsWithPredicate,
240 OnlyWithPredicate,
241}
242
243#[derive(Clone, Copy, Debug, Eq, PartialEq)]
249pub(crate) enum IntentTerminalPolicyViolation {
250 GroupedFieldTargetExtremaUnsupported,
251}
252
253#[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
302pub(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
332pub(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
367fn 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
394fn 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
457fn 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
469pub(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
520fn 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#[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#[must_use]
563pub(crate) fn has_empty_order(order: Option<&OrderSpec>) -> bool {
564 order.is_some_and(|order| order.fields.is_empty())
565}
566
567pub(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
576pub(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
591pub(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
606pub(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
626pub(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
638pub(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
662pub(crate) const fn validate_grouped_field_target_extrema_policy()
664-> Result<(), IntentTerminalPolicyViolation> {
665 Err(IntentTerminalPolicyViolation::GroupedFieldTargetExtremaUnsupported)
666}
667
668pub(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
683pub(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
707pub(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 if plan.page.is_some() && !has_order && !grouped {
735 return Err(PolicyPlanError::UnorderedPagination);
736 }
737 }
738 }
739
740 Ok(())
741}
742
743pub(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 return Err(PlanError::from(OrderPlanError::UnorderableField {
756 field: field.clone(),
757 }));
758 }
759 }
760
761 Ok(())
762}
763
764pub(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
786pub(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}