Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

SQLArgon

Test Build License Python Format PyPi Mypy Ruff security: bandit

SQLAlchemy repository pattern and utilities


Documentation: https://asynq-io.github.io/sqlargon/

Repository: https://github.com/asynq-io/sqlargon


Features

  • Repository pattern — one object wraps async sessions, core queries and ORM models; sessions are context-local and resolved at call time, so nothing gets passed around
  • High-level CRUDcreate, get, get_or_create, create_or_update, all, list, count, update_one, update_many, delete_one, delete_many and remove out of the box
  • Bulk operationsbulk_create, bulk_create_or_update and bulk_update with per-repository conflict handling
  • Query builder — fluent, dialect-aware statements for upserts, RETURNING, advisory locks and streaming, with terminal helpers that cast results to .scalars(), .one(), .mappings(), ...
  • Multi-dialect — PostgreSQL, SQLite, MySQL and MariaDB, with capability-gated SQL generation per backend
  • Transactions@atomic and database-scoped atomic() blocks, plus named advisory locks
  • Unit of work — repositories declared as annotations on a unit of work share one session and one transaction
  • Database routing — clusters with read replicas, shards and vertical partitioning; using(), read_only and per-request use_context
  • Pagination — page-number, offset/limit and keyset cursor strategies
  • Outbox — transactional outbox with a background relay and eventiq integration
  • Cron — database-backed scheduler with namespaces and safe multi-instance claiming
  • Column types and mixins — UUID (v4/v7), timestamp, orjson JSON and pydantic-validated columns; mixins for UUID keys, created/updated timestamps and soft delete
  • Soft delete — tombstone-based deletes via SoftDeleteRepository
  • Versioned models — optimistic concurrency with UUID or PostgreSQL xmin versions
  • Auditable models — append-only versioned history with point-in-time reads and restore
  • Vector search — embeddings with cosine, L2, dot and L1 similarity, full-text and hybrid reciprocal-rank-fusion search on PostgreSQL and SQLite
  • Internationalization — multi-locale text in a JSON column or a translation table, with per-request locales and fallback chains
  • FastAPI-ready — repositories and units of work work directly as dependencies
  • Alembic migrations — async-first migration setup
  • OpenTelemetry — optional SQLAlchemy instrumentation

About

This library provides glue code to use sqlalchemy async sessions, core queries and orm models from one object which provides somewhat of repository pattern. This solution has few advantages:

  • no need to pass session object to every function/method. Sessions are context-local and resolved by the repository itself
  • write data access queries in one place
  • no need to import insert, update, delete, select from sqlalchemy over and over again
  • implicit cast of results to .scalars().all(), .one(), .mappings(), ...
  • dialect-aware query builder (Postgres, SQLite, MySQL) for upserts, RETURNING and advisory locks
  • your view model (e.g. FastAPI routes) does not need to know about the underlying storage. Repository class can be replaced at any moment with any object providing similar interface
  • engines and routing policy are separate, so the same repository runs against one database, a primary with read replicas, or a set of shards

Installation

pip install sqlargon

or

uv add sqlargon

Optional extras: postgres, sqlite, mysql, pagination (cursor pagination), cron, vectors, eventiq, opentelemetry, or standard for the drivers, pagination, cron and OpenTelemetry:

pip install "sqlargon[standard]"

Usage

from typing import Sequence

import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column

from sqlargon import Base, Database, SQLAlchemyRepository, set_default_database
from sqlargon.mixins import CreatedUpdatedMixin, UUIDModelMixin

set_default_database(Database(url="postgresql+asyncpg://localhost:5432/app"))


class User(UUIDModelMixin, CreatedUpdatedMixin, Base):
    name: Mapped[str] = mapped_column(sa.Unicode(255))
    last_name: Mapped[str | None] = mapped_column(sa.Unicode(255), nullable=True)


class UserRepository(SQLAlchemyRepository[User]):
    default_order_by = User.created_at.desc()

    async def get_by_name(self, name: str) -> User:
        # custom query, built with the repository's query builder
        return await self.select().filter_by(name=name).one()


user_repository = UserRepository()

The model is taken from the generic parameter, and __init__ takes no arguments — the repository resolves its database at call time (see Routing).

High level CRUD

user = await user_repository.create(name="John")
user = await user_repository.get(name="John")                # None if missing
user = await user_repository.get_or_create(name="John")
user = await user_repository.create_or_update(id=user_id, name="John")  # upsert

users = await user_repository.all()
users = await user_repository.list(User.name == "John")
count = await user_repository.count(User.name == "John")

user = await user_repository.update_one({"last_name": "Connor"}, User.id == user_id)
await user_repository.update_many({"last_name": "Connor"}, User.name == "John")

user = await user_repository.delete_one(User.id == user_id)
users = await user_repository.delete_many(User.name == "John")
await user_repository.remove(User.id == user_id)             # no results returned

Bulk operations

users = [{"name": "Alice"}, {"name": "Bob"}]

await user_repository.bulk_create(users)                       # ON CONFLICT DO NOTHING
created = await user_repository.bulk_create(users, return_results=True)
await user_repository.bulk_create_or_update(users)             # ON CONFLICT DO UPDATE
await user_repository.bulk_update(
    values=[{"name": "Alice", "last_name": "Connor"}],
    on_={"name"},
)

Conflict handling defaults to the model's primary key as index_elements and every other column in set_. Override it per repository:

from sqlargon.typing import OnConflictOptions


class UserRepository(SQLAlchemyRepository[User]):
    @property
    def on_conflict(self) -> OnConflictOptions:
        return {"index_elements": {"id"}, "set_": {"name"}, "exclude_set": {"last_name"}}

Building queries

Query methods (select, insert, upsert, update, delete, filter/where, join, load) return a repository copy carrying the statement; any other attribute is proxied to the underlying SQLAlchemy statement, so limit, order_by, group_by, ... chain as usual. Awaiting the repository executes the statement and returns a Result; the terminal helpers cast it for you:

users = await (
    user_repository.select()
    .join(Order, Order.user_id == User.id)
    .filter(User.name == "John")
    .order_by(User.created_at)
    .limit(2)
    .all()
)

user = await user_repository.select().filter(name="John").one_or_none()
name = await user_repository.select(User.name).scalar()
rows = await user_repository.select(User.id, User.name).mappings()
result = await user_repository.insert({"name": "John"}, return_results=True)

async for row in user_repository.select().stream():
    ...

Terminal methods: all(unique=False), one(), one_or_none(), first(), scalar(), scalars(), unique(), mappings(), stream(), execute().

Transactions

atomic wraps a repository method in a single session, committed on success and rolled back on error:

from sqlargon import atomic


class UserRepository(SQLAlchemyRepository[User]):
    @atomic
    async def create_users(self, names: Sequence[str]) -> None:
        for name in names:
            await self.create(name=name)

The same works on any coroutine via the database object, which also exposes named locks (database-native advisory locks where the dialect supports them):

db = Database.from_env()


@db.atomic
async def do_work() -> None: ...


@db.with_lock(key="import")
async def import_data() -> None: ...


async with db.lock("import"):
    ...

Unit of work

Repositories declared as annotations on a unit of work share one session and one transaction:

from sqlargon import SQLAlchemyUnitOfWork


class OrdersUow(SQLAlchemyUnitOfWork):
    users: UserRepository
    orders: OrderRepository


async with OrdersUow() as uow:
    user = await uow.users.create(name="John")
    await uow.orders.create(user_id=user.id)
    await uow.commit()

A unit of work never spans databases; the member database is resolved once on __aenter__ and pinned for the whole transaction.

Routing

A repository (or unit of work) without an explicit database uses the process-wide default, set with set_default_database(...) or built lazily from DATABASE_* environment variables (DATABASE_URL, DATABASE_ECHO, DATABASE_POOL_SIZE, DATABASE_READ_REPLICAS, ...):

from sqlargon import Database, DatabaseCluster

db = Database.from_env()                 # single database
cluster = DatabaseCluster.from_env()     # primary + DATABASE_READ_REPLICAS

Bind explicitly with the database class attribute or per call with using:

from sqlargon import DatabaseCluster, read_only, using

db = DatabaseCluster.with_replicas(
    "postgresql+asyncpg://primary/app",
    read_replicas=["postgresql+asyncpg://replica-1/app"],
    auto_route=True,  # SELECTs go to replicas automatically
)


class UserRepository(SQLAlchemyRepository[User]):
    database = db

    @read_only
    async def active(self) -> Sequence[User]:
        return await self.filter(is_active=True).all()


await user_repository.using("replica_0").all()
await user_repository.using(shard_key=tenant_id).create(name="John")

with using(read_only=True):
    users = await user_repository.all()

Clusters take named databases plus a router — DefaultRouter, PrimaryReplicaRouter, ModelRouter (vertical partitioning), ShardRouter (horizontal partitioning), or any object with a route(databases, context) method. See the routing docs for the full resolution order.

Pagination

Pagination is a strategy attached to a repository class; accessed on an instance it returns a paginator typed with the repository's model:

from sqlargon.pagination import PageNumberPagination


class UserRepository(SQLAlchemyRepository[User]):
    paginate = PageNumberPagination(default_page_size=25)


page = await UserRepository().filter(User.name == "John").paginate(page=2)
page.items, page.current_page, page.page_size, page.has_more

async for page in UserRepository().paginate.pages(page_size=100):
    ...

Available strategies: PageNumberPagination, TotalPageNumberPagination, LimitOffsetPagination, TotalLimitOffsetPagination and CursorPagination (keyset, requires sqlargon[pagination]).

Outbox

sqlargon.outbox implements the transactional outbox pattern: a write through the repository also appends a CloudEvent-shaped row to outbox_events in the same transaction, so an event can neither be lost by a rollback nor published for a row that never committed. A background relay then publishes them in write order:

from sqlargon import Base
from sqlargon.outbox import OutboxConfig, OutboxRelay, OutboxRepository


class User(UUIDModelMixin, CreatedUpdatedMixin, Base):  # is_new tells insert from update
    name: Mapped[str] = mapped_column(sa.Unicode(255))
    password: Mapped[str] = mapped_column(sa.Unicode(255))


class UserRepository(OutboxRepository[User]):
    outbox = OutboxConfig(topic="users", exclude={"password"})


await UserRepository().create(name="John", password=hashed)
# -> one outbox_events row, type "user.created", password left out

async with OutboxRelay(publish).running():  # publish is any async callable
    ...

The relay takes a plain publisher callable, so sqlargon depends on no broker client; sqlargon.integrations.eventiq adapts the rows to eventiq.CloudEvent. Only writes that go through the repository are recorded. See the outbox docs for retention, retries and ordering.

Column types and mixins

sqlargon.types provides dialect-aware column types: GUID with GenerateUUID / GenerateUUIDV7 server defaults, Timestamp with a now() server default and JSON (orjson-serialized), whose comparator carries portable JSON operators — containment and key tests, plus server-side mutation (set_key, update, remove_key) that rewrites a document in the UPDATE itself. sqlargon.types.pydantic adds Pydantic and ValidatedType for pydantic-validated columns. sqlargon.mixins bundles them into UUIDModelMixin, UUIDV7ModelMixin, CreatedUpdatedMixin and SoftDeleteMixin.

Auditable models

AuditableRepository never updates a row: every write appends the next version of the same entity, so the table is the audit log. Reads are scoped to the newest live version, so the usual methods keep their usual meaning:

from sqlargon import AuditableBase, AuditableRepository
from sqlargon.mixins import UUIDModelMixin


class Article(UUIDModelMixin, AuditableBase):
    title: Mapped[str] = mapped_column(sa.Unicode(255))


class ArticleRepository(AuditableRepository[Article]): ...


articles = ArticleRepository()

article = await articles.create(title="draft")  # version 1
await articles.update_one({"title": "final"}, Article.id == article.id)  # version 2

await articles.get(id=article.id)  # version 2
await articles.history(id=article.id)  # versions 1 and 2
await articles.get_version(1, id=article.id)  # version 1
await articles.at(yesterday).list()  # the state as of yesterday

await articles.remove(Article.id == article.id)  # appends a tombstoned version 3
await articles.restore(Article.id == article.id)  # and a live version 4

The version joins the primary key, so concurrent appends collide there rather than one silently winning, and update_if_match gives the cheaper check first. Versions are either a human-readable counter (AuditableBase) or a sortable UUIDv7 (UUIDAuditableBase), and sqlargon.audit relates other tables to one exact version or to whichever is newest. See the documentation for the full picture.

Internationalization

sqlargon.i18n keeps text in more than one locale and reads back whichever the current request wants. Register a locale getter and a fallback chain at startup, and attribute access stays a plain string in the active locale:

from sqlargon.i18n import TranslatedString, Translation, set_fallback_chain, set_locale_getter

set_locale_getter(lambda: request_locale.get())
set_fallback_chain(lambda locale: (locale or "en", "en"))


class Post(TranslationMixin, Base):
    title: Mapped[Translation] = mapped_column(TranslatedString())


await PostRepository().create(title={"en": "Hello", "pl": "Czesc"})

post = await PostRepository().select().one()
str(post.title)  # "Czesc" under a "pl" locale
post.title.data  # {"en": "Hello", "pl": "Czesc"}

Every column operator is rewritten onto the active locale's text, so Post.title == "Czesc", .like(...) and order_by need no join and no special syntax — the dialect-specific JSON read is handled per backend. Long text or many locales are better served by the second backend, translation_table, which keeps one row per locale in a side table and joins it through TranslatedRepository. See the documentation for the full picture.

FastAPI

Repository and unit-of-work __init__ take no arguments, so subclasses work directly as dependencies — no routing knobs leak into the endpoint signature:

from fastapi import Depends, FastAPI

app = FastAPI()


@app.get("/users")
async def list_users(repo: UserRepository = Depends()) -> list[UserOut]:
    return await repo.all()


@app.post("/orders")
async def create_order(data: OrderIn, uow: OrdersUow = Depends()) -> OrderOut:
    async with uow:
        return await uow.orders.create(**data.model_dump())

To route a whole endpoint, attach use_context as a dependency — it applies using(...) for the span of the request:

from sqlargon import use_context


@app.get("/reports", dependencies=[Depends(use_context(read_only=True))])
async def reports(repo: UserRepository = Depends()) -> list[UserOut]:
    return await repo.all()

Tests

The unit suite runs against an in-memory SQLite database and needs nothing installed:

pytest

The end-to-end suite runs the same stack against PostgreSQL, MySQL, MariaDB and an on-disk SQLite database, spinning the servers up and tearing them down with testcontainers — so it only needs a Docker daemon. It is skipped unless asked for:

pytest --e2e                              # unit suite + every backend
pytest --e2e ./tests/e2e                  # e2e only
pytest --e2e --e2e-backends=mysql,mariadb # selected backends

See tests/e2e/README.md for the layout and the per-backend capability gaps it pins down.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sqlargon-1.1.0b1.tar.gz (88.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sqlargon-1.1.0b1-py3-none-any.whl (117.0 kB view details)

Uploaded Python 3

File details

Details for the file sqlargon-1.1.0b1.tar.gz.

File metadata

  • Download URL: sqlargon-1.1.0b1.tar.gz
  • Upload date:
  • Size: 88.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.13

File hashes

Hashes for sqlargon-1.1.0b1.tar.gz
Algorithm Hash digest
SHA256 fcd74719b5ad0096d56c07378d1e09b987479052c55691a79ee33e62d5c53c43
MD5 d8cca51b0af32a598d1faf74f640fa1b
BLAKE2b-256 c43147e3ecaf3151d81c77f435df7948d0a7e095af1f595e586b173895c63fe3

See more details on using hashes here.

File details

Details for the file sqlargon-1.1.0b1-py3-none-any.whl.

File metadata

  • Download URL: sqlargon-1.1.0b1-py3-none-any.whl
  • Upload date:
  • Size: 117.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.8.13

File hashes

Hashes for sqlargon-1.1.0b1-py3-none-any.whl
Algorithm Hash digest
SHA256 69a528e61924c307f4e024f2fd9f7e2ce91da6fcdd6c6c6c12a5ba33aa841173
MD5 1dc2f7e9ac23399ea09dd69e10eb0e2c
BLAKE2b-256 19a1f0a0be67af1176723dbaa795c890bbc0873d4f85da4d5abfd4b9c4444cb6

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.1.0b1 This release

2 files

1.0.0

2 files

0.6.13

2 files

0.6.12

2 files

0.6.11

2 files

0.6.10

2 files

0.6.9

2 files

0.6.8

2 files

0.6.7

2 files

0.6.6

2 files

0.6.5

2 files

0.6.4

2 files

0.6.3

2 files

0.6.2

2 files

0.6.1

2 files

0.6.0

2 files

0.5.1

2 files

0.5.0

2 files

0.4.5

2 files

0.4.4

2 files

0.4.3

2 files

0.4.2

2 files

0.4.1

2 files

0.4.0

2 files

0.3.2

2 files

0.3.1

2 files

0.3.0

2 files

0.2.8

2 files

0.2.6

2 files

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

0.0.5

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page