1use crate::{
7 db::{
8 codec::cursor::encode_cursor,
9 query::plan::AccessPlannedQuery,
10 query::{explain::ExplainPlan, fingerprint::hash_parts},
11 },
12 traits::FieldValue,
13};
14use sha2::{Digest, Sha256};
15
16#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
23pub struct PlanFingerprint([u8; 32]);
24
25impl PlanFingerprint {
26 #[must_use]
27 pub fn as_hex(&self) -> String {
28 encode_cursor(&self.0)
29 }
30}
31
32impl std::fmt::Display for PlanFingerprint {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.write_str(&self.as_hex())
35 }
36}
37
38impl<K> AccessPlannedQuery<K>
39where
40 K: FieldValue,
41{
42 #[must_use]
44 #[cfg(test)]
45 pub(crate) fn fingerprint(&self) -> PlanFingerprint {
46 let explain = self.explain();
47 let projection = self.projection_spec_for_identity();
48 let mut hasher = Sha256::new();
49 hasher.update(b"planfp:v2");
50 hash_parts::hash_explain_plan_profile_with_projection(
51 &mut hasher,
52 &explain,
53 hash_parts::ExplainHashProfile::FingerprintV2,
54 &projection,
55 );
56 let digest = hasher.finalize();
57 let mut out = [0u8; 32];
58 out.copy_from_slice(&digest);
59
60 PlanFingerprint(out)
61 }
62}
63
64impl ExplainPlan {
65 #[must_use]
67 pub fn fingerprint(&self) -> PlanFingerprint {
68 let mut hasher = Sha256::new();
70 hasher.update(b"planfp:v2");
71 hash_parts::hash_explain_plan_profile(
72 &mut hasher,
73 self,
74 hash_parts::ExplainHashProfile::FingerprintV2,
75 );
76
77 let digest = hasher.finalize();
79 let mut out = [0u8; 32];
80 out.copy_from_slice(&digest);
81
82 PlanFingerprint(out)
83 }
84}
85
86#[cfg(test)]
91mod tests {
92 use std::ops::Bound;
93
94 use crate::db::access::AccessPath;
95 use crate::db::predicate::{
96 CoercionId, CompareOp, ComparePredicate, MissingRowPolicy, Predicate,
97 };
98 use crate::db::query::explain::{ExplainGroupedStrategy, ExplainGrouping};
99 use crate::db::query::fingerprint::hash_parts;
100 use crate::db::query::intent::{KeyAccess, build_access_plan_from_keys};
101 use crate::db::query::plan::expr::{
102 Alias, BinaryOp, Expr, FieldId, ProjectionField, ProjectionSpec,
103 };
104 use crate::db::query::plan::{
105 AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
106 GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, LoadSpec, LogicalPlan, PageSpec,
107 QueryMode,
108 };
109 use crate::db::query::{builder::field::FieldRef, builder::sum};
110 use crate::model::index::IndexModel;
111 use crate::types::{Decimal, Ulid};
112 use crate::value::Value;
113 use sha2::{Digest, Sha256};
114
115 fn fingerprint_with_projection(
116 plan: &AccessPlannedQuery<Value>,
117 projection: &ProjectionSpec,
118 ) -> super::PlanFingerprint {
119 let explain = plan.explain();
120 let mut hasher = Sha256::new();
121 hasher.update(b"planfp:v2");
122 hash_parts::hash_explain_plan_profile_with_projection(
123 &mut hasher,
124 &explain,
125 hash_parts::ExplainHashProfile::FingerprintV2,
126 projection,
127 );
128 let digest = hasher.finalize();
129 let mut out = [0u8; 32];
130 out.copy_from_slice(&digest);
131
132 super::PlanFingerprint(out)
133 }
134
135 fn full_scan_query() -> AccessPlannedQuery<Value> {
136 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
137 }
138
139 fn index_prefix_query(index: IndexModel, values: Vec<Value>) -> AccessPlannedQuery<Value> {
140 AccessPlannedQuery::new(
141 AccessPath::IndexPrefix { index, values },
142 MissingRowPolicy::Ignore,
143 )
144 }
145
146 fn index_range_query(
147 index: IndexModel,
148 prefix: Vec<Value>,
149 lower: Bound<Value>,
150 upper: Bound<Value>,
151 ) -> AccessPlannedQuery<Value> {
152 AccessPlannedQuery::new(
153 AccessPath::index_range(index, prefix, lower, upper),
154 MissingRowPolicy::Ignore,
155 )
156 }
157
158 fn grouped_explain_with_fixed_shape() -> crate::db::query::explain::ExplainPlan {
159 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
160 .into_grouped(GroupSpec {
161 group_fields: vec![FieldSlot::from_parts_for_test(1, "rank")],
162 aggregates: vec![GroupAggregateSpec {
163 kind: AggregateKind::Count,
164 target_field: None,
165 distinct: false,
166 }],
167 execution: GroupedExecutionConfig::with_hard_limits(64, 4096),
168 })
169 .explain()
170 }
171
172 #[test]
173 fn fingerprint_is_deterministic_for_equivalent_predicates() {
174 let id = Ulid::default();
175
176 let predicate_a = Predicate::And(vec![
177 FieldRef::new("id").eq(id),
178 FieldRef::new("other").eq(Value::Text("x".to_string())),
179 ]);
180 let predicate_b = Predicate::And(vec![
181 FieldRef::new("other").eq(Value::Text("x".to_string())),
182 FieldRef::new("id").eq(id),
183 ]);
184
185 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
186 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
187
188 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
189 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
190
191 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
192 }
193
194 #[test]
195 fn fingerprint_and_signature_are_stable_for_reordered_and_non_canonical_map_predicates() {
196 let map_a = Value::Map(vec![
197 (Value::Text("z".to_string()), Value::Int(9)),
198 (Value::Text("a".to_string()), Value::Int(1)),
199 ]);
200 let map_b = Value::Map(vec![
201 (Value::Text("a".to_string()), Value::Int(1)),
202 (Value::Text("z".to_string()), Value::Int(9)),
203 ]);
204
205 let predicate_a = Predicate::And(vec![
206 FieldRef::new("other").eq(Value::Text("x".to_string())),
207 Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_a)),
208 ]);
209 let predicate_b = Predicate::And(vec![
210 Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_b)),
211 FieldRef::new("other").eq(Value::Text("x".to_string())),
212 ]);
213
214 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
215 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
216
217 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
218 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
219
220 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
221 assert_eq!(
222 plan_a.continuation_signature("tests::Entity"),
223 plan_b.continuation_signature("tests::Entity")
224 );
225 }
226
227 #[test]
228 fn fingerprint_and_signature_treat_equivalent_decimal_predicate_literals_as_identical() {
229 let predicate_a = Predicate::Compare(ComparePredicate::eq(
230 "rank".to_string(),
231 Value::Decimal(Decimal::new(10, 1)),
232 ));
233 let predicate_b = Predicate::Compare(ComparePredicate::eq(
234 "rank".to_string(),
235 Value::Decimal(Decimal::new(100, 2)),
236 ));
237
238 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
239 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
240
241 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
242 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
243
244 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
245 assert_eq!(
246 plan_a.continuation_signature("tests::Entity"),
247 plan_b.continuation_signature("tests::Entity")
248 );
249 }
250
251 #[test]
252 fn fingerprint_and_signature_treat_equivalent_in_list_predicates_as_identical() {
253 let predicate_a = Predicate::Compare(ComparePredicate::in_(
254 "rank".to_string(),
255 vec![Value::Uint(3), Value::Uint(1), Value::Uint(2)],
256 ));
257 let predicate_b = Predicate::Compare(ComparePredicate::in_(
258 "rank".to_string(),
259 vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
260 ));
261
262 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
263 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
264
265 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
266 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
267
268 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
269 assert_eq!(
270 plan_a.continuation_signature("tests::Entity"),
271 plan_b.continuation_signature("tests::Entity")
272 );
273 }
274
275 #[test]
276 fn fingerprint_and_signature_treat_equivalent_in_list_duplicate_literals_as_identical() {
277 let predicate_a = Predicate::Compare(ComparePredicate::in_(
278 "rank".to_string(),
279 vec![
280 Value::Uint(3),
281 Value::Uint(1),
282 Value::Uint(3),
283 Value::Uint(2),
284 ],
285 ));
286 let predicate_b = Predicate::Compare(ComparePredicate::in_(
287 "rank".to_string(),
288 vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
289 ));
290
291 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
292 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
293
294 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
295 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
296
297 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
298 assert_eq!(
299 plan_a.continuation_signature("tests::Entity"),
300 plan_b.continuation_signature("tests::Entity")
301 );
302 }
303
304 #[test]
305 fn fingerprint_and_signature_treat_implicit_and_explicit_strict_coercion_as_identical() {
306 let predicate_a =
307 Predicate::Compare(ComparePredicate::eq("rank".to_string(), Value::Int(7)));
308 let predicate_b = Predicate::Compare(ComparePredicate::with_coercion(
309 "rank",
310 CompareOp::Eq,
311 Value::Int(7),
312 CoercionId::Strict,
313 ));
314
315 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
316 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
317
318 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
319 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
320
321 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
322 assert_eq!(
323 plan_a.continuation_signature("tests::Entity"),
324 plan_b.continuation_signature("tests::Entity")
325 );
326 }
327
328 #[test]
329 fn fingerprint_and_signature_distinguish_different_coercion_ids() {
330 let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
331 "rank",
332 CompareOp::Eq,
333 Value::Int(7),
334 CoercionId::Strict,
335 ));
336 let predicate_numeric_widen = Predicate::Compare(ComparePredicate::with_coercion(
337 "rank",
338 CompareOp::Eq,
339 Value::Int(7),
340 CoercionId::NumericWiden,
341 ));
342
343 let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
344 strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
345
346 let mut numeric_widen_plan: AccessPlannedQuery<Value> = full_scan_query();
347 numeric_widen_plan.scalar_plan_mut().predicate = Some(predicate_numeric_widen);
348
349 assert_ne!(strict_plan.fingerprint(), numeric_widen_plan.fingerprint());
350 assert_ne!(
351 strict_plan.continuation_signature("tests::Entity"),
352 numeric_widen_plan.continuation_signature("tests::Entity")
353 );
354 }
355
356 #[test]
357 fn fingerprint_and_signature_treat_numeric_widen_equivalent_literal_subtypes_as_identical() {
358 let predicate_int = Predicate::Compare(ComparePredicate::with_coercion(
359 "rank",
360 CompareOp::Eq,
361 Value::Int(1),
362 CoercionId::NumericWiden,
363 ));
364 let predicate_decimal = Predicate::Compare(ComparePredicate::with_coercion(
365 "rank",
366 CompareOp::Eq,
367 Value::Decimal(Decimal::new(10, 1)),
368 CoercionId::NumericWiden,
369 ));
370
371 let mut int_plan: AccessPlannedQuery<Value> = full_scan_query();
372 int_plan.scalar_plan_mut().predicate = Some(predicate_int);
373
374 let mut decimal_plan: AccessPlannedQuery<Value> = full_scan_query();
375 decimal_plan.scalar_plan_mut().predicate = Some(predicate_decimal);
376
377 assert_eq!(int_plan.fingerprint(), decimal_plan.fingerprint());
378 assert_eq!(
379 int_plan.continuation_signature("tests::Entity"),
380 decimal_plan.continuation_signature("tests::Entity")
381 );
382 }
383
384 #[test]
385 fn fingerprint_and_signature_treat_text_casefold_case_only_literals_as_identical() {
386 let predicate_lower = Predicate::Compare(ComparePredicate::with_coercion(
387 "name",
388 CompareOp::Eq,
389 Value::Text("ada".to_string()),
390 CoercionId::TextCasefold,
391 ));
392 let predicate_upper = Predicate::Compare(ComparePredicate::with_coercion(
393 "name",
394 CompareOp::Eq,
395 Value::Text("ADA".to_string()),
396 CoercionId::TextCasefold,
397 ));
398
399 let mut lower_plan: AccessPlannedQuery<Value> = full_scan_query();
400 lower_plan.scalar_plan_mut().predicate = Some(predicate_lower);
401
402 let mut upper_plan: AccessPlannedQuery<Value> = full_scan_query();
403 upper_plan.scalar_plan_mut().predicate = Some(predicate_upper);
404
405 assert_eq!(lower_plan.fingerprint(), upper_plan.fingerprint());
406 assert_eq!(
407 lower_plan.continuation_signature("tests::Entity"),
408 upper_plan.continuation_signature("tests::Entity")
409 );
410 }
411
412 #[test]
413 fn fingerprint_and_signature_keep_strict_text_case_variants_distinct() {
414 let predicate_lower = Predicate::Compare(ComparePredicate::with_coercion(
415 "name",
416 CompareOp::Eq,
417 Value::Text("ada".to_string()),
418 CoercionId::Strict,
419 ));
420 let predicate_upper = Predicate::Compare(ComparePredicate::with_coercion(
421 "name",
422 CompareOp::Eq,
423 Value::Text("ADA".to_string()),
424 CoercionId::Strict,
425 ));
426
427 let mut lower_plan: AccessPlannedQuery<Value> = full_scan_query();
428 lower_plan.scalar_plan_mut().predicate = Some(predicate_lower);
429
430 let mut upper_plan: AccessPlannedQuery<Value> = full_scan_query();
431 upper_plan.scalar_plan_mut().predicate = Some(predicate_upper);
432
433 assert_ne!(lower_plan.fingerprint(), upper_plan.fingerprint());
434 assert_ne!(
435 lower_plan.continuation_signature("tests::Entity"),
436 upper_plan.continuation_signature("tests::Entity")
437 );
438 }
439
440 #[test]
441 fn fingerprint_and_signature_treat_text_casefold_in_list_case_variants_as_identical() {
442 let predicate_mixed = Predicate::Compare(ComparePredicate::with_coercion(
443 "name",
444 CompareOp::In,
445 Value::List(vec![
446 Value::Text("ADA".to_string()),
447 Value::Text("ada".to_string()),
448 Value::Text("Bob".to_string()),
449 ]),
450 CoercionId::TextCasefold,
451 ));
452 let predicate_canonical = Predicate::Compare(ComparePredicate::with_coercion(
453 "name",
454 CompareOp::In,
455 Value::List(vec![
456 Value::Text("ada".to_string()),
457 Value::Text("bob".to_string()),
458 ]),
459 CoercionId::TextCasefold,
460 ));
461
462 let mut mixed_plan: AccessPlannedQuery<Value> = full_scan_query();
463 mixed_plan.scalar_plan_mut().predicate = Some(predicate_mixed);
464
465 let mut canonical_plan: AccessPlannedQuery<Value> = full_scan_query();
466 canonical_plan.scalar_plan_mut().predicate = Some(predicate_canonical);
467
468 assert_eq!(mixed_plan.fingerprint(), canonical_plan.fingerprint());
469 assert_eq!(
470 mixed_plan.continuation_signature("tests::Entity"),
471 canonical_plan.continuation_signature("tests::Entity")
472 );
473 }
474
475 #[test]
476 fn fingerprint_and_signature_distinguish_strict_from_text_casefold_coercion() {
477 let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
478 "name",
479 CompareOp::Eq,
480 Value::Text("ada".to_string()),
481 CoercionId::Strict,
482 ));
483 let predicate_casefold = Predicate::Compare(ComparePredicate::with_coercion(
484 "name",
485 CompareOp::Eq,
486 Value::Text("ada".to_string()),
487 CoercionId::TextCasefold,
488 ));
489
490 let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
491 strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
492
493 let mut casefold_plan: AccessPlannedQuery<Value> = full_scan_query();
494 casefold_plan.scalar_plan_mut().predicate = Some(predicate_casefold);
495
496 assert_ne!(strict_plan.fingerprint(), casefold_plan.fingerprint());
497 assert_ne!(
498 strict_plan.continuation_signature("tests::Entity"),
499 casefold_plan.continuation_signature("tests::Entity")
500 );
501 }
502
503 #[test]
504 fn fingerprint_and_signature_distinguish_strict_from_collection_element_coercion() {
505 let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
506 "rank",
507 CompareOp::Eq,
508 Value::Int(7),
509 CoercionId::Strict,
510 ));
511 let predicate_collection_element = Predicate::Compare(ComparePredicate::with_coercion(
512 "rank",
513 CompareOp::Eq,
514 Value::Int(7),
515 CoercionId::CollectionElement,
516 ));
517
518 let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
519 strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
520
521 let mut collection_plan: AccessPlannedQuery<Value> = full_scan_query();
522 collection_plan.scalar_plan_mut().predicate = Some(predicate_collection_element);
523
524 assert_ne!(strict_plan.fingerprint(), collection_plan.fingerprint());
525 assert_ne!(
526 strict_plan.continuation_signature("tests::Entity"),
527 collection_plan.continuation_signature("tests::Entity")
528 );
529 }
530
531 #[test]
532 fn fingerprint_is_deterministic_for_by_keys() {
533 let a = Ulid::from_u128(1);
534 let b = Ulid::from_u128(2);
535
536 let access_a = build_access_plan_from_keys(&KeyAccess::Many(vec![a, b, a]));
537 let access_b = build_access_plan_from_keys(&KeyAccess::Many(vec![b, a]));
538
539 let plan_a: AccessPlannedQuery<Value> = AccessPlannedQuery {
540 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
541 mode: QueryMode::Load(LoadSpec::new()),
542 predicate: None,
543 order: None,
544 distinct: false,
545 delete_limit: None,
546 page: None,
547 consistency: MissingRowPolicy::Ignore,
548 }),
549 access: access_a,
550 };
551 let plan_b: AccessPlannedQuery<Value> = AccessPlannedQuery {
552 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
553 mode: QueryMode::Load(LoadSpec::new()),
554 predicate: None,
555 order: None,
556 distinct: false,
557 delete_limit: None,
558 page: None,
559 consistency: MissingRowPolicy::Ignore,
560 }),
561 access: access_b,
562 };
563
564 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
565 }
566
567 #[test]
568 fn fingerprint_changes_with_index_choice() {
569 const INDEX_FIELDS: [&str; 1] = ["idx_a"];
570 const INDEX_A: IndexModel = IndexModel::new(
571 "fingerprint::idx_a",
572 "fingerprint::store",
573 &INDEX_FIELDS,
574 false,
575 );
576 const INDEX_B: IndexModel = IndexModel::new(
577 "fingerprint::idx_b",
578 "fingerprint::store",
579 &INDEX_FIELDS,
580 false,
581 );
582
583 let plan_a: AccessPlannedQuery<Value> =
584 index_prefix_query(INDEX_A, vec![Value::Text("alpha".to_string())]);
585 let plan_b: AccessPlannedQuery<Value> =
586 index_prefix_query(INDEX_B, vec![Value::Text("alpha".to_string())]);
587
588 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
589 }
590
591 #[test]
592 fn fingerprint_changes_with_pagination() {
593 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
594 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
595 plan_a.scalar_plan_mut().page = Some(PageSpec {
596 limit: Some(10),
597 offset: 0,
598 });
599 plan_b.scalar_plan_mut().page = Some(PageSpec {
600 limit: Some(10),
601 offset: 1,
602 });
603
604 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
605 }
606
607 #[test]
608 fn fingerprint_changes_with_delete_limit() {
609 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
610 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
611 plan_a.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
612 plan_b.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
613 plan_a.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
614 plan_b.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
615
616 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
617 }
618
619 #[test]
620 fn fingerprint_changes_with_distinct_flag() {
621 let plan_a: AccessPlannedQuery<Value> = full_scan_query();
622 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
623 plan_b.scalar_plan_mut().distinct = true;
624
625 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
626 }
627
628 #[test]
629 fn fingerprint_numeric_projection_alias_only_change_does_not_invalidate() {
630 let plan: AccessPlannedQuery<Value> = full_scan_query();
631 let numeric_projection =
632 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
633 expr: Expr::Binary {
634 op: crate::db::query::plan::expr::BinaryOp::Add,
635 left: Box::new(Expr::Field(FieldId::new("rank"))),
636 right: Box::new(Expr::Literal(Value::Int(1))),
637 },
638 alias: None,
639 }]);
640 let alias_only_numeric_projection =
641 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
642 expr: Expr::Alias {
643 expr: Box::new(Expr::Binary {
644 op: crate::db::query::plan::expr::BinaryOp::Add,
645 left: Box::new(Expr::Field(FieldId::new("rank"))),
646 right: Box::new(Expr::Literal(Value::Int(1))),
647 }),
648 name: Alias::new("rank_plus_one_expr"),
649 },
650 alias: Some(Alias::new("rank_plus_one")),
651 }]);
652
653 let semantic_fingerprint = fingerprint_with_projection(&plan, &numeric_projection);
654 let alias_fingerprint = fingerprint_with_projection(&plan, &alias_only_numeric_projection);
655
656 assert_eq!(
657 semantic_fingerprint, alias_fingerprint,
658 "numeric projection alias wrappers must not affect fingerprint identity",
659 );
660 }
661
662 #[test]
663 fn fingerprint_numeric_projection_semantic_change_invalidates() {
664 let plan: AccessPlannedQuery<Value> = full_scan_query();
665 let projection_add_one =
666 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
667 expr: Expr::Binary {
668 op: crate::db::query::plan::expr::BinaryOp::Add,
669 left: Box::new(Expr::Field(FieldId::new("rank"))),
670 right: Box::new(Expr::Literal(Value::Int(1))),
671 },
672 alias: None,
673 }]);
674 let projection_mul_one =
675 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
676 expr: Expr::Binary {
677 op: crate::db::query::plan::expr::BinaryOp::Mul,
678 left: Box::new(Expr::Field(FieldId::new("rank"))),
679 right: Box::new(Expr::Literal(Value::Int(1))),
680 },
681 alias: None,
682 }]);
683
684 let add_fingerprint = fingerprint_with_projection(&plan, &projection_add_one);
685 let mul_fingerprint = fingerprint_with_projection(&plan, &projection_mul_one);
686
687 assert_ne!(
688 add_fingerprint, mul_fingerprint,
689 "numeric projection semantic changes must invalidate fingerprint identity",
690 );
691 }
692
693 #[test]
694 fn fingerprint_numeric_literal_decimal_scale_is_canonicalized() {
695 let plan: AccessPlannedQuery<Value> = full_scan_query();
696 let decimal_one_scale_1 =
697 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
698 expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
699 alias: None,
700 }]);
701 let decimal_one_scale_2 =
702 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
703 expr: Expr::Literal(Value::Decimal(Decimal::new(100, 2))),
704 alias: None,
705 }]);
706
707 assert_eq!(
708 fingerprint_with_projection(&plan, &decimal_one_scale_1),
709 fingerprint_with_projection(&plan, &decimal_one_scale_2),
710 "decimal scale-only literal changes must not fragment fingerprint identity",
711 );
712 }
713
714 #[test]
715 fn fingerprint_literal_numeric_subtype_remains_significant_when_observable() {
716 let plan: AccessPlannedQuery<Value> = full_scan_query();
717 let int_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
718 expr: Expr::Literal(Value::Int(1)),
719 alias: None,
720 }]);
721 let decimal_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
722 expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
723 alias: None,
724 }]);
725
726 assert_ne!(
727 fingerprint_with_projection(&plan, &int_literal),
728 fingerprint_with_projection(&plan, &decimal_literal),
729 "top-level literal subtype remains observable and identity-significant",
730 );
731 }
732
733 #[test]
734 fn fingerprint_numeric_promotion_paths_do_not_fragment() {
735 let plan: AccessPlannedQuery<Value> = full_scan_query();
736 let int_plus_int = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
737 expr: Expr::Binary {
738 op: BinaryOp::Add,
739 left: Box::new(Expr::Literal(Value::Int(1))),
740 right: Box::new(Expr::Literal(Value::Int(2))),
741 },
742 alias: None,
743 }]);
744 let int_plus_decimal =
745 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
746 expr: Expr::Binary {
747 op: BinaryOp::Add,
748 left: Box::new(Expr::Literal(Value::Int(1))),
749 right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(20, 1)))),
750 },
751 alias: None,
752 }]);
753 let decimal_plus_int =
754 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
755 expr: Expr::Binary {
756 op: BinaryOp::Add,
757 left: Box::new(Expr::Literal(Value::Decimal(Decimal::new(10, 1)))),
758 right: Box::new(Expr::Literal(Value::Int(2))),
759 },
760 alias: None,
761 }]);
762
763 let fingerprint_int_plus_int = fingerprint_with_projection(&plan, &int_plus_int);
764 let fingerprint_int_plus_decimal = fingerprint_with_projection(&plan, &int_plus_decimal);
765 let fingerprint_decimal_plus_int = fingerprint_with_projection(&plan, &decimal_plus_int);
766
767 assert_eq!(fingerprint_int_plus_int, fingerprint_int_plus_decimal);
768 assert_eq!(fingerprint_int_plus_int, fingerprint_decimal_plus_int);
769 }
770
771 #[test]
772 fn fingerprint_commutative_operand_order_remains_significant_without_ast_normalization() {
773 let plan: AccessPlannedQuery<Value> = full_scan_query();
774 let rank_plus_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
775 expr: Expr::Binary {
776 op: BinaryOp::Add,
777 left: Box::new(Expr::Field(FieldId::new("rank"))),
778 right: Box::new(Expr::Field(FieldId::new("score"))),
779 },
780 alias: None,
781 }]);
782 let score_plus_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
783 expr: Expr::Binary {
784 op: BinaryOp::Add,
785 left: Box::new(Expr::Field(FieldId::new("score"))),
786 right: Box::new(Expr::Field(FieldId::new("rank"))),
787 },
788 alias: None,
789 }]);
790
791 assert_ne!(
792 fingerprint_with_projection(&plan, &rank_plus_score),
793 fingerprint_with_projection(&plan, &score_plus_rank),
794 "fingerprint preserves AST operand order for commutative operators in v2",
795 );
796 }
797
798 #[test]
799 fn fingerprint_aggregate_numeric_target_field_remains_significant() {
800 let plan: AccessPlannedQuery<Value> = full_scan_query();
801 let sum_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
802 expr: Expr::Aggregate(sum("rank")),
803 alias: None,
804 }]);
805 let sum_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
806 expr: Expr::Aggregate(sum("score")),
807 alias: None,
808 }]);
809
810 assert_ne!(
811 fingerprint_with_projection(&plan, &sum_rank),
812 fingerprint_with_projection(&plan, &sum_score),
813 "aggregate target field changes must invalidate fingerprint identity",
814 );
815 }
816
817 #[test]
818 fn fingerprint_distinct_numeric_noop_paths_stay_stable() {
819 let plan: AccessPlannedQuery<Value> = full_scan_query();
820 let sum_distinct_plus_int_zero =
821 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
822 expr: Expr::Binary {
823 op: BinaryOp::Add,
824 left: Box::new(Expr::Aggregate(sum("rank").distinct())),
825 right: Box::new(Expr::Literal(Value::Int(0))),
826 },
827 alias: None,
828 }]);
829 let sum_distinct_plus_decimal_zero =
830 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
831 expr: Expr::Binary {
832 op: BinaryOp::Add,
833 left: Box::new(Expr::Aggregate(sum("rank").distinct())),
834 right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(0, 1)))),
835 },
836 alias: None,
837 }]);
838
839 assert_eq!(
840 fingerprint_with_projection(&plan, &sum_distinct_plus_int_zero),
841 fingerprint_with_projection(&plan, &sum_distinct_plus_decimal_zero),
842 "distinct numeric no-op literal subtype differences must not fragment fingerprint identity",
843 );
844 }
845
846 #[test]
847 fn fingerprint_is_stable_for_full_scan() {
848 let plan: AccessPlannedQuery<Value> = full_scan_query();
849 let fingerprint_a = plan.fingerprint();
850 let fingerprint_b = plan.fingerprint();
851 assert_eq!(fingerprint_a, fingerprint_b);
852 }
853
854 #[test]
855 fn fingerprint_is_stable_for_equivalent_index_range_bounds() {
856 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
857 const INDEX: IndexModel = IndexModel::new(
858 "fingerprint::group_rank",
859 "fingerprint::store",
860 &INDEX_FIELDS,
861 false,
862 );
863
864 let plan_a: AccessPlannedQuery<Value> = index_range_query(
865 INDEX,
866 vec![Value::Uint(7)],
867 Bound::Included(Value::Uint(100)),
868 Bound::Excluded(Value::Uint(200)),
869 );
870 let plan_b: AccessPlannedQuery<Value> = index_range_query(
871 INDEX,
872 vec![Value::Uint(7)],
873 Bound::Included(Value::Uint(100)),
874 Bound::Excluded(Value::Uint(200)),
875 );
876
877 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
878 }
879
880 #[test]
881 fn fingerprint_changes_when_index_range_bound_discriminant_changes() {
882 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
883 const INDEX: IndexModel = IndexModel::new(
884 "fingerprint::group_rank",
885 "fingerprint::store",
886 &INDEX_FIELDS,
887 false,
888 );
889
890 let plan_included: AccessPlannedQuery<Value> = index_range_query(
891 INDEX,
892 vec![Value::Uint(7)],
893 Bound::Included(Value::Uint(100)),
894 Bound::Excluded(Value::Uint(200)),
895 );
896 let plan_excluded: AccessPlannedQuery<Value> = index_range_query(
897 INDEX,
898 vec![Value::Uint(7)],
899 Bound::Excluded(Value::Uint(100)),
900 Bound::Excluded(Value::Uint(200)),
901 );
902
903 assert_ne!(plan_included.fingerprint(), plan_excluded.fingerprint());
904 }
905
906 #[test]
907 fn fingerprint_changes_when_index_range_bound_value_changes() {
908 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
909 const INDEX: IndexModel = IndexModel::new(
910 "fingerprint::group_rank",
911 "fingerprint::store",
912 &INDEX_FIELDS,
913 false,
914 );
915
916 let plan_low_100: AccessPlannedQuery<Value> = index_range_query(
917 INDEX,
918 vec![Value::Uint(7)],
919 Bound::Included(Value::Uint(100)),
920 Bound::Excluded(Value::Uint(200)),
921 );
922 let plan_low_101: AccessPlannedQuery<Value> = index_range_query(
923 INDEX,
924 vec![Value::Uint(7)],
925 Bound::Included(Value::Uint(101)),
926 Bound::Excluded(Value::Uint(200)),
927 );
928
929 assert_ne!(plan_low_100.fingerprint(), plan_low_101.fingerprint());
930 }
931
932 #[test]
933 fn explain_fingerprint_grouped_strategy_only_change_does_not_invalidate() {
934 let mut hash_strategy = grouped_explain_with_fixed_shape();
935 let mut ordered_strategy = hash_strategy.clone();
936
937 let ExplainGrouping::Grouped {
938 strategy: hash_value,
939 ..
940 } = &mut hash_strategy.grouping
941 else {
942 panic!("grouped explain fixture must produce grouped explain shape");
943 };
944 *hash_value = ExplainGroupedStrategy::HashGroup;
945 let ExplainGrouping::Grouped {
946 strategy: ordered_value,
947 ..
948 } = &mut ordered_strategy.grouping
949 else {
950 panic!("grouped explain fixture must produce grouped explain shape");
951 };
952 *ordered_value = ExplainGroupedStrategy::OrderedGroup;
953
954 assert_eq!(
955 hash_strategy.fingerprint(),
956 ordered_strategy.fingerprint(),
957 "execution strategy hints are explain/runtime metadata and must not affect semantic fingerprint identity",
958 );
959 }
960}