Djangors logoDjangors

Changelog

All notable changes to Djangors are documented here. Format loosely follows Keep a Changelog.

[Unreleased]

[0.7.0]

This release contains a breaking change to the ModelAdmin trait, made to fix a cross-tenant data leak in the admin. Per the pre-1.0 policy in API Stability, that bumps the minor version.

Removed

Changed (breaking)

Added

Fixed

Internal

Documentation

[0.6.3]

Router

REST

[0.6.2]

ORM

Model macros (#[derive(Model)])

Migrations

Admin

REST

Other

[0.6.0 and earlier]

The sections below group the history by the PLAN.md phase each change shipped under; the [0.6.0] heading lower in the file marks the first versioned crates.io release since this phased history was written.

Everything to date, through Phase 12. Workspace version is 0.2.0; most crates were first published to crates.io as 0.1.0, but that snapshot predates every fix and feature in Phase 12 below. See Phase 12's own publish-status note.

Phase 0-1: Bootstrap and core request/response layer

Phase 2: ORM and migrations v1

Phase 3: Templates, forms, static files

Phase 4: Sessions, CSRF, and auth

Phase 5: The admin site

Phase 6: Developer experience / CLI

Phase 7: Batteries

Phase 8: REST framework and background jobs

Phase 9: Documentation

Phase 10: Hardening, architecture parity, and 1.0 prep

Phase 11: Django-parity gap closure

Phase 12: Post-1.0 hardening

Compiled after the first real deployment surfaced genuine bugs, and after comparing Djangors directly against a real production Django SaaS backend to find concrete gaps. Sequenced easiest to hardest.

Phase 12.1: dj new fix, 0.2.1 republish, and site polish

Phase 12.2: Error handling, ORM transactions, and REST polish

Driven by an external review of a real backend built on 0.2.2, which rated error handling 4.5/10 and REST 5.5/10 as the framework's weakest areas.

Error handling. DjangorsError was a closed 7-variant enum whose payload was a bare String, with code() and message() private — so an application could not attach its own status code, stable domain code, or structured details, and even a custom ErrorRenderer could not read them back out.

ORM transactions. Database::transaction existed, but QuerySet only ever executed against Database::pool(), so no ORM call could join a transaction — atomic work had to drop to raw sqlx and give up the QuerySet API.

REST.

REST, part two — the serializer layer. djangors-rest was a single 3,257-line lib.rs whose serialize/deserialize were direct Model-to-JSON with no field control and no validation hook.

Two real bugs found while building the above, both caught by new tests.

Router state and middleware ergonomics.

Phase 12.3: ORM query expressiveness, REST filtering/throttling, and a responsive site

Continues the response to the 0.2.2 external review. Phase 12.2 addressed error handling and the REST serializer layer; this phase addresses the remaining low scores — the ORM's query surface (rated 6/10) and the rest of the DRF parity gap — plus the marketing site's mobile layout.

ORM: Q-object filters. Expr already supported And/Or/Not internally and the SQL compiler already emitted all three, but UnresolvedExpr — the type q! produces — had a single And variant, so nothing a user could write ever reached the Or or Not paths. Two ad-hoc escape hatches (filter_or_icontains, filter_datetime_range) existed precisely because of this.

ORM: lookup suffixes. Added __ne, __iexact, __in, __isnull, __regex, and __iregex to the existing comparison and substring lookups.

ORM: F expressions in filters. F previously existed only for the update side (set!(votes = F("votes") + 1)). Added Expr::CompareField and the q_f! macro for column-to-column comparison in a WHERE clause (q_f!(paid_at__gte due_at)), which binds no parameters.

ORM: grouping and projections.

REST: filter backends. ViewSetConfig::filterable_fields only ever produced exact matches. Added a FilterBackend<M> trait with three implementations, composed in order via ViewSetOptions::with_filter_backend:

All three are allowlist-driven, and the field/lookup name handed to the ORM is rebuilt from the compile-time allowlist rather than taken from the query string, so a client-supplied name cannot reach SQL.

REST: throttling. Added Throttle and parse_rate, built on the existing djangors_core::ratelimit sliding window rather than duplicating it. DRF-style rate strings ("100/hour"), keyed per authenticated user and falling back to client IP (ByUserOrIp), applied to every ViewSet action via ViewSetOptions::with_throttle. A malformed rate returns None instead of silently becoming a default budget.

REST: nested serializers. Added NestedSerializer and Serializer::to_representation_nested, which embed a pre-fetched related object in place of its raw foreign key. The serializer trait is synchronous and issues no queries, so this pairs with select_related; without a loaded relation the field keeps its id rather than becoming null.

Ergonomics. Value now converts from i8/i16/i32/u8/u16/u32/f32, not just i64/f64. A model field declared i32 — the common case for Django's IntegerField — could not previously be used with q! without a manual cast. This surfaced from the compile-tested doc examples, which the polls example's votes: i32 field exercises.

Bug fixed: contains on a non-text value built a Debug-formatted pattern. The LIKE/ILIKE branches formatted non-Text values with {:?}, so q!(field__contains = 5i64) bound %I64(5)% instead of %5% and matched nothing. All four affected branches now use Display.

Docs.

Site: mobile.

Phase 13.1: SQLite backend (dual-backend foundation)

Djangors was PostgreSQL-only. This adds SQLite as a second backend for the ORM query path, the derive macro, and the migration type mapping — enough for dj new → running app with no database server, and for a test suite that needs no Postgres.

sqlx::Any was evaluated and rejected: its AnyValueKind is Null|Bool|SmallInt|Integer|BigInt|Real|Double|Text|Blob with no chrono support, and every model in this workspace uses DateTime<Utc>. Making QuerySet/FromRow/the derive macro generic over sqlx::Database was also rejected — it would propagate for<'r> i64: Decode<'r, DB> + Type<DB> bounds through every method and every downstream crate. The chosen design is enum dispatch confined to djangors-db.

New in djangors-db:

Simplification: because Conn now binds parameters itself, the eight duplicated bind blocks in queryset.rs and four more in the derive macro are gone — twelve copies of the same match value { … .bind(…) } collapsed into two (one per driver).

Breaking: FromRow::from_row takes &DbRow instead of &sqlx::postgres::PgRow. This is a public trait implemented by every #[derive(Model)] type.

Integer decoding now falls back i64 → i32 → i16, widening losslessly. Postgres's binary protocol is width-strict: an INT4 column cannot be decoded as i64. SQLite is dynamically typed and hides this entirely, so a SQLite-only test would never have caught it — eight admin tests did.

SQLite type mapping (sql_type_for is dialect-aware): VARCHAR(n)/TEXT→TEXT, DOUBLE PRECISION→REAL, BOOLEAN→INTEGER, date/time/INTERVAL/UUID/INET/JSONB→TEXT, BYTEA→BLOB. An auto primary key becomes INTEGER PRIMARY KEY AUTOINCREMENT, and column_sql suppresses its usual NOT NULL/PRIMARY KEY for such a column — SQLite rejects a table with two primary keys.

Known limitations (deliberately deferred):

400 tests pass.

Phase 13.2: Dialect-aware raw SQL

13.1 made the ORM dual-backend, but every crate that drops to raw SQL still wrote $N placeholders and called Database::pool(), which panics on a SQLite handle — so an app on SQLite got a working ORM and an admin that panicked on the first request. This closes that.

Scope correction. 13.1's notes said "~555 raw sqlx::query sites". That count included test-setup SQL. The production surface was 31: admin 4, auth 3, tasks 11, guardian 5, contenttypes 4, cache 4. All are now dialect-aware.

Two cases were not mechanical:

Also added Dialect::bytea_type() (BYTEA/BLOB) for djangors-cache's DDL.

Still Postgres-only (deferred): dj makemigrations and migration plan generation, and the djangors-test harness (whose job is provisioning isolated Postgres databases). Neither is an application-serving path. (makemigrations and plan generation were closed in Phase 13.3 below.)

Review finding. The dispatch added a cross-process lock to examples/polls/tests/voting.rs after seeing that test fail with 401 "user not found". That failure was an artifact of two cargo test runs executing concurrently against the same database (the auth suite drops and recreates auth_user mid-flight). Verified by running the suite with the change reverted: 401 tests pass. Reverted, since it would otherwise have shipped two std::mem::forget leaks and an unneeded dev-dependency into an example app that users read as reference code.

401 tests pass.

Phase 13.3: Dialect-aware migrations (dj makemigrations, dj migrate, plan/DDL)

13.1 made the ORM dual-backend and 13.2 did the same for production raw SQL, but the piece that creates the schema was still Postgres-only — a SQLite project could run its ORM and admin only against tables it had hand-written. This closes the dj new → SQLite → running admin story.

The blocker. Database::transaction takes FnOnce(&mut PgConnection) and returned TransactionFailed on a SQLite handle. Every migration apply/rollback path runs inside one, so nothing downstream could be ported until it was solved. Conn already had PgTx/SqliteTx variants with no Database method handing one out, so this adds Database::transaction_conn (dual-backend, commit on Ok, rollback on Err) alongside the existing methods, which are unchanged.

New Dialect helpers: auto_pk_type() (SERIAL PRIMARY KEY / INTEGER PRIMARY KEY AUTOINCREMENT), timestamp_type() (TIMESTAMPTZ / TEXT), current_timestamp() (now() / CURRENT_TIMESTAMP), and from_url(). timestamp_type is TEXT on SQLite, not TIMESTAMP: sqlx's SQLite DateTime<Utc> codec reads and writes ISO-8601 text, and TEXT affinity stores that losslessly where TIMESTAMP's NUMERIC affinity would coerce. Database::connect now delegates to from_url rather than keeping a second copy of the URL detection.

Plan and DDL. build_create_all_plan and build_create_plan_from_snapshots take a Dialect instead of hardcoding Postgres. Operation::to_sql is now dialect-aware and fallible: AlterColumnType has no SQLite equivalent (SQLite's ALTER TABLE cannot change a column's type; the real workaround is a 12-step table rebuild), so it returns the new MigrationError::UnsupportedOnDialect rather than emitting Postgres syntax that would fail at apply time. A generated migration file that is silently wrong is worse than one that refuses to generate. The plan builders never construct that variant, so no working path changed.

Runner. All Database::pool() calls in djangors-migrations and the CLI's migrate_with_plan now go through Conn; the history-table DDL is built from Dialect instead of three copies of a Postgres literal. rollback_from_dir's WHERE name = ANY($1) (no SQLite array binding) became an expanded IN (?, ?, …) with an empty-input guard, since IN () is a syntax error in both dialects. makemigrations is synchronous and never connects, so it infers its dialect from DATABASE_URL via Dialect::from_url, defaulting to Postgres when unset.

Still Postgres-only (deferred): the djangors-test harness, whose job is provisioning isolated Postgres databases and whose isolation relies on Postgres session-level advisory locks.

405 tests pass (401 + 4 new).

Phase 13.4: Documentation pass

Measured doc-line-to-code-line ratio across every crate and documented the ten thinnest. djangors-admin was both the largest crate in the workspace and the least documented (9074 code lines, 1.5%). Added explanatory comments — the "why", not restatements of the "what" — with priority on module headers, private helpers, and non-obvious logic. Several rationales that previously existed only in CHANGELOG.md or docs/design/*.md were brought down to the code they describe (the Vec<(String,String)>-vs-HashMap form-extractor decision, the html_escape &#x2F; vs minijinja &#x2f; escaping discrepancy, why Conn uses enum dispatch rather than generics over sqlx::Database).

Constrained to comments only: verified by stripping all comments from both revisions of every touched .rs file and confirming the remainder was byte-identical. Zero code changed in 13 files.

Eight new topic guides for subsystems that had no prose documentation: databases.md (the 13.1–13.3 dual-backend story, previously undocumented anywhere), migrations.md, tasks.md, caching.md, i18n.md, sessions.md, static-files.md, signals.md.

Known limitation: every Rust block in the new guides is tagged rust,illustrative, so none is compile-checked by doc-code-check. API names were instead verified by hand against real source. Promoting the runnable ones to rust,compile is follow-up work.

405 tests pass.

[0.6.0]

Phase 14.1: Dual-backend test harness

The original reason for adding SQLite was that tests would run faster. 13.1–13.3 made application code dual-backend but left the framework's own suite Postgres-only — ~475 test SQL sites plus 11 Postgres-specific mechanisms in djangors-test. This closes that.

djangors-test gains TestBackend { Postgres, Sqlite } and TestDatabase::new_for_backend(), selected by TEST_BACKEND or by whether DATABASE_URL is set. SQLite needs neither an advisory lock nor CREATE DATABASE: a fresh sqlite::memory: handle is a private database, so per-test isolation is free and strictly stronger than the Postgres scheme.

The SQLite pool is pinned to max_connections(1). With a plain sqlite::memory: URL every pooled connection is a separate database, so setup DDL executed on one connection would be invisible to the next query on another.

Ported: djangors-test, -db, -migrations, -contrib-guardian, -views, -tasks, -auth, -orm, -rest, -admin.

This is dual-mode, not a switch. Postgres stays first-class — isolation levels, pg_advisory_lock, SKIP LOCKED, and width-strict INT4/INT8 decoding are only meaningfully covered there.

Measured on a development machine: djangors-admin's 32 tests run in 0.69s on SQLite versus 15.8s on Postgres; djangors-rest's 45 in 0.44s versus 4.7s.

Caveat, stated plainly: five tests early-return when DATABASE_URL is unset, each with a comment naming the Postgres feature required. They report as passing on SQLite without executing, so a green SQLite run alone does not prove those paths still work. Run against Postgres before changing anything dialect-specific.

Backend selection and this caveat are documented in docs/src/guides/testing.md.