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