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