v2.3.0
The 2.3 release sharpens the FastAPI / Pydantic integration so
field-level constraints declared on dorm models actually surface at the
API boundary (HTTP 422 + OpenAPI), fixes a connection-leak class that
was producing random ResourceWarning and intermittent CI hangs on
Python 3.14, and adds 178 new tests that close several coverage gaps
across content types, the Pydantic layer, and the SQLite backend.
Added — Pydantic constraint translation
max_lengthpropagates to the schema.CharField(max_length=N),
EmailField(default 254),URLField(default 200),SlugField
(default 50),FileField, andEnumFieldof strings now generate
Annotated[str, Field(max_length=N)]— and by extension
"maxLength": NinSchema.model_json_schema(). Previously the
constraint was enforced only atfull_clean/ DB time, so FastAPI
accepted oversize strings and the user only saw the failure deep
inside the request handler.DecimalField.max_digits/decimal_placestranslate to
Pydantic'sField(max_digits=…, decimal_places=…).choices=[…]becomesLiteral[…]in the annotation. Both
the canonical[(value, label), …]shape and the flat[value, …]
shape are accepted. Members render as an"enum": […]array in the
JSON Schema; non-members are rejected with the usual Pydantic error.PositiveIntegerField/PositiveSmallIntegerFieldcarry a
ge=0/"minimum": 0constraint instead of justint.EmailField/URLFieldadvertise their format hint —
"format": "email"/"format": "uri"— so OpenAPI clients
render the right input affordance and downstream code generators
pick the right type, without dragging in the optional
email-validatordependency.- Built-in validators are translated.
MinValueValidator→
ge=N,MaxValueValidator→le=N,MinLengthValidator→
min_length=N,MaxLengthValidator→max_length=N,
RegexValidator→pattern=str. When a validator and a native
field constraint disagree the strictest wins (e.g.
CharField(max_length=20, validators=[MaxLengthValidator(5)])
effectively bounds the schema at 5).
Fixed — Default value propagation
- Field defaults are now exposed in the schema.
BooleanField(default=False)
/IntegerField(default=3)/CharField(default="anon")previously
producedT | Nonewith defaultNone— meaning a request body
that omitted the field arrived at the model asNoneinstead of
the field's real default. The schema now exposes the actual default
(ordefault_factoryfor callable defaults), and the annotation
stays the bare typeT(no spurious| None). Nullable columns
without a default still render asT | Nonewith defaultNone.
Fixed — Connection leaks under pytest -n 4 / Python 3.14
- Async SQLite connections are now closed deterministically across
event-loop boundaries.SQLiteAsyncDatabaseWrapper._check_loop
used to drop the held aiosqlite connection by setting
self._conn = Nonewhenever a new event loop was detected; the
underlyingsqlite3.Connectionwas finalised by the GC later as
ResourceWarning: unclosed databaseand an aiosqlite worker
thread stayed parked on its queue. The new_force_close_sync
helper drivesaiosqlite.Connection.stop()and joins the worker
thread (5 s bounded) so the handle is gone before we move on. New
force_close_sync()method on the async wrappers, called by
reset_connectionsand the atexit hook, plugs the same hole at
test-teardown / process-exit time. - Sync SQLite wrapper now closes connections from every thread.
SQLiteDatabaseWrappermirrors itsthreading.localcache in
a thread-id-keyed dict, soclose()releases the handles opened
in worker threads as well as the calling thread's. Previously,
cross-thread connections leaked silently. - Validation runs before
sqlite3.connectin both sync and
async_new_connection. AnImproperlyConfiguredraised on a
badOPTIONS["journal_mode"]no longer leaks the just-opened
handle to the GC. - Test-suite teardown closes async wrappers explicitly in the
session-scoped fixture, eliminating the warnings pytest's
unraisableexceptionplugin used to flush near the end of each
run.
Added — Coverage uplift (178 new tests)
dorm.contrib.contenttypes75% → 96%. New tests cover
GenericForeignKey.aget(async resolution, cache hits, deleted
target, unset descriptor),_GenericRelatedManagerextras
(exclude,exists,first,add,acreate,
_act_filterround-trip), descriptor-on-class access for both
GenericForeignKeyandGenericRelation,
GenericRelation._resolve_relatedhappy / sad / class-target
paths, and explicitContentType.objects.clear_cache
invalidation.dorm.contrib.pydanticconstraint-edge cases. Validator
merging (MinLengthValidator+MaxLengthValidator, multiple
MaxValueValidators), recursive types (ArrayFieldelement
type,GeneratedFielddelegating tooutput_field),
_coerce_field_file_to_strfallback for objects without
.name,Meta.nestedwith FK / M2M / unknown-field error,
Meta.fields+Meta.excludemutual exclusion, missing
Meta.model, and explicit user annotations winning over the
auto-derived ones.dorm/db/backends/sqlite.py81% → 84%._is_single_statement
edge cases (empty input, semicolons inside string literals /
double-quoted identifiers),_validate_journal_modeerror
paths, sync wrapper auto-reconnect on a stale handle, multi-thread
close(), idempotent close,set_autocommitflipping
isolation_levelon the live connection,get_table_columns/
pool_statsparity with the async wrapper, and a regression
guard that asserts an invalidjournal_modedoesn't emit a
ResourceWarning.dorm/db/connection.py85% → 88%.DATABASE_ROUTERS
branches (router missingdb_for_read, raising router falling
through, falsy alias ignored), unknown-alias error,health_check
/pool_statshappy + uninitialised paths, idempotent
force_close_sync,close_all_asyncclearing the registry.
Added — Tier 3 Django-parity features
-
CompositePrimaryKey(*field_names)— declare a primary key
spanning multiple existing fields. The migration writer emits
PRIMARY KEY (col1, col2, …)(and strips the per-column
PRIMARY KEY);Model.pkreturns / accepts a tuple;
filter(pk=(a, b))decomposes into per-component WHERE clauses
at the queryset boundary;save/deleteuse the multi-
column predicate. Documented limitations: no auto-increment on
components, can't be the target of a regularForeignKey,
filter(pk__in=[...])is unsupported (useQwith explicit
per-field clauses). -
dorm.contrib.contenttypes— Django-style polymorphic FKs.
AddsContentType(one row per registered model, keyed by
(app_label, model)),GenericForeignKey(virtual field
composingcontent_type+object_idinto a single
descriptor), andGenericRelation(reverse accessor on the
target side).ContentTypeManager.get_for_model/
aget_for_modelmemoises lookups per process; descriptor
reads cache the resolved instance per row. Includes async paths
(ageton the GFK descriptor,acreateon the relation
manager). Tests cover create / cached lookup / round-trip /
polymorphic targets / dangling object-id / reverse manager /
isolation between instances on both SQLite and PostgreSQL.
Added — Transaction lifecycle hooks
transaction.on_rollback(callback, using="default")and the
matching asynctransaction.aon_rollback. The mirror of
on_commitfor code that needs to undo non-transactional side
effects when the surroundingatomic()rolls back. Examples:
deleting a file just written to a storage backend, removing a key
from a cache, sending a "previous notification was reverted"
webhook. Outside an active transaction the callback is dropped
(mirror ofon_commit's "fire immediately" path); inside nested
atomic()blocks, callbacks fire when their block rolls back —
savepoint rollbacks fire only inner callbacks.
Fixed — FileField + atomic() no longer leaks orphan files
- A file written inside
atomic()is now cleaned up
automatically when the transaction rolls back. Before this
release,FileField.pre_savecalledstorage.saveduring
the transaction; if the surrounding block then raised, the bytes
stayed on disk / S3 with no row referencing them. The save path
now registers anon_rollbackcleanup that calls
storage.delete(name)on the just-written file, so a
RuntimeErrormid-block leaves the storage as it was before
the block. Savepoint rollbacks clean up only the files written
inside that savepoint; files written in the outer block (which
still commits) are preserved. Outside anyatomic()block, no
cleanup is registered — there's nothing to undo.
Fixed — Behaviour bugs (observable changes; review on upgrade)
Q()is now a tautology, not a no-op.Q() | Q(age=2)
now correctly matches every row (TRUE OR (age=2) ⇒ TRUE);
previously the emptyQ()was silently dropped from the OR
and the query collapsed toQ(age=2). AND with empty Q
remains a no-op (TRUE AND X ⇒ X), unchanged. Code that
relied on the buggy behaviour to filter rows viaQ() | …
will now return more rows — review your filters.filter(col=F("other_col"))now works for the comparison
lookups (exact,gt,gte,lt,lte).
Previously theFobject was passed as a bound parameter and
the cursor errored out with"type 'F' is not supported".
Other lookups raiseNotImplementedErrorwith a clear hint
pointing atannotate().order_by("fk_name")now resolves to the underlying
fk_name_idcolumn whenfk_nameis aForeignKeyon
the model. Previously the SQL emittedORDER BY "fk_name",
which doesn't exist as a column — the query crashed on PG and
silently misbehaved on SQLite.bulk_create([Model(pk=42, …)])now honours the explicit pk.
The previous code excluded everyAutoFieldcolumn from the
INSERT regardless of whether the caller had pre-assigned the pk,
so rows ended up at fresh auto-generated ids instead of the
requested ones. This regressed Django-style fixtures that pin
primary keys.bulk_update(rows, [])now raisesValueErrorinstead of
emitting malformedUPDATE … WHERE …(noSETclause). Same
fix on the asyncabulk_updatepath.
Improved — FileField(upload_to=callable)
- Migration writer round-trips module-level callables. A
FileField(upload_to=upload_owner_scoped)declared with a
function defined at module scope now serialises to
upload_to=upload_owner_scopedplus the matching
from <module> import <fn>line in the migration's header — no
more silentFIXMEfor the common dynamic-path pattern.
Lambdas and nested functions still fall back to the FIXME marker
(they have no stable importable name); the marker text now
explains why and how to fix it. - Documentation surfaces dynamic-upload patterns. The "Files"
section indocs/models.md(EN + ES) gains a "Dynamic upload
paths" subsection with three realistic examples (owner-scoped
prefixes, route-by-extension, content-addressed) plus migration-
round-trip rules and a path-safety note. - End-to-end coverage in
tests/test_filefield_callable_upload_to.py: 28 tests across
rendering, save/load, async parity, FK-aware paths, lambdas,
collision handling under shared dynamic paths, and the writer's
ability (or inability) to round-trip each callable shape.