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    IsMissing {
380        field: String,
381    },
382    IsEmpty {
383        field: String,
384    },
385    IsNotEmpty {
386        field: String,
387    },
388    TextContains {
389        field: String,
390        value: Value,
391    },
392    TextContainsCi {
393        field: String,
394        value: Value,
395    },
396}
397
398///
399/// ExplainOrderBy
400///
401/// Deterministic projection of canonical ORDER BY shape.
402///
403
404#[derive(Clone, Debug, Eq, PartialEq)]
405pub enum ExplainOrderBy {
406    None,
407    Fields(Vec<ExplainOrder>),
408}
409
410///
411/// ExplainOrder
412///
413/// One canonical ORDER BY field + direction pair.
414///
415
416#[derive(Clone, Debug, Eq, PartialEq)]
417pub struct ExplainOrder {
418    pub(crate) field: String,
419    pub(crate) direction: OrderDirection,
420}
421
422impl ExplainOrder {
423    /// Borrow ORDER BY field name.
424    #[must_use]
425    pub const fn field(&self) -> &str {
426        self.field.as_str()
427    }
428
429    /// Return ORDER BY direction.
430    #[must_use]
431    pub const fn direction(&self) -> OrderDirection {
432        self.direction
433    }
434}
435
436///
437/// ExplainPagination
438///
439/// Explain-surface projection of pagination window configuration.
440///
441
442#[derive(Clone, Debug, Eq, PartialEq)]
443pub enum ExplainPagination {
444    None,
445    Page { limit: Option<u32>, offset: u32 },
446}
447
448///
449/// ExplainDeleteLimit
450///
451/// Explain-surface projection of delete-limit configuration.
452///
453
454#[derive(Clone, Debug, Eq, PartialEq)]
455pub enum ExplainDeleteLimit {
456    None,
457    Limit { max_rows: u32 },
458}
459
460impl<K> AccessPlannedQuery<K>
461where
462    K: FieldValue,
463{
464    /// Produce a stable, deterministic explanation of this logical plan.
465    #[must_use]
466    #[cfg(test)]
467    pub(crate) fn explain(&self) -> ExplainPlan {
468        self.explain_inner(None)
469    }
470
471    /// Produce a stable, deterministic explanation of this logical plan
472    /// with optional model context for query-layer projections.
473    ///
474    /// Query explain intentionally does not evaluate executor route pushdown
475    /// feasibility to keep query-layer dependencies executor-agnostic.
476    #[must_use]
477    pub(crate) fn explain_with_model(&self, model: &EntityModel) -> ExplainPlan {
478        self.explain_inner(Some(model))
479    }
480
481    fn explain_inner(&self, model: Option<&EntityModel>) -> ExplainPlan {
482        // Phase 1: project logical plan variant into scalar core + grouped metadata.
483        let (logical, grouping) = match &self.logical {
484            LogicalPlan::Scalar(logical) => (logical, ExplainGrouping::None),
485            LogicalPlan::Grouped(logical) => (
486                &logical.scalar,
487                ExplainGrouping::Grouped {
488                    strategy: grouped_plan_strategy_hint(self)
489                        .map_or(ExplainGroupedStrategy::HashGroup, Into::into),
490                    group_fields: logical
491                        .group
492                        .group_fields
493                        .iter()
494                        .map(|field_slot| ExplainGroupField {
495                            slot_index: field_slot.index(),
496                            field: field_slot.field().to_string(),
497                        })
498                        .collect(),
499                    aggregates: logical
500                        .group
501                        .aggregates
502                        .iter()
503                        .map(|aggregate| ExplainGroupAggregate {
504                            kind: aggregate.kind,
505                            target_field: aggregate.target_field.clone(),
506                            distinct: aggregate.distinct,
507                        })
508                        .collect(),
509                    having: explain_group_having(logical.having.as_ref()),
510                    max_groups: logical.group.execution.max_groups(),
511                    max_group_bytes: logical.group.execution.max_group_bytes(),
512                },
513            ),
514        };
515
516        // Phase 2: project scalar plan + access path into deterministic explain surface.
517        explain_scalar_inner(logical, grouping, model, &self.access)
518    }
519}
520
521fn explain_group_having(having: Option<&GroupHavingSpec>) -> Option<ExplainGroupHaving> {
522    let having = having?;
523
524    Some(ExplainGroupHaving {
525        clauses: having
526            .clauses()
527            .iter()
528            .map(explain_group_having_clause)
529            .collect(),
530    })
531}
532
533fn explain_group_having_clause(clause: &GroupHavingClause) -> ExplainGroupHavingClause {
534    ExplainGroupHavingClause {
535        symbol: explain_group_having_symbol(clause.symbol()),
536        op: clause.op(),
537        value: clause.value().clone(),
538    }
539}
540
541fn explain_group_having_symbol(symbol: &GroupHavingSymbol) -> ExplainGroupHavingSymbol {
542    match symbol {
543        GroupHavingSymbol::GroupField(field_slot) => ExplainGroupHavingSymbol::GroupField {
544            slot_index: field_slot.index(),
545            field: field_slot.field().to_string(),
546        },
547        GroupHavingSymbol::AggregateIndex(index) => {
548            ExplainGroupHavingSymbol::AggregateIndex { index: *index }
549        }
550    }
551}
552
553fn explain_scalar_inner<K>(
554    logical: &ScalarPlan,
555    grouping: ExplainGrouping,
556    model: Option<&EntityModel>,
557    access: &AccessPlan<K>,
558) -> ExplainPlan
559where
560    K: FieldValue,
561{
562    // Phase 1: derive canonical predicate projection from normalized predicate model.
563    let predicate_model = logical.predicate.as_ref().map(normalize);
564    let predicate = match &predicate_model {
565        Some(predicate) => ExplainPredicate::from_predicate(predicate),
566        None => ExplainPredicate::None,
567    };
568
569    // Phase 2: project scalar-plan fields into explain-specific enums.
570    let order_by = explain_order(logical.order.as_ref());
571    let order_pushdown = explain_order_pushdown(model);
572    let page = explain_page(logical.page.as_ref());
573    let delete_limit = explain_delete_limit(logical.delete_limit.as_ref());
574
575    // Phase 3: assemble one stable explain payload.
576    ExplainPlan {
577        mode: logical.mode,
578        access: ExplainAccessPath::from_access_plan(access),
579        predicate,
580        predicate_model,
581        order_by,
582        distinct: logical.distinct,
583        grouping,
584        order_pushdown,
585        page,
586        delete_limit,
587        consistency: logical.consistency,
588    }
589}
590
591const fn explain_order_pushdown(model: Option<&EntityModel>) -> ExplainOrderPushdown {
592    let _ = model;
593
594    // Query explain does not own physical pushdown feasibility routing.
595    ExplainOrderPushdown::MissingModelContext
596}
597
598impl From<SecondaryOrderPushdownEligibility> for ExplainOrderPushdown {
599    fn from(value: SecondaryOrderPushdownEligibility) -> Self {
600        Self::from(PushdownSurfaceEligibility::from(&value))
601    }
602}
603
604impl From<PushdownSurfaceEligibility<'_>> for ExplainOrderPushdown {
605    fn from(value: PushdownSurfaceEligibility<'_>) -> Self {
606        match value {
607            PushdownSurfaceEligibility::EligibleSecondaryIndex { index, prefix_len } => {
608                Self::EligibleSecondaryIndex { index, prefix_len }
609            }
610            PushdownSurfaceEligibility::Rejected { reason } => Self::Rejected(reason.clone()),
611        }
612    }
613}
614
615impl ExplainPredicate {
616    fn from_predicate(predicate: &Predicate) -> Self {
617        match predicate {
618            Predicate::True => Self::True,
619            Predicate::False => Self::False,
620            Predicate::And(children) => {
621                Self::And(children.iter().map(Self::from_predicate).collect())
622            }
623            Predicate::Or(children) => {
624                Self::Or(children.iter().map(Self::from_predicate).collect())
625            }
626            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
627            Predicate::Compare(compare) => Self::from_compare(compare),
628            Predicate::IsNull { field } => Self::IsNull {
629                field: field.clone(),
630            },
631            Predicate::IsMissing { field } => Self::IsMissing {
632                field: field.clone(),
633            },
634            Predicate::IsEmpty { field } => Self::IsEmpty {
635                field: field.clone(),
636            },
637            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
638                field: field.clone(),
639            },
640            Predicate::TextContains { field, value } => Self::TextContains {
641                field: field.clone(),
642                value: value.clone(),
643            },
644            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
645                field: field.clone(),
646                value: value.clone(),
647            },
648        }
649    }
650
651    fn from_compare(compare: &ComparePredicate) -> Self {
652        Self::Compare {
653            field: compare.field.clone(),
654            op: compare.op,
655            value: compare.value.clone(),
656            coercion: compare.coercion.clone(),
657        }
658    }
659}
660
661fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
662    let Some(order) = order else {
663        return ExplainOrderBy::None;
664    };
665
666    if order.fields.is_empty() {
667        return ExplainOrderBy::None;
668    }
669
670    ExplainOrderBy::Fields(
671        order
672            .fields
673            .iter()
674            .map(|(field, direction)| ExplainOrder {
675                field: field.clone(),
676                direction: *direction,
677            })
678            .collect(),
679    )
680}
681
682const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
683    match page {
684        Some(page) => ExplainPagination::Page {
685            limit: page.limit,
686            offset: page.offset,
687        },
688        None => ExplainPagination::None,
689    }
690}
691
692const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
693    match limit {
694        Some(limit) => ExplainDeleteLimit::Limit {
695            max_rows: limit.max_rows,
696        },
697        None => ExplainDeleteLimit::None,
698    }
699}