Skip to main content

icydb_core/db/query/explain/
plan.rs

1//! Module: query::explain::plan
2//! Responsibility: deterministic logical-plan projection for EXPLAIN.
3//! Does not own: execution descriptor rendering or access visitor adapters.
4//! Boundary: logical explain DTOs and plan-side projection logic.
5
6use crate::{
7    db::{
8        access::{
9            AccessPlan, PushdownSurfaceEligibility, SecondaryOrderPushdownEligibility,
10            SecondaryOrderPushdownRejection,
11        },
12        predicate::{
13            CoercionSpec, CompareOp, ComparePredicate, MissingRowPolicy, Predicate, normalize,
14        },
15        query::plan::{
16            AccessPlannedQuery, AggregateKind, DeleteLimitSpec, GroupHavingClause, GroupHavingSpec,
17            GroupHavingSymbol, GroupedPlanStrategyHint, LogicalPlan, OrderDirection, OrderSpec,
18            PageSpec, QueryMode, ScalarPlan, grouped_plan_strategy_hint,
19        },
20    },
21    model::entity::EntityModel,
22    traits::FieldValue,
23    value::Value,
24};
25use std::ops::Bound;
26
27///
28/// ExplainPlan
29///
30/// Stable, deterministic representation of a planned query for observability.
31///
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct ExplainPlan {
35    pub(crate) mode: QueryMode,
36    pub(crate) access: ExplainAccessPath,
37    pub(crate) predicate: ExplainPredicate,
38    predicate_model: Option<Predicate>,
39    pub(crate) order_by: ExplainOrderBy,
40    pub(crate) distinct: bool,
41    pub(crate) grouping: ExplainGrouping,
42    pub(crate) order_pushdown: ExplainOrderPushdown,
43    pub(crate) page: ExplainPagination,
44    pub(crate) delete_limit: ExplainDeleteLimit,
45    pub(crate) consistency: MissingRowPolicy,
46}
47
48impl ExplainPlan {
49    /// Return query mode projected by this explain plan.
50    #[must_use]
51    pub const fn mode(&self) -> QueryMode {
52        self.mode
53    }
54
55    /// Borrow projected access-path shape.
56    #[must_use]
57    pub const fn access(&self) -> &ExplainAccessPath {
58        &self.access
59    }
60
61    /// Borrow projected predicate shape.
62    #[must_use]
63    pub const fn predicate(&self) -> &ExplainPredicate {
64        &self.predicate
65    }
66
67    /// Borrow projected ORDER BY shape.
68    #[must_use]
69    pub const fn order_by(&self) -> &ExplainOrderBy {
70        &self.order_by
71    }
72
73    /// Return whether DISTINCT is enabled.
74    #[must_use]
75    pub const fn distinct(&self) -> bool {
76        self.distinct
77    }
78
79    /// Borrow projected grouped-shape metadata.
80    #[must_use]
81    pub const fn grouping(&self) -> &ExplainGrouping {
82        &self.grouping
83    }
84
85    /// Borrow projected ORDER pushdown status.
86    #[must_use]
87    pub const fn order_pushdown(&self) -> &ExplainOrderPushdown {
88        &self.order_pushdown
89    }
90
91    /// Borrow projected pagination status.
92    #[must_use]
93    pub const fn page(&self) -> &ExplainPagination {
94        &self.page
95    }
96
97    /// Borrow projected delete-limit status.
98    #[must_use]
99    pub const fn delete_limit(&self) -> &ExplainDeleteLimit {
100        &self.delete_limit
101    }
102
103    /// Return missing-row consistency policy.
104    #[must_use]
105    pub const fn consistency(&self) -> MissingRowPolicy {
106        self.consistency
107    }
108}
109
110impl ExplainPlan {
111    /// Return the canonical predicate model used for hashing/fingerprints.
112    ///
113    /// The explain projection must remain a faithful rendering of this model.
114    #[must_use]
115    pub(crate) fn predicate_model_for_hash(&self) -> Option<&Predicate> {
116        if let Some(predicate) = &self.predicate_model {
117            debug_assert_eq!(
118                self.predicate,
119                ExplainPredicate::from_predicate(predicate),
120                "explain predicate surface drifted from canonical predicate model"
121            );
122            Some(predicate)
123        } else {
124            debug_assert!(
125                matches!(self.predicate, ExplainPredicate::None),
126                "missing canonical predicate model requires ExplainPredicate::None"
127            );
128            None
129        }
130    }
131}
132
133///
134/// ExplainGrouping
135///
136/// Grouped-shape annotation for deterministic explain/fingerprint surfaces.
137///
138
139#[derive(Clone, Debug, Eq, PartialEq)]
140pub enum ExplainGrouping {
141    None,
142    Grouped {
143        strategy: ExplainGroupedStrategy,
144        group_fields: Vec<ExplainGroupField>,
145        aggregates: Vec<ExplainGroupAggregate>,
146        having: Option<ExplainGroupHaving>,
147        max_groups: u64,
148        max_group_bytes: u64,
149    },
150}
151
152///
153/// ExplainGroupedStrategy
154///
155/// Deterministic explain projection of grouped strategy selection.
156///
157
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub enum ExplainGroupedStrategy {
160    HashGroup,
161    OrderedGroup,
162}
163
164impl From<GroupedPlanStrategyHint> for ExplainGroupedStrategy {
165    fn from(value: GroupedPlanStrategyHint) -> Self {
166        match value {
167            GroupedPlanStrategyHint::HashGroup => Self::HashGroup,
168            GroupedPlanStrategyHint::OrderedGroup => Self::OrderedGroup,
169        }
170    }
171}
172
173///
174/// ExplainGroupField
175///
176/// Stable grouped-key field identity carried by explain/hash surfaces.
177///
178
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct ExplainGroupField {
181    pub(crate) slot_index: usize,
182    pub(crate) field: String,
183}
184
185impl ExplainGroupField {
186    /// Return grouped slot index.
187    #[must_use]
188    pub const fn slot_index(&self) -> usize {
189        self.slot_index
190    }
191
192    /// Borrow grouped field name.
193    #[must_use]
194    pub const fn field(&self) -> &str {
195        self.field.as_str()
196    }
197}
198
199///
200/// ExplainGroupAggregate
201///
202/// Stable explain-surface projection of one grouped aggregate terminal.
203///
204
205#[derive(Clone, Debug, Eq, PartialEq)]
206pub struct ExplainGroupAggregate {
207    pub(crate) kind: AggregateKind,
208    pub(crate) target_field: Option<String>,
209    pub(crate) distinct: bool,
210}
211
212impl ExplainGroupAggregate {
213    /// Return grouped aggregate kind.
214    #[must_use]
215    pub const fn kind(&self) -> AggregateKind {
216        self.kind
217    }
218
219    /// Borrow optional grouped aggregate target field.
220    #[must_use]
221    pub fn target_field(&self) -> Option<&str> {
222        self.target_field.as_deref()
223    }
224
225    /// Return whether grouped aggregate uses DISTINCT input semantics.
226    #[must_use]
227    pub const fn distinct(&self) -> bool {
228        self.distinct
229    }
230}
231
232///
233/// ExplainGroupHaving
234///
235/// Deterministic explain projection of grouped HAVING clauses.
236///
237
238#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct ExplainGroupHaving {
240    pub(crate) clauses: Vec<ExplainGroupHavingClause>,
241}
242
243impl ExplainGroupHaving {
244    /// Borrow grouped HAVING clauses.
245    #[must_use]
246    pub const fn clauses(&self) -> &[ExplainGroupHavingClause] {
247        self.clauses.as_slice()
248    }
249}
250
251///
252/// ExplainGroupHavingClause
253///
254/// Stable explain-surface projection for one grouped HAVING clause.
255///
256
257#[derive(Clone, Debug, Eq, PartialEq)]
258pub struct ExplainGroupHavingClause {
259    pub(crate) symbol: ExplainGroupHavingSymbol,
260    pub(crate) op: CompareOp,
261    pub(crate) value: Value,
262}
263
264impl ExplainGroupHavingClause {
265    /// Borrow grouped HAVING symbol.
266    #[must_use]
267    pub const fn symbol(&self) -> &ExplainGroupHavingSymbol {
268        &self.symbol
269    }
270
271    /// Return grouped HAVING comparison operator.
272    #[must_use]
273    pub const fn op(&self) -> CompareOp {
274        self.op
275    }
276
277    /// Borrow grouped HAVING literal value.
278    #[must_use]
279    pub const fn value(&self) -> &Value {
280        &self.value
281    }
282}
283
284///
285/// ExplainGroupHavingSymbol
286///
287/// Stable explain-surface identity for grouped HAVING symbols.
288///
289
290#[derive(Clone, Debug, Eq, PartialEq)]
291pub enum ExplainGroupHavingSymbol {
292    GroupField { slot_index: usize, field: String },
293    AggregateIndex { index: usize },
294}
295
296///
297/// ExplainOrderPushdown
298///
299/// Deterministic ORDER BY pushdown eligibility reported by explain.
300///
301
302#[derive(Clone, Debug, Eq, PartialEq)]
303pub enum ExplainOrderPushdown {
304    MissingModelContext,
305    EligibleSecondaryIndex {
306        index: &'static str,
307        prefix_len: usize,
308    },
309    Rejected(SecondaryOrderPushdownRejection),
310}
311
312///
313/// ExplainAccessPath
314///
315/// Deterministic projection of logical access path shape for diagnostics.
316/// Mirrors planner-selected structural paths without runtime cursor state.
317///
318
319#[derive(Clone, Debug, Eq, PartialEq)]
320pub enum ExplainAccessPath {
321    ByKey {
322        key: Value,
323    },
324    ByKeys {
325        keys: Vec<Value>,
326    },
327    KeyRange {
328        start: Value,
329        end: Value,
330    },
331    IndexPrefix {
332        name: &'static str,
333        fields: Vec<&'static str>,
334        prefix_len: usize,
335        values: Vec<Value>,
336    },
337    IndexMultiLookup {
338        name: &'static str,
339        fields: Vec<&'static str>,
340        values: Vec<Value>,
341    },
342    IndexRange {
343        name: &'static str,
344        fields: Vec<&'static str>,
345        prefix_len: usize,
346        prefix: Vec<Value>,
347        lower: Bound<Value>,
348        upper: Bound<Value>,
349    },
350    FullScan,
351    Union(Vec<Self>),
352    Intersection(Vec<Self>),
353}
354
355///
356/// ExplainPredicate
357///
358/// Deterministic projection of canonical predicate structure for explain output.
359/// This preserves normalized predicate shape used by hashing/fingerprints.
360///
361
362#[derive(Clone, Debug, Eq, PartialEq)]
363pub enum ExplainPredicate {
364    None,
365    True,
366    False,
367    And(Vec<Self>),
368    Or(Vec<Self>),
369    Not(Box<Self>),
370    Compare {
371        field: String,
372        op: CompareOp,
373        value: Value,
374        coercion: CoercionSpec,
375    },
376    IsNull {
377        field: String,
378    },
379    IsNotNull {
380        field: String,
381    },
382    IsMissing {
383        field: String,
384    },
385    IsEmpty {
386        field: String,
387    },
388    IsNotEmpty {
389        field: String,
390    },
391    TextContains {
392        field: String,
393        value: Value,
394    },
395    TextContainsCi {
396        field: String,
397        value: Value,
398    },
399}
400
401///
402/// ExplainOrderBy
403///
404/// Deterministic projection of canonical ORDER BY shape.
405///
406
407#[derive(Clone, Debug, Eq, PartialEq)]
408pub enum ExplainOrderBy {
409    None,
410    Fields(Vec<ExplainOrder>),
411}
412
413///
414/// ExplainOrder
415///
416/// One canonical ORDER BY field + direction pair.
417///
418
419#[derive(Clone, Debug, Eq, PartialEq)]
420pub struct ExplainOrder {
421    pub(crate) field: String,
422    pub(crate) direction: OrderDirection,
423}
424
425impl ExplainOrder {
426    /// Borrow ORDER BY field name.
427    #[must_use]
428    pub const fn field(&self) -> &str {
429        self.field.as_str()
430    }
431
432    /// Return ORDER BY direction.
433    #[must_use]
434    pub const fn direction(&self) -> OrderDirection {
435        self.direction
436    }
437}
438
439///
440/// ExplainPagination
441///
442/// Explain-surface projection of pagination window configuration.
443///
444
445#[derive(Clone, Debug, Eq, PartialEq)]
446pub enum ExplainPagination {
447    None,
448    Page { limit: Option<u32>, offset: u32 },
449}
450
451///
452/// ExplainDeleteLimit
453///
454/// Explain-surface projection of delete-limit configuration.
455///
456
457#[derive(Clone, Debug, Eq, PartialEq)]
458pub enum ExplainDeleteLimit {
459    None,
460    Limit { max_rows: u32 },
461}
462
463impl<K> AccessPlannedQuery<K>
464where
465    K: FieldValue,
466{
467    /// Produce a stable, deterministic explanation of this logical plan.
468    #[must_use]
469    #[cfg(test)]
470    pub(crate) fn explain(&self) -> ExplainPlan {
471        self.explain_inner(None)
472    }
473
474    /// Produce a stable, deterministic explanation of this logical plan
475    /// with optional model context for query-layer projections.
476    ///
477    /// Query explain intentionally does not evaluate executor route pushdown
478    /// feasibility to keep query-layer dependencies executor-agnostic.
479    #[must_use]
480    pub(crate) fn explain_with_model(&self, model: &EntityModel) -> ExplainPlan {
481        self.explain_inner(Some(model))
482    }
483
484    fn explain_inner(&self, model: Option<&EntityModel>) -> ExplainPlan {
485        // Phase 1: project logical plan variant into scalar core + grouped metadata.
486        let (logical, grouping) = match &self.logical {
487            LogicalPlan::Scalar(logical) => (logical, ExplainGrouping::None),
488            LogicalPlan::Grouped(logical) => (
489                &logical.scalar,
490                ExplainGrouping::Grouped {
491                    strategy: grouped_plan_strategy_hint(self)
492                        .map_or(ExplainGroupedStrategy::HashGroup, Into::into),
493                    group_fields: logical
494                        .group
495                        .group_fields
496                        .iter()
497                        .map(|field_slot| ExplainGroupField {
498                            slot_index: field_slot.index(),
499                            field: field_slot.field().to_string(),
500                        })
501                        .collect(),
502                    aggregates: logical
503                        .group
504                        .aggregates
505                        .iter()
506                        .map(|aggregate| ExplainGroupAggregate {
507                            kind: aggregate.kind,
508                            target_field: aggregate.target_field.clone(),
509                            distinct: aggregate.distinct,
510                        })
511                        .collect(),
512                    having: explain_group_having(logical.having.as_ref()),
513                    max_groups: logical.group.execution.max_groups(),
514                    max_group_bytes: logical.group.execution.max_group_bytes(),
515                },
516            ),
517        };
518
519        // Phase 2: project scalar plan + access path into deterministic explain surface.
520        explain_scalar_inner(logical, grouping, model, &self.access)
521    }
522}
523
524fn explain_group_having(having: Option<&GroupHavingSpec>) -> Option<ExplainGroupHaving> {
525    let having = having?;
526
527    Some(ExplainGroupHaving {
528        clauses: having
529            .clauses()
530            .iter()
531            .map(explain_group_having_clause)
532            .collect(),
533    })
534}
535
536fn explain_group_having_clause(clause: &GroupHavingClause) -> ExplainGroupHavingClause {
537    ExplainGroupHavingClause {
538        symbol: explain_group_having_symbol(clause.symbol()),
539        op: clause.op(),
540        value: clause.value().clone(),
541    }
542}
543
544fn explain_group_having_symbol(symbol: &GroupHavingSymbol) -> ExplainGroupHavingSymbol {
545    match symbol {
546        GroupHavingSymbol::GroupField(field_slot) => ExplainGroupHavingSymbol::GroupField {
547            slot_index: field_slot.index(),
548            field: field_slot.field().to_string(),
549        },
550        GroupHavingSymbol::AggregateIndex(index) => {
551            ExplainGroupHavingSymbol::AggregateIndex { index: *index }
552        }
553    }
554}
555
556fn explain_scalar_inner<K>(
557    logical: &ScalarPlan,
558    grouping: ExplainGrouping,
559    model: Option<&EntityModel>,
560    access: &AccessPlan<K>,
561) -> ExplainPlan
562where
563    K: FieldValue,
564{
565    // Phase 1: derive canonical predicate projection from normalized predicate model.
566    let predicate_model = logical.predicate.as_ref().map(normalize);
567    let predicate = match &predicate_model {
568        Some(predicate) => ExplainPredicate::from_predicate(predicate),
569        None => ExplainPredicate::None,
570    };
571
572    // Phase 2: project scalar-plan fields into explain-specific enums.
573    let order_by = explain_order(logical.order.as_ref());
574    let order_pushdown = explain_order_pushdown(model);
575    let page = explain_page(logical.page.as_ref());
576    let delete_limit = explain_delete_limit(logical.delete_limit.as_ref());
577
578    // Phase 3: assemble one stable explain payload.
579    ExplainPlan {
580        mode: logical.mode,
581        access: ExplainAccessPath::from_access_plan(access),
582        predicate,
583        predicate_model,
584        order_by,
585        distinct: logical.distinct,
586        grouping,
587        order_pushdown,
588        page,
589        delete_limit,
590        consistency: logical.consistency,
591    }
592}
593
594const fn explain_order_pushdown(model: Option<&EntityModel>) -> ExplainOrderPushdown {
595    let _ = model;
596
597    // Query explain does not own physical pushdown feasibility routing.
598    ExplainOrderPushdown::MissingModelContext
599}
600
601impl From<SecondaryOrderPushdownEligibility> for ExplainOrderPushdown {
602    fn from(value: SecondaryOrderPushdownEligibility) -> Self {
603        Self::from(PushdownSurfaceEligibility::from(&value))
604    }
605}
606
607impl From<PushdownSurfaceEligibility<'_>> for ExplainOrderPushdown {
608    fn from(value: PushdownSurfaceEligibility<'_>) -> Self {
609        match value {
610            PushdownSurfaceEligibility::EligibleSecondaryIndex { index, prefix_len } => {
611                Self::EligibleSecondaryIndex { index, prefix_len }
612            }
613            PushdownSurfaceEligibility::Rejected { reason } => Self::Rejected(reason.clone()),
614        }
615    }
616}
617
618impl ExplainPredicate {
619    fn from_predicate(predicate: &Predicate) -> Self {
620        match predicate {
621            Predicate::True => Self::True,
622            Predicate::False => Self::False,
623            Predicate::And(children) => {
624                Self::And(children.iter().map(Self::from_predicate).collect())
625            }
626            Predicate::Or(children) => {
627                Self::Or(children.iter().map(Self::from_predicate).collect())
628            }
629            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
630            Predicate::Compare(compare) => Self::from_compare(compare),
631            Predicate::IsNull { field } => Self::IsNull {
632                field: field.clone(),
633            },
634            Predicate::IsNotNull { field } => Self::IsNotNull {
635                field: field.clone(),
636            },
637            Predicate::IsMissing { field } => Self::IsMissing {
638                field: field.clone(),
639            },
640            Predicate::IsEmpty { field } => Self::IsEmpty {
641                field: field.clone(),
642            },
643            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
644                field: field.clone(),
645            },
646            Predicate::TextContains { field, value } => Self::TextContains {
647                field: field.clone(),
648                value: value.clone(),
649            },
650            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
651                field: field.clone(),
652                value: value.clone(),
653            },
654        }
655    }
656
657    fn from_compare(compare: &ComparePredicate) -> Self {
658        Self::Compare {
659            field: compare.field.clone(),
660            op: compare.op,
661            value: compare.value.clone(),
662            coercion: compare.coercion.clone(),
663        }
664    }
665}
666
667fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
668    let Some(order) = order else {
669        return ExplainOrderBy::None;
670    };
671
672    if order.fields.is_empty() {
673        return ExplainOrderBy::None;
674    }
675
676    ExplainOrderBy::Fields(
677        order
678            .fields
679            .iter()
680            .map(|(field, direction)| ExplainOrder {
681                field: field.clone(),
682                direction: *direction,
683            })
684            .collect(),
685    )
686}
687
688const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
689    match page {
690        Some(page) => ExplainPagination::Page {
691            limit: page.limit,
692            offset: page.offset,
693        },
694        None => ExplainPagination::None,
695    }
696}
697
698const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
699    match limit {
700        Some(limit) => ExplainDeleteLimit::Limit {
701            max_rows: limit.max_rows,
702        },
703        None => ExplainDeleteLimit::None,
704    }
705}