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        predicate::{CoercionSpec, CompareOp, ComparePredicate, MissingRowPolicy, Predicate},
11        query::{
12            builder::scalar_projection::render_scalar_projection_expr_plan_label,
13            explain::{
14                access_projection::write_access_json_detailed, explain_access_plan,
15                writer::JsonWriter,
16            },
17            plan::{
18                AccessChoiceCandidateExplainSummary, AccessChoiceExplainSnapshot,
19                AccessChoiceResidualBurden, AccessPlannedQuery, AggregateKind, DeleteLimitSpec,
20                GroupedPlanAggregateFamily, GroupedPlanFallbackReason, GroupedPlanStrategy,
21                LogicalPlan, OrderDirection, OrderSpec, PageSpec, QueryMode, ScalarPlan,
22                explain_access_strategy_label, expr::Expr, grouped_plan_strategy,
23                render_scalar_filter_expr_plan_label,
24            },
25        },
26    },
27    traits::KeyValueCodec,
28    value::Value,
29};
30use std::{fmt, ops::Bound};
31
32///
33/// ExplainPlan
34///
35/// Stable, deterministic representation of a planned query for observability.
36///
37
38#[derive(Clone, Eq, PartialEq)]
39pub struct ExplainPlan {
40    pub(in crate::db) mode: QueryMode,
41    pub(in crate::db) access: ExplainAccessPath,
42    pub(in crate::db) access_decision: ExplainAccessDecisionV1,
43    pub(in crate::db) filter_expr: Option<String>,
44    filter_expr_model: Option<Expr>,
45    pub(in crate::db) predicate: ExplainPredicate,
46    predicate_model: Option<Predicate>,
47    pub(in crate::db) order_by: ExplainOrderBy,
48    pub(in crate::db) distinct: bool,
49    pub(in crate::db) grouping: ExplainGrouping,
50    pub(in crate::db) order_pushdown: ExplainOrderPushdown,
51    pub(in crate::db) page: ExplainPagination,
52    pub(in crate::db) delete_limit: ExplainDeleteLimit,
53    pub(in crate::db) consistency: MissingRowPolicy,
54}
55
56#[expect(clippy::missing_fields_in_debug)]
57impl fmt::Debug for ExplainPlan {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.debug_struct("ExplainPlan")
60            .field("mode", &self.mode)
61            .field("access", &self.access)
62            .field("filter_expr", &self.filter_expr)
63            .field("filter_expr_model", &self.filter_expr_model)
64            .field("predicate", &self.predicate)
65            .field("predicate_model", &self.predicate_model)
66            .field("order_by", &self.order_by)
67            .field("distinct", &self.distinct)
68            .field("grouping", &self.grouping)
69            .field("order_pushdown", &self.order_pushdown)
70            .field("page", &self.page)
71            .field("delete_limit", &self.delete_limit)
72            .field("consistency", &self.consistency)
73            .finish()
74    }
75}
76
77impl ExplainPlan {
78    /// Return query mode projected by this explain plan.
79    #[must_use]
80    pub const fn mode(&self) -> QueryMode {
81        self.mode
82    }
83
84    /// Borrow projected access-path shape.
85    #[must_use]
86    pub const fn access(&self) -> &ExplainAccessPath {
87        &self.access
88    }
89
90    /// Borrow the structured planner access-decision projection.
91    #[must_use]
92    pub const fn access_decision(&self) -> &ExplainAccessDecisionV1 {
93        &self.access_decision
94    }
95
96    /// Borrow projected semantic scalar filter expression when present.
97    #[must_use]
98    pub fn filter_expr(&self) -> Option<&str> {
99        self.filter_expr.as_deref()
100    }
101
102    /// Borrow the canonical scalar filter model used for identity hashing.
103    #[must_use]
104    pub(in crate::db::query) fn filter_expr_model_for_hash(&self) -> Option<&Expr> {
105        if let Some(filter_expr_model) = &self.filter_expr_model {
106            debug_assert_eq!(
107                self.filter_expr(),
108                Some(render_scalar_filter_expr_plan_label(filter_expr_model).as_str()),
109                "explain scalar filter label drifted from canonical filter model"
110            );
111            Some(filter_expr_model)
112        } else {
113            debug_assert!(
114                self.filter_expr.is_none(),
115                "missing canonical filter model requires filter_expr=None"
116            );
117            None
118        }
119    }
120
121    /// Borrow projected predicate shape.
122    #[must_use]
123    pub const fn predicate(&self) -> &ExplainPredicate {
124        &self.predicate
125    }
126
127    /// Borrow projected ORDER BY shape.
128    #[must_use]
129    pub const fn order_by(&self) -> &ExplainOrderBy {
130        &self.order_by
131    }
132
133    /// Return whether DISTINCT is enabled.
134    #[must_use]
135    pub const fn distinct(&self) -> bool {
136        self.distinct
137    }
138
139    /// Borrow projected grouped-shape metadata.
140    #[must_use]
141    pub const fn grouping(&self) -> &ExplainGrouping {
142        &self.grouping
143    }
144
145    /// Borrow projected ORDER pushdown status.
146    #[must_use]
147    pub const fn order_pushdown(&self) -> &ExplainOrderPushdown {
148        &self.order_pushdown
149    }
150
151    /// Borrow projected pagination status.
152    #[must_use]
153    pub const fn page(&self) -> &ExplainPagination {
154        &self.page
155    }
156
157    /// Borrow projected delete-limit status.
158    #[must_use]
159    pub const fn delete_limit(&self) -> &ExplainDeleteLimit {
160        &self.delete_limit
161    }
162
163    /// Return missing-row consistency policy.
164    #[must_use]
165    pub const fn consistency(&self) -> MissingRowPolicy {
166        self.consistency
167    }
168}
169
170impl ExplainPlan {
171    /// Return the canonical predicate model used as the fallback hash surface.
172    ///
173    /// When a semantic scalar `filter_expr` exists, hashing now prefers that
174    /// canonical filter surface instead. The explain predicate projection must
175    /// still remain a faithful rendering of this fallback model.
176    #[must_use]
177    pub(in crate::db::query) fn predicate_model_for_hash(&self) -> Option<&Predicate> {
178        if let Some(predicate) = &self.predicate_model {
179            debug_assert_eq!(
180                self.predicate,
181                ExplainPredicate::from_predicate(predicate),
182                "explain predicate surface drifted from canonical predicate model"
183            );
184            Some(predicate)
185        } else {
186            debug_assert!(
187                matches!(self.predicate, ExplainPredicate::None),
188                "missing canonical predicate model requires ExplainPredicate::None"
189            );
190            None
191        }
192    }
193
194    /// Render this logical explain plan as deterministic canonical text.
195    ///
196    /// This surface is frontend-facing and intentionally stable for SQL/CLI
197    /// explain output and snapshot-style diagnostics.
198    #[must_use]
199    pub fn render_text_canonical(&self) -> String {
200        format!(
201            concat!(
202                "mode={:?}\n",
203                "access={:?}\n",
204                "access_decision={}\n",
205                "filter_expr={:?}\n",
206                "predicate={:?}\n",
207                "order_by={:?}\n",
208                "distinct={}\n",
209                "grouping={:?}\n",
210                "order_pushdown={:?}\n",
211                "page={:?}\n",
212                "delete_limit={:?}\n",
213                "consistency={:?}",
214            ),
215            self.mode(),
216            self.access(),
217            self.access_decision().render_compact_summary(),
218            self.filter_expr(),
219            self.predicate(),
220            self.order_by(),
221            self.distinct(),
222            self.grouping(),
223            self.order_pushdown(),
224            self.page(),
225            self.delete_limit(),
226            self.consistency(),
227        )
228    }
229
230    /// Render this logical explain plan as canonical JSON.
231    #[must_use]
232    pub fn render_json_canonical(&self) -> String {
233        let mut out = String::new();
234        write_logical_explain_json(self, &mut out);
235
236        out
237    }
238}
239
240///
241/// ExplainGrouping
242///
243/// Grouped-shape annotation for deterministic explain/fingerprint surfaces.
244///
245
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub enum ExplainGrouping {
248    None,
249    Grouped {
250        strategy: &'static str,
251        fallback_reason: Option<&'static str>,
252        group_fields: Vec<ExplainGroupField>,
253        aggregates: Vec<ExplainGroupAggregate>,
254        having: Option<ExplainGroupHaving>,
255        max_groups: u64,
256        max_group_bytes: u64,
257    },
258}
259
260///
261/// ExplainGroupField
262///
263/// Stable grouped-key field identity carried by explain/hash surfaces.
264///
265
266#[derive(Clone, Debug, Eq, PartialEq)]
267pub struct ExplainGroupField {
268    pub(in crate::db) slot_index: usize,
269    pub(in crate::db) field: String,
270}
271
272impl ExplainGroupField {
273    /// Return grouped slot index.
274    #[must_use]
275    pub const fn slot_index(&self) -> usize {
276        self.slot_index
277    }
278
279    /// Borrow grouped field name.
280    #[must_use]
281    pub const fn field(&self) -> &str {
282        self.field.as_str()
283    }
284}
285
286///
287/// ExplainGroupAggregate
288///
289/// Stable explain-surface projection of one grouped aggregate terminal.
290///
291
292#[derive(Clone, Debug, Eq, PartialEq)]
293pub struct ExplainGroupAggregate {
294    pub(in crate::db) kind: AggregateKind,
295    pub(in crate::db) target_field: Option<String>,
296    pub(in crate::db) input_expr: Option<String>,
297    pub(in crate::db) filter_expr: Option<String>,
298    pub(in crate::db) distinct: bool,
299}
300
301impl ExplainGroupAggregate {
302    /// Return grouped aggregate kind.
303    #[must_use]
304    pub const fn kind(&self) -> AggregateKind {
305        self.kind
306    }
307
308    /// Borrow optional grouped aggregate target field.
309    #[must_use]
310    pub fn target_field(&self) -> Option<&str> {
311        self.target_field.as_deref()
312    }
313
314    /// Borrow optional grouped aggregate input expression label.
315    #[must_use]
316    pub fn input_expr(&self) -> Option<&str> {
317        self.input_expr.as_deref()
318    }
319
320    /// Borrow optional grouped aggregate filter expression label.
321    #[must_use]
322    pub fn filter_expr(&self) -> Option<&str> {
323        self.filter_expr.as_deref()
324    }
325
326    /// Return whether grouped aggregate uses DISTINCT input semantics.
327    #[must_use]
328    pub const fn distinct(&self) -> bool {
329        self.distinct
330    }
331}
332
333///
334/// ExplainGroupHaving
335///
336/// Deterministic explain projection of grouped HAVING clauses.
337/// This surface now carries the shared planner-owned post-aggregate expression
338/// directly so explain no longer keeps a second grouped HAVING AST.
339///
340
341#[derive(Clone, Debug, Eq, PartialEq)]
342pub struct ExplainGroupHaving {
343    pub(in crate::db) expr: Expr,
344}
345
346impl ExplainGroupHaving {
347    /// Borrow grouped HAVING expression.
348    #[must_use]
349    pub(in crate::db) const fn expr(&self) -> &Expr {
350        &self.expr
351    }
352}
353
354///
355/// ExplainOrderPushdown
356///
357/// Deterministic ORDER BY pushdown eligibility reported by explain.
358///
359
360#[derive(Clone, Debug, Eq, PartialEq)]
361pub enum ExplainOrderPushdown {
362    MissingModelContext,
363    EligibleSecondaryIndex { index: String, prefix_len: usize },
364    Rejected(SecondaryOrderPushdownRejection),
365}
366
367///
368/// SecondaryOrderPushdownRejection
369///
370/// Stable explain-surface reason why secondary-index ORDER BY pushdown was
371/// rejected. Executor route planning converts its runtime route reasons into
372/// this neutral query DTO before rendering explain payloads.
373///
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub enum SecondaryOrderPushdownRejection {
376    NoOrderBy,
377    AccessPathNotSingleIndexPrefix,
378    AccessPathIndexRangeUnsupported {
379        index: String,
380        prefix_len: usize,
381    },
382    InvalidIndexPrefixBounds {
383        prefix_len: usize,
384        index_field_len: usize,
385    },
386    MissingPrimaryKeyTieBreak {
387        field: String,
388    },
389    PrimaryKeyDirectionNotAscending {
390        field: String,
391    },
392    MixedDirectionNotEligible {
393        field: String,
394    },
395    OrderFieldsDoNotMatchIndex {
396        index: String,
397        prefix_len: usize,
398        expected_suffix: Vec<String>,
399        expected_full: Vec<String>,
400        actual: Vec<String>,
401    },
402}
403
404///
405/// ExplainAccessPath
406///
407/// Deterministic projection of logical access path shape for diagnostics.
408/// Mirrors planner-selected structural paths without runtime cursor state.
409///
410
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub enum ExplainAccessPath {
413    ByKey {
414        key: Value,
415    },
416    ByKeys {
417        keys: Vec<Value>,
418    },
419    KeyRange {
420        start: Value,
421        end: Value,
422    },
423    IndexPrefix {
424        name: String,
425        fields: Vec<String>,
426        prefix_len: usize,
427        values: Vec<Value>,
428    },
429    IndexMultiLookup {
430        name: String,
431        fields: Vec<String>,
432        values: Vec<Value>,
433    },
434    IndexBranchSet {
435        name: String,
436        fields: Vec<String>,
437        fixed_values: Vec<Value>,
438        branch_values: Vec<Value>,
439        branch_field: Option<String>,
440        ordered_suffix: String,
441    },
442    IndexRange {
443        name: String,
444        fields: Vec<String>,
445        prefix_len: usize,
446        prefix: Vec<Value>,
447        lower: Bound<Value>,
448        upper: Bound<Value>,
449    },
450    FullScan,
451    Union(Vec<Self>),
452    Intersection(Vec<Self>),
453}
454
455/// Stable JSON-facing access-decision projection for logical EXPLAIN.
456///
457/// This DTO is derived from the planner-owned access-choice snapshot and the
458/// selected explain access path. It is not an optimizer model and does not
459/// participate in access selection.
460#[derive(Clone, Debug, Eq, PartialEq)]
461pub struct ExplainAccessDecisionV1 {
462    /// Schema version for this access-decision payload shape.
463    pub schema_version: u32,
464    /// Selected access path summary.
465    pub selected: ExplainSelectedAccessV1,
466    /// Planner candidate summaries recorded for the selected access family.
467    pub candidates: Vec<ExplainAccessCandidateV1>,
468    /// Eligible alternatives not selected by the planner.
469    pub alternatives: Vec<ExplainEligibleAlternativeV1>,
470    /// Rejected index candidates and planner-owned reason strings.
471    pub rejections: Vec<ExplainRejectedIndexV1>,
472    /// Residual-work summary for the selected route when available.
473    pub residual: ExplainResidualSummaryV1,
474}
475
476impl ExplainAccessDecisionV1 {
477    const SCHEMA_VERSION: u32 = 1;
478
479    fn from_snapshot(
480        selected_access: &ExplainAccessPath,
481        snapshot: &AccessChoiceExplainSnapshot,
482    ) -> Self {
483        let selected_label = explain_access_strategy_label(selected_access);
484        let selected_candidate = selected_candidate_summary(&selected_label, &snapshot.candidates);
485
486        Self {
487            schema_version: Self::SCHEMA_VERSION,
488            selected: ExplainSelectedAccessV1 {
489                kind: ExplainAccessDecisionKind::from_access_path(selected_access),
490                index_name: selected_index_name(selected_access).map(ToOwned::to_owned),
491                label: selected_label,
492                reason: snapshot.chosen_reason().code(),
493            },
494            candidates: snapshot
495                .candidates
496                .iter()
497                .map(ExplainAccessCandidateV1::from_candidate)
498                .collect(),
499            alternatives: snapshot
500                .alternatives
501                .iter()
502                .map(|index_name| ExplainEligibleAlternativeV1 {
503                    index_name: index_name.clone(),
504                })
505                .collect(),
506            rejections: snapshot
507                .rejected
508                .iter()
509                .map(|rejection| ExplainRejectedIndexV1::from_rejection(rejection))
510                .collect(),
511            residual: ExplainResidualSummaryV1::from_selected_access_and_candidate(
512                selected_access,
513                selected_candidate,
514            ),
515        }
516    }
517
518    fn render_compact_summary(&self) -> String {
519        let index = self
520            .selected
521            .index_name
522            .as_deref()
523            .map_or("none", |index| index);
524
525        format!(
526            "kind={} index={} reason={} residual={} candidates={} alternatives={} rejections={}",
527            self.selected.kind.code(),
528            index,
529            self.selected.reason,
530            self.residual.burden_class,
531            self.candidates.len(),
532            self.alternatives.len(),
533            self.rejections.len(),
534        )
535    }
536}
537
538/// Selected access path summary inside an access-decision explain payload.
539#[derive(Clone, Debug, Eq, PartialEq)]
540pub struct ExplainSelectedAccessV1 {
541    /// Selected access kind.
542    pub kind: ExplainAccessDecisionKind,
543    /// Selected semantic index name, when the selected route is index-backed.
544    pub index_name: Option<String>,
545    /// Planner access label used for candidate matching and diagnostics.
546    pub label: String,
547    /// Planner-owned selected reason code.
548    pub reason: &'static str,
549}
550
551/// Stable access-kind code used by the access-decision explain payload.
552#[derive(Clone, Copy, Debug, Eq, PartialEq)]
553pub enum ExplainAccessDecisionKind {
554    /// Direct primary-key lookup.
555    ByKey,
556    /// Multiple primary-key lookup.
557    ByKeys,
558    /// Primary-key range lookup.
559    KeyRange,
560    /// Secondary-index equality prefix lookup.
561    IndexPrefix,
562    /// Secondary-index multi-value lookup.
563    IndexMultiLookup,
564    /// Branch-aware secondary-index composite prefix lookup.
565    IndexBranchSet,
566    /// Secondary-index range lookup.
567    IndexRange,
568    /// Full entity scan.
569    FullScan,
570    /// Union access route.
571    Union,
572    /// Intersection access route.
573    Intersection,
574}
575
576impl ExplainAccessDecisionKind {
577    const fn from_access_path(access: &ExplainAccessPath) -> Self {
578        match access {
579            ExplainAccessPath::ByKey { .. } => Self::ByKey,
580            ExplainAccessPath::ByKeys { .. } => Self::ByKeys,
581            ExplainAccessPath::KeyRange { .. } => Self::KeyRange,
582            ExplainAccessPath::IndexPrefix { .. } => Self::IndexPrefix,
583            ExplainAccessPath::IndexMultiLookup { .. } => Self::IndexMultiLookup,
584            ExplainAccessPath::IndexBranchSet { .. } => Self::IndexBranchSet,
585            ExplainAccessPath::IndexRange { .. } => Self::IndexRange,
586            ExplainAccessPath::FullScan => Self::FullScan,
587            ExplainAccessPath::Union(_) => Self::Union,
588            ExplainAccessPath::Intersection(_) => Self::Intersection,
589        }
590    }
591
592    const fn code(self) -> &'static str {
593        match self {
594            Self::ByKey => "ByKey",
595            Self::ByKeys => "ByKeys",
596            Self::KeyRange => "KeyRange",
597            Self::IndexPrefix => "IndexPrefix",
598            Self::IndexMultiLookup => "IndexMultiLookup",
599            Self::IndexBranchSet => "IndexBranchSet",
600            Self::IndexRange => "IndexRange",
601            Self::FullScan => "FullScan",
602            Self::Union => "Union",
603            Self::Intersection => "Intersection",
604        }
605    }
606}
607
608/// Candidate summary recorded by the planner access-choice snapshot.
609#[derive(Clone, Debug, Eq, PartialEq)]
610pub struct ExplainAccessCandidateV1 {
611    /// Planner access label for the candidate route.
612    pub label: String,
613    /// Whether the candidate structurally satisfied all usable predicates.
614    pub exact: bool,
615    /// Whether the candidate uses a filtered index contract.
616    pub filtered: bool,
617    /// Number of range-bound fields recorded by the planner scorer.
618    pub range_bound_count: usize,
619    /// Whether candidate ordering is compatible with query ordering.
620    pub order_compatible: bool,
621    /// Residual burden class recorded by the planner.
622    pub residual_burden: &'static str,
623    /// Number of residual predicate terms recorded by the planner.
624    pub residual_predicate_terms: usize,
625}
626
627impl ExplainAccessCandidateV1 {
628    fn from_candidate(candidate: &AccessChoiceCandidateExplainSummary) -> Self {
629        Self {
630            label: candidate.label.clone(),
631            exact: candidate.exact,
632            filtered: candidate.filtered,
633            range_bound_count: candidate.range_bound_count,
634            order_compatible: candidate.order_compatible,
635            residual_burden: candidate.residual_burden.label(),
636            residual_predicate_terms: candidate.residual_predicate_terms,
637        }
638    }
639}
640
641/// Eligible alternative index name recorded by the planner.
642#[derive(Clone, Debug, Eq, PartialEq)]
643pub struct ExplainEligibleAlternativeV1 {
644    /// Semantic index name of the eligible alternative.
645    pub index_name: String,
646}
647
648/// Rejected index candidate summary recorded by the planner.
649#[derive(Clone, Debug, Eq, PartialEq)]
650pub struct ExplainRejectedIndexV1 {
651    /// Semantic index name when parsed from the planner rejection label.
652    pub index_name: Option<String>,
653    /// Planner-owned rejection reason code when parsed from the rejection label.
654    pub reason: Option<String>,
655    /// Original planner rejection label.
656    pub label: String,
657}
658
659impl ExplainRejectedIndexV1 {
660    fn from_rejection(rejection: &str) -> Self {
661        let (index_name, reason) = parse_rejected_index_label(rejection);
662
663        Self {
664            index_name,
665            reason,
666            label: rejection.to_string(),
667        }
668    }
669}
670
671/// Residual-work summary for the selected access route.
672#[derive(Clone, Debug, Eq, PartialEq)]
673pub struct ExplainResidualSummaryV1 {
674    /// Residual burden class for the selected access route.
675    pub burden_class: &'static str,
676    /// Whether any residual scalar filter expression survives access planning.
677    pub has_residual_filter: bool,
678    /// Whether any residual predicate model survives access planning.
679    pub has_residual_predicate: bool,
680    /// Number of predicate-like constraints structurally consumed by access.
681    pub access_bound_predicate_count: usize,
682    /// Number of residual predicate terms for the selected access route.
683    pub residual_predicate_count: usize,
684    /// Deprecated JSON compatibility mirror of `residual_predicate_count`.
685    pub predicate_terms: usize,
686}
687
688impl ExplainResidualSummaryV1 {
689    fn from_selected_access_and_candidate(
690        selected_access: &ExplainAccessPath,
691        selected_candidate: Option<&AccessChoiceCandidateExplainSummary>,
692    ) -> Self {
693        match selected_candidate {
694            Some(candidate) => Self {
695                burden_class: candidate.residual_burden.label(),
696                has_residual_filter: matches!(
697                    candidate.residual_burden,
698                    AccessChoiceResidualBurden::ScalarExpression
699                ),
700                has_residual_predicate: candidate.residual_predicate_terms > 0,
701                access_bound_predicate_count: access_bound_predicate_count(selected_access),
702                residual_predicate_count: candidate.residual_predicate_terms,
703                predicate_terms: candidate.residual_predicate_terms,
704            },
705            None => Self {
706                burden_class: AccessChoiceResidualBurden::None.label(),
707                has_residual_filter: false,
708                has_residual_predicate: false,
709                access_bound_predicate_count: access_bound_predicate_count(selected_access),
710                residual_predicate_count: 0,
711                predicate_terms: 0,
712            },
713        }
714    }
715}
716
717///
718/// ExplainPredicate
719///
720/// Deterministic projection of canonical predicate structure for explain output.
721/// This preserves normalized predicate shape used by hashing/fingerprints.
722///
723
724#[derive(Clone, Debug, Eq, PartialEq)]
725pub enum ExplainPredicate {
726    None,
727    True,
728    False,
729    And(Vec<Self>),
730    Or(Vec<Self>),
731    Not(Box<Self>),
732    Compare {
733        field: String,
734        op: CompareOp,
735        value: Value,
736        coercion: CoercionSpec,
737    },
738    CompareFields {
739        left_field: String,
740        op: CompareOp,
741        right_field: String,
742        coercion: CoercionSpec,
743    },
744    IsNull {
745        field: String,
746    },
747    IsNotNull {
748        field: String,
749    },
750    IsMissing {
751        field: String,
752    },
753    IsEmpty {
754        field: String,
755    },
756    IsNotEmpty {
757        field: String,
758    },
759    TextContains {
760        field: String,
761        value: Value,
762    },
763    TextContainsCi {
764        field: String,
765        value: Value,
766    },
767}
768
769///
770/// ExplainOrderBy
771///
772/// Deterministic projection of canonical ORDER BY shape.
773///
774
775#[derive(Clone, Debug, Eq, PartialEq)]
776pub enum ExplainOrderBy {
777    None,
778    Fields(Vec<ExplainOrder>),
779}
780
781///
782/// ExplainOrder
783///
784/// One canonical ORDER BY field + direction pair.
785///
786
787#[derive(Clone, Debug, Eq, PartialEq)]
788pub struct ExplainOrder {
789    pub(in crate::db) field: String,
790    pub(in crate::db) direction: OrderDirection,
791}
792
793impl ExplainOrder {
794    /// Borrow ORDER BY field name.
795    #[must_use]
796    pub const fn field(&self) -> &str {
797        self.field.as_str()
798    }
799
800    /// Return ORDER BY direction.
801    #[must_use]
802    pub const fn direction(&self) -> OrderDirection {
803        self.direction
804    }
805}
806
807///
808/// ExplainPagination
809///
810/// Explain-surface projection of pagination window configuration.
811///
812
813#[derive(Clone, Debug, Eq, PartialEq)]
814pub enum ExplainPagination {
815    None,
816    Page { limit: Option<u32>, offset: u32 },
817}
818
819///
820/// ExplainDeleteLimit
821///
822/// Explain-surface projection of delete-limit configuration.
823///
824
825#[derive(Clone, Debug, Eq, PartialEq)]
826pub enum ExplainDeleteLimit {
827    None,
828    Limit { max_rows: u32 },
829    Window { limit: Option<u32>, offset: u32 },
830}
831
832impl AccessPlannedQuery {
833    /// Produce a stable, deterministic explanation of this logical plan.
834    #[must_use]
835    pub(in crate::db) fn explain(&self) -> ExplainPlan {
836        self.explain_inner()
837    }
838
839    fn explain_inner(&self) -> ExplainPlan {
840        // Phase 1: project logical plan variant into scalar core + grouped metadata.
841        let (logical, grouping) = match &self.logical {
842            LogicalPlan::Scalar(logical) => (logical, ExplainGrouping::None),
843            LogicalPlan::Grouped(logical) => {
844                let grouped_strategy = grouped_plan_strategy(self).unwrap_or_else(|| {
845                    debug_assert!(
846                        grouped_plan_strategy(self).is_some(),
847                        "grouped logical explain projection requires planner-owned grouped strategy",
848                    );
849                    GroupedPlanStrategy::hash_group_with_aggregate_family(
850                        GroupedPlanFallbackReason::GroupKeyOrderUnavailable,
851                        GroupedPlanAggregateFamily::from_grouped_aggregates(
852                            logical.group.aggregates.as_slice(),
853                        ),
854                    )
855                });
856
857                (
858                    &logical.scalar,
859                    ExplainGrouping::Grouped {
860                        strategy: grouped_strategy.code(),
861                        fallback_reason: grouped_strategy
862                            .fallback_reason()
863                            .map(GroupedPlanFallbackReason::code),
864                        group_fields: logical
865                            .group
866                            .group_fields
867                            .iter()
868                            .map(|field_slot| ExplainGroupField {
869                                slot_index: field_slot.index(),
870                                field: field_slot.field().to_string(),
871                            })
872                            .collect(),
873                        aggregates: logical
874                            .group
875                            .aggregates
876                            .iter()
877                            .map(|aggregate| ExplainGroupAggregate {
878                                kind: aggregate.kind,
879                                target_field: aggregate.target_field().map(str::to_string),
880                                input_expr: aggregate
881                                    .input_expr()
882                                    .map(render_scalar_projection_expr_plan_label),
883                                filter_expr: aggregate
884                                    .filter_expr()
885                                    .map(render_scalar_projection_expr_plan_label),
886                                distinct: aggregate.distinct,
887                            })
888                            .collect(),
889                        having: explain_group_having(logical),
890                        max_groups: logical.group.execution.max_groups(),
891                        max_group_bytes: logical.group.execution.max_group_bytes(),
892                    },
893                )
894            }
895        };
896
897        // Phase 2: project scalar plan + access path into deterministic explain surface.
898        explain_scalar_inner(logical, grouping, &self.access, self.access_choice())
899    }
900}
901
902fn explain_group_having(logical: &crate::db::query::plan::GroupPlan) -> Option<ExplainGroupHaving> {
903    let expr = logical.effective_having_expr()?;
904
905    Some(ExplainGroupHaving {
906        expr: expr.into_owned(),
907    })
908}
909
910fn explain_scalar_inner<K>(
911    logical: &ScalarPlan,
912    grouping: ExplainGrouping,
913    access: &AccessPlan<K>,
914    access_choice: &AccessChoiceExplainSnapshot,
915) -> ExplainPlan
916where
917    K: KeyValueCodec,
918{
919    // Phase 1: consume canonical predicate model from planner-owned scalar semantics.
920    let filter_expr = logical
921        .filter_expr
922        .as_ref()
923        .map(render_scalar_filter_expr_plan_label);
924    let filter_expr_model = logical.filter_expr.clone();
925    let predicate_model = logical.predicate.clone();
926    let predicate = match &predicate_model {
927        Some(predicate) => ExplainPredicate::from_predicate(predicate),
928        None => ExplainPredicate::None,
929    };
930
931    // Phase 2: project scalar-plan fields into explain-specific enums.
932    let order_by = explain_order(logical.order.as_ref());
933    let order_pushdown = explain_order_pushdown();
934    let page = explain_page(logical.page.as_ref());
935    let delete_limit = explain_delete_limit(logical.delete_limit.as_ref());
936
937    // Phase 3: assemble one stable explain payload.
938    let access = explain_access_plan(access);
939    let access_decision = ExplainAccessDecisionV1::from_snapshot(&access, access_choice);
940
941    ExplainPlan {
942        mode: logical.mode,
943        access,
944        access_decision,
945        filter_expr,
946        filter_expr_model,
947        predicate,
948        predicate_model,
949        order_by,
950        distinct: logical.distinct,
951        grouping,
952        order_pushdown,
953        page,
954        delete_limit,
955        consistency: logical.consistency,
956    }
957}
958
959fn selected_candidate_summary<'a>(
960    selected_label: &str,
961    candidates: &'a [AccessChoiceCandidateExplainSummary],
962) -> Option<&'a AccessChoiceCandidateExplainSummary> {
963    candidates
964        .iter()
965        .find(|candidate| candidate.label == selected_label)
966        .or_else(|| (candidates.len() == 1).then(|| &candidates[0]))
967}
968
969const fn selected_index_name(access: &ExplainAccessPath) -> Option<&str> {
970    match access {
971        ExplainAccessPath::IndexPrefix { name, .. }
972        | ExplainAccessPath::IndexMultiLookup { name, .. }
973        | ExplainAccessPath::IndexBranchSet { name, .. }
974        | ExplainAccessPath::IndexRange { name, .. } => Some(name.as_str()),
975        ExplainAccessPath::ByKey { .. }
976        | ExplainAccessPath::ByKeys { .. }
977        | ExplainAccessPath::KeyRange { .. }
978        | ExplainAccessPath::FullScan
979        | ExplainAccessPath::Union(_)
980        | ExplainAccessPath::Intersection(_) => None,
981    }
982}
983
984fn access_bound_predicate_count(access: &ExplainAccessPath) -> usize {
985    match access {
986        ExplainAccessPath::ByKey { .. }
987        | ExplainAccessPath::ByKeys { .. }
988        | ExplainAccessPath::IndexMultiLookup { .. } => 1,
989        ExplainAccessPath::IndexBranchSet {
990            fixed_values,
991            branch_values,
992            ..
993        } => fixed_values.len() + usize::from(!branch_values.is_empty()),
994        ExplainAccessPath::KeyRange { .. } => 2,
995        ExplainAccessPath::IndexPrefix { prefix_len, .. } => *prefix_len,
996        ExplainAccessPath::IndexRange {
997            prefix_len,
998            lower,
999            upper,
1000            ..
1001        } => *prefix_len + bound_constraint_count(lower) + bound_constraint_count(upper),
1002        ExplainAccessPath::FullScan => 0,
1003        ExplainAccessPath::Union(children) | ExplainAccessPath::Intersection(children) => {
1004            children.iter().map(access_bound_predicate_count).sum()
1005        }
1006    }
1007}
1008
1009const fn bound_constraint_count(bound: &Bound<Value>) -> usize {
1010    match bound {
1011        Bound::Included(_) | Bound::Excluded(_) => 1,
1012        Bound::Unbounded => 0,
1013    }
1014}
1015
1016fn parse_rejected_index_label(rejection: &str) -> (Option<String>, Option<String>) {
1017    let Some(rest) = rejection.strip_prefix("index:") else {
1018        return (None, None);
1019    };
1020
1021    match rest.split_once('=') {
1022        Some((index_name, reason)) => (Some(index_name.to_string()), Some(reason.to_string())),
1023        None => (Some(rest.to_string()), None),
1024    }
1025}
1026
1027const fn explain_order_pushdown() -> ExplainOrderPushdown {
1028    // Query explain does not own physical pushdown feasibility routing.
1029    ExplainOrderPushdown::MissingModelContext
1030}
1031
1032impl ExplainPredicate {
1033    pub(in crate::db) fn from_predicate(predicate: &Predicate) -> Self {
1034        match predicate {
1035            Predicate::True => Self::True,
1036            Predicate::False => Self::False,
1037            Predicate::And(children) => {
1038                Self::And(children.iter().map(Self::from_predicate).collect())
1039            }
1040            Predicate::Or(children) => {
1041                Self::Or(children.iter().map(Self::from_predicate).collect())
1042            }
1043            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
1044            Predicate::Compare(compare) => Self::from_compare(compare),
1045            Predicate::CompareFields(compare) => Self::CompareFields {
1046                left_field: compare.left_field().to_string(),
1047                op: compare.op(),
1048                right_field: compare.right_field().to_string(),
1049                coercion: compare.coercion().clone(),
1050            },
1051            Predicate::IsNull { field } => Self::IsNull {
1052                field: field.clone(),
1053            },
1054            Predicate::IsNotNull { field } => Self::IsNotNull {
1055                field: field.clone(),
1056            },
1057            Predicate::IsMissing { field } => Self::IsMissing {
1058                field: field.clone(),
1059            },
1060            Predicate::IsEmpty { field } => Self::IsEmpty {
1061                field: field.clone(),
1062            },
1063            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
1064                field: field.clone(),
1065            },
1066            Predicate::TextContains { field, value } => Self::TextContains {
1067                field: field.clone(),
1068                value: value.clone(),
1069            },
1070            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
1071                field: field.clone(),
1072                value: value.clone(),
1073            },
1074        }
1075    }
1076
1077    fn from_compare(compare: &ComparePredicate) -> Self {
1078        Self::Compare {
1079            field: compare.field.clone(),
1080            op: compare.op,
1081            value: compare.value.clone(),
1082            coercion: compare.coercion.clone(),
1083        }
1084    }
1085}
1086
1087fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
1088    let Some(order) = order else {
1089        return ExplainOrderBy::None;
1090    };
1091
1092    if order.fields.is_empty() {
1093        return ExplainOrderBy::None;
1094    }
1095
1096    ExplainOrderBy::Fields(
1097        order
1098            .fields
1099            .iter()
1100            .map(|term| ExplainOrder {
1101                field: term.rendered_label(),
1102                direction: term.direction(),
1103            })
1104            .collect(),
1105    )
1106}
1107
1108const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
1109    match page {
1110        Some(page) => ExplainPagination::Page {
1111            limit: page.limit,
1112            offset: page.offset,
1113        },
1114        None => ExplainPagination::None,
1115    }
1116}
1117
1118const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
1119    match limit {
1120        Some(limit) if limit.offset == 0 => match limit.limit {
1121            Some(max_rows) => ExplainDeleteLimit::Limit { max_rows },
1122            None => ExplainDeleteLimit::Window {
1123                limit: None,
1124                offset: 0,
1125            },
1126        },
1127        Some(limit) => ExplainDeleteLimit::Window {
1128            limit: limit.limit,
1129            offset: limit.offset,
1130        },
1131        None => ExplainDeleteLimit::None,
1132    }
1133}
1134
1135fn write_logical_explain_json(explain: &ExplainPlan, out: &mut String) {
1136    let mut object = JsonWriter::begin_object(out);
1137    object.field_with("mode", |out| {
1138        let mut object = JsonWriter::begin_object(out);
1139        match explain.mode() {
1140            QueryMode::Load(spec) => {
1141                object.field_str("type", "Load");
1142                match spec.limit() {
1143                    Some(limit) => object.field_u64("limit", u64::from(limit)),
1144                    None => object.field_null("limit"),
1145                }
1146                object.field_u64("offset", u64::from(spec.offset()));
1147            }
1148            QueryMode::Delete(spec) => {
1149                object.field_str("type", "Delete");
1150                match spec.limit() {
1151                    Some(limit) => object.field_u64("limit", u64::from(limit)),
1152                    None => object.field_null("limit"),
1153                }
1154            }
1155        }
1156        object.finish();
1157    });
1158    object.field_with("access", |out| {
1159        write_access_json_detailed(explain.access(), out);
1160    });
1161    object.field_with("access_decision", |out| {
1162        write_access_decision_json(explain.access_decision(), out);
1163    });
1164    match explain.filter_expr() {
1165        Some(filter_expr) => object.field_str("filter_expr", filter_expr),
1166        None => object.field_null("filter_expr"),
1167    }
1168    object.field_value_debug("predicate", explain.predicate());
1169    object.field_value_debug("order_by", explain.order_by());
1170    object.field_bool("distinct", explain.distinct());
1171    object.field_value_debug("grouping", explain.grouping());
1172    object.field_value_debug("order_pushdown", explain.order_pushdown());
1173    object.field_with("page", |out| {
1174        let mut object = JsonWriter::begin_object(out);
1175        match explain.page() {
1176            ExplainPagination::None => {
1177                object.field_str("type", "None");
1178            }
1179            ExplainPagination::Page { limit, offset } => {
1180                object.field_str("type", "Page");
1181                match limit {
1182                    Some(limit) => object.field_u64("limit", u64::from(*limit)),
1183                    None => object.field_null("limit"),
1184                }
1185                object.field_u64("offset", u64::from(*offset));
1186            }
1187        }
1188        object.finish();
1189    });
1190    object.field_with("delete_limit", |out| {
1191        let mut object = JsonWriter::begin_object(out);
1192        match explain.delete_limit() {
1193            ExplainDeleteLimit::None => {
1194                object.field_str("type", "None");
1195            }
1196            ExplainDeleteLimit::Limit { max_rows } => {
1197                object.field_str("type", "Limit");
1198                object.field_u64("max_rows", u64::from(*max_rows));
1199            }
1200            ExplainDeleteLimit::Window { limit, offset } => {
1201                object.field_str("type", "Window");
1202                object.field_with("limit", |out| match limit {
1203                    Some(limit) => out.push_str(&limit.to_string()),
1204                    None => out.push_str("null"),
1205                });
1206                object.field_u64("offset", u64::from(*offset));
1207            }
1208        }
1209        object.finish();
1210    });
1211    object.field_value_debug("consistency", &explain.consistency());
1212    object.finish();
1213}
1214
1215fn write_access_decision_json(decision: &ExplainAccessDecisionV1, out: &mut String) {
1216    let mut object = JsonWriter::begin_object(out);
1217    object.field_u64("schema_version", u64::from(decision.schema_version));
1218    object.field_with("selected", |out| {
1219        let mut selected = JsonWriter::begin_object(out);
1220        selected.field_str("kind", decision.selected.kind.code());
1221        match decision.selected.index_name.as_deref() {
1222            Some(index_name) => selected.field_str("index_name", index_name),
1223            None => selected.field_null("index_name"),
1224        }
1225        selected.field_str("label", decision.selected.label.as_str());
1226        selected.field_str("reason", decision.selected.reason);
1227        selected.finish();
1228    });
1229    object.field_with("candidates", |out| {
1230        out.push('[');
1231        for (index, candidate) in decision.candidates.iter().enumerate() {
1232            if index > 0 {
1233                out.push(',');
1234            }
1235            write_access_candidate_json(candidate, out);
1236        }
1237        out.push(']');
1238    });
1239    object.field_with("alternatives", |out| {
1240        out.push('[');
1241        for (index, alternative) in decision.alternatives.iter().enumerate() {
1242            if index > 0 {
1243                out.push(',');
1244            }
1245            let mut object = JsonWriter::begin_object(out);
1246            object.field_str("index_name", alternative.index_name.as_str());
1247            object.finish();
1248        }
1249        out.push(']');
1250    });
1251    object.field_with("rejections", |out| {
1252        out.push('[');
1253        for (index, rejection) in decision.rejections.iter().enumerate() {
1254            if index > 0 {
1255                out.push(',');
1256            }
1257            let mut object = JsonWriter::begin_object(out);
1258            match rejection.index_name.as_deref() {
1259                Some(index_name) => object.field_str("index_name", index_name),
1260                None => object.field_null("index_name"),
1261            }
1262            match rejection.reason.as_deref() {
1263                Some(reason) => object.field_str("reason", reason),
1264                None => object.field_null("reason"),
1265            }
1266            object.field_str("label", rejection.label.as_str());
1267            object.finish();
1268        }
1269        out.push(']');
1270    });
1271    object.field_with("residual", |out| {
1272        let mut residual = JsonWriter::begin_object(out);
1273        residual.field_str("burden_class", decision.residual.burden_class);
1274        residual.field_bool("has_residual_filter", decision.residual.has_residual_filter);
1275        residual.field_bool(
1276            "has_residual_predicate",
1277            decision.residual.has_residual_predicate,
1278        );
1279        residual.field_u64(
1280            "access_bound_predicate_count",
1281            decision.residual.access_bound_predicate_count as u64,
1282        );
1283        residual.field_u64(
1284            "residual_predicate_count",
1285            decision.residual.residual_predicate_count as u64,
1286        );
1287        residual.field_u64("predicate_terms", decision.residual.predicate_terms as u64);
1288        residual.finish();
1289    });
1290    object.finish();
1291}
1292
1293fn write_access_candidate_json(candidate: &ExplainAccessCandidateV1, out: &mut String) {
1294    let mut object = JsonWriter::begin_object(out);
1295    object.field_str("label", candidate.label.as_str());
1296    object.field_bool("exact", candidate.exact);
1297    object.field_bool("filtered", candidate.filtered);
1298    object.field_u64("range_bound_count", candidate.range_bound_count as u64);
1299    object.field_bool("order_compatible", candidate.order_compatible);
1300    object.field_str("residual_burden", candidate.residual_burden);
1301    object.field_u64(
1302        "residual_predicate_terms",
1303        candidate.residual_predicate_terms as u64,
1304    );
1305    object.finish();
1306}