SQLAlchemy (edri.integrations.sqlalchemy)¶
Optional, first-party support for SQLAlchemy ORM models: explicit response projections, safe session ownership across EDRI's multiprocessing boundary, model serialization, strict filter/sort builders, foreign-key injection, and a compatibility JSON encoder for simpler detached-model applications.
Prerequisites¶
pip install edri[sqlalchemy]
SQLAlchemy is an optional dependency of EDRI — it is declared as an extra in
pyproject.toml. The integration module guards its import, so a bare
ModuleNotFoundError is replaced with a clear message naming the extra.
from edri.integrations.sqlalchemy import (
ForeignKeyValidator,
SQLAlchemyManager,
SQLAlchemyModelMixin,
SQLAlchemyResponseMiddleware,
orm_projection,
orm_validator,
projection,
)
Projected ORM responses¶
Use projections when an API lets clients select fields or when ORM responses cross a Manager/router process boundary. A projection is an explicit contract: only declared columns and relationships may be requested, SQLAlchemy loads the whole graph before the session closes, and the broker converts it to transport values without issuing SQL.
The flow is:
request fields -> projection() -> ORM query -> Manager finalization/detach
-> router queue -> SQLAlchemyResponseMiddleware -> JSON
Model base¶
Put SQLAlchemyModelMixin before DeclarativeBase. __json__ defines the
shape used only on the non-projected path — bare ORM responses and
SessionJSONEncoder; a model used exclusively through projections does not
need it. Override _json_extra() only for derived values or request-specific
access rules:
from edri.integrations.sqlalchemy import SQLAlchemyModelMixin
from sqlalchemy.orm import DeclarativeBase
class Base(SQLAlchemyModelMixin, DeclarativeBase):
pass
class User(Base):
__tablename__ = "user"
__json__ = ("id_user", "name", "organization") # optional; non-projected path only
# mapped_column(...) and relationship(...) declarations
Response declaration¶
Declare the projection on a normal dataclasses.field. EDRI preserves its
default/factory and metadata natively:
from dataclasses import field
from edri.dataclass.response import Response, response
from edri.integrations.sqlalchemy import orm_count, orm_projection
@response
class UserListResponse(Response):
users: list[User] = field(
default_factory=list,
metadata=orm_projection(
source="columns",
default=("idUser", "name"),
allowed=(
"idUser",
"name",
"organization.idOrganization",
"organization.name",
),
dependencies=("idOrganization",),
functions=(orm_count("sessions"),),
),
)
source names the Event field carrying requested public paths. default is
used when that field is empty; with no default, an empty request emits the
whole allowed set. allowed is the complete public allowlist, and
dependencies are loaded but not emitted. The projection is a self-contained
contract — it does not consult the model's __json__. Public camel-case paths
are resolved strictly against mapped attributes. An explicit request for an
unknown path, scalar traversal, a relationship not in the allowlist, or a
function not declared in functions raises ProjectionError.
orm_count("sessions") whitelists the exact request count(sessions) and
adds a correlated scalar subquery. It does not load the collection, add a root
GROUP BY, or introduce one query per row.
Self-referential relationships can be repeated with recursive=("parent",).
Recursion activates only when the relationship path itself is selected and is
capped at 64 loader levels. Repeated identities serialize as their primary-key
shape, preventing cyclic output.
Query execution¶
Apply the projection before execution. Use the supplied materializers so any computed projection values are retained:
from sqlalchemy import select
from edri.integrations.sqlalchemy import projection, projection_all
statement = projection(
select(User).order_by(User.name),
event=event,
field="users",
)
event.response.users = projection_all(
session,
statement,
response=event.response,
field="users",
)
Use projection_one() for exactly one model and projection_rows() when the
statement intentionally selects additional columns. Projection loader plans use
load_only(..., raiseload=True), wildcard relationship raiseload, and nested
selectinload; omitted state therefore fails explicitly instead of silently
causing lazy SQL.
Manager and broker registration¶
Managers returning ORM values inherit from SQLAlchemyManager. It keeps
registered sessions alive for one resolution, validates/completes projected
graphs, and detaches them before multiprocessing. For another Manager variant,
put SQLAlchemyManagerMixin first in the inheritance list.
from edri.integrations.sqlalchemy import (
SQLAlchemyManager,
SQLAlchemyResponseMiddleware,
)
class UserManager(SQLAlchemyManager):
...
edri.start_api(middlewares=[SQLAlchemyResponseMiddleware(), ...])
SQLAlchemyResponseMiddleware runs on the broker side. It validates the whole
response before mutation, calls the model mixin with the resolved plan, and
never opens a session or reloads state. Missing plans, unloaded fields, and
cycles raise IncompleteProjectionError with the failing response path.
Create application sessions with ResponseAwareSession (usually through
sessionmaker(class_=ResponseAwareSession, expire_on_commit=False)). Calls to
close() during an active Manager resolution are deferred until projected
responses have been finalized.
ORM Events¶
ORMEvent and @orm_event identify fields injected through
ForeignKeyValidator and merge their detached values into a handler's session.
The merge session is automatically registered with the current resolution:
from dataclasses import dataclass
from edri.integrations.sqlalchemy import ORMEvent, ForeignKeyValidator, orm_event
from edri.utility.function import inject
@orm_event
@dataclass
class UpdateUser(ORMEvent):
user: inject(ForeignKeyValidator[User], session_factory=SessionLocal)
# inside the handler
event.merge(session, load=False)
Validators derived from ORM columns¶
orm_validator() is a validator factory for request fields. It reads one
mapped column when the event class is declared and returns a one-argument
validator callable for inject(...):
from edri.integrations.sqlalchemy import orm_validator
from edri.utility.function import inject
@api(url="/users", method=HTTPMethod.POST)
class CreateUser(Event):
name: inject(orm_validator(User.name))
nickname: inject(orm_validator(User.nickname)) | None = None
response: CreateUserResponse
For User.name = mapped_column(String(100)), the generated callable uses
StringValidator(maximum_length=100). Changing the ORM length therefore also
changes request validation without repeating 100 in the event. The factory
also selects the matching integer, float, date, time, or datetime validator,
and falls back to the column type's Python type through normal EDRI coercion
for types without a dedicated validator (UUID, Decimal, bool).
A SQLAlchemyEnum("public", "private") column restricts the field to those
strings — the allowed values come from the column, not a repeated
Literal[...] in the event. A column mapped to a Python enum.Enum needs
nothing from the factory: it is coerced against that class (SomeEnum(value),
matched by member value), the same as a plain field: SomeEnum annotation.
Keyword arguments are forwarded to the derived validator and take precedence over anything read from the column:
name: inject(orm_validator(User.name, maximum_length=64, minimum_length=2))
A keyword the underlying validator does not accept, or any keyword on a
column with no dedicated validator, is rejected when the event class is
declared. Overrides go to orm_validator(), not to inject() — a keyword
passed to inject() around the generated callable is filtered out and has
no effect.
Nullability remains explicit in the event because database nullability and
request-field optionality are separate contracts. Uniqueness and foreign keys
also remain database/session concerns; use ForeignKeyValidator when an input
id must load a related model.
Strict query builders¶
FilterBuilder turns parallel client filter arrays into typed SQLAlchemy
expressions. Dotted paths use correlated has()/any() expressions; they do
not guess foreign keys or association objects. Negative operators (not,
doesNotContain, isNoneOf, notBetween, isEmpty, …) match NULL values:
on a scalar column they add OR … IS NULL, and on a dotted path they negate the
positive EXISTS. So owner.name not "Acme" also matches rows without an
owner, and items.name not "Acme" means that no item is named Acme.
Configure unknown paths with
unknown_field_policy="ignore", "log", "raise", or "collect".
SortBuilder.build() resolves scalar sort paths. apply_sorting() then adds
the required aliased outer joins and ORDER BY clauses. Keeping resolution and
statement mutation separate makes accepted client paths auditable.
ForeignKeyValidator[T]¶
An inject(...) validator that converts a raw value (typically a URL path
segment) into a loaded ORM model instance. The target model type is taken from
the subscript (ForeignKeyValidator[User]), and the database session is
supplied per field via session_factory=:
from edri.integrations.sqlalchemy import ForeignKeyValidator
from app.db import SessionLocal # a sessionmaker
from app.models import User
@api(url="/users/{user}", method=HTTPMethod.GET)
class GetUser(Event):
user: inject(ForeignKeyValidator[User], session_factory=SessionLocal)
response: GetUserResponse
The validator:
Reads the model's primary-key type from the SQLAlchemy mapper and coerces the raw value to it —
int,uuid.UUID,strand similar scalar keys all work. A value that cannot be coerced raisesValueError(which EDRI maps to a validation failure); a model with a composite primary key is unsupported and also raisesValueError.Opens a short-lived session via
session_factory().Calls
session.get(model, pk).Raises
ValueErrorif no row is found.
The result is the loaded ORM instance, available as self.user inside the
handler — no manual lookup code needed.
Because the session is opened and closed inside the validator call, the
returned model instance is detached by the time the handler runs. For read
operations this is rarely a problem (all column values were loaded by
session.get). If you need lazy relationships or further database access,
see SessionJSONEncoder below for serialization, or open a longer-lived
session in your handler.
See Validation & Injection for the general validator pattern.
SessionJSONEncoder (compatibility mode)¶
Note
Prefer the projection flow above for new code. SessionJSONEncoder opens a
database session and issues SQL from the broker at serialization time, once per
mapped instance; it exists for existing applications and for simple single-node
cases where that cost is acceptable. It is not the path for selective fields or
for responses that cross a process boundary.
A JSON encoder that re-attaches detached ORM instances to a fresh session before serializing them. This is necessary because a model instance loaded in one session and then returned in a response is detached by the time EDRI renders the payload — accessing a lazy relationship or an expired column would fail.
SessionJSONEncoder extends CustomJSONEncoder. Only mapped ORM instances
that expose a to_json() method are intercepted; everything else falls
through to the base class encoding (dataclasses, enums, UUIDs, …).
Usage¶
Subclass SessionJSONEncoder and implement get_session() to return a
context-managed Session, then pass the subclass to
edri.start_api(json_encoder=...):
from edri.integrations.sqlalchemy import SessionJSONEncoder
from app.db import SessionLocal # a sessionmaker
class AppJSONEncoder(SessionJSONEncoder):
def get_session(self):
return SessionLocal()
edri.start_api(json_encoder=AppJSONEncoder)
How it works¶
When the encoder encounters an object that has a to_json() method and
is a mapped SQLAlchemy ORM instance (detected via object_mapper — base
class agnostic), it:
Opens a session via
self.get_session().Merges the detached instance into that session (
session.merge(data, load=True)).Calls
merged.to_json(), which can safely access lazy relationships and expired columns.Closes the session.
Every mapped instance in the response therefore costs one session and at least one round trip, from the broker. The projection flow above does this work once in the Manager and leaves broker-side serialization free of database access; use it whenever responses select fields or cross a Manager/router boundary.
The encoder is invoked per field, but the session is opened and closed for each
mapped instance individually — there is no long-lived session shared across the
entire response. For more control, use self.context (populated from
Event.get_context()) to influence session creation per request.
Per-request session via context¶
The encoder's self.context dictionary is set from the event's keyword-only
fields (see Event.get_context()). If your SessionJSONEncoder subclass
needs a request-scoped session (for example one that carries the authenticated
user's tenant filter), read self.context inside get_session():
class TenantAwareEncoder(SessionJSONEncoder):
def get_session(self):
tenant_id = (self.context or {}).get("tenant_id")
return SessionLocal(tenant_id=tenant_id)
See also¶
Validation & Injection —
ForeignKeyValidator,StringValidator, and custom validatorsJSON encoding —
CustomJSONEncoderbase class, encoder context, and the general encoding patternWriting your own integration — how to add a new optional integration to EDRI