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