1use crate::{
7 db::{
8 predicate::{CoercionId, CompareOp, MissingRowPolicy, Predicate},
9 query::{
10 builder::aggregate::AggregateExpr,
11 explain::{
12 ExplainAccessPath, ExplainExecutionNodeDescriptor, ExplainExecutionNodeType,
13 ExplainOrderPushdown, ExplainPlan, ExplainPredicate,
14 },
15 expr::{FilterExpr, SortExpr},
16 intent::{QueryError, access_plan_to_entity_keys, model::QueryModel},
17 plan::{AccessPlannedQuery, LoadSpec, QueryMode},
18 },
19 },
20 traits::{EntityKind, EntityValue, SingletonEntity},
21 value::Value,
22};
23
24#[derive(Debug)]
36pub struct Query<E: EntityKind> {
37 intent: QueryModel<'static, E::Key>,
38}
39
40impl<E: EntityKind> Query<E> {
41 #[must_use]
45 pub const fn new(consistency: MissingRowPolicy) -> Self {
46 Self {
47 intent: QueryModel::new(E::MODEL, consistency),
48 }
49 }
50
51 #[must_use]
53 pub const fn mode(&self) -> QueryMode {
54 self.intent.mode()
55 }
56
57 #[must_use]
58 pub(crate) fn has_explicit_order(&self) -> bool {
59 self.intent.has_explicit_order()
60 }
61
62 #[must_use]
63 pub(crate) const fn has_grouping(&self) -> bool {
64 self.intent.has_grouping()
65 }
66
67 #[must_use]
68 pub(crate) const fn load_spec(&self) -> Option<LoadSpec> {
69 match self.intent.mode() {
70 QueryMode::Load(spec) => Some(spec),
71 QueryMode::Delete(_) => None,
72 }
73 }
74
75 #[must_use]
77 pub fn filter(mut self, predicate: Predicate) -> Self {
78 self.intent = self.intent.filter(predicate);
79 self
80 }
81
82 pub fn filter_expr(self, expr: FilterExpr) -> Result<Self, QueryError> {
84 let Self { intent } = self;
85 let intent = intent.filter_expr(expr)?;
86
87 Ok(Self { intent })
88 }
89
90 pub fn sort_expr(self, expr: SortExpr) -> Result<Self, QueryError> {
92 let Self { intent } = self;
93 let intent = intent.sort_expr(expr)?;
94
95 Ok(Self { intent })
96 }
97
98 #[must_use]
100 pub fn order_by(mut self, field: impl AsRef<str>) -> Self {
101 self.intent = self.intent.order_by(field);
102 self
103 }
104
105 #[must_use]
107 pub fn order_by_desc(mut self, field: impl AsRef<str>) -> Self {
108 self.intent = self.intent.order_by_desc(field);
109 self
110 }
111
112 #[must_use]
114 pub fn distinct(mut self) -> Self {
115 self.intent = self.intent.distinct();
116 self
117 }
118
119 pub fn group_by(self, field: impl AsRef<str>) -> Result<Self, QueryError> {
121 let Self { intent } = self;
122 let intent = intent.push_group_field(field.as_ref())?;
123
124 Ok(Self { intent })
125 }
126
127 #[must_use]
129 pub fn aggregate(mut self, aggregate: AggregateExpr) -> Self {
130 self.intent = self.intent.push_group_aggregate(aggregate);
131 self
132 }
133
134 #[must_use]
136 pub fn grouped_limits(mut self, max_groups: u64, max_group_bytes: u64) -> Self {
137 self.intent = self.intent.grouped_limits(max_groups, max_group_bytes);
138 self
139 }
140
141 pub fn having_group(
143 self,
144 field: impl AsRef<str>,
145 op: CompareOp,
146 value: Value,
147 ) -> Result<Self, QueryError> {
148 let field = field.as_ref().to_owned();
149 let Self { intent } = self;
150 let intent = intent.push_having_group_clause(&field, op, value)?;
151
152 Ok(Self { intent })
153 }
154
155 pub fn having_aggregate(
157 self,
158 aggregate_index: usize,
159 op: CompareOp,
160 value: Value,
161 ) -> Result<Self, QueryError> {
162 let Self { intent } = self;
163 let intent = intent.push_having_aggregate_clause(aggregate_index, op, value)?;
164
165 Ok(Self { intent })
166 }
167
168 pub(crate) fn by_id(self, id: E::Key) -> Self {
170 let Self { intent } = self;
171 Self {
172 intent: intent.by_id(id),
173 }
174 }
175
176 pub(crate) fn by_ids<I>(self, ids: I) -> Self
178 where
179 I: IntoIterator<Item = E::Key>,
180 {
181 let Self { intent } = self;
182 Self {
183 intent: intent.by_ids(ids),
184 }
185 }
186
187 #[must_use]
189 pub fn delete(mut self) -> Self {
190 self.intent = self.intent.delete();
191 self
192 }
193
194 #[must_use]
201 pub fn limit(mut self, limit: u32) -> Self {
202 self.intent = self.intent.limit(limit);
203 self
204 }
205
206 #[must_use]
212 pub fn offset(mut self, offset: u32) -> Self {
213 self.intent = self.intent.offset(offset);
214 self
215 }
216
217 pub fn explain(&self) -> Result<ExplainPlan, QueryError> {
219 let plan = self.planned()?;
220
221 Ok(plan.explain())
222 }
223
224 pub fn plan_hash_hex(&self) -> Result<String, QueryError> {
229 Ok(self.explain()?.fingerprint().to_string())
230 }
231
232 pub fn explain_execution(&self) -> Result<ExplainExecutionNodeDescriptor, QueryError>
234 where
235 E: EntityValue,
236 {
237 let executable = self.plan()?.into_executable();
238
239 executable
240 .explain_load_execution_node_descriptor()
241 .map_err(QueryError::execute)
242 }
243
244 pub fn explain_execution_text(&self) -> Result<String, QueryError>
246 where
247 E: EntityValue,
248 {
249 Ok(self.explain_execution()?.render_text_tree())
250 }
251
252 pub fn explain_execution_json(&self) -> Result<String, QueryError>
254 where
255 E: EntityValue,
256 {
257 Ok(self.explain_execution()?.render_json_canonical())
258 }
259
260 pub fn explain_execution_verbose(&self) -> Result<String, QueryError>
262 where
263 E: EntityValue,
264 {
265 let executable = self.plan()?.into_executable();
266 let descriptor = executable
267 .explain_load_execution_node_descriptor()
268 .map_err(QueryError::execute)?;
269 let route_diagnostics = executable
270 .explain_load_execution_verbose_diagnostics()
271 .map_err(QueryError::execute)?;
272 let explain = self.explain()?;
273
274 let mut lines = vec![descriptor.render_text_tree_verbose()];
276 lines.extend(route_diagnostics);
277
278 lines.push(format!(
280 "diagnostic.descriptor.has_top_n_seek={}",
281 contains_execution_node_type(&descriptor, ExplainExecutionNodeType::TopNSeek)
282 ));
283 lines.push(format!(
284 "diagnostic.descriptor.has_index_range_limit_pushdown={}",
285 contains_execution_node_type(
286 &descriptor,
287 ExplainExecutionNodeType::IndexRangeLimitPushdown,
288 )
289 ));
290 lines.push(format!(
291 "diagnostic.descriptor.has_index_predicate_prefilter={}",
292 contains_execution_node_type(
293 &descriptor,
294 ExplainExecutionNodeType::IndexPredicatePrefilter,
295 )
296 ));
297 lines.push(format!(
298 "diagnostic.descriptor.has_residual_predicate_filter={}",
299 contains_execution_node_type(
300 &descriptor,
301 ExplainExecutionNodeType::ResidualPredicateFilter,
302 )
303 ));
304
305 lines.push(format!("diagnostic.plan.mode={:?}", explain.mode()));
307 lines.push(format!(
308 "diagnostic.plan.order_pushdown={}",
309 plan_order_pushdown_label(explain.order_pushdown())
310 ));
311 lines.push(format!(
312 "diagnostic.plan.predicate_pushdown={}",
313 plan_predicate_pushdown_label(explain.predicate(), explain.access())
314 ));
315 lines.push(format!("diagnostic.plan.distinct={}", explain.distinct()));
316 lines.push(format!("diagnostic.plan.page={:?}", explain.page()));
317 lines.push(format!(
318 "diagnostic.plan.consistency={:?}",
319 explain.consistency()
320 ));
321
322 Ok(lines.join("\n"))
323 }
324
325 pub fn planned(&self) -> Result<PlannedQuery<E>, QueryError> {
327 let plan = self.build_plan()?;
328 let _projection = plan.projection_spec(E::MODEL);
329
330 Ok(PlannedQuery::new(plan))
331 }
332
333 pub fn plan(&self) -> Result<CompiledQuery<E>, QueryError> {
337 let plan = self.build_plan()?;
338 let _projection = plan.projection_spec(E::MODEL);
339
340 Ok(CompiledQuery::new(plan))
341 }
342
343 fn build_plan(&self) -> Result<AccessPlannedQuery<E::Key>, QueryError> {
345 let plan_value = self.intent.build_plan_model()?;
346 let (logical, access) = plan_value.into_parts();
347 let access = access_plan_to_entity_keys::<E>(E::MODEL, access)?;
348 let plan = AccessPlannedQuery::from_parts(logical, access);
349
350 Ok(plan)
351 }
352}
353
354fn contains_execution_node_type(
355 descriptor: &ExplainExecutionNodeDescriptor,
356 target: ExplainExecutionNodeType,
357) -> bool {
358 descriptor.node_type() == target
359 || descriptor
360 .children()
361 .iter()
362 .any(|child| contains_execution_node_type(child, target))
363}
364
365fn plan_order_pushdown_label(order_pushdown: &ExplainOrderPushdown) -> String {
366 match order_pushdown {
367 ExplainOrderPushdown::MissingModelContext => "missing_model_context".to_string(),
368 ExplainOrderPushdown::EligibleSecondaryIndex { index, prefix_len } => {
369 format!("eligible(index={index},prefix_len={prefix_len})",)
370 }
371 ExplainOrderPushdown::Rejected(reason) => format!("rejected({reason:?})"),
372 }
373}
374
375fn plan_predicate_pushdown_label(
376 predicate: &ExplainPredicate,
377 access: &ExplainAccessPath,
378) -> String {
379 let access_label = match access {
380 ExplainAccessPath::ByKey { .. } => "by_key",
381 ExplainAccessPath::ByKeys { keys } if keys.is_empty() => "empty_access_contract",
382 ExplainAccessPath::ByKeys { .. } => "by_keys",
383 ExplainAccessPath::KeyRange { .. } => "key_range",
384 ExplainAccessPath::IndexPrefix { .. } => "index_prefix",
385 ExplainAccessPath::IndexMultiLookup { .. } => "index_multi_lookup",
386 ExplainAccessPath::IndexRange { .. } => "index_range",
387 ExplainAccessPath::FullScan => "full_scan",
388 ExplainAccessPath::Union(_) => "union",
389 ExplainAccessPath::Intersection(_) => "intersection",
390 };
391 if matches!(predicate, ExplainPredicate::None) {
392 return "none".to_string();
393 }
394 if matches!(access, ExplainAccessPath::FullScan) {
395 if explain_predicate_contains_non_strict_compare(predicate) {
396 return "fallback(non_strict_compare_coercion)".to_string();
397 }
398 if explain_predicate_contains_empty_prefix_starts_with(predicate) {
399 return "fallback(starts_with_empty_prefix)".to_string();
400 }
401 if explain_predicate_contains_is_null(predicate) {
402 return "fallback(is_null_full_scan)".to_string();
403 }
404 if explain_predicate_contains_text_scan_operator(predicate) {
405 return "fallback(text_operator_full_scan)".to_string();
406 }
407
408 return format!("fallback({access_label})");
409 }
410
411 format!("applied({access_label})")
412}
413
414fn explain_predicate_contains_non_strict_compare(predicate: &ExplainPredicate) -> bool {
415 match predicate {
416 ExplainPredicate::Compare { coercion, .. } => coercion.id != CoercionId::Strict,
417 ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
418 .iter()
419 .any(explain_predicate_contains_non_strict_compare),
420 ExplainPredicate::Not(inner) => explain_predicate_contains_non_strict_compare(inner),
421 ExplainPredicate::None
422 | ExplainPredicate::True
423 | ExplainPredicate::False
424 | ExplainPredicate::IsNull { .. }
425 | ExplainPredicate::IsMissing { .. }
426 | ExplainPredicate::IsEmpty { .. }
427 | ExplainPredicate::IsNotEmpty { .. }
428 | ExplainPredicate::TextContains { .. }
429 | ExplainPredicate::TextContainsCi { .. } => false,
430 }
431}
432
433fn explain_predicate_contains_is_null(predicate: &ExplainPredicate) -> bool {
434 match predicate {
435 ExplainPredicate::IsNull { .. } => true,
436 ExplainPredicate::And(children) | ExplainPredicate::Or(children) => {
437 children.iter().any(explain_predicate_contains_is_null)
438 }
439 ExplainPredicate::Not(inner) => explain_predicate_contains_is_null(inner),
440 ExplainPredicate::None
441 | ExplainPredicate::True
442 | ExplainPredicate::False
443 | ExplainPredicate::Compare { .. }
444 | ExplainPredicate::IsMissing { .. }
445 | ExplainPredicate::IsEmpty { .. }
446 | ExplainPredicate::IsNotEmpty { .. }
447 | ExplainPredicate::TextContains { .. }
448 | ExplainPredicate::TextContainsCi { .. } => false,
449 }
450}
451
452fn explain_predicate_contains_empty_prefix_starts_with(predicate: &ExplainPredicate) -> bool {
453 match predicate {
454 ExplainPredicate::Compare {
455 op: CompareOp::StartsWith,
456 value: Value::Text(prefix),
457 ..
458 } => prefix.is_empty(),
459 ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
460 .iter()
461 .any(explain_predicate_contains_empty_prefix_starts_with),
462 ExplainPredicate::Not(inner) => explain_predicate_contains_empty_prefix_starts_with(inner),
463 ExplainPredicate::None
464 | ExplainPredicate::True
465 | ExplainPredicate::False
466 | ExplainPredicate::Compare { .. }
467 | ExplainPredicate::IsNull { .. }
468 | ExplainPredicate::IsMissing { .. }
469 | ExplainPredicate::IsEmpty { .. }
470 | ExplainPredicate::IsNotEmpty { .. }
471 | ExplainPredicate::TextContains { .. }
472 | ExplainPredicate::TextContainsCi { .. } => false,
473 }
474}
475
476fn explain_predicate_contains_text_scan_operator(predicate: &ExplainPredicate) -> bool {
477 match predicate {
478 ExplainPredicate::Compare {
479 op: CompareOp::EndsWith,
480 ..
481 }
482 | ExplainPredicate::TextContains { .. }
483 | ExplainPredicate::TextContainsCi { .. } => true,
484 ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
485 .iter()
486 .any(explain_predicate_contains_text_scan_operator),
487 ExplainPredicate::Not(inner) => explain_predicate_contains_text_scan_operator(inner),
488 ExplainPredicate::Compare { .. }
489 | ExplainPredicate::None
490 | ExplainPredicate::True
491 | ExplainPredicate::False
492 | ExplainPredicate::IsNull { .. }
493 | ExplainPredicate::IsMissing { .. }
494 | ExplainPredicate::IsEmpty { .. }
495 | ExplainPredicate::IsNotEmpty { .. } => false,
496 }
497}
498
499impl<E> Query<E>
500where
501 E: EntityKind + SingletonEntity,
502 E::Key: Default,
503{
504 pub(crate) fn only(self) -> Self {
506 let Self { intent } = self;
507
508 Self {
509 intent: intent.only(E::Key::default()),
510 }
511 }
512}
513
514#[derive(Debug)]
522pub struct PlannedQuery<E: EntityKind> {
523 plan: AccessPlannedQuery<E::Key>,
524}
525
526impl<E: EntityKind> PlannedQuery<E> {
527 #[must_use]
528 pub(in crate::db) const fn new(plan: AccessPlannedQuery<E::Key>) -> Self {
529 Self { plan }
530 }
531
532 #[must_use]
533 pub fn explain(&self) -> ExplainPlan {
534 self.plan.explain_with_model(E::MODEL)
535 }
536
537 #[must_use]
539 pub fn plan_hash_hex(&self) -> String {
540 self.explain().fingerprint().to_string()
541 }
542}
543
544#[derive(Clone, Debug)]
553pub struct CompiledQuery<E: EntityKind> {
554 plan: AccessPlannedQuery<E::Key>,
555}
556
557impl<E: EntityKind> CompiledQuery<E> {
558 #[must_use]
559 pub(in crate::db) const fn new(plan: AccessPlannedQuery<E::Key>) -> Self {
560 Self { plan }
561 }
562
563 #[must_use]
564 pub fn explain(&self) -> ExplainPlan {
565 self.plan.explain_with_model(E::MODEL)
566 }
567
568 #[must_use]
570 pub fn plan_hash_hex(&self) -> String {
571 self.explain().fingerprint().to_string()
572 }
573
574 #[must_use]
576 #[cfg(test)]
577 pub(crate) fn projection_spec(&self) -> crate::db::query::plan::expr::ProjectionSpec {
578 self.plan.projection_spec(E::MODEL)
579 }
580
581 #[must_use]
582 pub(in crate::db) fn into_inner(self) -> AccessPlannedQuery<E::Key> {
583 self.plan
584 }
585}
586
587#[cfg(test)]
592mod tests {
593 use super::*;
594 use crate::{db::predicate::CoercionSpec, types::Ulid};
595
596 fn strict_compare(field: &str, op: CompareOp, value: Value) -> ExplainPredicate {
597 ExplainPredicate::Compare {
598 field: field.to_string(),
599 op,
600 value,
601 coercion: CoercionSpec::new(CoercionId::Strict),
602 }
603 }
604
605 #[test]
606 fn predicate_pushdown_label_prefix_like_and_equivalent_range_share_label() {
607 let starts_with_predicate = strict_compare(
608 "name",
609 CompareOp::StartsWith,
610 Value::Text("foo".to_string()),
611 );
612 let equivalent_range_predicate = ExplainPredicate::And(vec![
613 strict_compare("name", CompareOp::Gte, Value::Text("foo".to_string())),
614 strict_compare("name", CompareOp::Lt, Value::Text("fop".to_string())),
615 ]);
616 let access = ExplainAccessPath::IndexRange {
617 name: "idx_name",
618 fields: vec!["name"],
619 prefix_len: 0,
620 prefix: Vec::new(),
621 lower: std::ops::Bound::Included(Value::Text("foo".to_string())),
622 upper: std::ops::Bound::Excluded(Value::Text("fop".to_string())),
623 };
624
625 assert_eq!(
626 plan_predicate_pushdown_label(&starts_with_predicate, &access),
627 plan_predicate_pushdown_label(&equivalent_range_predicate, &access),
628 "equivalent prefix-like and bounded-range shapes should report identical pushdown reason labels",
629 );
630 assert_eq!(
631 plan_predicate_pushdown_label(&starts_with_predicate, &access),
632 "applied(index_range)"
633 );
634 }
635
636 #[test]
637 fn predicate_pushdown_label_distinguishes_is_null_and_non_strict_full_scan_fallbacks() {
638 let is_null_predicate = ExplainPredicate::IsNull {
639 field: "group".to_string(),
640 };
641 let non_strict_predicate = ExplainPredicate::Compare {
642 field: "group".to_string(),
643 op: CompareOp::Eq,
644 value: Value::Uint(7),
645 coercion: CoercionSpec::new(CoercionId::NumericWiden),
646 };
647 let access = ExplainAccessPath::FullScan;
648
649 assert_eq!(
650 plan_predicate_pushdown_label(&is_null_predicate, &access),
651 "fallback(is_null_full_scan)"
652 );
653 assert_eq!(
654 plan_predicate_pushdown_label(&non_strict_predicate, &access),
655 "fallback(non_strict_compare_coercion)"
656 );
657 }
658
659 #[test]
660 fn predicate_pushdown_label_reports_none_when_no_predicate_is_present() {
661 let predicate = ExplainPredicate::None;
662 let access = ExplainAccessPath::ByKey {
663 key: Value::Ulid(Ulid::from_u128(7)),
664 };
665
666 assert_eq!(plan_predicate_pushdown_label(&predicate, &access), "none");
667 }
668
669 #[test]
670 fn predicate_pushdown_label_reports_empty_access_contract_for_impossible_shapes() {
671 let predicate = ExplainPredicate::Or(vec![
672 ExplainPredicate::IsNull {
673 field: "id".to_string(),
674 },
675 ExplainPredicate::And(vec![
676 ExplainPredicate::Compare {
677 field: "id".to_string(),
678 op: CompareOp::In,
679 value: Value::List(Vec::new()),
680 coercion: CoercionSpec::new(CoercionId::Strict),
681 },
682 ExplainPredicate::True,
683 ]),
684 ]);
685 let access = ExplainAccessPath::ByKeys { keys: Vec::new() };
686
687 assert_eq!(
688 plan_predicate_pushdown_label(&predicate, &access),
689 "applied(empty_access_contract)"
690 );
691 }
692
693 #[test]
694 fn predicate_pushdown_label_distinguishes_empty_prefix_starts_with_full_scan_fallback() {
695 let empty_prefix_predicate = ExplainPredicate::Compare {
696 field: "label".to_string(),
697 op: CompareOp::StartsWith,
698 value: Value::Text(String::new()),
699 coercion: CoercionSpec::new(CoercionId::Strict),
700 };
701 let non_empty_prefix_predicate = ExplainPredicate::Compare {
702 field: "label".to_string(),
703 op: CompareOp::StartsWith,
704 value: Value::Text("l".to_string()),
705 coercion: CoercionSpec::new(CoercionId::Strict),
706 };
707 let access = ExplainAccessPath::FullScan;
708
709 assert_eq!(
710 plan_predicate_pushdown_label(&empty_prefix_predicate, &access),
711 "fallback(starts_with_empty_prefix)"
712 );
713 assert_eq!(
714 plan_predicate_pushdown_label(&non_empty_prefix_predicate, &access),
715 "fallback(full_scan)"
716 );
717 }
718
719 #[test]
720 fn predicate_pushdown_label_reports_text_operator_full_scan_fallback() {
721 let text_contains = ExplainPredicate::TextContainsCi {
722 field: "label".to_string(),
723 value: Value::Text("needle".to_string()),
724 };
725 let ends_with = ExplainPredicate::Compare {
726 field: "label".to_string(),
727 op: CompareOp::EndsWith,
728 value: Value::Text("fix".to_string()),
729 coercion: CoercionSpec::new(CoercionId::Strict),
730 };
731 let access = ExplainAccessPath::FullScan;
732
733 assert_eq!(
734 plan_predicate_pushdown_label(&text_contains, &access),
735 "fallback(text_operator_full_scan)"
736 );
737 assert_eq!(
738 plan_predicate_pushdown_label(&ends_with, &access),
739 "fallback(text_operator_full_scan)"
740 );
741 }
742
743 #[test]
744 fn predicate_pushdown_label_keeps_collection_contains_on_generic_full_scan_fallback() {
745 let collection_contains = ExplainPredicate::Compare {
746 field: "tags".to_string(),
747 op: CompareOp::Contains,
748 value: Value::Uint(7),
749 coercion: CoercionSpec::new(CoercionId::CollectionElement),
750 };
751 let access = ExplainAccessPath::FullScan;
752
753 assert_eq!(
754 plan_predicate_pushdown_label(&collection_contains, &access),
755 "fallback(non_strict_compare_coercion)"
756 );
757 assert_ne!(
758 plan_predicate_pushdown_label(&collection_contains, &access),
759 "fallback(text_operator_full_scan)"
760 );
761 }
762
763 #[test]
764 fn predicate_pushdown_label_non_strict_ends_with_uses_non_strict_fallback_precedence() {
765 let non_strict_ends_with = ExplainPredicate::Compare {
766 field: "label".to_string(),
767 op: CompareOp::EndsWith,
768 value: Value::Text("fix".to_string()),
769 coercion: CoercionSpec::new(CoercionId::TextCasefold),
770 };
771 let access = ExplainAccessPath::FullScan;
772
773 assert_eq!(
774 plan_predicate_pushdown_label(&non_strict_ends_with, &access),
775 "fallback(non_strict_compare_coercion)"
776 );
777 assert_ne!(
778 plan_predicate_pushdown_label(&non_strict_ends_with, &access),
779 "fallback(text_operator_full_scan)"
780 );
781 }
782}