Skip to content

[pull] master from django-tenants:master - #87

Merged
pull[bot] merged 10 commits into
Dresdn:masterfrom
django-tenants:master
Aug 5, 2026
Merged

pull[bot] merged 10 commits into
Dresdn:masterfrom
django-tenants:master

Conversation

@pull

@pull pull Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

tomturner and others added 10 commits August 5, 2026 12:54
`_cursor()` has a dedicated branch for named cursors: a named cursor can only
execute one statement, so `SET search_path` has to run on a separate cursor
before the query does. That branch had no test coverage, and it is the one place
where a mistake reads as working code -- a named cursor resolves its table names
when Postgres runs DECLARE, so a missing `SET search_path` returns another
tenant's rows rather than raising.

The tests assert against `pg_cursors`, Postgres' own view of the cursors open in
the session, so they cannot pass by silently falling back to a client-side
fetch -- which is the regression most worth catching, because it is invisible in
the returned rows.

Verified by mutation:

  - making `_cursor()` skip `SET search_path` for named cursors fails three of
    the five, returning tenant 2's rows where tenant 1's were asserted
  - setting DISABLE_SERVER_SIDE_CURSORS globally fails the guard test with an
    empty pg_cursors

Passes on both drivers: Django 6.0.8 / psycopg 3.2.13 and Django 5.2.9 /
psycopg2 2.9.12, against PostgreSQL 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tenant-isolation

Add tenant-isolation tests for QuerySet.iterator() (server-side cursors)
Django 6.1rc1 is on PyPI and django-tenants currently fails to import on it.

1. Circular import (postgresql_backend/introspection.py)

Django 6.1 added `from django.db.backends.postgresql.base import psycopg_version`
to its PostgreSQL introspection module. `base` already imports `introspection`, so
the two now form a cycle. Django is unaffected because `base` is always imported
first in normal use; the cycle only breaks for a third party importing
`introspection` first, which we did on line 1:

    ImportError: cannot import name 'DatabaseIntrospection' from partially
    initialized module 'django.db.backends.postgresql.introspection'

Nothing imported at all, so manage.py could not start. Fixed by importing the
backend's base module first. Pure ordering -- no behaviour change on any version.

2. find(all=True) removed (tests/staticfiles/test_finders.py)

The keyword was renamed rather than moved to a new method: 4.2 takes `all`, 5.2
renamed it to `find_all` while still tolerating `all` via **kwargs, and 6.1 dropped
**kwargs so `all=True` raises TypeError. Since 4.2 is still supported, the spelling
is chosen from django.VERSION. TenantFileSystemFinder does not override find(), so
this is test-only.

3. pyproject.toml: `django>=2.1,<6.2`, plus the 6.1 classifier.

4. CI: `==6.1.*` in the matrix, excluding Python 3.10/3.11 (6.1 requires 3.12+).

The Django install gains `--pre` so a series that has not had its final release yet
resolves to its release candidate. Whether pip falls back to a pre-release on its
own turned out to be pip-version dependent -- pip 26.2.1 resolved `Django==6.1.*`
to 6.1rc1, pip 25.0.1 reported "No matching distribution found" -- and an `==X.Y.*`
specifier still keeps every other series on its final release.

Verified with ./run_tests.sh -- both executors plus the create_tenant and
clone_tenant integration steps -- on Python 3.12 / PostgreSQL 17 / psycopg3:

    Django 4.2.30    exit 0 | executors OK 2/2 | unit FAIL/ERROR 0 | clone_tenant 1
    Django 5.2.17    exit 0 | executors OK 2/2 | unit FAIL/ERROR 0 | clone_tenant 1
    Django 6.0.8     exit 0 | executors OK 2/2 | unit FAIL/ERROR 0 | clone_tenant 1
    Django 6.1rc1    exit 0 | executors OK 2/2 | unit FAIL/ERROR 0 | clone_tenant 1

Closes #1246

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Django 4.2 LTS reached end of extended support on 7 April 2026 and is listed
under "Unsupported previous releases" upstream -- it receives no further security
or bug fixes. The oldest supported Django is now 5.2 LTS.

  - pyproject.toml: `django>=5.2,<6.2`, and the 4.2 classifier is dropped.
    `requires-python` stays at >=3.10, which 5.2 still supports.
  - CI: the `==4.2.*` matrix entry and its Python 3.13 exclude are removed,
    freeing three jobs per run.

With 4.2 gone, three version shims become dead code and are removed:

  - `postgresql_backend/base.py`: the try/except around importing `is_psycopg3`
    from `psycopg_any`. That module has existed since 4.2, so the ImportError
    branch can no longer be reached.
  - `tests/test_tenants.py`: the `< (5, 2)` fork over `_pre_setup` being an
    instance method versus a classmethod, plus its now-unused imports.
  - `tests/staticfiles/test_finders.py`: the runtime choice between the `all` and
    `find_all` keywords added for 6.1 support. 5.2 onwards accepts `find_all`, so
    it is now unconditional.
  - `docs/files.rst`: the DEFAULT_FILE_STORAGE fallback noted for "django < 4.2".

Verified with ./run_tests.sh -- both executors plus the create_tenant and
clone_tenant integration steps -- on Python 3.12 / PostgreSQL 17 / psycopg3:

    Django 5.2.17    exit 0 | 130 tests | executors OK 2/2 | FAIL 0 | clone 1
    Django 6.0.8     exit 0 | 130 tests | executors OK 2/2 | FAIL 0 | clone 1
    Django 6.1rc1    exit 0 | 130 tests | executors OK 2/2 | FAIL 0 | clone 1

The new pin is enforced, not just declared: installing against 4.2 now fails to
resolve rather than silently working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop Django 4.2 support (EOL 7 April 2026)
Fixes #1244.

`_cursor()` special-cased psycopg3, never reusing the caller's cursor for
`SET search_path` in order to avoid the recursion 340f137 describes. Opening a raw
`self.connection.cursor()` instead meant the statement bypassed Django's cursor
wrapper -- so on psycopg3 it was invisible to assertNumQueries while on psycopg2 it
was counted. That is why test_switching_search_path failed on psycopg2: the
assertions had been written against psycopg3's accounting rather than the actual
behaviour.

Following the approach django-pgschemas takes, a `_setting_search_path` flag makes
the re-entry a no-op instead of recursing, so the caller's cursor can be reused on
both drivers and the statement is visible on both. The SET logic moves out of
`_cursor()` into `_handle_search_path()`, which now branches only on whether a
cursor was supplied -- never on the driver.

This changes no database traffic. With `log_statement='all'`, master and this branch
both send exactly 646 `SET search_path` statements for the same test; only their
visibility to the cursor wrapper differs. The updated counts in
test_switching_search_path{,_limited_calls} therefore make those assertions more
accurate rather than looser -- they now count statements that were always executed.

Also included, both from the same comparison:

  - `rollback()` clears the cached search_path. A session-level `SET` is
    transactional in PostgreSQL, so aborting the transaction that issued it reverts
    the search_path; with TENANT_LIMIT_SET_CALLS on, the cache could otherwise
    outlive a value the database had already discarded.
  - `_get_cursor_search_paths()` validates every part of the search path rather
    than only `self.schema_name`, which brings PG_EXTRA_SEARCH_PATHS under the same
    check before it is interpolated into the SET statement.

CI gains a `psycopg-version` axis so the psycopg2 fallback is actually exercised.

Verified with ./run_tests.sh -- both executors plus the create_tenant and
clone_tenant integration steps -- on Python 3.12 / PostgreSQL 17:

    Dj 5.2.17   psycopg3   exit 0 | 130 tests | exec 2/2 | FAIL 0 | clone 1
    Dj 5.2.17   psycopg2   exit 0 | 130 tests | exec 2/2 | FAIL 0 | clone 1
    Dj 6.0.8    psycopg3   exit 0 | 130 tests | exec 2/2 | FAIL 0 | clone 1
    Dj 6.0.8    psycopg2   exit 0 | 130 tests | exec 2/2 | FAIL 0 | clone 1
    Dj 6.1rc1   psycopg3   exit 0 | 130 tests | exec 2/2 | FAIL 0 | clone 1
    Dj 6.1rc1   psycopg2   exit 0 | 130 tests | exec 2/2 | FAIL 0 | clone 1

An earlier revision of this branch also failed on Django 4.2 + psycopg3 with
`9 != 6`, because Django's own compose_sql/mogrify opened a second cursor per
statement there and each one triggered a SET. Dropping 4.2 in #1248 removed that
case, so no version-conditional assertion is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…arch-path

Make search_path handling driver-agnostic (fixes psycopg2 test failures)
Minor rather than major despite dropping Django 4.2: previous releases have
removed Django versions without a major bump (0f559a3 removed 3.1 and 4.0,
f3f72d3 reworked supported versions), and 3.13.0 shipped a Breaking section of
its own while staying on a minor.

Contents:

  - Breaking: Django 4.2 support dropped, EOL 7 April 2026 (#1248)
  - New: Django 6.1 support (#1247, #1246)
  - Fixes: search_path handling made driver-agnostic, so the psycopg2 fallback
    has a passing suite and is tested in CI (#1249, #1244)
  - Tests: tenant-isolation coverage for QuerySet.iterator() (#1245)
  - Docs: "How it works" links in the README and docs index (#1243)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pull pull Bot locked and limited conversation to collaborators Aug 5, 2026
@pull pull Bot added the ⤵️ pull label Aug 5, 2026
@pull
pull Bot merged commit ba42724 into Dresdn:master Aug 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant