Skip to main content

icydb_core/db/query/fingerprint/
fingerprint.rs

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