Skip to main content

icydb_core/db/query/explain/
plan.rs

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