Skip to content

Commit 021127d

Browse files
committed
Respect null masks in Column reads
1 parent 742d2d1 commit 021127d

7 files changed

Lines changed: 201 additions & 28 deletions

File tree

‎doc/reference/ctable.rst‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,14 @@ inferred from Arrow, Parquet or CSV — keeps its nulls in a sidecar, so:
212212
blosc2.utf8(nullable=True) # any string, including "" and "\x00"
213213
blosc2.complex128(nullable=True) # nullable at all, for the first time
214214
215+
Scalar column reads apply the validity mask: ``t["price"][i]`` returns
216+
``None`` for a missing value, matching ``t[i].price``. Slices and gathers
217+
containing mask-storage nulls return a NumPy masked array, preserving the
218+
native dtype and displaying missing values as ``--``. Null-free selections
219+
return plain NumPy arrays. ``column.to_numpy()`` explicitly returns unmasked
220+
values; ``column.to_numpy(masked=True)`` always includes a mask.
221+
Sentinel-storage reads keep returning the reserved value.
222+
215223
Sentinel storage is supported indefinitely and is one keyword away, per column
216224
(``null_storage="sentinel"``, or any explicit ``null_value=``) or globally
217225
through :class:`NullPolicy`. It is the right choice when a column has to stay

‎src/blosc2/ctable.py‎

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1586,8 +1586,15 @@ def __getitem__(self, key: int | slice | list | np.ndarray):
15861586
- ``list / np.ndarray`` → :class:`numpy.ndarray`
15871587
- ``bool np.ndarray`` → :class:`numpy.ndarray`
15881588

1589+
Scalar reads return ``None`` for mask-storage nulls. Slices and gathers
1590+
containing mask-storage nulls return :class:`numpy.ma.MaskedArray`,
1591+
retaining the native dtype. Null-free selections return plain arrays.
1592+
15891593
For a writable logical sub-view use :attr:`view`.
15901594
"""
1595+
if self._remote_storage_ref is not None:
1596+
with self._remote_storage_ref._owner.lock:
1597+
return self._values_from_key(key)
15911598
return self._values_from_key(key)
15921599

15931600
def contains(self, value):
@@ -1634,13 +1641,13 @@ def _values_from_key(self, key, *, check_stale: bool = True): # noqa: C901
16341641
raise IndexError("empty tuple index is not valid for Column")
16351642
row_key, inner_key = key[0], key[1:]
16361643
values = self._values_from_key(row_key, check_stale=False)
1637-
if not inner_key:
1644+
if not inner_key or values is None:
16381645
return values
16391646
if isinstance(row_key, (int, np.integer)) and not isinstance(row_key, (bool, np.bool_)):
16401647
return values[inner_key]
16411648
return values[(slice(None), *inner_key)]
16421649

1643-
if isinstance(key, int):
1650+
if isinstance(key, (int, np.integer)):
16441651
n_rows = len(self)
16451652
if key < 0:
16461653
key += n_rows
@@ -1653,6 +1660,9 @@ def _values_from_key(self, key, *, check_stale: bool = True): # noqa: C901
16531660
pos_true = int(_slp[key])
16541661
else:
16551662
pos_true = _find_physical_index(self._valid_rows, key)
1663+
mask = self._nulls.valid_array()
1664+
if mask is not None and not mask[int(pos_true)]:
1665+
return None
16561666
if self.is_dictionary:
16571667
return self._raw_col[int(pos_true)]
16581668
return self._maybe_decode_timestamp_values(self._raw_col[int(pos_true)])
@@ -1675,14 +1685,16 @@ def _values_from_key(self, key, *, check_stale: bool = True): # noqa: C901
16751685
)
16761686
and self._has_identity_positions()
16771687
):
1678-
return self._maybe_decode_timestamp_values(np.asarray(self._raw_col[key]))
1688+
return self._mask_selected_values(
1689+
self._maybe_decode_timestamp_values(np.asarray(self._raw_col[key])), key
1690+
)
16791691
real_pos = self._resolve_live_positions()
16801692
# Apply the slice straight to the physical positions so that all
16811693
# slice semantics (including negative steps) follow NumPy.
16821694
selected_pos = real_pos[key]
16831695
if selected_pos.size == 0:
16841696
if self.is_utf8:
1685-
return self._raw_col[selected_pos]
1697+
return self._mask_selected_values(self._raw_col[selected_pos], selected_pos)
16861698
if self.is_list or self.is_varlen_scalar or self.is_dictionary:
16871699
return []
16881700
if self.is_ndarray:
@@ -1694,8 +1706,10 @@ def _values_from_key(self, key, *, check_stale: bool = True): # noqa: C901
16941706
chunk = np.asarray(self._raw_col[lo : hi + 1])
16951707
return chunk[selected_pos - lo]
16961708
if self.is_list or self.is_varlen_scalar or self.is_dictionary:
1697-
return self._raw_col[selected_pos]
1698-
return self._maybe_decode_timestamp_values(np.asarray(self._raw_col[selected_pos]))
1709+
return self._mask_selected_values(self._raw_col[selected_pos], selected_pos)
1710+
return self._mask_selected_values(
1711+
self._maybe_decode_timestamp_values(np.asarray(self._raw_col[selected_pos])), selected_pos
1712+
)
16991713

17001714
elif isinstance(key, np.ndarray) and key.dtype == np.bool_:
17011715
n_live = len(self)
@@ -1709,8 +1723,10 @@ def _values_from_key(self, key, *, check_stale: bool = True): # noqa: C901
17091723
raw_np = np.asarray(self._raw_col[:])
17101724
return raw_np[phys_indices]
17111725
if self.is_list or self.is_varlen_scalar or self.is_dictionary:
1712-
return self._raw_col[phys_indices]
1713-
return self._maybe_decode_timestamp_values(self._raw_col[phys_indices])
1726+
return self._mask_selected_values(self._raw_col[phys_indices], phys_indices)
1727+
return self._mask_selected_values(
1728+
self._maybe_decode_timestamp_values(self._raw_col[phys_indices]), phys_indices
1729+
)
17141730

17151731
elif isinstance(key, (list, tuple, np.ndarray)):
17161732
real_pos = self._resolve_live_positions()
@@ -1719,11 +1735,25 @@ def _values_from_key(self, key, *, check_stale: bool = True): # noqa: C901
17191735
raw_np = np.asarray(self._raw_col[:])
17201736
return raw_np[phys_indices]
17211737
if self.is_list or self.is_varlen_scalar or self.is_dictionary:
1722-
return self._raw_col[phys_indices]
1723-
return self._maybe_decode_timestamp_values(self._raw_col[phys_indices])
1738+
return self._mask_selected_values(self._raw_col[phys_indices], phys_indices)
1739+
return self._mask_selected_values(
1740+
self._maybe_decode_timestamp_values(self._raw_col[phys_indices]), phys_indices
1741+
)
17241742

17251743
raise TypeError(f"Invalid index type: {type(key)}")
17261744

1745+
def _mask_selected_values(self, values, positions):
1746+
"""Attach validity for selected physical rows without changing the dtype."""
1747+
valid = self._nulls.valid_array()
1748+
if valid is None:
1749+
return values
1750+
null = ~np.asarray(valid[positions], dtype=bool)
1751+
if not null.any():
1752+
return values
1753+
values = np.asarray(values)
1754+
mask = np.broadcast_to(null.reshape((-1,) + (1,) * (values.ndim - 1)), values.shape).copy()
1755+
return np.ma.MaskedArray(values, mask=mask, copy=False)
1756+
17271757
def _view_from_key(self, key) -> Column:
17281758
"""Build a Column sub-view for the given logical index key.
17291759

@@ -3323,7 +3353,9 @@ def to_numpy(self, *, masked: bool = False):
33233353
arr = np.asarray(self[:])
33243354
if not masked:
33253355
return arr
3326-
return np.ma.MaskedArray(arr, mask=self.is_null())
3356+
null = self.is_null()
3357+
mask = np.broadcast_to(null.reshape((-1,) + (1,) * (arr.ndim - 1)), arr.shape).copy()
3358+
return np.ma.MaskedArray(arr, mask=mask, copy=False)
33273359

33283360
def _nonnull_chunks(self):
33293361
"""Yield chunks of live, non-null values.
@@ -8465,7 +8497,8 @@ def iter_arrow_batches( # noqa: C901
84658497
remote_values = column_values(self, leaves, positions, null_masks=remote_nulls)
84668498

84678499
def read_values(name, remote_values=remote_values, start=start, stop=stop):
8468-
return remote_values[name] if name in remote_values else self[name][start:stop]
8500+
values = remote_values[name] if name in remote_values else self[name][start:stop]
8501+
return np.ma.getdata(values) if isinstance(values, np.ma.MaskedArray) else values
84698502

84708503
def read_nulls(
84718504
name, values, remote_values=remote_values, remote_nulls=remote_nulls, start=start, stop=stop

‎tests/ctable/test_null_mask_api.py‎

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def test_nullable_bool_is_a_real_bool():
114114
"""The case that motivated the whole design: no uint8, no reserved 255."""
115115
t = simple([True, None, False], spec=blosc2.bool(null_storage="mask"))
116116
assert t["a"].dtype == np.dtype(np.bool_)
117-
assert t["a"][:].tolist() == [True, False, False]
117+
assert t["a"][:].tolist() == [True, None, False]
118118
assert t["a"].is_null().tolist() == [False, True, False]
119119

120120

@@ -145,7 +145,7 @@ def test_timestamp_accepts_none():
145145
spec = blosc2.timestamp(null_storage="mask")
146146
t = simple(["2020-01-01", None, "2020-01-03"], spec=spec)
147147
assert t["a"].is_null().tolist() == [False, True, False]
148-
assert np.isnat(t["a"][:][1])
148+
assert np.ma.is_masked(t["a"][:][1])
149149

150150

151151
def test_ndarray_column_accepts_none():
@@ -651,7 +651,7 @@ def test_extend_from_a_table_carries_the_nulls_over():
651651
src = simple([1, None, 3])
652652
dst = simple([])
653653
dst.extend(src)
654-
assert dst["a"][:].tolist() == [1, 0, 3]
654+
assert dst["a"][:].tolist() == [1, None, 3]
655655
assert dst["a"].is_null().tolist() == [False, True, False]
656656
assert dst["a"].null_count() == 1
657657

@@ -677,7 +677,7 @@ def test_extend_from_a_table_carries_utf8_nulls():
677677
src = table([("x",), (None,), ("",)], s=utf8_spec(null_storage="mask"))
678678
dst = table([], s=utf8_spec(null_storage="mask"))
679679
dst.extend(src)
680-
assert list(dst["s"][:]) == ["x", "", ""]
680+
assert dst["s"][:].tolist() == ["x", None, ""]
681681
assert dst["s"].is_null().tolist() == [False, True, False]
682682

683683

@@ -705,7 +705,7 @@ def test_extend_from_a_sorted_view_copies_it_in_sorted_order():
705705
dst = simple([])
706706
dst.extend(src.sort_by("a", view=True))
707707
# Nulls sort last, in both directions, and the copy has to agree.
708-
assert dst["a"][:].tolist() == [1, 2, 3, 0]
708+
assert dst["a"][:].tolist() == [1, 2, 3, None]
709709
assert dst["a"].is_null().tolist() == [False, False, False, True]
710710

711711

@@ -730,7 +730,7 @@ def test_extend_from_a_null_free_table_writes_no_sidecar():
730730
def test_add_column_values_accept_none():
731731
t = simple([1, 2, 3], spec=blosc2.int64())
732732
t.add_column("b", blosc2.int64(nullable=True), values=[10, None, 30])
733-
assert t["b"][:].tolist() == [10, 0, 30]
733+
assert t["b"][:].tolist() == [10, None, 30]
734734
assert t["b"].is_null().tolist() == [False, True, False]
735735
assert t["b"].null_count() == 1
736736

@@ -747,7 +747,7 @@ def test_add_column_default_none_still_applies_to_later_rows():
747747
t.add_column("b", blosc2.field(blosc2.int64(nullable=True), default=None))
748748
t.append((3, 9))
749749
t.extend([(4, None)])
750-
assert t["b"][:].tolist() == [0, 0, 9, 0]
750+
assert t["b"][:].tolist() == [None, None, 9, None]
751751
assert t["b"].is_null().tolist() == [True, True, False, True]
752752

753753

@@ -767,15 +767,15 @@ def test_add_column_scatters_nulls_past_deleted_rows():
767767
assert t["b"].is_null().tolist() == [False, True, False]
768768
t.compact()
769769
assert t["b"].is_null().tolist() == [False, True, False]
770-
assert t["b"][:].tolist() == [10, 0, 30]
770+
assert t["b"][:].tolist() == [10, None, 30]
771771

772772

773773
@needs_utf8
774774
def test_add_column_utf8_values_accept_none():
775775
"""The fill is "" here, which a genuine row may also hold."""
776776
t = simple([1, 2, 3], spec=blosc2.int64())
777777
t.add_column("s", utf8_spec(null_storage="mask"), values=["p", None, ""])
778-
assert list(t["s"][:]) == ["p", "", ""]
778+
assert t["s"][:].tolist() == ["p", None, ""]
779779
assert t["s"].is_null().tolist() == [False, True, False]
780780

781781

@@ -786,7 +786,7 @@ def test_add_column_ndarray_values_accept_none():
786786
blosc2.ndarray((3,), dtype=blosc2.int64(), nullable=True),
787787
values=[np.array([1, 2, 3]), None],
788788
)
789-
assert t["v"][:].tolist() == [[1, 2, 3], [0, 0, 0]]
789+
assert t["v"][:].tolist() == [[1, 2, 3], [None, None, None]]
790790
assert t["v"].is_null().tolist() == [False, True]
791791

792792

@@ -846,7 +846,7 @@ def test_add_column_timestamp_null_reads_as_nat():
846846
t = simple([1, 2], spec=blosc2.int64())
847847
t.add_column("ts", blosc2.timestamp(nullable=True), values=[when, None])
848848
assert t["ts"].is_null().tolist() == [False, True]
849-
assert np.isnat(t["ts"][:][1])
849+
assert np.ma.is_masked(t["ts"][:][1])
850850

851851

852852
# ---------------------------------------------------------------------------
@@ -863,7 +863,7 @@ def test_add_column_timestamp_null_reads_as_nat():
863863
def test_isin_does_not_match_the_fill():
864864
t = simple([1, None, 0])
865865
# Rows 1 and 2 both hold a physical 0; only row 2 holds it as a value.
866-
assert t["a"][:].tolist() == [1, 0, 0]
866+
assert t["a"][:].tolist() == [1, None, 0]
867867
assert t["a"].isin([0]).tolist() == [False, False, True]
868868

869869

@@ -887,7 +887,7 @@ def test_isin_pandas_na_spells_the_same_request():
887887
def test_isin_does_not_match_the_empty_string_fill():
888888
"""The utf8 fill is "", which a genuine row may hold -- so it has to be the sidecar."""
889889
t = table([("x",), (None,), ("",)], s=utf8_spec(null_storage="mask"))
890-
assert list(t["s"][:]) == ["x", "", ""]
890+
assert t["s"][:].tolist() == ["x", None, ""]
891891
assert t["s"].isin([""]).tolist() == [False, False, True]
892892

893893

@@ -1160,4 +1160,100 @@ def test_a_sentinel_column_still_shows_its_sentinel_in_a_row():
11601160
t = blosc2.CTable(Row, expected_size=8)
11611161
t.extend([(1,), (-1,), (3,)])
11621162
assert t[1].n == -1
1163+
assert t["n"][1] == -1
11631164
assert t["n"].is_null().tolist() == [False, True, False]
1165+
1166+
1167+
@pytest.mark.parametrize(
1168+
("spec", "value"),
1169+
[
1170+
(blosc2.int64(null_storage="mask"), 0),
1171+
(blosc2.uint8(null_storage="mask"), 0),
1172+
(blosc2.float64(null_storage="mask"), float("nan")),
1173+
(blosc2.bool(null_storage="mask"), False),
1174+
(blosc2.string(max_length=4, null_storage="mask"), ""),
1175+
(blosc2.timestamp(null_storage="mask"), datetime.datetime(2020, 1, 1)),
1176+
],
1177+
)
1178+
def test_scalar_column_reads_apply_null_mask(spec, value):
1179+
with simple([value, None], spec) as t:
1180+
col = t["a"]
1181+
assert col[1] is None
1182+
assert col[-1] is None
1183+
assert col[np.int64(1)] is None
1184+
assert col[np.int32(-1)] is None
1185+
assert col[0] is not None
1186+
assert col.is_null().tolist() == [False, True]
1187+
if isinstance(value, float):
1188+
assert np.isnan(col[0]) # A valid NaN remains a value.
1189+
with pytest.raises(IndexError):
1190+
col[np.int64(2)]
1191+
1192+
1193+
def test_scalar_column_nulls_follow_view_positions_and_deletions():
1194+
with mixed_null_table() as t:
1195+
for name in ("n", "v", "t", "ts"):
1196+
assert t[name][1] is None
1197+
assert t[1:][name][0] is None
1198+
assert t[name].view[1:][0] is None
1199+
sorted_view = t.sort_by("n", view=True)
1200+
assert sorted_view["n"][-1] is None
1201+
assert sorted_view["n"][0] == 1
1202+
t.delete(0)
1203+
assert t["n"][0] is None
1204+
assert t["n"][1] == 3
1205+
1206+
1207+
def test_scalar_masked_ndarray_column_returns_none():
1208+
spec = blosc2.ndarray((2,), dtype=blosc2.float32(), null_storage="mask")
1209+
with simple([[1.0, 2.0], None], spec) as t:
1210+
assert t["a"][1] is None
1211+
assert t["a"][1, 0] is None
1212+
assert t["a"][np.int64(1), 0] is None
1213+
assert t["a"][0, 1] == 2.0
1214+
1215+
1216+
@pytest.mark.parametrize(
1217+
"key", [slice(None), slice(None, None, -1), slice(0, 3, 2), [2, 0, 2], np.array([True, False, True])]
1218+
)
1219+
def test_column_selections_mask_nulls_in_selection_order(key):
1220+
with simple([None, 0, 33], blosc2.uint8(null_storage="mask")) as t:
1221+
values = t["a"][key]
1222+
expected = np.array([True, False, False])[key]
1223+
if expected.any():
1224+
assert isinstance(values, np.ma.MaskedArray)
1225+
assert values.dtype == np.dtype("uint8")
1226+
np.testing.assert_array_equal(values.mask, expected)
1227+
np.testing.assert_array_equal(
1228+
values.compressed(), np.array([0, 0, 33], dtype=np.uint8)[key][~expected]
1229+
)
1230+
assert values.tolist() == np.array([None, 0, 33], dtype=object)[key].tolist()
1231+
else:
1232+
assert not isinstance(values, np.ma.MaskedArray)
1233+
assert not isinstance(t["a"][1:], np.ma.MaskedArray)
1234+
assert not isinstance(t["a"][0:0], np.ma.MaskedArray)
1235+
1236+
1237+
def test_masked_column_selections_follow_views_and_deletions():
1238+
with mixed_null_table() as t:
1239+
assert t[1:]["n"][:].tolist() == [None, 3]
1240+
assert t["n"].view[1:][:].tolist() == [None, 3]
1241+
assert t.sort_by("n", view=True)["n"][:].tolist() == [1, 3, None]
1242+
t.delete(0)
1243+
assert t["n"][:].tolist() == [None, 3]
1244+
assert t["n"][[1, 0]].tolist() == [3, None]
1245+
1246+
1247+
def test_masked_ndarray_selections_broadcast_validity():
1248+
spec = blosc2.ndarray((2,), dtype=blosc2.float32(), null_storage="mask")
1249+
with simple([[1.0, 2.0], None, [3.0, 4.0]], spec) as t:
1250+
values = t["a"][:]
1251+
assert values.shape == (3, 2)
1252+
assert values.mask.tolist() == [[False, False], [True, True], [False, False]]
1253+
assert values.dtype == np.dtype("float32")
1254+
assert t["a"][:, 1].tolist() == [2.0, None, 4.0]
1255+
assert t["a"][[1, 0], 0].tolist() == [None, 1.0]
1256+
assert t["a"].to_numpy(masked=True).mask.tolist() == values.mask.tolist()
1257+
values[1] = [5.0, 6.0] # The returned mask is writable and independent of storage.
1258+
assert not values.mask[1].any()
1259+
assert t["a"][1] is None

‎tests/ctable/test_null_mask_arrow.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def test_nullable_bool_imports_without_the_255_reservation():
196196
table = pa.table({"v": pa.array([True, None, False], type=pa.bool_())})
197197
ct = blosc2.CTable.from_arrow(table, null_storage="mask")
198198
assert ct["v"].dtype == np.dtype(np.bool_)
199-
assert ct["v"][:].tolist() == [True, False, False]
199+
assert ct["v"][:].tolist() == [True, None, False]
200200
assert ct["v"].is_null().tolist() == [False, True, False]
201201

202202

‎tests/ctable/test_null_mask_expressions.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def test_argmax_points_at_a_real_row():
8080
def test_argmin_would_have_picked_the_fill():
8181
"""Regression: the fill sits at row 1 and is smaller than every real value."""
8282
col = mask_col([5, None, 1, 9])["v"]
83-
assert col[:][1] == 0 # the fill really is there in the values
83+
assert col.to_numpy()[1] == 0 # explicit unmasked access exposes the fill
8484
assert col.argmin() == 2 # ...and is not what argmin reports
8585

8686

‎tests/ctable/test_null_storage_equivalence.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,7 @@ def test_isin_agrees(kind):
568568
# Probe with each storage's own stand-in for a null. Neither should match,
569569
# because neither stand-in is the row's value: the fill is not part of the
570570
# format contract, and the sentinel is reserved.
571-
for probe in (m["a"][:][NULL_ROW], s["a"][:][NULL_ROW]):
571+
for probe in (m["a"].to_numpy()[NULL_ROW], s["a"].to_numpy()[NULL_ROW]):
572572
got, want = m["a"].isin([probe]).tolist(), s["a"].isin([probe]).tolist()
573573
assert_same(got, want, f"isin([{probe!r}])")
574574
if is_na_marker(probe):

0 commit comments

Comments
 (0)