Skip to main content

icydb_core/db/query/explain/
plan.rs

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