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