"""The Table class: create, write, and read a H5Col column-oriented table.
A table is an HDF5 group carrying ``CLASS="COLUMN_TABLE"``. This module builds
such groups from :class:`~h5col.specs.TableSpec` / :class:`~h5col.specs.ColumnSpec`,
appends rows following the H5Col write protocol (extend every column, write,
then commit ``NROWS`` last), reads them back, and validates conformance.
"""
from __future__ import annotations
from collections.abc import Iterator, Mapping, Sequence
from typing import Any
import h5py
import numpy as np
from . import categorical, indexes, lists, query, references
from ._hdf5 import (
create_column_dataset,
extend_to,
prepare_column_data,
read_str_array_attr,
read_str_attr,
read_uint64_attr,
substitute_fill_for_none,
write_ascii_token_attr,
write_uint64_attr,
write_utf8_array_attr,
write_utf8_attr,
)
from .booleans import bool_dtype, is_bool_dtype
from .column import Column
from .exceptions import ConformanceError, SchemaError, VersionError
from .listcolumn import ListColumn
from .missing import recommended_fill, validate_fill_outside_range
from .reserved import (
ATTR_CATEGORIES,
ATTR_CLASS,
ATTR_COLUMN_ORDER,
ATTR_DESCRIPTION,
ATTR_ENCODING_TYPE,
ATTR_ENCODING_VERSION,
ATTR_GENERATION,
ATTR_INDEX,
ATTR_INDEX_COLUMNS,
ATTR_NROWS,
ATTR_TITLE,
ATTR_UNITS,
ATTR_UNITS_VOCABULARY,
ATTR_VALID_MAX,
ATTR_VALID_MIN,
ATTR_VERSION,
CLASS_COLUMN_TABLE,
CLASS_LIST_COLUMN,
GROUP_CATEGORIES,
GROUP_SEARCH_INDEXES,
KIND_BITMAP,
KIND_CHUNK_MINMAX,
KIND_SORTED_ROWS,
MEMBER_MASK,
validate_column_name,
)
from .searchindex import SearchIndex, wrap_index
from .specs import ColumnSpec, ListColumnSpec, TableSpec
from .strings import FixedString
#: HEP001 revision this implementation writes and the highest major it reads.
VERSION = "1.0"
SUPPORTED_MAJOR = 1
_SKIP_CHILDREN = frozenset({GROUP_CATEGORIES, GROUP_SEARCH_INDEXES})
[docs]
class Table:
"""A H5Col column-oriented table backed by an HDF5 group."""
def __init__(self, group: Any) -> None:
self._group = group
def __repr__(self) -> str:
try:
return f"<h5col.Table {self._group.name!r} nrows={self.nrows}>"
except Exception:
return "<h5col.Table (closed or invalid)>"
# -- construction ------------------------------------------------------- #
[docs]
@staticmethod
def is_table_group(group: Any) -> bool:
"""True if *group* is a H5Col table group (lenient CLASS check)."""
return read_str_attr(group, ATTR_CLASS) == CLASS_COLUMN_TABLE
[docs]
@classmethod
def open(cls, group: Any) -> Table:
"""Open an existing table group, checking its CLASS and VERSION major.
Raises
------
ConformanceError
If *group* is not a H5Col table group, or its ``VERSION`` is missing
or unparsable.
VersionError
If the table's ``VERSION`` major exceeds the supported major.
"""
if not cls.is_table_group(group):
raise ConformanceError(
f"{group.name!r} is not a H5Col table group "
f"(missing or wrong {ATTR_CLASS} attribute)"
)
table = cls(group)
table._check_version()
return table
[docs]
@classmethod
def create(
cls,
group: Any,
columns: TableSpec | Sequence[ColumnSpec | ListColumnSpec],
*,
title: str | None = None,
description: str | None = None,
index_columns: Sequence[str] | None = None,
column_order: Sequence[str] | None = None,
units_vocabulary: str | None = None,
encoding_type: str | None = None,
encoding_version: str | None = None,
default_chunk_bytes: int | None = None,
) -> Table:
"""Create a new, empty table (``NROWS = 0``) with the given columns.
``default_chunk_bytes`` overrides the automatic (chunk-cache-scaled)
target for columns that do not set an explicit ``chunks`` shape.
Raises
------
SchemaError
If *group* is already a H5Col table group, a column spec is invalid,
or an ``index_columns`` name is not among the declared columns.
ReservedNameError
If a column name is a H5Col reserved name.
FillValueError
If a column's fill value lies inside its declared valid range.
"""
if cls.is_table_group(group):
raise SchemaError(f"{group.name!r} is already a H5Col table group")
if isinstance(columns, TableSpec):
spec = columns
else:
spec = TableSpec(
columns=list(columns),
title=title,
description=description,
index_columns=list(index_columns) if index_columns else [],
column_order=list(column_order) if column_order else None,
units_vocabulary=units_vocabulary,
encoding_type=encoding_type,
encoding_version=encoding_version,
)
# Pre-flight: validate every column BEFORE writing the CLASS identifier,
# so an invalid spec never leaves a group marked as a (broken) table
# group (H5Col forbids CLASS="COLUMN_TABLE" on a non-conformant group).
for col in spec.columns:
cls._validate_column_spec(col)
declared = {c.name for c in spec.columns}
for ic in spec.index_columns:
if ic not in declared:
raise SchemaError(f"index column {ic!r} is not a declared column")
# Mutate the group, rolling everything back on failure so a partially
# built table is never left behind.
written: list[str] = []
created: list[str] = []
cat_existed = GROUP_CATEGORIES in group
try:
write_ascii_token_attr(group, ATTR_CLASS, CLASS_COLUMN_TABLE)
written.append(ATTR_CLASS)
write_ascii_token_attr(group, ATTR_VERSION, VERSION)
written.append(ATTR_VERSION)
write_uint64_attr(group, ATTR_NROWS, 0)
written.append(ATTR_NROWS)
for attr, value in (
(ATTR_TITLE, spec.title),
(ATTR_DESCRIPTION, spec.description),
(ATTR_UNITS_VOCABULARY, spec.units_vocabulary),
(ATTR_ENCODING_TYPE, spec.encoding_type),
(ATTR_ENCODING_VERSION, spec.encoding_version),
):
if value is not None:
write_utf8_attr(group, attr, value)
written.append(attr)
for col in spec.columns:
cls._create_one_column(
group, col, default_chunk_bytes=default_chunk_bytes
)
created.append(col.name)
if spec.columns:
write_utf8_array_attr(group, ATTR_COLUMN_ORDER, spec.ordered_names)
written.append(ATTR_COLUMN_ORDER)
if spec.index_columns:
references.write_ref_array_attr(
group, ATTR_INDEX_COLUMNS, [group[n] for n in spec.index_columns]
)
written.append(ATTR_INDEX_COLUMNS)
write_utf8_attr(group, ATTR_INDEX, spec.index_columns[0])
written.append(ATTR_INDEX)
except BaseException:
for n in created:
if n in group:
del group[n]
for a in written:
if a in group.attrs:
del group.attrs[a]
if not cat_existed and GROUP_CATEGORIES in group:
del group[GROUP_CATEGORIES]
raise
return cls(group)
@staticmethod
def _validate_column_spec(col: ColumnSpec | ListColumnSpec) -> None:
"""Validate one column spec without touching the file."""
if isinstance(col, ListColumnSpec):
lists.validate_list_column_spec(col)
return
validate_column_name(col.name)
if col.is_categorical:
assert col.categories is not None
ncats = len(col.categories)
if len(set(col.categories)) != len(col.categories):
raise SchemaError(
f"categorical column {col.name!r} has duplicate categories"
)
dtype = col.resolved_dtype()
if ncats > 0 and int(np.iinfo(dtype).max) < ncats - 1:
raise SchemaError(
f"categorical column {col.name!r} code dtype {dtype} cannot "
f"index {ncats} categories"
)
fill = (
col.fill_value
if col.fill_value is not None
else categorical.default_categorical_fill(dtype)
)
try:
cast_fill = int(np.asarray(fill, dtype=dtype))
except (OverflowError, ValueError) as exc:
raise SchemaError(
f"categorical column {col.name!r} fill {fill!r} is not "
f"representable in code dtype {dtype}"
) from exc
if 0 <= cast_fill < ncats:
raise SchemaError(
f"categorical column {col.name!r} fill {cast_fill} collides "
f"with a valid code [0, {ncats})"
)
validate_fill_outside_range(cast_fill, col.valid_min, col.valid_max)
return
if col.is_boolean:
if col.fill_value is not None:
raise SchemaError(
f"boolean column {col.name!r} must not declare a fill value"
)
if col.valid_min is not None or col.valid_max is not None:
raise SchemaError(
f"boolean column {col.name!r} must not declare valid_min/valid_max"
)
else:
fill = (
col.fill_value
if col.fill_value is not None
else recommended_fill(col.resolved_dtype())
)
validate_fill_outside_range(fill, col.valid_min, col.valid_max)
@staticmethod
def _create_one_column(
group: Any,
col: ColumnSpec | ListColumnSpec,
default_chunk_bytes: int | None = None,
) -> Any:
if isinstance(col, ListColumnSpec):
return lists.create_list_column(
group, col, default_chunk_bytes=default_chunk_bytes
)
name = validate_column_name(col.name)
dtype = col.resolved_dtype()
if col.is_categorical:
return Table._create_categorical_column(
group, col, name, dtype, default_chunk_bytes=default_chunk_bytes
)
if col.is_boolean:
if col.fill_value is not None:
raise SchemaError(
f"boolean column {name!r} must not declare a fill value"
)
if col.valid_min is not None or col.valid_max is not None:
raise SchemaError(
f"boolean column {name!r} must not declare valid_min/valid_max"
)
fill: Any = None
else:
fill = (
col.fill_value
if col.fill_value is not None
else recommended_fill(dtype)
)
validate_fill_outside_range(fill, col.valid_min, col.valid_max)
ds = create_column_dataset(
group,
name,
dtype,
chunks=col.chunks,
fill_value=fill,
filters=col.filters,
default_chunk_bytes=default_chunk_bytes,
)
if col.valid_min is not None:
ds.attrs.create(ATTR_VALID_MIN, np.asarray(col.valid_min, dtype=dtype))
if col.valid_max is not None:
ds.attrs.create(ATTR_VALID_MAX, np.asarray(col.valid_max, dtype=dtype))
if col.units is not None:
write_utf8_attr(ds, ATTR_UNITS, col.units)
if col.units_vocabulary is not None:
write_utf8_attr(ds, ATTR_UNITS_VOCABULARY, col.units_vocabulary)
if col.description is not None:
write_utf8_attr(ds, ATTR_DESCRIPTION, col.description)
return ds
@staticmethod
def _create_categorical_column(
group: Any,
col: ColumnSpec,
name: str,
dtype: np.dtype,
default_chunk_bytes: int | None = None,
) -> Any:
assert col.categories is not None
# Fill range, dtype fit, and representability are enforced by
# _validate_column_spec, which always runs before creation.
fill = (
col.fill_value
if col.fill_value is not None
else categorical.default_categorical_fill(dtype)
)
ds = create_column_dataset(
group,
name,
dtype,
chunks=col.chunks,
fill_value=np.asarray(fill, dtype=dtype),
filters=col.filters,
default_chunk_bytes=default_chunk_bytes,
)
cat_group = group.require_group(GROUP_CATEGORIES)
cat_ds = categorical.create_categories_dataset(
cat_group, f"{name}__CATEGORIES", col.categories, col.ordered
)
references.write_ref_attr(ds, ATTR_CATEGORIES, cat_ds)
if col.valid_min is not None:
ds.attrs.create(ATTR_VALID_MIN, np.asarray(col.valid_min, dtype=dtype))
if col.valid_max is not None:
ds.attrs.create(ATTR_VALID_MAX, np.asarray(col.valid_max, dtype=dtype))
if col.units is not None:
write_utf8_attr(ds, ATTR_UNITS, col.units)
if col.units_vocabulary is not None:
write_utf8_attr(ds, ATTR_UNITS_VOCABULARY, col.units_vocabulary)
if col.description is not None:
write_utf8_attr(ds, ATTR_DESCRIPTION, col.description)
return ds
[docs]
@classmethod
def from_arrays(
cls,
group: Any,
arrays: Mapping[str, Any],
*,
specs: Sequence[ColumnSpec] | None = None,
**table_kwargs: Any,
) -> Table:
"""Create a table from column arrays and write them in one call.
When *specs* is omitted, a :class:`ColumnSpec` is inferred per array
(boolean, fixed-string sized to the longest value, or the array dtype).
The column order follows ``arrays``.
"""
if specs is None:
specs = [_infer_column_spec(name, arr) for name, arr in arrays.items()]
table = cls.create(group, specs, **table_kwargs)
table.append(arrays)
return table
# -- introspection ------------------------------------------------------ #
@property
def group(self) -> Any:
"""The underlying h5py Group backing the table."""
return self._group
@property
def nrows(self) -> int:
"""The table's logical row count (its ``NROWS`` attribute).
Raises
------
ConformanceError
If the group carries no ``NROWS`` attribute.
"""
n = read_uint64_attr(self._group, ATTR_NROWS)
if n is None:
raise ConformanceError(f"table {self._group.name!r} has no NROWS attribute")
return n
@property
def version(self) -> str | None:
"""The table's H5Col ``VERSION`` string, or None when absent."""
return read_str_attr(self._group, ATTR_VERSION)
@property
def title(self) -> str | None:
"""The table's ``title`` attribute, or None when unset."""
return read_str_attr(self._group, ATTR_TITLE)
@property
def description(self) -> str | None:
"""The table's ``description`` attribute, or None when unset."""
return read_str_attr(self._group, ATTR_DESCRIPTION)
@property
def generation(self) -> int | None:
"""The table's ``GENERATION`` validity token, or None when absent.
A table acquires ``GENERATION`` when its first search index is built
and increments it on every subsequent mutation of committed data.
"""
return indexes.table_generation(self._group)
def _discover_columns(self) -> dict[str, Any]:
"""All columns by name: rank-1 datasets and ``CLASS=LIST_COLUMN`` groups."""
found: dict[str, Any] = {}
for name, obj in self._group.items():
if name in _SKIP_CHILDREN:
continue
if isinstance(obj, h5py.Dataset) and obj.ndim == 1:
found[name] = obj
elif isinstance(obj, h5py.Group):
if read_str_attr(obj, ATTR_CLASS) == CLASS_LIST_COLUMN:
found[name] = obj
return found
def _scalar_column_datasets(self) -> dict[str, Any]:
"""Only the column *datasets* (excludes list column groups)."""
return {
n: o
for n, o in self._discover_columns().items()
if isinstance(o, h5py.Dataset)
}
def _wrap(self, obj: Any) -> Column | ListColumn:
if isinstance(obj, h5py.Dataset):
return Column(obj, self)
return ListColumn(obj, self)
@property
def column_names(self) -> list[str]:
"""Column names in logical order (``column-order`` if present)."""
order = read_str_array_attr(self._group, ATTR_COLUMN_ORDER)
columns = self._discover_columns()
if order is not None:
return [n for n in order if n in columns]
return sorted(columns)
@property
def index_columns(self) -> list[str]:
"""Names of the row-index columns, outermost first."""
if ATTR_INDEX_COLUMNS not in self._group.attrs:
return []
refs = self._group.attrs[ATTR_INDEX_COLUMNS]
names = []
for r in refs:
if references.is_null_ref(r):
continue
names.append(references.resolve(self._group, r).name.rsplit("/", 1)[-1])
return names
@property
def columns(self) -> dict[str, Column | ListColumn]:
"""The table's columns by name, in column order, as wrapper objects."""
cols = self._discover_columns()
return {n: self._wrap(cols[n]) for n in self.column_names}
def __getitem__(self, name: str) -> Column | ListColumn:
cols = self._discover_columns()
if name not in cols:
raise KeyError(name)
return self._wrap(cols[name])
def __contains__(self, name: str) -> bool:
return name in self._discover_columns()
def __iter__(self) -> Iterator[str]:
return iter(self.column_names)
def __len__(self) -> int:
return len(self._discover_columns())
# -- writing ------------------------------------------------------------ #
[docs]
def append(
self, data: Mapping[str, Any], *, maintain_indexes: bool = False
) -> None:
"""Append rows following the H5Col write protocol.
Every provided column must supply the same number of rows ``K``. A scalar
column absent from *data* is extended and left as its fill value
(missing); a boolean column (which has no fill) must always be provided.
A list column absent from *data* must be nullable — its new rows become
null lists; a non-nullable list column must always be provided. ``NROWS``
is committed last, then the file is flushed.
``None`` in a column's values marks that row as missing and is stored as
the column's fill value (for a categorical column, its fill code). A
column with no fill to store — a boolean, which H5Col forbids from
declaring one — rejects ``None`` instead of coercing it.
By default, search indexes are **not** maintained: the ``GENERATION``
increment that publishes the append disables them, detectably, and
:meth:`refresh_indexes` restores them later — this keeps the hot append
path fast. With ``maintain_indexes=True``, every supported index is
rewritten inside the append protocol (future-valued tokens before
content) and remains valid after the commit; indexes this
implementation cannot rebuild — unsupported kinds, element dtypes the
builder does not handle, non-growable index datasets — are left
entirely untouched, tokens included.
Raises
------
OversizedStringError
If a fixed-length string value's encoding exceeds the column's byte
budget (H5Col never silently truncates).
SchemaError
For unknown columns, values that are not 1-D, unequal column
lengths, an omitted fill-less/boolean column, an omitted
non-nullable list column, an unknown category label, or a ``None``
in a column that declares no fill value.
"""
cols = self._discover_columns()
unknown = set(data) - set(cols)
if unknown:
raise SchemaError(f"unknown columns in append data: {sorted(unknown)}")
scalar_ds = {n: o for n, o in cols.items() if isinstance(o, h5py.Dataset)}
list_grps = {n: o for n, o in cols.items() if not isinstance(o, h5py.Dataset)}
prepared: dict[str, np.ndarray] = {}
list_rows: dict[str, list[Any]] = {}
lengths: set[int] = set()
for name, values in data.items():
if name in scalar_ds:
ds = scalar_ds[name]
if ATTR_CATEGORIES in ds.attrs:
arr = categorical.encode_labels(self._group, ds, values)
else:
# None means "this row is missing", so it becomes the
# column's fill value before encoding.
arr = prepare_column_data(
ds.dtype, substitute_fill_for_none(ds, values, name)
)
if arr.ndim != 1:
raise SchemaError(
f"append values for column {name!r} must be a 1-D sequence, "
f"got {arr.ndim}-D"
)
prepared[name] = arr
lengths.add(arr.shape[0])
else:
rows = list(values)
list_rows[name] = rows
lengths.add(len(rows))
if len(lengths) > 1:
raise SchemaError(f"append columns have unequal lengths: {sorted(lengths)}")
k = lengths.pop() if lengths else 0
if k == 0:
return
# Validate before mutating. A scalar column absent from the append is
# filled with its fill value, so a fill-less boolean column must be
# provided. A list column absent from the append becomes null lists, so
# it must be nullable (carry a top-level MASK).
for name, ds in scalar_ds.items():
has_fill = ds.id.get_create_plist().fill_value_defined() == 2
if name not in prepared and not has_fill:
raise SchemaError(
f"column {name!r} has no fill value and must be provided "
"in the append data"
)
for name, g in list_grps.items():
if name not in list_rows:
if MEMBER_MASK not in g:
raise SchemaError(
f"list column {name!r} is not nullable and must be provided "
"in the append data"
)
list_rows[name] = [None] * k
# Append-protocol step 1: read the pre-append state. The strict
# (repairing) read keeps a malformed foreign GENERATION from being
# incremented as-is, which could collide with an index's residue
# tokens and spuriously validate it after the step-5 rewrite.
n_old = self.nrows
n_new = n_old + k
g_old = indexes.mutation_generation(self._group)
# Steps 2-3: extend + write every scalar column (equal-extent
# invariant); resize leaves absent columns' new rows at their fill.
for name, ds in scalar_ds.items():
extend_to(ds, n_new)
if name in prepared:
ds[n_old:n_new] = prepared[name]
# Encode + write every list column (leaf-first, top OFFSETS last).
for name, g in list_grps.items():
lists.append_list_column(g, list_rows[name], n_old)
# Flush the new column data before committing NROWS, so a crash cannot
# publish a larger NROWS that points at unwritten rows (H5Col ordering).
self._group.file.flush()
# Step 4: maintain the supported search indexes — future-valued tokens
# first, then content, then flush (tokens-before-content rule).
if maintain_indexes:
if g_old is None and indexes.search_index_datasets(self._group):
# A table with indexes must carry GENERATION (rule 12); repair.
g_old = indexes.ensure_generation(self._group)
if g_old is not None and indexes.append_refresh_indexes(
self._group, g_old, n_new
):
self._group.file.flush()
# Step 5: bump GENERATION whenever the table carries it, so indexes
# not maintained above fail the validity check.
if g_old is not None:
write_uint64_attr(self._group, ATTR_GENERATION, g_old + 1)
# Step 6: commit NROWS last, then flush again.
write_uint64_attr(self._group, ATTR_NROWS, n_new)
self._group.file.flush()
[docs]
def truncate(self, nrows: int, *, maintain_indexes: bool = False) -> None:
"""Shrink the logical table to *nrows* rows (H5Col logical truncation).
The truncation is logical: no column dataset changes extent, and the
rows ``[nrows, old_NROWS)`` become reserved storage that consumers
ignore. List columns need no extra writes — the smaller ``NROWS``
bounds their offsets recursively. Reclaiming physical space would
require rewriting each column to its new extent, which this
implementation does not do.
Index handling mirrors :meth:`append` (the spec applies the same
steps 4-6 with the new row count): by default every search index is
left detectably stale by the ``GENERATION`` bump; with
``maintain_indexes=True`` the supported indexes are rebuilt inside the
protocol and remain valid after the commit.
Truncating to the current row count is a no-op (nothing changes, so
nothing is published); growing is an error — that is what
:meth:`append` is for.
Raises
------
SchemaError
If *nrows* is negative or greater than the current row count.
"""
n_new = int(nrows)
if n_new < 0:
raise SchemaError(f"cannot truncate to a negative row count {n_new}")
n_old = self.nrows
if n_new > n_old:
raise SchemaError(
f"cannot truncate {n_old} rows to {n_new}; truncation only shrinks"
)
if n_new == n_old:
return
g_old = indexes.mutation_generation(self._group)
# Step 4: maintain the supported search indexes — future-valued tokens
# first, then content, then flush (tokens-before-content rule).
if maintain_indexes:
if g_old is None and indexes.search_index_datasets(self._group):
# A table with indexes must carry GENERATION (rule 12); repair.
g_old = indexes.ensure_generation(self._group)
if g_old is not None and indexes.append_refresh_indexes(
self._group, g_old, n_new
):
self._group.file.flush()
# Step 5: bump GENERATION whenever the table carries it, so indexes
# not maintained above fail the validity check.
if g_old is not None:
write_uint64_attr(self._group, ATTR_GENERATION, g_old + 1)
# Step 6: commit NROWS last, then flush.
write_uint64_attr(self._group, ATTR_NROWS, n_new)
self._group.file.flush()
# -- reading ------------------------------------------------------------ #
[docs]
def read(
self,
columns: Sequence[str] | None = None,
*,
where: Any = None,
explain: bool = False,
) -> Any:
"""Read columns (default all) as ``{name: array}`` over ``[0, NROWS)``.
With ``where=`` (a query :class:`~h5col.query.Expression`, a
``List[Tuple]`` = AND, or a ``List[List[Tuple]]`` = OR-of-ANDs), only the
matching rows are returned. With ``explain=True`` the return value is a
``(result, QueryPlan)`` pair.
Raises
------
KeyError
If a requested column name is not a column of the table.
SchemaError
If ``where=`` is malformed or references an unknown column.
"""
if where is not None or explain:
sel = self.select(where)
result = sel.read(columns)
return (result, sel.explain()) if explain else result
names = list(columns) if columns is not None else self.column_names
cols = self.columns
out: dict[str, Any] = {}
for name in names:
if name not in cols:
raise KeyError(name)
out[name] = cols[name].read()
return out
[docs]
def select(self, where: Any = None) -> query.Selection:
"""Build a lazy :class:`~h5col.query.Selection` over the table.
Accepts a query :class:`~h5col.query.Expression`, a ``List[Tuple]``
(AND), or a ``List[List[Tuple]]`` (OR-of-ANDs, pyarrow DNF). ``None``
selects every row.
"""
return query.Selection(self, query._to_expression(where))
[docs]
def count(self, where: Any = None) -> int:
"""Number of rows matching *where* (no column materialization)."""
return self.select(where).count
[docs]
def build_index(
self,
column: str,
kind: str | None = None,
*,
name: str | None = None,
description: str | None = None,
) -> SearchIndex:
"""Build a search index over *column* (alias of :meth:`add_search_index`)."""
return self.add_search_index(column, kind, name=name, description=description)
# -- search indexes ------------------------------------------------------ #
@property
def search_indexes(self) -> dict[str, SearchIndex]:
"""Every search-index dataset under ``SEARCH_INDEXES``, wrapped by kind."""
return {
name: wrap_index(ds, self)
for name, ds in indexes.search_index_datasets(self._group).items()
}
[docs]
def add_search_index(
self,
column: str,
kind: str | None = None,
*,
name: str | None = None,
description: str | None = None,
) -> SearchIndex:
"""Build a search index over *column* and link it to the column.
With ``kind=None`` the family is picked automatically: ``BITMAP`` for
boolean and categorical columns (low cardinality, exact equality
answers), ``CHUNK_MINMAX`` for any other orderable column. Building an
index over an unchanged table is not a mutation: ``GENERATION`` is
created (``0``) if absent but never incremented. The default dataset
name is ``<column>__<kind, lowercased>`` — a readable convention only;
the linkage is the object reference in the column's
``SEARCH_INDEX_LIST``.
Raises
------
KeyError
If *column* is not a column of the table.
SchemaError
If *column* is a list column, no index family applies to its dtype
(``kind=None``), *kind* is unimplemented, or ``SEARCH_INDEXES``
already holds a dataset of the chosen name.
ReservedNameError
If *name* is a H5Col reserved name.
ConformanceError
If the table carries no ``NROWS`` attribute.
"""
cols = self._discover_columns()
if column not in cols:
raise KeyError(column)
ds = cols[column]
if not isinstance(ds, h5py.Dataset):
raise SchemaError(
f"search indexes over list columns are not permitted ({column!r})"
)
if kind is None:
if not indexes.supported_index_dtype(ds.dtype):
raise SchemaError(
f"no search-index family applies to column {column!r} with "
f"dtype {ds.dtype!r}"
)
if is_bool_dtype(ds.dtype) or ATTR_CATEGORIES in ds.attrs:
kind = KIND_BITMAP
else:
kind = KIND_CHUNK_MINMAX
if kind == KIND_CHUNK_MINMAX:
index_ds = indexes.create_chunk_minmax(
self._group, ds, name=name, description=description
)
elif kind == KIND_SORTED_ROWS:
index_ds = indexes.create_sorted_rows(
self._group, ds, name=name, description=description
)
elif kind == KIND_BITMAP:
index_ds = indexes.create_bitmap(
self._group, ds, name=name, description=description
)
else:
raise SchemaError(
f"search-index kind {kind!r} is not implemented "
"(CHUNK_BLOOM is sub-phase 4c)"
)
return wrap_index(index_ds, self, ds)
[docs]
def refresh_indexes(self) -> int:
"""Rebuild every supported search index against the current table state.
Restores indexes left stale by ``append(maintain_indexes=False)`` or by
any other mutation. Returns the number of indexes refreshed; indexes of
unsupported kinds are left untouched (and stay detectably stale).
"""
return indexes.refresh_all_indexes(self._group)
[docs]
def index_is_valid(self, index: SearchIndex | Any) -> bool:
"""The H5Col consumer validity check for *index* (wrapper or dataset)."""
ds = index.dataset if isinstance(index, SearchIndex) else index
return indexes.index_is_valid(ds, self._group)
# -- validation --------------------------------------------------------- #
def _check_version(self) -> None:
v = self.version
if v is None:
raise ConformanceError("table has no VERSION attribute")
try:
major = int(v.split(".")[0])
except (ValueError, IndexError) as exc:
raise ConformanceError(f"unparsable VERSION {v!r}") from exc
if major > SUPPORTED_MAJOR:
raise VersionError(
f"table VERSION major {major} exceeds supported {SUPPORTED_MAJOR}"
)
def _check_nrows_attr(self) -> None:
if ATTR_NROWS not in self._group.attrs:
raise ConformanceError("table has no NROWS attribute")
val = np.asarray(self._group.attrs[ATTR_NROWS])
if val.shape != ():
raise ConformanceError(
f"NROWS must be a scalar attribute, got shape {val.shape}"
)
if not (val.dtype.kind == "u" and val.dtype.itemsize == 8):
raise ConformanceError(f"NROWS must be uint64, got dtype {val.dtype}")
[docs]
def validate(self, *, deep: bool = False) -> None:
"""Check the H5Col consistency requirements, raising on any violation.
``deep=True`` additionally re-derives every *valid* search index from
its column and compares (consistency rule 9's semantic half) — an
O(index build) check; the default run is structural only. A stale index
is never an error: the validity check disables it, as the spec intends.
Raises
------
ConformanceError
On the first consistency violation found.
VersionError
If the table's ``VERSION`` major exceeds the supported major.
"""
if not self.is_table_group(self._group):
raise ConformanceError("missing/incorrect CLASS attribute")
self._check_version()
self._check_nrows_attr()
nrows = self.nrows
columns = self._discover_columns()
datasets = {n: o for n, o in columns.items() if isinstance(o, h5py.Dataset)}
list_grps = {
n: o for n, o in columns.items() if not isinstance(o, h5py.Dataset)
}
# Rule 2 applies to column datasets only; list column members have their
# own per-level extents (checked below).
extents = {ds.shape[0] for ds in datasets.values()}
if len(extents) > 1:
raise ConformanceError(
f"column datasets have unequal extents: {sorted(extents)}"
)
if extents and next(iter(extents)) < nrows:
raise ConformanceError("a column extent is smaller than NROWS")
# Rule 6: column-order lists every column (datasets AND list columns).
order = read_str_array_attr(self._group, ATTR_COLUMN_ORDER)
if order is not None:
if sorted(order) != sorted(columns):
raise ConformanceError(
"column-order does not list every column exactly once"
)
# Rules 10 and 11: every list column subtree is structurally conformant.
for lg in list_grps.values():
lists.validate_list_column(lg, nrows)
for name, ds in datasets.items():
user_fill = ds.id.get_create_plist().fill_value_defined() == 2
if FixedString.is_fixed_string(ds.dtype) or ds.dtype.kind not in ("b",):
if not user_fill and not _looks_boolean(ds):
raise ConformanceError(
f"non-boolean column {name!r} lacks a user-defined fill value"
)
if _looks_boolean(ds) and user_fill:
raise ConformanceError(
f"boolean column {name!r} must not declare a fill value"
)
# INDEX_COLUMNS: non-null references to direct-child column datasets
# (compared by HDF5 path, not basename); _index must agree with the first.
if ATTR_INDEX_COLUMNS in self._group.attrs:
child_paths = {ds.name for ds in datasets.values()}
resolved_names: list[str] = []
for r in self._group.attrs[ATTR_INDEX_COLUMNS]:
if references.is_null_ref(r):
raise ConformanceError("INDEX_COLUMNS contains a null reference")
obj = references.resolve(self._group, r)
if obj.name not in child_paths:
raise ConformanceError(
f"INDEX_COLUMNS entry {obj.name!r} is not a direct-child "
"column dataset"
)
resolved_names.append(obj.name.rsplit("/", 1)[-1])
idx = read_str_attr(self._group, ATTR_INDEX)
if idx is not None and resolved_names and idx != resolved_names[0]:
raise ConformanceError(
f"_index {idx!r} does not match INDEX_COLUMNS[0] "
f"{resolved_names[0]!r}"
)
# Categorical columns and the CATEGORIES subgroup (rules 5 and 8).
cat_group = self._group.get(GROUP_CATEGORIES)
referenced: set[str] = set()
for name, ds in datasets.items():
if ATTR_CATEGORIES not in ds.attrs:
continue
if ds.dtype.kind not in ("i", "u"):
raise ConformanceError(
f"categorical column {name!r} must have an integer datatype"
)
ref = ds.attrs[ATTR_CATEGORIES]
if references.is_null_ref(ref):
raise ConformanceError(
f"categorical column {name!r} has a null CATEGORIES reference"
)
cat_ds = references.resolve(self._group, ref)
if cat_group is None or not cat_ds.name.startswith(f"{cat_group.name}/"):
raise ConformanceError(
f"categorical column {name!r} CATEGORIES does not resolve into "
"the CATEGORIES subgroup"
)
referenced.add(cat_ds.name)
if ds.id.get_create_plist().fill_value_defined() == 2:
fill = int(np.asarray(ds.fillvalue))
ncats = cat_ds.shape[0]
if 0 <= fill < ncats:
raise ConformanceError(
f"categorical column {name!r} fill {fill} collides with a "
f"valid code [0, {ncats})"
)
if cat_group is not None:
for cname, cobj in cat_group.items():
if not isinstance(cobj, h5py.Dataset):
raise ConformanceError(
f"CATEGORIES contains a non-dataset object {cname!r}"
)
if cobj.name not in referenced:
raise ConformanceError(
f"categories dataset {cname!r} is not referenced by any "
"categorical column"
)
# Search indexes: rules 3, 4, 12, and rule 9 for every supported kind.
indexes.validate_search_indexes(self._group, nrows, deep=deep)
[docs]
def add_column(
self,
spec: ColumnSpec | ListColumnSpec,
*,
default_chunk_bytes: int | None = None,
) -> Column | ListColumn:
"""Add a new column to an existing table (schema evolution).
A scalar column is created and grown to the table's current extent; its
existing rows read as its fill value (missing). A fill-less (boolean)
column, or a list column (whose "missing" analogue is a null list that
would need explicit backfilling), cannot represent pre-existing rows, so
adding one to a table that already has rows is refused.
Raises
------
SchemaError
If a column of that name already exists, the spec is invalid, or the
column cannot backfill pre-existing rows (a boolean or list column on
a non-empty table).
"""
cols = self._discover_columns()
if spec.name in cols:
raise SchemaError(f"column {spec.name!r} already exists")
self._validate_column_spec(spec)
if isinstance(spec, ListColumnSpec):
is_list = True
cannot_backfill = True
else:
is_list = False
cannot_backfill = spec.is_boolean
if cannot_backfill and self.nrows > 0:
kind = "list" if is_list else "boolean"
raise SchemaError(
f"cannot add {kind} column {spec.name!r} to a table with rows: "
"it cannot represent the pre-existing rows as missing"
)
datasets = {n: o for n, o in cols.items() if isinstance(o, h5py.Dataset)}
obj = self._create_one_column(
self._group, spec, default_chunk_bytes=default_chunk_bytes
)
if not is_list:
extent = next(iter({d.shape[0] for d in datasets.values()}), self.nrows)
extend_to(obj, extent)
order = read_str_array_attr(self._group, ATTR_COLUMN_ORDER) or list(cols)
if ATTR_COLUMN_ORDER in self._group.attrs:
del self._group.attrs[ATTR_COLUMN_ORDER]
write_utf8_array_attr(self._group, ATTR_COLUMN_ORDER, [*order, spec.name])
return self._wrap(obj)
def _looks_boolean(dataset: Any) -> bool:
from .booleans import is_bool_dtype
return is_bool_dtype(dataset.dtype)
def _infer_column_spec(name: str, arr: Any) -> ColumnSpec:
a = np.asarray(arr)
if a.dtype.kind == "b":
return ColumnSpec(name=name, dtype=bool_dtype())
if a.dtype.kind in ("U", "S") or (
a.dtype.kind == "O" and all(isinstance(v, str | bytes) for v in a.ravel())
):
vals = a.ravel().tolist()
max_bytes = max(
(len(v.encode("utf-8") if isinstance(v, str) else v) for v in vals),
default=1,
)
return ColumnSpec(name=name, dtype=FixedString(max(1, max_bytes)))
return ColumnSpec(name=name, dtype=a.dtype)