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    TextContains {
87        field: String,
88        value: Value,
89    },
90    TextContainsCi {
91        field: String,
92        value: Value,
93    },
94}
95
96///
97/// ExplainOrderBy
98///
99
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum ExplainOrderBy {
102    None,
103    Fields(Vec<ExplainOrder>),
104}
105
106///
107/// ExplainOrder
108///
109
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub struct ExplainOrder {
112    pub field: String,
113    pub direction: OrderDirection,
114}
115
116///
117/// ExplainPagination
118///
119
120#[derive(Clone, Debug, Eq, PartialEq)]
121pub enum ExplainPagination {
122    None,
123    Page { limit: Option<u32>, offset: u32 },
124}
125
126///
127/// ExplainDeleteLimit
128///
129
130#[derive(Clone, Debug, Eq, PartialEq)]
131pub enum ExplainDeleteLimit {
132    None,
133    Limit { max_rows: u32 },
134}
135
136impl<K> LogicalPlan<K>
137where
138    K: FieldValue,
139{
140    /// Produce a stable, deterministic explanation of this logical plan.
141    #[must_use]
142    pub fn explain(&self) -> ExplainPlan {
143        let predicate = match &self.predicate {
144            Some(predicate) => ExplainPredicate::from_predicate(&normalize(predicate)),
145            None => ExplainPredicate::None,
146        };
147
148        let order_by = explain_order(self.order.as_ref());
149        let page = explain_page(self.page.as_ref());
150        let delete_limit = explain_delete_limit(self.delete_limit.as_ref());
151
152        ExplainPlan {
153            mode: self.mode,
154            access: ExplainAccessPath::from_access_plan(&self.access),
155            predicate,
156            order_by,
157            page,
158            delete_limit,
159            consistency: self.consistency,
160        }
161    }
162}
163
164impl ExplainAccessPath {
165    fn from_access_plan<K>(access: &AccessPlan<K>) -> Self
166    where
167        K: FieldValue,
168    {
169        match access {
170            AccessPlan::Path(path) => Self::from_path(path),
171            AccessPlan::Union(children) => {
172                Self::Union(children.iter().map(Self::from_access_plan).collect())
173            }
174            AccessPlan::Intersection(children) => {
175                Self::Intersection(children.iter().map(Self::from_access_plan).collect())
176            }
177        }
178    }
179
180    fn from_path<K>(path: &AccessPath<K>) -> Self
181    where
182        K: FieldValue,
183    {
184        match path {
185            AccessPath::ByKey(key) => Self::ByKey {
186                key: key.to_value(),
187            },
188            AccessPath::ByKeys(keys) => Self::ByKeys {
189                keys: keys.iter().map(FieldValue::to_value).collect(),
190            },
191            AccessPath::KeyRange { start, end } => Self::KeyRange {
192                start: start.to_value(),
193                end: end.to_value(),
194            },
195            AccessPath::IndexPrefix { index, values } => Self::IndexPrefix {
196                name: index.name,
197                fields: index.fields.to_vec(),
198                prefix_len: values.len(),
199                values: values.clone(),
200            },
201            AccessPath::FullScan => Self::FullScan,
202        }
203    }
204}
205
206impl ExplainPredicate {
207    fn from_predicate(predicate: &Predicate) -> Self {
208        match predicate {
209            Predicate::True => Self::True,
210            Predicate::False => Self::False,
211            Predicate::And(children) => {
212                Self::And(children.iter().map(Self::from_predicate).collect())
213            }
214            Predicate::Or(children) => {
215                Self::Or(children.iter().map(Self::from_predicate).collect())
216            }
217            Predicate::Not(inner) => Self::Not(Box::new(Self::from_predicate(inner))),
218            Predicate::Compare(compare) => Self::from_compare(compare),
219            Predicate::IsNull { field } => Self::IsNull {
220                field: field.clone(),
221            },
222            Predicate::IsMissing { field } => Self::IsMissing {
223                field: field.clone(),
224            },
225            Predicate::IsEmpty { field } => Self::IsEmpty {
226                field: field.clone(),
227            },
228            Predicate::IsNotEmpty { field } => Self::IsNotEmpty {
229                field: field.clone(),
230            },
231            Predicate::TextContains { field, value } => Self::TextContains {
232                field: field.clone(),
233                value: value.clone(),
234            },
235            Predicate::TextContainsCi { field, value } => Self::TextContainsCi {
236                field: field.clone(),
237                value: value.clone(),
238            },
239        }
240    }
241
242    fn from_compare(compare: &ComparePredicate) -> Self {
243        Self::Compare {
244            field: compare.field.clone(),
245            op: compare.op,
246            value: compare.value.clone(),
247            coercion: compare.coercion.clone(),
248        }
249    }
250}
251
252fn explain_order(order: Option<&OrderSpec>) -> ExplainOrderBy {
253    let Some(order) = order else {
254        return ExplainOrderBy::None;
255    };
256
257    if order.fields.is_empty() {
258        return ExplainOrderBy::None;
259    }
260
261    ExplainOrderBy::Fields(
262        order
263            .fields
264            .iter()
265            .map(|(field, direction)| ExplainOrder {
266                field: field.clone(),
267                direction: *direction,
268            })
269            .collect(),
270    )
271}
272
273const fn explain_page(page: Option<&PageSpec>) -> ExplainPagination {
274    match page {
275        Some(page) => ExplainPagination::Page {
276            limit: page.limit,
277            offset: page.offset,
278        },
279        None => ExplainPagination::None,
280    }
281}
282
283const fn explain_delete_limit(limit: Option<&DeleteLimitSpec>) -> ExplainDeleteLimit {
284    match limit {
285        Some(limit) => ExplainDeleteLimit::Limit {
286            max_rows: limit.max_rows,
287        },
288        None => ExplainDeleteLimit::None,
289    }
290}
291
292///
293/// TESTS
294///
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::db::query::intent::{KeyAccess, access_plan_from_keys_value};
300    use crate::db::query::plan::{AccessPath, LogicalPlan};
301    use crate::db::query::predicate::Predicate;
302    use crate::db::query::{FieldRef, LoadSpec, QueryMode, ReadConsistency};
303    use crate::model::index::IndexModel;
304    use crate::types::Ulid;
305    use crate::value::Value;
306
307    #[test]
308    fn explain_is_deterministic_for_same_query() {
309        let predicate = FieldRef::new("id").eq(Ulid::default());
310        let mut plan: LogicalPlan<Value> =
311            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
312        plan.predicate = Some(predicate);
313
314        assert_eq!(plan.explain(), plan.explain());
315    }
316
317    #[test]
318    fn explain_is_deterministic_for_equivalent_predicates() {
319        let id = Ulid::default();
320
321        let predicate_a = Predicate::And(vec![
322            FieldRef::new("id").eq(id),
323            FieldRef::new("other").eq(Value::Text("x".to_string())),
324        ]);
325        let predicate_b = Predicate::And(vec![
326            FieldRef::new("other").eq(Value::Text("x".to_string())),
327            FieldRef::new("id").eq(id),
328        ]);
329
330        let mut plan_a: LogicalPlan<Value> =
331            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
332        plan_a.predicate = Some(predicate_a);
333
334        let mut plan_b: LogicalPlan<Value> =
335            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
336        plan_b.predicate = Some(predicate_b);
337
338        assert_eq!(plan_a.explain(), plan_b.explain());
339    }
340
341    #[test]
342    fn explain_is_deterministic_for_by_keys() {
343        let a = Ulid::from_u128(1);
344        let b = Ulid::from_u128(2);
345
346        let access_a = access_plan_from_keys_value(&KeyAccess::Many(vec![a, b, a]));
347        let access_b = access_plan_from_keys_value(&KeyAccess::Many(vec![b, a]));
348
349        let plan_a: LogicalPlan<Value> = LogicalPlan {
350            mode: QueryMode::Load(LoadSpec::new()),
351            access: access_a,
352            predicate: None,
353            order: None,
354            delete_limit: None,
355            page: None,
356            consistency: ReadConsistency::MissingOk,
357        };
358        let plan_b: LogicalPlan<Value> = LogicalPlan {
359            mode: QueryMode::Load(LoadSpec::new()),
360            access: access_b,
361            predicate: None,
362            order: None,
363            delete_limit: None,
364            page: None,
365            consistency: ReadConsistency::MissingOk,
366        };
367
368        assert_eq!(plan_a.explain(), plan_b.explain());
369    }
370
371    #[test]
372    fn explain_reports_deterministic_index_choice() {
373        const INDEX_FIELDS: [&str; 1] = ["idx_a"];
374        const INDEX_A: IndexModel =
375            IndexModel::new("explain::idx_a", "explain::store", &INDEX_FIELDS, false);
376        const INDEX_B: IndexModel =
377            IndexModel::new("explain::idx_a_alt", "explain::store", &INDEX_FIELDS, false);
378
379        let mut indexes = [INDEX_B, INDEX_A];
380        indexes.sort_by(|left, right| left.name.cmp(right.name));
381        let chosen = indexes[0];
382
383        let plan: LogicalPlan<Value> = LogicalPlan::new(
384            AccessPath::IndexPrefix {
385                index: chosen,
386                values: vec![Value::Text("alpha".to_string())],
387            },
388            crate::db::query::ReadConsistency::MissingOk,
389        );
390
391        let explain = plan.explain();
392        match explain.access {
393            ExplainAccessPath::IndexPrefix {
394                name,
395                fields,
396                prefix_len,
397                ..
398            } => {
399                assert_eq!(name, "explain::idx_a");
400                assert_eq!(fields, vec!["idx_a"]);
401                assert_eq!(prefix_len, 1);
402            }
403            _ => panic!("expected index prefix"),
404        }
405    }
406
407    #[test]
408    fn explain_differs_for_semantic_changes() {
409        let plan_a: LogicalPlan<Value> = LogicalPlan::new(
410            AccessPath::ByKey(Value::Ulid(Ulid::from_u128(1))),
411            ReadConsistency::MissingOk,
412        );
413        let plan_b: LogicalPlan<Value> =
414            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
415
416        assert_ne!(plan_a.explain(), plan_b.explain());
417    }
418}