v3.0.0
Major release. Combines three originally-planned minors (2.6 / 2.7 / 2.8)
plus the Django-parity sprint that was going to ship as 3.1. No
breaking changes vs 2.5: every addition is opt-in or zero-cost
when unused.
The Django-parity additions close the last π΄ gaps that would have
blocked a Djangoβdorm migration: timezone-aware datetimes,
proxy models, dates() / datetimes(), migrate --fake,
JSONField PG operators, Field.deconstruct(), Model.from_db(),
manual savepoint API, Meta.permissions, password-reset tokens,
multi-tenant context manager, and the MySQL / MariaDB scaffold +
vector support.
Django-parity additions (was going to ship as 3.1)
Added β settings.USE_TZ actually wired up
DateTimeFieldreadssettings.USE_TZ(defaultFalse).
WhenTrue, naive datetimes are interpreted in
settings.TIME_ZONEand converted to UTC for storage. PostgreSQL
columns becomeTIMESTAMP WITH TIME ZONEso the engine
preserves the offset; SQLite stores UTC-isoformat.from_db_valuealways returns tz-aware datetimes when
USE_TZis on, normalised to UTC. Cross-row comparisons stay
honest regardless of the writer's local zone.settings.TIME_ZONEresolves throughzoneinfo.ZoneInfo;
unrecognised values fall back to UTC silently.
Added β Meta.proxy = True
- Proxy models share their concrete parent's table (no migration,
no duplicate columns) and inherit field instances directly so a
proxy's_meta.fieldspoints at the same descriptors. The
autodetector skips them; only the concrete parent emits a
CreateModel. Options.concrete_modelresolves to the closest non-proxy
ancestor whose table backs the rows β useful for libs that need
to know "where do this instance's bytes live".
Added β QuerySet.dates() / QuerySet.datetimes()
- Returns
list[date]/list[datetime]of distinct
truncated values for field. Truncation happens in Python (the
queryset compiler's annotation+distinct combo isn't ready for the
SQL-level shape yet); chainable variants land alongside the
compiler refactor in v4. dates()accepts"day","week","month",
"year".datetimes()adds"hour"/"minute"/
"second". Order argument respects"ASC"/"DESC".
Added β dorm migrate --fake / --fake-initial
--fakemarks every pending migration as applied without
running its operations. Use when adopting dorm against a hand-
managed legacy database β the schema already matches and you
just want the recorder synced.--fake-initialonly fakes each app's initial migration
(no dependencies), and only when everyCreateModelit
contains targets a table that already exists. Subsequent
migrations run for real. Mirrors Django's flag of the same
name.MigrationExecutor.migrate(... fake=, fake_initial=)exposes
the same shape programmatically.
Added β JSONField / ArrayField PG operators
- New lookup names matching Django's contrib.postgres spellings:
__contained_by(<@),__has_key(?),
__has_keys(?&),__has_any_keys(?|),
__overlap(&&),__len(array_length(col, 1)).
Existing__array_contains/__array_overlap/
__json_has_*aliases stay for users who already wrote them.
Added β Field.deconstruct()
- Default base-class implementation returns
(name, dotted_path, args, kwargs)and emits only kwargs that
differ from the framework defaults. Custom field classes get
serialisation in the migration writer for free; subclasses with
extra constructor parameters override and callsuper().
Added β Model.from_db(db, field_names, values)
- Django-parity classmethod for custom hydration logic. Default
implementation zips the parallel lists and stamps the resulting
_state.dbwith the alias the row came from. Libs that hook
through it (history-tracking, soft-delete extensions) keep
working when migrated from Django.
Added β dorm.contrib.auth.tokens
- Stateless HMAC-signed reset tokens, framework-agnostic. The
signature binds to the user'slast_login/password/
emailso a single use of the token (which changes the
password) invalidates every outstanding URL. PasswordResetTokenGeneratorwith configurable
timeout(default 24h) andsalt_namespace(domain
separation between password-reset / email-verification / etc.).- Stateful alternative
generate_short_lived_tokenfor cases
where the caller needs a one-row revoke list.
Added β Meta.permissions + dorm.contrib.auth.management.sync_permissions
Meta.permissions = [(codename, name), ...]declares custom
per-model permissions. Default verbs (add,change,
delete,view) get auto-emitted per concrete model.sync_permissions()materialises the declarations into rows
in theauth_permissiontable. Idempotent β safe to call
every deploy.
Added β transaction.savepoint / savepoint_commit / savepoint_rollback
- Manual savepoint API for branching / rolling back inside a
singleatomic()block without unwinding it. Returns a
random savepoint ID (s_<hex>) that's safe to splice into
SQL; callers that pass arbitrary strings getValueErrorto
prevent SQL injection through the savepoint name.
Added β MySQL backend scaffold
ENGINE = "mysql"parses throughparse_database_urland
the connection wrappers raiseImproperlyConfiguredpointing
at the v3.2 milestone. Lets users pin on a forward-compatible
config string today.
Added β Multi-tenant dorm.contrib.tenants
TenantContext(name)/aTenantContext(name)set
PostgreSQL'ssearch_pathto<schema>, publicfor the
duration of a context. Per-task isolation viaContextVarβ
concurrent FastAPI / Starlette requests routed to different
tenants don't bleed.current_tenant()lookup forDATABASE_ROUTERS/
middleware.register_tenant(name)for the future migration
runner. Schema names validated against the same identifier
regex used elsewhere β no SQL injection through tenant names.
Tests
tests/test_v3_1_use_tz.pyβ datetime round-trip with
USE_TZon/off, naiveβaware conversion, PG TIMESTAMPTZ
selection.tests/test_v3_1_proxy.pyβ proxy inheritance shares table,
proxy skipped from migration state,concrete_modelwiring.tests/test_v3_1_dates.pyβ round-trip distinct truncation
for every kind, order ASC/DESC, kind / order validation.tests/test_v3_1_migrations_fake.pyβfakeflag records
without running,fake_initialonly fires when table exists.tests/test_v3_1_jsonfield.pyβ new lookup names compile to
the right PG operator.tests/test_v3_1_deconstruct.pyβ baseField.deconstruct
returns sane shape and round-trips throughcls(**kwargs).tests/test_v3_1_from_db.pyβ alias stamped on_state.db,
hydration matches column / attname mapping.tests/test_v3_1_auth_tokens.pyβ make/check round-trip,
password-change invalidation, timeout enforcement, namespace
separation, malformed input handling.tests/test_v3_1_permissions.pyβ default permissions
per concrete model, custom Meta entries, idempotence,
abstract / proxy skip.tests/test_v3_1_savepoint.pyβ savepoint commit / rollback
round-trip, invalid id rejection.tests/test_v3_1_tenants.pyβ schema validation, context
state restoration, non-PG backend rejection.
Added β settings.SLOW_QUERY_MS
- New setting (default
500.0ms). When an executed statement
takes longer than this threshold, the
dorm.db.backends.<vendor>logger emits aWARNINGline
with the SQL text and elapsed time. Resolution order: explicit
configure(SLOW_QUERY_MS=β¦)> env var
DORM_SLOW_QUERY_MS> default500.0. SLOW_QUERY_MS=Nonedisables the warning entirely β the
threshold comparison itself is skipped, so on-path cost
collapses to the timing already collected for the query
signals.SLOW_QUERY_MS=0logs every query as slow β useful in
development to surface every SQL statement at WARNING level
without flipping thedorm.queriesDEBUG stream on.- The threshold is memoised once after
configure(...);
subsequentconfigure(SLOW_QUERY_MS=β¦)calls invalidate
the memo so a runtime swap takes effect on the next query.
The env-var / default branch is intentionally not memoised so
testmonkeypatch.setenvworkflows keep observing the
current value without an explicit cache flush.
Added β settings.RETRY_ATTEMPTS / settings.RETRY_BACKOFF
- Same resolver shape as
SLOW_QUERY_MS: explicit setting >
env var (DORM_RETRY_ATTEMPTS/DORM_RETRY_BACKOFF) >
default (3 attempts / 0.1 s). The retry loop in
with_transient_retry/awith_transient_retrynow
reads the resolved value on every call so a runtime
configure(...)swap takes effect without a restart. - Settings-derived values are memoised; env-var path re-reads
each call so testmonkeypatch.setenvkeeps working.
Added β query-count guard (dorm.contrib.querycount)
- New
query_count_guard(warn_above=β¦, label=β¦)context
manager. Counts queries inside the block viapre_query
and emits a singleWARNINGwhen the count crosses the
threshold.warn_abovefalls back to
settings.QUERY_COUNT_WARN(defaultNone, inert). - Per-task isolation via
ContextVarβ concurrent ASGI
requests / asyncio tasks get independent counters.
Added β dorm.test.assertNumQueries / assertMaxQueries
- Django-parity helpers. Context-manager forms assert exact
/ β€-N query counts on exit; decorator forms
(assertNumQueriesFactory(N),assertMaxQueriesFactory(N))
wrap a sync orasync deftest function. Both use the same
ContextVar-based listener as the query-count guard so
async tests don't bleed counters across tasks.
Added β sticky read-after-write window
settings.READ_AFTER_WRITE_WINDOW(default3.0
seconds). After a write throughrouter_db_for_write, the
router pins reads of the same model on the same context to
the primary alias for the configured window β so a request
that writes and immediately re-reads sees its own change
instead of a stale replica row.0/Nonedisables.- Sticky state lives in a
ContextVar, so concurrent ASGI
requests / asyncio tasks see independent windows. - New helper
dorm.db.connection.clear_read_after_write_window
for tests / middleware that want a clean window per request. - Pass
sticky=Falsethrough the router hints (e.g.
Model.objects.using("replica", sticky=False)) to opt out
of the pin for analytics queries that explicitly want the
replica.
Added β dorm lint-migrations
- New CLI command +
dorm.migrations.lintAPI. Walks every
migration inINSTALLED_APPSand emits findings for known
online-deploy footguns. Exits non-zero on findings β wire as
a CI gate. - Rules:
DORM-M001AddField NOT NULL with default
(full-table backfill),DORM-M002AlterField (review type
vs. flag-flip),DORM-M003AddIndex without
concurrently=True(PG ACCESS EXCLUSIVE lock),
DORM-M004RunPython withoutreverse_code
(irreversible). --format=jsonfor machine-parseable output,--rule DORM-M00Xto filter,--exit-zerofor advisory CI runs.
Suppress per-file with# noqa: DORM-M00X.
Added β dorm migrate --plan
- Alias for the existing
--dry-runflag, mirroring
Django's command name. Prints the SQL that would be
executed without touching the database. The migration
recorder is NOT updated.
Added β request-scoped query collector (dorm.contrib.querylog)
QueryLogcontext manager captures every SQL statement
inside a block:sql/params/alias/
elapsed_ms/error.summary()groups by SQL
template (placeholders normalised to?) and returns
list[TemplateStats]with count / total / p50 / p95
timings.QueryLogASGIMiddlewarewraps any ASGI app; the per-request
log lands onscope["dorm_querylog"]for downstream
handlers / middlewares to inspect.
Added β LocMemCache cache backend (dorm.cache.locmem)
- Thread-safe
OrderedDict-backed LRU. Same
:class:BaseCachecontract asRedisCacheβ sync + async
helpers,delete_patternfor signal-driven invalidation,
OPTIONS["maxsize"]cap. - Useful for tests, single-process scripts, or as a layer in
front of Redis. NOT shared across worker processes.
Added β single-row cache (Manager.cache_get)
Model.objects.cache_get(pk=β¦, timeout=β¦, using=β¦)reads
through the configured cache before falling through to the
DB. Uses the per-model invalidation version so a
post_saveon the model bumps both queryset-cache entries
and row-cache entries in lock-step.cache_get_many(pks=[β¦])β batch read; misses collapse
into oneWHERE pk IN (...)query and write back to
cache.- Async parity via
acache_get/acache_get_many. Cache
miss / outage falls through silently β sametry / except
policy asRedisCache.
Added β dorm.contrib.auth
User/Group/Permissionmodels β framework-agnostic.
Provides only the data model and password-hashing helpers; login
views, sessions and middleware are NOT included (that's the web
framework's job).- Password hashing via stdlib
hashlib.pbkdf2_hmacwith format
pbkdf2_sha256$<iterations>$<salt>$<hash>β same shape Django
emits, so passwords migrate cleanly between the two ORMs. No
passlib/bcrypt/argon2dependency. UserManager.create_user/create_superuserensure
passwords land hashed instead of stored in the clear.user.set_password()/check_password()/has_perm()
with both sync and async (ahas_perm) variants. Group-based
permission checks walk forward M2M only β no reverse-M2M
traversal that would break across vendors.
Added β dorm.contrib.asyncguard
enable_async_guard(mode="warn"|"raise"|"raise_first")connects
topre_queryand walks the call stack β sync ORM calls
inside a running event loop trigger the configured action; async
ORM calls stay silent. Frame walking distinguishes the two paths
by looking forasync defframes inside thedormpackage.SyncCallInAsyncContextinherits from :class:BaseException
so it bypasses the dispatcher'sexcept Exceptionand
surfaces as a 500, not a logged-and-swallowed warning.- Recommended for development / test environments β disabled by
default in production.
Added β DB function corpus
- Math:
Power,Sqrt,Mod,Sign,Ceil,Floor,
Log,Ln,Exp,Random. - Util:
NullIf. - String:
Trim,LTrim,RTrim. - All cross-backend on PG and SQLite β₯3.35 (Python 3.11+ ships
recent enough libsqlite3 by default). MySQL coverage lands with
the v3.1 backend.
Added β Meta.managed = False
- Models with
managed = Falseare skipped by
ProjectState.from_appsβmakemigrationsno longer emits
aCreateModelfor tables the user marks as externally
managed (legacy schema, view, foreign data wrapper). - Runtime queries / saves keep working unchanged β only the
migration emission path is affected.
Added β async-aware dorm shell
- The fallback REPL (when IPython is absent) now compiles input
withPyCF_ALLOW_TOP_LEVEL_AWAITso
await Article.objects.aget(pk=1)works directly.--no-async
reverts to the classic stdlib REPL.
Added β benchmark suite skeleton
bench/run.pyβ stdlib-only microbenchmark runner.
Scenarios:create,bulk_create,get,filter_count,
list_first_n. JSON output suitable for committing under
bench/results/and charting later.tests/test_v2_7_bench_smoke.pyβ ensures the runner survives
one end-to-end sqlite run with the smallest parameters.
Added β dorm.contrib.encrypted
EncryptedCharFieldandEncryptedTextFieldstore
ciphertext on disk and decrypt transparently on read. Backed by
AES-GCM via the optionalcryptographypackage
(djanorm[encrypted]).- Deterministic mode (default) keeps equality lookup working β
same plaintext produces the same ciphertext via an
HMAC-derived nonce. Switch todeterministic=Falsefor
random-nonce indistinguishability when equality lookups aren't
required. - Key rotation:
settings.FIELD_ENCRYPTION_KEYSaccepts a list
of keys (newest first); decryption tries each in order so old
rows keep decrypting after a primary-key roll. - Tampered ciphertext is rejected with a clear
ValueError
rather than silently returningNoneβ better to surface the
bug than mask it. - New settings:
FIELD_ENCRYPTION_KEY(single-key form) and
FIELD_ENCRYPTION_KEYS(list-form for rotation).
Added β dorm.contrib.prometheus
- Stdlib-only Prometheus text-exposition exporter β no
prometheus_clientdependency. Connects topost_queryto
emitdorm_queries_total(counter),dorm_query_duration_seconds
(histogram), plus optional pool / cache gauges. install()attaches the listener;uninstall()removes it
and resets state. Idempotent so a FastAPI lifespan can wire it
without ceremony.record_cache_hit(alias)/record_cache_miss(alias)
helpers for custom cache backends that want their numbers in the
exposition.
Changed β Settings tracks explicit overrides
- New private attribute
Settings._explicit_settingsrecords
the names of settings the user passed toconfigure(...).
Resolvers can now distinguish a class-level default (apply
env-var or built-in fallback first) from an explicit user
choice (always wins). Used by every memoised setting (slow
query, retry knobs, β¦) β future resolvers register one
MemoizedSettinginstance and the central registry handles
the rest.
CLI
dorm inittemplate now scaffolds (commented-out by default
except forSLOW_QUERY_MS) every new 3.0 knob:
RETRY_ATTEMPTS,RETRY_BACKOFF,QUERY_COUNT_WARN,
READ_AFTER_WRITE_WINDOW,DATABASE_ROUTERSexample,
aCACHESblock with both Redis and LocMemCache examples,
andFIELD_ENCRYPTION_KEYplaceholder.dorm lint-migrationsregistered as a top-level subcommand
with--format/--rule/--exit-zero/--settings
flags.dorm migrate --planaccepted as an alias for--dry-run.
Documentation
docs/production.{en,es}.mdβ new subsections under
Observability coveringSLOW_QUERY_MS,
RETRY_ATTEMPTS/RETRY_BACKOFF,QUERY_COUNT_WARN,
READ_AFTER_WRITE_WINDOWand the request-scoped
QueryLogcollector. New top-level Migration safety
section documentingdorm lint-migrationsrules.docs/cli.{en,es}.mdβ newdorm lint-migrationssection
with--rule/--exit-zero/--formatflags.
dorm migrateflag table now lists--planas the
Django-style alias for--dry-run.docs/cache_redis.{en,es}.mdβ newLocMemCache
configuration block andManager.cache_get(pk=β¦)/
cache_get_many(pks=[β¦])row-cache subsection (sync + async).docs/cookbook.{en,es}.mdβ testing fixtures section grew
anassertNumQueries/assertMaxQueriesentry covering
the context-manager and decorator forms (sync + async).docs/migration-from-django.{en,es}.mdβ new
You don't needasgirefsection with a Djangoβdorm
cheatsheet (everysync_to_async(qs.X)has a nativeaX
counterpart) plus call-outs for the optionalcontrib.auth,
contrib.encryptedandcontrib.prometheusmodules.
Internal
dorm._memoized_setting.MemoizedSettingβ central registry
for thesettings β env β defaultresolver pattern. New
per-call knobs register one instance andconf.configure's
invalidation pulse fans out automatically (no per-knob
if "X" in kwargs:block).dorm._scoped.ScopedCollectorβ shared primitive for
ContextVar-based per-task signal collectors. Used by
query_count_guard,assertNumQueriesandQueryLog.LocMemCachecarries a secondarydefaultdict[prefix, set]
index βdelete_pattern("ns:*")is now O(matches) instead of
O(n).- Sticky read-after-write window upgraded from copy-on-write to
lazy-copy-then-mutate: each task takes one private dict on
first write, then mutates in place for the rest of the request. QueryRecordandTemplateStatsare now@dataclass(slots=True).
QueryLog.summary()returnslist[TemplateStats];
consumers that read dict keys should switch to attribute access
or call.to_dict().Settings._explicit_settingslives on the instance (set in
__init__) instead of as a class-level mutable default.- Per-query counters in
query_count_guardand
assertNumQueriesmutate a single-elementlist[int]in
place instead of callingContextVar.setper query β saves
oneTokenallocation per signal.
Audit fixes (pre-publish)
QueryRecord.aliasβQueryRecord.vendor. Thepost_query
signal carriessender=vendor, never analiaskey, so the
field had been silently storing the vendor name under a misleading
label.QueryRecord.to_dict()likewise emitsvendornow.- Prometheus exposition no longer emits an empty
alias=""
label β counters group by(vendor, outcome), histograms by
vendor. If a future release threads the alias through
log_querythe label will come back; until then it isn't
there to confuse scrapers. ScopedCollectorforwardssendertoon_event. The
dispatcher peelssenderoff as a positional arg before
**kwargs, so receivers built onScopedCollector
(querylog, querycount,assertNumQueries) were getting an
empty kwargs payload for the vendor name. The collector now
mergessenderback into the kwargs dict it passes to
on_event.LocMemCacheprefix index ignores keys without:. A
delete_pattern("foo:*")would previously evict a bare
"foo"key because both shared the same prefix bucket. Keys
without a namespace separator now sit outside the index and
only get reached by the slow-pathfnmatchscan when a glob
actually matches them.UserManager.create_userraisesRuntimeError(not a
bareassert) when the manager isn't attached to a model.
python -Ostripsassert, so the previous shape would
have crashed with a confusingAttributeErrorunder
optimised builds.Settings.FIELD_ENCRYPTION_KEYSlives on the instance (set
in__init__) instead of as a class-level mutable default.
Singleton-style usage isn't affected; a future test that builds
a secondSettings()won't inherit a shared list.
Tests
tests/test_slow_query_setting_v2_6.pyβSLOW_QUERY_MS
resolution order,None-disables behaviour,
configure-driven cache invalidation,cacheableflag.tests/test_v2_6_features.py+test_v2_6_audit_fixes.pytest_v2_6_improvements.pyβ query-count guard,
assertNumQueries, sticky window, lint rules, querylog,
LocMemCache, row-cache, audit-fix regression tests, and the
MemoizedSetting/ScopedCollectorhelper coverage.
tests/test_v2_7_auth.pyβ password-hashing helpers + User /
Group / Permission round-trip, key reuse, salt entropy,
superuser-flag enforcement, group-permission resolution.tests/test_v2_7_asyncguard.pyβ guard activation modes,
warn dedup, sync-context inertness, listener teardown.tests/test_v2_7_functions.pyβ SQL-shape pinning for every
new function and re-export check againstdorm.__all__.tests/test_v2_7_parity.pyβin_bulk,
Manager.from_queryset,Prefetch(to_attr=β¦)and
Meta.managed = Falseround-trips.tests/test_v2_7_bench_smoke.pyβ benchmark runner survives
an end-to-end sqlite run.tests/test_v2_8_encrypted.pyβ round-trip, deterministic /
random nonce semantics, key rotation, tamper detection,
missing / invalid key handling, field-level prep / from_db_value
path. Skipped whencryptographyisn't installed.tests/test_v2_8_prometheus.pyβ install idempotence, query
recording, exposition shape, label escaping, uninstall reset.tests/test_v3_audit_fixes.pyβ pins the audit-fix list above:
QueryRecord.vendorrename, Prometheus exposition without
alias="",ScopedCollectorsender forwarding,
LocMemCacheprefix-index correctness for keys without
:,UserManagerunbound-manager error path, per-instance
FIELD_ENCRYPTION_KEYS.