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    VariablePrefixSuffixOrderUnsupported {
403        index: String,
404        prefix_len: usize,
405        expected_full: Vec<String>,
406        actual: Vec<String>,
407    },
408}
409
410///
411/// ExplainAccessPath
412///
413/// Deterministic projection of logical access path shape for diagnostics.
414/// Mirrors planner-selected structural paths without runtime cursor state.
415///
416
417#[derive(Clone, Debug, Eq, PartialEq)]
418pub enum ExplainAccessPath {
419    ByKey {
420        key: Value,
421    },
422    ByKeys {
423        keys: Vec<Value>,
424    },
425    KeyRange {
426        start: Value,
427        end: Value,
428    },
429    IndexPrefix {
430        name: String,
431        fields: Vec<String>,
432        prefix_len: usize,
433        values: Vec<Value>,
434    },
435    IndexMultiLookup {
436        name: String,
437        fields: Vec<String>,
438        values: Vec<Value>,
439    },
440    IndexBranchSet {
441        name: String,
442        fields: Vec<String>,
443        fixed_values: Vec<Value>,
444        branch_values: Vec<Value>,
445        branch_field: Option<String>,
446        ordered_suffix: String,
447    },
448    IndexRange {
449        name: String,
450        fields: Vec<String>,
451        prefix_len: usize,
452        prefix: Vec<Value>,
453        lower: Bound<Value>,
454        upper: Bound<Value>,
455    },
456    FullScan,
457    Union(Vec<Self>),
458    Intersection(Vec<Self>),
459}
460
461/// Stable JSON-facing access-decision projection for logical EXPLAIN.
462///
463/// This DTO is derived from the planner-owned access-choice snapshot and the
464/// selected explain access path. It is not an optimizer model and does not
465/// participate in access selection.
466#[derive(Clone, Debug, Eq, PartialEq)]
467pub struct ExplainAccessDecisionV1 {
468    /// Schema version for this access-decision payload shape.
469    pub schema_version: u32,
470    /// Selected access path summary.
471    pub selected: ExplainSelectedAccessV1,
472    /// Planner candidate summaries recorded for the selected access family.
473    pub candidates: Vec<ExplainAccessCandidateV1>,
474    /// Eligible alternatives not selected by the planner.
475    pub alternatives: Vec<ExplainEligibleAlternativeV1>,
476    /// Rejected index candidates and planner-owned reason strings.
477    pub rejections: Vec<ExplainRejectedIndexV1>,
478    /// Residual-work summary for the selected route when available.
479    pub residual: ExplainResidualSummaryV1,
480}
481
482impl ExplainAccessDecisionV1 {
483    const SCHEMA_VERSION: u32 = 1;
484
485    fn from_snapshot(
486        selected_access: &ExplainAccessPath,
487        snapshot: &AccessChoiceExplainSnapshot,
488    ) -> Self {
489        let selected_label = explain_access_strategy_label(selected_access);
490        let selected_candidate = selected_candidate_summary(&selected_label, &snapshot.candidates);
491
492        Self {
493            schema_version: Self::SCHEMA_VERSION,
494            selected: ExplainSelectedAccessV1 {
495                kind: ExplainAccessDecisionKind::from_access_path(selected_access),
496                index_name: selected_index_name(selected_access).map(ToOwned::to_owned),
497                label: selected_label,
498                reason: snapshot.chosen_reason().code(),
499            },
500            candidates: snapshot
501                .candidates
502                .iter()
503                .map(ExplainAccessCandidateV1::from_candidate)
504                .collect(),
505            alternatives: snapshot
506                .alternatives
507                .iter()
508                .map(|index_name| ExplainEligibleAlternativeV1 {
509                    index_name: index_name.clone(),
510                })
511                .collect(),
512            rejections: snapshot
513                .rejected
514                .iter()
515                .map(|rejection| ExplainRejectedIndexV1::from_rejection(rejection))
516                .collect(),
517            residual: ExplainResidualSummaryV1::from_selected_access_and_candidate(
518                selected_access,
519                selected_candidate,
520            ),
521        }
522    }
523
524    fn render_compact_summary(&self) -> String {
525        let index = self
526            .selected
527            .index_name
528            .as_deref()
529            .map_or("none", |index| index);
530
531        format!(
532            "kind={} index={} reason={} residual={} candidates={} alternatives={} rejections={}",
533            self.selected.kind.code(),
534            index,
535            self.selected.reason,
536            self.residual.burden_class,
537            self.candidates.len(),
538            self.alternatives.len(),
539            self.rejections.len(),
540        )
541    }
542}
543
544/// Selected access path summary inside an access-decision explain payload.
545#[derive(Clone, Debug, Eq, PartialEq)]
546pub struct ExplainSelectedAccessV1 {
547    /// Selected access kind.
548    pub kind: ExplainAccessDecisionKind,
549    /// Selected semantic index name, when the selected route is index-backed.
550    pub index_name: Option<String>,
551    /// Planner access label used for candidate matching and diagnostics.
552    pub label: String,
553    /// Planner-owned selected reason code.
554    pub reason: &'static str,
555}
556
557/// Stable access-kind code used by the access-decision explain payload.
558#[derive(Clone, Copy, Debug, Eq, PartialEq)]
559pub enum ExplainAccessDecisionKind {
560    /// Direct primary-key lookup.
561    ByKey,
562    /// Multiple primary-key lookup.
563    ByKeys,
564    /// Primary-key range lookup.
565    KeyRange,
566    /// Secondary-index equality prefix lookup.
567    IndexPrefix,
568    /// Secondary-index multi-value lookup.
569    IndexMultiLookup,
570    /// Branch-aware secondary-index composite prefix lookup.
571    IndexBranchSet,
572    /// Secondary-index range lookup.
573    IndexRange,
574    /// Full entity scan.
575    FullScan,
576    /// Union access route.
577    Union,
578    /// Intersection access route.
579    Intersection,
580}
581
582impl ExplainAccessDecisionKind {
583    const fn from_access_path(access: &ExplainAccessPath) -> Self {
584        match access {
585            ExplainAccessPath::ByKey { .. } => Self::ByKey,
586            ExplainAccessPath::ByKeys { .. } => Self::ByKeys,
587            ExplainAccessPath::KeyRange { .. } => Self::KeyRange,
588            ExplainAccessPath::IndexPrefix { .. } => Self::IndexPrefix,
589            ExplainAccessPath::IndexMultiLookup { .. } => Self::IndexMultiLookup,
590            ExplainAccessPath::IndexBranchSet { .. } => Self::IndexBranchSet,
591            ExplainAccessPath::IndexRange { .. } => Self::IndexRange,
592            ExplainAccessPath::FullScan => Self::FullScan,
593            ExplainAccessPath::Union(_) => Self::Union,
594            ExplainAccessPath::Intersection(_) => Self::Intersection,
595        }
596    }
597
598    const fn code(self) -> &'static str {
599        match self {
600            Self::ByKey => "ByKey",
601            Self::ByKeys => "ByKeys",
602            Self::KeyRange => "KeyRange",
603            Self::IndexPrefix => "IndexPrefix",
604            Self::IndexMultiLookup => "IndexMultiLookup",
605            Self::IndexBranchSet => "IndexBranchSet",
606            Self::IndexRange => "IndexRange",
607            Self::FullScan => "FullScan",
608            Self::Union => "Union",
609            Self::Intersection => "Intersection",
610        }
611    }
612}
613
614/// Candidate summary recorded by the planner access-choice snapshot.
615#[derive(Clone, Debug, Eq, PartialEq)]
616pub struct ExplainAccessCandidateV1 {
617    /// Planner access label for the candidate route.
618    pub label: String,
619    /// Whether the candidate structurally satisfied all usable predicates.
620    pub exact: bool,
621    /// Whether the candidate uses a filtered index contract.
622    pub filtered: bool,
623    /// Number of range-bound fields recorded by the planner scorer.
624    pub range_bound_count: usize,
625    /// Whether candidate ordering is compatible with query ordering.
626    pub order_compatible: bool,
627    /// Residual burden class recorded by the planner.
628    pub residual_burden: &'static str,
629    /// Number of residual predicate terms recorded by the planner.
630    pub residual_predicate_terms: usize,
631}
632
633impl ExplainAccessCandidateV1 {
634    fn from_candidate(candidate: &AccessChoiceCandidateExplainSummary) -> Self {
635        Self {
636            label: candidate.label.clone(),
637            exact: candidate.exact,
638            filtered: candidate.filtered,
639            range_bound_count: candidate.range_bound_count,
640            order_compatible: candidate.order_compatible,
641            residual_burden: candidate.residual_burden.label(),
642            residual_predicate_terms: candidate.residual_predicate_terms,
643        }
644    }
645}
646
647/// Eligible alternative index name recorded by the planner.
648#[derive(Clone, Debug, Eq, PartialEq)]
649pub struct ExplainEligibleAlternativeV1 {
650    /// Semantic index name of the eligible alternative.
651    pub index_name: String,
652}
653
654/// Rejected index candidate summary recorded by the planner.
655#[derive(Clone, Debug, Eq, PartialEq)]
656pub struct ExplainRejectedIndexV1 {
657    /// Semantic index name when parsed from the planner rejection label.
658    pub index_name: Option<String>,
659    /// Planner-owned rejection reason code when parsed from the rejection label.
660    pub reason: Option<String>,
661    /// Original planner rejection label.
662    pub label: String,
663}
664
665impl ExplainRejectedIndexV1 {
666    fn from_rejection(rejection: &str) -> Self {
667        let (index_name, reason) = parse_rejected_index_label(rejection);
668
669        Self {
670            index_name,
671            reason,
672            label: rejection.to_string(),
673        }
674    }
675}
676
677/// Residual-work summary for the selected access route.
678#[derive(Clone, Debug, Eq, PartialEq)]
679pub struct ExplainResidualSummaryV1 {
680    /// Residual burden class for the selected access route.
681    pub burden_class: &'static str,
682    /// Whether any residual scalar filter expression survives access planning.
683    pub has_residual_filter: bool,
684    /// Whether any residual predicate model survives access planning.
685    pub has_residual_predicate: bool,
686    /// Number of predicate-like constraints structurally consumed by access.
687    pub access_bound_predicate_count: usize,
688    /// Number of residual predicate terms for the selected access route.
689    pub residual_predicate_count: usize,
690    /// Deprecated JSON compatibility mirror of `residual_predicate_count`.
691    pub predicate_terms: usize,
692}
693
694impl ExplainResidualSummaryV1 {
695    fn from_selected_access_and_candidate(
696        selected_access: &ExplainAccessPath,
697        selected_candidate: Option<&AccessChoiceCandidateExplainSummary>,
698    ) -> Self {
699        match selected_candidate {
700            Some(candidate) => Self {
701                burden_class: candidate.residual_burden.label(),
702                has_residual_filter: matches!(
703                    candidate.residual_burden,
704                    AccessChoiceResidualBurden::ScalarExpression
705                ),
706                has_residual_predicate: candidate.residual_predicate_terms > 0,
707                access_bound_predicate_count: access_bound_predicate_count(selected_access),
708                residual_predicate_count: candidate.residual_predicate_terms,
709                predicate_terms: candidate.residual_predicate_terms,
710            },
711            None => Self {
712                burden_class: AccessChoiceResidualBurden::None.label(),
713                has_residual_filter: false,
714                has_residual_predicate: false,
715                access_bound_predicate_count: access_bound_predicate_count(selected_access),
716                residual_predicate_count: 0,
717                predicate_terms: 0,
718            },
719        }
720    }
721}
722
723///
724/// ExplainPredicate
725///
726/// Deterministic projection of canonical predicate structure for explain output.
727/// This preserves normalized predicate shape used by hashing/fingerprints.
728///
729
730#[derive(Clone, Debug, Eq, PartialEq)]
731pub enum ExplainPredicate {
732    None,
733    True,
734    False,
735    And(Vec<Self>),
736    Or(Vec<Self>),
737    Not(Box<Self>),
738    Compare {
739        field: String,
740        op: CompareOp,
741        value: Value,
742        coercion: CoercionSpec,
743    },
744    CompareFields {
745        left_field: String,
746        op: CompareOp,
747        right_field: String,
748        coercion: CoercionSpec,
749    },
750    IsNull {
751        field: String,
752    },
753    IsNotNull {
754        field: String,
755    },
756    IsMissing {
757        field: String,
758    },
759    IsEmpty {
760        field: String,
761    },
762    IsNotEmpty {
763        field: String,
764    },
765    TextContains {
766        field: String,
767        value: Value,
768    },
769    TextContainsCi {
770        field: String,
771        value: Value,
772    },
773}
774
775///
776/// ExplainOrderBy
777///
778/// Deterministic projection of canonical ORDER BY shape.
779///
780
781#[derive(Clone, Debug, Eq, PartialEq)]
782pub enum ExplainOrderBy {
783    None,
784    Fields(Vec<ExplainOrder>),
785}
786
787///
788/// ExplainOrder
789///
790/// One canonical ORDER BY field + direction pair.
791///
792
793#[derive(Clone, Debug, Eq, PartialEq)]
794pub struct ExplainOrder {
795    pub(in crate::db) field: String,
796    pub(in crate::db) direction: OrderDirection,
797}
798
799impl ExplainOrder {
800    /// Borrow ORDER BY field name.
801    #[must_use]
802    pub const fn field(&self) -> &str {
803        self.field.as_str()
804    }
805
806    /// Return ORDER BY direction.
807    #[must_use]
808    pub const fn direction(&self) -> OrderDirection {
809        self.direction
810    }
811}
812
813///
814/// ExplainPagination
815///
816/// Explain-surface projection of pagination window configuration.
817///
818
819#[derive(Clone, Debug, Eq, PartialEq)]
820pub enum ExplainPagination {
821    None,
822    Page { limit: Option<u32>, offset: u32 },
823}
824
825///
826/// ExplainDeleteLimit
827///
828/// Explain-surface projection of delete-limit configuration.
829///
830
831#[derive(Clone, Debug, Eq, PartialEq)]
832pub enum ExplainDeleteLimit {
833    None,
834    Limit { max_rows: u32 },
835    Window { limit: Option<u32>, offset: u32 },
836}
837
838impl AccessPlannedQuery {
839    /// Produce a stable, deterministic explanation of this logical plan.
840    #[must_use]
841    pub(in crate::db) fn explain(&self) -> ExplainPlan {
842        self.explain_inner()
843    }
844
845    fn explain_inner(&self) -> ExplainPlan {
846        // Phase 1: project logical plan variant into scalar core + grouped metadata.
847        let (logical, grouping) = match &self.logical {
848            LogicalPlan::Scalar(logical) => (logical, ExplainGrouping::None),
849            LogicalPlan::Grouped(logical) => {
850                let grouped_strategy = grouped_plan_strategy(self).unwrap_or_else(|| {
851                    debug_assert!(
852                        grouped_plan_strategy(self).is_some(),
853                        "grouped logical explain projection requires planner-owned grouped strategy",
854                    );
855                    GroupedPlanStrategy::hash_group_with_aggregate_family(
856                        GroupedPlanFallbackReason::GroupKeyOrderUnavailable,
857                        GroupedPlanAggregateFamily::from_grouped_aggregates(
858                            logical.group.aggregates.as_slice(),
859                        ),
860                    )
861                });
862
863                (
864                    &logical.scalar,
865                    ExplainGrouping::Grouped {
866                        strategy: grouped_strategy.code(),
867                        fallback_reason: grouped_strategy
868                            .fallback_reason()
869                            .map(GroupedPlanFallbackReason::code),
870                        group_fields: logical
871                            .group
872                            .group_fields
873                            .iter()
874                            .map(|field_slot| ExplainGroupField {
875                                slot_index: field_slot.index(),
876                                field: field_slot.field().to_string(),
877                            })
878                            .collect(),
879                        aggregates: logical
880                            .group
881                            .aggregates
882                            .iter()
883                            .map(|aggregate| ExplainGroupAggregate {
884                                kind: aggregate.kind,
885                                target_field: aggregate.target_field().map(str::to_string),
886                                input_expr: aggregate
887                                    .input_expr()
888                                    .map(render_scalar_projection_expr_plan_label),
889                                filter_expr: aggregate
890                                    .filter_expr()
891                                    .map(render_scalar_projection_expr_plan_label),
892                                distinct: aggregate.distinct,
893                            })
894                            .collect(),
895                        having: explain_group_having(logical),
896                        max_groups: logical.group.execution.max_groups(),
897                        max_group_bytes: logical.group.execution.max_group_bytes(),
898                    },
899                )
900            }
901        };
902
903        // Phase 2: project scalar plan + access path into deterministic explain surface.
904        explain_scalar_inner(logical, grouping, &self.access, self.access_choice())
905    }
906}
907
908fn explain_group_having(logical: &crate::db::query::plan::GroupPlan) -> Option<ExplainGroupHaving> {
909    let expr = logical.effective_having_expr()?;
910
911    Some(ExplainGroupHaving {
912        expr: expr.into_owned(),
913    })
914}
915
916fn explain_scalar_inner<K>(
917    logical: &ScalarPlan,
918    grouping: ExplainGrouping,
919    access: &AccessPlan<K>,
920    access_choice: &AccessChoiceExplainSnapshot,
921) -> ExplainPlan
922where
923    K: KeyValueCodec,
924{
925    // Phase 1: consume canonical predicate model from planner-owned scalar semantics.
926    let filter_expr = logical
927        .filter_expr
928        .as_ref()
929        .map(render_scalar_filter_expr_plan_label);
930    let filter_expr_model = logical.filter_expr.clone();
931    let predicate_model = logical.predicate.clone();
932    let predicate = match &predicate_model {
933        Some(predicate) => ExplainPredicate::from_predicate(predicate),
934        None => ExplainPredicate::None,
935    };
936
937    // Phase 2: project scalar-plan fields into explain-specific enums.
938    let order_by = explain_order(logical.order.as_ref());
939    let order_pushdown = explain_order_pushdown();
940    let page = explain_page(logical.page.as_ref());
941    let delete_limit = explain_delete_limit(logical.delete_limit.as_ref());
942
943    // Phase 3: assemble one stable explain payload.
944    let access = explain_access_plan(access);
945    let access_decision = ExplainAccessDecisionV1::from_snapshot(&access, access_choice);
946
947    ExplainPlan {
948        mode: logical.mode,
949        access,
950        access_decision,
951        filter_expr,
952        filter_expr_model,
953        predicate,
954        predicate_model,
955        order_by,
956        distinct: logical.distinct,
957        grouping,
958        order_pushdown,
959        page,
960        delete_limit,
961        consistency: logical.consistency,
962    }
963}
964
965fn selected_candidate_summary<'a>(
966    selected_label: &str,
967    candidates: &'a [AccessChoiceCandidateExplainSummary],
968) -> Option<&'a AccessChoiceCandidateExplainSummary> {
969    candidates
970        .iter()
971        .find(|candidate| candidate.label == selected_label)
972        .or_else(|| (candidates.len() == 1).then(|| &candidates[0]))
973}
974
975const fn selected_index_name(access: &ExplainAccessPath) -> Option<&str> {
976    match access {
977        ExplainAccessPath::IndexPrefix { name, .. }
978        | ExplainAccessPath::IndexMultiLookup { name, .. }
979        | ExplainAccessPath::IndexBranchSet { name, .. }
980        | ExplainAccessPath::IndexRange { name, .. } => Some(name.as_str()),
981        ExplainAccessPath::ByKey { .. }
982        | ExplainAccessPath::ByKeys { .. }
983        | ExplainAccessPath::KeyRange { .. }
984        | ExplainAccessPath::FullScan
985        | ExplainAccessPath::Union(_)
986        | ExplainAccessPath::Intersection(_) => None,
987    }
988}
989
990fn access_bound_predicate_count(access: &ExplainAccessPath) -> usize {
991    match access {
992        ExplainAccessPath::ByKey { .. }
993        | ExplainAccessPath::ByKeys { .. }
994        | ExplainAccessPath::IndexMultiLookup { .. } => 1,
995        ExplainAccessPath::IndexBranchSet {
996            fixed_values,
997            branch_values,
998            ..
999        } => fixed_values.len() + usize::from(!branch_values.is_empty()),
1000        ExplainAccessPath::KeyRange { .. } => 2,
1001        ExplainAccessPath::IndexPrefix { prefix_len, .. } => *prefix_len,
1002        ExplainAccessPath::IndexRange {
1003            prefix_len,
1004            lower,
1005            upper,
1006            ..
1007        } => *prefix_len + bound_constraint_count(lower) + bound_constraint_count(upper),
1008        ExplainAccessPath::FullScan => 0,
1009        ExplainAccessPath::Union(children) | ExplainAccessPath::Intersection(children) => {
1010            children.iter().map(access_bound_predicate_count).sum()
1011        }
1012    }
1013}
1014
1015const fn bound_constraint_count(bound: &Bound<Value>) -> usize {
1016    match bound {
1017        Bound::Included(_) | Bound::Excluded(_) => 1,
1018        Bound::Unbounded => 0,
1019    }
1020}
1021
1022fn parse_rejected_index_label(rejection: &str) -> (Option<String>, Option<String>) {
1023    let Some(rest) = rejection.strip_prefix("index:") else {
1024        return (None, None);
1025    };
1026
1027    match rest.split_once('=') {
1028        Some((index_name, reason)) => (Some(index_name.to_string()), Some(reason.to_string())),
1029        None => (Some(rest.to_string()), None),
1030    }
1031}
1032
1033const fn explain_order_pushdown() -> ExplainOrderPushdown {
1034    // Query explain does not own physical pushdown feasibility routing.
1035    ExplainOrderPushdown::MissingModelContext
1036}
1037
1038impl ExplainPredicate {
1039    pub(in crate::db) fn from_predicate(predicate: &Predicate) -> Self {
1040        match predicate {
1041            Predicate::True => Self::True,
1042            Predicate::False => Self::False,
1043            Predicate::And(children) => {
1044                Self::And(children.iter().map(Self::from_predicate).collect())
1045            }
1046            Predicate::Or(children) => {
1047                Self::Or(children.iter().map(Self::from_predicate).collect())
1048            }
1049            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
1050            Predicate::Compare(compare) => Self::from_compare(compare),
1051            Predicate::CompareFields(compare) => Self::CompareFields {
1052                left_field: compare.left_field().to_string(),
1053                op: compare.op(),
1054                right_field: compare.right_field().to_string(),
1055                coercion: compare.coercion().clone(),
1056            },
1057            Predicate::IsNull { field } => Self::IsNull {
1058                field: field.clone(),
1059            },
1060            Predicate::IsNotNull { field } => Self::IsNotNull {
1061                field: field.clone(),
1062            },
1063            Predicate::IsMissing { field } => Self::IsMissing {
1064                field: field.clone(),
1065            },
1066            Predicate::IsEmpty { field } => Self::IsEmpty {
1067                field: field.clone(),
1068            },
1069            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
1070                field: field.clone(),
1071            },
1072            Predicate::TextContains { field, value } => Self::TextContains {
1073                field: field.clone(),
1074                value: value.clone(),
1075            },
1076            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
1077                field: field.clone(),
1078                value: value.clone(),
1079            },
1080        }
1081    }
1082
1083    fn from_compare(compare: &ComparePredicate) -> Self {
1084        Self::Compare {
1085            field: compare.field.clone(),
1086            op: compare.op,
1087            value: compare.value.clone(),
1088            coercion: compare.coercion.clone(),
1089        }
1090    }
1091}
1092
1093fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
1094    let Some(order) = order else {
1095        return ExplainOrderBy::None;
1096    };
1097
1098    if order.fields.is_empty() {
1099        return ExplainOrderBy::None;
1100    }
1101
1102    ExplainOrderBy::Fields(
1103        order
1104            .fields
1105            .iter()
1106            .map(|term| ExplainOrder {
1107                field: term.rendered_label(),
1108                direction: term.direction(),
1109            })
1110            .collect(),
1111    )
1112}
1113
1114const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
1115    match page {
1116        Some(page) => ExplainPagination::Page {
1117            limit: page.limit,
1118            offset: page.offset,
1119        },
1120        None => ExplainPagination::None,
1121    }
1122}
1123
1124const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
1125    match limit {
1126        Some(limit) if limit.offset == 0 => match limit.limit {
1127            Some(max_rows) => ExplainDeleteLimit::Limit { max_rows },
1128            None => ExplainDeleteLimit::Window {
1129                limit: None,
1130                offset: 0,
1131            },
1132        },
1133        Some(limit) => ExplainDeleteLimit::Window {
1134            limit: limit.limit,
1135            offset: limit.offset,
1136        },
1137        None => ExplainDeleteLimit::None,
1138    }
1139}
1140
1141fn write_logical_explain_json(explain: &ExplainPlan, out: &mut String) {
1142    let mut object = JsonWriter::begin_object(out);
1143    object.field_with("mode", |out| {
1144        let mut object = JsonWriter::begin_object(out);
1145        match explain.mode() {
1146            QueryMode::Load(spec) => {
1147                object.field_str("type", "Load");
1148                match spec.limit() {
1149                    Some(limit) => object.field_u64("limit", u64::from(limit)),
1150                    None => object.field_null("limit"),
1151                }
1152                object.field_u64("offset", u64::from(spec.offset()));
1153            }
1154            QueryMode::Delete(spec) => {
1155                object.field_str("type", "Delete");
1156                match spec.limit() {
1157                    Some(limit) => object.field_u64("limit", u64::from(limit)),
1158                    None => object.field_null("limit"),
1159                }
1160            }
1161        }
1162        object.finish();
1163    });
1164    object.field_with("access", |out| {
1165        write_access_json_detailed(explain.access(), out);
1166    });
1167    object.field_with("access_decision", |out| {
1168        write_access_decision_json(explain.access_decision(), out);
1169    });
1170    match explain.filter_expr() {
1171        Some(filter_expr) => object.field_str("filter_expr", filter_expr),
1172        None => object.field_null("filter_expr"),
1173    }
1174    object.field_value_debug("predicate", explain.predicate());
1175    object.field_value_debug("order_by", explain.order_by());
1176    object.field_bool("distinct", explain.distinct());
1177    object.field_value_debug("grouping", explain.grouping());
1178    object.field_value_debug("order_pushdown", explain.order_pushdown());
1179    object.field_with("page", |out| {
1180        let mut object = JsonWriter::begin_object(out);
1181        match explain.page() {
1182            ExplainPagination::None => {
1183                object.field_str("type", "None");
1184            }
1185            ExplainPagination::Page { limit, offset } => {
1186                object.field_str("type", "Page");
1187                match limit {
1188                    Some(limit) => object.field_u64("limit", u64::from(*limit)),
1189                    None => object.field_null("limit"),
1190                }
1191                object.field_u64("offset", u64::from(*offset));
1192            }
1193        }
1194        object.finish();
1195    });
1196    object.field_with("delete_limit", |out| {
1197        let mut object = JsonWriter::begin_object(out);
1198        match explain.delete_limit() {
1199            ExplainDeleteLimit::None => {
1200                object.field_str("type", "None");
1201            }
1202            ExplainDeleteLimit::Limit { max_rows } => {
1203                object.field_str("type", "Limit");
1204                object.field_u64("max_rows", u64::from(*max_rows));
1205            }
1206            ExplainDeleteLimit::Window { limit, offset } => {
1207                object.field_str("type", "Window");
1208                object.field_with("limit", |out| match limit {
1209                    Some(limit) => out.push_str(&limit.to_string()),
1210                    None => out.push_str("null"),
1211                });
1212                object.field_u64("offset", u64::from(*offset));
1213            }
1214        }
1215        object.finish();
1216    });
1217    object.field_value_debug("consistency", &explain.consistency());
1218    object.finish();
1219}
1220
1221fn write_access_decision_json(decision: &ExplainAccessDecisionV1, out: &mut String) {
1222    let mut object = JsonWriter::begin_object(out);
1223    object.field_u64("schema_version", u64::from(decision.schema_version));
1224    object.field_with("selected", |out| {
1225        let mut selected = JsonWriter::begin_object(out);
1226        selected.field_str("kind", decision.selected.kind.code());
1227        match decision.selected.index_name.as_deref() {
1228            Some(index_name) => selected.field_str("index_name", index_name),
1229            None => selected.field_null("index_name"),
1230        }
1231        selected.field_str("label", decision.selected.label.as_str());
1232        selected.field_str("reason", decision.selected.reason);
1233        selected.finish();
1234    });
1235    object.field_with("candidates", |out| {
1236        out.push('[');
1237        for (index, candidate) in decision.candidates.iter().enumerate() {
1238            if index > 0 {
1239                out.push(',');
1240            }
1241            write_access_candidate_json(candidate, out);
1242        }
1243        out.push(']');
1244    });
1245    object.field_with("alternatives", |out| {
1246        out.push('[');
1247        for (index, alternative) in decision.alternatives.iter().enumerate() {
1248            if index > 0 {
1249                out.push(',');
1250            }
1251            let mut object = JsonWriter::begin_object(out);
1252            object.field_str("index_name", alternative.index_name.as_str());
1253            object.finish();
1254        }
1255        out.push(']');
1256    });
1257    object.field_with("rejections", |out| {
1258        out.push('[');
1259        for (index, rejection) in decision.rejections.iter().enumerate() {
1260            if index > 0 {
1261                out.push(',');
1262            }
1263            let mut object = JsonWriter::begin_object(out);
1264            match rejection.index_name.as_deref() {
1265                Some(index_name) => object.field_str("index_name", index_name),
1266                None => object.field_null("index_name"),
1267            }
1268            match rejection.reason.as_deref() {
1269                Some(reason) => object.field_str("reason", reason),
1270                None => object.field_null("reason"),
1271            }
1272            object.field_str("label", rejection.label.as_str());
1273            object.finish();
1274        }
1275        out.push(']');
1276    });
1277    object.field_with("residual", |out| {
1278        let mut residual = JsonWriter::begin_object(out);
1279        residual.field_str("burden_class", decision.residual.burden_class);
1280        residual.field_bool("has_residual_filter", decision.residual.has_residual_filter);
1281        residual.field_bool(
1282            "has_residual_predicate",
1283            decision.residual.has_residual_predicate,
1284        );
1285        residual.field_u64(
1286            "access_bound_predicate_count",
1287            decision.residual.access_bound_predicate_count as u64,
1288        );
1289        residual.field_u64(
1290            "residual_predicate_count",
1291            decision.residual.residual_predicate_count as u64,
1292        );
1293        residual.field_u64("predicate_terms", decision.residual.predicate_terms as u64);
1294        residual.finish();
1295    });
1296    object.finish();
1297}
1298
1299fn write_access_candidate_json(candidate: &ExplainAccessCandidateV1, out: &mut String) {
1300    let mut object = JsonWriter::begin_object(out);
1301    object.field_str("label", candidate.label.as_str());
1302    object.field_bool("exact", candidate.exact);
1303    object.field_bool("filtered", candidate.filtered);
1304    object.field_u64("range_bound_count", candidate.range_bound_count as u64);
1305    object.field_bool("order_compatible", candidate.order_compatible);
1306    object.field_str("residual_burden", candidate.residual_burden);
1307    object.field_u64(
1308        "residual_predicate_terms",
1309        candidate.residual_predicate_terms as u64,
1310    );
1311    object.finish();
1312}