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