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::AccessPlan,
10        predicate::{CoercionSpec, CompareOp, ComparePredicate, MissingRowPolicy, Predicate},
11        query::{
12            builder::scalar_projection::render_scalar_projection_expr_plan_label,
13            explain::{
14                access_projection::write_access_json, explain_access_plan, writer::JsonWriter,
15            },
16            plan::{
17                AccessPlannedQuery, AggregateKind, DeleteLimitSpec, GroupedPlanFallbackReason,
18                LogicalPlan, OrderDirection, OrderSpec, PageSpec, QueryMode, ScalarPlan,
19                expr::Expr, grouped_plan_strategy, render_scalar_filter_expr_plan_label,
20            },
21        },
22    },
23    traits::KeyValueCodec,
24    value::Value,
25};
26use std::ops::Bound;
27
28///
29/// ExplainPlan
30///
31/// Stable, deterministic representation of a planned query for observability.
32///
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct ExplainPlan {
36    pub(in crate::db) mode: QueryMode,
37    pub(in crate::db) access: ExplainAccessPath,
38    pub(in crate::db) filter_expr: Option<String>,
39    filter_expr_model: Option<Expr>,
40    pub(in crate::db) predicate: ExplainPredicate,
41    predicate_model: Option<Predicate>,
42    pub(in crate::db) order_by: ExplainOrderBy,
43    pub(in crate::db) distinct: bool,
44    pub(in crate::db) grouping: ExplainGrouping,
45    pub(in crate::db) order_pushdown: ExplainOrderPushdown,
46    pub(in crate::db) page: ExplainPagination,
47    pub(in crate::db) delete_limit: ExplainDeleteLimit,
48    pub(in crate::db) 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 semantic scalar filter expression when present.
65    #[must_use]
66    pub fn filter_expr(&self) -> Option<&str> {
67        self.filter_expr.as_deref()
68    }
69
70    /// Borrow the canonical scalar filter model used for identity hashing.
71    #[must_use]
72    pub(in crate::db::query) fn filter_expr_model_for_hash(&self) -> Option<&Expr> {
73        if let Some(filter_expr_model) = &self.filter_expr_model {
74            debug_assert_eq!(
75                self.filter_expr(),
76                Some(render_scalar_filter_expr_plan_label(filter_expr_model).as_str()),
77                "explain scalar filter label drifted from canonical filter model"
78            );
79            Some(filter_expr_model)
80        } else {
81            debug_assert!(
82                self.filter_expr.is_none(),
83                "missing canonical filter model requires filter_expr=None"
84            );
85            None
86        }
87    }
88
89    /// Borrow projected predicate shape.
90    #[must_use]
91    pub const fn predicate(&self) -> &ExplainPredicate {
92        &self.predicate
93    }
94
95    /// Borrow projected ORDER BY shape.
96    #[must_use]
97    pub const fn order_by(&self) -> &ExplainOrderBy {
98        &self.order_by
99    }
100
101    /// Return whether DISTINCT is enabled.
102    #[must_use]
103    pub const fn distinct(&self) -> bool {
104        self.distinct
105    }
106
107    /// Borrow projected grouped-shape metadata.
108    #[must_use]
109    pub const fn grouping(&self) -> &ExplainGrouping {
110        &self.grouping
111    }
112
113    /// Borrow projected ORDER pushdown status.
114    #[must_use]
115    pub const fn order_pushdown(&self) -> &ExplainOrderPushdown {
116        &self.order_pushdown
117    }
118
119    /// Borrow projected pagination status.
120    #[must_use]
121    pub const fn page(&self) -> &ExplainPagination {
122        &self.page
123    }
124
125    /// Borrow projected delete-limit status.
126    #[must_use]
127    pub const fn delete_limit(&self) -> &ExplainDeleteLimit {
128        &self.delete_limit
129    }
130
131    /// Return missing-row consistency policy.
132    #[must_use]
133    pub const fn consistency(&self) -> MissingRowPolicy {
134        self.consistency
135    }
136}
137
138impl ExplainPlan {
139    /// Return the canonical predicate model used as the fallback hash surface.
140    ///
141    /// When a semantic scalar `filter_expr` exists, hashing now prefers that
142    /// canonical filter surface instead. The explain predicate projection must
143    /// still remain a faithful rendering of this fallback model.
144    #[must_use]
145    pub(in crate::db::query) fn predicate_model_for_hash(&self) -> Option<&Predicate> {
146        if let Some(predicate) = &self.predicate_model {
147            debug_assert_eq!(
148                self.predicate,
149                ExplainPredicate::from_predicate(predicate),
150                "explain predicate surface drifted from canonical predicate model"
151            );
152            Some(predicate)
153        } else {
154            debug_assert!(
155                matches!(self.predicate, ExplainPredicate::None),
156                "missing canonical predicate model requires ExplainPredicate::None"
157            );
158            None
159        }
160    }
161
162    /// Render this logical explain plan as deterministic canonical text.
163    ///
164    /// This surface is frontend-facing and intentionally stable for SQL/CLI
165    /// explain output and snapshot-style diagnostics.
166    #[must_use]
167    pub fn render_text_canonical(&self) -> String {
168        format!(
169            concat!(
170                "mode={:?}\n",
171                "access={:?}\n",
172                "filter_expr={:?}\n",
173                "predicate={:?}\n",
174                "order_by={:?}\n",
175                "distinct={}\n",
176                "grouping={:?}\n",
177                "order_pushdown={:?}\n",
178                "page={:?}\n",
179                "delete_limit={:?}\n",
180                "consistency={:?}",
181            ),
182            self.mode(),
183            self.access(),
184            self.filter_expr(),
185            self.predicate(),
186            self.order_by(),
187            self.distinct(),
188            self.grouping(),
189            self.order_pushdown(),
190            self.page(),
191            self.delete_limit(),
192            self.consistency(),
193        )
194    }
195
196    /// Render this logical explain plan as canonical JSON.
197    #[must_use]
198    pub fn render_json_canonical(&self) -> String {
199        let mut out = String::new();
200        write_logical_explain_json(self, &mut out);
201
202        out
203    }
204}
205
206///
207/// ExplainGrouping
208///
209/// Grouped-shape annotation for deterministic explain/fingerprint surfaces.
210///
211
212#[derive(Clone, Debug, Eq, PartialEq)]
213pub enum ExplainGrouping {
214    None,
215    Grouped {
216        strategy: &'static str,
217        fallback_reason: Option<&'static str>,
218        group_fields: Vec<ExplainGroupField>,
219        aggregates: Vec<ExplainGroupAggregate>,
220        having: Option<ExplainGroupHaving>,
221        max_groups: u64,
222        max_group_bytes: u64,
223    },
224}
225
226///
227/// ExplainGroupField
228///
229/// Stable grouped-key field identity carried by explain/hash surfaces.
230///
231
232#[derive(Clone, Debug, Eq, PartialEq)]
233pub struct ExplainGroupField {
234    pub(in crate::db) slot_index: usize,
235    pub(in crate::db) field: String,
236}
237
238impl ExplainGroupField {
239    /// Return grouped slot index.
240    #[must_use]
241    pub const fn slot_index(&self) -> usize {
242        self.slot_index
243    }
244
245    /// Borrow grouped field name.
246    #[must_use]
247    pub const fn field(&self) -> &str {
248        self.field.as_str()
249    }
250}
251
252///
253/// ExplainGroupAggregate
254///
255/// Stable explain-surface projection of one grouped aggregate terminal.
256///
257
258#[derive(Clone, Debug, Eq, PartialEq)]
259pub struct ExplainGroupAggregate {
260    pub(in crate::db) kind: AggregateKind,
261    pub(in crate::db) target_field: Option<String>,
262    pub(in crate::db) input_expr: Option<String>,
263    pub(in crate::db) filter_expr: Option<String>,
264    pub(in crate::db) distinct: bool,
265}
266
267impl ExplainGroupAggregate {
268    /// Return grouped aggregate kind.
269    #[must_use]
270    pub const fn kind(&self) -> AggregateKind {
271        self.kind
272    }
273
274    /// Borrow optional grouped aggregate target field.
275    #[must_use]
276    pub fn target_field(&self) -> Option<&str> {
277        self.target_field.as_deref()
278    }
279
280    /// Borrow optional grouped aggregate input expression label.
281    #[must_use]
282    pub fn input_expr(&self) -> Option<&str> {
283        self.input_expr.as_deref()
284    }
285
286    /// Borrow optional grouped aggregate filter expression label.
287    #[must_use]
288    pub fn filter_expr(&self) -> Option<&str> {
289        self.filter_expr.as_deref()
290    }
291
292    /// Return whether grouped aggregate uses DISTINCT input semantics.
293    #[must_use]
294    pub const fn distinct(&self) -> bool {
295        self.distinct
296    }
297}
298
299///
300/// ExplainGroupHaving
301///
302/// Deterministic explain projection of grouped HAVING clauses.
303/// This surface now carries the shared planner-owned post-aggregate expression
304/// directly so explain no longer keeps a second grouped HAVING AST.
305///
306
307#[derive(Clone, Debug, Eq, PartialEq)]
308pub struct ExplainGroupHaving {
309    pub(in crate::db) expr: Expr,
310}
311
312impl ExplainGroupHaving {
313    /// Borrow grouped HAVING expression.
314    #[must_use]
315    pub(in crate::db) const fn expr(&self) -> &Expr {
316        &self.expr
317    }
318}
319
320///
321/// ExplainOrderPushdown
322///
323/// Deterministic ORDER BY pushdown eligibility reported by explain.
324///
325
326#[derive(Clone, Debug, Eq, PartialEq)]
327pub enum ExplainOrderPushdown {
328    MissingModelContext,
329    EligibleSecondaryIndex {
330        index: &'static str,
331        prefix_len: usize,
332    },
333    Rejected(SecondaryOrderPushdownRejection),
334}
335
336///
337/// SecondaryOrderPushdownRejection
338///
339/// Stable explain-surface reason why secondary-index ORDER BY pushdown was
340/// rejected. Executor route planning converts its runtime route reasons into
341/// this neutral query DTO before rendering explain payloads.
342///
343#[derive(Clone, Debug, Eq, PartialEq)]
344pub enum SecondaryOrderPushdownRejection {
345    NoOrderBy,
346    AccessPathNotSingleIndexPrefix,
347    AccessPathIndexRangeUnsupported {
348        index: &'static str,
349        prefix_len: usize,
350    },
351    InvalidIndexPrefixBounds {
352        prefix_len: usize,
353        index_field_len: usize,
354    },
355    MissingPrimaryKeyTieBreak {
356        field: String,
357    },
358    PrimaryKeyDirectionNotAscending {
359        field: String,
360    },
361    MixedDirectionNotEligible {
362        field: String,
363    },
364    OrderFieldsDoNotMatchIndex {
365        index: &'static str,
366        prefix_len: usize,
367        expected_suffix: Vec<String>,
368        expected_full: Vec<String>,
369        actual: Vec<String>,
370    },
371}
372
373///
374/// ExplainAccessPath
375///
376/// Deterministic projection of logical access path shape for diagnostics.
377/// Mirrors planner-selected structural paths without runtime cursor state.
378///
379
380#[derive(Clone, Debug, Eq, PartialEq)]
381pub enum ExplainAccessPath {
382    ByKey {
383        key: Value,
384    },
385    ByKeys {
386        keys: Vec<Value>,
387    },
388    KeyRange {
389        start: Value,
390        end: Value,
391    },
392    IndexPrefix {
393        name: &'static str,
394        fields: Vec<&'static str>,
395        prefix_len: usize,
396        values: Vec<Value>,
397    },
398    IndexMultiLookup {
399        name: &'static str,
400        fields: Vec<&'static str>,
401        values: Vec<Value>,
402    },
403    IndexRange {
404        name: &'static str,
405        fields: Vec<&'static str>,
406        prefix_len: usize,
407        prefix: Vec<Value>,
408        lower: Bound<Value>,
409        upper: Bound<Value>,
410    },
411    FullScan,
412    Union(Vec<Self>),
413    Intersection(Vec<Self>),
414}
415
416///
417/// ExplainPredicate
418///
419/// Deterministic projection of canonical predicate structure for explain output.
420/// This preserves normalized predicate shape used by hashing/fingerprints.
421///
422
423#[derive(Clone, Debug, Eq, PartialEq)]
424pub enum ExplainPredicate {
425    None,
426    True,
427    False,
428    And(Vec<Self>),
429    Or(Vec<Self>),
430    Not(Box<Self>),
431    Compare {
432        field: String,
433        op: CompareOp,
434        value: Value,
435        coercion: CoercionSpec,
436    },
437    CompareFields {
438        left_field: String,
439        op: CompareOp,
440        right_field: String,
441        coercion: CoercionSpec,
442    },
443    IsNull {
444        field: String,
445    },
446    IsNotNull {
447        field: String,
448    },
449    IsMissing {
450        field: String,
451    },
452    IsEmpty {
453        field: String,
454    },
455    IsNotEmpty {
456        field: String,
457    },
458    TextContains {
459        field: String,
460        value: Value,
461    },
462    TextContainsCi {
463        field: String,
464        value: Value,
465    },
466}
467
468///
469/// ExplainOrderBy
470///
471/// Deterministic projection of canonical ORDER BY shape.
472///
473
474#[derive(Clone, Debug, Eq, PartialEq)]
475pub enum ExplainOrderBy {
476    None,
477    Fields(Vec<ExplainOrder>),
478}
479
480///
481/// ExplainOrder
482///
483/// One canonical ORDER BY field + direction pair.
484///
485
486#[derive(Clone, Debug, Eq, PartialEq)]
487pub struct ExplainOrder {
488    pub(in crate::db) field: String,
489    pub(in crate::db) direction: OrderDirection,
490}
491
492impl ExplainOrder {
493    /// Borrow ORDER BY field name.
494    #[must_use]
495    pub const fn field(&self) -> &str {
496        self.field.as_str()
497    }
498
499    /// Return ORDER BY direction.
500    #[must_use]
501    pub const fn direction(&self) -> OrderDirection {
502        self.direction
503    }
504}
505
506///
507/// ExplainPagination
508///
509/// Explain-surface projection of pagination window configuration.
510///
511
512#[derive(Clone, Debug, Eq, PartialEq)]
513pub enum ExplainPagination {
514    None,
515    Page { limit: Option<u32>, offset: u32 },
516}
517
518///
519/// ExplainDeleteLimit
520///
521/// Explain-surface projection of delete-limit configuration.
522///
523
524#[derive(Clone, Debug, Eq, PartialEq)]
525pub enum ExplainDeleteLimit {
526    None,
527    Limit { max_rows: u32 },
528    Window { limit: Option<u32>, offset: u32 },
529}
530
531impl AccessPlannedQuery {
532    /// Produce a stable, deterministic explanation of this logical plan.
533    #[must_use]
534    pub(in crate::db) fn explain(&self) -> ExplainPlan {
535        self.explain_inner()
536    }
537
538    pub(in crate::db::query::explain) fn explain_inner(&self) -> ExplainPlan {
539        // Phase 1: project logical plan variant into scalar core + grouped metadata.
540        let (logical, grouping) = match &self.logical {
541            LogicalPlan::Scalar(logical) => (logical, ExplainGrouping::None),
542            LogicalPlan::Grouped(logical) => {
543                let grouped_strategy = grouped_plan_strategy(self).expect(
544                    "grouped logical explain projection requires planner-owned grouped strategy",
545                );
546
547                (
548                    &logical.scalar,
549                    ExplainGrouping::Grouped {
550                        strategy: grouped_strategy.code(),
551                        fallback_reason: grouped_strategy
552                            .fallback_reason()
553                            .map(GroupedPlanFallbackReason::code),
554                        group_fields: logical
555                            .group
556                            .group_fields
557                            .iter()
558                            .map(|field_slot| ExplainGroupField {
559                                slot_index: field_slot.index(),
560                                field: field_slot.field().to_string(),
561                            })
562                            .collect(),
563                        aggregates: logical
564                            .group
565                            .aggregates
566                            .iter()
567                            .map(|aggregate| ExplainGroupAggregate {
568                                kind: aggregate.kind,
569                                target_field: aggregate.target_field().map(str::to_string),
570                                input_expr: aggregate
571                                    .input_expr()
572                                    .map(render_scalar_projection_expr_plan_label),
573                                filter_expr: aggregate
574                                    .filter_expr()
575                                    .map(render_scalar_projection_expr_plan_label),
576                                distinct: aggregate.distinct,
577                            })
578                            .collect(),
579                        having: explain_group_having(logical),
580                        max_groups: logical.group.execution.max_groups(),
581                        max_group_bytes: logical.group.execution.max_group_bytes(),
582                    },
583                )
584            }
585        };
586
587        // Phase 2: project scalar plan + access path into deterministic explain surface.
588        explain_scalar_inner(logical, grouping, &self.access)
589    }
590}
591
592fn explain_group_having(logical: &crate::db::query::plan::GroupPlan) -> Option<ExplainGroupHaving> {
593    let expr = logical.effective_having_expr()?;
594
595    Some(ExplainGroupHaving {
596        expr: expr.into_owned(),
597    })
598}
599
600fn explain_scalar_inner<K>(
601    logical: &ScalarPlan,
602    grouping: ExplainGrouping,
603    access: &AccessPlan<K>,
604) -> ExplainPlan
605where
606    K: KeyValueCodec,
607{
608    // Phase 1: consume canonical predicate model from planner-owned scalar semantics.
609    let filter_expr = logical
610        .filter_expr
611        .as_ref()
612        .map(render_scalar_filter_expr_plan_label);
613    let filter_expr_model = logical.filter_expr.clone();
614    let predicate_model = logical.predicate.clone();
615    let predicate = match &predicate_model {
616        Some(predicate) => ExplainPredicate::from_predicate(predicate),
617        None => ExplainPredicate::None,
618    };
619
620    // Phase 2: project scalar-plan fields into explain-specific enums.
621    let order_by = explain_order(logical.order.as_ref());
622    let order_pushdown = explain_order_pushdown();
623    let page = explain_page(logical.page.as_ref());
624    let delete_limit = explain_delete_limit(logical.delete_limit.as_ref());
625
626    // Phase 3: assemble one stable explain payload.
627    ExplainPlan {
628        mode: logical.mode,
629        access: explain_access_plan(access),
630        filter_expr,
631        filter_expr_model,
632        predicate,
633        predicate_model,
634        order_by,
635        distinct: logical.distinct,
636        grouping,
637        order_pushdown,
638        page,
639        delete_limit,
640        consistency: logical.consistency,
641    }
642}
643
644const fn explain_order_pushdown() -> ExplainOrderPushdown {
645    // Query explain does not own physical pushdown feasibility routing.
646    ExplainOrderPushdown::MissingModelContext
647}
648
649impl ExplainPredicate {
650    pub(in crate::db) fn from_predicate(predicate: &Predicate) -> Self {
651        match predicate {
652            Predicate::True => Self::True,
653            Predicate::False => Self::False,
654            Predicate::And(children) => {
655                Self::And(children.iter().map(Self::from_predicate).collect())
656            }
657            Predicate::Or(children) => {
658                Self::Or(children.iter().map(Self::from_predicate).collect())
659            }
660            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
661            Predicate::Compare(compare) => Self::from_compare(compare),
662            Predicate::CompareFields(compare) => Self::CompareFields {
663                left_field: compare.left_field().to_string(),
664                op: compare.op(),
665                right_field: compare.right_field().to_string(),
666                coercion: compare.coercion().clone(),
667            },
668            Predicate::IsNull { field } => Self::IsNull {
669                field: field.clone(),
670            },
671            Predicate::IsNotNull { field } => Self::IsNotNull {
672                field: field.clone(),
673            },
674            Predicate::IsMissing { field } => Self::IsMissing {
675                field: field.clone(),
676            },
677            Predicate::IsEmpty { field } => Self::IsEmpty {
678                field: field.clone(),
679            },
680            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
681                field: field.clone(),
682            },
683            Predicate::TextContains { field, value } => Self::TextContains {
684                field: field.clone(),
685                value: value.clone(),
686            },
687            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
688                field: field.clone(),
689                value: value.clone(),
690            },
691        }
692    }
693
694    fn from_compare(compare: &ComparePredicate) -> Self {
695        Self::Compare {
696            field: compare.field.clone(),
697            op: compare.op,
698            value: compare.value.clone(),
699            coercion: compare.coercion.clone(),
700        }
701    }
702}
703
704fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
705    let Some(order) = order else {
706        return ExplainOrderBy::None;
707    };
708
709    if order.fields.is_empty() {
710        return ExplainOrderBy::None;
711    }
712
713    ExplainOrderBy::Fields(
714        order
715            .fields
716            .iter()
717            .map(|term| ExplainOrder {
718                field: term.rendered_label(),
719                direction: term.direction(),
720            })
721            .collect(),
722    )
723}
724
725const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
726    match page {
727        Some(page) => ExplainPagination::Page {
728            limit: page.limit,
729            offset: page.offset,
730        },
731        None => ExplainPagination::None,
732    }
733}
734
735const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
736    match limit {
737        Some(limit) if limit.offset == 0 => match limit.limit {
738            Some(max_rows) => ExplainDeleteLimit::Limit { max_rows },
739            None => ExplainDeleteLimit::Window {
740                limit: None,
741                offset: 0,
742            },
743        },
744        Some(limit) => ExplainDeleteLimit::Window {
745            limit: limit.limit,
746            offset: limit.offset,
747        },
748        None => ExplainDeleteLimit::None,
749    }
750}
751
752fn write_logical_explain_json(explain: &ExplainPlan, out: &mut String) {
753    let mut object = JsonWriter::begin_object(out);
754    object.field_with("mode", |out| {
755        let mut object = JsonWriter::begin_object(out);
756        match explain.mode() {
757            QueryMode::Load(spec) => {
758                object.field_str("type", "Load");
759                match spec.limit() {
760                    Some(limit) => object.field_u64("limit", u64::from(limit)),
761                    None => object.field_null("limit"),
762                }
763                object.field_u64("offset", u64::from(spec.offset()));
764            }
765            QueryMode::Delete(spec) => {
766                object.field_str("type", "Delete");
767                match spec.limit() {
768                    Some(limit) => object.field_u64("limit", u64::from(limit)),
769                    None => object.field_null("limit"),
770                }
771            }
772        }
773        object.finish();
774    });
775    object.field_with("access", |out| write_access_json(explain.access(), out));
776    match explain.filter_expr() {
777        Some(filter_expr) => object.field_str("filter_expr", filter_expr),
778        None => object.field_null("filter_expr"),
779    }
780    object.field_value_debug("predicate", explain.predicate());
781    object.field_value_debug("order_by", explain.order_by());
782    object.field_bool("distinct", explain.distinct());
783    object.field_value_debug("grouping", explain.grouping());
784    object.field_value_debug("order_pushdown", explain.order_pushdown());
785    object.field_with("page", |out| {
786        let mut object = JsonWriter::begin_object(out);
787        match explain.page() {
788            ExplainPagination::None => {
789                object.field_str("type", "None");
790            }
791            ExplainPagination::Page { limit, offset } => {
792                object.field_str("type", "Page");
793                match limit {
794                    Some(limit) => object.field_u64("limit", u64::from(*limit)),
795                    None => object.field_null("limit"),
796                }
797                object.field_u64("offset", u64::from(*offset));
798            }
799        }
800        object.finish();
801    });
802    object.field_with("delete_limit", |out| {
803        let mut object = JsonWriter::begin_object(out);
804        match explain.delete_limit() {
805            ExplainDeleteLimit::None => {
806                object.field_str("type", "None");
807            }
808            ExplainDeleteLimit::Limit { max_rows } => {
809                object.field_str("type", "Limit");
810                object.field_u64("max_rows", u64::from(*max_rows));
811            }
812            ExplainDeleteLimit::Window { limit, offset } => {
813                object.field_str("type", "Window");
814                object.field_with("limit", |out| match limit {
815                    Some(limit) => out.push_str(&limit.to_string()),
816                    None => out.push_str("null"),
817                });
818                object.field_u64("offset", u64::from(*offset));
819            }
820        }
821        object.finish();
822    });
823    object.field_value_debug("consistency", &explain.consistency());
824    object.finish();
825}