Версии записей¶
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)), или отказ от уникальности в базе и
проверка «один действующий слаг» в приложении.