Skip to content

Commit 29d967c

Browse files
committed
initial commit
0 parents  commit 29d967c

20 files changed

+819
-0
lines changed

.github/workflows/tests.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
services:
14+
postgres:
15+
image: postgres:17
16+
env:
17+
POSTGRES_DB: test_fqq
18+
POSTGRES_USER: postgres
19+
POSTGRES_PASSWORD: postgres
20+
ports:
21+
- 5432:5432
22+
options: >-
23+
--health-cmd pg_isready
24+
--health-interval 10s
25+
--health-timeout 5s
26+
--health-retries 5
27+
28+
env:
29+
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_fqq
30+
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- name: Install uv
35+
uses: astral-sh/setup-uv@v4
36+
37+
- name: Set up Python
38+
run: uv python install
39+
40+
- name: Install dependencies
41+
run: uv sync
42+
43+
- name: Run tests
44+
run: uv run pytest

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Python-generated files
2+
__pycache__/
3+
*.py[oc]
4+
build/
5+
dist/
6+
wheels/
7+
*.egg-info
8+
9+
# Virtual environments
10+
.venv
11+
12+
.env
13+
14+
.claude/

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12

README.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# django-fqq
2+
3+
Lightweight PostgreSQL schema-based multi-tenancy for Django.
4+
5+
django-fqq automatically qualifies table names with the appropriate PostgreSQL schema, routing queries to tenant-specific schemas using a simple `ContextVar`-based approach. Shared tables (like `auth`) stay in `public`, while tenant tables are directed to the active schema.
6+
7+
## How it works
8+
9+
django-fqq provides a custom database backend that wraps Django's built-in PostgreSQL backend. It overrides `quote_name` to prepend the active schema to table names — so `"my_table"` becomes `"tenant_schema"."my_table"` automatically. No changes to your models or queries required.
10+
11+
django-fqq does not modify queries if `set_schema` has not been called or has been set to a falsy value (e.g. None or "").
12+
13+
## Installation
14+
15+
```bash
16+
pip install django-fqq
17+
```
18+
19+
## Configuration
20+
21+
### 1. Set the database backend
22+
23+
```python
24+
DATABASES = {
25+
"default": {
26+
"ENGINE": "django_fqq.backend",
27+
"NAME": "your_db",
28+
# ... other options
29+
}
30+
}
31+
```
32+
33+
### 2. Add the app
34+
35+
```python
36+
INSTALLED_APPS = [
37+
# ...
38+
"django_fqq",
39+
]
40+
```
41+
42+
### 3. Configure shared apps
43+
44+
Apps listed in `FQQ_SHARED_APPS` will always use the `public` schema. Everything else uses the active tenant schema.
45+
46+
```python
47+
FQQ_SHARED_APPS = ["auth", "contenttypes"]
48+
```
49+
50+
### 4. Add the middleware (optional)
51+
52+
```python
53+
MIDDLEWARE = [
54+
"django_fqq.middleware.SchemaMiddleware",
55+
# ...
56+
]
57+
```
58+
59+
Subclass `SchemaMiddleware` and override `_get_schema(request)` to resolve the tenant schema from the request (e.g. from subdomain, header, or URL).
60+
61+
## Usage
62+
63+
### Setting the schema manually
64+
65+
```python
66+
from django_fqq.schema import set_schema, clear_schema
67+
68+
set_schema("tenant_abc")
69+
# All queries now target the "tenant_abc" schema
70+
# ...
71+
clear_schema()
72+
```
73+
74+
### In a middleware or view
75+
76+
```python
77+
from django_fqq.middleware import SchemaMiddleware
78+
79+
class MySchemaMiddleware(SchemaMiddleware):
80+
def _get_schema(self, request):
81+
# Resolve tenant from subdomain
82+
host = request.get_host().split(".")[0]
83+
return host
84+
```
85+
86+
### Using a context manager
87+
88+
`query_schema` is a context manager that sets the schema for the duration of a block and automatically restores the previous state on exit. If a schema was already active, it is restored rather than cleared.
89+
90+
```python
91+
from django_fqq.schema import query_schema
92+
93+
with query_schema("tenant_abc"):
94+
# All queries target "tenant_abc"
95+
...
96+
# Schema is restored to its previous value (or cleared if none was set)
97+
```
98+
99+
It supports nesting — each level restores the schema that was active before it:
100+
101+
```python
102+
from django_fqq.schema import set_schema, query_schema
103+
104+
set_schema("tenant_a")
105+
106+
with query_schema("tenant_b"):
107+
# queries target "tenant_b"
108+
with query_schema("tenant_c"):
109+
# queries target "tenant_c"
110+
...
111+
# back to "tenant_b"
112+
113+
# back to "tenant_a"
114+
```
115+
116+
The previous schema is also restored if an exception is raised inside the block.
117+
118+
### Context-safe
119+
120+
Schema state is stored in a `ContextVar`, so it's safe across concurrent async requests and threads.
121+
122+
## Development
123+
124+
Requires Python 3.12+ and Django 6.0+.
125+
126+
```bash
127+
uv sync
128+
uv run pytest
129+
```
130+
131+
## License
132+
133+
MIT

django_fqq/__init__.py

Whitespace-only changes.

django_fqq/apps.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from django.apps import AppConfig
2+
3+
4+
class DjangoFqqConfig(AppConfig):
5+
name = "django_fqq"
6+
default_auto_field = "django.db.models.BigAutoField"
7+
8+
def ready(self):
9+
from django.apps import apps
10+
from django.conf import settings
11+
from django.db import connections
12+
13+
from django_fqq.backend.operations import DatabaseOperations
14+
15+
shared_apps = set(getattr(settings, "FQQ_SHARED_APPS", []))
16+
17+
table_names = set()
18+
shared_table_names = set()
19+
for model in apps.get_models():
20+
table = model._meta.db_table
21+
table_names.add(table)
22+
if model._meta.app_label in shared_apps:
23+
shared_table_names.add(table)
24+
25+
DatabaseOperations._table_names = table_names
26+
DatabaseOperations._shared_table_names = shared_table_names
27+
28+
for conn in connections.all():
29+
if isinstance(conn.ops, DatabaseOperations):
30+
conn.ops._table_names = table_names
31+
conn.ops._shared_table_names = shared_table_names

django_fqq/backend/__init__.py

Whitespace-only changes.

django_fqq/backend/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.db.backends.postgresql.base import (
2+
DatabaseWrapper as PostgresqlDatabaseWrapper,
3+
)
4+
5+
from django_fqq.backend.operations import DatabaseOperations
6+
7+
8+
class DatabaseWrapper(PostgresqlDatabaseWrapper):
9+
ops_class = DatabaseOperations

django_fqq/backend/operations.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from django.db.backends.postgresql.operations import (
2+
DatabaseOperations as PostgresqlDatabaseOperations,
3+
)
4+
5+
from django_fqq.schema import get_schema
6+
7+
8+
class DatabaseOperations(PostgresqlDatabaseOperations):
9+
_table_names: set[str] = set()
10+
_shared_table_names: set[str] = set()
11+
12+
def quote_name(self, name):
13+
14+
schema = get_schema()
15+
if not schema:
16+
return super().quote_name(name)
17+
18+
if name.startswith('"'):
19+
return name
20+
21+
if "." in name:
22+
parts = name.split(".")
23+
quote = super().quote_name
24+
return ".".join(quote(part) for part in parts)
25+
26+
if name in self._table_names:
27+
if name in self._shared_table_names:
28+
schema = "public"
29+
return super().quote_name(schema) + "." + super().quote_name(name)
30+
31+
return super().quote_name(name)

django_fqq/middleware.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from django_fqq.schema import clear_schema, set_schema
2+
3+
4+
class SchemaMiddleware:
5+
def __init__(self, get_response):
6+
self.get_response = get_response
7+
8+
def __call__(self, request):
9+
# TBD: determine schema from request (e.g. subdomain, header, etc.)
10+
schema = self._get_schema(request)
11+
if schema:
12+
set_schema(schema)
13+
try:
14+
return self.get_response(request)
15+
finally:
16+
clear_schema()
17+
18+
def _get_schema(self, request):
19+
"""Override this method or replace with your own logic."""
20+
return None

0 commit comments

Comments
 (0)