sqlarec
sqlarec adds a small Active Record API to SQLAlchemy 2 models:
user = User.query.where(User.email == "hamza@example.com").one_or_none()
user = User.create(name="Hamza", email="hamza@example.com")
user.save()
Your application remains in control of engines, sessions, commits, rollbacks, and cleanup. SQLARec resolves the current session and flushes writes, but never commits implicitly.
Choose one of two mapping styles
SQLARec supports two alternative mapping styles. Both provide the same query and persistence API; choose one according to who should own the SQLAlchemy mapping setup.
Style 1: BaseModel |
Style 2: ActiveRecordMixin + registry |
|
|---|---|---|
| Best for | Most applications and the shortest setup | Existing domain classes or custom mapping requirements |
| Mapping style | SQLAlchemy declarative mapping | SQLAlchemy imperative mapping |
| Mapping ownership | SQLARec provides DeclarativeBase; your model declares its table and columns |
Your application provides the registry, table, and mapping |
The styles are alternatives for defining a model hierarchy:
- Choose Style 1 if you want SQLARec to provide the declarative base.
- Choose Style 2 if you want to map your own classes with SQLAlchemy's
registry.map_imperatively().
After mapping, models from either style use the same API:
Model.query.where(...).all()
Model.get_by_pk(...)
Model.create(...)
instance.save()
instance.delete()
Install
uv add sqlarec
For asynchronous use, also install a driver for your database:
uv add aiosqlite # SQLite
uv add "psycopg[binary]" # PostgreSQL
SQLARec requires Python 3.11 or later and SQLAlchemy 2.
Style 1: declarative mapping with BaseModel
BaseModel is the easiest way to get started. It already combines SQLARec's
Active Record behavior with SQLAlchemy's DeclarativeBase. Define models as
regular SQLAlchemy declarative classes:
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlarec import BaseModel, init_engine, new_session
class User(BaseModel):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(255), unique=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
# Set up the database.
engine = init_engine("sqlite:///:memory:")
BaseModel.metadata.create_all(engine)
# Register the session used by model operations.
session = new_session()
BaseModel.register_session_provider(lambda: session)
# Create and persist a user. create() adds and flushes; your app commits.
user = User.create(name="Hamza", email="hamza@example.com")
session.commit()
# Query models directly from the class.
user = User.query.where(User.email == "hamza@example.com").one()
# Change and persist an existing model.
user.name = "Hamza Senhaji"
user.save()
session.commit()
# Delete follows the same transaction rule.
user.delete()
session.commit()
Use this style unless your application already has its own mapping layer or you specifically want to keep domain classes independent from table definitions.
Style 2: imperative mapping with your own registry
ActiveRecordMixin supplies SQLARec behavior without inheriting from
DeclarativeBase. This means a class can contain domain behavior first and be
mapped separately afterward.
1. Define the domain class
from sqlarec import ActiveRecordMixin
class BookingBehaviour:
id: int
reference: str
def display_reference(self) -> str:
return f"Booking {self.reference}"
class Booking(BookingBehaviour, ActiveRecordMixin):
pass
The plain type annotations describe the domain attributes used by the behavior.
At this point, Booking does not choose a table or a mapping strategy.
2. Define and apply the mapping
Use SQLAlchemy's regular imperative mapping API:
from sqlalchemy import Column, Integer, String, Table
from sqlalchemy.orm import registry
mapper_registry = registry()
booking_table = Table(
"bookings",
mapper_registry.metadata,
Column("id", Integer, primary_key=True),
Column("reference", String(100), nullable=False),
)
mapper_registry.map_imperatively(Booking, booking_table)
Because the table columns are named id and reference, SQLAlchemy maps them
to the annotated attributes with the same names. You can use explicit
imperative properties when your attribute and column names differ.
Your application can use any registry, metadata, tables, column properties, or relationships supported by SQLAlchemy. SQLARec does not replace or wrap that configuration.
3. Register a session and use the model
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
engine = create_engine("sqlite:///:memory:")
mapper_registry.metadata.create_all(engine)
session = Session(engine, expire_on_commit=False)
ActiveRecordMixin.register_session_provider(lambda: session)
booking = Booking.create(reference="SQLAREC-1")
session.commit()
found = Booking.query.where(Booking.reference == "SQLAREC-1").one()
print(found.display_reference())
This is still normal SQLAlchemy imperative mapping. The mixin only adds the query and persistence conveniences.
Manage sessions at the application boundary
Register a zero-argument provider that returns the session for the current request, job, command, or task:
BaseModel.register_session_provider(lambda: current_session)
For imperatively mapped models, register it on the mixin:
ActiveRecordMixin.register_session_provider(lambda: current_session)
SQLARec deliberately does not:
- create a session for each operation;
- commit or roll back a transaction;
- decide when a transaction begins or ends;
- close application-owned sessions.
Query and update builders retain the provider rather than a particular session. They resolve the current session only when the statement executes. This makes a context-local provider suitable for request-scoped and task-scoped sessions.
For example, an async application can bind one session to each task:
from contextvars import ContextVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlarec.asyncio import AsyncBaseModel, new_async_session
current_session = ContextVar[AsyncSession]("current_session")
AsyncBaseModel.register_session_provider(current_session.get)
async def database_middleware(request, handler):
async with new_async_session() as session:
token = current_session.set(session)
try:
response = await handler(request)
await session.commit()
return response
except Exception:
await session.rollback()
raise
finally:
current_session.reset(token)
Code inside that boundary can use models without receiving a session argument:
async def find_user(email: str) -> User | None:
return await User.query.where(User.email == email).one_or_none()
The same pattern works with synchronous Session objects.
Query and persist models
The APIs below work with declarative and imperative models. Async methods have the same shape but must be awaited.
Query mapped models
Model.query starts a query that returns instances of that model:
users = User.query.order_by(User.name).all()
active_users = (
User.query.where(User.active.is_(True)).order_by(User.name).limit(20).all()
)
user = User.query.filter_by(email="hamza@example.com").one_or_none()
Result methods are:
all()for every matching model;first()for the first model orNone;one()for exactly one model;one_or_none()for zero or one model.
SQLAlchemy raises its normal result exceptions when one() or one_or_none()
receives an unexpected number of rows.
Select individual values
Pass mapped attributes to select() when you need rows instead of model
instances:
rows = User.select(User.id, User.email).order_by(User.id).all()
email_row = User.select(User.email).where(User.id == 42).one()
mappings = User.select(User.id, User.email).mappings().all()
Model and row queries support immutable builder methods including where(),
filter_by(), order_by(), group_by(), having(), join(), outerjoin(),
limit(), offset(), distinct(), options(), union(), and union_all().
Each call returns a new query, so a base query can safely be reused:
users = User.query.order_by(User.id)
active_users = users.filter_by(active=True)
Find models with helper methods
user = User.get_by_pk(42)
exists = User.exists(42)
users = User.all()
user = User.get_instance_by_keys(email="hamza@example.com")
users = User.filter_by_keys(active=True)
user = User.get_or_create(email="hamza@example.com", name="Hamza")
Models also provide primary-key inspection and serialization helpers such as
get_id(), get_primary_key_name(), get_primary_key_names(), and to_dict().
Create, save, and delete
user = User.create(name="Hamza", email="hamza@example.com")
user.name = "Hamza Senhaji"
user.save()
user.delete()
session.commit()
create(), save(), and delete() flush immediately, which makes generated
identifiers and database errors available to the caller. They do not commit.
The final transaction can still be committed or rolled back as one unit.
Execute bulk updates
result = User.update().where(User.active.is_(False)).values(active=True).execute()
session.commit()
print(result.rowcount)
Use returning() when supported by the database:
updated_users = (
User.update()
.where(User.active.is_(False))
.values(active=True)
.returning(User)
.all()
)
Every query and update wrapper exposes .statement for SQLAlchemy features not
covered by SQLARec. Models expose the resolved session through Model.session.
Use the asynchronous API
The async API mirrors the synchronous API and lives in sqlarec.asyncio:
import asyncio
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from sqlarec.asyncio import (
AsyncBaseModel,
init_async_engine,
new_async_session,
)
class User(AsyncBaseModel):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(255), unique=True)
async def main() -> None:
engine = init_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as connection:
await connection.run_sync(AsyncBaseModel.metadata.create_all)
async with new_async_session() as session:
AsyncBaseModel.register_session_provider(lambda: session)
user = await User.create(name="Hamza", email="hamza@example.com")
await session.commit()
found = await User.query.where(User.id == user.id).one()
found.name = "Hamza Senhaji"
await found.save()
await session.commit()
await engine.dispose()
asyncio.run(main())
For custom imperative mappings, combine your domain class with
AsyncActiveRecordMixin, map it with registry.map_imperatively(), and register
an AsyncSession provider on the mixin. AsyncActiveRecordMixin also includes
SQLAlchemy's AsyncAttrs.
Prefer eager relationship loading in async code. When lazy loading is needed, use SQLAlchemy's awaitable attributes:
items = await booking.awaitable_attrs.items
How the pieces fit together
ActiveRecordMixin
= SQLARec persistence and query capabilities
BaseModel
= ActiveRecordMixin + SQLAlchemy DeclarativeBase
Imperatively mapped model
= domain behavior + ActiveRecordMixin
+ application-owned SQLAlchemy mapping
The async equivalents are AsyncActiveRecordMixin and AsyncBaseModel.
init_engine() and new_session() are optional conveniences. Applications can
use their existing SQLAlchemy engine and session setup with either mapping
style.
Develop the library
Install all development dependencies:
uv sync
| Command | Purpose |
|---|---|
make test |
Run synchronous and asynchronous tests. |
make lint |
Check source and tests with Ruff. |
make typecheck |
Check source and tests with Astral ty. |
make format |
Format source and tests with Ruff. |
make clean |
Remove Python, pytest, and Ruff caches. |
Current limitations
- Sync and async models use separate declarative bases and metadata registries.
- Each concurrent task must use its own
AsyncSession. - Async database drivers are selected and installed by the application.
- Query wrappers cover common operations; use
.statementand the resolved session for advanced SQLAlchemy features.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sqlarec-0.3.0.tar.gz.
File metadata
- Download URL: sqlarec-0.3.0.tar.gz
- Upload date:
- Size: 35.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.4 {"installer":{"name":"uv","version":"0.12.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
255562e58e026fe4ff14efcd7dab9804f346a49972a96010a7adfb0bc3d8a3fa
|
|
| MD5 |
4b91379ec9de9467533c4082084dfe8d
|
|
| BLAKE2b-256 |
11eee1515ebcd06c4033c5bc52a7a3ec3e4847a6aaf0f9e8602fffb189c97f77
|
File details
Details for the file sqlarec-0.3.0-py3-none-any.whl.
File metadata
- Download URL: sqlarec-0.3.0-py3-none-any.whl
- Upload date:
- Size: 20.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.4 {"installer":{"name":"uv","version":"0.12.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
80c59642f3f67f2f76e1259c71f10c1dedb3fa1375dd322a10c0072400c74a24
|
|
| MD5 |
51a389c16f9f0f1607f2f426d60358ff
|
|
| BLAKE2b-256 |
cd1e6a39b2810a9c4aa699a5184368a1f07c3aaeb74ce784cf5e218d4ca5038c
|