Skip to content

v2.3.0

Choose a tag to compare

@rroblf01 rroblf01 released this 29 Apr 20:32
· 91 commits to main since this release

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_length propagates to the schema. CharField(max_length=N),
    EmailField (default 254), URLField (default 200), SlugField
    (default 50), FileField, and EnumField of strings now generate
    Annotated[str, Field(max_length=N)] — and by extension
    "maxLength": N in Schema.model_json_schema(). Previously the
    constraint was enforced only at full_clean / DB time, so FastAPI
    accepted oversize strings and the user only saw the failure deep
    inside the request handler.
  • DecimalField.max_digits / decimal_places translate to
    Pydantic's Field(max_digits=…, decimal_places=…).
  • choices=[…] becomes Literal[…] 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 / PositiveSmallIntegerField carry a
    ge=0 / "minimum": 0 constraint instead of just int.
  • EmailField / URLField advertise 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-validator dependency.
  • Built-in validators are translated. MinValueValidator
    ge=N, MaxValueValidatorle=N, MinLengthValidator
    min_length=N, MaxLengthValidatormax_length=N,
    RegexValidatorpattern=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
    produced T | None with default None — meaning a request body
    that omitted the field arrived at the model as None instead of
    the field's real default. The schema now exposes the actual default
    (or default_factory for callable defaults), and the annotation
    stays the bare type T (no spurious | None). Nullable columns
    without a default still render as T | None with default None.

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 = None whenever a new event loop was detected; the
    underlying sqlite3.Connection was finalised by the GC later as
    ResourceWarning: unclosed database and an aiosqlite worker
    thread stayed parked on its queue. The new _force_close_sync
    helper drives aiosqlite.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_connections and the atexit hook, plugs the same hole at
    test-teardown / process-exit time.
  • Sync SQLite wrapper now closes connections from every thread.
    SQLiteDatabaseWrapper mirrors its threading.local cache in
    a thread-id-keyed dict, so close() releases the handles opened
    in worker threads as well as the calling thread's. Previously,
    cross-thread connections leaked silently.
  • Validation runs before sqlite3.connect in both sync and
    async _new_connection. An ImproperlyConfigured raised on a
    bad OPTIONS["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
    unraisableexception plugin used to flush near the end of each
    run.

Added — Coverage uplift (178 new tests)

  • dorm.contrib.contenttypes 75% → 96%. New tests cover
    GenericForeignKey.aget (async resolution, cache hits, deleted
    target, unset descriptor), _GenericRelatedManager extras
    (exclude, exists, first, add, acreate,
    _act_filter round-trip), descriptor-on-class access for both
    GenericForeignKey and GenericRelation,
    GenericRelation._resolve_related happy / sad / class-target
    paths, and explicit ContentType.objects.clear_cache
    invalidation.
  • dorm.contrib.pydantic constraint-edge cases. Validator
    merging (MinLengthValidator + MaxLengthValidator, multiple
    MaxValueValidators), recursive types (ArrayField element
    type, GeneratedField delegating to output_field),
    _coerce_field_file_to_str fallback for objects without
    .name, Meta.nested with FK / M2M / unknown-field error,
    Meta.fields + Meta.exclude mutual exclusion, missing
    Meta.model, and explicit user annotations winning over the
    auto-derived ones.
  • dorm/db/backends/sqlite.py 81% → 84%. _is_single_statement
    edge cases (empty input, semicolons inside string literals /
    double-quoted identifiers), _validate_journal_mode error
    paths, sync wrapper auto-reconnect on a stale handle, multi-thread
    close(), idempotent close, set_autocommit flipping
    isolation_level on the live connection, get_table_columns /
    pool_stats parity with the async wrapper, and a regression
    guard that asserts an invalid journal_mode doesn't emit a
    ResourceWarning.
  • dorm/db/connection.py 85% → 88%. DATABASE_ROUTERS
    branches (router missing db_for_read, raising router falling
    through, falsy alias ignored), unknown-alias error, health_check
    / pool_stats happy + uninitialised paths, idempotent
    force_close_sync, close_all_async clearing 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.pk returns / accepts a tuple;
    filter(pk=(a, b)) decomposes into per-component WHERE clauses
    at the queryset boundary; save / delete use the multi-
    column predicate. Documented limitations: no auto-increment on
    components, can't be the target of a regular ForeignKey,
    filter(pk__in=[...]) is unsupported (use Q with explicit
    per-field clauses).

  • dorm.contrib.contenttypes — Django-style polymorphic FKs.
    Adds ContentType (one row per registered model, keyed by
    (app_label, model)), GenericForeignKey (virtual field
    composing content_type + object_id into a single
    descriptor), and GenericRelation (reverse accessor on the
    target side). ContentTypeManager.get_for_model /
    aget_for_model memoises lookups per process; descriptor
    reads cache the resolved instance per row. Includes async paths
    (aget on the GFK descriptor, acreate on 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 async transaction.aon_rollback. The mirror of
    on_commit for code that needs to undo non-transactional side
    effects when the surrounding atomic() 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 of on_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_save called storage.save during
    the transaction; if the surrounding block then raised, the bytes
    stayed on disk / S3 with no row referencing them. The save path
    now registers an on_rollback cleanup that calls
    storage.delete(name) on the just-written file, so a
    RuntimeError mid-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 any atomic() 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 empty Q() was silently dropped from the OR
    and the query collapsed to Q(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 via Q() | …
    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 the F object was passed as a bound parameter and
    the cursor errored out with "type 'F' is not supported".
    Other lookups raise NotImplementedError with a clear hint
    pointing at annotate().
  • order_by("fk_name") now resolves to the underlying
    fk_name_id column
    when fk_name is a ForeignKey on
    the model. Previously the SQL emitted ORDER 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 every AutoField column 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 raises ValueError instead of
    emitting malformed UPDATE … WHERE … (no SET clause). Same
    fix on the async abulk_update path.

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_scoped plus the matching
    from <module> import <fn> line in the migration's header — no
    more silent FIXME for 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 in docs/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.