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