Skip to main content

icydb_core/db/query/explain/
mod.rs

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