1#![allow(clippy::cast_possible_truncation)]
3
4use super::{
5 ExplainAccessPath, ExplainDeleteLimit, ExplainOrderBy, ExplainPagination, ExplainPlan,
6 ExplainPredicate, ExplainProjection,
7};
8use crate::db::index::fingerprint::hash_value;
9use crate::db::query::QueryMode;
10use crate::db::query::{
11 ReadConsistency,
12 predicate::{CompareOp, coercion::CoercionId},
13};
14use crate::key::Key;
15use sha2::{Digest, Sha256};
16
17#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub struct PlanFingerprint([u8; 32]);
25
26impl PlanFingerprint {
27 pub(crate) const fn from_bytes(bytes: [u8; 32]) -> Self {
28 Self(bytes)
29 }
30
31 #[must_use]
32 pub fn as_hex(&self) -> String {
33 let mut out = String::with_capacity(64);
34 for byte in self.0 {
35 use std::fmt::Write as _;
36 let _ = write!(out, "{byte:02x}");
37 }
38 out
39 }
40}
41
42impl std::fmt::Display for PlanFingerprint {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.write_str(&self.as_hex())
45 }
46}
47
48impl super::LogicalPlan {
49 #[must_use]
51 pub fn fingerprint(&self) -> PlanFingerprint {
52 self.explain().fingerprint()
53 }
54}
55
56impl ExplainPlan {
57 #[must_use]
59 pub fn fingerprint(&self) -> PlanFingerprint {
60 let mut hasher = Sha256::new();
61 hasher.update(b"planfp:v2");
62 hash_explain_plan(&mut hasher, self);
63 let digest = hasher.finalize();
64 let mut out = [0u8; 32];
65 out.copy_from_slice(&digest);
66 PlanFingerprint(out)
67 }
68}
69
70fn hash_explain_plan(hasher: &mut Sha256, plan: &ExplainPlan) {
71 write_tag(hasher, 0x01);
72 hash_access(hasher, &plan.access);
73
74 write_tag(hasher, 0x02);
75 hash_predicate(hasher, &plan.predicate);
76
77 write_tag(hasher, 0x03);
78 hash_order(hasher, &plan.order_by);
79
80 write_tag(hasher, 0x04);
81 hash_page(hasher, &plan.page);
82
83 write_tag(hasher, 0x05);
84 hash_delete_limit(hasher, &plan.delete_limit);
85
86 write_tag(hasher, 0x06);
87 hash_projection(hasher, &plan.projection);
88
89 write_tag(hasher, 0x07);
90 hash_consistency(hasher, plan.consistency);
91
92 write_tag(hasher, 0x08);
93 hash_mode(hasher, plan.mode);
94}
95
96fn hash_access(hasher: &mut Sha256, access: &ExplainAccessPath) {
97 match access {
98 ExplainAccessPath::ByKey { key } => {
99 write_tag(hasher, 0x10);
100 write_key(hasher, key);
101 }
102 ExplainAccessPath::ByKeys { keys } => {
103 write_tag(hasher, 0x11);
104 write_u32(hasher, keys.len() as u32);
105 for key in keys {
106 write_key(hasher, key);
107 }
108 }
109 ExplainAccessPath::KeyRange { start, end } => {
110 write_tag(hasher, 0x12);
111 write_key(hasher, start);
112 write_key(hasher, end);
113 }
114 ExplainAccessPath::IndexPrefix {
115 name,
116 fields,
117 prefix_len,
118 values,
119 } => {
120 write_tag(hasher, 0x13);
121 write_str(hasher, name);
122 write_u32(hasher, fields.len() as u32);
123 for field in fields {
124 write_str(hasher, field);
125 }
126 write_u32(hasher, *prefix_len as u32);
127 write_u32(hasher, values.len() as u32);
128 for value in values {
129 write_value(hasher, value);
130 }
131 }
132 ExplainAccessPath::FullScan => {
133 write_tag(hasher, 0x14);
134 }
135 ExplainAccessPath::Union(children) => {
136 write_tag(hasher, 0x15);
137 write_u32(hasher, children.len() as u32);
138 for child in children {
139 hash_access(hasher, child);
140 }
141 }
142 ExplainAccessPath::Intersection(children) => {
143 write_tag(hasher, 0x16);
144 write_u32(hasher, children.len() as u32);
145 for child in children {
146 hash_access(hasher, child);
147 }
148 }
149 }
150}
151
152fn hash_predicate(hasher: &mut Sha256, predicate: &ExplainPredicate) {
153 match predicate {
154 ExplainPredicate::None => write_tag(hasher, 0x20),
155 ExplainPredicate::True => write_tag(hasher, 0x21),
156 ExplainPredicate::False => write_tag(hasher, 0x22),
157 ExplainPredicate::And(children) => {
158 write_tag(hasher, 0x23);
159 write_u32(hasher, children.len() as u32);
160 for child in children {
161 hash_predicate(hasher, child);
162 }
163 }
164 ExplainPredicate::Or(children) => {
165 write_tag(hasher, 0x24);
166 write_u32(hasher, children.len() as u32);
167 for child in children {
168 hash_predicate(hasher, child);
169 }
170 }
171 ExplainPredicate::Not(inner) => {
172 write_tag(hasher, 0x25);
173 hash_predicate(hasher, inner);
174 }
175 ExplainPredicate::Compare {
176 field,
177 op,
178 value,
179 coercion,
180 } => {
181 write_tag(hasher, 0x26);
182 write_str(hasher, field);
183 write_tag(hasher, compare_op_tag(*op));
184 write_value(hasher, value);
185 hash_coercion(hasher, coercion.id, &coercion.params);
186 }
187 ExplainPredicate::IsNull { field } => {
188 write_tag(hasher, 0x27);
189 write_str(hasher, field);
190 }
191 ExplainPredicate::IsMissing { field } => {
192 write_tag(hasher, 0x28);
193 write_str(hasher, field);
194 }
195 ExplainPredicate::IsEmpty { field } => {
196 write_tag(hasher, 0x29);
197 write_str(hasher, field);
198 }
199 ExplainPredicate::IsNotEmpty { field } => {
200 write_tag(hasher, 0x2a);
201 write_str(hasher, field);
202 }
203 ExplainPredicate::MapContainsKey {
204 field,
205 key,
206 coercion,
207 } => {
208 write_tag(hasher, 0x2b);
209 write_str(hasher, field);
210 write_value(hasher, key);
211 hash_coercion(hasher, coercion.id, &coercion.params);
212 }
213 ExplainPredicate::MapContainsValue {
214 field,
215 value,
216 coercion,
217 } => {
218 write_tag(hasher, 0x2c);
219 write_str(hasher, field);
220 write_value(hasher, value);
221 hash_coercion(hasher, coercion.id, &coercion.params);
222 }
223 ExplainPredicate::MapContainsEntry {
224 field,
225 key,
226 value,
227 coercion,
228 } => {
229 write_tag(hasher, 0x2d);
230 write_str(hasher, field);
231 write_value(hasher, key);
232 write_value(hasher, value);
233 hash_coercion(hasher, coercion.id, &coercion.params);
234 }
235 ExplainPredicate::TextContains { field, value } => {
236 write_tag(hasher, 0x2e);
237 write_str(hasher, field);
238 write_value(hasher, value);
239 }
240 ExplainPredicate::TextContainsCi { field, value } => {
241 write_tag(hasher, 0x2f);
242 write_str(hasher, field);
243 write_value(hasher, value);
244 }
245 }
246}
247
248fn hash_order(hasher: &mut Sha256, order: &ExplainOrderBy) {
249 match order {
250 ExplainOrderBy::None => write_tag(hasher, 0x30),
251 ExplainOrderBy::Fields(fields) => {
252 write_tag(hasher, 0x31);
253 write_u32(hasher, fields.len() as u32);
254 for field in fields {
255 write_str(hasher, &field.field);
256 write_tag(hasher, order_direction_tag(field.direction));
257 }
258 }
259 }
260}
261
262fn hash_page(hasher: &mut Sha256, page: &ExplainPagination) {
263 match page {
264 ExplainPagination::None => write_tag(hasher, 0x40),
265 ExplainPagination::Page { limit, offset } => {
266 write_tag(hasher, 0x41);
267 match limit {
268 Some(limit) => {
269 write_tag(hasher, 0x01);
270 write_u32(hasher, *limit);
271 }
272 None => write_tag(hasher, 0x00),
273 }
274 write_u64(hasher, *offset);
275 }
276 }
277}
278
279fn hash_delete_limit(hasher: &mut Sha256, limit: &ExplainDeleteLimit) {
280 match limit {
281 ExplainDeleteLimit::None => write_tag(hasher, 0x42),
282 ExplainDeleteLimit::Limit { max_rows } => {
283 write_tag(hasher, 0x43);
284 write_u32(hasher, *max_rows);
285 }
286 }
287}
288
289fn hash_projection(hasher: &mut Sha256, projection: &ExplainProjection) {
290 match projection {
291 ExplainProjection::All => write_tag(hasher, 0x40),
292 }
293}
294
295fn hash_consistency(hasher: &mut Sha256, consistency: ReadConsistency) {
296 match consistency {
297 ReadConsistency::MissingOk => write_tag(hasher, 0x50),
298 ReadConsistency::Strict => write_tag(hasher, 0x51),
299 }
300}
301
302fn hash_mode(hasher: &mut Sha256, mode: QueryMode) {
303 match mode {
304 QueryMode::Load(_) => write_tag(hasher, 0x60),
305 QueryMode::Delete(_) => write_tag(hasher, 0x61),
306 }
307}
308
309fn hash_coercion(
310 hasher: &mut Sha256,
311 id: CoercionId,
312 params: &std::collections::BTreeMap<String, String>,
313) {
314 write_tag(hasher, coercion_id_tag(id));
315 write_u32(hasher, params.len() as u32);
316 for (key, value) in params {
317 write_str(hasher, key);
318 write_str(hasher, value);
319 }
320}
321
322fn write_key(hasher: &mut Sha256, key: &Key) {
323 match key.to_bytes() {
324 Ok(bytes) => hasher.update(bytes),
325 Err(err) => {
326 write_tag(hasher, 0xED);
327 write_str(hasher, &err.to_string());
328 }
329 }
330}
331
332fn write_value(hasher: &mut Sha256, value: &crate::value::Value) {
333 match hash_value(value) {
334 Ok(digest) => hasher.update(digest),
335 Err(err) => {
336 write_tag(hasher, 0xEE);
337 write_str(hasher, &err.display_with_class());
338 }
339 }
340}
341
342fn write_str(hasher: &mut Sha256, value: &str) {
343 write_u32(hasher, value.len() as u32);
344 hasher.update(value.as_bytes());
345}
346
347fn write_u32(hasher: &mut Sha256, value: u32) {
348 hasher.update(value.to_be_bytes());
349}
350
351fn write_u64(hasher: &mut Sha256, value: u64) {
352 hasher.update(value.to_be_bytes());
353}
354
355fn write_tag(hasher: &mut Sha256, tag: u8) {
356 hasher.update([tag]);
357}
358
359const fn compare_op_tag(op: CompareOp) -> u8 {
360 match op {
361 CompareOp::Eq => 0x01,
362 CompareOp::Ne => 0x02,
363 CompareOp::Lt => 0x03,
364 CompareOp::Lte => 0x04,
365 CompareOp::Gt => 0x05,
366 CompareOp::Gte => 0x06,
367 CompareOp::In => 0x07,
368 CompareOp::NotIn => 0x08,
369 CompareOp::AnyIn => 0x09,
370 CompareOp::AllIn => 0x0a,
371 CompareOp::Contains => 0x0b,
372 CompareOp::StartsWith => 0x0c,
373 CompareOp::EndsWith => 0x0d,
374 }
375}
376
377const fn order_direction_tag(direction: crate::db::query::plan::OrderDirection) -> u8 {
378 match direction {
379 crate::db::query::plan::OrderDirection::Asc => 0x01,
380 crate::db::query::plan::OrderDirection::Desc => 0x02,
381 }
382}
383
384const fn coercion_id_tag(id: CoercionId) -> u8 {
385 match id {
386 CoercionId::Strict => 0x01,
387 CoercionId::NumericWiden => 0x02,
388 CoercionId::IdentifierText => 0x03,
389 CoercionId::TextCasefold => 0x04,
390 CoercionId::CollectionElement => 0x05,
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use crate::db::query::plan::{AccessPath, DeleteLimitSpec, LogicalPlan};
397 use crate::db::query::{FieldRef, plan::planner::PlannerEntity};
398 use crate::db::query::{Query, QueryMode, ReadConsistency};
399 use crate::model::index::IndexModel;
400 use crate::types::Ulid;
401 use crate::value::Value;
402
403 #[test]
404 fn fingerprint_is_deterministic_for_equivalent_predicates() {
405 let id = Ulid::default();
406
407 let query_a = Query::<PlannerEntity>::new(ReadConsistency::MissingOk)
408 .filter(FieldRef::new("id").eq(id))
409 .filter(FieldRef::new("other").eq("x"));
410
411 let query_b = Query::<PlannerEntity>::new(ReadConsistency::MissingOk)
412 .filter(FieldRef::new("other").eq("x"))
413 .filter(FieldRef::new("id").eq(id));
414
415 let plan_a = query_a.plan().expect("plan a");
416 let plan_b = query_b.plan().expect("plan b");
417
418 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
419 }
420
421 #[test]
422 fn fingerprint_changes_with_index_choice() {
423 const INDEX_FIELDS: [&str; 1] = ["idx_a"];
424 const INDEX_A: IndexModel = IndexModel::new(
425 "fingerprint::idx_a",
426 "fingerprint::store",
427 &INDEX_FIELDS,
428 false,
429 );
430 const INDEX_B: IndexModel = IndexModel::new(
431 "fingerprint::idx_b",
432 "fingerprint::store",
433 &INDEX_FIELDS,
434 false,
435 );
436
437 let plan_a = LogicalPlan::new(
438 AccessPath::IndexPrefix {
439 index: INDEX_A,
440 values: vec![Value::Text("alpha".to_string())],
441 },
442 crate::db::query::ReadConsistency::MissingOk,
443 );
444 let plan_b = LogicalPlan::new(
445 AccessPath::IndexPrefix {
446 index: INDEX_B,
447 values: vec![Value::Text("alpha".to_string())],
448 },
449 crate::db::query::ReadConsistency::MissingOk,
450 );
451
452 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
453 }
454
455 #[test]
456 fn fingerprint_changes_with_pagination() {
457 let mut plan_a = LogicalPlan::new(
458 AccessPath::FullScan,
459 crate::db::query::ReadConsistency::MissingOk,
460 );
461 let mut plan_b = LogicalPlan::new(
462 AccessPath::FullScan,
463 crate::db::query::ReadConsistency::MissingOk,
464 );
465 plan_a.page = Some(crate::db::query::plan::PageSpec {
466 limit: Some(10),
467 offset: 0,
468 });
469 plan_b.page = Some(crate::db::query::plan::PageSpec {
470 limit: Some(10),
471 offset: 1,
472 });
473
474 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
475 }
476
477 #[test]
478 fn fingerprint_changes_with_delete_limit() {
479 let mut plan_a = LogicalPlan::new(
480 AccessPath::FullScan,
481 crate::db::query::ReadConsistency::MissingOk,
482 );
483 let mut plan_b = LogicalPlan::new(
484 AccessPath::FullScan,
485 crate::db::query::ReadConsistency::MissingOk,
486 );
487 plan_a.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
488 plan_b.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
489 plan_a.delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
490 plan_b.delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
491
492 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
493 }
494
495 #[test]
496 fn fingerprint_is_stable_for_full_scan() {
497 let plan = LogicalPlan::new(
498 AccessPath::FullScan,
499 crate::db::query::ReadConsistency::MissingOk,
500 );
501 let fingerprint_a = plan.fingerprint();
502 let fingerprint_b = plan.fingerprint();
503 assert_eq!(fingerprint_a, fingerprint_b);
504 }
505}