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