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