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::{ComparePredicate, MissingRowPolicy, Predicate};
96 use crate::db::query::explain::{ExplainGroupedStrategy, ExplainGrouping};
97 use crate::db::query::fingerprint::hash_parts;
98 use crate::db::query::intent::{KeyAccess, build_access_plan_from_keys};
99 use crate::db::query::plan::expr::{
100 Alias, BinaryOp, Expr, FieldId, ProjectionField, ProjectionSpec,
101 };
102 use crate::db::query::plan::{
103 AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
104 GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, LoadSpec, LogicalPlan, PageSpec,
105 QueryMode,
106 };
107 use crate::db::query::{builder::field::FieldRef, builder::sum};
108 use crate::model::index::IndexModel;
109 use crate::types::{Decimal, Ulid};
110 use crate::value::Value;
111 use sha2::{Digest, Sha256};
112
113 fn fingerprint_with_projection(
114 plan: &AccessPlannedQuery<Value>,
115 projection: &ProjectionSpec,
116 ) -> super::PlanFingerprint {
117 let explain = plan.explain();
118 let mut hasher = Sha256::new();
119 hasher.update(b"planfp:v2");
120 hash_parts::hash_explain_plan_profile_with_projection(
121 &mut hasher,
122 &explain,
123 hash_parts::ExplainHashProfile::FingerprintV2,
124 projection,
125 );
126 let digest = hasher.finalize();
127 let mut out = [0u8; 32];
128 out.copy_from_slice(&digest);
129
130 super::PlanFingerprint(out)
131 }
132
133 fn full_scan_query() -> AccessPlannedQuery<Value> {
134 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
135 }
136
137 fn index_prefix_query(index: IndexModel, values: Vec<Value>) -> AccessPlannedQuery<Value> {
138 AccessPlannedQuery::new(
139 AccessPath::IndexPrefix { index, values },
140 MissingRowPolicy::Ignore,
141 )
142 }
143
144 fn index_range_query(
145 index: IndexModel,
146 prefix: Vec<Value>,
147 lower: Bound<Value>,
148 upper: Bound<Value>,
149 ) -> AccessPlannedQuery<Value> {
150 AccessPlannedQuery::new(
151 AccessPath::index_range(index, prefix, lower, upper),
152 MissingRowPolicy::Ignore,
153 )
154 }
155
156 fn grouped_explain_with_fixed_shape() -> crate::db::query::explain::ExplainPlan {
157 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
158 .into_grouped(GroupSpec {
159 group_fields: vec![FieldSlot::from_parts_for_test(1, "rank")],
160 aggregates: vec![GroupAggregateSpec {
161 kind: AggregateKind::Count,
162 target_field: None,
163 distinct: false,
164 }],
165 execution: GroupedExecutionConfig::with_hard_limits(64, 4096),
166 })
167 .explain()
168 }
169
170 #[test]
171 fn fingerprint_is_deterministic_for_equivalent_predicates() {
172 let id = Ulid::default();
173
174 let predicate_a = Predicate::And(vec![
175 FieldRef::new("id").eq(id),
176 FieldRef::new("other").eq(Value::Text("x".to_string())),
177 ]);
178 let predicate_b = Predicate::And(vec![
179 FieldRef::new("other").eq(Value::Text("x".to_string())),
180 FieldRef::new("id").eq(id),
181 ]);
182
183 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
184 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
185
186 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
187 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
188
189 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
190 }
191
192 #[test]
193 fn fingerprint_and_signature_are_stable_for_reordered_and_non_canonical_map_predicates() {
194 let map_a = Value::Map(vec![
195 (Value::Text("z".to_string()), Value::Int(9)),
196 (Value::Text("a".to_string()), Value::Int(1)),
197 ]);
198 let map_b = Value::Map(vec![
199 (Value::Text("a".to_string()), Value::Int(1)),
200 (Value::Text("z".to_string()), Value::Int(9)),
201 ]);
202
203 let predicate_a = Predicate::And(vec![
204 FieldRef::new("other").eq(Value::Text("x".to_string())),
205 Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_a)),
206 ]);
207 let predicate_b = Predicate::And(vec![
208 Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_b)),
209 FieldRef::new("other").eq(Value::Text("x".to_string())),
210 ]);
211
212 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
213 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
214
215 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
216 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
217
218 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
219 assert_eq!(
220 plan_a.continuation_signature("tests::Entity"),
221 plan_b.continuation_signature("tests::Entity")
222 );
223 }
224
225 #[test]
226 fn fingerprint_and_signature_treat_equivalent_decimal_predicate_literals_as_identical() {
227 let predicate_a = Predicate::Compare(ComparePredicate::eq(
228 "rank".to_string(),
229 Value::Decimal(Decimal::new(10, 1)),
230 ));
231 let predicate_b = Predicate::Compare(ComparePredicate::eq(
232 "rank".to_string(),
233 Value::Decimal(Decimal::new(100, 2)),
234 ));
235
236 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
237 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
238
239 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
240 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
241
242 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
243 assert_eq!(
244 plan_a.continuation_signature("tests::Entity"),
245 plan_b.continuation_signature("tests::Entity")
246 );
247 }
248
249 #[test]
250 fn fingerprint_and_signature_treat_equivalent_in_list_predicates_as_identical() {
251 let predicate_a = Predicate::Compare(ComparePredicate::in_(
252 "rank".to_string(),
253 vec![Value::Uint(3), Value::Uint(1), Value::Uint(2)],
254 ));
255 let predicate_b = Predicate::Compare(ComparePredicate::in_(
256 "rank".to_string(),
257 vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
258 ));
259
260 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
261 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
262
263 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
264 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
265
266 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
267 assert_eq!(
268 plan_a.continuation_signature("tests::Entity"),
269 plan_b.continuation_signature("tests::Entity")
270 );
271 }
272
273 #[test]
274 fn fingerprint_and_signature_treat_equivalent_in_list_duplicate_literals_as_identical() {
275 let predicate_a = Predicate::Compare(ComparePredicate::in_(
276 "rank".to_string(),
277 vec![
278 Value::Uint(3),
279 Value::Uint(1),
280 Value::Uint(3),
281 Value::Uint(2),
282 ],
283 ));
284 let predicate_b = Predicate::Compare(ComparePredicate::in_(
285 "rank".to_string(),
286 vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
287 ));
288
289 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
290 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
291
292 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
293 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
294
295 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
296 assert_eq!(
297 plan_a.continuation_signature("tests::Entity"),
298 plan_b.continuation_signature("tests::Entity")
299 );
300 }
301
302 #[test]
303 fn fingerprint_is_deterministic_for_by_keys() {
304 let a = Ulid::from_u128(1);
305 let b = Ulid::from_u128(2);
306
307 let access_a = build_access_plan_from_keys(&KeyAccess::Many(vec![a, b, a]));
308 let access_b = build_access_plan_from_keys(&KeyAccess::Many(vec![b, a]));
309
310 let plan_a: AccessPlannedQuery<Value> = AccessPlannedQuery {
311 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
312 mode: QueryMode::Load(LoadSpec::new()),
313 predicate: None,
314 order: None,
315 distinct: false,
316 delete_limit: None,
317 page: None,
318 consistency: MissingRowPolicy::Ignore,
319 }),
320 access: access_a,
321 };
322 let plan_b: AccessPlannedQuery<Value> = AccessPlannedQuery {
323 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
324 mode: QueryMode::Load(LoadSpec::new()),
325 predicate: None,
326 order: None,
327 distinct: false,
328 delete_limit: None,
329 page: None,
330 consistency: MissingRowPolicy::Ignore,
331 }),
332 access: access_b,
333 };
334
335 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
336 }
337
338 #[test]
339 fn fingerprint_changes_with_index_choice() {
340 const INDEX_FIELDS: [&str; 1] = ["idx_a"];
341 const INDEX_A: IndexModel = IndexModel::new(
342 "fingerprint::idx_a",
343 "fingerprint::store",
344 &INDEX_FIELDS,
345 false,
346 );
347 const INDEX_B: IndexModel = IndexModel::new(
348 "fingerprint::idx_b",
349 "fingerprint::store",
350 &INDEX_FIELDS,
351 false,
352 );
353
354 let plan_a: AccessPlannedQuery<Value> =
355 index_prefix_query(INDEX_A, vec![Value::Text("alpha".to_string())]);
356 let plan_b: AccessPlannedQuery<Value> =
357 index_prefix_query(INDEX_B, vec![Value::Text("alpha".to_string())]);
358
359 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
360 }
361
362 #[test]
363 fn fingerprint_changes_with_pagination() {
364 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
365 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
366 plan_a.scalar_plan_mut().page = Some(PageSpec {
367 limit: Some(10),
368 offset: 0,
369 });
370 plan_b.scalar_plan_mut().page = Some(PageSpec {
371 limit: Some(10),
372 offset: 1,
373 });
374
375 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
376 }
377
378 #[test]
379 fn fingerprint_changes_with_delete_limit() {
380 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
381 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
382 plan_a.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
383 plan_b.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
384 plan_a.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
385 plan_b.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
386
387 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
388 }
389
390 #[test]
391 fn fingerprint_changes_with_distinct_flag() {
392 let plan_a: AccessPlannedQuery<Value> = full_scan_query();
393 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
394 plan_b.scalar_plan_mut().distinct = true;
395
396 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
397 }
398
399 #[test]
400 fn fingerprint_numeric_projection_alias_only_change_does_not_invalidate() {
401 let plan: AccessPlannedQuery<Value> = full_scan_query();
402 let numeric_projection =
403 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
404 expr: Expr::Binary {
405 op: crate::db::query::plan::expr::BinaryOp::Add,
406 left: Box::new(Expr::Field(FieldId::new("rank"))),
407 right: Box::new(Expr::Literal(Value::Int(1))),
408 },
409 alias: None,
410 }]);
411 let alias_only_numeric_projection =
412 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
413 expr: Expr::Alias {
414 expr: Box::new(Expr::Binary {
415 op: crate::db::query::plan::expr::BinaryOp::Add,
416 left: Box::new(Expr::Field(FieldId::new("rank"))),
417 right: Box::new(Expr::Literal(Value::Int(1))),
418 }),
419 name: Alias::new("rank_plus_one_expr"),
420 },
421 alias: Some(Alias::new("rank_plus_one")),
422 }]);
423
424 let semantic_fingerprint = fingerprint_with_projection(&plan, &numeric_projection);
425 let alias_fingerprint = fingerprint_with_projection(&plan, &alias_only_numeric_projection);
426
427 assert_eq!(
428 semantic_fingerprint, alias_fingerprint,
429 "numeric projection alias wrappers must not affect fingerprint identity",
430 );
431 }
432
433 #[test]
434 fn fingerprint_numeric_projection_semantic_change_invalidates() {
435 let plan: AccessPlannedQuery<Value> = full_scan_query();
436 let projection_add_one =
437 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
438 expr: Expr::Binary {
439 op: crate::db::query::plan::expr::BinaryOp::Add,
440 left: Box::new(Expr::Field(FieldId::new("rank"))),
441 right: Box::new(Expr::Literal(Value::Int(1))),
442 },
443 alias: None,
444 }]);
445 let projection_mul_one =
446 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
447 expr: Expr::Binary {
448 op: crate::db::query::plan::expr::BinaryOp::Mul,
449 left: Box::new(Expr::Field(FieldId::new("rank"))),
450 right: Box::new(Expr::Literal(Value::Int(1))),
451 },
452 alias: None,
453 }]);
454
455 let add_fingerprint = fingerprint_with_projection(&plan, &projection_add_one);
456 let mul_fingerprint = fingerprint_with_projection(&plan, &projection_mul_one);
457
458 assert_ne!(
459 add_fingerprint, mul_fingerprint,
460 "numeric projection semantic changes must invalidate fingerprint identity",
461 );
462 }
463
464 #[test]
465 fn fingerprint_numeric_literal_decimal_scale_is_canonicalized() {
466 let plan: AccessPlannedQuery<Value> = full_scan_query();
467 let decimal_one_scale_1 =
468 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
469 expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
470 alias: None,
471 }]);
472 let decimal_one_scale_2 =
473 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
474 expr: Expr::Literal(Value::Decimal(Decimal::new(100, 2))),
475 alias: None,
476 }]);
477
478 assert_eq!(
479 fingerprint_with_projection(&plan, &decimal_one_scale_1),
480 fingerprint_with_projection(&plan, &decimal_one_scale_2),
481 "decimal scale-only literal changes must not fragment fingerprint identity",
482 );
483 }
484
485 #[test]
486 fn fingerprint_literal_numeric_subtype_remains_significant_when_observable() {
487 let plan: AccessPlannedQuery<Value> = full_scan_query();
488 let int_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
489 expr: Expr::Literal(Value::Int(1)),
490 alias: None,
491 }]);
492 let decimal_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
493 expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
494 alias: None,
495 }]);
496
497 assert_ne!(
498 fingerprint_with_projection(&plan, &int_literal),
499 fingerprint_with_projection(&plan, &decimal_literal),
500 "top-level literal subtype remains observable and identity-significant",
501 );
502 }
503
504 #[test]
505 fn fingerprint_numeric_promotion_paths_do_not_fragment() {
506 let plan: AccessPlannedQuery<Value> = full_scan_query();
507 let int_plus_int = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
508 expr: Expr::Binary {
509 op: BinaryOp::Add,
510 left: Box::new(Expr::Literal(Value::Int(1))),
511 right: Box::new(Expr::Literal(Value::Int(2))),
512 },
513 alias: None,
514 }]);
515 let int_plus_decimal =
516 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
517 expr: Expr::Binary {
518 op: BinaryOp::Add,
519 left: Box::new(Expr::Literal(Value::Int(1))),
520 right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(20, 1)))),
521 },
522 alias: None,
523 }]);
524 let decimal_plus_int =
525 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
526 expr: Expr::Binary {
527 op: BinaryOp::Add,
528 left: Box::new(Expr::Literal(Value::Decimal(Decimal::new(10, 1)))),
529 right: Box::new(Expr::Literal(Value::Int(2))),
530 },
531 alias: None,
532 }]);
533
534 let fingerprint_int_plus_int = fingerprint_with_projection(&plan, &int_plus_int);
535 let fingerprint_int_plus_decimal = fingerprint_with_projection(&plan, &int_plus_decimal);
536 let fingerprint_decimal_plus_int = fingerprint_with_projection(&plan, &decimal_plus_int);
537
538 assert_eq!(fingerprint_int_plus_int, fingerprint_int_plus_decimal);
539 assert_eq!(fingerprint_int_plus_int, fingerprint_decimal_plus_int);
540 }
541
542 #[test]
543 fn fingerprint_commutative_operand_order_remains_significant_without_ast_normalization() {
544 let plan: AccessPlannedQuery<Value> = full_scan_query();
545 let rank_plus_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
546 expr: Expr::Binary {
547 op: BinaryOp::Add,
548 left: Box::new(Expr::Field(FieldId::new("rank"))),
549 right: Box::new(Expr::Field(FieldId::new("score"))),
550 },
551 alias: None,
552 }]);
553 let score_plus_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
554 expr: Expr::Binary {
555 op: BinaryOp::Add,
556 left: Box::new(Expr::Field(FieldId::new("score"))),
557 right: Box::new(Expr::Field(FieldId::new("rank"))),
558 },
559 alias: None,
560 }]);
561
562 assert_ne!(
563 fingerprint_with_projection(&plan, &rank_plus_score),
564 fingerprint_with_projection(&plan, &score_plus_rank),
565 "fingerprint preserves AST operand order for commutative operators in v2",
566 );
567 }
568
569 #[test]
570 fn fingerprint_aggregate_numeric_target_field_remains_significant() {
571 let plan: AccessPlannedQuery<Value> = full_scan_query();
572 let sum_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
573 expr: Expr::Aggregate(sum("rank")),
574 alias: None,
575 }]);
576 let sum_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
577 expr: Expr::Aggregate(sum("score")),
578 alias: None,
579 }]);
580
581 assert_ne!(
582 fingerprint_with_projection(&plan, &sum_rank),
583 fingerprint_with_projection(&plan, &sum_score),
584 "aggregate target field changes must invalidate fingerprint identity",
585 );
586 }
587
588 #[test]
589 fn fingerprint_distinct_numeric_noop_paths_stay_stable() {
590 let plan: AccessPlannedQuery<Value> = full_scan_query();
591 let sum_distinct_plus_int_zero =
592 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
593 expr: Expr::Binary {
594 op: BinaryOp::Add,
595 left: Box::new(Expr::Aggregate(sum("rank").distinct())),
596 right: Box::new(Expr::Literal(Value::Int(0))),
597 },
598 alias: None,
599 }]);
600 let sum_distinct_plus_decimal_zero =
601 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
602 expr: Expr::Binary {
603 op: BinaryOp::Add,
604 left: Box::new(Expr::Aggregate(sum("rank").distinct())),
605 right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(0, 1)))),
606 },
607 alias: None,
608 }]);
609
610 assert_eq!(
611 fingerprint_with_projection(&plan, &sum_distinct_plus_int_zero),
612 fingerprint_with_projection(&plan, &sum_distinct_plus_decimal_zero),
613 "distinct numeric no-op literal subtype differences must not fragment fingerprint identity",
614 );
615 }
616
617 #[test]
618 fn fingerprint_is_stable_for_full_scan() {
619 let plan: AccessPlannedQuery<Value> = full_scan_query();
620 let fingerprint_a = plan.fingerprint();
621 let fingerprint_b = plan.fingerprint();
622 assert_eq!(fingerprint_a, fingerprint_b);
623 }
624
625 #[test]
626 fn fingerprint_is_stable_for_equivalent_index_range_bounds() {
627 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
628 const INDEX: IndexModel = IndexModel::new(
629 "fingerprint::group_rank",
630 "fingerprint::store",
631 &INDEX_FIELDS,
632 false,
633 );
634
635 let plan_a: AccessPlannedQuery<Value> = index_range_query(
636 INDEX,
637 vec![Value::Uint(7)],
638 Bound::Included(Value::Uint(100)),
639 Bound::Excluded(Value::Uint(200)),
640 );
641 let plan_b: AccessPlannedQuery<Value> = index_range_query(
642 INDEX,
643 vec![Value::Uint(7)],
644 Bound::Included(Value::Uint(100)),
645 Bound::Excluded(Value::Uint(200)),
646 );
647
648 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
649 }
650
651 #[test]
652 fn fingerprint_changes_when_index_range_bound_discriminant_changes() {
653 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
654 const INDEX: IndexModel = IndexModel::new(
655 "fingerprint::group_rank",
656 "fingerprint::store",
657 &INDEX_FIELDS,
658 false,
659 );
660
661 let plan_included: AccessPlannedQuery<Value> = index_range_query(
662 INDEX,
663 vec![Value::Uint(7)],
664 Bound::Included(Value::Uint(100)),
665 Bound::Excluded(Value::Uint(200)),
666 );
667 let plan_excluded: AccessPlannedQuery<Value> = index_range_query(
668 INDEX,
669 vec![Value::Uint(7)],
670 Bound::Excluded(Value::Uint(100)),
671 Bound::Excluded(Value::Uint(200)),
672 );
673
674 assert_ne!(plan_included.fingerprint(), plan_excluded.fingerprint());
675 }
676
677 #[test]
678 fn fingerprint_changes_when_index_range_bound_value_changes() {
679 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
680 const INDEX: IndexModel = IndexModel::new(
681 "fingerprint::group_rank",
682 "fingerprint::store",
683 &INDEX_FIELDS,
684 false,
685 );
686
687 let plan_low_100: AccessPlannedQuery<Value> = index_range_query(
688 INDEX,
689 vec![Value::Uint(7)],
690 Bound::Included(Value::Uint(100)),
691 Bound::Excluded(Value::Uint(200)),
692 );
693 let plan_low_101: AccessPlannedQuery<Value> = index_range_query(
694 INDEX,
695 vec![Value::Uint(7)],
696 Bound::Included(Value::Uint(101)),
697 Bound::Excluded(Value::Uint(200)),
698 );
699
700 assert_ne!(plan_low_100.fingerprint(), plan_low_101.fingerprint());
701 }
702
703 #[test]
704 fn explain_fingerprint_grouped_strategy_only_change_does_not_invalidate() {
705 let mut hash_strategy = grouped_explain_with_fixed_shape();
706 let mut ordered_strategy = hash_strategy.clone();
707
708 let ExplainGrouping::Grouped {
709 strategy: hash_value,
710 ..
711 } = &mut hash_strategy.grouping
712 else {
713 panic!("grouped explain fixture must produce grouped explain shape");
714 };
715 *hash_value = ExplainGroupedStrategy::HashGroup;
716 let ExplainGrouping::Grouped {
717 strategy: ordered_value,
718 ..
719 } = &mut ordered_strategy.grouping
720 else {
721 panic!("grouped explain fixture must produce grouped explain shape");
722 };
723 *ordered_value = ExplainGroupedStrategy::OrderedGroup;
724
725 assert_eq!(
726 hash_strategy.fingerprint(),
727 ordered_strategy.fingerprint(),
728 "execution strategy hints are explain/runtime metadata and must not affect semantic fingerprint identity",
729 );
730 }
731}