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 pub(in crate::db) fn fingerprint(&self) -> PlanFingerprint {
47 let projection = self.projection_spec_for_identity();
48 let mut hasher = new_plan_fingerprint_hasher_v2();
49 hash_parts::hash_planned_query_profile_with_projection(
50 &mut hasher,
51 self,
52 hash_parts::ExplainHashProfile::FingerprintV2,
53 &projection,
54 );
55
56 PlanFingerprint(finalize_sha256_digest(hasher))
57 }
58}
59
60impl ExplainPlan {
61 #[must_use]
63 pub fn fingerprint(&self) -> PlanFingerprint {
64 let mut hasher = new_plan_fingerprint_hasher_v2();
66 hash_parts::hash_explain_plan_profile(
67 &mut hasher,
68 self,
69 hash_parts::ExplainHashProfile::FingerprintV2,
70 );
71
72 PlanFingerprint(finalize_sha256_digest(hasher))
74 }
75}
76
77#[cfg(test)]
82mod tests {
83 use std::ops::Bound;
84
85 use crate::db::access::AccessPath;
86 use crate::db::predicate::{
87 CoercionId, CompareOp, ComparePredicate, MissingRowPolicy, Predicate,
88 };
89 use crate::db::query::explain::{ExplainGroupedStrategy, ExplainGrouping};
90 use crate::db::query::fingerprint::hash_parts;
91 use crate::db::query::intent::{KeyAccess, build_access_plan_from_keys};
92 use crate::db::query::plan::expr::{
93 Alias, BinaryOp, Expr, FieldId, ProjectionField, ProjectionSpec,
94 };
95 use crate::db::query::plan::{
96 AccessPlannedQuery, AggregateKind, DeleteLimitSpec, DeleteSpec, FieldSlot,
97 GroupAggregateSpec, GroupSpec, GroupedExecutionConfig, LoadSpec, LogicalPlan, PageSpec,
98 QueryMode,
99 };
100 use crate::db::query::{builder::field::FieldRef, builder::sum};
101 use crate::model::index::IndexModel;
102 use crate::types::{Decimal, Ulid};
103 use crate::value::Value;
104
105 fn fingerprint_with_projection(
106 plan: &AccessPlannedQuery<Value>,
107 projection: &ProjectionSpec,
108 ) -> super::PlanFingerprint {
109 let explain = plan.explain();
110 let mut hasher = super::super::new_plan_fingerprint_hasher_v2();
111 hash_parts::hash_explain_plan_profile_with_projection(
112 &mut hasher,
113 &explain,
114 hash_parts::ExplainHashProfile::FingerprintV2,
115 projection,
116 );
117
118 super::PlanFingerprint(super::super::finalize_sha256_digest(hasher))
119 }
120
121 fn full_scan_query() -> AccessPlannedQuery<Value> {
122 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
123 }
124
125 fn index_prefix_query(index: IndexModel, values: Vec<Value>) -> AccessPlannedQuery<Value> {
126 AccessPlannedQuery::new(
127 AccessPath::IndexPrefix { index, values },
128 MissingRowPolicy::Ignore,
129 )
130 }
131
132 fn index_range_query(
133 index: IndexModel,
134 prefix: Vec<Value>,
135 lower: Bound<Value>,
136 upper: Bound<Value>,
137 ) -> AccessPlannedQuery<Value> {
138 AccessPlannedQuery::new(
139 AccessPath::index_range(index, prefix, lower, upper),
140 MissingRowPolicy::Ignore,
141 )
142 }
143
144 fn grouped_explain_with_fixed_shape() -> crate::db::query::explain::ExplainPlan {
145 AccessPlannedQuery::new(AccessPath::<Value>::FullScan, MissingRowPolicy::Ignore)
146 .into_grouped(GroupSpec {
147 group_fields: vec![FieldSlot::from_parts_for_test(1, "rank")],
148 aggregates: vec![GroupAggregateSpec {
149 kind: AggregateKind::Count,
150 target_field: None,
151 distinct: false,
152 }],
153 execution: GroupedExecutionConfig::with_hard_limits(64, 4096),
154 })
155 .explain()
156 }
157
158 #[test]
159 fn fingerprint_is_deterministic_for_equivalent_predicates() {
160 let id = Ulid::default();
161
162 let predicate_a = Predicate::And(vec![
163 FieldRef::new("id").eq(id),
164 FieldRef::new("other").eq(Value::Text("x".to_string())),
165 ]);
166 let predicate_b = Predicate::And(vec![
167 FieldRef::new("other").eq(Value::Text("x".to_string())),
168 FieldRef::new("id").eq(id),
169 ]);
170
171 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
172 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
173
174 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
175 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
176
177 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
178 }
179
180 #[test]
181 fn fingerprint_and_signature_are_stable_for_reordered_and_non_canonical_map_predicates() {
182 let map_a = Value::Map(vec![
183 (Value::Text("z".to_string()), Value::Int(9)),
184 (Value::Text("a".to_string()), Value::Int(1)),
185 ]);
186 let map_b = Value::Map(vec![
187 (Value::Text("a".to_string()), Value::Int(1)),
188 (Value::Text("z".to_string()), Value::Int(9)),
189 ]);
190
191 let predicate_a = Predicate::And(vec![
192 FieldRef::new("other").eq(Value::Text("x".to_string())),
193 Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_a)),
194 ]);
195 let predicate_b = Predicate::And(vec![
196 Predicate::Compare(ComparePredicate::eq("meta".to_string(), map_b)),
197 FieldRef::new("other").eq(Value::Text("x".to_string())),
198 ]);
199
200 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
201 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
202
203 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
204 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
205
206 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
207 assert_eq!(
208 plan_a.continuation_signature("tests::Entity"),
209 plan_b.continuation_signature("tests::Entity")
210 );
211 }
212
213 #[test]
214 fn fingerprint_and_signature_treat_equivalent_decimal_predicate_literals_as_identical() {
215 let predicate_a = Predicate::Compare(ComparePredicate::eq(
216 "rank".to_string(),
217 Value::Decimal(Decimal::new(10, 1)),
218 ));
219 let predicate_b = Predicate::Compare(ComparePredicate::eq(
220 "rank".to_string(),
221 Value::Decimal(Decimal::new(100, 2)),
222 ));
223
224 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
225 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
226
227 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
228 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
229
230 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
231 assert_eq!(
232 plan_a.continuation_signature("tests::Entity"),
233 plan_b.continuation_signature("tests::Entity")
234 );
235 }
236
237 #[test]
238 fn fingerprint_and_signature_treat_equivalent_in_list_predicates_as_identical() {
239 let predicate_a = Predicate::Compare(ComparePredicate::in_(
240 "rank".to_string(),
241 vec![Value::Uint(3), Value::Uint(1), Value::Uint(2)],
242 ));
243 let predicate_b = Predicate::Compare(ComparePredicate::in_(
244 "rank".to_string(),
245 vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
246 ));
247
248 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
249 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
250
251 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
252 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
253
254 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
255 assert_eq!(
256 plan_a.continuation_signature("tests::Entity"),
257 plan_b.continuation_signature("tests::Entity")
258 );
259 }
260
261 #[test]
262 fn fingerprint_and_signature_treat_equivalent_in_list_duplicate_literals_as_identical() {
263 let predicate_a = Predicate::Compare(ComparePredicate::in_(
264 "rank".to_string(),
265 vec![
266 Value::Uint(3),
267 Value::Uint(1),
268 Value::Uint(3),
269 Value::Uint(2),
270 ],
271 ));
272 let predicate_b = Predicate::Compare(ComparePredicate::in_(
273 "rank".to_string(),
274 vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
275 ));
276
277 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
278 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
279
280 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
281 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
282
283 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
284 assert_eq!(
285 plan_a.continuation_signature("tests::Entity"),
286 plan_b.continuation_signature("tests::Entity")
287 );
288 }
289
290 #[test]
291 fn fingerprint_and_signature_treat_implicit_and_explicit_strict_coercion_as_identical() {
292 let predicate_a =
293 Predicate::Compare(ComparePredicate::eq("rank".to_string(), Value::Int(7)));
294 let predicate_b = Predicate::Compare(ComparePredicate::with_coercion(
295 "rank",
296 CompareOp::Eq,
297 Value::Int(7),
298 CoercionId::Strict,
299 ));
300
301 let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
302 plan_a.scalar_plan_mut().predicate = Some(predicate_a);
303
304 let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
305 plan_b.scalar_plan_mut().predicate = Some(predicate_b);
306
307 assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
308 assert_eq!(
309 plan_a.continuation_signature("tests::Entity"),
310 plan_b.continuation_signature("tests::Entity")
311 );
312 }
313
314 #[test]
315 fn fingerprint_and_signature_distinguish_different_coercion_ids() {
316 let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
317 "rank",
318 CompareOp::Eq,
319 Value::Int(7),
320 CoercionId::Strict,
321 ));
322 let predicate_numeric_widen = Predicate::Compare(ComparePredicate::with_coercion(
323 "rank",
324 CompareOp::Eq,
325 Value::Int(7),
326 CoercionId::NumericWiden,
327 ));
328
329 let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
330 strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
331
332 let mut numeric_widen_plan: AccessPlannedQuery<Value> = full_scan_query();
333 numeric_widen_plan.scalar_plan_mut().predicate = Some(predicate_numeric_widen);
334
335 assert_ne!(strict_plan.fingerprint(), numeric_widen_plan.fingerprint());
336 assert_ne!(
337 strict_plan.continuation_signature("tests::Entity"),
338 numeric_widen_plan.continuation_signature("tests::Entity")
339 );
340 }
341
342 #[test]
343 fn fingerprint_and_signature_treat_numeric_widen_equivalent_literal_subtypes_as_identical() {
344 let predicate_int = Predicate::Compare(ComparePredicate::with_coercion(
345 "rank",
346 CompareOp::Eq,
347 Value::Int(1),
348 CoercionId::NumericWiden,
349 ));
350 let predicate_decimal = Predicate::Compare(ComparePredicate::with_coercion(
351 "rank",
352 CompareOp::Eq,
353 Value::Decimal(Decimal::new(10, 1)),
354 CoercionId::NumericWiden,
355 ));
356
357 let mut int_plan: AccessPlannedQuery<Value> = full_scan_query();
358 int_plan.scalar_plan_mut().predicate = Some(predicate_int);
359
360 let mut decimal_plan: AccessPlannedQuery<Value> = full_scan_query();
361 decimal_plan.scalar_plan_mut().predicate = Some(predicate_decimal);
362
363 assert_eq!(int_plan.fingerprint(), decimal_plan.fingerprint());
364 assert_eq!(
365 int_plan.continuation_signature("tests::Entity"),
366 decimal_plan.continuation_signature("tests::Entity")
367 );
368 }
369
370 #[test]
371 fn fingerprint_and_signature_treat_text_casefold_case_only_literals_as_identical() {
372 let predicate_lower = Predicate::Compare(ComparePredicate::with_coercion(
373 "name",
374 CompareOp::Eq,
375 Value::Text("ada".to_string()),
376 CoercionId::TextCasefold,
377 ));
378 let predicate_upper = Predicate::Compare(ComparePredicate::with_coercion(
379 "name",
380 CompareOp::Eq,
381 Value::Text("ADA".to_string()),
382 CoercionId::TextCasefold,
383 ));
384
385 let mut lower_plan: AccessPlannedQuery<Value> = full_scan_query();
386 lower_plan.scalar_plan_mut().predicate = Some(predicate_lower);
387
388 let mut upper_plan: AccessPlannedQuery<Value> = full_scan_query();
389 upper_plan.scalar_plan_mut().predicate = Some(predicate_upper);
390
391 assert_eq!(lower_plan.fingerprint(), upper_plan.fingerprint());
392 assert_eq!(
393 lower_plan.continuation_signature("tests::Entity"),
394 upper_plan.continuation_signature("tests::Entity")
395 );
396 }
397
398 #[test]
399 fn fingerprint_and_signature_keep_strict_text_case_variants_distinct() {
400 let predicate_lower = Predicate::Compare(ComparePredicate::with_coercion(
401 "name",
402 CompareOp::Eq,
403 Value::Text("ada".to_string()),
404 CoercionId::Strict,
405 ));
406 let predicate_upper = Predicate::Compare(ComparePredicate::with_coercion(
407 "name",
408 CompareOp::Eq,
409 Value::Text("ADA".to_string()),
410 CoercionId::Strict,
411 ));
412
413 let mut lower_plan: AccessPlannedQuery<Value> = full_scan_query();
414 lower_plan.scalar_plan_mut().predicate = Some(predicate_lower);
415
416 let mut upper_plan: AccessPlannedQuery<Value> = full_scan_query();
417 upper_plan.scalar_plan_mut().predicate = Some(predicate_upper);
418
419 assert_ne!(lower_plan.fingerprint(), upper_plan.fingerprint());
420 assert_ne!(
421 lower_plan.continuation_signature("tests::Entity"),
422 upper_plan.continuation_signature("tests::Entity")
423 );
424 }
425
426 #[test]
427 fn fingerprint_and_signature_treat_text_casefold_in_list_case_variants_as_identical() {
428 let predicate_mixed = Predicate::Compare(ComparePredicate::with_coercion(
429 "name",
430 CompareOp::In,
431 Value::List(vec![
432 Value::Text("ADA".to_string()),
433 Value::Text("ada".to_string()),
434 Value::Text("Bob".to_string()),
435 ]),
436 CoercionId::TextCasefold,
437 ));
438 let predicate_canonical = Predicate::Compare(ComparePredicate::with_coercion(
439 "name",
440 CompareOp::In,
441 Value::List(vec![
442 Value::Text("ada".to_string()),
443 Value::Text("bob".to_string()),
444 ]),
445 CoercionId::TextCasefold,
446 ));
447
448 let mut mixed_plan: AccessPlannedQuery<Value> = full_scan_query();
449 mixed_plan.scalar_plan_mut().predicate = Some(predicate_mixed);
450
451 let mut canonical_plan: AccessPlannedQuery<Value> = full_scan_query();
452 canonical_plan.scalar_plan_mut().predicate = Some(predicate_canonical);
453
454 assert_eq!(mixed_plan.fingerprint(), canonical_plan.fingerprint());
455 assert_eq!(
456 mixed_plan.continuation_signature("tests::Entity"),
457 canonical_plan.continuation_signature("tests::Entity")
458 );
459 }
460
461 #[test]
462 fn fingerprint_and_signature_distinguish_strict_from_text_casefold_coercion() {
463 let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
464 "name",
465 CompareOp::Eq,
466 Value::Text("ada".to_string()),
467 CoercionId::Strict,
468 ));
469 let predicate_casefold = Predicate::Compare(ComparePredicate::with_coercion(
470 "name",
471 CompareOp::Eq,
472 Value::Text("ada".to_string()),
473 CoercionId::TextCasefold,
474 ));
475
476 let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
477 strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
478
479 let mut casefold_plan: AccessPlannedQuery<Value> = full_scan_query();
480 casefold_plan.scalar_plan_mut().predicate = Some(predicate_casefold);
481
482 assert_ne!(strict_plan.fingerprint(), casefold_plan.fingerprint());
483 assert_ne!(
484 strict_plan.continuation_signature("tests::Entity"),
485 casefold_plan.continuation_signature("tests::Entity")
486 );
487 }
488
489 #[test]
490 fn fingerprint_and_signature_distinguish_strict_from_collection_element_coercion() {
491 let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
492 "rank",
493 CompareOp::Eq,
494 Value::Int(7),
495 CoercionId::Strict,
496 ));
497 let predicate_collection_element = Predicate::Compare(ComparePredicate::with_coercion(
498 "rank",
499 CompareOp::Eq,
500 Value::Int(7),
501 CoercionId::CollectionElement,
502 ));
503
504 let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
505 strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
506
507 let mut collection_plan: AccessPlannedQuery<Value> = full_scan_query();
508 collection_plan.scalar_plan_mut().predicate = Some(predicate_collection_element);
509
510 assert_ne!(strict_plan.fingerprint(), collection_plan.fingerprint());
511 assert_ne!(
512 strict_plan.continuation_signature("tests::Entity"),
513 collection_plan.continuation_signature("tests::Entity")
514 );
515 }
516
517 #[test]
518 fn fingerprint_is_deterministic_for_by_keys() {
519 let a = Ulid::from_u128(1);
520 let b = Ulid::from_u128(2);
521
522 let access_a = build_access_plan_from_keys(&KeyAccess::Many(vec![a, b, a]));
523 let access_b = build_access_plan_from_keys(&KeyAccess::Many(vec![b, a]));
524
525 let plan_a: AccessPlannedQuery<Value> = AccessPlannedQuery {
526 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
527 mode: QueryMode::Load(LoadSpec::new()),
528 predicate: None,
529 order: None,
530 distinct: false,
531 delete_limit: None,
532 page: None,
533 consistency: MissingRowPolicy::Ignore,
534 }),
535 access: access_a,
536 projection_selection: crate::db::query::plan::expr::ProjectionSelection::All,
537 };
538 let plan_b: AccessPlannedQuery<Value> = AccessPlannedQuery {
539 logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
540 mode: QueryMode::Load(LoadSpec::new()),
541 predicate: None,
542 order: None,
543 distinct: false,
544 delete_limit: None,
545 page: None,
546 consistency: MissingRowPolicy::Ignore,
547 }),
548 access: access_b,
549 projection_selection: crate::db::query::plan::expr::ProjectionSelection::All,
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}