Перейти к содержанию

Версии записей

VersionedModel из hare.contrib.versioning — основа для хранения версий записей, в которую только добавляют: каждое изменение создаёт новую строку с тем же id, но большим version, а не меняет строку на месте.

class VersionedModel(Model):
    id = fields.UUIDField(default=Uuid7.new, db_index=True, version=7)
    version = fields.PositiveSmallIntField(default=1)
    pk = CompositePrimaryKey("id", "version")

    class Meta:
        abstract = True

    @classmethod
    async def get_last_version_or_exception(
        cls, *args: Q, exception: type[Exception] | None = None, **kwargs: Any
    ) -> Self: ...
    @classmethod
    async def get_last_visible_version_or_exception(
        cls, *args: Q, exception: type[Exception] | None = None, **kwargs: Any
    ) -> Self: ...

    def get_new_version(self, **kwargs: Any) -> Self: ...      # QueryError, если в kwargs есть "id"/"version"
    async def create_new_version(self, **kwargs: Any) -> Self: ...

id новой записи — UUID версии 7 (hare.fields.uuid7.Uuid7.new): упорядочен по времени и в пределах процесса растёт даже внутри одной миллисекунды — индекс по id растёт с конца. На Python 3.14 и новее это собственный uuid.uuid7, на 3.12 и 3.13 — та же раскладка, которую делает hare. Поле — это UUIDField(version=7): id другой версии отклоняется (ValidationError) везде, где hare передаёт его в базу, — в записи, в фильтре, в ключе существующей строки и в ссылке на неё.

id и version вместе — настоящий первичный ключ таблицы, а не искусственный ключ с добавленным сбоку ограничением уникальности. Поэтому внешний ключ может ссылаться на конкретную версию настоящим, проверяемым базой FOREIGN KEY (см. Связи — связь с составным первичным ключом). Чтения состояния на момент версии, сравнения версий и очистки старых версий нет.

Создание версии

Запись каталога библиотеки, которую библиотекари правят версия за версией:

from hare import fields
from hare.contrib.versioning import VersionedModel


class CatalogRecord(VersionedModel):
    title = fields.CharField(max_length=200)
    subjects = fields.JSONField(default=list)
    published_at = fields.DatetimeField(null=True)

    class Meta:
        new_version_excluded_fields = ("published_at",)


current = await CatalogRecord.get_last_version_or_exception(id=record_id)
revised = await current.create_new_version(title="Updated title")

get_last_version_or_exception() возвращает наибольшую версию, подходящую под фильтры, — или DoesNotExist (либо переданное exception), если такой нет. Он видит все версии, в том числе мягко удалённые: следующая версия не должна с ними совпасть. get_last_visible_version_or_exception() возвращает наибольшую из версий, которые показывает менеджер модели, — мягко удалённые скрыты, область арендатора применена — чтобы читать текущее состояние записи, удаление которой пометило её версии. get_new_version/create_new_version копируют каждое поле самой модели через deepcopy, кроме id, version и всего, что перечислено в Meta.new_version_excluded_fields, и увеличивают version на 1; get_new_version() строит версию, не сохраняя её. Колонка, которую вычисляет база (GeneratedField, любое поле с generated=True), и колонка с auto_now=True никогда не копируются — они получают свежее значение при записи новой версии. Прямую связь (внешний ключ или «один-к-одному») можно перечислить или переопределить по имени связи (author) или по её колонке ключа (author_id) — в обоих случаях колонка ключа из старой версии не копируется. Объект, загруженный через .only()/.defer(), даёт IncompleteInstanceError с именами незагруженных полей, если они не переданы как переопределения; колонки, которые вычисляет база, и колонки auto_now загружать не нужно никогда.

create_new_version() от версии, которая не наибольшая, даёт IntegrityError — следующий номер уже занят.

Вместе с мягким удалением

С Meta.soft_delete_field новая версия никогда не рождается удалённой — это поле не копируется, — а get_last_version_or_exception() ищет наибольшую версию и среди мягко удалённых строк, так что следующая версия никогда не совпадает с удалённой. Он читает и версии, которые скрывает собственный Meta.manager (фильтры менеджера не действуют, область арендатора — действует): наибольшей может оказаться скрытая версия.

Запись версий (ClickHouse)

ClickHouse не хранит уникальность: ключ (id, version) не проверяется, поэтому create_new_version() не самой старшей версии ничего не бросает, а два одновременных create_new_version() одной версии пишут оба — и то и другое оставляет две строки с одним ключом (см. модели ClickHouse). Там пишите версии одной записи из одной задачи за раз.

Вместе с базовой моделью проекта

Объединяйте VersionedModel с базовой моделью своего проекта через множественное наследование, VersionedModel — первым:

class AppVersionedModel(VersionedModel, YourBaseModel):
    class Meta(YourBaseModel.Meta, VersionedModel.Meta):
        abstract = True

Поля, объявленные в обоих классах, берутся из первого: id — тогда поле VersionedModel, часть ключа id + version. Базовая модель со своим первичным ключом id (UUIDField(primary_key=True)), стоящая первой, даёт ConfigurationError: поле составного ключа не может само быть первичным ключом.

Уникальные поля

Каждая версия — настоящая постоянная строка: create_new_version() никогда не удаляет и не перезаписывает предыдущую. Поле с unique=True (или UniqueConstraint в Meta.constraints), которое не сбрасывается через Meta.new_version_excluded_fields, сохраняет в новой строке старое значение, и вторая версия сталкивается с первой на уровне базы:

class CatalogRecord(VersionedModel):
    slug = fields.CharField(max_length=200, unique=True)
    title = fields.CharField(max_length=200)


record = await CatalogRecord.objects.create(slug="moby-dick", title="Draft")
await record.create_new_version(title="Revised")  # IntegrityError: slug уже существует

Полю, которое обозначает одну и ту же запись во всех её версиях (слаг, внешний ключ другой системы), нужна иная уникальность, чем простое unique=True: например, колонка-признак текущей версии и частичное UniqueConstraint(fields=("slug",), condition=Q(is_current=True)), или отказ от уникальности в базе и проверка «один действующий слаг» в приложении.