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_is_deterministic_for_by_keys() {
251 let a = Ulid::from_u128(1);
252 let b = Ulid::from_u128(2);
253
254 let access_a = build_access_plan_from_keys(&KeyAccess::Many(vec![a, b, a]));
255 let access_b = build_access_plan_from_keys(&KeyAccess::Many(vec![b, a]));
256
257 let plan_a: AccessPlannedQuery<Value> = AccessPlannedQuery {
258 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
259 mode: QueryMode::Load(LoadSpec::new()),
260 predicate: None,
261 order: None,
262 distinct: false,
263 delete_limit: None,
264 page: None,
265 consistency: MissingRowPolicy::Ignore,
266 }),
267 access: access_a,
268 };
269 let plan_b: AccessPlannedQuery<Value> = AccessPlannedQuery {
270 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
271 mode: QueryMode::Load(LoadSpec::new()),
272 predicate: None,
273 order: None,
274 distinct: false,
275 delete_limit: None,
276 page: None,
277 consistency: MissingRowPolicy::Ignore,
278 }),
279 access: access_b,
280 };
281
282 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
283 }
284
285 #[test]
286 fn fingerprint_changes_with_index_choice() {
287 const INDEX_FIELDS: [&str; 1] = ["idx_a"];
288 const INDEX_A: IndexModel = IndexModel::new(
289 "fingerprint::idx_a",
290 "fingerprint::store",
291 &INDEX_FIELDS,
292 false,
293 );
294 const INDEX_B: IndexModel = IndexModel::new(
295 "fingerprint::idx_b",
296 "fingerprint::store",
297 &INDEX_FIELDS,
298 false,
299 );
300
301 let plan_a: AccessPlannedQuery<Value> =
302 index_prefix_query(INDEX_A, vec![Value::Text("alpha".to_string())]);
303 let plan_b: AccessPlannedQuery<Value> =
304 index_prefix_query(INDEX_B, vec![Value::Text("alpha".to_string())]);
305
306 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
307 }
308
309 #[test]
310 fn fingerprint_changes_with_pagination() {
311 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
312 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
313 plan_a.scalar_plan_mut().page = Some(PageSpec {
314 limit: Some(10),
315 offset: 0,
316 });
317 plan_b.scalar_plan_mut().page = Some(PageSpec {
318 limit: Some(10),
319 offset: 1,
320 });
321
322 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
323 }
324
325 #[test]
326 fn fingerprint_changes_with_delete_limit() {
327 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
328 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
329 plan_a.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
330 plan_b.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
331 plan_a.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
332 plan_b.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
333
334 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
335 }
336
337 #[test]
338 fn fingerprint_changes_with_distinct_flag() {
339 let plan_a: AccessPlannedQuery<Value> = full_scan_query();
340 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
341 plan_b.scalar_plan_mut().distinct = true;
342
343 assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
344 }
345
346 #[test]
347 fn fingerprint_numeric_projection_alias_only_change_does_not_invalidate() {
348 let plan: AccessPlannedQuery<Value> = full_scan_query();
349 let numeric_projection =
350 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
351 expr: Expr::Binary {
352 op: crate::db::query::plan::expr::BinaryOp::Add,
353 left: Box::new(Expr::Field(FieldId::new("rank"))),
354 right: Box::new(Expr::Literal(Value::Int(1))),
355 },
356 alias: None,
357 }]);
358 let alias_only_numeric_projection =
359 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
360 expr: Expr::Alias {
361 expr: Box::new(Expr::Binary {
362 op: crate::db::query::plan::expr::BinaryOp::Add,
363 left: Box::new(Expr::Field(FieldId::new("rank"))),
364 right: Box::new(Expr::Literal(Value::Int(1))),
365 }),
366 name: Alias::new("rank_plus_one_expr"),
367 },
368 alias: Some(Alias::new("rank_plus_one")),
369 }]);
370
371 let semantic_fingerprint = fingerprint_with_projection(&plan, &numeric_projection);
372 let alias_fingerprint = fingerprint_with_projection(&plan, &alias_only_numeric_projection);
373
374 assert_eq!(
375 semantic_fingerprint, alias_fingerprint,
376 "numeric projection alias wrappers must not affect fingerprint identity",
377 );
378 }
379
380 #[test]
381 fn fingerprint_numeric_projection_semantic_change_invalidates() {
382 let plan: AccessPlannedQuery<Value> = full_scan_query();
383 let projection_add_one =
384 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
385 expr: Expr::Binary {
386 op: crate::db::query::plan::expr::BinaryOp::Add,
387 left: Box::new(Expr::Field(FieldId::new("rank"))),
388 right: Box::new(Expr::Literal(Value::Int(1))),
389 },
390 alias: None,
391 }]);
392 let projection_mul_one =
393 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
394 expr: Expr::Binary {
395 op: crate::db::query::plan::expr::BinaryOp::Mul,
396 left: Box::new(Expr::Field(FieldId::new("rank"))),
397 right: Box::new(Expr::Literal(Value::Int(1))),
398 },
399 alias: None,
400 }]);
401
402 let add_fingerprint = fingerprint_with_projection(&plan, &projection_add_one);
403 let mul_fingerprint = fingerprint_with_projection(&plan, &projection_mul_one);
404
405 assert_ne!(
406 add_fingerprint, mul_fingerprint,
407 "numeric projection semantic changes must invalidate fingerprint identity",
408 );
409 }
410
411 #[test]
412 fn fingerprint_numeric_literal_decimal_scale_is_canonicalized() {
413 let plan: AccessPlannedQuery<Value> = full_scan_query();
414 let decimal_one_scale_1 =
415 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
416 expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
417 alias: None,
418 }]);
419 let decimal_one_scale_2 =
420 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
421 expr: Expr::Literal(Value::Decimal(Decimal::new(100, 2))),
422 alias: None,
423 }]);
424
425 assert_eq!(
426 fingerprint_with_projection(&plan, &decimal_one_scale_1),
427 fingerprint_with_projection(&plan, &decimal_one_scale_2),
428 "decimal scale-only literal changes must not fragment fingerprint identity",
429 );
430 }
431
432 #[test]
433 fn fingerprint_literal_numeric_subtype_remains_significant_when_observable() {
434 let plan: AccessPlannedQuery<Value> = full_scan_query();
435 let int_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
436 expr: Expr::Literal(Value::Int(1)),
437 alias: None,
438 }]);
439 let decimal_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
440 expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
441 alias: None,
442 }]);
443
444 assert_ne!(
445 fingerprint_with_projection(&plan, &int_literal),
446 fingerprint_with_projection(&plan, &decimal_literal),
447 "top-level literal subtype remains observable and identity-significant",
448 );
449 }
450
451 #[test]
452 fn fingerprint_numeric_promotion_paths_do_not_fragment() {
453 let plan: AccessPlannedQuery<Value> = full_scan_query();
454 let int_plus_int = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
455 expr: Expr::Binary {
456 op: BinaryOp::Add,
457 left: Box::new(Expr::Literal(Value::Int(1))),
458 right: Box::new(Expr::Literal(Value::Int(2))),
459 },
460 alias: None,
461 }]);
462 let int_plus_decimal =
463 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
464 expr: Expr::Binary {
465 op: BinaryOp::Add,
466 left: Box::new(Expr::Literal(Value::Int(1))),
467 right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(20, 1)))),
468 },
469 alias: None,
470 }]);
471 let decimal_plus_int =
472 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
473 expr: Expr::Binary {
474 op: BinaryOp::Add,
475 left: Box::new(Expr::Literal(Value::Decimal(Decimal::new(10, 1)))),
476 right: Box::new(Expr::Literal(Value::Int(2))),
477 },
478 alias: None,
479 }]);
480
481 let fingerprint_int_plus_int = fingerprint_with_projection(&plan, &int_plus_int);
482 let fingerprint_int_plus_decimal = fingerprint_with_projection(&plan, &int_plus_decimal);
483 let fingerprint_decimal_plus_int = fingerprint_with_projection(&plan, &decimal_plus_int);
484
485 assert_eq!(fingerprint_int_plus_int, fingerprint_int_plus_decimal);
486 assert_eq!(fingerprint_int_plus_int, fingerprint_decimal_plus_int);
487 }
488
489 #[test]
490 fn fingerprint_commutative_operand_order_remains_significant_without_ast_normalization() {
491 let plan: AccessPlannedQuery<Value> = full_scan_query();
492 let rank_plus_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
493 expr: Expr::Binary {
494 op: BinaryOp::Add,
495 left: Box::new(Expr::Field(FieldId::new("rank"))),
496 right: Box::new(Expr::Field(FieldId::new("score"))),
497 },
498 alias: None,
499 }]);
500 let score_plus_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
501 expr: Expr::Binary {
502 op: BinaryOp::Add,
503 left: Box::new(Expr::Field(FieldId::new("score"))),
504 right: Box::new(Expr::Field(FieldId::new("rank"))),
505 },
506 alias: None,
507 }]);
508
509 assert_ne!(
510 fingerprint_with_projection(&plan, &rank_plus_score),
511 fingerprint_with_projection(&plan, &score_plus_rank),
512 "fingerprint preserves AST operand order for commutative operators in v2",
513 );
514 }
515
516 #[test]
517 fn fingerprint_aggregate_numeric_target_field_remains_significant() {
518 let plan: AccessPlannedQuery<Value> = full_scan_query();
519 let sum_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
520 expr: Expr::Aggregate(sum("rank")),
521 alias: None,
522 }]);
523 let sum_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
524 expr: Expr::Aggregate(sum("score")),
525 alias: None,
526 }]);
527
528 assert_ne!(
529 fingerprint_with_projection(&plan, &sum_rank),
530 fingerprint_with_projection(&plan, &sum_score),
531 "aggregate target field changes must invalidate fingerprint identity",
532 );
533 }
534
535 #[test]
536 fn fingerprint_distinct_numeric_noop_paths_stay_stable() {
537 let plan: AccessPlannedQuery<Value> = full_scan_query();
538 let sum_distinct_plus_int_zero =
539 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
540 expr: Expr::Binary {
541 op: BinaryOp::Add,
542 left: Box::new(Expr::Aggregate(sum("rank").distinct())),
543 right: Box::new(Expr::Literal(Value::Int(0))),
544 },
545 alias: None,
546 }]);
547 let sum_distinct_plus_decimal_zero =
548 ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
549 expr: Expr::Binary {
550 op: BinaryOp::Add,
551 left: Box::new(Expr::Aggregate(sum("rank").distinct())),
552 right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(0, 1)))),
553 },
554 alias: None,
555 }]);
556
557 assert_eq!(
558 fingerprint_with_projection(&plan, &sum_distinct_plus_int_zero),
559 fingerprint_with_projection(&plan, &sum_distinct_plus_decimal_zero),
560 "distinct numeric no-op literal subtype differences must not fragment fingerprint identity",
561 );
562 }
563
564 #[test]
565 fn fingerprint_is_stable_for_full_scan() {
566 let plan: AccessPlannedQuery<Value> = full_scan_query();
567 let fingerprint_a = plan.fingerprint();
568 let fingerprint_b = plan.fingerprint();
569 assert_eq!(fingerprint_a, fingerprint_b);
570 }
571
572 #[test]
573 fn fingerprint_is_stable_for_equivalent_index_range_bounds() {
574 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
575 const INDEX: IndexModel = IndexModel::new(
576 "fingerprint::group_rank",
577 "fingerprint::store",
578 &INDEX_FIELDS,
579 false,
580 );
581
582 let plan_a: AccessPlannedQuery<Value> = index_range_query(
583 INDEX,
584 vec![Value::Uint(7)],
585 Bound::Included(Value::Uint(100)),
586 Bound::Excluded(Value::Uint(200)),
587 );
588 let plan_b: AccessPlannedQuery<Value> = index_range_query(
589 INDEX,
590 vec![Value::Uint(7)],
591 Bound::Included(Value::Uint(100)),
592 Bound::Excluded(Value::Uint(200)),
593 );
594
595 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
596 }
597
598 #[test]
599 fn fingerprint_changes_when_index_range_bound_discriminant_changes() {
600 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
601 const INDEX: IndexModel = IndexModel::new(
602 "fingerprint::group_rank",
603 "fingerprint::store",
604 &INDEX_FIELDS,
605 false,
606 );
607
608 let plan_included: AccessPlannedQuery<Value> = index_range_query(
609 INDEX,
610 vec![Value::Uint(7)],
611 Bound::Included(Value::Uint(100)),
612 Bound::Excluded(Value::Uint(200)),
613 );
614 let plan_excluded: AccessPlannedQuery<Value> = index_range_query(
615 INDEX,
616 vec![Value::Uint(7)],
617 Bound::Excluded(Value::Uint(100)),
618 Bound::Excluded(Value::Uint(200)),
619 );
620
621 assert_ne!(plan_included.fingerprint(), plan_excluded.fingerprint());
622 }
623
624 #[test]
625 fn fingerprint_changes_when_index_range_bound_value_changes() {
626 const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
627 const INDEX: IndexModel = IndexModel::new(
628 "fingerprint::group_rank",
629 "fingerprint::store",
630 &INDEX_FIELDS,
631 false,
632 );
633
634 let plan_low_100: AccessPlannedQuery<Value> = index_range_query(
635 INDEX,
636 vec![Value::Uint(7)],
637 Bound::Included(Value::Uint(100)),
638 Bound::Excluded(Value::Uint(200)),
639 );
640 let plan_low_101: AccessPlannedQuery<Value> = index_range_query(
641 INDEX,
642 vec![Value::Uint(7)],
643 Bound::Included(Value::Uint(101)),
644 Bound::Excluded(Value::Uint(200)),
645 );
646
647 assert_ne!(plan_low_100.fingerprint(), plan_low_101.fingerprint());
648 }
649
650 #[test]
651 fn explain_fingerprint_grouped_strategy_only_change_does_not_invalidate() {
652 let mut hash_strategy = grouped_explain_with_fixed_shape();
653 let mut ordered_strategy = hash_strategy.clone();
654
655 let ExplainGrouping::Grouped {
656 strategy: hash_value,
657 ..
658 } = &mut hash_strategy.grouping
659 else {
660 panic!("grouped explain fixture must produce grouped explain shape");
661 };
662 *hash_value = ExplainGroupedStrategy::HashGroup;
663 let ExplainGrouping::Grouped {
664 strategy: ordered_value,
665 ..
666 } = &mut ordered_strategy.grouping
667 else {
668 panic!("grouped explain fixture must produce grouped explain shape");
669 };
670 *ordered_value = ExplainGroupedStrategy::OrderedGroup;
671
672 assert_eq!(
673 hash_strategy.fingerprint(),
674 ordered_strategy.fingerprint(),
675 "execution strategy hints are explain/runtime metadata and must not affect semantic fingerprint identity",
676 );
677 }
678}