Skip to content

Commit 9632981

Browse files
Merge pull request #694 from Blosc/mask-based-nulls
Mask-based nullable columns for CTable
2 parents d0d0bf0 + d742399 commit 9632981

44 files changed

Lines changed: 13947 additions & 788 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

RELEASE_NOTES.md

Lines changed: 221 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,229 @@
11
# Release notes
22

3-
## Changes from 4.10.1 to 4.10.2
3+
## Changes from 4.10.1 to 4.11.0
44

55
XXX version-specific blurb XXX
66

7+
### New features
8+
9+
#### Mask-based nullable columns for CTable, and they are now the default
10+
11+
A nullable CTable column keeps its nulls in a per-column **validity sidecar**
12+
Arrow's own model — instead of reserving a value from its own range. This is now
13+
what a bare `nullable=True` resolves to, and what every nullable column inferred
14+
from Arrow, Parquet or CSV gets:
15+
16+
```python
17+
blosc2.bool(nullable=True) # no reserved 255; dtype stays np.bool_
18+
blosc2.int8(nullable=True) # all 256 values usable, plus nulls
19+
blosc2.utf8(nullable=True) # any string, including "" and "\x00"
20+
blosc2.complex128(nullable=True) # nullable at all, for the first time
21+
```
22+
23+
This is what makes nullability **lossless**: a sentinel steals a value from the
24+
dtype, so a nullable `int8` could not hold `-128`, a free-text `utf8` column had
25+
no safe sentinel at all, and Arrow columns whose type had no value to spare could
26+
not be imported. `to_arrow(from_arrow(x))` now returns `x` for nullable `bool`,
27+
full-range `int8`/`uint8`, `float64` containing `nan`/`±inf`/`-0.0` as values,
28+
`utf8` containing `""` and `"__BLOSC2_NULL__"`, and `timestamp` with `int64.min`
29+
as a value — none of which round-trip through a sentinel.
30+
31+
Under mask storage `None` is how you write a null (`t.append((None,))`,
32+
`t["price"][3] = None`), which a fixed-width sentinel column cannot accept at
33+
all. `is_null()` is unchanged and remains the uniform API across every kind.
34+
35+
**Nothing on disk changes.** The new default governs *creation* only: opening a
36+
stored table never re-resolves anything, so every existing table keeps the
37+
storage, dtype and sentinel it was written with, and every rewrite rule for the
38+
reserved `255` stays permanently in place. Sentinel storage is supported
39+
indefinitely and is one keyword away, per column (`null_storage="sentinel"`, or
40+
any explicit `null_value=`) or globally through `NullPolicy`. Setting a type-wide
41+
`NullPolicy` sentinel field still implies sentinel storage for the kinds it
42+
covers, so existing `NullPolicy(float_value=...)` code is unaffected — with one
43+
unavoidable exception: `255` is the only value a nullable bool may reserve, so it
44+
is also `bool_value`'s default, and `NullPolicy(bool_value=255)` carries no
45+
information to act on. A bool column that wants a sentinel has to say so with
46+
`null_storage` or `column_null_values`.
47+
48+
A table containing a mask column records **schema version 3**. Only such tables do:
49+
a table with no nullable column still records version 1, exactly as before, and a
50+
sentinel one does too. Readers older than 4.11.0 refuse a version-3 table rather
51+
than misreading it, but their message is a bare `ValueError: Unsupported schema
52+
version 3` — the hint naming `convert_nulls(to='sentinel')` ships in 4.11.0, so
53+
only readers that can already open the file will print it.
54+
55+
If some of your data has to stay readable by an earlier release, pin the storage
56+
rather than discovering this downstream. Per column with
57+
`null_storage="sentinel"` (or any explicit `null_value=`), or process-wide,
58+
including for schemas inferred from Arrow, Parquet and CSV:
59+
60+
```python
61+
with blosc2.null_policy(blosc2.NullPolicy(null_storage="sentinel")):
62+
t = blosc2.CTable.from_parquet("data.parquet")
63+
```
64+
65+
That reinstates the sentinel's lossiness — a float column's nulls become `NaN`
66+
again, and a type with no value to spare still cannot be imported — which is the
67+
trade being made.
68+
69+
`Column.null_storage` reports where a column keeps its nulls and `info` tags each
70+
column (`int64 nullable[mask]`), so `CTable.convert_nulls()` can move columns
71+
between the two in either direction — never implicitly, and refusing rather than
72+
silently relabelling data when a sentinel is unavailable.
73+
74+
One deliberate semantic difference: in a mask column `NaN` is a **value**,
75+
following Arrow, and only the sidecar marks a null. Sentinel float columns keep
76+
NaN-as-null. See "Where nulls are stored" in the CTable reference.
77+
78+
#### Column indexes are null-aware
79+
80+
Every index kind stores per-segment `min`/`max`, and those extrema are now taken
81+
over the rows that carry a **value**: a column's nulls are read from its
82+
validity channel and left out, and a segment with no value at all is flagged
83+
rather than summarised. This applies to both storages — an `INT64_MIN` sentinel
84+
is exactly as invisible to a summary as a mask column's fill.
85+
86+
Two things follow. `Column.min`/`Column.max` answer from the index for a
87+
nullable column (**236x** on a 20M-row `int64`, measured) where before every
88+
nullable column but a NaN-sentinel float had to scan; and `where()` with an `OR`
89+
over a nullable indexed column uses the index instead of falling back to a full
90+
scan (**1.6x** on a 20M-row two-column probe). The `OR` fallback existed because
91+
the only null filtering available was global, and a global filter drops a row
92+
that is null in one branch but matches the other; the segment path never needed
93+
it, because it *evaluates* the predicate, which has been null-aware per leaf
94+
since the string-predicate fix below.
95+
96+
Indexes written by an earlier release are read as not null-aware and keep the
97+
old fallback, so nothing silently changes meaning; `rebuild_index()` promotes
98+
them. Building an index over a nullable column that actually holds nulls now
99+
costs one decompression pass (33 ms for a 20M-row `int64` column) because the
100+
incremental per-block summaries folded during writes carry no validity; a
101+
nullable column with no nulls keeps that fast path untouched.
102+
103+
#### Predicates over nulls follow three-valued (Kleene) logic
104+
105+
A comparison against a null is neither true nor false. It is now **unknown**,
106+
the third value SQL and Arrow both use, and `&`, `|`, `^` and `~` combine it by
107+
Kleene's rules instead of collapsing it to `False` at the leaf:
108+
109+
```python
110+
t.where(t.price > 10) # rows definitely above 10
111+
t.where(~(t.price > 10)) # rows definitely *not* above 10 — nulls in neither
112+
```
113+
114+
`where()` keeps what a predicate is **true** for, so the rows it returns for a
115+
plain comparison are unchanged. What this fixes is everything built on top of
116+
one. `~(t.price > 10)` used to invert a null that had already been collapsed to
117+
`False` and so returned every null row — the exact opposite of the intent — and
118+
`~((a > 10) & (b == 999))` dropped rows that qualify, because `unknown & false`
119+
is *false*, not unknown, and only a real third value can express that. Both
120+
query forms are covered, and both now agree with SQL: the string form carries
121+
the second channel through an AST rewrite under negation.
122+
123+
A predicate can be asked about its unknown rows rather than only filtered with:
124+
125+
```python
126+
p = t.price > 10
127+
p.is_null() # boolean array: the rows it cannot answer for
128+
p.null_count()
129+
t.where(p.fillna(True)) # keep what cannot be ruled out
130+
```
131+
132+
`fillna(False)` is the other reading, and is what `where()` applies implicitly.
133+
134+
Predicates over non-nullable columns are untouched and cost nothing extra; the
135+
result of a nullable comparison is still a `blosc2.LazyExpr`, so it computes,
136+
indexes and plans exactly as before. Measured cost of the exact answer: a
137+
negated two-column conjunction over a 20M-row nullable table runs 1.15x slower
138+
than the wrong answer it replaces; every other predicate shape is unchanged.
139+
140+
Two consequences worth knowing. `t.where(dict_col != "x")` no longer returns the
141+
rows where `dict_col` is null (its reserved code differs from every value's, so
142+
it used to match); `dict_col == None` remains how to ask for them. And
143+
`Column.isin()` stays deliberately two-valued — it returns a materialized array
144+
and has its own spelling for nulls (`None` among the values).
145+
146+
### Bug fixes
147+
148+
- **`group_by` returned the wrong `min` for a `bool` value column.** The
149+
per-group accumulator was seeded from the dtype's opposite identity, and `bool`
150+
had none, so an all-`True` group reduced to `False`. Reachable with any plain
151+
non-nullable bool column on the generic aggregation path.
152+
- **Descending `sort_by` on a `bool` column raised**, and on a signed-integer
153+
column holding its dtype's minimum (`-128` for `int8`) that row sorted as if it
154+
were the largest. The descending key negated in the column's own dtype, where
155+
`bool` has no unary minus and a narrow signed type wraps.
156+
- **`add_column()` after `copy()` backfilled one row short**, and raised for a
157+
variable-length column: the copy recorded its write watermark one below the
158+
convention every other writer follows.
159+
- **A string predicate over a nullable column returned its nulls as matches.**
160+
`t.where("a > 10")` compared the stored sentinel, so any sentinel satisfying
161+
the predicate (`null_value=999` against `> 10`) came back as a match. The
162+
operator form (`t.where(t.a > 10)`) was always correct. Fixed for both storages.
163+
- **A nullable `uint8` ndarray column came back as `bool`.** The `bool → uint8`
164+
widening that sentinel storage needs was undone by dtype rather than by
165+
whether it had been applied, so a column declared `uint8` was truncated to
166+
flags.
167+
- **`~` on a nullable bool column selected its nulls.** SQL `WHERE` semantics
168+
say a null satisfies neither a predicate nor its negation; the mask path
169+
inverted the stored `False` fill instead. (The sentinel path was already
170+
correct, via its `== 0` rewrite.)
171+
- **CSV import and export ignored a validity sidecar.** `to_csv` compared
172+
against the sentinel to find nulls, so a mask column wrote its fill as if it
173+
were data, and `from_csv` had nothing to put in an empty field and raised.
174+
Both go through the sidecar now: an empty CSV field is a null in either
175+
direction. Sentinel columns keep writing their sentinel, unchanged.
176+
- **Reductions on a sorted view of a mask column read the wrong rows.** The
177+
null flags were gathered in the view's order and the values in physical
178+
order, so `sum()` on a sorted view could return `NaN` from a column whose
179+
nulls are not `NaN`, and `unique()` could report the fill as data while
180+
dropping a real value. Sentinel columns were unaffected.
181+
- **`convert_nulls()` flattened a nested column**, even when it had nothing to
182+
convert: the schema copy it makes dropped both the table metadata and the
183+
logical parent of a nested group, so a struct column came back as its leaves.
184+
An in-place conversion on a persistent table wrote that flattened schema to
185+
disk. Saving and reopening a nested table dropped the same parent, which is
186+
fixed alongside it.
187+
- **Descending `sort_by` mis-ordered the widest integers.** The key was built
188+
by negating, and negation has a fixed point: `int64`'s minimum sorted as if
189+
it were the largest, and a `uint64` above 2**63 wrapped negative and sorted
190+
below small values.
191+
- **CSV was written and read in the platform's locale encoding**, so text a
192+
column can hold but cp1252 cannot encode — anything outside Latin-1 — raised
193+
`UnicodeEncodeError` on Windows. Both directions are UTF-8 now, and reading
194+
absorbs a byte-order mark if one is present.
195+
- **A timestamp column could not be written through `col[key] = value`.** A
196+
`datetime` was never encoded to the stored `int64`, so every key form failed;
197+
`extend()` was unaffected. ISO strings and `datetime64` are accepted too.
198+
- **Assigning one value to many rows raised on a mask-storage column.**
199+
`col[0:2] = 7` — and the same write through a boolean mask or an index list —
200+
failed with `TypeError: iteration over a 0-d array`, because the write path
201+
looked for nulls *inside* a value that was a single cell rather than a batch.
202+
Scalar broadcast works again, and `col[0:2] = None` now makes every selected
203+
row null.
204+
- **`extend()` from another table lost nulls between storages.** Copying rows
205+
from a mask-backed column into a sentinel-backed one (or the reverse, or
206+
between two sentinels reserving different values) wrote whatever stood in for
207+
the null as real data. Nullity is translated now.
208+
- **A row read showed a mask column's fill instead of `None`.** `t[i]`,
209+
iteration and `repr` surfaced the placeholder that occupies a null slot, which
210+
is not part of the format contract — and disagreed with a `vlstring` column in
211+
the same row, which already read `None`. Sentinel columns still show their
212+
sentinel, which is the value you chose.
213+
- **`to_numpy(masked=True)` and `dropna()` raised on a nullable dictionary
214+
column**, which reported its nulls per physical slot rather than per live row.
215+
- **A null in a complex column reached pandas as `nan+0j`** rather than as
216+
missing.
217+
- **A nullable `ndarray` of `bool` forgot it had been widened when reopened**, so
218+
converting it to mask storage left it `uint8`, and the guard against a
219+
dtype-changing in-place conversion on a persistent table stopped firing.
220+
- **`convert_nulls(to="sentinel")` refused over a value in a deleted row.** The
221+
collision check scanned physical slots, so a proposed sentinel present only in
222+
a row already deleted — unreadable, and dropped by the next `compact()`
223+
blocked the conversion. Only live rows are consulted now, and the row named in
224+
the refusal is the logical one the caller can index rather than a physical
225+
slot.
226+
7227

8228
## Changes from 4.10.0 to 4.10.1
9229

0 commit comments

Comments
 (0)