Skip to main content

icydb_core/db/query/plan/
explain.rs

1//! Deterministic, read-only explanation of logical plans; must not execute or validate.
2
3use super::{
4    AccessPath, AccessPlan, DeleteLimitSpec, LogicalPlan, OrderDirection, OrderSpec, PageSpec,
5};
6use crate::db::query::QueryMode;
7use crate::db::query::predicate::{
8    CompareOp, ComparePredicate, Predicate, coercion::CoercionSpec, normalize,
9};
10use crate::{db::query::ReadConsistency, traits::FieldValue, value::Value};
11
12///
13/// ExplainPlan
14///
15/// Stable, deterministic representation of a `LogicalPlan` for observability.
16///
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ExplainPlan {
20    pub mode: QueryMode,
21    pub access: ExplainAccessPath,
22    pub predicate: ExplainPredicate,
23    pub order_by: ExplainOrderBy,
24    pub page: ExplainPagination,
25    pub delete_limit: ExplainDeleteLimit,
26    pub consistency: ReadConsistency,
27}
28
29///
30/// ExplainAccessPath
31///
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub enum ExplainAccessPath {
35    ByKey {
36        key: Value,
37    },
38    ByKeys {
39        keys: Vec<Value>,
40    },
41    KeyRange {
42        start: Value,
43        end: Value,
44    },
45    IndexPrefix {
46        name: &'static str,
47        fields: Vec<&'static str>,
48        prefix_len: usize,
49        values: Vec<Value>,
50    },
51    FullScan,
52    Union(Vec<Self>),
53    Intersection(Vec<Self>),
54}
55
56///
57/// ExplainPredicate
58///
59
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub enum ExplainPredicate {
62    None,
63    True,
64    False,
65    And(Vec<Self>),
66    Or(Vec<Self>),
67    Not(Box<Self>),
68    Compare {
69        field: String,
70        op: CompareOp,
71        value: Value,
72        coercion: CoercionSpec,
73    },
74    IsNull {
75        field: String,
76    },
77    IsMissing {
78        field: String,
79    },
80    IsEmpty {
81        field: String,
82    },
83    IsNotEmpty {
84        field: String,
85    },
86    MapContainsKey {
87        field: String,
88        key: Value,
89        coercion: CoercionSpec,
90    },
91    MapContainsValue {
92        field: String,
93        value: Value,
94        coercion: CoercionSpec,
95    },
96    MapContainsEntry {
97        field: String,
98        key: Value,
99        value: Value,
100        coercion: CoercionSpec,
101    },
102    TextContains {
103        field: String,
104        value: Value,
105    },
106    TextContainsCi {
107        field: String,
108        value: Value,
109    },
110}
111
112///
113/// ExplainOrderBy
114///
115
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub enum ExplainOrderBy {
118    None,
119    Fields(Vec<ExplainOrder>),
120}
121
122///
123/// ExplainOrder
124///
125
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub struct ExplainOrder {
128    pub field: String,
129    pub direction: OrderDirection,
130}
131
132///
133/// ExplainPagination
134///
135
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub enum ExplainPagination {
138    None,
139    Page { limit: Option<u32>, offset: u32 },
140}
141
142///
143/// ExplainDeleteLimit
144///
145
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub enum ExplainDeleteLimit {
148    None,
149    Limit { max_rows: u32 },
150}
151
152impl<K> LogicalPlan<K>
153where
154    K: FieldValue,
155{
156    /// Produce a stable, deterministic explanation of this logical plan.
157    #[must_use]
158    pub fn explain(&self) -> ExplainPlan {
159        let predicate = match &self.predicate {
160            Some(predicate) => ExplainPredicate::from_predicate(&normalize(predicate)),
161            None => ExplainPredicate::None,
162        };
163
164        let order_by = explain_order(self.order.as_ref());
165        let page = explain_page(self.page.as_ref());
166        let delete_limit = explain_delete_limit(self.delete_limit.as_ref());
167
168        ExplainPlan {
169            mode: self.mode,
170            access: ExplainAccessPath::from_access_plan(&self.access),
171            predicate,
172            order_by,
173            page,
174            delete_limit,
175            consistency: self.consistency,
176        }
177    }
178}
179
180impl ExplainAccessPath {
181    fn from_access_plan<K>(access: &AccessPlan<K>) -> Self
182    where
183        K: FieldValue,
184    {
185        match access {
186            AccessPlan::Path(path) => Self::from_path(path),
187            AccessPlan::Union(children) => {
188                Self::Union(children.iter().map(Self::from_access_plan).collect())
189            }
190            AccessPlan::Intersection(children) => {
191                Self::Intersection(children.iter().map(Self::from_access_plan).collect())
192            }
193        }
194    }
195
196    fn from_path<K>(path: &AccessPath<K>) -> Self
197    where
198        K: FieldValue,
199    {
200        match path {
201            AccessPath::ByKey(key) => Self::ByKey {
202                key: key.to_value(),
203            },
204            AccessPath::ByKeys(keys) => Self::ByKeys {
205                keys: keys.iter().map(FieldValue::to_value).collect(),
206            },
207            AccessPath::KeyRange { start, end } => Self::KeyRange {
208                start: start.to_value(),
209                end: end.to_value(),
210            },
211            AccessPath::IndexPrefix { index, values } => Self::IndexPrefix {
212                name: index.name,
213                fields: index.fields.to_vec(),
214                prefix_len: values.len(),
215                values: values.clone(),
216            },
217            AccessPath::FullScan => Self::FullScan,
218        }
219    }
220}
221
222impl ExplainPredicate {
223    fn from_predicate(predicate: &Predicate) -> Self {
224        match predicate {
225            Predicate::True => Self::True,
226            Predicate::False => Self::False,
227            Predicate::And(children) => {
228                Self::And(children.iter().map(Self::from_predicate).collect())
229            }
230            Predicate::Or(children) => {
231                Self::Or(children.iter().map(Self::from_predicate).collect())
232            }
233            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
234            Predicate::Compare(compare) => Self::from_compare(compare),
235            Predicate::IsNull { field } => Self::IsNull {
236                field: field.clone(),
237            },
238            Predicate::IsMissing { field } => Self::IsMissing {
239                field: field.clone(),
240            },
241            Predicate::IsEmpty { field } => Self::IsEmpty {
242                field: field.clone(),
243            },
244            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
245                field: field.clone(),
246            },
247            Predicate::MapContainsKey {
248                field,
249                key,
250                coercion,
251            } => Self::MapContainsKey {
252                field: field.clone(),
253                key: key.clone(),
254                coercion: coercion.clone(),
255            },
256            Predicate::MapContainsValue {
257                field,
258                value,
259                coercion,
260            } => Self::MapContainsValue {
261                field: field.clone(),
262                value: value.clone(),
263                coercion: coercion.clone(),
264            },
265            Predicate::MapContainsEntry {
266                field,
267                key,
268                value,
269                coercion,
270            } => Self::MapContainsEntry {
271                field: field.clone(),
272                key: key.clone(),
273                value: value.clone(),
274                coercion: coercion.clone(),
275            },
276            Predicate::TextContains { field, value } => Self::TextContains {
277                field: field.clone(),
278                value: value.clone(),
279            },
280            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
281                field: field.clone(),
282                value: value.clone(),
283            },
284        }
285    }
286
287    fn from_compare(compare: &ComparePredicate) -> Self {
288        Self::Compare {
289            field: compare.field.clone(),
290            op: compare.op,
291            value: compare.value.clone(),
292            coercion: compare.coercion.clone(),
293        }
294    }
295}
296
297fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
298    let Some(order) = order else {
299        return ExplainOrderBy::None;
300    };
301
302    if order.fields.is_empty() {
303        return ExplainOrderBy::None;
304    }
305
306    ExplainOrderBy::Fields(
307        order
308            .fields
309            .iter()
310            .map(|(field, direction)| ExplainOrder {
311                field: field.clone(),
312                direction: *direction,
313            })
314            .collect(),
315    )
316}
317
318const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
319    match page {
320        Some(page) => ExplainPagination::Page {
321            limit: page.limit,
322            offset: page.offset,
323        },
324        None => ExplainPagination::None,
325    }
326}
327
328const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
329    match limit {
330        Some(limit) => ExplainDeleteLimit::Limit {
331            max_rows: limit.max_rows,
332        },
333        None => ExplainDeleteLimit::None,
334    }
335}
336
337///
338/// TESTS
339///
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::db::query::intent::{KeyAccess, access_plan_from_keys_value};
345    use crate::db::query::plan::{AccessPath, LogicalPlan};
346    use crate::db::query::predicate::Predicate;
347    use crate::db::query::{FieldRef, LoadSpec, QueryMode, ReadConsistency};
348    use crate::model::index::IndexModel;
349    use crate::types::Ulid;
350    use crate::value::Value;
351
352    #[test]
353    fn explain_is_deterministic_for_same_query() {
354        let predicate = FieldRef::new("id").eq(Ulid::default());
355        let mut plan: LogicalPlan<Value> =
356            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
357        plan.predicate = Some(predicate);
358
359        assert_eq!(plan.explain(), plan.explain());
360    }
361
362    #[test]
363    fn explain_is_deterministic_for_equivalent_predicates() {
364        let id = Ulid::default();
365
366        let predicate_a = Predicate::And(vec![
367            FieldRef::new("id").eq(id),
368            FieldRef::new("other").eq(Value::Text("x".to_string())),
369        ]);
370        let predicate_b = Predicate::And(vec![
371            FieldRef::new("other").eq(Value::Text("x".to_string())),
372            FieldRef::new("id").eq(id),
373        ]);
374
375        let mut plan_a: LogicalPlan<Value> =
376            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
377        plan_a.predicate = Some(predicate_a);
378
379        let mut plan_b: LogicalPlan<Value> =
380            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
381        plan_b.predicate = Some(predicate_b);
382
383        assert_eq!(plan_a.explain(), plan_b.explain());
384    }
385
386    #[test]
387    fn explain_is_deterministic_for_by_keys() {
388        let a = Ulid::from_u128(1);
389        let b = Ulid::from_u128(2);
390
391        let access_a = access_plan_from_keys_value(&KeyAccess::Many(vec![a, b, a]));
392        let access_b = access_plan_from_keys_value(&KeyAccess::Many(vec![b, a]));
393
394        let plan_a: LogicalPlan<Value> = LogicalPlan {
395            mode: QueryMode::Load(LoadSpec::new()),
396            access: access_a,
397            predicate: None,
398            order: None,
399            delete_limit: None,
400            page: None,
401            consistency: ReadConsistency::MissingOk,
402        };
403        let plan_b: LogicalPlan<Value> = LogicalPlan {
404            mode: QueryMode::Load(LoadSpec::new()),
405            access: access_b,
406            predicate: None,
407            order: None,
408            delete_limit: None,
409            page: None,
410            consistency: ReadConsistency::MissingOk,
411        };
412
413        assert_eq!(plan_a.explain(), plan_b.explain());
414    }
415
416    #[test]
417    fn explain_reports_deterministic_index_choice() {
418        const INDEX_FIELDS: [&str; 1] = ["idx_a"];
419        const INDEX_A: IndexModel =
420            IndexModel::new("explain::idx_a", "explain::store", &INDEX_FIELDS, false);
421        const INDEX_B: IndexModel =
422            IndexModel::new("explain::idx_a_alt", "explain::store", &INDEX_FIELDS, false);
423
424        let mut indexes = [INDEX_B, INDEX_A];
425        indexes.sort_by(|left, right| left.name.cmp(right.name));
426        let chosen = indexes[0];
427
428        let plan: LogicalPlan<Value> = LogicalPlan::new(
429            AccessPath::IndexPrefix {
430                index: chosen,
431                values: vec![Value::Text("alpha".to_string())],
432            },
433            crate::db::query::ReadConsistency::MissingOk,
434        );
435
436        let explain = plan.explain();
437        match explain.access {
438            ExplainAccessPath::IndexPrefix {
439                name,
440                fields,
441                prefix_len,
442                ..
443            } => {
444                assert_eq!(name, "explain::idx_a");
445                assert_eq!(fields, vec!["idx_a"]);
446                assert_eq!(prefix_len, 1);
447            }
448            _ => panic!("expected index prefix"),
449        }
450    }
451
452    #[test]
453    fn explain_differs_for_semantic_changes() {
454        let plan_a: LogicalPlan<Value> = LogicalPlan::new(
455            AccessPath::ByKey(Value::Ulid(Ulid::from_u128(1))),
456            ReadConsistency::MissingOk,
457        );
458        let plan_b: LogicalPlan<Value> =
459            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
460
461        assert_ne!(plan_a.explain(), plan_b.explain());
462    }
463}