django-overlay 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_overlay-0.1.0/.github/workflows/publish.yml +47 -0
- django_overlay-0.1.0/.github/workflows/tests.yml +64 -0
- django_overlay-0.1.0/.gitignore +21 -0
- django_overlay-0.1.0/.pre-commit-config.yaml +23 -0
- django_overlay-0.1.0/.sqlfluff +28 -0
- django_overlay-0.1.0/.sqlfluffignore +4 -0
- django_overlay-0.1.0/INDEX.md +1 -0
- django_overlay-0.1.0/LICENCE +21 -0
- django_overlay-0.1.0/PKG-INFO +59 -0
- django_overlay-0.1.0/README.md +40 -0
- django_overlay-0.1.0/django_overlay/__init__.py +0 -0
- django_overlay-0.1.0/django_overlay/_templating.py +22 -0
- django_overlay-0.1.0/django_overlay/apps.py +20 -0
- django_overlay-0.1.0/django_overlay/checks.py +59 -0
- django_overlay-0.1.0/django_overlay/constraints.py +18 -0
- django_overlay-0.1.0/django_overlay/fields.py +76 -0
- django_overlay-0.1.0/django_overlay/management/__init__.py +0 -0
- django_overlay-0.1.0/django_overlay/management/commands/__init__.py +0 -0
- django_overlay-0.1.0/django_overlay/management/commands/makemigrations.py +156 -0
- django_overlay-0.1.0/django_overlay/management/commands/resync_overlay_views.py +30 -0
- django_overlay-0.1.0/django_overlay/models.py +195 -0
- django_overlay-0.1.0/django_overlay/operations.py +261 -0
- django_overlay-0.1.0/django_overlay/sources.py +14 -0
- django_overlay-0.1.0/django_overlay/sql.py +138 -0
- django_overlay-0.1.0/django_overlay/sql_templates/ddl/drop_trigger.sql.j2 +1 -0
- django_overlay-0.1.0/django_overlay/sql_templates/ddl/drop_view.sql.j2 +1 -0
- django_overlay-0.1.0/django_overlay/sql_templates/introspection/current_schema.sql.j2 +1 -0
- django_overlay-0.1.0/django_overlay/sql_templates/pk_defaults/sequence_nextval.sql.j2 +1 -0
- django_overlay-0.1.0/django_overlay/sql_templates/pk_defaults/uuid4.sql.j2 +1 -0
- django_overlay-0.1.0/django_overlay/sql_templates/pk_defaults/uuid7.sql.j2 +1 -0
- django_overlay-0.1.0/django_overlay/sql_templates/pk_defaults/uuid7_polyfill.sql.j2 +8 -0
- django_overlay-0.1.0/django_overlay/sql_templates/triggers/constraint_trigger.sql.j2 +26 -0
- django_overlay-0.1.0/django_overlay/sql_templates/triggers/instead_of_delete.sql.j2 +13 -0
- django_overlay-0.1.0/django_overlay/sql_templates/triggers/instead_of_delete_soft.sql.j2 +21 -0
- django_overlay-0.1.0/django_overlay/sql_templates/triggers/instead_of_insert.sql.j2 +16 -0
- django_overlay-0.1.0/django_overlay/sql_templates/triggers/instead_of_update.sql.j2 +30 -0
- django_overlay-0.1.0/django_overlay/sql_templates/triggers/unique_constraint_trigger.sql.j2 +38 -0
- django_overlay-0.1.0/django_overlay/sql_templates/view/view.sql.j2 +18 -0
- django_overlay-0.1.0/django_overlay/strategies.py +46 -0
- django_overlay-0.1.0/django_overlay/sync.py +72 -0
- django_overlay-0.1.0/django_overlay/uuid7.py +17 -0
- django_overlay-0.1.0/docs/INDEX.md +18 -0
- django_overlay-0.1.0/docs/concepts/ARCHITECTURE.md +38 -0
- django_overlay-0.1.0/docs/concepts/DELETION.md +34 -0
- django_overlay-0.1.0/docs/concepts/IDS.md +32 -0
- django_overlay-0.1.0/docs/concepts/UNIQUENESS.md +38 -0
- django_overlay-0.1.0/docs/development/DEVELOPMENT.md +32 -0
- django_overlay-0.1.0/docs/guide/MANY_TO_MANY.md +37 -0
- django_overlay-0.1.0/docs/guide/USAGE.md +43 -0
- django_overlay-0.1.0/docs/operations/LIMITATIONS.md +23 -0
- django_overlay-0.1.0/docs/operations/MIGRATIONS.md +21 -0
- django_overlay-0.1.0/pyproject.toml +80 -0
- django_overlay-0.1.0/sqlfluff_libs/__init__.py +11 -0
- django_overlay-0.1.0/tests/__init__.py +0 -0
- django_overlay-0.1.0/tests/bad_fixtures_app/__init__.py +0 -0
- django_overlay-0.1.0/tests/bad_fixtures_app/apps.py +7 -0
- django_overlay-0.1.0/tests/bad_fixtures_app/models.py +23 -0
- django_overlay-0.1.0/tests/bad_fixtures_settings.py +36 -0
- django_overlay-0.1.0/tests/conftest.py +8 -0
- django_overlay-0.1.0/tests/django_settings.py +34 -0
- django_overlay-0.1.0/tests/django_tenants_settings.py +47 -0
- django_overlay-0.1.0/tests/tenants_app/__init__.py +0 -0
- django_overlay-0.1.0/tests/tenants_app/apps.py +7 -0
- django_overlay-0.1.0/tests/tenants_app/migrations/0001_initial.py +33 -0
- django_overlay-0.1.0/tests/tenants_app/migrations/__init__.py +0 -0
- django_overlay-0.1.0/tests/tenants_app/models.py +16 -0
- django_overlay-0.1.0/tests/test_checks.py +90 -0
- django_overlay-0.1.0/tests/test_concurrency.py +117 -0
- django_overlay-0.1.0/tests/test_constraints.py +15 -0
- django_overlay-0.1.0/tests/test_delete_model_integration.py +81 -0
- django_overlay-0.1.0/tests/test_field_removal_integration.py +24 -0
- django_overlay-0.1.0/tests/test_fields.py +93 -0
- django_overlay-0.1.0/tests/test_makemigrations.py +354 -0
- django_overlay-0.1.0/tests/test_manytomany.py +63 -0
- django_overlay-0.1.0/tests/test_meta_forwarding.py +65 -0
- django_overlay-0.1.0/tests/test_migration_reversibility.py +76 -0
- django_overlay-0.1.0/tests/test_models.py +326 -0
- django_overlay-0.1.0/tests/test_operations_deconstruct.py +55 -0
- django_overlay-0.1.0/tests/test_overlay_view.py +115 -0
- django_overlay-0.1.0/tests/test_rename_integration.py +31 -0
- django_overlay-0.1.0/tests/test_reserved_words.py +32 -0
- django_overlay-0.1.0/tests/test_resync_view.py +49 -0
- django_overlay-0.1.0/tests/test_soft_delete.py +70 -0
- django_overlay-0.1.0/tests/test_source_filtering.py +25 -0
- django_overlay-0.1.0/tests/test_sql.py +79 -0
- django_overlay-0.1.0/tests/test_strategies.py +62 -0
- django_overlay-0.1.0/tests/test_sync.py +8 -0
- django_overlay-0.1.0/tests/test_templating.py +15 -0
- django_overlay-0.1.0/tests/test_tenants/__init__.py +0 -0
- django_overlay-0.1.0/tests/test_tenants/conftest.py +18 -0
- django_overlay-0.1.0/tests/test_tenants/test_multi_tenant.py +43 -0
- django_overlay-0.1.0/tests/test_unique_constraint.py +105 -0
- django_overlay-0.1.0/tests/test_unique_constraint_removal_integration.py +26 -0
- django_overlay-0.1.0/tests/test_uuid7.py +29 -0
- django_overlay-0.1.0/tests/testapp/__init__.py +0 -0
- django_overlay-0.1.0/tests/testapp/apps.py +7 -0
- django_overlay-0.1.0/tests/testapp/migrations/0001_initial.py +524 -0
- django_overlay-0.1.0/tests/testapp/migrations/0002_reservedword_reservedwordbase.py +40 -0
- django_overlay-0.1.0/tests/testapp/migrations/0003_metatest_metatestbase.py +42 -0
- django_overlay-0.1.0/tests/testapp/migrations/0004_uniquetest_uniquetestbase.py +47 -0
- django_overlay-0.1.0/tests/testapp/migrations/0005_uniquetestcomposite_uniquetestcompositebase.py +49 -0
- django_overlay-0.1.0/tests/testapp/migrations/0006_rename_integration_test.py +54 -0
- django_overlay-0.1.0/tests/testapp/migrations/0007_apply_the_renames.py +28 -0
- django_overlay-0.1.0/tests/testapp/migrations/0008_switchablesourcetest_switchablesourcetestbase.py +40 -0
- django_overlay-0.1.0/tests/testapp/migrations/0009_filteredsourcetest_uniquetestnosource_and_more.py +193 -0
- django_overlay-0.1.0/tests/testapp/migrations/0010_removablefktest.py +35 -0
- django_overlay-0.1.0/tests/testapp/migrations/0011_remove_removablefktest_address.py +22 -0
- django_overlay-0.1.0/tests/testapp/migrations/0012_removableuniquetest.py +50 -0
- django_overlay-0.1.0/tests/testapp/migrations/0013_remove_removableuniquetest_ssn_unique.py +21 -0
- django_overlay-0.1.0/tests/testapp/migrations/0014_softdeletetest_softdeletetestnosource_and_more.py +84 -0
- django_overlay-0.1.0/tests/testapp/migrations/0015_digitleadingtablenametest_and_more.py +40 -0
- django_overlay-0.1.0/tests/testapp/migrations/__init__.py +0 -0
- django_overlay-0.1.0/tests/testapp/models.py +491 -0
- django_overlay-0.1.0/tests/testapp/registry.py +45 -0
- django_overlay-0.1.0/tests/testapp_shared/__init__.py +0 -0
- django_overlay-0.1.0/tests/testapp_shared/apps.py +7 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0001_initial.py +84 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0002_reservedwordsource.py +20 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0003_uniquetestsource.py +20 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0004_uniquetestcompositesource.py +21 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0005_providerapersonsource_providerbpersonsource.py +27 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0006_filteredsourcetestsource.py +21 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0007_uniquetestsource_notes.py +18 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0008_removableuniquetestsource.py +20 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/0009_softdeletetestsource.py +20 -0
- django_overlay-0.1.0/tests/testapp_shared/migrations/__init__.py +0 -0
- django_overlay-0.1.0/tests/testapp_shared/models.py +137 -0
- django_overlay-0.1.0/uv.lock +987 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- master
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v3
|
|
16
|
+
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
uses: actions/setup-python@v4
|
|
19
|
+
with:
|
|
20
|
+
python-version: '3.12'
|
|
21
|
+
|
|
22
|
+
- name: Install build tools
|
|
23
|
+
run: |
|
|
24
|
+
python -m pip install --upgrade pip setuptools wheel build
|
|
25
|
+
|
|
26
|
+
- name: Check if version changed
|
|
27
|
+
id: check_version
|
|
28
|
+
run: |
|
|
29
|
+
LOCAL_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
30
|
+
PUBLISHED_VERSION=$(curl -s -f https://pypi.org/pypi/django-overlay/json | python -c "import json, sys; print(json.load(sys.stdin).get('info', {}).get('version', ''))" || echo "")
|
|
31
|
+
echo "Local version: $LOCAL_VERSION, published version: $PUBLISHED_VERSION"
|
|
32
|
+
if [ "$LOCAL_VERSION" = "$PUBLISHED_VERSION" ]; then
|
|
33
|
+
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
34
|
+
else
|
|
35
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
- name: Build package
|
|
39
|
+
if: steps.check_version.outputs.should_publish == 'true'
|
|
40
|
+
run: |
|
|
41
|
+
python -m build
|
|
42
|
+
|
|
43
|
+
- name: Publish to PyPI
|
|
44
|
+
if: steps.check_version.outputs.should_publish == 'true'
|
|
45
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
46
|
+
with:
|
|
47
|
+
packages-dir: dist
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- master
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- master
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ['3.11', '3.12', '3.13', '3.14']
|
|
17
|
+
os-version: [ubuntu-22.04]
|
|
18
|
+
|
|
19
|
+
runs-on: ${{ matrix.os-version }}
|
|
20
|
+
|
|
21
|
+
services:
|
|
22
|
+
postgres:
|
|
23
|
+
image: postgres:16
|
|
24
|
+
env:
|
|
25
|
+
POSTGRES_USER: postgres
|
|
26
|
+
POSTGRES_PASSWORD: postgres
|
|
27
|
+
POSTGRES_DB: django_overlay_test
|
|
28
|
+
ports:
|
|
29
|
+
- 5432:5432
|
|
30
|
+
options: >-
|
|
31
|
+
--health-cmd pg_isready
|
|
32
|
+
--health-interval 10s
|
|
33
|
+
--health-timeout 5s
|
|
34
|
+
--health-retries 5
|
|
35
|
+
|
|
36
|
+
env:
|
|
37
|
+
POSTGRES_HOST: localhost
|
|
38
|
+
POSTGRES_PORT: 5432
|
|
39
|
+
POSTGRES_USER: postgres
|
|
40
|
+
POSTGRES_PASSWORD: postgres
|
|
41
|
+
POSTGRES_DB: django_overlay_test
|
|
42
|
+
POSTGRES_TENANTS_DB: django_overlay_tenants_test
|
|
43
|
+
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v3
|
|
46
|
+
|
|
47
|
+
- name: Install uv
|
|
48
|
+
uses: astral-sh/setup-uv@v3
|
|
49
|
+
with:
|
|
50
|
+
python-version: ${{ matrix.python-version }}
|
|
51
|
+
|
|
52
|
+
- name: Install dependencies
|
|
53
|
+
run: |
|
|
54
|
+
uv sync
|
|
55
|
+
|
|
56
|
+
- name: Run tests with coverage
|
|
57
|
+
run: |
|
|
58
|
+
uv run pytest --cov=django_overlay --cov-report=term-missing
|
|
59
|
+
|
|
60
|
+
- name: Run multi-tenant tests
|
|
61
|
+
env:
|
|
62
|
+
DJANGO_SETTINGS_MODULE: tests.django_tenants_settings
|
|
63
|
+
run: |
|
|
64
|
+
uv run pytest tests/test_tenants -o addopts="" --create-db
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v5.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: end-of-file-fixer
|
|
6
|
+
- id: trailing-whitespace
|
|
7
|
+
- id: check-yaml
|
|
8
|
+
- id: check-added-large-files
|
|
9
|
+
|
|
10
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
11
|
+
rev: v0.9.0
|
|
12
|
+
hooks:
|
|
13
|
+
- id: ruff
|
|
14
|
+
args: [ --fix ]
|
|
15
|
+
- id: ruff-format
|
|
16
|
+
|
|
17
|
+
- repo: https://github.com/sqlfluff/sqlfluff
|
|
18
|
+
rev: 4.2.2
|
|
19
|
+
hooks:
|
|
20
|
+
- id: sqlfluff-lint
|
|
21
|
+
files: \.sql\.j2$
|
|
22
|
+
types: [text]
|
|
23
|
+
additional_dependencies: ["jinja2"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[sqlfluff]
|
|
2
|
+
dialect = postgres
|
|
3
|
+
templater = jinja
|
|
4
|
+
max_line_length = 120
|
|
5
|
+
library_path = sqlfluff_libs
|
|
6
|
+
# AM04: ambiguous column count in a SELECT * (n/a, we always name columns).
|
|
7
|
+
# LT02/LT03/LT05/LT14/CV06/CV07/CP01: pure layout/formatting rules that
|
|
8
|
+
# fight the compact-but-valid whitespace real Jinja for-loops produce
|
|
9
|
+
# (e.g. a comma-joined column list rendered on one line). These templates'
|
|
10
|
+
# actual SQL correctness is verified empirically by the test suite
|
|
11
|
+
# executing every one of them against real Postgres, not by sqlfluff.
|
|
12
|
+
exclude_rules = AM04, LT02, LT03, LT05, LT14, CV06, CV07, CP01
|
|
13
|
+
|
|
14
|
+
[sqlfluff:templater:jinja:context]
|
|
15
|
+
tenant_schema = public
|
|
16
|
+
view_name = person_view
|
|
17
|
+
base_table = person
|
|
18
|
+
function_name = person_view_instead_of_update
|
|
19
|
+
pk_column = id
|
|
20
|
+
pk_default_expr = nextval('"public"."person_id_seq"')
|
|
21
|
+
columns = ["id", "first_name", "age"]
|
|
22
|
+
trigger_name = overlayfk_note_person_id
|
|
23
|
+
referencing_table = note
|
|
24
|
+
column = person_id
|
|
25
|
+
table_name = note
|
|
26
|
+
negate = True
|
|
27
|
+
source = {"schema": "public", "table": "personsource", "id_column": "id", "extra_where": "", "negate": True}
|
|
28
|
+
target_tables = [{"schema": "public", "table": "person", "id_column": "id", "negate": False}, {"schema": "public", "table": "personsource", "id_column": "id", "negate": True}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
docs/INDEX.md
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Işık Kaplan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-overlay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Writable Django models layered over read-only, shared source tables — one view, no coalescing at read time.
|
|
5
|
+
Project-URL: Homepage, https://github.com/isik-kaplan/django-overlay
|
|
6
|
+
Project-URL: Repository, https://github.com/isik-kaplan/django-overlay
|
|
7
|
+
Author-email: Işık Kaplan <isik.kaplan@outlook.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENCE
|
|
10
|
+
Keywords: django,multi-tenant,postgres,views
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: django>=4.2
|
|
17
|
+
Requires-Dist: jinja2>=3.1.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# django-overlay
|
|
21
|
+
|
|
22
|
+
A writable Django model layered on top of a read-only table you don't own —
|
|
23
|
+
one model to query, no `COALESCE`/`FULL OUTER JOIN` at read time.
|
|
24
|
+
|
|
25
|
+
## The problem
|
|
26
|
+
|
|
27
|
+
You have a big shared read-only table (a vendor import, a third-party
|
|
28
|
+
dataset) and a small table of tenant edits on top of it. You want one Django model to
|
|
29
|
+
query, writes landing only in your own table, an edit that copies the row
|
|
30
|
+
over on first touch, and a foreign key that can still point at it even
|
|
31
|
+
though Postgres can't put a real FK on a view.
|
|
32
|
+
|
|
33
|
+
This is [OverlayFS](https://docs.kernel.org/filesystems/overlayfs.html)
|
|
34
|
+
applied to Postgres: a writable "upper" table merged with one read-only
|
|
35
|
+
"lower" table into one "merged" view. Each overlay model has exactly one
|
|
36
|
+
source table, though which physical table that is can be resolved per
|
|
37
|
+
tenant — a single, tenant-scoped source, not a union of several.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from django.db import models
|
|
41
|
+
|
|
42
|
+
from django_overlay.models import OverlayMeta, OverlayModel
|
|
43
|
+
from django_overlay.sources import SourceTable
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Person(OverlayModel):
|
|
47
|
+
first_name = models.CharField(max_length=100)
|
|
48
|
+
|
|
49
|
+
class OverlayMeta(OverlayMeta):
|
|
50
|
+
@staticmethod
|
|
51
|
+
def get_source():
|
|
52
|
+
return SourceTable(schema="external_source", table="people")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Person.objects.create(first_name="Jane") # goes straight into the writable table
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
See [docs/INDEX.md](docs/INDEX.md) for everything else — usage, how it
|
|
59
|
+
works, ids, uniqueness, deletion, migrations, and running the tests.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# django-overlay
|
|
2
|
+
|
|
3
|
+
A writable Django model layered on top of a read-only table you don't own —
|
|
4
|
+
one model to query, no `COALESCE`/`FULL OUTER JOIN` at read time.
|
|
5
|
+
|
|
6
|
+
## The problem
|
|
7
|
+
|
|
8
|
+
You have a big shared read-only table (a vendor import, a third-party
|
|
9
|
+
dataset) and a small table of tenant edits on top of it. You want one Django model to
|
|
10
|
+
query, writes landing only in your own table, an edit that copies the row
|
|
11
|
+
over on first touch, and a foreign key that can still point at it even
|
|
12
|
+
though Postgres can't put a real FK on a view.
|
|
13
|
+
|
|
14
|
+
This is [OverlayFS](https://docs.kernel.org/filesystems/overlayfs.html)
|
|
15
|
+
applied to Postgres: a writable "upper" table merged with one read-only
|
|
16
|
+
"lower" table into one "merged" view. Each overlay model has exactly one
|
|
17
|
+
source table, though which physical table that is can be resolved per
|
|
18
|
+
tenant — a single, tenant-scoped source, not a union of several.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from django.db import models
|
|
22
|
+
|
|
23
|
+
from django_overlay.models import OverlayMeta, OverlayModel
|
|
24
|
+
from django_overlay.sources import SourceTable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Person(OverlayModel):
|
|
28
|
+
first_name = models.CharField(max_length=100)
|
|
29
|
+
|
|
30
|
+
class OverlayMeta(OverlayMeta):
|
|
31
|
+
@staticmethod
|
|
32
|
+
def get_source():
|
|
33
|
+
return SourceTable(schema="external_source", table="people")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Person.objects.create(first_name="Jane") # goes straight into the writable table
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
See [docs/INDEX.md](docs/INDEX.md) for everything else — usage, how it
|
|
40
|
+
works, ids, uniqueness, deletion, migrations, and running the tests.
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared Jinja env for sql_templates/. Split out of sql.py so
|
|
2
|
+
strategies.py can render templates too without a circular import."""
|
|
3
|
+
|
|
4
|
+
import jinja2
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _qi(name: str) -> str:
|
|
8
|
+
"""Jinja filter: quote a Postgres identifier, e.g. reserved words like `order`."""
|
|
9
|
+
return '"' + name.replace('"', '""') + '"'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_ENV = jinja2.Environment(
|
|
13
|
+
loader=jinja2.PackageLoader("django_overlay", "sql_templates"),
|
|
14
|
+
autoescape=False,
|
|
15
|
+
trim_blocks=True,
|
|
16
|
+
lstrip_blocks=True,
|
|
17
|
+
)
|
|
18
|
+
_ENV.filters["qi"] = _qi
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render(template_name: str, **context) -> str:
|
|
22
|
+
return _ENV.get_template(template_name).render(**context).strip()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DjangoOverlayConfig(AppConfig):
|
|
6
|
+
name = "django_overlay"
|
|
7
|
+
verbose_name = "Django Overlay"
|
|
8
|
+
default_auto_field = "django.db.models.AutoField"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from . import checks # also registers the manage.py check
|
|
12
|
+
|
|
13
|
+
# Runs on every process boot, not just `manage.py check` — a bad
|
|
14
|
+
# FK/M2M against an overlay model should fail loudly right away.
|
|
15
|
+
errors = checks.check_no_plain_fk_to_overlay_models(None)
|
|
16
|
+
if errors:
|
|
17
|
+
raise ImproperlyConfigured(
|
|
18
|
+
"django_overlay found unsafe references to overlay view models:\n"
|
|
19
|
+
+ "\n".join(f"{error.id}: {error.msg}" for error in errors)
|
|
20
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from django.apps import apps
|
|
2
|
+
from django.core import checks
|
|
3
|
+
from django.db import models
|
|
4
|
+
|
|
5
|
+
from .fields import OverlayForeignKey
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@checks.register(checks.Tags.models)
|
|
9
|
+
def check_no_plain_fk_to_overlay_models(app_configs, **kwargs):
|
|
10
|
+
"""Fails `manage.py check` for a plain ForeignKey/OneToOneField
|
|
11
|
+
pointing at an OverlayModel, or a ManyToManyField pointing at one
|
|
12
|
+
without an explicit through= model."""
|
|
13
|
+
errors = []
|
|
14
|
+
for model in apps.get_models(include_auto_created=True):
|
|
15
|
+
for field in model._meta.get_fields():
|
|
16
|
+
if not field.is_relation:
|
|
17
|
+
continue
|
|
18
|
+
related_model = field.related_model
|
|
19
|
+
if related_model is None or not getattr(related_model, "_is_overlay_view_model", False):
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
# M2M fields are never "concrete" (even the declaring side), so
|
|
23
|
+
# check the type directly instead.
|
|
24
|
+
if isinstance(field, models.ManyToManyField):
|
|
25
|
+
if not field.remote_field.through._meta.auto_created:
|
|
26
|
+
continue
|
|
27
|
+
errors.append(
|
|
28
|
+
checks.Error(
|
|
29
|
+
f"{model.__name__}.{field.name} is a plain ManyToManyField pointing at "
|
|
30
|
+
f"{related_model.__name__}, which is a django_overlay view model. Django's "
|
|
31
|
+
"auto-created through table would use a plain ForeignKey, which can't hold "
|
|
32
|
+
"a real FK constraint against a view.",
|
|
33
|
+
hint="Use django_overlay.fields.OverlayManyToManyField (through=... is "
|
|
34
|
+
"required) or write your own explicit through model with OverlayForeignKey "
|
|
35
|
+
"fields.",
|
|
36
|
+
obj=field,
|
|
37
|
+
id="django_overlay.E002",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
# Every concrete, non-M2M relation field Django has is a
|
|
43
|
+
# ForeignKey or OneToOneField (or a subclass), so this is
|
|
44
|
+
# already effectively "is a plain FK/O2O".
|
|
45
|
+
if not getattr(field, "concrete", False):
|
|
46
|
+
continue
|
|
47
|
+
if isinstance(field, OverlayForeignKey):
|
|
48
|
+
continue
|
|
49
|
+
errors.append(
|
|
50
|
+
checks.Error(
|
|
51
|
+
f"{model.__name__}.{field.name} is a plain {type(field).__name__} pointing at "
|
|
52
|
+
f"{related_model.__name__}, which is a django_overlay view model. Postgres cannot "
|
|
53
|
+
f"hold a real FK constraint against a view.",
|
|
54
|
+
hint=f"Use django_overlay.fields.OverlayForeignKey instead of {type(field).__name__}.",
|
|
55
|
+
obj=field,
|
|
56
|
+
id="django_overlay.E001",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return errors
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
from .models import OverlayConfigurationError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OverlayUniqueConstraint(models.UniqueConstraint):
|
|
7
|
+
"""Also guards against a value already present in the source table.
|
|
8
|
+
Postgres can't enforce that across a view's UNION ALL, so that half is
|
|
9
|
+
a trigger instead (see AddOverlayUniqueConstraint)."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, *args, **kwargs):
|
|
12
|
+
if kwargs.get("condition") is not None:
|
|
13
|
+
raise OverlayConfigurationError(
|
|
14
|
+
"OverlayUniqueConstraint doesn't support condition= — the source-vs-base trigger "
|
|
15
|
+
"has no way to apply it, so it would silently check for collisions the condition "
|
|
16
|
+
"should have excluded."
|
|
17
|
+
)
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
from .models import OverlayConfigurationError
|
|
4
|
+
from .strategies import negates_source_ids
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OverlayForeignKey(models.ForeignKey):
|
|
8
|
+
"""FK for pointing at an OverlayModel. Postgres can't hold a real FK
|
|
9
|
+
against a view, so this never creates a db constraint; referential
|
|
10
|
+
integrity instead comes from a constraint trigger (see
|
|
11
|
+
operations.AddOverlayConstraint)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, to, *args, **kwargs):
|
|
14
|
+
if "db_constraint" in kwargs:
|
|
15
|
+
raise OverlayConfigurationError(
|
|
16
|
+
"OverlayForeignKey always sets db_constraint=False (Postgres can't hold a real FK "
|
|
17
|
+
"against a view) — don't pass db_constraint yourself."
|
|
18
|
+
)
|
|
19
|
+
kwargs["db_constraint"] = False
|
|
20
|
+
super().__init__(to, *args, **kwargs)
|
|
21
|
+
|
|
22
|
+
def deconstruct(self):
|
|
23
|
+
name, path, args, kwargs = super().deconstruct()
|
|
24
|
+
kwargs.pop("db_constraint", None)
|
|
25
|
+
return name, path, args, kwargs
|
|
26
|
+
|
|
27
|
+
def trigger_name(self, model) -> str:
|
|
28
|
+
return f"overlayfk_{model._meta.db_table}_{self.column}"[:63]
|
|
29
|
+
|
|
30
|
+
def target_tables(self, tenant_schema: str) -> list[dict]:
|
|
31
|
+
"""The target's base table plus its source, if any — see target_tables_for()."""
|
|
32
|
+
return target_tables_for(self.remote_field.model, tenant_schema)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def target_tables_for(target, tenant_schema: str) -> list[dict]:
|
|
36
|
+
"""[{"schema", "table", "id_column", "negate", "soft_delete"}, ...] for
|
|
37
|
+
`target`'s base table (never negated) plus its source, if any (negated
|
|
38
|
+
for a NEGATIVE_ID target). Takes the target model directly, not `self`, so a
|
|
39
|
+
migration operation can call it against a *live* model even when the
|
|
40
|
+
referencing field only exists in migration-historical state."""
|
|
41
|
+
tables = [
|
|
42
|
+
{
|
|
43
|
+
"schema": tenant_schema,
|
|
44
|
+
"table": target._base_model._meta.db_table,
|
|
45
|
+
"id_column": "id",
|
|
46
|
+
"negate": False,
|
|
47
|
+
"soft_delete": target._overlay_meta.soft_delete,
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
source = target.get_source()
|
|
51
|
+
if source is not None:
|
|
52
|
+
negate = negates_source_ids(target._overlay_meta.strategy)
|
|
53
|
+
tables.append(
|
|
54
|
+
{
|
|
55
|
+
"schema": source.schema,
|
|
56
|
+
"table": source.table,
|
|
57
|
+
"id_column": source.id_column,
|
|
58
|
+
"negate": negate,
|
|
59
|
+
"soft_delete": False,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return tables
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OverlayOneToOneField(OverlayForeignKey, models.OneToOneField):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OverlayManyToManyField(models.ManyToManyField):
|
|
70
|
+
"""M2M field for relating to an OverlayModel. Requires an explicit
|
|
71
|
+
through= model with OverlayForeignKey fields — Django's auto-created
|
|
72
|
+
through table always uses a plain ForeignKey, which can never be safe
|
|
73
|
+
against a view."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, to, *args, through, **kwargs):
|
|
76
|
+
super().__init__(to, *args, through=through, **kwargs)
|
|
File without changes
|
|
File without changes
|