Versioned models¶
VersionedModel of hare.contrib.versioning is an append-only versioning base: every change creates a
new row sharing the same id but a higher version, instead of mutating a row in place.
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: ... # raises QueryError if kwargs has "id"/"version"
async def create_new_version(self, **kwargs: Any) -> Self: ...
A new record's id is a version 7 UUID (hare.fields.uuid7.Uuid7.new): time-ordered, and increasing
within a process even within one millisecond — an index over the ids grows at its end. It is Python's
own uuid.uuid7 on 3.14 and newer, and the same layout made by hare on 3.12 and 3.13. The field is
UUIDField(version=7): an id of another version is refused (ValidationError) wherever hare passes it
to the database — in a write, a filter, the key of an existing row and a reference to it.
id and version together are the table's real primary key, not a surrogate one with a bolted-on
uniqueness constraint — which means a foreign key can target a specific version with a real, enforced
FOREIGN KEY (see Relations — targeting a composite primary
key). Reading the state as of a version,
diffing versions and cleaning old versions up aren't built in.
Creating a version¶
A library's catalog record, edited by its librarians version by version:
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() returns the highest version matching the filters — DoesNotExist, or
the exception passed, when none does. It sees every version, a soft-deleted one too: the next version
must not collide with it. get_last_visible_version_or_exception() returns the highest version of those
the model's manager shows — soft-deleted versions hidden, the tenant scope applied — for reading the
current state of a record whose deletion marked its versions. get_new_version/create_new_version
copy every direct field
via deepcopy, except id, version, and anything listed in Meta.new_version_excluded_fields,
bumping version by 1; get_new_version() builds the version without saving it. A database-computed
column (GeneratedField, any generated=True field) and an auto_now=True column are never copied —
they get a fresh value when the new version is written. A forward FK/O2O can be listed or overridden by
its relation name (author) or by its shadow column (author_id) — either way the shadow column is not
copied from the old version. An instance loaded with .only()/.defer() raises
IncompleteInstanceError naming the unfetched fields, unless they are passed as overrides;
database-computed and auto_now columns never need to be loaded.
create_new_version() of a version that isn't the highest raises IntegrityError — the next number is
taken.
With soft delete¶
With Meta.soft_delete_field, a new version is never born deleted — the field is not
copied — and get_last_version_or_exception() finds the highest version among the soft-deleted rows too,
so the next version never collides with a deleted one. It reads the versions a custom Meta.manager
hides as well (the manager's filters don't apply, the tenant scope does): the highest version may be one
the manager hides.
Writing versions (ClickHouse)¶
ClickHouse keeps nothing unique: the key (id, version) isn't checked, so create_new_version() of a
version that isn't the highest raises nothing, and two create_new_version() of one version running
at once both write — each leaves two rows of one key (see
ClickHouse models). Write the versions of one
record from one task at a time there.
With a project base model¶
Combine VersionedModel with your project's base model through multiple inheritance, VersionedModel
first:
class AppVersionedModel(VersionedModel, YourBaseModel):
class Meta(YourBaseModel.Meta, VersionedModel.Meta):
abstract = True
The first base gives the fields both declare: id is then VersionedModel's, a part of the key
id + version. A base model declaring its own primary key id (UUIDField(primary_key=True)) listed
first raises ConfigurationError - a field of a composite key can't be a primary key itself.
Unique fields¶
Every version is a real, permanent row — create_new_version() never deletes or overwrites the previous
one. A unique=True field (or a UniqueConstraint in Meta.constraints) that isn't reset via
Meta.new_version_excluded_fields keeps its old value on the new row too, and the second version
collides with the first at the database level:
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 already exists
A field meant to identify the same logical record across all its versions (a slug, an external key) needs
a different scoping strategy than a bare unique=True — e.g. a column marking the current version and a
partial UniqueConstraint(fields=("slug",), condition=Q(is_current=True)), or dropping uniqueness in the
database and enforcing "one active slug" at the application level instead.