Skip to content

Commit cc6803a

Browse files
authored
Fix nested scalar hashing (#8804)
Fixes #8744 `Scalar` equality ignores dtype nullability recursively, but hashing only removed top-level nullability. The typed scalar views had the same mismatch, so equal nested values could produce different hashes. - adds recursive nullability-insensitive dtype hashing matching `DType::eq_ignore_nullability` - uses it for `Scalar`, `ListScalar`, `StructScalar`, and `ExtScalar` - covers nested list, struct, Union dtype, and extension storage nullability regressions This is kind of ugly but I don't think there is a better way to do this
1 parent 928738e commit cc6803a

10 files changed

Lines changed: 245 additions & 25 deletions

File tree

vortex-array/src/dtype/dtype_impl.rs

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
use std::fmt::Display;
55
use std::fmt::Formatter;
6+
use std::hash::Hash;
7+
use std::hash::Hasher;
68
use std::sync::Arc;
79

810
use DType::*;
@@ -120,21 +122,9 @@ impl DType {
120122
lhs_size == rhs_size && lhs_dtype.eq_ignore_nullability(rhs_dtype)
121123
}
122124
(Struct(lhs_dtype, _), Struct(rhs_dtype, _)) => {
123-
(lhs_dtype.names() == rhs_dtype.names())
124-
&& (lhs_dtype
125-
.fields()
126-
.zip_eq(rhs_dtype.fields())
127-
.all(|(l, r)| l.eq_ignore_nullability(&r)))
128-
}
129-
(Union(lhs, _), Union(rhs, _)) => {
130-
// Equal `names` implies equal length by FieldNames equality.
131-
lhs.names() == rhs.names()
132-
&& lhs.type_ids() == rhs.type_ids()
133-
&& lhs
134-
.variants()
135-
.zip_eq(rhs.variants())
136-
.all(|(l, r)| l.eq_ignore_nullability(&r))
125+
lhs_dtype.eq_ignore_nullability(rhs_dtype)
137126
}
127+
(Union(lhs, _), Union(rhs, _)) => lhs.eq_ignore_nullability(rhs),
138128
(Variant(_), Variant(_)) => true,
139129
(Extension(lhs_extdtype), Extension(rhs_extdtype)) => {
140130
lhs_extdtype.eq_ignore_nullability(rhs_extdtype)
@@ -143,6 +133,25 @@ impl DType {
143133
}
144134
}
145135

136+
/// Hash this dtype using the same equivalence relation as [`Self::eq_ignore_nullability`].
137+
pub(crate) fn hash_ignore_nullability<H: Hasher>(&self, state: &mut H) {
138+
std::mem::discriminant(self).hash(state);
139+
140+
match self {
141+
Null | Bool(_) | Utf8(_) | Binary(_) | Variant(_) => {}
142+
Primitive(ptype, _) => ptype.hash(state),
143+
Decimal(decimal, _) => decimal.hash(state),
144+
List(element, _) => element.hash_ignore_nullability(state),
145+
FixedSizeList(element, size, _) => {
146+
element.hash_ignore_nullability(state);
147+
size.hash(state);
148+
}
149+
Struct(fields, _) => fields.hash_ignore_nullability(state),
150+
Union(variants, _) => variants.hash_ignore_nullability(state),
151+
Extension(ext) => ext.hash_ignore_nullability(state),
152+
}
153+
}
154+
146155
/// Returns `true` if `self` is a subset type of `other, otherwise `false`.
147156
///
148157
/// If `self` is nullable, this means that the other `DType` must also be nullable (since a
@@ -514,18 +523,29 @@ impl Display for DType {
514523

515524
#[cfg(test)]
516525
mod tests {
526+
use std::collections::hash_map::DefaultHasher;
527+
use std::hash::Hasher;
517528
use std::sync::Arc;
518529

530+
use vortex_error::VortexResult;
531+
519532
use crate::dtype::DType;
520533
use crate::dtype::Nullability::NonNullable;
521534
use crate::dtype::Nullability::Nullable;
522535
use crate::dtype::PType;
536+
use crate::dtype::UnionVariants;
523537
use crate::dtype::decimal::DecimalDType;
524538
use crate::extension::datetime::Date;
525539
use crate::extension::datetime::Time;
526540
use crate::extension::datetime::TimeUnit;
527541
use crate::extension::datetime::Timestamp;
528542

543+
fn hash_ignore_nullability(dtype: &DType) -> u64 {
544+
let mut hasher = DefaultHasher::new();
545+
dtype.hash_ignore_nullability(&mut hasher);
546+
hasher.finish()
547+
}
548+
529549
#[test]
530550
fn test_ext_dtype_eq_ignore_nullability() {
531551
let d1 = DType::Extension(Time::new(TimeUnit::Seconds, Nullable).erased());
@@ -541,6 +561,37 @@ mod tests {
541561
assert!(!t1.eq_ignore_nullability(&t2));
542562
}
543563

564+
#[test]
565+
fn test_union_dtype_hash_ignores_variant_nullability() -> VortexResult<()> {
566+
let lhs = DType::Union(
567+
UnionVariants::try_new(
568+
["int", "string"].into(),
569+
vec![
570+
DType::Primitive(PType::I32, Nullable),
571+
DType::Utf8(NonNullable),
572+
],
573+
vec![5, 9],
574+
)?,
575+
NonNullable,
576+
);
577+
let rhs = DType::Union(
578+
UnionVariants::try_new(
579+
["int", "string"].into(),
580+
vec![
581+
DType::Primitive(PType::I32, NonNullable),
582+
DType::Utf8(Nullable),
583+
],
584+
vec![5, 9],
585+
)?,
586+
NonNullable,
587+
);
588+
589+
assert!(lhs.eq_ignore_nullability(&rhs));
590+
assert_eq!(hash_ignore_nullability(&lhs), hash_ignore_nullability(&rhs));
591+
592+
Ok(())
593+
}
594+
544595
#[test]
545596
fn element_size_null() {
546597
assert_eq!(DType::Null.element_size(), Some(0));

vortex-array/src/dtype/extension/erased.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ impl ExtDTypeRef {
7575
.eq_ignore_nullability(other.storage_dtype())
7676
}
7777

78+
/// Hash this extension dtype using the same equivalence relation as
79+
/// [`Self::eq_ignore_nullability`].
80+
pub(crate) fn hash_ignore_nullability<H: Hasher>(&self, state: &mut H) {
81+
self.id().hash(state);
82+
self.0.metadata_hash(state);
83+
self.storage_dtype().hash_ignore_nullability(state);
84+
}
85+
7886
// TODO(connor): We should add a different type that returns something that can be serialized.
7987
/// Serialize the metadata into a byte vector.
8088
pub fn serialize_metadata(&self) -> VortexResult<Vec<u8>> {

vortex-array/src/dtype/struct_.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use std::fmt::Display;
55
use std::fmt::Formatter;
66
use std::hash::Hash;
7+
use std::hash::Hasher;
78
use std::sync::Arc;
89
use std::sync::OnceLock;
910

@@ -83,7 +84,7 @@ impl PartialEq for FieldDTypeInner {
8384
impl Eq for FieldDTypeInner {}
8485

8586
impl Hash for FieldDTypeInner {
86-
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
87+
fn hash<H: Hasher>(&self, state: &mut H) {
8788
match self {
8889
FieldDTypeInner::Owned(owned) => {
8990
owned.hash(state);
@@ -255,12 +256,33 @@ impl PartialEq for StructFieldsInner {
255256
impl Eq for StructFieldsInner {}
256257

257258
impl Hash for StructFieldsInner {
258-
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
259+
fn hash<H: Hasher>(&self, state: &mut H) {
259260
self.names.hash(state);
260261
self.dtypes.hash(state);
261262
}
262263
}
263264

265+
impl StructFields {
266+
/// Check if these struct fields are equal, ignoring field dtype nullability recursively.
267+
pub fn eq_ignore_nullability(&self, other: &Self) -> bool {
268+
Arc::ptr_eq(&self.0, &other.0)
269+
|| (self.0.names == other.0.names
270+
&& self
271+
.fields()
272+
.zip_eq(other.fields())
273+
.all(|(lhs, rhs)| lhs.eq_ignore_nullability(&rhs)))
274+
}
275+
276+
/// Hash these struct fields using the same equivalence relation as
277+
/// [`Self::eq_ignore_nullability`].
278+
pub(crate) fn hash_ignore_nullability<H: Hasher>(&self, state: &mut H) {
279+
self.0.names.hash(state);
280+
for field in self.fields() {
281+
field.hash_ignore_nullability(state);
282+
}
283+
}
284+
}
285+
264286
impl Default for StructFields {
265287
fn default() -> Self {
266288
Self::empty()

vortex-array/src/dtype/union.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
use std::fmt;
55
use std::hash::Hash;
6+
use std::hash::Hasher;
67
use std::sync::Arc;
78

89
use itertools::Itertools;
@@ -133,13 +134,36 @@ impl PartialEq for UnionVariantsInner {
133134
impl Eq for UnionVariantsInner {}
134135

135136
impl Hash for UnionVariantsInner {
136-
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
137+
fn hash<H: Hasher>(&self, state: &mut H) {
137138
self.names.hash(state);
138139
self.dtypes.hash(state);
139140
self.type_ids.hash(state);
140141
}
141142
}
142143

144+
impl UnionVariants {
145+
/// Check if these union variants are equal, ignoring variant dtype nullability recursively.
146+
pub fn eq_ignore_nullability(&self, other: &Self) -> bool {
147+
Arc::ptr_eq(&self.0, &other.0)
148+
|| (self.0.names == other.0.names
149+
&& self
150+
.variants()
151+
.zip_eq(other.variants())
152+
.all(|(lhs, rhs)| lhs.eq_ignore_nullability(&rhs))
153+
&& self.0.type_ids == other.0.type_ids)
154+
}
155+
156+
/// Hash these union variants using the same equivalence relation as
157+
/// [`Self::eq_ignore_nullability`].
158+
pub(crate) fn hash_ignore_nullability<H: Hasher>(&self, state: &mut H) {
159+
self.0.names.hash(state);
160+
for variant in self.variants() {
161+
variant.hash_ignore_nullability(state);
162+
}
163+
self.0.type_ids.hash(state);
164+
}
165+
}
166+
143167
impl Default for UnionVariants {
144168
fn default() -> Self {
145169
Self::empty()

vortex-array/src/scalar/scalar_impl.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ impl Scalar {
282282
/// values have equal hashes.
283283
impl Hash for Scalar {
284284
fn hash<H: Hasher>(&self, state: &mut H) {
285-
self.dtype.as_nonnullable().hash(state);
285+
self.dtype.hash_ignore_nullability(state);
286286
self.value.hash(state);
287287
}
288288
}

vortex-array/src/scalar/tests/primitives.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
66
#[cfg(test)]
77
mod tests {
8+
use std::collections::hash_map::DefaultHasher;
9+
use std::hash::Hash;
10+
use std::hash::Hasher;
811
use std::sync::Arc;
912

1013
use vortex_buffer::ByteBuffer;
@@ -24,6 +27,12 @@ mod tests {
2427
use crate::scalar::Scalar;
2528
use crate::scalar::ScalarValue;
2629

30+
fn scalar_hash(scalar: &Scalar) -> u64 {
31+
let mut hasher = DefaultHasher::new();
32+
scalar.hash(&mut hasher);
33+
hasher.finish()
34+
}
35+
2736
#[test]
2837
fn default_value_for_complex_dtype() {
2938
let struct_dtype = DType::struct_(
@@ -412,6 +421,23 @@ mod tests {
412421
assert_eq!(set.len(), 5);
413422
}
414423

424+
#[test]
425+
fn test_scalar_hash_ignores_nested_nullability() {
426+
let nullable = Scalar::list(
427+
DType::Primitive(PType::I32, Nullability::Nullable),
428+
vec![Scalar::primitive(42_i32, Nullability::Nullable)],
429+
Nullability::NonNullable,
430+
);
431+
let non_nullable = Scalar::list(
432+
DType::Primitive(PType::I32, Nullability::NonNullable),
433+
vec![Scalar::primitive(42_i32, Nullability::NonNullable)],
434+
Nullability::NonNullable,
435+
);
436+
437+
assert_eq!(nullable, non_nullable);
438+
assert_eq!(scalar_hash(&nullable), scalar_hash(&non_nullable));
439+
}
440+
415441
#[test]
416442
fn test_scalar_partial_ord_incompatible_types() {
417443
let int_scalar = Scalar::primitive(42i32, Nullability::NonNullable);

vortex-array/src/scalar/typed_view/extension/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ impl PartialOrd for ExtScalar<'_> {
139139

140140
impl Hash for ExtScalar<'_> {
141141
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
142-
self.ext_dtype.hash(state);
142+
self.ext_dtype.hash_ignore_nullability(state);
143143
self.to_storage_scalar().hash(state);
144144
}
145145
}

vortex-array/src/scalar/typed_view/extension/tests.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4+
use std::collections::hash_map::DefaultHasher;
5+
use std::hash::Hash;
6+
use std::hash::Hasher;
7+
48
use vortex_error::VortexResult;
59
use vortex_error::vortex_bail;
610

@@ -174,6 +178,28 @@ fn test_ext_scalar_hash() {
174178
assert_eq!(set.len(), 2);
175179
}
176180

181+
#[test]
182+
fn test_ext_scalar_hash_ignores_storage_nullability() {
183+
let nullable = Scalar::extension::<TestI32Ext>(
184+
EmptyMetadata,
185+
Scalar::primitive(42_i32, Nullability::Nullable),
186+
);
187+
let non_nullable = Scalar::extension::<TestI32Ext>(
188+
EmptyMetadata,
189+
Scalar::primitive(42_i32, Nullability::NonNullable),
190+
);
191+
let nullable = nullable.as_extension();
192+
let non_nullable = non_nullable.as_extension();
193+
194+
assert_eq!(nullable, non_nullable);
195+
196+
let mut nullable_hasher = DefaultHasher::new();
197+
nullable.hash(&mut nullable_hasher);
198+
let mut non_nullable_hasher = DefaultHasher::new();
199+
non_nullable.hash(&mut non_nullable_hasher);
200+
assert_eq!(nullable_hasher.finish(), non_nullable_hasher.finish());
201+
}
202+
177203
#[test]
178204
fn test_ext_scalar_storage() {
179205
let storage_scalar = Scalar::primitive(42i32, Nullability::NonNullable);

0 commit comments

Comments
 (0)