What's new in 4.1¶
Minor release. No breaking changes vs 4.0 — every addition is opt-in or zero-cost when unused.
1. CockroachDB backend¶
CockroachDB speaks the PostgreSQL wire protocol, so dorm reuses the psycopg-based pipeline and ships a thin subclass with port 26257 + a serialization-retry helper.
# settings.py
DATABASES = {
"default": {
"ENGINE": "cockroachdb",
"NAME": "defaultdb",
"HOST": "localhost",
"PORT": 26257,
"USER": "root",
"PASSWORD": "",
"OPTIONS": {"sslmode": "disable"},
}
}
Install with pip install 'djanorm[cockroachdb]' (alias for
[postgresql]). vendor stays "postgresql" so every PG-only ORM
feature (CreatePGEnum, copy_from, partitioning, materialised views,
HStoreField, …) keeps working; the new dialect = "cockroachdb"
attribute is the opt-in escape hatch for Cockroach-specific code.
Serialization-failure retry (SQLSTATE 40001 / 40003):
import dorm
from dorm.contrib.cockroach import retry_on_serialization, with_retry
def transfer() -> None:
with dorm.transaction.atomic():
src = Account.objects.select_for_update().get(pk=1)
src.balance -= 100
src.save()
# Functional form:
retry_on_serialization(transfer, max_attempts=5)
# Decorator form (auto-detects async):
@with_retry(max_attempts=5)
def transfer_money(src_id: int, dst_id: int, amount: int) -> None:
with dorm.transaction.atomic():
...
2. AsyncSchemaEditor¶
Async façade over SchemaEditor — every DDL helper offloads to
asyncio.to_thread so a FastAPI / Litestar startup hook can
bootstrap a schema without blocking the event loop.
from dorm.migrations.schema import AsyncSchemaEditor
from dorm.db.connection import get_connection
async def init_schema() -> None:
async with AsyncSchemaEditor(get_connection()) as se:
await se.acreate_model(Article)
await se.aadd_field(Article, "summary", dorm.TextField(null=True))
3. PostgreSQL Row-Level Security migration ops¶
Full RLS policy lifecycle as first-class migration operations.
from dorm.migrations.operations import (
AlterPolicy,
CreatePolicy,
DropPolicy,
EnableRowLevelSecurity,
ForceRowLevelSecurity,
)
operations = [
EnableRowLevelSecurity("articles"),
ForceRowLevelSecurity("articles"),
CreatePolicy(
"p_tenant_isolation",
"articles",
command="ALL",
using="tenant_id = current_setting('app.tenant_id')::int",
check="tenant_id = current_setting('app.tenant_id')::int",
),
]
CreatePolicy rejects SELECT/UPDATE/DELETE/ALL policies without an
explicit using= (and INSERT without check=) so a silent default
predicate can't slip into a security-sensitive migration. All ops
are PostgreSQL-only and no-op on every other backend.
4. PII field tagging¶
class User(dorm.Model):
email = dorm.EmailField(pii=True)
full_name = dorm.CharField(max_length=120, pii=True)
username = dorm.CharField(max_length=40, unique=True)
The flag has no effect on the SQL schema. Consumers:
from dorm.contrib.pii import (
anonymize_row,
has_pii_fields,
mask_dict,
mask_instance,
pii_fields,
)
pii_fields(User)
# [<EmailField: email>, <CharField: full_name>]
mask_instance(user)
# user.email == "[REDACTED]", user.full_name == "[REDACTED]"
anonymize_row(user) # mask + .save() for GDPR right-to-be-forgotten
Enable audit-trail masking for @track_history models:
5. PgBouncer transaction-pool compatibility¶
PgBouncer in transaction-pool mode reuses backend connections across
client transactions, so server-side prepared statements survive past
the client that allocated them. The new PGBOUNCER_MODE setting
disables them on every connection.
DATABASES = {
"default": {
"ENGINE": "postgresql",
"NAME": "app",
# True, "transaction", or "statement" — disables psycopg's
# server-side prepared-statement cache.
"PGBOUNCER_MODE": "transaction",
}
}
The setting overrides any explicit PREPARE_THRESHOLD and logs a
warning when it does.
6. ASGI middleware¶
Three pure-ASGI-3 middlewares, framework-agnostic:
from fastapi import FastAPI
from dorm.contrib.asgi import (
NPlusOneMiddleware,
OTelDormMiddleware,
QueryBudgetMiddleware,
)
app = FastAPI()
app.add_middleware(NPlusOneMiddleware, threshold=10)
app.add_middleware(QueryBudgetMiddleware, timeout_ms=2000, max_rows=10_000)
app.add_middleware(OTelDormMiddleware)
Order matters — the outermost (last add_middleware) sees the
request first. OTel goes outermost so every later span (budget,
N+1 detector, dorm queries) is parented under the request span.
Each middleware bypasses lifespan / websocket scopes — those
aren't request-scoped, so wrapping them in a per-request budget
would be wrong.
7. Litestar plugin¶
from litestar import Litestar
from dorm.contrib.litestar import dorm_plugin
app = Litestar(
plugins=[
dorm_plugin(
budget_timeout_ms=2000,
nplusone_threshold=10,
warmup_pool=5, # opens 5 connections at startup
)
],
)
Wires the three ASGI middlewares via Litestar's
DefineMiddleware and registers async startup / shutdown hooks
that pre-warm the connection pool and drain it cleanly on shutdown.
Install with pip install 'djanorm[litestar]'.
8. connection_for(alias) / aconnection_for(alias)¶
Public handle for the per-alias backend wrapper, mirroring Django's
connections['default']:
import dorm
conn = dorm.connection_for("replica")
rows = conn.execute("SELECT 1")
aconn = await dorm.aconnection_for("replica")
rows = await aconn.execute("SELECT 1")
Replaces the previously private dorm.transaction._connection_for.
9. QuerySet.bulk_update_when / abulk_update_when¶
Compile a list of (Q, {field: value}) cases into a single
UPDATE … SET col = CASE WHEN … THEN … END WHERE … statement.
from dorm.expressions import Q
Article.objects.filter(published=True).bulk_update_when(
[
(Q(score__gte=90), {"label": "A", "featured": True}),
(Q(score__gte=70), {"label": "B"}),
],
default={"label": "C", "featured": False},
)
Without default, a field's column is preserved (ELSE col) for
rows that no condition matches. QuerySet.update also accepts
Case expressions directly for callers who prefer the manual form.
10. QueryLog dumps¶
Export captured queries to JSON, JSONL, or Parquet for replay / analysis pipelines:
from dorm.contrib.querylog import QueryLog
with QueryLog() as log:
do_work()
log.dump_json("queries.json")
log.dump_jsonl("queries.jsonl") # memory-bounded streaming
log.dump_parquet("queries.parquet") # requires djanorm[parquet]
Parameters are excluded by default — pass include_params=True on
each helper to opt in, and pair with
dorm.contrib.pii.mask_dict(Model, row) for a per-column redaction
pass before persisting.
See the project CHANGELOG for the full list of changes. Upgrading from 4.0 → 4.1 needs no code changes.