icydb_core/db/query/plan/
fingerprint.rs1#![allow(clippy::cast_possible_truncation)]
3
4use super::ExplainPlan;
5use crate::{db::query::plan::hash_parts, traits::FieldValue};
6use sha2::{Digest, Sha256};
7
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct PlanFingerprint([u8; 32]);
16
17impl PlanFingerprint {
18 pub(crate) const fn from_bytes(bytes: [u8; 32]) -> Self {
19 Self(bytes)
20 }
21
22 #[must_use]
23 pub fn as_hex(&self) -> String {
24 let mut out = String::with_capacity(64);
25 for byte in self.0 {
26 use std::fmt::Write as _;
27 let _ = write!(out, "{byte:02x}");
28 }
29 out
30 }
31}
32
33impl std::fmt::Display for PlanFingerprint {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.write_str(&self.as_hex())
36 }
37}
38
39impl<K> super::LogicalPlan<K>
40where
41 K: FieldValue,
42{
43 #[must_use]
45 pub fn fingerprint(&self) -> PlanFingerprint {
46 self.explain().fingerprint()
47 }
48}
49
50impl ExplainPlan {
51 #[must_use]
53 pub fn fingerprint(&self) -> PlanFingerprint {
54 let mut hasher = Sha256::new();
55 hasher.update(b"planfp:v2");
56 hash_parts::hash_explain_plan_profile(
57 &mut hasher,
58 self,
59 hash_parts::ExplainHashProfile::FingerprintV2,
60 );
61 let digest = hasher.finalize();
62 let mut out = [0u8; 32];
63 out.copy_from_slice(&digest);
64 PlanFingerprint(out)
65 }
66}
67
68#[cfg(test)]
73mod tests {
74 use crate::db::query::intent::{KeyAccess, access_plan_from_keys_value};
75 use crate::db::query::plan::{AccessPath, DeleteLimitSpec, LogicalPlan};
76 use crate::db::query::predicate::Predicate;
77 use crate::db::query::{FieldRef, QueryMode, ReadConsistency};
78 use crate::model::index::IndexModel;
79 use crate::types::Ulid;
80 use crate::value::Value;
81
82 #[test]
83 fn fingerprint_is_deterministic_for_equivalent_predicates() {
84 let id = Ulid::default();
85
86 let predicate_a = Predicate::And(vec![
87 FieldRef::new("id").eq(id),
88 FieldRef::new("other").eq(Value::Text("x".to_string())),
89 ]);
90 let predicate_b = Predicate::And(vec![
91 FieldRef::new("other").eq(Value::Text("x".to_string())),
92 FieldRef::new("id").eq(id),
93 ]);
94
95 let mut plan_a: LogicalPlan<Value> =
96 LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
97 plan_a.predicate = Some(predicate_a);
98
99 let mut plan_b: LogicalPlan<Value> =
100 LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
101 plan_b.predicate = Some(predicate_b);
102
103 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
104 }
105
106 #[test]
107 fn fingerprint_is_deterministic_for_by_keys() {
108 let a = Ulid::from_u128(1);
109 let b = Ulid::from_u128(2);
110
111 let access_a = access_plan_from_keys_value(&KeyAccess::Many(vec![a, b, a]));
112 let access_b = access_plan_from_keys_value(&KeyAccess::Many(vec![b, a]));
113
114 let plan_a: LogicalPlan<Value> = LogicalPlan {
115 mode: QueryMode::Load(crate::db::query::LoadSpec::new()),
116 access: access_a,
117 predicate: None,
118 order: None,
119 delete_limit: None,
120 page: None,
121 consistency: ReadConsistency::MissingOk,
122 };
123 let plan_b: LogicalPlan<Value> = LogicalPlan {
124 mode: QueryMode::Load(crate::db::query::LoadSpec::new()),
125 access: access_b,
126 predicate: None,
127 order: None,
128 delete_limit: None,
129 page: None,
130 consistency: ReadConsistency::MissingOk,
131 };
132
133 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
134 }
135
136 #[test]
137 fn fingerprint_changes_with_index_choice() {
138 const INDEX_FIELDS: [&str; 1] = ["idx_a"];
139 const INDEX_A: IndexModel = IndexModel::new(
140 "fingerprint::idx_a",
141 "fingerprint::store",
142 &INDEX_FIELDS,
143 false,
144 );
145 const INDEX_B: IndexModel = IndexModel::new(
146 "fingerprint::idx_b",
147 "fingerprint::store",
148 &INDEX_FIELDS,
149 false,
150 );
151
152 let plan_a: LogicalPlan<Value> = LogicalPlan::new(
153 AccessPath::IndexPrefix {
154 index: INDEX_A,
155 values: vec![Value::Text("alpha".to_string())],
156 },
157 crate::db::query::ReadConsistency::MissingOk,
158 );
159 let plan_b: LogicalPlan<Value> = LogicalPlan::new(
160 AccessPath::IndexPrefix {
161 index: INDEX_B,
162 values: vec![Value::Text("alpha".to_string())],
163 },
164 crate::db::query::ReadConsistency::MissingOk,
165 );
166
167 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
168 }
169
170 #[test]
171 fn fingerprint_changes_with_pagination() {
172 let mut plan_a: LogicalPlan<Value> =
173 LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
174 let mut plan_b: LogicalPlan<Value> =
175 LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
176 plan_a.page = Some(crate::db::query::plan::PageSpec {
177 limit: Some(10),
178 offset: 0,
179 });
180 plan_b.page = Some(crate::db::query::plan::PageSpec {
181 limit: Some(10),
182 offset: 1,
183 });
184
185 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
186 }
187
188 #[test]
189 fn fingerprint_changes_with_delete_limit() {
190 let mut plan_a: LogicalPlan<Value> =
191 LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
192 let mut plan_b: LogicalPlan<Value> =
193 LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
194 plan_a.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
195 plan_b.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
196 plan_a.delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
197 plan_b.delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
198
199 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
200 }
201
202 #[test]
203 fn fingerprint_is_stable_for_full_scan() {
204 let plan: LogicalPlan<Value> =
205 LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
206 let fingerprint_a = plan.fingerprint();
207 let fingerprint_b = plan.fingerprint();
208 assert_eq!(fingerprint_a, fingerprint_b);
209 }
210}