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