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