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::{
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///
19/// PlanFingerprint
20///
21/// Stable, deterministic fingerprint for logical plans.
22///
23
24#[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    /// Compute a stable fingerprint for this logical plan.
45    #[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    /// Compute a stable fingerprint for this explain plan.
62    #[must_use]
63    pub fn fingerprint(&self) -> PlanFingerprint {
64        // Phase 1: hash canonical explain fields under the current fingerprint profile.
65        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        // Phase 2: finalize into the fixed-width fingerprint payload.
73        PlanFingerprint(finalize_sha256_digest(hasher))
74    }
75}
76
77///
78/// TESTS
79///
80
81#[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_same_field_or_eq_and_in_as_identical() {
263        let predicate_or_eq = Predicate::Or(vec![
264            Predicate::Compare(ComparePredicate::with_coercion(
265                "rank",
266                CompareOp::Eq,
267                Value::Uint(3),
268                CoercionId::Strict,
269            )),
270            Predicate::Compare(ComparePredicate::with_coercion(
271                "rank",
272                CompareOp::Eq,
273                Value::Uint(1),
274                CoercionId::Strict,
275            )),
276            Predicate::Compare(ComparePredicate::with_coercion(
277                "rank",
278                CompareOp::Eq,
279                Value::Uint(3),
280                CoercionId::Strict,
281            )),
282        ]);
283        let predicate_in = Predicate::Compare(ComparePredicate::with_coercion(
284            "rank",
285            CompareOp::In,
286            Value::List(vec![Value::Uint(1), Value::Uint(3)]),
287            CoercionId::Strict,
288        ));
289
290        let mut plan_or_eq: AccessPlannedQuery<Value> = full_scan_query();
291        plan_or_eq.scalar_plan_mut().predicate = Some(predicate_or_eq);
292
293        let mut plan_in: AccessPlannedQuery<Value> = full_scan_query();
294        plan_in.scalar_plan_mut().predicate = Some(predicate_in);
295
296        assert_eq!(plan_or_eq.fingerprint(), plan_in.fingerprint());
297        assert_eq!(
298            plan_or_eq.continuation_signature("tests::Entity"),
299            plan_in.continuation_signature("tests::Entity")
300        );
301    }
302
303    #[test]
304    fn fingerprint_and_signature_treat_equivalent_in_list_duplicate_literals_as_identical() {
305        let predicate_a = Predicate::Compare(ComparePredicate::in_(
306            "rank".to_string(),
307            vec![
308                Value::Uint(3),
309                Value::Uint(1),
310                Value::Uint(3),
311                Value::Uint(2),
312            ],
313        ));
314        let predicate_b = Predicate::Compare(ComparePredicate::in_(
315            "rank".to_string(),
316            vec![Value::Uint(1), Value::Uint(2), Value::Uint(3)],
317        ));
318
319        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
320        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
321
322        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
323        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
324
325        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
326        assert_eq!(
327            plan_a.continuation_signature("tests::Entity"),
328            plan_b.continuation_signature("tests::Entity")
329        );
330    }
331
332    #[test]
333    fn fingerprint_and_signature_treat_implicit_and_explicit_strict_coercion_as_identical() {
334        let predicate_a =
335            Predicate::Compare(ComparePredicate::eq("rank".to_string(), Value::Int(7)));
336        let predicate_b = Predicate::Compare(ComparePredicate::with_coercion(
337            "rank",
338            CompareOp::Eq,
339            Value::Int(7),
340            CoercionId::Strict,
341        ));
342
343        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
344        plan_a.scalar_plan_mut().predicate = Some(predicate_a);
345
346        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
347        plan_b.scalar_plan_mut().predicate = Some(predicate_b);
348
349        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
350        assert_eq!(
351            plan_a.continuation_signature("tests::Entity"),
352            plan_b.continuation_signature("tests::Entity")
353        );
354    }
355
356    #[test]
357    fn fingerprint_and_signature_distinguish_different_coercion_ids() {
358        let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
359            "rank",
360            CompareOp::Eq,
361            Value::Int(7),
362            CoercionId::Strict,
363        ));
364        let predicate_numeric_widen = Predicate::Compare(ComparePredicate::with_coercion(
365            "rank",
366            CompareOp::Eq,
367            Value::Int(7),
368            CoercionId::NumericWiden,
369        ));
370
371        let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
372        strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
373
374        let mut numeric_widen_plan: AccessPlannedQuery<Value> = full_scan_query();
375        numeric_widen_plan.scalar_plan_mut().predicate = Some(predicate_numeric_widen);
376
377        assert_ne!(strict_plan.fingerprint(), numeric_widen_plan.fingerprint());
378        assert_ne!(
379            strict_plan.continuation_signature("tests::Entity"),
380            numeric_widen_plan.continuation_signature("tests::Entity")
381        );
382    }
383
384    #[test]
385    fn fingerprint_and_signature_treat_numeric_widen_equivalent_literal_subtypes_as_identical() {
386        let predicate_int = Predicate::Compare(ComparePredicate::with_coercion(
387            "rank",
388            CompareOp::Eq,
389            Value::Int(1),
390            CoercionId::NumericWiden,
391        ));
392        let predicate_decimal = Predicate::Compare(ComparePredicate::with_coercion(
393            "rank",
394            CompareOp::Eq,
395            Value::Decimal(Decimal::new(10, 1)),
396            CoercionId::NumericWiden,
397        ));
398
399        let mut int_plan: AccessPlannedQuery<Value> = full_scan_query();
400        int_plan.scalar_plan_mut().predicate = Some(predicate_int);
401
402        let mut decimal_plan: AccessPlannedQuery<Value> = full_scan_query();
403        decimal_plan.scalar_plan_mut().predicate = Some(predicate_decimal);
404
405        assert_eq!(int_plan.fingerprint(), decimal_plan.fingerprint());
406        assert_eq!(
407            int_plan.continuation_signature("tests::Entity"),
408            decimal_plan.continuation_signature("tests::Entity")
409        );
410    }
411
412    #[test]
413    fn fingerprint_and_signature_treat_text_casefold_case_only_literals_as_identical() {
414        let predicate_lower = Predicate::Compare(ComparePredicate::with_coercion(
415            "name",
416            CompareOp::Eq,
417            Value::Text("ada".to_string()),
418            CoercionId::TextCasefold,
419        ));
420        let predicate_upper = Predicate::Compare(ComparePredicate::with_coercion(
421            "name",
422            CompareOp::Eq,
423            Value::Text("ADA".to_string()),
424            CoercionId::TextCasefold,
425        ));
426
427        let mut lower_plan: AccessPlannedQuery<Value> = full_scan_query();
428        lower_plan.scalar_plan_mut().predicate = Some(predicate_lower);
429
430        let mut upper_plan: AccessPlannedQuery<Value> = full_scan_query();
431        upper_plan.scalar_plan_mut().predicate = Some(predicate_upper);
432
433        assert_eq!(lower_plan.fingerprint(), upper_plan.fingerprint());
434        assert_eq!(
435            lower_plan.continuation_signature("tests::Entity"),
436            upper_plan.continuation_signature("tests::Entity")
437        );
438    }
439
440    #[test]
441    fn fingerprint_and_signature_keep_strict_text_case_variants_distinct() {
442        let predicate_lower = Predicate::Compare(ComparePredicate::with_coercion(
443            "name",
444            CompareOp::Eq,
445            Value::Text("ada".to_string()),
446            CoercionId::Strict,
447        ));
448        let predicate_upper = Predicate::Compare(ComparePredicate::with_coercion(
449            "name",
450            CompareOp::Eq,
451            Value::Text("ADA".to_string()),
452            CoercionId::Strict,
453        ));
454
455        let mut lower_plan: AccessPlannedQuery<Value> = full_scan_query();
456        lower_plan.scalar_plan_mut().predicate = Some(predicate_lower);
457
458        let mut upper_plan: AccessPlannedQuery<Value> = full_scan_query();
459        upper_plan.scalar_plan_mut().predicate = Some(predicate_upper);
460
461        assert_ne!(lower_plan.fingerprint(), upper_plan.fingerprint());
462        assert_ne!(
463            lower_plan.continuation_signature("tests::Entity"),
464            upper_plan.continuation_signature("tests::Entity")
465        );
466    }
467
468    #[test]
469    fn fingerprint_and_signature_treat_text_casefold_in_list_case_variants_as_identical() {
470        let predicate_mixed = Predicate::Compare(ComparePredicate::with_coercion(
471            "name",
472            CompareOp::In,
473            Value::List(vec![
474                Value::Text("ADA".to_string()),
475                Value::Text("ada".to_string()),
476                Value::Text("Bob".to_string()),
477            ]),
478            CoercionId::TextCasefold,
479        ));
480        let predicate_canonical = Predicate::Compare(ComparePredicate::with_coercion(
481            "name",
482            CompareOp::In,
483            Value::List(vec![
484                Value::Text("ada".to_string()),
485                Value::Text("bob".to_string()),
486            ]),
487            CoercionId::TextCasefold,
488        ));
489
490        let mut mixed_plan: AccessPlannedQuery<Value> = full_scan_query();
491        mixed_plan.scalar_plan_mut().predicate = Some(predicate_mixed);
492
493        let mut canonical_plan: AccessPlannedQuery<Value> = full_scan_query();
494        canonical_plan.scalar_plan_mut().predicate = Some(predicate_canonical);
495
496        assert_eq!(mixed_plan.fingerprint(), canonical_plan.fingerprint());
497        assert_eq!(
498            mixed_plan.continuation_signature("tests::Entity"),
499            canonical_plan.continuation_signature("tests::Entity")
500        );
501    }
502
503    #[test]
504    fn fingerprint_and_signature_distinguish_strict_from_text_casefold_coercion() {
505        let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
506            "name",
507            CompareOp::Eq,
508            Value::Text("ada".to_string()),
509            CoercionId::Strict,
510        ));
511        let predicate_casefold = Predicate::Compare(ComparePredicate::with_coercion(
512            "name",
513            CompareOp::Eq,
514            Value::Text("ada".to_string()),
515            CoercionId::TextCasefold,
516        ));
517
518        let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
519        strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
520
521        let mut casefold_plan: AccessPlannedQuery<Value> = full_scan_query();
522        casefold_plan.scalar_plan_mut().predicate = Some(predicate_casefold);
523
524        assert_ne!(strict_plan.fingerprint(), casefold_plan.fingerprint());
525        assert_ne!(
526            strict_plan.continuation_signature("tests::Entity"),
527            casefold_plan.continuation_signature("tests::Entity")
528        );
529    }
530
531    #[test]
532    fn fingerprint_and_signature_distinguish_strict_from_collection_element_coercion() {
533        let predicate_strict = Predicate::Compare(ComparePredicate::with_coercion(
534            "rank",
535            CompareOp::Eq,
536            Value::Int(7),
537            CoercionId::Strict,
538        ));
539        let predicate_collection_element = Predicate::Compare(ComparePredicate::with_coercion(
540            "rank",
541            CompareOp::Eq,
542            Value::Int(7),
543            CoercionId::CollectionElement,
544        ));
545
546        let mut strict_plan: AccessPlannedQuery<Value> = full_scan_query();
547        strict_plan.scalar_plan_mut().predicate = Some(predicate_strict);
548
549        let mut collection_plan: AccessPlannedQuery<Value> = full_scan_query();
550        collection_plan.scalar_plan_mut().predicate = Some(predicate_collection_element);
551
552        assert_ne!(strict_plan.fingerprint(), collection_plan.fingerprint());
553        assert_ne!(
554            strict_plan.continuation_signature("tests::Entity"),
555            collection_plan.continuation_signature("tests::Entity")
556        );
557    }
558
559    #[test]
560    fn fingerprint_is_deterministic_for_by_keys() {
561        let a = Ulid::from_u128(1);
562        let b = Ulid::from_u128(2);
563
564        let access_a = build_access_plan_from_keys(&KeyAccess::Many(vec![a, b, a]));
565        let access_b = build_access_plan_from_keys(&KeyAccess::Many(vec![b, a]));
566
567        let plan_a: AccessPlannedQuery<Value> = AccessPlannedQuery {
568            logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
569                mode: QueryMode::Load(LoadSpec::new()),
570                predicate: None,
571                order: None,
572                distinct: false,
573                delete_limit: None,
574                page: None,
575                consistency: MissingRowPolicy::Ignore,
576            }),
577            access: access_a,
578            projection_selection: crate::db::query::plan::expr::ProjectionSelection::All,
579        };
580        let plan_b: AccessPlannedQuery<Value> = AccessPlannedQuery {
581            logical: LogicalPlan::Scalar(crate::db::query::plan::ScalarPlan {
582                mode: QueryMode::Load(LoadSpec::new()),
583                predicate: None,
584                order: None,
585                distinct: false,
586                delete_limit: None,
587                page: None,
588                consistency: MissingRowPolicy::Ignore,
589            }),
590            access: access_b,
591            projection_selection: crate::db::query::plan::expr::ProjectionSelection::All,
592        };
593
594        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
595    }
596
597    #[test]
598    fn fingerprint_changes_with_index_choice() {
599        const INDEX_FIELDS: [&str; 1] = ["idx_a"];
600        const INDEX_A: IndexModel = IndexModel::new(
601            "fingerprint::idx_a",
602            "fingerprint::store",
603            &INDEX_FIELDS,
604            false,
605        );
606        const INDEX_B: IndexModel = IndexModel::new(
607            "fingerprint::idx_b",
608            "fingerprint::store",
609            &INDEX_FIELDS,
610            false,
611        );
612
613        let plan_a: AccessPlannedQuery<Value> =
614            index_prefix_query(INDEX_A, vec![Value::Text("alpha".to_string())]);
615        let plan_b: AccessPlannedQuery<Value> =
616            index_prefix_query(INDEX_B, vec![Value::Text("alpha".to_string())]);
617
618        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
619    }
620
621    #[test]
622    fn fingerprint_changes_with_pagination() {
623        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
624        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
625        plan_a.scalar_plan_mut().page = Some(PageSpec {
626            limit: Some(10),
627            offset: 0,
628        });
629        plan_b.scalar_plan_mut().page = Some(PageSpec {
630            limit: Some(10),
631            offset: 1,
632        });
633
634        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
635    }
636
637    #[test]
638    fn fingerprint_changes_with_delete_limit() {
639        let mut plan_a: AccessPlannedQuery<Value> = full_scan_query();
640        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
641        plan_a.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
642        plan_b.scalar_plan_mut().mode = QueryMode::Delete(DeleteSpec::new());
643        plan_a.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
644        plan_b.scalar_plan_mut().delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
645
646        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
647    }
648
649    #[test]
650    fn fingerprint_changes_with_distinct_flag() {
651        let plan_a: AccessPlannedQuery<Value> = full_scan_query();
652        let mut plan_b: AccessPlannedQuery<Value> = full_scan_query();
653        plan_b.scalar_plan_mut().distinct = true;
654
655        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
656    }
657
658    #[test]
659    fn fingerprint_numeric_projection_alias_only_change_does_not_invalidate() {
660        let plan: AccessPlannedQuery<Value> = full_scan_query();
661        let numeric_projection =
662            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
663                expr: Expr::Binary {
664                    op: crate::db::query::plan::expr::BinaryOp::Add,
665                    left: Box::new(Expr::Field(FieldId::new("rank"))),
666                    right: Box::new(Expr::Literal(Value::Int(1))),
667                },
668                alias: None,
669            }]);
670        let alias_only_numeric_projection =
671            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
672                expr: Expr::Alias {
673                    expr: Box::new(Expr::Binary {
674                        op: crate::db::query::plan::expr::BinaryOp::Add,
675                        left: Box::new(Expr::Field(FieldId::new("rank"))),
676                        right: Box::new(Expr::Literal(Value::Int(1))),
677                    }),
678                    name: Alias::new("rank_plus_one_expr"),
679                },
680                alias: Some(Alias::new("rank_plus_one")),
681            }]);
682
683        let semantic_fingerprint = fingerprint_with_projection(&plan, &numeric_projection);
684        let alias_fingerprint = fingerprint_with_projection(&plan, &alias_only_numeric_projection);
685
686        assert_eq!(
687            semantic_fingerprint, alias_fingerprint,
688            "numeric projection alias wrappers must not affect fingerprint identity",
689        );
690    }
691
692    #[test]
693    fn fingerprint_numeric_projection_semantic_change_invalidates() {
694        let plan: AccessPlannedQuery<Value> = full_scan_query();
695        let projection_add_one =
696            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
697                expr: Expr::Binary {
698                    op: crate::db::query::plan::expr::BinaryOp::Add,
699                    left: Box::new(Expr::Field(FieldId::new("rank"))),
700                    right: Box::new(Expr::Literal(Value::Int(1))),
701                },
702                alias: None,
703            }]);
704        let projection_mul_one =
705            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
706                expr: Expr::Binary {
707                    op: crate::db::query::plan::expr::BinaryOp::Mul,
708                    left: Box::new(Expr::Field(FieldId::new("rank"))),
709                    right: Box::new(Expr::Literal(Value::Int(1))),
710                },
711                alias: None,
712            }]);
713
714        let add_fingerprint = fingerprint_with_projection(&plan, &projection_add_one);
715        let mul_fingerprint = fingerprint_with_projection(&plan, &projection_mul_one);
716
717        assert_ne!(
718            add_fingerprint, mul_fingerprint,
719            "numeric projection semantic changes must invalidate fingerprint identity",
720        );
721    }
722
723    #[test]
724    fn fingerprint_numeric_literal_decimal_scale_is_canonicalized() {
725        let plan: AccessPlannedQuery<Value> = full_scan_query();
726        let decimal_one_scale_1 =
727            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
728                expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
729                alias: None,
730            }]);
731        let decimal_one_scale_2 =
732            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
733                expr: Expr::Literal(Value::Decimal(Decimal::new(100, 2))),
734                alias: None,
735            }]);
736
737        assert_eq!(
738            fingerprint_with_projection(&plan, &decimal_one_scale_1),
739            fingerprint_with_projection(&plan, &decimal_one_scale_2),
740            "decimal scale-only literal changes must not fragment fingerprint identity",
741        );
742    }
743
744    #[test]
745    fn fingerprint_literal_numeric_subtype_remains_significant_when_observable() {
746        let plan: AccessPlannedQuery<Value> = full_scan_query();
747        let int_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
748            expr: Expr::Literal(Value::Int(1)),
749            alias: None,
750        }]);
751        let decimal_literal = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
752            expr: Expr::Literal(Value::Decimal(Decimal::new(10, 1))),
753            alias: None,
754        }]);
755
756        assert_ne!(
757            fingerprint_with_projection(&plan, &int_literal),
758            fingerprint_with_projection(&plan, &decimal_literal),
759            "top-level literal subtype remains observable and identity-significant",
760        );
761    }
762
763    #[test]
764    fn fingerprint_numeric_promotion_paths_do_not_fragment() {
765        let plan: AccessPlannedQuery<Value> = full_scan_query();
766        let int_plus_int = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
767            expr: Expr::Binary {
768                op: BinaryOp::Add,
769                left: Box::new(Expr::Literal(Value::Int(1))),
770                right: Box::new(Expr::Literal(Value::Int(2))),
771            },
772            alias: None,
773        }]);
774        let int_plus_decimal =
775            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
776                expr: Expr::Binary {
777                    op: BinaryOp::Add,
778                    left: Box::new(Expr::Literal(Value::Int(1))),
779                    right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(20, 1)))),
780                },
781                alias: None,
782            }]);
783        let decimal_plus_int =
784            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
785                expr: Expr::Binary {
786                    op: BinaryOp::Add,
787                    left: Box::new(Expr::Literal(Value::Decimal(Decimal::new(10, 1)))),
788                    right: Box::new(Expr::Literal(Value::Int(2))),
789                },
790                alias: None,
791            }]);
792
793        let fingerprint_int_plus_int = fingerprint_with_projection(&plan, &int_plus_int);
794        let fingerprint_int_plus_decimal = fingerprint_with_projection(&plan, &int_plus_decimal);
795        let fingerprint_decimal_plus_int = fingerprint_with_projection(&plan, &decimal_plus_int);
796
797        assert_eq!(fingerprint_int_plus_int, fingerprint_int_plus_decimal);
798        assert_eq!(fingerprint_int_plus_int, fingerprint_decimal_plus_int);
799    }
800
801    #[test]
802    fn fingerprint_commutative_operand_order_remains_significant_without_ast_normalization() {
803        let plan: AccessPlannedQuery<Value> = full_scan_query();
804        let rank_plus_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
805            expr: Expr::Binary {
806                op: BinaryOp::Add,
807                left: Box::new(Expr::Field(FieldId::new("rank"))),
808                right: Box::new(Expr::Field(FieldId::new("score"))),
809            },
810            alias: None,
811        }]);
812        let score_plus_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
813            expr: Expr::Binary {
814                op: BinaryOp::Add,
815                left: Box::new(Expr::Field(FieldId::new("score"))),
816                right: Box::new(Expr::Field(FieldId::new("rank"))),
817            },
818            alias: None,
819        }]);
820
821        assert_ne!(
822            fingerprint_with_projection(&plan, &rank_plus_score),
823            fingerprint_with_projection(&plan, &score_plus_rank),
824            "fingerprint preserves AST operand order for commutative operators in v2",
825        );
826    }
827
828    #[test]
829    fn fingerprint_aggregate_numeric_target_field_remains_significant() {
830        let plan: AccessPlannedQuery<Value> = full_scan_query();
831        let sum_rank = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
832            expr: Expr::Aggregate(sum("rank")),
833            alias: None,
834        }]);
835        let sum_score = ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
836            expr: Expr::Aggregate(sum("score")),
837            alias: None,
838        }]);
839
840        assert_ne!(
841            fingerprint_with_projection(&plan, &sum_rank),
842            fingerprint_with_projection(&plan, &sum_score),
843            "aggregate target field changes must invalidate fingerprint identity",
844        );
845    }
846
847    #[test]
848    fn fingerprint_distinct_numeric_noop_paths_stay_stable() {
849        let plan: AccessPlannedQuery<Value> = full_scan_query();
850        let sum_distinct_plus_int_zero =
851            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
852                expr: Expr::Binary {
853                    op: BinaryOp::Add,
854                    left: Box::new(Expr::Aggregate(sum("rank").distinct())),
855                    right: Box::new(Expr::Literal(Value::Int(0))),
856                },
857                alias: None,
858            }]);
859        let sum_distinct_plus_decimal_zero =
860            ProjectionSpec::from_fields_for_test(vec![ProjectionField::Scalar {
861                expr: Expr::Binary {
862                    op: BinaryOp::Add,
863                    left: Box::new(Expr::Aggregate(sum("rank").distinct())),
864                    right: Box::new(Expr::Literal(Value::Decimal(Decimal::new(0, 1)))),
865                },
866                alias: None,
867            }]);
868
869        assert_eq!(
870            fingerprint_with_projection(&plan, &sum_distinct_plus_int_zero),
871            fingerprint_with_projection(&plan, &sum_distinct_plus_decimal_zero),
872            "distinct numeric no-op literal subtype differences must not fragment fingerprint identity",
873        );
874    }
875
876    #[test]
877    fn fingerprint_is_stable_for_full_scan() {
878        let plan: AccessPlannedQuery<Value> = full_scan_query();
879        let fingerprint_a = plan.fingerprint();
880        let fingerprint_b = plan.fingerprint();
881        assert_eq!(fingerprint_a, fingerprint_b);
882    }
883
884    #[test]
885    fn fingerprint_is_stable_for_equivalent_index_range_bounds() {
886        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
887        const INDEX: IndexModel = IndexModel::new(
888            "fingerprint::group_rank",
889            "fingerprint::store",
890            &INDEX_FIELDS,
891            false,
892        );
893
894        let plan_a: AccessPlannedQuery<Value> = index_range_query(
895            INDEX,
896            vec![Value::Uint(7)],
897            Bound::Included(Value::Uint(100)),
898            Bound::Excluded(Value::Uint(200)),
899        );
900        let plan_b: AccessPlannedQuery<Value> = index_range_query(
901            INDEX,
902            vec![Value::Uint(7)],
903            Bound::Included(Value::Uint(100)),
904            Bound::Excluded(Value::Uint(200)),
905        );
906
907        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
908    }
909
910    #[test]
911    fn fingerprint_changes_when_index_range_bound_discriminant_changes() {
912        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
913        const INDEX: IndexModel = IndexModel::new(
914            "fingerprint::group_rank",
915            "fingerprint::store",
916            &INDEX_FIELDS,
917            false,
918        );
919
920        let plan_included: AccessPlannedQuery<Value> = index_range_query(
921            INDEX,
922            vec![Value::Uint(7)],
923            Bound::Included(Value::Uint(100)),
924            Bound::Excluded(Value::Uint(200)),
925        );
926        let plan_excluded: AccessPlannedQuery<Value> = index_range_query(
927            INDEX,
928            vec![Value::Uint(7)],
929            Bound::Excluded(Value::Uint(100)),
930            Bound::Excluded(Value::Uint(200)),
931        );
932
933        assert_ne!(plan_included.fingerprint(), plan_excluded.fingerprint());
934    }
935
936    #[test]
937    fn fingerprint_changes_when_index_range_bound_value_changes() {
938        const INDEX_FIELDS: [&str; 2] = ["group", "rank"];
939        const INDEX: IndexModel = IndexModel::new(
940            "fingerprint::group_rank",
941            "fingerprint::store",
942            &INDEX_FIELDS,
943            false,
944        );
945
946        let plan_low_100: AccessPlannedQuery<Value> = index_range_query(
947            INDEX,
948            vec![Value::Uint(7)],
949            Bound::Included(Value::Uint(100)),
950            Bound::Excluded(Value::Uint(200)),
951        );
952        let plan_low_101: AccessPlannedQuery<Value> = index_range_query(
953            INDEX,
954            vec![Value::Uint(7)],
955            Bound::Included(Value::Uint(101)),
956            Bound::Excluded(Value::Uint(200)),
957        );
958
959        assert_ne!(plan_low_100.fingerprint(), plan_low_101.fingerprint());
960    }
961
962    #[test]
963    fn explain_fingerprint_grouped_strategy_only_change_does_not_invalidate() {
964        let mut hash_strategy = grouped_explain_with_fixed_shape();
965        let mut ordered_strategy = hash_strategy.clone();
966
967        let ExplainGrouping::Grouped {
968            strategy: hash_value,
969            ..
970        } = &mut hash_strategy.grouping
971        else {
972            panic!("grouped explain fixture must produce grouped explain shape");
973        };
974        *hash_value = ExplainGroupedStrategy::HashGroup;
975        let ExplainGrouping::Grouped {
976            strategy: ordered_value,
977            ..
978        } = &mut ordered_strategy.grouping
979        else {
980            panic!("grouped explain fixture must produce grouped explain shape");
981        };
982        *ordered_value = ExplainGroupedStrategy::OrderedGroup;
983
984        assert_eq!(
985            hash_strategy.fingerprint(),
986            ordered_strategy.fingerprint(),
987            "execution strategy hints are explain/runtime metadata and must not affect semantic fingerprint identity",
988        );
989    }
990}