Repository

Repository is the primary data-access façade for a single model. Instantiate it ad-hoc with Repository(Model) or subclass Repository[Model] to attach default settings such as caching.

from alchemiq import Repository
from myapp.models import User

# ad-hoc
users = Repository(User)

# subclass - can add cache, default ordering, etc.
class UserRepository(Repository[User]):
    cache = True
    cache_ttl = 300

All methods are async.


CRUD operations

Creating rows

create(**values) instantiates the model, persists it, and returns the new instance:

user = await users.create(name="Ada Lovelace", email="ada@example.com")
print(user.id)  # auto-assigned PK

Reading rows

get(**lookups) returns exactly one row, or raises if none or many match:

user = await users.get(id=1)
user = await users.get(email="ada@example.com")
# raises NotFoundError if absent, MultipleResultsFound if ambiguous

get_or_none(**lookups) is the same but returns None instead of raising NotFoundError:

user = await users.get_or_none(id=99)   # None if not found

Positional Q expressions are accepted alongside keyword lookups in both methods:

user = await users.get(Q(role="admin"), email="alice@example.com")

Other read helpers:

first  = await users.first()               # first row (no guaranteed order without order_by)
last   = await users.last()                # last row (reverses ordering or falls back to -PK)
all_   = await users.all()                 # list of all rows
exists = await users.exists(status="active")
count  = await users.count(status="active")

Updating rows

update(id, **changes) applies the changes and returns the refreshed instance:

user = await users.update(3, name="Ada King")

For optimistic concurrency, pass expected_version (read with alchemiq.version_of(obj) on versioned models):

ver = alchemiq.version_of(user)
user = await users.update(3, expected_version=ver, name="Ada King")
# raises ConcurrentModificationError if the row was changed concurrently

Deleting rows

delete(id) performs a soft-delete (stamps deleted_at) if the model has Meta.soft_delete = True, otherwise issues a physical DELETE:

await users.delete(3)

hard_delete(id) always removes the physical row regardless of soft-delete configuration.


Filtering and chaining

All filter methods on Repository delegate to QuerySet and return a QuerySet you can chain further:

# filter -> order -> paginate
page = await (
    users
    .filter(status="active")
    .order_by("-created_at")
    .paginate(page=1, size=20)
)

See the Queries guide for the full filter and builder API.


Offset pagination

paginate(page, size) issues two queries (count + windowed fetch) and returns a Page:

page = await users.filter(status="active").order_by("id").paginate(page=1, size=20)

page.items      # list[User]   - current page rows
page.total      # int          - total matching rows
page.page       # int          - current page number (1-based)
page.size       # int          - requested page size
page.pages      # int          - total number of pages
page.has_next   # bool
page.has_prev   # bool

Note

count() and all() run in two separate database sessions. A row inserted between the two queries may inflate total without appearing in items, or vice versa.


Cursor / keyset pagination

For high-volume lists where offset pagination becomes slow, use keyset pagination. cursor_paginate(*, size, after, before) (keyword-only arguments) adds a PK tiebreaker and returns a CursorPage - no total-count query:

p1 = await users.order_by("created_at").cursor_paginate(size=20)

p1.items        # list[User]    - current page rows
p1.next_cursor  # str | None    - opaque token; pass as after= to fetch next page
p1.prev_cursor  # str | None    - opaque token; pass as before= to fetch previous page
p1.has_next     # bool
p1.has_prev     # bool

Navigate forward by passing the previous page’s next_cursor:

p2 = await users.order_by("created_at").cursor_paginate(size=20, after=p1.next_cursor)

Navigate backward with before=:

p0 = await users.order_by("created_at").cursor_paginate(size=20, before=p1.prev_cursor)

after and before are mutually exclusive; the cursor tokens are opaque base64 strings encoding the effective ordering position. Keyset pagination is stable under concurrent inserts and O(1) at any page depth.


Bulk operations

bulk_create

bulk_create(objs) inserts multiple instances in one flush:

rows = await users.bulk_create([
    User(name="Alice", email="alice@example.com"),
    User(name="Bob",   email="bob@example.com"),
])

Fires no per-row signals and writes no outbox entries.

bulk_upsert (PostgreSQL)

bulk_upsert(objs) emits an idempotent INSERT ... ON CONFLICT statement. Returns the number of rows affected (inserted + updated).

n = await users.bulk_upsert([User(id=1, email="a@x.c", name="A")])

By default the conflict target is the primary key and all non-conflict columns are updated. Override with keyword arguments:

# conflict on a unique email column; update only the name
await users.bulk_upsert(
    [User(id=1, email="dup@x.c", name="First")],
    conflict=["email"],
    update_fields=["name"],
)

# skip conflicting rows silently
await users.bulk_upsert(rows, ignore_conflicts=True)

bulk_upsert is PostgreSQL-only. It fires no signals and writes no outbox entries.

bulk_update

bulk_update(objs, fields) runs a bulk UPDATE by PK for the listed columns and returns the count of submitted objects:

n = await users.bulk_update(rows, fields=["status", "updated_at"])

The returned count is len(items) - the number of objects you submitted, not the database rowcount. Rows whose PK is absent from the table are silently skipped by SQLAlchemy’s bulk path, so do not rely on this count to detect missing PKs.


Aggregations

aggregate(**exprs) computes reduce-aggregates over the filtered set and returns a dict mapping each alias to its computed value.

Import the aggregate expressions from alchemiq:

from alchemiq import Count, Sum, Avg, Min, Max

stats = await users.filter(status="active").aggregate(
    total=Sum("balance"),
    n=Count(),
    avg_age=Avg("age"),
    min_age=Min("age"),
    max_age=Max("age"),
)
# {"total": Decimal("..."), "n": 42, "avg_age": 31.5, "min_age": 18, "max_age": 65}

Sum, Avg, Min, and Max return None over an empty set; Count returns 0.

Count() emits count(*). Pass a field name for count(col), or set distinct=True for count(DISTINCT col):

stats = await users.aggregate(
    n=Count(),
    unique_emails=Count("email", distinct=True),
)

There is no GROUP BY - aggregate() always returns a single row.


Explain (query plan)

.explain() runs EXPLAIN on the compiled SELECT and returns the plan. It is a diagnostic tool only - never cached, PostgreSQL-only:

# textual plan (str)
plan = await users.filter(status="active").explain()
print(plan)

# EXPLAIN ANALYZE - real execution, real timings (str)
plan = await users.filter(status="active").explain(analyze=True)

# parsed JSON plan (list)
plan = await users.filter(status="active").explain(format="json")

analyze=True executes the underlying SELECT inside a rolled-back read-only transaction so no data is modified.

.explain() is also available directly on QuerySet.