Skip to main content

icydb_core/db/query/explain/
plan.rs

1//! Module: query::explain::plan
2//! Responsibility: deterministic logical-plan projection for EXPLAIN.
3//! Does not own: execution descriptor rendering or access visitor adapters.
4//! Boundary: logical explain DTOs and plan-side projection logic.
5
6use crate::{
7    db::{
8        access::{
9            AccessPlan, PushdownSurfaceEligibility, SecondaryOrderPushdownEligibility,
10            SecondaryOrderPushdownRejection,
11        },
12        predicate::{CoercionSpec, CompareOp, ComparePredicate, MissingRowPolicy, Predicate},
13        query::{
14            builder::scalar_projection::render_scalar_projection_expr_sql_label,
15            explain::{access_projection::write_access_json, writer::JsonWriter},
16            plan::{
17                AccessPlannedQuery, AggregateKind, DeleteLimitSpec, GroupedPlanFallbackReason,
18                LogicalPlan, OrderDirection, OrderSpec, PageSpec, QueryMode, ScalarPlan,
19                expr::{BinaryOp, Expr, UnaryOp},
20                grouped_plan_strategy,
21            },
22        },
23    },
24    traits::FieldValue,
25    value::Value,
26};
27use std::ops::Bound;
28
29///
30/// ExplainPlan
31///
32/// Stable, deterministic representation of a planned query for observability.
33///
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct ExplainPlan {
37    pub(crate) mode: QueryMode,
38    pub(crate) access: ExplainAccessPath,
39    pub(crate) predicate: ExplainPredicate,
40    predicate_model: Option<Predicate>,
41    pub(crate) order_by: ExplainOrderBy,
42    pub(crate) distinct: bool,
43    pub(crate) grouping: ExplainGrouping,
44    pub(crate) order_pushdown: ExplainOrderPushdown,
45    pub(crate) page: ExplainPagination,
46    pub(crate) delete_limit: ExplainDeleteLimit,
47    pub(crate) consistency: MissingRowPolicy,
48}
49
50impl ExplainPlan {
51    /// Return query mode projected by this explain plan.
52    #[must_use]
53    pub const fn mode(&self) -> QueryMode {
54        self.mode
55    }
56
57    /// Borrow projected access-path shape.
58    #[must_use]
59    pub const fn access(&self) -> &ExplainAccessPath {
60        &self.access
61    }
62
63    /// Borrow projected predicate shape.
64    #[must_use]
65    pub const fn predicate(&self) -> &ExplainPredicate {
66        &self.predicate
67    }
68
69    /// Borrow projected ORDER BY shape.
70    #[must_use]
71    pub const fn order_by(&self) -> &ExplainOrderBy {
72        &self.order_by
73    }
74
75    /// Return whether DISTINCT is enabled.
76    #[must_use]
77    pub const fn distinct(&self) -> bool {
78        self.distinct
79    }
80
81    /// Borrow projected grouped-shape metadata.
82    #[must_use]
83    pub const fn grouping(&self) -> &ExplainGrouping {
84        &self.grouping
85    }
86
87    /// Borrow projected ORDER pushdown status.
88    #[must_use]
89    pub const fn order_pushdown(&self) -> &ExplainOrderPushdown {
90        &self.order_pushdown
91    }
92
93    /// Borrow projected pagination status.
94    #[must_use]
95    pub const fn page(&self) -> &ExplainPagination {
96        &self.page
97    }
98
99    /// Borrow projected delete-limit status.
100    #[must_use]
101    pub const fn delete_limit(&self) -> &ExplainDeleteLimit {
102        &self.delete_limit
103    }
104
105    /// Return missing-row consistency policy.
106    #[must_use]
107    pub const fn consistency(&self) -> MissingRowPolicy {
108        self.consistency
109    }
110}
111
112impl ExplainPlan {
113    /// Return the canonical predicate model used for hashing/fingerprints.
114    ///
115    /// The explain projection must remain a faithful rendering of this model.
116    #[must_use]
117    pub(crate) fn predicate_model_for_hash(&self) -> Option<&Predicate> {
118        if let Some(predicate) = &self.predicate_model {
119            debug_assert_eq!(
120                self.predicate,
121                ExplainPredicate::from_predicate(predicate),
122                "explain predicate surface drifted from canonical predicate model"
123            );
124            Some(predicate)
125        } else {
126            debug_assert!(
127                matches!(self.predicate, ExplainPredicate::None),
128                "missing canonical predicate model requires ExplainPredicate::None"
129            );
130            None
131        }
132    }
133
134    /// Render this logical explain plan as deterministic canonical text.
135    ///
136    /// This surface is frontend-facing and intentionally stable for SQL/CLI
137    /// explain output and snapshot-style diagnostics.
138    #[must_use]
139    pub fn render_text_canonical(&self) -> String {
140        format!(
141            concat!(
142                "mode={:?}\n",
143                "access={:?}\n",
144                "predicate={:?}\n",
145                "order_by={:?}\n",
146                "distinct={}\n",
147                "grouping={:?}\n",
148                "order_pushdown={:?}\n",
149                "page={:?}\n",
150                "delete_limit={:?}\n",
151                "consistency={:?}",
152            ),
153            self.mode(),
154            self.access(),
155            self.predicate(),
156            self.order_by(),
157            self.distinct(),
158            self.grouping(),
159            self.order_pushdown(),
160            self.page(),
161            self.delete_limit(),
162            self.consistency(),
163        )
164    }
165
166    /// Render this logical explain plan as canonical JSON.
167    #[must_use]
168    pub fn render_json_canonical(&self) -> String {
169        let mut out = String::new();
170        write_logical_explain_json(self, &mut out);
171
172        out
173    }
174}
175
176///
177/// ExplainGrouping
178///
179/// Grouped-shape annotation for deterministic explain/fingerprint surfaces.
180///
181
182#[expect(clippy::large_enum_variant)]
183#[derive(Clone, Debug, Eq, PartialEq)]
184pub enum ExplainGrouping {
185    None,
186    Grouped {
187        strategy: &'static str,
188        fallback_reason: Option<&'static str>,
189        group_fields: Vec<ExplainGroupField>,
190        aggregates: Vec<ExplainGroupAggregate>,
191        having: Option<ExplainGroupHaving>,
192        max_groups: u64,
193        max_group_bytes: u64,
194    },
195}
196
197///
198/// ExplainGroupField
199///
200/// Stable grouped-key field identity carried by explain/hash surfaces.
201///
202
203#[derive(Clone, Debug, Eq, PartialEq)]
204pub struct ExplainGroupField {
205    pub(crate) slot_index: usize,
206    pub(crate) field: String,
207}
208
209impl ExplainGroupField {
210    /// Return grouped slot index.
211    #[must_use]
212    pub const fn slot_index(&self) -> usize {
213        self.slot_index
214    }
215
216    /// Borrow grouped field name.
217    #[must_use]
218    pub const fn field(&self) -> &str {
219        self.field.as_str()
220    }
221}
222
223///
224/// ExplainGroupAggregate
225///
226/// Stable explain-surface projection of one grouped aggregate terminal.
227///
228
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct ExplainGroupAggregate {
231    pub(crate) kind: AggregateKind,
232    pub(crate) target_field: Option<String>,
233    pub(crate) input_expr: Option<String>,
234    pub(crate) distinct: bool,
235}
236
237impl ExplainGroupAggregate {
238    /// Return grouped aggregate kind.
239    #[must_use]
240    pub const fn kind(&self) -> AggregateKind {
241        self.kind
242    }
243
244    /// Borrow optional grouped aggregate target field.
245    #[must_use]
246    pub fn target_field(&self) -> Option<&str> {
247        self.target_field.as_deref()
248    }
249
250    /// Borrow optional grouped aggregate input expression label.
251    #[must_use]
252    pub fn input_expr(&self) -> Option<&str> {
253        self.input_expr.as_deref()
254    }
255
256    /// Return whether grouped aggregate uses DISTINCT input semantics.
257    #[must_use]
258    pub const fn distinct(&self) -> bool {
259        self.distinct
260    }
261}
262
263///
264/// ExplainGroupHaving
265///
266/// Deterministic explain projection of grouped HAVING clauses.
267///
268
269#[derive(Clone, Debug, Eq, PartialEq)]
270pub struct ExplainGroupHaving {
271    pub(crate) expr: GroupHavingExpr,
272}
273
274impl ExplainGroupHaving {
275    /// Borrow widened grouped HAVING expression.
276    #[must_use]
277    pub const fn expr(&self) -> &GroupHavingExpr {
278        &self.expr
279    }
280}
281
282///
283/// GroupHavingExpr
284///
285/// Canonical grouped HAVING boolean-expression projection shared by EXPLAIN
286/// surfaces and grouped fingerprint hashing.
287///
288
289#[derive(Clone, Debug, Eq, PartialEq)]
290pub enum GroupHavingExpr {
291    Compare {
292        left: GroupHavingValueExpr,
293        op: CompareOp,
294        right: GroupHavingValueExpr,
295    },
296    And(Vec<Self>),
297    Value(GroupHavingValueExpr),
298}
299
300///
301/// GroupHavingValueExpr
302///
303/// Canonical grouped HAVING value-expression projection shared by EXPLAIN
304/// surfaces and grouped fingerprint hashing.
305/// Leaves remain restricted to grouped keys, aggregate outputs, and literals.
306///
307
308#[derive(Clone, Debug, Eq, PartialEq)]
309pub enum GroupHavingValueExpr {
310    GroupField {
311        slot_index: usize,
312        field: String,
313    },
314    AggregateIndex {
315        index: usize,
316    },
317    Literal(Value),
318    FunctionCall {
319        function: String,
320        args: Vec<Self>,
321    },
322    Unary {
323        op: String,
324        expr: Box<Self>,
325    },
326    Case {
327        when_then_arms: Vec<GroupHavingCaseArm>,
328        else_expr: Box<Self>,
329    },
330    Binary {
331        op: String,
332        left: Box<Self>,
333        right: Box<Self>,
334    },
335}
336
337///
338/// GroupHavingCaseArm
339///
340/// Canonical grouped HAVING searched-CASE arm shared by EXPLAIN surfaces and
341/// grouped fingerprint hashing.
342/// This keeps both consumers aligned with the planner-owned grouped HAVING
343/// expression seam when searched CASE support is admitted through SQL.
344///
345
346#[derive(Clone, Debug, Eq, PartialEq)]
347pub struct GroupHavingCaseArm {
348    pub(crate) condition: GroupHavingValueExpr,
349    pub(crate) result: GroupHavingValueExpr,
350}
351
352impl GroupHavingExpr {
353    /// Project one grouped planner expression onto the canonical grouped
354    /// HAVING shape shared by EXPLAIN and fingerprint hashing.
355    #[must_use]
356    pub(in crate::db) fn from_plan(
357        expr: &Expr,
358        group_fields: &[crate::db::query::plan::FieldSlot],
359        aggregates: &[crate::db::query::plan::GroupAggregateSpec],
360    ) -> Self {
361        // First preserve the boolean shell for compare and AND nodes because
362        // those are the grouped HAVING control-flow forms hashed and rendered
363        // distinctly from general value expressions.
364        match expr {
365            Expr::Binary { op, left, right } => match op {
366                BinaryOp::Eq => Self::Compare {
367                    left: GroupHavingValueExpr::from_plan(left, group_fields, aggregates),
368                    op: CompareOp::Eq,
369                    right: GroupHavingValueExpr::from_plan(right, group_fields, aggregates),
370                },
371                BinaryOp::Ne => Self::Compare {
372                    left: GroupHavingValueExpr::from_plan(left, group_fields, aggregates),
373                    op: CompareOp::Ne,
374                    right: GroupHavingValueExpr::from_plan(right, group_fields, aggregates),
375                },
376                BinaryOp::Lt => Self::Compare {
377                    left: GroupHavingValueExpr::from_plan(left, group_fields, aggregates),
378                    op: CompareOp::Lt,
379                    right: GroupHavingValueExpr::from_plan(right, group_fields, aggregates),
380                },
381                BinaryOp::Lte => Self::Compare {
382                    left: GroupHavingValueExpr::from_plan(left, group_fields, aggregates),
383                    op: CompareOp::Lte,
384                    right: GroupHavingValueExpr::from_plan(right, group_fields, aggregates),
385                },
386                BinaryOp::Gt => Self::Compare {
387                    left: GroupHavingValueExpr::from_plan(left, group_fields, aggregates),
388                    op: CompareOp::Gt,
389                    right: GroupHavingValueExpr::from_plan(right, group_fields, aggregates),
390                },
391                BinaryOp::Gte => Self::Compare {
392                    left: GroupHavingValueExpr::from_plan(left, group_fields, aggregates),
393                    op: CompareOp::Gte,
394                    right: GroupHavingValueExpr::from_plan(right, group_fields, aggregates),
395                },
396                BinaryOp::And => Self::And(vec![
397                    Self::from_plan(left, group_fields, aggregates),
398                    Self::from_plan(right, group_fields, aggregates),
399                ]),
400                BinaryOp::Or | BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
401                    Self::Value(GroupHavingValueExpr::from_plan(
402                        expr,
403                        group_fields,
404                        aggregates,
405                    ))
406                }
407            },
408            // Then fall back to the value-expression projection for every
409            // other grouped HAVING expression shape.
410            _ => Self::Value(GroupHavingValueExpr::from_plan(
411                expr,
412                group_fields,
413                aggregates,
414            )),
415        }
416    }
417
418    /// Project one grouped plan's effective HAVING expression onto the
419    /// canonical grouped HAVING shape.
420    #[must_use]
421    pub(in crate::db) fn from_group_plan(
422        logical: &crate::db::query::plan::GroupPlan,
423        expr: &Expr,
424    ) -> Self {
425        Self::from_plan(
426            expr,
427            logical.group.group_fields.as_slice(),
428            logical.group.aggregates.as_slice(),
429        )
430    }
431}
432
433impl GroupHavingValueExpr {
434    /// Project one grouped planner value expression onto the canonical grouped
435    /// HAVING value-expression shape.
436    #[must_use]
437    pub(in crate::db) fn from_plan(
438        expr: &Expr,
439        group_fields: &[crate::db::query::plan::FieldSlot],
440        aggregates: &[crate::db::query::plan::GroupAggregateSpec],
441    ) -> Self {
442        // First normalize grouped leaves onto stable slot/index identity so the
443        // canonical model does not retain planner-only field or aggregate nodes.
444        match expr {
445            Expr::Field(field_id) => {
446                let field_name = field_id.as_str();
447                let field_slot = group_fields
448                    .iter()
449                    .find(|field| field.field() == field_name)
450                    .expect("grouped explain requires HAVING fields to match grouped key fields");
451
452                Self::GroupField {
453                    slot_index: field_slot.index(),
454                    field: field_slot.field().to_string(),
455                }
456            }
457            Expr::Aggregate(aggregate_expr) => {
458                let index = aggregates
459                    .iter()
460                    .position(|aggregate| {
461                        let distinct_matches = aggregate.distinct() == aggregate_expr.is_distinct();
462
463                        aggregate.kind() == aggregate_expr.kind()
464                            && aggregate.target_field() == aggregate_expr.target_field()
465                            && aggregate.semantic_input_expr_owned().as_ref()
466                                == aggregate_expr.input_expr()
467                            && distinct_matches
468                    })
469                    .expect(
470                        "grouped explain requires HAVING aggregates to match grouped aggregates",
471                    );
472
473                Self::AggregateIndex { index }
474            }
475            // Then preserve compound expression structure using explain-stable
476            // operator labels so EXPLAIN text and fingerprint hashing share one
477            // source of truth for recursive grouped HAVING shapes.
478            Expr::Literal(value) => Self::Literal(value.clone()),
479            Expr::FunctionCall { function, args } => Self::FunctionCall {
480                function: function.sql_label().to_string(),
481                args: args
482                    .iter()
483                    .map(|arg| Self::from_plan(arg, group_fields, aggregates))
484                    .collect(),
485            },
486            Expr::Unary { op, expr } => Self::Unary {
487                op: group_having_unary_op_label(*op).to_string(),
488                expr: Box::new(Self::from_plan(expr, group_fields, aggregates)),
489            },
490            Expr::Case {
491                when_then_arms,
492                else_expr,
493            } => Self::Case {
494                when_then_arms: when_then_arms
495                    .iter()
496                    .map(|arm| {
497                        GroupHavingCaseArm::from_plan(
498                            arm.condition(),
499                            arm.result(),
500                            group_fields,
501                            aggregates,
502                        )
503                    })
504                    .collect(),
505                else_expr: Box::new(Self::from_plan(else_expr, group_fields, aggregates)),
506            },
507            Expr::Binary { op, left, right } => Self::Binary {
508                op: group_having_binary_op_label(*op).to_string(),
509                left: Box::new(Self::from_plan(left, group_fields, aggregates)),
510                right: Box::new(Self::from_plan(right, group_fields, aggregates)),
511            },
512            #[cfg(test)]
513            Expr::Alias { expr, name: _ } => Self::from_plan(expr, group_fields, aggregates),
514        }
515    }
516}
517
518impl GroupHavingCaseArm {
519    /// Project one grouped planner searched-CASE arm onto the canonical
520    /// grouped HAVING case-arm shape.
521    #[must_use]
522    pub(in crate::db) fn from_plan(
523        condition: &Expr,
524        result: &Expr,
525        group_fields: &[crate::db::query::plan::FieldSlot],
526        aggregates: &[crate::db::query::plan::GroupAggregateSpec],
527    ) -> Self {
528        Self {
529            condition: GroupHavingValueExpr::from_plan(condition, group_fields, aggregates),
530            result: GroupHavingValueExpr::from_plan(result, group_fields, aggregates),
531        }
532    }
533}
534
535///
536/// ExplainOrderPushdown
537///
538/// Deterministic ORDER BY pushdown eligibility reported by explain.
539///
540
541#[derive(Clone, Debug, Eq, PartialEq)]
542pub enum ExplainOrderPushdown {
543    MissingModelContext,
544    EligibleSecondaryIndex {
545        index: &'static str,
546        prefix_len: usize,
547    },
548    Rejected(SecondaryOrderPushdownRejection),
549}
550
551///
552/// ExplainAccessPath
553///
554/// Deterministic projection of logical access path shape for diagnostics.
555/// Mirrors planner-selected structural paths without runtime cursor state.
556///
557
558#[derive(Clone, Debug, Eq, PartialEq)]
559pub enum ExplainAccessPath {
560    ByKey {
561        key: Value,
562    },
563    ByKeys {
564        keys: Vec<Value>,
565    },
566    KeyRange {
567        start: Value,
568        end: Value,
569    },
570    IndexPrefix {
571        name: &'static str,
572        fields: Vec<&'static str>,
573        prefix_len: usize,
574        values: Vec<Value>,
575    },
576    IndexMultiLookup {
577        name: &'static str,
578        fields: Vec<&'static str>,
579        values: Vec<Value>,
580    },
581    IndexRange {
582        name: &'static str,
583        fields: Vec<&'static str>,
584        prefix_len: usize,
585        prefix: Vec<Value>,
586        lower: Bound<Value>,
587        upper: Bound<Value>,
588    },
589    FullScan,
590    Union(Vec<Self>),
591    Intersection(Vec<Self>),
592}
593
594///
595/// ExplainPredicate
596///
597/// Deterministic projection of canonical predicate structure for explain output.
598/// This preserves normalized predicate shape used by hashing/fingerprints.
599///
600
601#[derive(Clone, Debug, Eq, PartialEq)]
602pub enum ExplainPredicate {
603    None,
604    True,
605    False,
606    And(Vec<Self>),
607    Or(Vec<Self>),
608    Not(Box<Self>),
609    Compare {
610        field: String,
611        op: CompareOp,
612        value: Value,
613        coercion: CoercionSpec,
614    },
615    CompareFields {
616        left_field: String,
617        op: CompareOp,
618        right_field: String,
619        coercion: CoercionSpec,
620    },
621    IsNull {
622        field: String,
623    },
624    IsNotNull {
625        field: String,
626    },
627    IsMissing {
628        field: String,
629    },
630    IsEmpty {
631        field: String,
632    },
633    IsNotEmpty {
634        field: String,
635    },
636    TextContains {
637        field: String,
638        value: Value,
639    },
640    TextContainsCi {
641        field: String,
642        value: Value,
643    },
644}
645
646///
647/// ExplainOrderBy
648///
649/// Deterministic projection of canonical ORDER BY shape.
650///
651
652#[derive(Clone, Debug, Eq, PartialEq)]
653pub enum ExplainOrderBy {
654    None,
655    Fields(Vec<ExplainOrder>),
656}
657
658///
659/// ExplainOrder
660///
661/// One canonical ORDER BY field + direction pair.
662///
663
664#[derive(Clone, Debug, Eq, PartialEq)]
665pub struct ExplainOrder {
666    pub(crate) field: String,
667    pub(crate) direction: OrderDirection,
668}
669
670impl ExplainOrder {
671    /// Borrow ORDER BY field name.
672    #[must_use]
673    pub const fn field(&self) -> &str {
674        self.field.as_str()
675    }
676
677    /// Return ORDER BY direction.
678    #[must_use]
679    pub const fn direction(&self) -> OrderDirection {
680        self.direction
681    }
682}
683
684///
685/// ExplainPagination
686///
687/// Explain-surface projection of pagination window configuration.
688///
689
690#[derive(Clone, Debug, Eq, PartialEq)]
691pub enum ExplainPagination {
692    None,
693    Page { limit: Option<u32>, offset: u32 },
694}
695
696///
697/// ExplainDeleteLimit
698///
699/// Explain-surface projection of delete-limit configuration.
700///
701
702#[derive(Clone, Debug, Eq, PartialEq)]
703pub enum ExplainDeleteLimit {
704    None,
705    Limit { max_rows: u32 },
706    Window { limit: Option<u32>, offset: u32 },
707}
708
709impl AccessPlannedQuery {
710    /// Produce a stable, deterministic explanation of this logical plan.
711    #[must_use]
712    pub(crate) fn explain(&self) -> ExplainPlan {
713        self.explain_inner()
714    }
715
716    pub(in crate::db::query::explain) fn explain_inner(&self) -> ExplainPlan {
717        // Phase 1: project logical plan variant into scalar core + grouped metadata.
718        let (logical, grouping) = match &self.logical {
719            LogicalPlan::Scalar(logical) => (logical, ExplainGrouping::None),
720            LogicalPlan::Grouped(logical) => {
721                let grouped_strategy = grouped_plan_strategy(self).expect(
722                    "grouped logical explain projection requires planner-owned grouped strategy",
723                );
724
725                (
726                    &logical.scalar,
727                    ExplainGrouping::Grouped {
728                        strategy: grouped_strategy.code(),
729                        fallback_reason: grouped_strategy
730                            .fallback_reason()
731                            .map(GroupedPlanFallbackReason::code),
732                        group_fields: logical
733                            .group
734                            .group_fields
735                            .iter()
736                            .map(|field_slot| ExplainGroupField {
737                                slot_index: field_slot.index(),
738                                field: field_slot.field().to_string(),
739                            })
740                            .collect(),
741                        aggregates: logical
742                            .group
743                            .aggregates
744                            .iter()
745                            .map(|aggregate| ExplainGroupAggregate {
746                                kind: aggregate.kind,
747                                target_field: aggregate.target_field().map(str::to_string),
748                                input_expr: aggregate
749                                    .input_expr()
750                                    .map(render_scalar_projection_expr_sql_label),
751                                distinct: aggregate.distinct,
752                            })
753                            .collect(),
754                        having: explain_group_having(logical),
755                        max_groups: logical.group.execution.max_groups(),
756                        max_group_bytes: logical.group.execution.max_group_bytes(),
757                    },
758                )
759            }
760        };
761
762        // Phase 2: project scalar plan + access path into deterministic explain surface.
763        explain_scalar_inner(logical, grouping, &self.access)
764    }
765}
766
767fn explain_group_having(logical: &crate::db::query::plan::GroupPlan) -> Option<ExplainGroupHaving> {
768    let expr = logical.effective_having_expr()?;
769
770    Some(ExplainGroupHaving {
771        expr: GroupHavingExpr::from_group_plan(logical, expr.as_ref()),
772    })
773}
774
775const fn group_having_unary_op_label(op: UnaryOp) -> &'static str {
776    match op {
777        UnaryOp::Not => "NOT",
778    }
779}
780
781const fn group_having_binary_op_label(op: BinaryOp) -> &'static str {
782    match op {
783        BinaryOp::Or => "OR",
784        BinaryOp::And => "AND",
785        BinaryOp::Eq => "=",
786        BinaryOp::Ne => "!=",
787        BinaryOp::Lt => "<",
788        BinaryOp::Lte => "<=",
789        BinaryOp::Gt => ">",
790        BinaryOp::Gte => ">=",
791        BinaryOp::Add => "+",
792        BinaryOp::Sub => "-",
793        BinaryOp::Mul => "*",
794        BinaryOp::Div => "/",
795    }
796}
797
798fn explain_scalar_inner<K>(
799    logical: &ScalarPlan,
800    grouping: ExplainGrouping,
801    access: &AccessPlan<K>,
802) -> ExplainPlan
803where
804    K: FieldValue,
805{
806    // Phase 1: consume canonical predicate model from planner-owned scalar semantics.
807    let predicate_model = logical.predicate.clone();
808    let predicate = match &predicate_model {
809        Some(predicate) => ExplainPredicate::from_predicate(predicate),
810        None => ExplainPredicate::None,
811    };
812
813    // Phase 2: project scalar-plan fields into explain-specific enums.
814    let order_by = explain_order(logical.order.as_ref());
815    let order_pushdown = explain_order_pushdown();
816    let page = explain_page(logical.page.as_ref());
817    let delete_limit = explain_delete_limit(logical.delete_limit.as_ref());
818
819    // Phase 3: assemble one stable explain payload.
820    ExplainPlan {
821        mode: logical.mode,
822        access: ExplainAccessPath::from_access_plan(access),
823        predicate,
824        predicate_model,
825        order_by,
826        distinct: logical.distinct,
827        grouping,
828        order_pushdown,
829        page,
830        delete_limit,
831        consistency: logical.consistency,
832    }
833}
834
835const fn explain_order_pushdown() -> ExplainOrderPushdown {
836    // Query explain does not own physical pushdown feasibility routing.
837    ExplainOrderPushdown::MissingModelContext
838}
839
840impl From<SecondaryOrderPushdownEligibility> for ExplainOrderPushdown {
841    fn from(value: SecondaryOrderPushdownEligibility) -> Self {
842        Self::from(PushdownSurfaceEligibility::from(&value))
843    }
844}
845
846impl From<PushdownSurfaceEligibility<'_>> for ExplainOrderPushdown {
847    fn from(value: PushdownSurfaceEligibility<'_>) -> Self {
848        match value {
849            PushdownSurfaceEligibility::EligibleSecondaryIndex { index, prefix_len } => {
850                Self::EligibleSecondaryIndex { index, prefix_len }
851            }
852            PushdownSurfaceEligibility::Rejected { reason } => Self::Rejected(reason.clone()),
853        }
854    }
855}
856
857impl ExplainPredicate {
858    pub(in crate::db) fn from_predicate(predicate: &Predicate) -> Self {
859        match predicate {
860            Predicate::True => Self::True,
861            Predicate::False => Self::False,
862            Predicate::And(children) => {
863                Self::And(children.iter().map(Self::from_predicate).collect())
864            }
865            Predicate::Or(children) => {
866                Self::Or(children.iter().map(Self::from_predicate).collect())
867            }
868            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
869            Predicate::Compare(compare) => Self::from_compare(compare),
870            Predicate::CompareFields(compare) => Self::CompareFields {
871                left_field: compare.left_field().to_string(),
872                op: compare.op(),
873                right_field: compare.right_field().to_string(),
874                coercion: compare.coercion().clone(),
875            },
876            Predicate::IsNull { field } => Self::IsNull {
877                field: field.clone(),
878            },
879            Predicate::IsNotNull { field } => Self::IsNotNull {
880                field: field.clone(),
881            },
882            Predicate::IsMissing { field } => Self::IsMissing {
883                field: field.clone(),
884            },
885            Predicate::IsEmpty { field } => Self::IsEmpty {
886                field: field.clone(),
887            },
888            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
889                field: field.clone(),
890            },
891            Predicate::TextContains { field, value } => Self::TextContains {
892                field: field.clone(),
893                value: value.clone(),
894            },
895            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
896                field: field.clone(),
897                value: value.clone(),
898            },
899        }
900    }
901
902    fn from_compare(compare: &ComparePredicate) -> Self {
903        Self::Compare {
904            field: compare.field.clone(),
905            op: compare.op,
906            value: compare.value.clone(),
907            coercion: compare.coercion.clone(),
908        }
909    }
910}
911
912fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
913    let Some(order) = order else {
914        return ExplainOrderBy::None;
915    };
916
917    if order.fields.is_empty() {
918        return ExplainOrderBy::None;
919    }
920
921    ExplainOrderBy::Fields(
922        order
923            .fields
924            .iter()
925            .map(|(field, direction)| ExplainOrder {
926                field: field.clone(),
927                direction: *direction,
928            })
929            .collect(),
930    )
931}
932
933const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
934    match page {
935        Some(page) => ExplainPagination::Page {
936            limit: page.limit,
937            offset: page.offset,
938        },
939        None => ExplainPagination::None,
940    }
941}
942
943const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
944    match limit {
945        Some(limit) if limit.offset == 0 => match limit.limit {
946            Some(max_rows) => ExplainDeleteLimit::Limit { max_rows },
947            None => ExplainDeleteLimit::Window {
948                limit: None,
949                offset: 0,
950            },
951        },
952        Some(limit) => ExplainDeleteLimit::Window {
953            limit: limit.limit,
954            offset: limit.offset,
955        },
956        None => ExplainDeleteLimit::None,
957    }
958}
959
960fn write_logical_explain_json(explain: &ExplainPlan, out: &mut String) {
961    let mut object = JsonWriter::begin_object(out);
962    object.field_with("mode", |out| {
963        let mut object = JsonWriter::begin_object(out);
964        match explain.mode() {
965            QueryMode::Load(spec) => {
966                object.field_str("type", "Load");
967                match spec.limit() {
968                    Some(limit) => object.field_u64("limit", u64::from(limit)),
969                    None => object.field_null("limit"),
970                }
971                object.field_u64("offset", u64::from(spec.offset()));
972            }
973            QueryMode::Delete(spec) => {
974                object.field_str("type", "Delete");
975                match spec.limit() {
976                    Some(limit) => object.field_u64("limit", u64::from(limit)),
977                    None => object.field_null("limit"),
978                }
979            }
980        }
981        object.finish();
982    });
983    object.field_with("access", |out| write_access_json(explain.access(), out));
984    object.field_value_debug("predicate", explain.predicate());
985    object.field_value_debug("order_by", explain.order_by());
986    object.field_bool("distinct", explain.distinct());
987    object.field_value_debug("grouping", explain.grouping());
988    object.field_value_debug("order_pushdown", explain.order_pushdown());
989    object.field_with("page", |out| {
990        let mut object = JsonWriter::begin_object(out);
991        match explain.page() {
992            ExplainPagination::None => {
993                object.field_str("type", "None");
994            }
995            ExplainPagination::Page { limit, offset } => {
996                object.field_str("type", "Page");
997                match limit {
998                    Some(limit) => object.field_u64("limit", u64::from(*limit)),
999                    None => object.field_null("limit"),
1000                }
1001                object.field_u64("offset", u64::from(*offset));
1002            }
1003        }
1004        object.finish();
1005    });
1006    object.field_with("delete_limit", |out| {
1007        let mut object = JsonWriter::begin_object(out);
1008        match explain.delete_limit() {
1009            ExplainDeleteLimit::None => {
1010                object.field_str("type", "None");
1011            }
1012            ExplainDeleteLimit::Limit { max_rows } => {
1013                object.field_str("type", "Limit");
1014                object.field_u64("max_rows", u64::from(*max_rows));
1015            }
1016            ExplainDeleteLimit::Window { limit, offset } => {
1017                object.field_str("type", "Window");
1018                object.field_with("limit", |out| match limit {
1019                    Some(limit) => out.push_str(&limit.to_string()),
1020                    None => out.push_str("null"),
1021                });
1022                object.field_u64("offset", u64::from(*offset));
1023            }
1024        }
1025        object.finish();
1026    });
1027    object.field_value_debug("consistency", &explain.consistency());
1028    object.finish();
1029}