Source code for loom.core.model.partition

"""Range partitioning of a row-scoped table: its declaration and the partitions that cover a period.

A model declares ``__partition_by__ = ("RANGE", "<column>")`` on a ``DateTime``
column of its primary key. Partitions are created ahead of time, one per day,
month or year, and are named deterministically after the table and the period
they hold: ``<table>_p<YYYY>``, ``<table>_p<YYYYMM>`` or ``<table>_p<YYYYMMDD>``.
Bounds are midnight UTC; a ``timestamp without time zone`` column reads them as
plain midnight.
"""

from __future__ import annotations

import datetime as dt
from dataclasses import dataclass
from typing import Final, Literal

from loom.core.schema_names import MAX_IDENTIFIER_LENGTH

Interval = Literal["day", "month", "year"]
_INTERVALS: Final[dict[str, Interval]] = {"day": "day", "month": "month", "year": "year"}
_SUFFIX: Final[dict[str, str]] = {"day": "%Y%m%d", "month": "%Y%m", "year": "%Y"}


[docs] @dataclass(frozen=True, slots=True) class RangePartition: """A table partitioned by range over ``column``.""" column: str
[docs] @dataclass(frozen=True, slots=True) class PartitionRange: """One partition: its name and the half-open range ``[lower, upper)`` it holds.""" name: str lower: str upper: str
[docs] def range_partitions( table: str, start: dt.date, end: dt.date, interval: Interval = "month" ) -> tuple[PartitionRange, ...]: """The partitions of ``table`` covering ``[start, end)``, from the period holding ``start``. A ``datetime`` bound is read as its date. Raises: ValueError: When ``interval`` is unknown or a partition name would be longer than Postgres keeps. """ period = _floor(as_date(start), validate_interval(interval)) stop = as_date(end) partitions: list[PartitionRange] = [] while period < stop: following = _next(period, interval) name = partition_name(table, period, interval) partitions.append(PartitionRange(name, _bound(period), _bound(following))) period = following return tuple(partitions)
def partition_name(table: str, period: dt.date, interval: Interval) -> str: """The name of the partition of ``table`` holding the period that starts on ``period``. Raises: ValueError: When the name would be longer than Postgres keeps. """ name = f"{table}_p{period.strftime(_SUFFIX[interval])}" if len(name.encode()) > MAX_IDENTIFIER_LENGTH: raise ValueError( f"partition name {name!r} is longer than the {MAX_IDENTIFIER_LENGTH} bytes " "Postgres keeps; shorten the table name" ) return name def validate_interval(interval: str) -> Interval: """Return ``interval`` when it is one partitions are created by. Raises: ValueError: When it is not ``day``, ``month`` or ``year``. """ known = _INTERVALS.get(interval) if known is None: raise ValueError(f"interval {interval!r} is not one of {tuple(_INTERVALS)}") return known def as_date(value: dt.date | str) -> dt.date: """Read an ISO date string, or a ``datetime``, as a date.""" if isinstance(value, str): return dt.date.fromisoformat(value) return value.date() if isinstance(value, dt.datetime) else value def _floor(day: dt.date, interval: Interval) -> dt.date: if interval == "year": return day.replace(month=1, day=1) if interval == "month": return day.replace(day=1) return day def _next(period: dt.date, interval: Interval) -> dt.date: if interval == "year": return period.replace(year=period.year + 1) if interval == "month": carry, month = divmod(period.month, 12) return period.replace(year=period.year + carry, month=month + 1) return period + dt.timedelta(days=1) def _bound(day: dt.date) -> str: return f"{day.isoformat()} 00:00:00+00"