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.
Files changed (128) hide show
  1. django_overlay-0.1.0/.github/workflows/publish.yml +47 -0
  2. django_overlay-0.1.0/.github/workflows/tests.yml +64 -0
  3. django_overlay-0.1.0/.gitignore +21 -0
  4. django_overlay-0.1.0/.pre-commit-config.yaml +23 -0
  5. django_overlay-0.1.0/.sqlfluff +28 -0
  6. django_overlay-0.1.0/.sqlfluffignore +4 -0
  7. django_overlay-0.1.0/INDEX.md +1 -0
  8. django_overlay-0.1.0/LICENCE +21 -0
  9. django_overlay-0.1.0/PKG-INFO +59 -0
  10. django_overlay-0.1.0/README.md +40 -0
  11. django_overlay-0.1.0/django_overlay/__init__.py +0 -0
  12. django_overlay-0.1.0/django_overlay/_templating.py +22 -0
  13. django_overlay-0.1.0/django_overlay/apps.py +20 -0
  14. django_overlay-0.1.0/django_overlay/checks.py +59 -0
  15. django_overlay-0.1.0/django_overlay/constraints.py +18 -0
  16. django_overlay-0.1.0/django_overlay/fields.py +76 -0
  17. django_overlay-0.1.0/django_overlay/management/__init__.py +0 -0
  18. django_overlay-0.1.0/django_overlay/management/commands/__init__.py +0 -0
  19. django_overlay-0.1.0/django_overlay/management/commands/makemigrations.py +156 -0
  20. django_overlay-0.1.0/django_overlay/management/commands/resync_overlay_views.py +30 -0
  21. django_overlay-0.1.0/django_overlay/models.py +195 -0
  22. django_overlay-0.1.0/django_overlay/operations.py +261 -0
  23. django_overlay-0.1.0/django_overlay/sources.py +14 -0
  24. django_overlay-0.1.0/django_overlay/sql.py +138 -0
  25. django_overlay-0.1.0/django_overlay/sql_templates/ddl/drop_trigger.sql.j2 +1 -0
  26. django_overlay-0.1.0/django_overlay/sql_templates/ddl/drop_view.sql.j2 +1 -0
  27. django_overlay-0.1.0/django_overlay/sql_templates/introspection/current_schema.sql.j2 +1 -0
  28. django_overlay-0.1.0/django_overlay/sql_templates/pk_defaults/sequence_nextval.sql.j2 +1 -0
  29. django_overlay-0.1.0/django_overlay/sql_templates/pk_defaults/uuid4.sql.j2 +1 -0
  30. django_overlay-0.1.0/django_overlay/sql_templates/pk_defaults/uuid7.sql.j2 +1 -0
  31. django_overlay-0.1.0/django_overlay/sql_templates/pk_defaults/uuid7_polyfill.sql.j2 +8 -0
  32. django_overlay-0.1.0/django_overlay/sql_templates/triggers/constraint_trigger.sql.j2 +26 -0
  33. django_overlay-0.1.0/django_overlay/sql_templates/triggers/instead_of_delete.sql.j2 +13 -0
  34. django_overlay-0.1.0/django_overlay/sql_templates/triggers/instead_of_delete_soft.sql.j2 +21 -0
  35. django_overlay-0.1.0/django_overlay/sql_templates/triggers/instead_of_insert.sql.j2 +16 -0
  36. django_overlay-0.1.0/django_overlay/sql_templates/triggers/instead_of_update.sql.j2 +30 -0
  37. django_overlay-0.1.0/django_overlay/sql_templates/triggers/unique_constraint_trigger.sql.j2 +38 -0
  38. django_overlay-0.1.0/django_overlay/sql_templates/view/view.sql.j2 +18 -0
  39. django_overlay-0.1.0/django_overlay/strategies.py +46 -0
  40. django_overlay-0.1.0/django_overlay/sync.py +72 -0
  41. django_overlay-0.1.0/django_overlay/uuid7.py +17 -0
  42. django_overlay-0.1.0/docs/INDEX.md +18 -0
  43. django_overlay-0.1.0/docs/concepts/ARCHITECTURE.md +38 -0
  44. django_overlay-0.1.0/docs/concepts/DELETION.md +34 -0
  45. django_overlay-0.1.0/docs/concepts/IDS.md +32 -0
  46. django_overlay-0.1.0/docs/concepts/UNIQUENESS.md +38 -0
  47. django_overlay-0.1.0/docs/development/DEVELOPMENT.md +32 -0
  48. django_overlay-0.1.0/docs/guide/MANY_TO_MANY.md +37 -0
  49. django_overlay-0.1.0/docs/guide/USAGE.md +43 -0
  50. django_overlay-0.1.0/docs/operations/LIMITATIONS.md +23 -0
  51. django_overlay-0.1.0/docs/operations/MIGRATIONS.md +21 -0
  52. django_overlay-0.1.0/pyproject.toml +80 -0
  53. django_overlay-0.1.0/sqlfluff_libs/__init__.py +11 -0
  54. django_overlay-0.1.0/tests/__init__.py +0 -0
  55. django_overlay-0.1.0/tests/bad_fixtures_app/__init__.py +0 -0
  56. django_overlay-0.1.0/tests/bad_fixtures_app/apps.py +7 -0
  57. django_overlay-0.1.0/tests/bad_fixtures_app/models.py +23 -0
  58. django_overlay-0.1.0/tests/bad_fixtures_settings.py +36 -0
  59. django_overlay-0.1.0/tests/conftest.py +8 -0
  60. django_overlay-0.1.0/tests/django_settings.py +34 -0
  61. django_overlay-0.1.0/tests/django_tenants_settings.py +47 -0
  62. django_overlay-0.1.0/tests/tenants_app/__init__.py +0 -0
  63. django_overlay-0.1.0/tests/tenants_app/apps.py +7 -0
  64. django_overlay-0.1.0/tests/tenants_app/migrations/0001_initial.py +33 -0
  65. django_overlay-0.1.0/tests/tenants_app/migrations/__init__.py +0 -0
  66. django_overlay-0.1.0/tests/tenants_app/models.py +16 -0
  67. django_overlay-0.1.0/tests/test_checks.py +90 -0
  68. django_overlay-0.1.0/tests/test_concurrency.py +117 -0
  69. django_overlay-0.1.0/tests/test_constraints.py +15 -0
  70. django_overlay-0.1.0/tests/test_delete_model_integration.py +81 -0
  71. django_overlay-0.1.0/tests/test_field_removal_integration.py +24 -0
  72. django_overlay-0.1.0/tests/test_fields.py +93 -0
  73. django_overlay-0.1.0/tests/test_makemigrations.py +354 -0
  74. django_overlay-0.1.0/tests/test_manytomany.py +63 -0
  75. django_overlay-0.1.0/tests/test_meta_forwarding.py +65 -0
  76. django_overlay-0.1.0/tests/test_migration_reversibility.py +76 -0
  77. django_overlay-0.1.0/tests/test_models.py +326 -0
  78. django_overlay-0.1.0/tests/test_operations_deconstruct.py +55 -0
  79. django_overlay-0.1.0/tests/test_overlay_view.py +115 -0
  80. django_overlay-0.1.0/tests/test_rename_integration.py +31 -0
  81. django_overlay-0.1.0/tests/test_reserved_words.py +32 -0
  82. django_overlay-0.1.0/tests/test_resync_view.py +49 -0
  83. django_overlay-0.1.0/tests/test_soft_delete.py +70 -0
  84. django_overlay-0.1.0/tests/test_source_filtering.py +25 -0
  85. django_overlay-0.1.0/tests/test_sql.py +79 -0
  86. django_overlay-0.1.0/tests/test_strategies.py +62 -0
  87. django_overlay-0.1.0/tests/test_sync.py +8 -0
  88. django_overlay-0.1.0/tests/test_templating.py +15 -0
  89. django_overlay-0.1.0/tests/test_tenants/__init__.py +0 -0
  90. django_overlay-0.1.0/tests/test_tenants/conftest.py +18 -0
  91. django_overlay-0.1.0/tests/test_tenants/test_multi_tenant.py +43 -0
  92. django_overlay-0.1.0/tests/test_unique_constraint.py +105 -0
  93. django_overlay-0.1.0/tests/test_unique_constraint_removal_integration.py +26 -0
  94. django_overlay-0.1.0/tests/test_uuid7.py +29 -0
  95. django_overlay-0.1.0/tests/testapp/__init__.py +0 -0
  96. django_overlay-0.1.0/tests/testapp/apps.py +7 -0
  97. django_overlay-0.1.0/tests/testapp/migrations/0001_initial.py +524 -0
  98. django_overlay-0.1.0/tests/testapp/migrations/0002_reservedword_reservedwordbase.py +40 -0
  99. django_overlay-0.1.0/tests/testapp/migrations/0003_metatest_metatestbase.py +42 -0
  100. django_overlay-0.1.0/tests/testapp/migrations/0004_uniquetest_uniquetestbase.py +47 -0
  101. django_overlay-0.1.0/tests/testapp/migrations/0005_uniquetestcomposite_uniquetestcompositebase.py +49 -0
  102. django_overlay-0.1.0/tests/testapp/migrations/0006_rename_integration_test.py +54 -0
  103. django_overlay-0.1.0/tests/testapp/migrations/0007_apply_the_renames.py +28 -0
  104. django_overlay-0.1.0/tests/testapp/migrations/0008_switchablesourcetest_switchablesourcetestbase.py +40 -0
  105. django_overlay-0.1.0/tests/testapp/migrations/0009_filteredsourcetest_uniquetestnosource_and_more.py +193 -0
  106. django_overlay-0.1.0/tests/testapp/migrations/0010_removablefktest.py +35 -0
  107. django_overlay-0.1.0/tests/testapp/migrations/0011_remove_removablefktest_address.py +22 -0
  108. django_overlay-0.1.0/tests/testapp/migrations/0012_removableuniquetest.py +50 -0
  109. django_overlay-0.1.0/tests/testapp/migrations/0013_remove_removableuniquetest_ssn_unique.py +21 -0
  110. django_overlay-0.1.0/tests/testapp/migrations/0014_softdeletetest_softdeletetestnosource_and_more.py +84 -0
  111. django_overlay-0.1.0/tests/testapp/migrations/0015_digitleadingtablenametest_and_more.py +40 -0
  112. django_overlay-0.1.0/tests/testapp/migrations/__init__.py +0 -0
  113. django_overlay-0.1.0/tests/testapp/models.py +491 -0
  114. django_overlay-0.1.0/tests/testapp/registry.py +45 -0
  115. django_overlay-0.1.0/tests/testapp_shared/__init__.py +0 -0
  116. django_overlay-0.1.0/tests/testapp_shared/apps.py +7 -0
  117. django_overlay-0.1.0/tests/testapp_shared/migrations/0001_initial.py +84 -0
  118. django_overlay-0.1.0/tests/testapp_shared/migrations/0002_reservedwordsource.py +20 -0
  119. django_overlay-0.1.0/tests/testapp_shared/migrations/0003_uniquetestsource.py +20 -0
  120. django_overlay-0.1.0/tests/testapp_shared/migrations/0004_uniquetestcompositesource.py +21 -0
  121. django_overlay-0.1.0/tests/testapp_shared/migrations/0005_providerapersonsource_providerbpersonsource.py +27 -0
  122. django_overlay-0.1.0/tests/testapp_shared/migrations/0006_filteredsourcetestsource.py +21 -0
  123. django_overlay-0.1.0/tests/testapp_shared/migrations/0007_uniquetestsource_notes.py +18 -0
  124. django_overlay-0.1.0/tests/testapp_shared/migrations/0008_removableuniquetestsource.py +20 -0
  125. django_overlay-0.1.0/tests/testapp_shared/migrations/0009_softdeletetestsource.py +20 -0
  126. django_overlay-0.1.0/tests/testapp_shared/migrations/__init__.py +0 -0
  127. django_overlay-0.1.0/tests/testapp_shared/models.py +137 -0
  128. 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,21 @@
1
+ # Python
2
+ __pycache__
3
+ *.pyc
4
+ .pytest_cache
5
+ .ruff_cache
6
+ .coverage
7
+ htmlcov
8
+ .hypothesis
9
+
10
+ # IDEs & Editors
11
+ .idea
12
+
13
+ # Frameworks & Libraries
14
+ dist
15
+ poetry.lock
16
+
17
+ # venv
18
+ .venv
19
+
20
+ # Project Specific
21
+ # ETC
@@ -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,4 @@
1
+ # Bare SQL expressions (not full statements) spliced into a larger
2
+ # statement elsewhere — there's no standalone statement here for sqlfluff
3
+ # to parse on its own.
4
+ django_overlay/sql_templates/pk_defaults/
@@ -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)