Repository¶
- class alchemiq.Repository(model=None, *, cache=None, cache_ttl=None)[source]¶
Bases:
Generic[M]Data-access surface for one model.
Instantiate ad-hoc with
Repository(Model)or subclass to attach behaviour:E.g.:
# ad-hoc (no subclass) users = Repository(User) user = await users.create(name="Ada", email="ada@x.io") # subclass with cache class UserRepository(Repository[User]): cache = True cache_ttl = 300 repo = UserRepository() user = await repo.get(1) await repo.update(1, name="Ada Lovelace")
The repository delegates query building to
QuerySetand write operations to SQLAlchemy sessions managed byUnitOfWork(or its ownsession_scope).- exclude(*args, **lookups)[source]¶
Return a QuerySet excluding rows matching the given lookup expressions.
- only(*fields)[source]¶
Return a QuerySet that loads only the named columns (deferred load for others).
Return a QuerySet that JOIN-loads the named relationships (one SQL query).
Return a QuerySet that SELECT-IN-loads the named relationships (extra query per rel).
- with_deleted()[source]¶
Return a QuerySet that includes soft-deleted rows alongside live ones.
- Return type:
- only_deleted()[source]¶
Return a QuerySet scoped to soft-deleted (tombstoned) rows only.
- Return type:
- async get(*args, **lookups)[source]¶
Fetch exactly one row matching lookups.
E.g.:
user = await users.get(id=1) user = await users.get(email="ada@x.io")
- Parameters:
- Returns:
the matched model instance.
- Raises:
NotFoundError – if no row matches.
MultipleResultsFound – if more than one row matches.
- Return type:
M
- async get_or_none(*args, **lookups)[source]¶
Fetch the row matching lookups, or
Noneif absent.E.g.:
user = await users.get_or_none(id=42) if user is None: ... # not found
- Parameters:
- Returns:
the matched model instance, or
None.- Raises:
MultipleResultsFound – if more than one row matches.
- Return type:
M | None
- async first()[source]¶
Return the first row in the current ordering (
LIMIT 1), orNoneif empty.With no
order_bychained, noORDER BYis emitted, so which row is “first” is database-defined; chainorder_byfor a deterministic result.Note
There is no default
ORDER BY. For a stable “first” result, always chainorder_bybefore calling this method.- Return type:
M | None
- async last()[source]¶
Return the last row in the current ordering, or
Noneif empty.Reverses each chained
order_bydirection; with no ordering, falls back to descending primary key.- Return type:
M | None
- async exists(*args, **lookups)[source]¶
Return
Trueif at least one row matches the optional lookups.
- async aggregate(**exprs)[source]¶
Compute aggregate expressions (Count, Sum, Avg, Min, Max) over the table.
- async explain(*, analyze=False, format='text')[source]¶
Return the query plan for the current QuerySet (PostgreSQL only).
- async cursor_paginate(*, size=20, after=None, before=None)[source]¶
Return a
CursorPagenavigating forward (after) or backward (before).- Parameters:
- Return type:
CursorPage[M]
- async create(**values)[source]¶
Instantiate the model from values, persist it, and return the new instance.
- Parameters:
values (Any)
- Return type:
M
- async add(obj)[source]¶
Persist an already-constructed model instance and return it (fires pre/post_create).
- Parameters:
obj (M)
- Return type:
M
- async update(id, *, expected_version=None, **changes)[source]¶
Apply
changesto the rowidand return the refreshed instance.Executes within the ambient
UnitOfWorkif one is active, otherwise in its own autocommit transaction. Fires thepre_update/post_updatesignals around the flush.E.g.:
user = await users.update(3, age=26) # optimistic concurrency control: version = alchemiq.version_of(user) await users.update(3, expected_version=version, name="Ada")
- Parameters:
id (Any) – primary key of the row to update.
expected_version (int | None) – when given, the row’s
_versionmust equal this value orConcurrentModificationErroris raised and nothing is written. Read it withversion_of().changes (Any) –
column=valuepairs to assign; each is validated as on assignment.
- Returns:
the refreshed model instance.
- Raises:
NotFoundError – if no row with
idexists.ConcurrentModificationError – if
expected_versiondid not match, or a concurrent flush detected a stale version.
- Return type:
M
See also
Repository.bulk_update()- set-based, no per-row signals.
- async delete(id, *, expected_version=None)[source]¶
Delete the row identified by
id.Soft-delete models stamp
deleted_at(no physical row removal); others issue a physicalDELETE. Fires thepre_delete/post_deletesignals in both cases. Supports optimistic locking viaexpected_version.E.g.:
# physical delete (non-soft-delete model) await posts.delete(5) # optimistic concurrency control on a soft-delete model await repo.delete(20, expected_version=1)
- Parameters:
id (Any) – primary key of the row to delete.
expected_version (int | None) – when given, the row’s
_versionmust equal this value orConcurrentModificationErroris raised and nothing is written.
- Raises:
NotFoundError – if no live row with
idexists (for soft-delete models, an already-deleted row also counts as not found).ConcurrentModificationError – if
expected_versiondid not match, or a concurrent flush detected a stale version.
- Return type:
None
Note
For soft-delete models,
delete()stampsdeleted_atand leaves the physical row intact. UseRepository.hard_delete()to remove it unconditionally, orRepository.restore()to reverse the deletion.
- async restore(id)[source]¶
Clear
deleted_aton a soft-deleted row, returning it to the live set.Fires the
pre_update/post_updatesignals around the flush.E.g.:
await repo.restore(1)
- Parameters:
id (Any) – primary key of the tombstoned row to restore.
- Returns:
the restored model instance.
- Raises:
ConfigError – if the model does not have soft-delete enabled.
NotFoundError – if no tombstone (soft-deleted row) with
idexists.
- Return type:
M
- async hard_delete(id)[source]¶
Physically DELETE the row identified by
id, regardless of soft-delete status.Bypasses the soft-delete predicate: removes the physical row even if it has a
deleted_atstamp. Fires thepre_delete/post_deletesignals.E.g.:
await repo.hard_delete(31)
- Parameters:
id (Any) – primary key of the row to delete (live or tombstoned).
- Raises:
NotFoundError – if no row with
idexists (live or tombstoned).- Return type:
None
See also
Repository.delete()- respects soft-delete; stampsdeleted_atinstead of issuingDELETE.
- async bulk_create(objs)[source]¶
Persist multiple instances in a single flush and return the inserted objects.
Fires no per-row signals and writes no outbox entries. Prefer this over repeated
Repository.create()calls when inserting many rows at once.E.g.:
rows = await repo.bulk_create([ Item(id=10, name="x", age=1), Item(id=11, name="y", age=2), ])
- Parameters:
objs (Iterable[M]) – iterable of model instances to insert.
- Returns:
the list of inserted objects (same order as input);
[]if objs is empty.- Return type:
list[M]
See also
Repository.bulk_upsert()- idempotentINSERT ... ON CONFLICT.
- async bulk_upsert(objs, *, conflict=None, update_fields=None, ignore_conflicts=False)[source]¶
Idempotent batch
INSERT ... ON CONFLICT(PostgreSQL). Returns the affected rowcount.conflictdefaults to the PK column(s);update_fieldsdefaults to all sent non-conflict columns.ignore_conflicts=TrueemitsDO NOTHINGinstead ofDO UPDATE. Fires no signals and writes no outbox (like all bulk operations). Operates on physical rows - the soft-delete predicate is not applied.E.g.:
n = await repo.bulk_upsert([User(id=1, email="a@x.c", name="A")]) # override the conflict column: await repo.bulk_upsert( [User(id=1, email="dup@x.c", name="first")], conflict=["email"], )
- Parameters:
objs (Iterable[M]) – iterable of model instances to upsert; empty -> returns
0.conflict (Sequence[str] | None) – columns that identify a conflict; defaults to the primary key.
update_fields (Sequence[str] | None) – columns to overwrite on conflict; defaults to all non-conflict columns present in the batch.
ignore_conflicts (bool) – when
True, conflicting rows are silently skipped (DO NOTHING);update_fieldsis ignored.
- Returns:
number of rows affected (inserted + updated).
- Return type:
See also
Repository.bulk_create()- plain insert without conflict handling.
- async get_or_create(defaults=None, **lookups)[source]¶
Fetch the row matching
lookups, or create it fromlookups+defaults.E.g.:
obj, created = await repo.get_or_create( id=1, email="a@b.c", defaults={"name": "Ann"} )
- Parameters:
- Returns:
(obj, created)-Truewhen a new row was inserted.- Return type:
Warning
NOT atomic outside a
UnitOfWork. The lookup and the create run in separate transactions, so a concurrent insert between them can surface an integrity error. Wrap inasync with UnitOfWork():for atomicity.
- async update_or_create(defaults=None, **lookups)[source]¶
Update the row matching
lookups(withdefaults), or create it.E.g.:
obj, created = await repo.update_or_create(id=2, defaults={"name": "new"})
- Parameters:
- Returns:
(obj, created)-Truewhen a new row was inserted.- Return type:
Warning
Best-effort atomic outside a
UnitOfWork. If the matched row is concurrently deleted between the lookup and the update, theNotFoundErroris caught and the call falls through tocreate(). Wrap inasync with UnitOfWork():for true atomicity.
- async bulk_update(objs, fields)[source]¶
Bulk UPDATE by PK for the given
fields. Returnslen(items), not DB rowcount.Uses SQLAlchemy’s bulk-update-by-PK path: a single
UPDATEper batch rather than one flush per row. Objects must have PK + field attributes accessible (noDetachedInstanceError) - prefer calling inside aUnitOfWork.E.g.:
n = await repo.bulk_update(rows, fields=["age"])
- Parameters:
- Returns:
number of objects submitted (
len(items)); rows absent from the DB are silently skipped by SQLAlchemy’s bulk path - do not rely on this count to detect missing PKs.- Return type:
See also
Repository.bulk_upsert()- insert-or-update in one statement.Warning
Bypasses the optimistic-lock version check and increment (set-based, no per-row ORM flush). Fires no per-row signals.
- async update_all(**changes)[source]¶
Apply
changesto every row without requiring a precedingfilter().Explicit full-table escape hatch:
filter().update()refuses an unfiltered call. That filtered variant limits lookups to own columns; relationship traversal raisesQueryError. Bypasses the optimistic-lock version check and increment (set-based, no per-row ORM flush). Fires no per-row signals.- Parameters:
changes (Any) –
column=valuepairs to assign across all rows.- Returns:
number of rows updated.
- Return type:
Warning
This method updates every row in the table. There is no
WHEREclause unless you callfilter().update()instead. Double-check that a full-table update is intentional before using this method.
- async delete_all()[source]¶
Delete every row without requiring a preceding
filter().Explicit full-table escape hatch:
filter().delete()refuses an unfiltered call. That filtered variant limits lookups to own columns; relationship traversal raisesQueryError. Soft-delete models stampdeleted_at; others issue a physicalDELETE. Bypasses the optimistic-lock version check and increment (set-based, no per-row ORM flush). Fires no per-row signals.E.g.:
n = await repo.delete_all()
- Returns:
number of rows deleted (or soft-deleted).
- Return type:
Warning
This method deletes every row in the table. There is no
WHEREclause unless you callfilter().delete()instead. Double-check that a full-table delete is intentional before using this method.