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