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