|
1 | 1 | # Release notes |
2 | 2 |
|
3 | | -## Changes from 4.10.1 to 4.10.2 |
| 3 | +## Changes from 4.10.1 to 4.11.0 |
4 | 4 |
|
5 | 5 | XXX version-specific blurb XXX |
6 | 6 |
|
| 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 | + |
7 | 227 |
|
8 | 228 | ## Changes from 4.10.0 to 4.10.1 |
9 | 229 |
|
|
0 commit comments