Skip to main content

icydb_core/db/query/explain/
plan.rs

1//! Module: query::explain::plan
2//! Responsibility: deterministic planned-query projection for EXPLAIN,
3//! including logical shape, access shape, and pushdown observability.
4//! Does not own: execution descriptor rendering or access visitor adapters.
5//! Boundary: explain DTOs and plan-side projection logic for query observability.
6
7use crate::{
8    db::{
9        access::{
10            AccessPlan, PushdownSurfaceEligibility, SecondaryOrderPushdownEligibility,
11            SecondaryOrderPushdownRejection,
12        },
13        predicate::{CoercionSpec, CompareOp, ComparePredicate, MissingRowPolicy, Predicate},
14        query::{
15            builder::scalar_projection::render_scalar_projection_expr_sql_label,
16            explain::{access_projection::write_access_json, writer::JsonWriter},
17            plan::{
18                AccessPlannedQuery, AggregateKind, DeleteLimitSpec, GroupedPlanFallbackReason,
19                LogicalPlan, OrderDirection, OrderSpec, PageSpec, QueryMode, ScalarPlan,
20                expr::Expr, grouped_plan_strategy,
21            },
22        },
23    },
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: &'static str,
187        fallback_reason: Option<&'static str>,
188        group_fields: Vec<ExplainGroupField>,
189        aggregates: Vec<ExplainGroupAggregate>,
190        having: Option<ExplainGroupHaving>,
191        max_groups: u64,
192        max_group_bytes: u64,
193    },
194}
195
196///
197/// ExplainGroupField
198///
199/// Stable grouped-key field identity carried by explain/hash surfaces.
200///
201
202#[derive(Clone, Debug, Eq, PartialEq)]
203pub struct ExplainGroupField {
204    pub(crate) slot_index: usize,
205    pub(crate) field: String,
206}
207
208impl ExplainGroupField {
209    /// Return grouped slot index.
210    #[must_use]
211    pub const fn slot_index(&self) -> usize {
212        self.slot_index
213    }
214
215    /// Borrow grouped field name.
216    #[must_use]
217    pub const fn field(&self) -> &str {
218        self.field.as_str()
219    }
220}
221
222///
223/// ExplainGroupAggregate
224///
225/// Stable explain-surface projection of one grouped aggregate terminal.
226///
227
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct ExplainGroupAggregate {
230    pub(crate) kind: AggregateKind,
231    pub(crate) target_field: Option<String>,
232    pub(crate) input_expr: Option<String>,
233    pub(crate) filter_expr: Option<String>,
234    pub(crate) distinct: bool,
235}
236
237impl ExplainGroupAggregate {
238    /// Return grouped aggregate kind.
239    #[must_use]
240    pub const fn kind(&self) -> AggregateKind {
241        self.kind
242    }
243
244    /// Borrow optional grouped aggregate target field.
245    #[must_use]
246    pub fn target_field(&self) -> Option<&str> {
247        self.target_field.as_deref()
248    }
249
250    /// Borrow optional grouped aggregate input expression label.
251    #[must_use]
252    pub fn input_expr(&self) -> Option<&str> {
253        self.input_expr.as_deref()
254    }
255
256    /// Borrow optional grouped aggregate filter expression label.
257    #[must_use]
258    pub fn filter_expr(&self) -> Option<&str> {
259        self.filter_expr.as_deref()
260    }
261
262    /// Return whether grouped aggregate uses DISTINCT input semantics.
263    #[must_use]
264    pub const fn distinct(&self) -> bool {
265        self.distinct
266    }
267}
268
269///
270/// ExplainGroupHaving
271///
272/// Deterministic explain projection of grouped HAVING clauses.
273/// This surface now carries the shared planner-owned post-aggregate expression
274/// directly so explain no longer keeps a second grouped HAVING AST.
275///
276
277#[derive(Clone, Debug, Eq, PartialEq)]
278pub struct ExplainGroupHaving {
279    pub(crate) expr: Expr,
280}
281
282impl ExplainGroupHaving {
283    /// Borrow grouped HAVING expression.
284    #[must_use]
285    pub(in crate::db) const fn expr(&self) -> &Expr {
286        &self.expr
287    }
288}
289
290///
291/// ExplainOrderPushdown
292///
293/// Deterministic ORDER BY pushdown eligibility reported by explain.
294///
295
296#[derive(Clone, Debug, Eq, PartialEq)]
297pub enum ExplainOrderPushdown {
298    MissingModelContext,
299    EligibleSecondaryIndex {
300        index: &'static str,
301        prefix_len: usize,
302    },
303    Rejected(SecondaryOrderPushdownRejection),
304}
305
306///
307/// ExplainAccessPath
308///
309/// Deterministic projection of logical access path shape for diagnostics.
310/// Mirrors planner-selected structural paths without runtime cursor state.
311///
312
313#[derive(Clone, Debug, Eq, PartialEq)]
314pub enum ExplainAccessPath {
315    ByKey {
316        key: Value,
317    },
318    ByKeys {
319        keys: Vec<Value>,
320    },
321    KeyRange {
322        start: Value,
323        end: Value,
324    },
325    IndexPrefix {
326        name: &'static str,
327        fields: Vec<&'static str>,
328        prefix_len: usize,
329        values: Vec<Value>,
330    },
331    IndexMultiLookup {
332        name: &'static str,
333        fields: Vec<&'static str>,
334        values: Vec<Value>,
335    },
336    IndexRange {
337        name: &'static str,
338        fields: Vec<&'static str>,
339        prefix_len: usize,
340        prefix: Vec<Value>,
341        lower: Bound<Value>,
342        upper: Bound<Value>,
343    },
344    FullScan,
345    Union(Vec<Self>),
346    Intersection(Vec<Self>),
347}
348
349///
350/// ExplainPredicate
351///
352/// Deterministic projection of canonical predicate structure for explain output.
353/// This preserves normalized predicate shape used by hashing/fingerprints.
354///
355
356#[derive(Clone, Debug, Eq, PartialEq)]
357pub enum ExplainPredicate {
358    None,
359    True,
360    False,
361    And(Vec<Self>),
362    Or(Vec<Self>),
363    Not(Box<Self>),
364    Compare {
365        field: String,
366        op: CompareOp,
367        value: Value,
368        coercion: CoercionSpec,
369    },
370    CompareFields {
371        left_field: String,
372        op: CompareOp,
373        right_field: String,
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    Window { limit: Option<u32>, offset: u32 },
462}
463
464impl AccessPlannedQuery {
465    /// Produce a stable, deterministic explanation of this logical plan.
466    #[must_use]
467    pub(crate) fn explain(&self) -> ExplainPlan {
468        self.explain_inner()
469    }
470
471    pub(in crate::db::query::explain) fn explain_inner(&self) -> ExplainPlan {
472        // Phase 1: project logical plan variant into scalar core + grouped metadata.
473        let (logical, grouping) = match &self.logical {
474            LogicalPlan::Scalar(logical) => (logical, ExplainGrouping::None),
475            LogicalPlan::Grouped(logical) => {
476                let grouped_strategy = grouped_plan_strategy(self).expect(
477                    "grouped logical explain projection requires planner-owned grouped strategy",
478                );
479
480                (
481                    &logical.scalar,
482                    ExplainGrouping::Grouped {
483                        strategy: grouped_strategy.code(),
484                        fallback_reason: grouped_strategy
485                            .fallback_reason()
486                            .map(GroupedPlanFallbackReason::code),
487                        group_fields: logical
488                            .group
489                            .group_fields
490                            .iter()
491                            .map(|field_slot| ExplainGroupField {
492                                slot_index: field_slot.index(),
493                                field: field_slot.field().to_string(),
494                            })
495                            .collect(),
496                        aggregates: logical
497                            .group
498                            .aggregates
499                            .iter()
500                            .map(|aggregate| ExplainGroupAggregate {
501                                kind: aggregate.kind,
502                                target_field: aggregate.target_field().map(str::to_string),
503                                input_expr: aggregate
504                                    .input_expr()
505                                    .map(render_scalar_projection_expr_sql_label),
506                                filter_expr: aggregate
507                                    .filter_expr()
508                                    .map(render_scalar_projection_expr_sql_label),
509                                distinct: aggregate.distinct,
510                            })
511                            .collect(),
512                        having: explain_group_having(logical),
513                        max_groups: logical.group.execution.max_groups(),
514                        max_group_bytes: logical.group.execution.max_group_bytes(),
515                    },
516                )
517            }
518        };
519
520        // Phase 2: project scalar plan + access path into deterministic explain surface.
521        explain_scalar_inner(logical, grouping, &self.access)
522    }
523}
524
525fn explain_group_having(logical: &crate::db::query::plan::GroupPlan) -> Option<ExplainGroupHaving> {
526    let expr = logical.effective_having_expr()?;
527
528    Some(ExplainGroupHaving {
529        expr: expr.into_owned(),
530    })
531}
532
533fn explain_scalar_inner<K>(
534    logical: &ScalarPlan,
535    grouping: ExplainGrouping,
536    access: &AccessPlan<K>,
537) -> ExplainPlan
538where
539    K: FieldValue,
540{
541    // Phase 1: consume canonical predicate model from planner-owned scalar semantics.
542    let predicate_model = logical.predicate.clone();
543    let predicate = match &predicate_model {
544        Some(predicate) => ExplainPredicate::from_predicate(predicate),
545        None => ExplainPredicate::None,
546    };
547
548    // Phase 2: project scalar-plan fields into explain-specific enums.
549    let order_by = explain_order(logical.order.as_ref());
550    let order_pushdown = explain_order_pushdown();
551    let page = explain_page(logical.page.as_ref());
552    let delete_limit = explain_delete_limit(logical.delete_limit.as_ref());
553
554    // Phase 3: assemble one stable explain payload.
555    ExplainPlan {
556        mode: logical.mode,
557        access: ExplainAccessPath::from_access_plan(access),
558        predicate,
559        predicate_model,
560        order_by,
561        distinct: logical.distinct,
562        grouping,
563        order_pushdown,
564        page,
565        delete_limit,
566        consistency: logical.consistency,
567    }
568}
569
570const fn explain_order_pushdown() -> ExplainOrderPushdown {
571    // Query explain does not own physical pushdown feasibility routing.
572    ExplainOrderPushdown::MissingModelContext
573}
574
575impl From<SecondaryOrderPushdownEligibility> for ExplainOrderPushdown {
576    fn from(value: SecondaryOrderPushdownEligibility) -> Self {
577        Self::from(PushdownSurfaceEligibility::from(&value))
578    }
579}
580
581impl From<PushdownSurfaceEligibility<'_>> for ExplainOrderPushdown {
582    fn from(value: PushdownSurfaceEligibility<'_>) -> Self {
583        match value {
584            PushdownSurfaceEligibility::EligibleSecondaryIndex { index, prefix_len } => {
585                Self::EligibleSecondaryIndex { index, prefix_len }
586            }
587            PushdownSurfaceEligibility::Rejected { reason } => Self::Rejected(reason.clone()),
588        }
589    }
590}
591
592impl ExplainPredicate {
593    pub(in crate::db) fn from_predicate(predicate: &Predicate) -> Self {
594        match predicate {
595            Predicate::True => Self::True,
596            Predicate::False => Self::False,
597            Predicate::And(children) => {
598                Self::And(children.iter().map(Self::from_predicate).collect())
599            }
600            Predicate::Or(children) => {
601                Self::Or(children.iter().map(Self::from_predicate).collect())
602            }
603            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
604            Predicate::Compare(compare) => Self::from_compare(compare),
605            Predicate::CompareFields(compare) => Self::CompareFields {
606                left_field: compare.left_field().to_string(),
607                op: compare.op(),
608                right_field: compare.right_field().to_string(),
609                coercion: compare.coercion().clone(),
610            },
611            Predicate::IsNull { field } => Self::IsNull {
612                field: field.clone(),
613            },
614            Predicate::IsNotNull { field } => Self::IsNotNull {
615                field: field.clone(),
616            },
617            Predicate::IsMissing { field } => Self::IsMissing {
618                field: field.clone(),
619            },
620            Predicate::IsEmpty { field } => Self::IsEmpty {
621                field: field.clone(),
622            },
623            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
624                field: field.clone(),
625            },
626            Predicate::TextContains { field, value } => Self::TextContains {
627                field: field.clone(),
628                value: value.clone(),
629            },
630            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
631                field: field.clone(),
632                value: value.clone(),
633            },
634        }
635    }
636
637    fn from_compare(compare: &ComparePredicate) -> Self {
638        Self::Compare {
639            field: compare.field.clone(),
640            op: compare.op,
641            value: compare.value.clone(),
642            coercion: compare.coercion.clone(),
643        }
644    }
645}
646
647fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
648    let Some(order) = order else {
649        return ExplainOrderBy::None;
650    };
651
652    if order.fields.is_empty() {
653        return ExplainOrderBy::None;
654    }
655
656    ExplainOrderBy::Fields(
657        order
658            .fields
659            .iter()
660            .map(|term| ExplainOrder {
661                field: term.rendered_label(),
662                direction: term.direction(),
663            })
664            .collect(),
665    )
666}
667
668const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
669    match page {
670        Some(page) => ExplainPagination::Page {
671            limit: page.limit,
672            offset: page.offset,
673        },
674        None => ExplainPagination::None,
675    }
676}
677
678const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
679    match limit {
680        Some(limit) if limit.offset == 0 => match limit.limit {
681            Some(max_rows) => ExplainDeleteLimit::Limit { max_rows },
682            None => ExplainDeleteLimit::Window {
683                limit: None,
684                offset: 0,
685            },
686        },
687        Some(limit) => ExplainDeleteLimit::Window {
688            limit: limit.limit,
689            offset: limit.offset,
690        },
691        None => ExplainDeleteLimit::None,
692    }
693}
694
695fn write_logical_explain_json(explain: &ExplainPlan, out: &mut String) {
696    let mut object = JsonWriter::begin_object(out);
697    object.field_with("mode", |out| {
698        let mut object = JsonWriter::begin_object(out);
699        match explain.mode() {
700            QueryMode::Load(spec) => {
701                object.field_str("type", "Load");
702                match spec.limit() {
703                    Some(limit) => object.field_u64("limit", u64::from(limit)),
704                    None => object.field_null("limit"),
705                }
706                object.field_u64("offset", u64::from(spec.offset()));
707            }
708            QueryMode::Delete(spec) => {
709                object.field_str("type", "Delete");
710                match spec.limit() {
711                    Some(limit) => object.field_u64("limit", u64::from(limit)),
712                    None => object.field_null("limit"),
713                }
714            }
715        }
716        object.finish();
717    });
718    object.field_with("access", |out| write_access_json(explain.access(), out));
719    object.field_value_debug("predicate", explain.predicate());
720    object.field_value_debug("order_by", explain.order_by());
721    object.field_bool("distinct", explain.distinct());
722    object.field_value_debug("grouping", explain.grouping());
723    object.field_value_debug("order_pushdown", explain.order_pushdown());
724    object.field_with("page", |out| {
725        let mut object = JsonWriter::begin_object(out);
726        match explain.page() {
727            ExplainPagination::None => {
728                object.field_str("type", "None");
729            }
730            ExplainPagination::Page { limit, offset } => {
731                object.field_str("type", "Page");
732                match limit {
733                    Some(limit) => object.field_u64("limit", u64::from(*limit)),
734                    None => object.field_null("limit"),
735                }
736                object.field_u64("offset", u64::from(*offset));
737            }
738        }
739        object.finish();
740    });
741    object.field_with("delete_limit", |out| {
742        let mut object = JsonWriter::begin_object(out);
743        match explain.delete_limit() {
744            ExplainDeleteLimit::None => {
745                object.field_str("type", "None");
746            }
747            ExplainDeleteLimit::Limit { max_rows } => {
748                object.field_str("type", "Limit");
749                object.field_u64("max_rows", u64::from(*max_rows));
750            }
751            ExplainDeleteLimit::Window { limit, offset } => {
752                object.field_str("type", "Window");
753                object.field_with("limit", |out| match limit {
754                    Some(limit) => out.push_str(&limit.to_string()),
755                    None => out.push_str("null"),
756                });
757                object.field_u64("offset", u64::from(*offset));
758            }
759        }
760        object.finish();
761    });
762    object.field_value_debug("consistency", &explain.consistency());
763    object.finish();
764}