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
ModelAdmin::set_tenant_filterandModelAdmin::clear_tenant_filter. These stored the active tenant on the admin object. AModelAdminis constructed once at registration and shared across every concurrent request behind anArc, so a handler that set the filter and then awaited the database could have a second request overwrite it mid-flight and run the first request's query against the wrong tenant.clear_tenant_filterwas worse: a superuser request removed the restriction globally for whatever else was in flight.There is no replacement, because the tenant is no longer stored anywhere. See below.
Changed (breaking)
The seven
ModelAdminmethods that touch data now take a finaltenant: TenantFilterargument:changelist,export_csv_rows,get_by_pk,update_from_form,update_fields_from_form,create_from_form, anddelete_by_pk.TenantFilterisOption<(&'static str, i64)>— the tenant FK field name plus the id every row must match, orNonefor unrestricted.Migrating a custom
ModelAdmin: add the parameter to each of those methods and apply it to every query the method runs. Do not cache it onself— that is the bug this change fixes. Implementations that ignore tenancy entirely can accept the argument and ignore it, but will then not be tenant-safe.DefaultModelAdminneeds no changes from callers.Admin tenant resolution now fails closed. For a non-superuser on a tenant-scoped site, a request whose tenant cannot be resolved returns
Forbiddeninstead of running the query unscoped across every tenant.tenant_scopereturnsInternalrather thanUnauthorizedwhen the membership lookup itself fails. A database error during tenant resolution was previously indistinguishable from "this user is not a member", so an outage was reported to clients as an authorization failure.TenantResolutionLayernow records aTenantResolutionFailedmarker thattenant_scopemaps to a 500.
Added
- Bachs payment provider (
djangors-contrib-payments):BachsProviderimplementingPaymentProvideragainst the bachs.io REST API — product-less checkout sessions, checkout-based verification, refunds, and HMAC-SHA256 webhook verification. Note thatverify/refundtake the Bachs checkout id (returned asaccess_codefrominitiate), not your merchant reference, because Bachs has no lookup-by-reference endpoint. Not yet exercised against the live API. PaymentProvider::verify_webhook_signature_with_timestamp, for providers that sign over a timestamp plus the body. Has a default implementation that ignores the timestamp and delegates toverify_webhook_signature, so existing implementations (including Paystack) are unaffected.Router::set_error_renderer(renderer), which performs theArc<dyn ErrorRenderer>cast for you.with_state(Arc::new(MyRenderer))compiles but stores the value under its concrete type, where the renderer lookup never finds it — the custom renderer is then silently ignored.TenantResolutionServiceis now re-exported fromdjangors-contrib-tenancy's root, alongside the newTenantResolutionFailed.TenantFilteris exported fromdjangors-admin.
Fixed
record_charge_initiatedis now idempotent on SQLite. It documents that a UNIQUE violation onreferencemeans "already recorded" and returns the existing row, but it matched only Postgres's23505. On SQLite — a supported backend since 0.5.0 — a concurrent webhook redelivery returned a hard error instead. SQLite's2067and1555are now recognised too.admin_historyhad no permission check at all, and no tenant restriction. It now requires theviewpermission and confirms the target object is visible to the current tenant before reading its audit log (the log table has no tenant column of its own, so visibility is gated on the object).- Several tests only passed on the backend they were written against, hiding the bug above: raw
sqlxagainstDatabase::pool()(which panics on a SQLite handle),BIGSERIAL/TIMESTAMPTZDDL,$1placeholders, an unquotedusercolumn (a reserved word in Postgres), and one hardcodedpostgres://…@localhost/djangors_testthat ignored bothTEST_BACKENDandDATABASE_URL. The suite now passes on both backends. - Four ORM tests shared one table against a single Postgres database and raced on
DROP/CREATEunder parallel execution; they now use isolated per-test databases.
Internal
djangors-admin'slib.rswent from 9,848 lines to 86, split into twelve modules (model_admin,site,changelist,edit_view,delete_view,index_view,history_view,action_view,forms,log,perms,favicon). Purely a move — every previously-public item is re-exported at its original path, so this is not a breaking change.- All
#[cfg(test)]modules across the workspace moved out of production sources into sibling test files (59 files). They remain child modules, so they keep access to private items.
Documentation
- New guidance in
guides/testing.mdon where test code lives and how to write tests that pass on both backends;guides/admin.mdgains the crate layout and the custom-ModelAdmintenancy contract.guides/payments.md,guides/multi-tenancy.md,guides/http-and-middleware.md,guides/orm.md, andguides/migrations.mdupdated for the changes above, plus the previously undocumented ORM features (OneToOne,ManyToMany, custom managers, composite indexes,QuerySet::delete, query caching,detect_changes).
[0.6.3]
Router
- Fix:
:name-style path segments (Express/Django URLconf syntax) are now accepted as an alias for the untyped{name}capture. Previouslyparse_patternonly recognized{name}/{name:type}as a capture; a segment written:namewas stored as the literal string":name", which a real request path segment can never equal — every route registered with that syntax 404'd unconditionally, silently.{name}/{name:i64}/{name:slug}remain the recommended forms (they give request-time validation the alias doesn't);:nameis a compatibility shim for code migrated from Express/Django-style URLconfs.
REST
- Fix:
scoped_viewset_routes_with_config'sGETlist handler was missing theIsAuthenticatedcheck that its owncreate/retrieve/update/destroyroutes already had (and that the siblingviewset_routes_with_config_and_permissionlist handler already had) — an unauthenticatedGET /reachedScoped::scopedirectly instead of being rejected up front. Every route this helper mounts now requires authentication before running. - Docs:
ViewSetandScopedViewSetnow carry an explicit "No built-in permission check" section warning that their associated functions (list/list_with_config,retrieve,create,update,destroy, and the_with_optionsvariants) perform no authentication or authorization check of their own — that's intentional, so callers can compose anyPermission(includingAllowAny) at the mounting layer — but it means registering one of these functions as a bare route handler (router.post("/x", ScopedViewSet::<M>::create)) mounts a completely unauthenticated endpoint, withScoped::scope's row filter as the only remaining check (and mostscopeimplementations only constrain which tenant's rows, not which role). Always mount throughviewset_routes*/scoped_viewset_routes*, which wrap every handler in the permission check for you.
[0.6.2]
ORM
QuerySet::get_or_create/QuerySet::update_or_create— idempotent upsert helpers; thedefaults/updatesclosures returnVec<(&'static str, Value)>/Vec<(&'static str, SetExpr)>(theset!form). RequiresT: Send; not yet wrapped in a transaction.QuerySet::search(query, &fields)— Postgresto_tsvector/plainto_tsqueryfull-text search that chains into the query as asearch_condition.QuerySet::explain()— Postgres-onlyEXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT), returns the plan as aString; errors withUnsupportedOnDialecton SQLite.FuncExpr(aggregate.rs) —COALESCE/LOWER/UPPER/CONCAT/LENGTHscalar functions usable withQuerySet::annotate_funcs.Exists/OuterRefcorrelated subqueries,select_for_update+ savepoints (15.1).
Model macros (#[derive(Model)])
#[djangors(auto_now_add = true)]/#[djangors(auto_now = true)]onDateTime<Utc>fields — auto-stampchrono::Utc::now()onsave()(both) andupdate()(auto_nowonly).#[djangors(choices = ["a", "b"])]onStringfields — populatesFieldMeta.choices.- Unblocked
uuid::Uuid,chrono::NaiveDate,chrono::NaiveTime,std::time::Duration, andrust_decimal::Decimalfield types (persisted via text serialization;Decimalrequiresmax_digits/decimal_places).
Migrations
Operation::CreateTablenow carriescheck_constraints;build_create_all_planemits aCHECK (col IN (...))constraint for every field declared with#[djangors(choices = [...])].- Migration CLI verbs (
dj migrate/makemigrations/--rollback), dialect-aware execution.
Admin
- Inlines —
ModelAdminConfig { inlines: Some(&[InlineConfig { struct_name, relation_field, fields }]) }renders a child model's rows inside the parent's add/change form. - Tenant scoping —
AdminSite::with_tenant_scoping(tenant_field, extract_tenant_id)filters every changelist/add/change/delete query by the resolved tenant; inlines inherit the parent's scope. list_filteraccepts Boolean fields and fields declared with#[djangors(choices = [...])].
REST
scoped_viewset_routes_with_config::<M>(router, path, config)— scoped routes with customViewSetConfig(previously only default config was possible).request.user()convenience — returnsResult<User, DjangorsError>(wrapscurrent_user).
Other
djangors-views: named routes +reverse(); template functions.- Multipart fuzz target and updated threat model (security).
- SQLite runs in CI; five Postgres-only fake "passes" became honest skips.
- Docs: new guides for class-based views, flatpages & redirects, messages, object-level permissions,
sites & 2FA, HTTP & middleware; augmented existing guides; full crate coverage in
docs/src/django-comparison.md.
[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
- Workspace scaffold:
djangors,djangors-core,djangors-cli(djbinary), and every placeholder crate. djangors-core:Request/Response/Router/Handler, tower middleware layer, a real TCP server (Djangorsapp struct), typed extractors (Json,Query,Form), panic isolation with Django-style debug error pages, a typed async signals bus (request_started/request_finished), and a dev/production tracing subscriber.
Phase 2: ORM and migrations v1
djangors-db: sqlx-backed Postgres pool, config, transactions with explicit isolation levels.djangors-orm:ModelMeta/FieldMeta/RelationMeta,#[derive(Model)](viadjangors-macros), theExprtree andq!()query macro,QuerySet<T>execution, model lifecycle (save/update/delete), aggregation (Count/Sum/Avg/Min/Max),F()expressions and race-safe bulkupdate(), andselect_related()(a 2-query batched fetch, not a JOIN).djangors-migrations: a CreateTable-only v1 engine, wired todj migrate.
Phase 3: Templates, forms, static files
djangors-template: MiniJinja-backed engine with loader precedence.djangors-forms:FormFieldtrait,CharField/IntegerField/BooleanField/EmailField,#[derive(Form)].djangors-staticfiles: dev serving,collectstatic, content-hashed manifest.
Phase 4: Sessions, CSRF, and auth
djangors-sessions: signed-cookie session engine.- CSRF middleware,
ALLOWED_HOSTS, HSTS, andSecurecookie flag support. djangors-auth:Usermodel with Argon2id password hashing,AuthBackend, login/logout, theAuth<U>extractor, login rate limiting, and audit signals.djangors-mail(console backend) plus a password-reset flow.- A fuzz/threat-model/OWASP security review pass, and
examples/pollsas the first real end-to-end app (login-gated voting).
Phase 5: The admin site
djangors-admin:AdminSiteregistry,is_staff-gated login, a realcreatesuperuser.- Changelist: generic field rendering, sorting, pagination,
list_display/search_fields,list_filter, bulk delete,date_hierarchy,list_editable, CSV export. - Add/edit forms and delete confirmation (GET confirm + POST delete).
- A full permissions data model (
has_perm) enforced across every admin view. - Converted every admin page from hand-built HTML to the real template engine, added a proper
HTML5 page shell with dark-mode CSS custom properties, per-site branding
(
site_header/site_title, favicons, logo/accent-color overrides), and CSRF-safe form wiring. - Computed columns, bulk actions, fieldsets, readonly/
raw_idfields, a transitivePROTECTwalk on delete, a per-object history page, and extension points. - A full audit log (
LogEntry) with a "Recent actions" panel. examples/schoolas the Phase 5 Definition-of-Done app.
Phase 6: Developer experience / CLI
dj new <name>real project scaffolding,dj new-app,dj run,dj check(--deploy),dj makemigrationspreview,dj dbshell,dj test,dj shell.djangors-test: a realTestClient/TestDatabase.- Graceful shutdown for
Djangors::run/run_service.
Phase 7: Batteries
djangors-cache(in-memory/database/Redis-backedCachetrait +CacheLayermiddleware) and a completedjangors-mail(console/SMTP/file/in-memory backends).djangors-contrib-messages, a shared pagination utility, humanize template filters.djangors-contrib-sitemaps,djangors-contrib-syndication(RSS/Atom),djangors-contrib-flatpages,djangors-contrib-redirects.- Real field-level audit diffing and
djangors-contrib-guardian(object-level permissions). djangors-i18n(Fluent-based.ftlcatalogs,Accept-Languageparsing, locale-aware formatting) anddjangors-contrib-otp.
Phase 8: REST framework and background jobs
djangors-rest: serializers,ViewSets, router mounting, session/token/JWT auth and permission classes, filtering/ordering, and OpenAPI 3.1 generation.djangors-core: Server-Sent Events streaming with in-process broadcast groups.djangors-tasks: the#[task]macro, aSKIP LOCKED-based DB-backed queue, a worker loop, and admin integration.
Phase 9: Documentation
- An 8-part polls tutorial mirroring Django's own.
- The mdBook docs site (
docs/src/) with 8 topic guides, a Django-comparison guide, and 6 how-tos. - A real
evcxr-backeddj shellREPL. - 100% public-API rustdoc coverage across every crate (
#![deny(missing_docs)]everywhere), and every documentation code block compiled as a real workspace test. - The root
README.md.
Phase 10: Hardening, architecture parity, and 1.0 prep
- Honest
oha-driven HTTP benchmarks against Django/Gunicorn and axum. - Real migration autogeneration (new-table + new-field diffing against a schema snapshot) and a
fix to a confirmed
dj migratebug found at the time. - Eight architecture-parity items: compile-time-enforced scoped viewsets,
prefetch_relatedbatch eager-loading with N+1 regression tooling, a pluggable/project-customizable global error envelope, named+scoped rate limiting per endpoint, cron/scheduled recurring background jobs, cursor (keyset) pagination for REST viewsets, a pluggableStoragetrait withLocalDiskStorage, and anS3Storagebackend plus a realFileField. - Load testing
djangors-adminunder concurrency, with a connection-pool tuning guide. - A completed
djangorsfacade crate, an API freeze review (both passes, all ~29 crates) with a published deprecation/stability policy. - An internal security review (
cargo-auditplus a manual audit, seedocs/security-review-2026-07-27.md) that fixed an insecure-by-default cookie gap in both example apps. A real third-party audit is still open, pending a budget/vendor decision. - The Astro marketing site (
site/), deployed on Vercel, with AI-agent-friendly markdown (llms.txt/llms-full.txt, raw.mdserved at every doc URL, an on-page "Copy as Markdown" button) and a shipped Claude Code Skill (.claude/skills/djangors-development/).
Phase 11: Django-parity gap closure
- CircleCI pipeline (fmt/clippy/build/test/doc-build against a real Postgres service container,
plus a
cargo-auditjob). While validating it, found and fixed a real pre-existing test-isolation race indjangors-tasks's test suite. - Migration rollback with typed
Operationvariants (AddColumn/DropColumn/AlterColumn/RenameColumn) and real down-migrations. - Model-level signals (
post_save/pre_save/post_delete/pre_delete). bulk_createand aModelFormequivalent auto-derived from#[derive(Model)].- Real
multipart/form-datafile upload parsing (not a stub). - Server-rendered generic class-based views (
ListView/DetailView/CreateView/UpdateView/DeleteView). - A custom management-command plugin mechanism (
#[management_command],dj <custom-command>). - A contenttypes/
GenericForeignKeyframework. djangors-test: transactional rollback-per-test and a JSON fixtures loader.- A genuine cross-crate test DDL race (colliding global table names like
auth_useracrossdjangors-admin/djangors-auth's own test suites) found and fixed via a real Postgres session-level advisory lock primitive (djangors_test::acquire_cross_process_lock), verified by re-reproducing the original collision as concurrent OS processes before and after the fix. - First real production deployment (
djangors-pollsto Render), surfacing and fixing three real bugs along the way: unquoted reserved-keyword SQL identifiers inderive(Model)'s generated INSERT/UPDATE, adj makemigrationsbug where new models with foreign keys to sibling new models couldn't resolve the relation (planned one model at a time instead of together), and a Docker build missing TLS/pkg-config/libssl-dev. - Most crates first published to crates.io as
0.1.0(superseded by Phase 12 below; that snapshot predates every fix and feature listed there). - Not done: the remaining two example-app deployments (only
djangors-pollsis live) and actually republishing the fixed/updated crates (Phase 12 does the fixing; the republish itself is still open, tracked inPLAN.md).
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.
- Quoted every remaining raw SQL identifier in
QuerySet(.filter()/.order_by()/bulk_create/prefetch_related/etc.), closing a gap the Phase 11 INSERT/UPDATE fix didn't cover. #[derive(Settings)]: a typed, validated app-config macro (thepydantic-settings/django-environequivalent) reading{PREFIX}_{FIELD}env vars with defaults andOption<T>support.- A
CspBuilder/CspLayermiddleware (thedjango-cspequivalent). - Optional Sentry error tracking (
djangors-core'ssentryfeature) alongside the existingtracing-based logging. - A
django-axes-style persistent, database-backed account lockout (PersistentLockoutBackend), distinct from the existing in-memory rate limiter. It rejects even correct credentials once locked, and survives process restarts. djangors-pdf: typed PDF generation (report cards, invoices, receipts) via a pure-Rust builder API, deliberately not an HTML/Chrome-based renderer (no viable headless-Chrome path in a container deployment).- Optional malware/AV scanning of uploaded bytes via
clamd's realINSTREAMwire protocol (djangors-staticfiles'sclamavfeature), scanning in-memory before anything touches disk. djangors-deploy: aDeployProvidertrait fordj deploy, shipping aRenderProvider(real REST API) and anSshProvider(shells out to the systemssh, avoiding a native SSH library dependency). A first slice, deliberately left to grow (Railway/GCP/AWS not yet implemented, nodj deployCLI subcommand wired in yet).djangors-contrib-payments: aPaymentProvidertrait andPaystackProvider, with an idempotency-key-firstTransactionmodel (a real DB-level UNIQUE constraint onreference, not an application-level check-then-insert) and webhook handling that verifies the HMAC-SHA512 signature against the raw body before ever parsing JSON. Real API shapes validated against a working production Paystack integration, not guessed.djangors-contrib-tenancy: multi-tenancy support, including aTenantmodel, aTenantMembershipjoin model, membership-verified per-request tenant resolution (TenantResolutionLayer, never trusts a client-supplied tenant header alone), and a one-linetenant_scope()helper reusingdjangors-rest's existingScoped/ScopedViewSetenforcement mechanism. Design doc:docs/design/12.1-multi-tenancy.md.- Workspace version bumped
0.1.0to0.2.0and all 32 crates published for real, includingdjangors-pdf/djangors-deploy/djangors-contrib-payments/djangors-contrib-tenancyfor the first time. Independently verified live on crates.io, not just attempted. Found and fixed a real bug along the way: 13 crates had internal dev-dependencies pinned with bothpathandversion(a leftover from the version-bump script not distinguishing[dependencies]from[dev-dependencies]), which made publishing circular for crates with a mutual dev-dependency on each other. Fixed by keeping onlypathon internal dev-dependencies, which cargo correctly drops from the published manifest entirely. - Also covered every Phase 12 feature in the mdBook doc site for the first time (new guides:
settings, PDF, payments, multi-tenancy; extended auth/security/deployment guides). The doc site
previously had zero mention of anything in this phase. Every code example genuinely compiles,
verified via
doc-code-check.
Phase 12.1: dj new fix, 0.2.1 republish, and site polish
- Found and fixed a real bug in
dj new: it located the siblingdjangors-corecrate viaenv!("CARGO_MANIFEST_DIR"), which only resolves correctly whendjangors-cliis built from inside the cloned monorepo. Anyone who installed viacargo install djangors-clifrom crates.io got a generated project with an unresolvable path dependency, andcargo buildfailed immediately. Fixed with a runtime check: use a path dependency when a sibling checkout exists, otherwise fall back to a real version dependency on the published crate. - Workspace version bumped
0.2.0to0.2.1and all 32 crates republished to ship the fix. Independently verified live on crates.io per crate, then re-verified end to end against the real registry (not the localcargo packagesimulation used during development): freshcargo install djangors-cli --version 0.2.1,dj new,cargo build, and real HTTP requests to the generated project's/and/healthz, all green. - mdBook docs pages gained the Copy-as-Markdown button (it previously only worked on the marketing site's own pages), and the button's contrast was fixed on both to meet WCAG AA (it was effectively invisible in dark mode due to an inherited low-contrast color).
- Marketing site SEO: real per-page meta descriptions (previously one generic line reused
site-wide), Open Graph/Twitter Card tags, JSON-LD structured data, a sitemap, and
robots.txt. Thesitecanonical was also pointed at the framework's actual live URL,djangors.vercel.app(it had been set todjangors.dev, a domain that was never registered). - Homepage gained four new content sections (pillars, get-started, features grid, a real 4-column footer with every link verified to resolve), and an em-dash reduction pass across the site copy, README, and every doc guide.
- Corrected an earlier (Phase 11) conclusion that had closed
tick_recurring_tasks's dual-claim race as "not a real bug, misattributed to cross-crate test load." It really is a genuine, deterministic bug: whenever a concurrent tick runs within roughly the first 60 seconds after a cron boundary, a single-step schedule advance can land on an occurrence that's still due, wrongly making the earlier fix's 520 clean verification runs a matter of wall-clock luck rather than proof. Captured the tick'snowafter acquiring its advisory lock instead of before, and fixed the regression test's setup to seed a realistic, boundary-aligned overdue value instead of an arbitrary offset. Deliberately preserved the intentional one-occurrence-per-tick catch-up design (multiple concurrent ticks are meant to share backlog work, not each grab everything at once).
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.
- Added
DjangorsError::Api(ApiError), carrying an explicitStatusCode, a stable domaincode, amessage, and an optionalserde_json::Valueofdetails. Constructed viaDjangorsError::api(status, code, message)and.with_details(json);.with_details()on a built-in variant promotes it, preserving status/code/message. - Made
code()andmessage()public and addeddetails(), so custom renderers can build their own envelope without re-matching on the variant. - Added the
ApiResultExttrait (.api_err(status, code)/.api_err_msg(...)) so foreign errors convert at the?site without amap_errclosure. - Error responses now content-negotiate: a project
ErrorRendererwins, then the JSON envelope when the caller sentAccept: application/jsonor the error is anApiError, then the debug or production page. This applies to every route, not just REST. The six duplicated render-the-error branches inrouter.rscollapsed onto oneDjangorsError::rendermethod. - The JSON envelope now omits
detailsentirely when absent rather than emittingnull.
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.
- Added
djangors_db::DbExecutor, implemented for&Database(the pool) and&mut PgConnection(an open transaction, as handed to thetransactionclosure), plus theConnhandle that centralises the pool-versus-transaction dispatch. - Every
QuerySetmethod is now generic overDbExecutor:all,get,first,exists,count,aggregate,update,insert_raw,bulk_create,delete_by_pk,select_related, and the freeprefetch_related. Existing&Databasecall sites are unchanged. - Added
impl From<OrmError> for DbError(and aDbError::Ormvariant). Without it the ORM still could not be used insidetransaction, whose closure must return an error convertible toDbError— the trait bound, not the executor, was the actual blocker. - Breaking:
select_relatedandprefetch_relatedgained a trailing inferred type parameter, so turbofished calls becomeselect_related::<Related, _>(..)andprefetch_related::<Parent, Child, _>(..).
REST.
- Page size is configurable per endpoint via
ViewSetConfig::page_size; it was a hardcodedREST_PER_PAGE = 100. Settingmax_page_sizeadditionally opts the endpoint into a client-supplied?page_size=, clamped to that cap. Unparseable or out-of-range values fall back to the default rather than failing an otherwise valid request. - Added the
IsStaff,IsSuperuser, andIsReadOnlypermission policies, theAnd/Or/Notcombinators, and thePermissionExttrait (.and(),.or(),.negate()) — previously the only policies wereAllowAnyandIsAuthenticated, with no way to compose them. - Added
current_user(), which resolves the request's user across session, token, and JWT auth using the same precedence asIsAuthenticated.
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.
- Split into
auth,permissions,serializers,validation,pagination,viewsets, andopenapimodules. Every name is re-exported from the crate root, so existing imports are unaffected. - Added the
Serializer<M>trait (to_representation/to_internal_value/validate/parse) andModelSerializer<M>, the metadata-driven default.ModelSerializerwithFieldSet::all()reproduces the previous behaviour exactly. - Added
FieldSet:only/excluding/read_only/write_only, giving the read/write split the review found missing. A write to a read-only field is now rejected rather than silently dropped — a client that thinks it setidis told it did not. - Added
ValidationErrors: a{field: [messages]}map withnon_field_errors, which renders as a422DjangorsError::Apicarrying the whole map indetails. Validation failures used to collapse into oneBadRequeststring, so a client could not tell which field was wrong. - Added the
Validator<T>trait andModelSerializer::with_validatorfor object-level rules. Every registered validator runs even if an earlier one failed, so the client sees the full set at once. - Added the
Paginationtrait withPageNumberPagination(the default),LimitOffsetPagination, andCursorPagination. The strategy owns both the row window and the response envelope. - Added
ViewSetOptions<M>(serializer + pagination + config),*_with_optionshandlers forlist/retrieve/create/update, andviewset_routes_with_optionsso the options reach every handler rather than justlist.
Two real bugs found while building the above, both caught by new tests.
PATCHwas not partial.deserializewalks every column and defaults the absent ones, so aPATCHof one field would persist afalsefor every omitted boolean andNULLfor every omitted nullable — silently resetting columns the client never mentioned.deserializeis now partial-aware (deserialize_partial), and absent keys are skipped rather than defaulted.- A missing non-nullable boolean was silently accepted as
falseon a full write. That is right for an HTML checkbox and wrong for a JSON API, wherePOST {}would quietly clear a flag.ModelSerializernow reports it as required;deserializekeeps the form semantics the admin depends on.
Router state and middleware ergonomics.
- Added
Request::require_state<T>(), returning a descriptive error naming the missing type instead of the.ok_or_else(|| ...Internal("Database connection not found"))?that appeared 30 times across the framework. All 30 call sites now use it. Router::mountnow inherits state from the sub-router for types the parent has not set (the parent still wins on conflict). Previously a mounted sub-router's state was dropped, so a handler that worked standalone would fail with "state absent" once mounted.- Added
AppState::merge,contains,len, andis_empty.
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.
UnresolvedExpris now a tree:All,Any, andNegatejoin the originalAndleaf, withBitAnd/BitOr/Notoperator impls.q!(a = 1) | q!(b = 2)is Django'sQ(a=1) | Q(b=2).QuerySet::filterresolves the tree recursively, validating every field name at every depth.- Added
QuerySet::exclude, Django's.exclude(). - The variants are additive, so the ~15 existing
UnresolvedExpr::And(...)construction sites across the workspace are unchanged.
ORM: lookup suffixes. Added __ne, __iexact, __in, __isnull, __regex, and __iregex
to the existing comparison and substring lookups.
__intakes aVecvia the newValue::List, expanded into one placeholder per element while compiling. An empty list compiles toFALSErather than the syntactically invalidIN (), matching Django's__in=[].__isnullbinds no parameter at all, and= falseinverts it.- Both
suffix_to_opandsplit_field_lookupwere updated; missing the latter is what made the first round of lookup tests fail, since an unrecognised suffix is treated as part of the field name and surfaces asFieldNotFound.
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.
QuerySet::annotate(db, group_by, aggs)— Django's.values(...).annotate(...). ReturnsGroupRows carrying the group keys and named aggregate results. Ordering is dropped for grouped queries, since ordering by a column outside theGROUP BYis not valid SQL.QuerySet::values/values_listfor column projections.QuerySet::debug_sql/debug_params— Django'sstr(queryset.query). Placeholders stay as$1,$2, … rather than being interpolated, so the output is never a runnable statement.- Added
OrmError::InvalidQueryfor queryset misuse caught before reaching the database.
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:
FieldFilter— lookup suffixes (?age__gte=18,?status__in=a,b,?deleted_at__isnull=true).SearchFilter— free-text?search=across configured fields.OrderingFilter— allowlisted?ordering=-field.
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.
- New topic guide: REST Framework (
docs/src/guides/rest.md), covering ViewSets, serializers, validation, nested serializers, all three pagination strategies, filter backends, permissions, throttling, and the JSON error envelope. There was previously no REST guide at all, despite REST being the review's second-lowest-rated area. - The ORM guide gains a lookup-suffix table and sections on combining filters with
OR/AND/NOT,q_f!, andannotate/values. Every example compiles undertools/doc-code-check.
Site: mobile.
- The viewport meta was
width=device-widthwith noinitial-scale=1. - Pinned the Vite CSS target. Without one the minifier rewrote every
@media (max-width: …)into Media Queries Level 4 range syntax (@media (width <= …)), which iOS Safari only understands from 16.4 — on an older iPhone no breakpoint matched and the desktop layout was served, which defeats the point of the responsive rules. This also affected the pre-existing breakpoints. - Added breakpoints at 900/650/480px plus a
pointer: coarseblock: the hero and headline scale down, every grid reaches a single column, long URLs and code no longer force horizontal page scroll, the "Copy as Markdown" button no longer overlaps the first heading, and tap targets meet the 44px guideline.
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:
Dialect(Postgres|Sqlite) — placeholder style ($1vs?),ILIKEvsLIKE, identifier quoting, float casts.BindValue/NullKind— a driver-independent parameter.NullKindcarries the typed-NULL information thatNullBindKindused to provide inside the ORM, because Postgres rejects a mismatched parameter type even for NULL.DbRow— wrapsPgRow/SqliteRowwith typed accessors by index and by name.Connis now four-way (PgPool/PgTx/SqlitePool/SqliteTx) and its methods take(sql, &[BindValue])rather than a pre-builtsqlx::Query, which is driver-typed and cannot cross a backend boundary.Databaseis an enum over the two pools;connect()picks the backend from the URL scheme.pool()still returns&PgPooland panics on a SQLite handle, so not-yet-ported raw-SQL call sites fail loudly rather than silently misbehaving.sqlite_pool()returnsOption<&SqlitePool>.
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):
- The ~555 raw
sqlx::querycall sites indjangors-admin,-auth,-rest,-tasks, anddjangors-contrib-*still write$Nplaceholders and are Postgres-only. dj makemigrationsandplan.rshardcodeDialect::Postgres, so generated migration files are still Postgres DDL.- Therefore SQLite currently supports the ORM query path, not a whole running admin site.
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:
EXTRACT(admin date_hierarchy). SQLite has noEXTRACT. AddedDialect::extract_date_part(DatePart, col):EXTRACT(YEAR FROM col)::inton Postgres,CAST(strftime('%Y', col) AS INTEGER)on SQLite. TheCASTis load-bearing —strftimereturns a zero-padded string, so without it the comparison against a bound integer silently matches nothing.pg_advisory_xact_lock(tasks). SQLite has no advisory locks and does not need one here: it permits a single writer and a write transaction holds an exclusive database lock for its duration, so the interleaving the advisory lock defends against cannot occur. The lock is now issued only underDialect::Postgres, with that reasoning recorded at the call site rather than left as a silent omission.
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
/ vs minijinja / 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.