Skip to main content

icydb_core/db/query/fingerprint/
fingerprint.rs

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