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