django-postgres-denormalize 1.0.0__py3-none-any.whl
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_postgres_denormalize-1.0.0.dist-info/METADATA +154 -0
- django_postgres_denormalize-1.0.0.dist-info/RECORD +15 -0
- django_postgres_denormalize-1.0.0.dist-info/WHEEL +5 -0
- django_postgres_denormalize-1.0.0.dist-info/licenses/LICENSE +23 -0
- django_postgres_denormalize-1.0.0.dist-info/top_level.txt +1 -0
- postgres_denormalize/__init__.py +36 -0
- postgres_denormalize/apps.py +18 -0
- postgres_denormalize/batch.py +130 -0
- postgres_denormalize/chains.py +121 -0
- postgres_denormalize/checks.py +381 -0
- postgres_denormalize/conf.py +170 -0
- postgres_denormalize/fields.py +716 -0
- postgres_denormalize/operations.py +139 -0
- postgres_denormalize/py.typed +0 -0
- postgres_denormalize/triggers.py +597 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-postgres-denormalize
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Trigger-maintained copies of ancestor columns for Django on PostgreSQL.
|
|
5
|
+
Author-email: DjanQuiltDB Project <djanquiltdb@portal42.net>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/djanquiltdb/django-postgres-denormalize
|
|
8
|
+
Project-URL: Documentation, https://django-postgres-denormalize.readthedocs.io/
|
|
9
|
+
Project-URL: Source, https://github.com/djanquiltdb/django-postgres-denormalize
|
|
10
|
+
Project-URL: Issues, https://github.com/djanquiltdb/django-postgres-denormalize/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/djanquiltdb/django-postgres-denormalize/blob/master/CHANGELOG.rst
|
|
12
|
+
Keywords: django,database,postgresql,denormalization,triggers
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Framework :: Django
|
|
21
|
+
Classifier: Framework :: Django :: 6.0
|
|
22
|
+
Classifier: Framework :: Django :: 6.1
|
|
23
|
+
Classifier: Topic :: Database
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.14
|
|
26
|
+
Description-Content-Type: text/x-rst
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: django<7.0,>=6.0
|
|
29
|
+
Requires-Dist: django-pgtrigger<5,>=4.17
|
|
30
|
+
Provides-Extra: lint
|
|
31
|
+
Requires-Dist: ruff==0.15.21; extra == "lint"
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: coverage; extra == "test"
|
|
34
|
+
Requires-Dist: psycopg[binary]>=3.0.0; extra == "test"
|
|
35
|
+
Requires-Dist: dj-database-url; extra == "test"
|
|
36
|
+
Provides-Extra: docs
|
|
37
|
+
Requires-Dist: sphinx<10,>=8; extra == "docs"
|
|
38
|
+
Requires-Dist: sphinx-rtd-theme<4,>=3.1; extra == "docs"
|
|
39
|
+
Requires-Dist: psycopg[binary]>=3.0.0; extra == "docs"
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: django-postgres-denormalize[docs,lint,test]; extra == "dev"
|
|
42
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
43
|
+
Requires-Dist: tox>=4.21; extra == "dev"
|
|
44
|
+
Requires-Dist: build; extra == "dev"
|
|
45
|
+
Requires-Dist: twine; extra == "dev"
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
.. image:: https://raw.githubusercontent.com/DjanQuiltDB/django-postgres-denormalize/master/assets/icon-128.png
|
|
49
|
+
:alt: django-postgres-denormalize
|
|
50
|
+
:width: 128
|
|
51
|
+
|
|
52
|
+
===========================
|
|
53
|
+
django-postgres-denormalize
|
|
54
|
+
===========================
|
|
55
|
+
|
|
56
|
+
.. image:: https://img.shields.io/pypi/v/django-postgres-denormalize.svg
|
|
57
|
+
:target: https://pypi.org/project/django-postgres-denormalize/
|
|
58
|
+
:alt: PyPI
|
|
59
|
+
|
|
60
|
+
.. image:: https://img.shields.io/pypi/pyversions/django-postgres-denormalize.svg
|
|
61
|
+
:target: https://pypi.org/project/django-postgres-denormalize/
|
|
62
|
+
:alt: Supported Python versions
|
|
63
|
+
|
|
64
|
+
.. image:: https://img.shields.io/pypi/frameworkversions/django/django-postgres-denormalize.svg
|
|
65
|
+
:target: https://pypi.org/project/django-postgres-denormalize/
|
|
66
|
+
:alt: Supported Django versions
|
|
67
|
+
|
|
68
|
+
.. image:: https://img.shields.io/badge/postgres-17%20%7C%2018-4169e1?logo=postgresql&logoColor=white
|
|
69
|
+
:target: https://www.postgresql.org/
|
|
70
|
+
:alt: Supported PostgreSQL versions
|
|
71
|
+
|
|
72
|
+
.. image:: https://github.com/djanquiltdb/django-postgres-denormalize/actions/workflows/ci.yml/badge.svg
|
|
73
|
+
:target: https://github.com/djanquiltdb/django-postgres-denormalize/actions/workflows/ci.yml
|
|
74
|
+
:alt: CI
|
|
75
|
+
|
|
76
|
+
.. image:: https://readthedocs.org/projects/django-postgres-denormalize/badge/?version=latest
|
|
77
|
+
:target: https://django-postgres-denormalize.readthedocs.io/
|
|
78
|
+
:alt: Documentation
|
|
79
|
+
|
|
80
|
+
.. image:: https://img.shields.io/pypi/l/django-postgres-denormalize.svg
|
|
81
|
+
:target: https://github.com/djanquiltdb/django-postgres-denormalize/blob/master/LICENSE
|
|
82
|
+
:alt: BSD-3-Clause licence
|
|
83
|
+
|
|
84
|
+
PostgreSQL cannot index, sort or constrain on a value that is stored in another table. A value that is several joins
|
|
85
|
+
away, such as the tenant that a row belongs to through its parent, cannot be part of an index or a unique constraint on
|
|
86
|
+
the row's table, and every query that filters or orders by it pays for the joins.
|
|
87
|
+
|
|
88
|
+
This package lets a model store a copy of such a value in an ordinary column, and keeps the copy correct with
|
|
89
|
+
PostgreSQL triggers. That works for every write, whether it comes from the ORM, a bulk update, raw SQL or the previous
|
|
90
|
+
release that is still serving during a rolling deploy. The model declares the lookup path and nothing else;
|
|
91
|
+
``makemigrations`` writes the column, the triggers and their changes.
|
|
92
|
+
|
|
93
|
+
Full documentation is at https://django-postgres-denormalize.readthedocs.io/.
|
|
94
|
+
|
|
95
|
+
.. code-block:: python
|
|
96
|
+
|
|
97
|
+
from django.db import models
|
|
98
|
+
from postgres_denormalize.fields import DenormalizedField, DenormalizedForeignKey
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Task(models.Model):
|
|
102
|
+
project = models.ForeignKey('Project', on_delete=models.CASCADE)
|
|
103
|
+
title = models.CharField(max_length=100)
|
|
104
|
+
# A copy of project.tenant: a real foreign key, its target and on_delete rule inferred from the path.
|
|
105
|
+
tenant = DenormalizedForeignKey('project__tenant')
|
|
106
|
+
# A copy of project.name: a column of the same type as the source.
|
|
107
|
+
project_name = DenormalizedField('project__name')
|
|
108
|
+
|
|
109
|
+
class Meta:
|
|
110
|
+
ordering = ['project_name', 'title']
|
|
111
|
+
constraints = [models.UniqueConstraint(fields=['tenant', 'title'], name='unique_task_title_per_tenant')]
|
|
112
|
+
indexes = [models.Index(fields=['tenant', 'project_name', 'title'], name='task_listing')]
|
|
113
|
+
|
|
114
|
+
From then on, saving a task computes its copies in the database and reads them back onto the instance. Moving a
|
|
115
|
+
project to another tenant, or renaming it, rewrites the copies of its tasks in the same transaction, but only the
|
|
116
|
+
copies that change.
|
|
117
|
+
|
|
118
|
+
**Use it for** values that an index or a unique constraint needs, and values that most queries sort or filter by across
|
|
119
|
+
several joins. **Do not use it** to save a join for rendering, because ``select_related`` does that for free. Do not use
|
|
120
|
+
it either for values whose source is often updated in bulk by code that you cannot wrap in one of its batch helpers.
|
|
121
|
+
See `when to use it, and when not to
|
|
122
|
+
<https://django-postgres-denormalize.readthedocs.io/en/latest/getting_started/when_to_use.html>`_, and the
|
|
123
|
+
`alternatives <https://django-postgres-denormalize.readthedocs.io/en/latest/getting_started/alternatives.html>`_ page,
|
|
124
|
+
which compares it with django-pgconstraints and django-computedfields.
|
|
125
|
+
|
|
126
|
+
Installation
|
|
127
|
+
------------
|
|
128
|
+
|
|
129
|
+
.. code-block:: bash
|
|
130
|
+
|
|
131
|
+
pip install django-postgres-denormalize
|
|
132
|
+
|
|
133
|
+
.. code-block:: python
|
|
134
|
+
|
|
135
|
+
INSTALLED_APPS = [
|
|
136
|
+
# ...
|
|
137
|
+
'pgtrigger',
|
|
138
|
+
'postgres_denormalize',
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
Requires PostgreSQL and django-pgtrigger, with pgtrigger's migration support turned on (the default). See the
|
|
142
|
+
`settings reference <https://django-postgres-denormalize.readthedocs.io/en/latest/reference/settings.html>`_ for the
|
|
143
|
+
options.
|
|
144
|
+
|
|
145
|
+
Relationship to DjanQuiltDB
|
|
146
|
+
---------------------------
|
|
147
|
+
|
|
148
|
+
While this library was written in conjunction with `DjanQuiltDB <https://github.com/djanquiltdb/djanquiltdb>`_ and the
|
|
149
|
+
libraries were designed to work together seamlessly in projects where both are installed, both libraries are written to
|
|
150
|
+
be used as standalone libraries as well. For using django-postgres-denormalize, it is not necessary to use or be
|
|
151
|
+
familiar with DjanQuiltDB.
|
|
152
|
+
|
|
153
|
+
If you wish to use DjanQuiltDB features for the copies that django-postgres-denormalize keeps, you can install the
|
|
154
|
+
``djanquiltdb[postgres-denormalize]`` extra. For more information, please refer to the DjanQuiltDB documentation.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
django_postgres_denormalize-1.0.0.dist-info/licenses/LICENSE,sha256=SjqLhDqFqtKwwJqhC-80QG9cPUmuTv3yBPEp67_7dQE,1505
|
|
2
|
+
postgres_denormalize/__init__.py,sha256=h2Exolqdz_lWkfG9CHR4v_pgUk8OET-deYTLt8WPm2E,2112
|
|
3
|
+
postgres_denormalize/apps.py,sha256=5NMFH3Cw5bO7CmTApEChIMPpRV_8gDZCVFKh0hYW-qE,886
|
|
4
|
+
postgres_denormalize/batch.py,sha256=bde0VQ3vwPz1ot_cs4sguZgt336mjoZrDcfeWj27i3M,7100
|
|
5
|
+
postgres_denormalize/chains.py,sha256=d97jiCI4QO7bin3neRBsG5F_ONeTPARqt8vGSeZhO3M,5215
|
|
6
|
+
postgres_denormalize/checks.py,sha256=tmH3JEA7MqYEM6Xaws9tJYqDDhjELUQoZpyFPDDd5KI,17574
|
|
7
|
+
postgres_denormalize/conf.py,sha256=qK3mZb4bZeglKBqHcSp9A8Kz9eg1xOkdf3SJoasaqPM,7198
|
|
8
|
+
postgres_denormalize/fields.py,sha256=aNDsle6S7V1EBVbepxeCwBMIdKFhll09nLiEHG3N-bw,34954
|
|
9
|
+
postgres_denormalize/operations.py,sha256=2yMu-ejpM5d01NfZCegh28XzraN5wzoAjsf_52FulwI,7830
|
|
10
|
+
postgres_denormalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
postgres_denormalize/triggers.py,sha256=tQLe7deabYCRQYaxoUeB3BxpzhJ51M6lD3Ky5ebWKrM,28882
|
|
12
|
+
django_postgres_denormalize-1.0.0.dist-info/METADATA,sha256=c2VVsjfFS9xXZn-wOftycEhmSOqM-HRg6W_qpyY0Lvs,7417
|
|
13
|
+
django_postgres_denormalize-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
django_postgres_denormalize-1.0.0.dist-info/top_level.txt,sha256=TiY8OQ1hfNrDOlg2pMMaoE5kfdktfvuUiFDk5rz5pAM,21
|
|
15
|
+
django_postgres_denormalize-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Copyright (c) 2026, DjanQuiltDB Project
|
|
2
|
+
|
|
3
|
+
Redistribution and use in source and binary forms, with or without
|
|
4
|
+
modification, are permitted provided that the following conditions are met:
|
|
5
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
6
|
+
list of conditions and the following disclaimer.
|
|
7
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
8
|
+
this list of conditions and the following disclaimer in the documentation
|
|
9
|
+
and/or other materials provided with the distribution.
|
|
10
|
+
* Neither the name of the copyright holder nor the names of its
|
|
11
|
+
contributors may be used to endorse or promote products derived from
|
|
12
|
+
this software without specific prior written permission.
|
|
13
|
+
|
|
14
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
15
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
16
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
17
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
18
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
19
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
20
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
21
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
22
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
23
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
postgres_denormalize
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Columns that store a copy of a value at the end of a chain of foreign keys, kept equal to it by PostgreSQL triggers.
|
|
3
|
+
|
|
4
|
+
Terms that are used throughout this package:
|
|
5
|
+
|
|
6
|
+
- A *denormalized field* is a ``DenormalizedForeignKey``, which copies a foreign key, or a ``DenormalizedField``, which
|
|
7
|
+
copies any other column. The model that declares it is the *leaf*.
|
|
8
|
+
- The field's ``denormalize_from`` is a *lookup path* such as ``'project__tenant'`` or ``'project__name'``: a chain of
|
|
9
|
+
forward foreign keys that starts on the leaf. It ends at the column whose value the field copies, which is the
|
|
10
|
+
*source column*.
|
|
11
|
+
- Each foreign key along the lookup path is a *hop*. The first hop is a column of the leaf itself. The model that has a
|
|
12
|
+
later hop, or the source column, is the *owner* of that segment.
|
|
13
|
+
|
|
14
|
+
A model declares the field and nothing else::
|
|
15
|
+
|
|
16
|
+
class Task(models.Model):
|
|
17
|
+
project = models.ForeignKey(Project, on_delete=models.CASCADE)
|
|
18
|
+
tenant = DenormalizedForeignKey('project__tenant', on_delete=models.CASCADE)
|
|
19
|
+
project_name = DenormalizedField('project__name')
|
|
20
|
+
|
|
21
|
+
The package has these modules:
|
|
22
|
+
|
|
23
|
+
- ``chains`` walks a lookup path.
|
|
24
|
+
- ``fields`` has both kinds of denormalized field, and the helpers that find a model's denormalized fields and compare
|
|
25
|
+
their stored values with their lookup paths.
|
|
26
|
+
- ``triggers`` has the triggers that keep every denormalized field correct, and the registration that derives them from
|
|
27
|
+
the fields while ``PostgresDenormalizeConfig.ready()`` runs.
|
|
28
|
+
- ``operations`` has ``BackfillDenormalized``, the migration operation that corrects stored values before a migration
|
|
29
|
+
installs or changes the triggers.
|
|
30
|
+
- ``batch`` has ``ignore_cascades`` and ``defer_cascades``, which skip the cascade triggers during a batch update and
|
|
31
|
+
correct the copies in one set-based pass when the batch ends. Inside the block, those copies are out of date.
|
|
32
|
+
- ``checks`` has the system checks. ``conf`` has the ``POSTGRES_DENORMALIZE`` setting, including ``EXCLUDE_MODELS``
|
|
33
|
+
and ``EXCLUDE_MODELS_CALLBACK``, which name the models that this package skips.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__version__ = '1.0.0'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PostgresDenormalizeConfig(AppConfig):
|
|
5
|
+
name = 'postgres_denormalize'
|
|
6
|
+
verbose_name = 'PostgreSQL denormalize'
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
from postgres_denormalize import checks, conf # noqa: F401 -- importing checks registers them.
|
|
10
|
+
from postgres_denormalize.fields import resolve_denormalized_fields
|
|
11
|
+
from postgres_denormalize.triggers import register_triggers
|
|
12
|
+
|
|
13
|
+
conf.validate_settings()
|
|
14
|
+
# Every model is loaded by the time any AppConfig.ready() runs, so every lookup path resolves here. The column
|
|
15
|
+
# type of each DenormalizedField is inferred first, because Django reads `null` when it builds queries. Then the
|
|
16
|
+
# triggers are derived from the fields and registered with pgtrigger, which adds them to the migration state.
|
|
17
|
+
resolve_denormalized_fields()
|
|
18
|
+
register_triggers()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context managers for batch updates of the models that a lookup path crosses. They skip the cascade triggers of those
|
|
3
|
+
models and correct the copies below them in one set-based pass.
|
|
4
|
+
|
|
5
|
+
A cascade trigger is row-level. A statement that changes a copied column on *N* rows of an owner therefore runs the
|
|
6
|
+
trigger *N* times, and each run is an indexed UPDATE of the leaf rows below one row. Both context managers skip the
|
|
7
|
+
cascade triggers on the tables of the named owners. When the block ends, they run ``backfill_denormalized`` for each
|
|
8
|
+
leaf that those triggers serve: one UPDATE per denormalized field, which corrects every leaf row that differs from its
|
|
9
|
+
lookup path. The block and the correction run in one transaction, so no other transaction sees out-of-date copies. The
|
|
10
|
+
block itself does see them: until it ends, every copy below a row that the batch changed has its old value. A query in
|
|
11
|
+
the block that reads or filters on such a copy gets a wrong answer, and saving a leaf row in the block does not correct
|
|
12
|
+
the row, because the save writes the old copy back unchanged.
|
|
13
|
+
|
|
14
|
+
The two context managers skip the triggers in different ways:
|
|
15
|
+
|
|
16
|
+
- ``ignore_cascades`` uses ``pgtrigger.ignore``. It works with any ``DEFERRABLE_CASCADES`` value, but Postgres still
|
|
17
|
+
calls each trigger function once per changed row, and the function returns at once. It skips the triggers only for
|
|
18
|
+
queries that Django sends from the current thread. A statement that is sent through another connection still runs the
|
|
19
|
+
cascade triggers.
|
|
20
|
+
- ``defer_cascades`` lists the triggers in a transaction-local Postgres setting that their WHEN condition reads, so
|
|
21
|
+
Postgres does not call the function at all. It skips the triggers for every statement in the transaction, including
|
|
22
|
+
raw SQL. It needs ``DEFERRABLE_CASCADES``, which is on by default.
|
|
23
|
+
|
|
24
|
+
Neither makes the work on the leaf's side cheaper. The correction reads every row of each leaf to find the rows that
|
|
25
|
+
differ, and every row that it rewrites runs the leaf's ``denormalize_set`` trigger, because the UPDATE writes a copy.
|
|
26
|
+
Both save the per-row work on the owner's side. That work dominates when a batch changes many owner rows that each have
|
|
27
|
+
few leaf rows below them.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
|
|
32
|
+
import pgtrigger
|
|
33
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
34
|
+
from django.db import connections, router, transaction
|
|
35
|
+
|
|
36
|
+
from postgres_denormalize import conf
|
|
37
|
+
from postgres_denormalize.operations import backfill_denormalized
|
|
38
|
+
from postgres_denormalize.triggers import DEFERRED_CASCADES_SETTING, cascade_triggers_on
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _find_cascade_triggers(owners, using):
|
|
42
|
+
"""
|
|
43
|
+
Return the ``(owner, leaf, trigger)`` triples of the cascade triggers on ``owners``, and the database to use.
|
|
44
|
+
"""
|
|
45
|
+
if not owners:
|
|
46
|
+
raise TypeError('Name at least one model whose rows the batch updates.')
|
|
47
|
+
found = cascade_triggers_on(owners)
|
|
48
|
+
return found, using or router.db_for_write(found[0][0])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _correct_leaves(found, using):
|
|
52
|
+
"""
|
|
53
|
+
Correct the copies of every leaf that the triggers in ``found`` serve, each leaf once, in label order.
|
|
54
|
+
"""
|
|
55
|
+
leaves = {leaf._meta.label: leaf for _owner, leaf, _trigger in found}
|
|
56
|
+
for label in sorted(leaves):
|
|
57
|
+
backfill_denormalized(leaves[label], using=using)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextlib.contextmanager
|
|
61
|
+
def ignore_cascades(*owners, using=None):
|
|
62
|
+
"""
|
|
63
|
+
Skip the cascade triggers on ``owners`` for the queries that Django sends inside the block, through
|
|
64
|
+
``pgtrigger.ignore``, and correct the copies below them when the block ends.
|
|
65
|
+
|
|
66
|
+
The block runs in a transaction, or in a savepoint inside one, on ``using``. By default, that is the database that
|
|
67
|
+
the router chooses for writing the first model in ``owners``. If the block raises, the transaction rolls back and
|
|
68
|
+
nothing is corrected::
|
|
69
|
+
|
|
70
|
+
with ignore_cascades(Project):
|
|
71
|
+
Project.objects.filter(tenant=old).update(tenant=new)
|
|
72
|
+
|
|
73
|
+
.. warning::
|
|
74
|
+
|
|
75
|
+
Until the block ends, every copy below a row that the batch changed has its old value. A query in the block that
|
|
76
|
+
reads or filters on such a copy gets a wrong answer, and saving a leaf row in the block does not correct it. See
|
|
77
|
+
:doc:`/guides/batch_updates`.
|
|
78
|
+
"""
|
|
79
|
+
found, using = _find_cascade_triggers(owners, using)
|
|
80
|
+
with transaction.atomic(using=using):
|
|
81
|
+
with pgtrigger.ignore(*(trigger.get_uri(owner) for owner, _leaf, trigger in found), databases=[using]):
|
|
82
|
+
# pgtrigger.ignore runs a query to clear its setting when it ends. The savepoint first rolls back a database
|
|
83
|
+
# error that the block raises, so that this query does not fail on an aborted transaction and hide the
|
|
84
|
+
# error.
|
|
85
|
+
with transaction.atomic(using=using):
|
|
86
|
+
yield
|
|
87
|
+
_correct_leaves(found, using)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@contextlib.contextmanager
|
|
91
|
+
def defer_cascades(*owners, using=None):
|
|
92
|
+
"""
|
|
93
|
+
Skip the cascade triggers on ``owners`` for every statement inside the block, through their WHEN condition, and
|
|
94
|
+
correct the copies below them when the block ends.
|
|
95
|
+
|
|
96
|
+
This needs ``DEFERRABLE_CASCADES``, and raises ``ImproperlyConfigured`` when it is off. ``ignore_cascades`` works
|
|
97
|
+
with either value. The block runs in a transaction on ``using``, as in ``ignore_cascades``. The triggers are listed
|
|
98
|
+
in ``DEFERRED_CASCADES_SETTING`` with ``set_config(..., true)``, and a rollback undoes that together with the rest
|
|
99
|
+
of the block. A nested block adds its triggers to the listed ones and restores the list when it ends. At that point
|
|
100
|
+
it corrects its own leaves. The outer block corrects its leaves when it ends, so a leaf that both blocks serve is
|
|
101
|
+
read twice.
|
|
102
|
+
|
|
103
|
+
.. warning::
|
|
104
|
+
|
|
105
|
+
Until the block ends, every copy below a row that the batch changed has its old value. A query in the block that
|
|
106
|
+
reads or filters on such a copy gets a wrong answer, and saving a leaf row in the block does not correct it. See
|
|
107
|
+
:doc:`/guides/batch_updates`.
|
|
108
|
+
"""
|
|
109
|
+
if not conf.get_setting('DEFERRABLE_CASCADES'):
|
|
110
|
+
raise ImproperlyConfigured(
|
|
111
|
+
f'defer_cascades() needs {conf.SETTING_NAME}["DEFERRABLE_CASCADES"], which is off, so the cascade triggers '
|
|
112
|
+
f'cannot be skipped through their WHEN condition. Use ignore_cascades() instead.'
|
|
113
|
+
)
|
|
114
|
+
found, using = _find_cascade_triggers(owners, using)
|
|
115
|
+
with transaction.atomic(using=using):
|
|
116
|
+
with connections[using].cursor() as cursor:
|
|
117
|
+
cursor.execute('SELECT current_setting(%s, true)', [DEFERRED_CASCADES_SETTING])
|
|
118
|
+
previous = cursor.fetchone()[0] or ''
|
|
119
|
+
listed = [pgid for pgid in previous.split(',') if pgid]
|
|
120
|
+
listed.extend(trigger.get_pgid(owner) for owner, _leaf, trigger in found)
|
|
121
|
+
cursor.execute(
|
|
122
|
+
'SELECT set_config(%s, %s, true)', [DEFERRED_CASCADES_SETTING, ','.join(dict.fromkeys(listed))]
|
|
123
|
+
)
|
|
124
|
+
yield
|
|
125
|
+
with connections[using].cursor() as cursor:
|
|
126
|
+
cursor.execute('SELECT set_config(%s, %s, true)', [DEFERRED_CASCADES_SETTING, previous])
|
|
127
|
+
_correct_leaves(found, using)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ['defer_cascades', 'ignore_cascades']
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Walking a lookup path through a model's foreign keys, which both ``fields`` and ``triggers`` need.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PathRefused(ValueError):
|
|
7
|
+
"""
|
|
8
|
+
A lookup path that names only fields that exist, but crosses a field that no lookup path may cross. ``code`` is the
|
|
9
|
+
id of the system check that reports it, and ``hint`` names the path to use instead.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message, *, code, hint):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.code = code
|
|
15
|
+
self.hint = hint
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_forward_hop(field):
|
|
19
|
+
"""
|
|
20
|
+
Say whether ``field`` is a hop that a lookup path can cross: a concrete foreign key or one-to-one field, which
|
|
21
|
+
leads to exactly one row.
|
|
22
|
+
"""
|
|
23
|
+
return field.concrete and (field.many_to_one or field.one_to_one)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def refuse_hop(model, field, path, position):
|
|
27
|
+
"""
|
|
28
|
+
Raise ``PathRefused`` when the segment of ``path`` at ``position``, which names ``field`` on ``model``, is a field
|
|
29
|
+
that no lookup path may cross or end at.
|
|
30
|
+
|
|
31
|
+
A denormalized field is refused, because its own value comes from a trigger. A path through it would need that
|
|
32
|
+
trigger to run before the trigger of the path, and a ``DenormalizedForeignKey`` along the path may not have its
|
|
33
|
+
target yet. The hint names the path that the copy itself walks.
|
|
34
|
+
|
|
35
|
+
A field that ``model`` inherits from a concrete parent is refused, because its column is in the parent's table and
|
|
36
|
+
not in the table of ``model``. The hint names the path through the parent links.
|
|
37
|
+
"""
|
|
38
|
+
segments = path.split('__')
|
|
39
|
+
copied = getattr(field, 'denormalize_from', None)
|
|
40
|
+
if copied is not None:
|
|
41
|
+
suggestion = '__'.join([*segments[:position], copied, *segments[position + 1 :]])
|
|
42
|
+
raise PathRefused(
|
|
43
|
+
f'`{path}` crosses `{field.name}`, which is itself a copy of `{copied}`',
|
|
44
|
+
code='postgres_denormalize.E006',
|
|
45
|
+
hint=f"Copy from '{suggestion}' instead.",
|
|
46
|
+
)
|
|
47
|
+
if field.model._meta.concrete_model is not model._meta.concrete_model:
|
|
48
|
+
suggestion = '__'.join([*segments[:position], *parent_link_names(model, field.model), *segments[position:]])
|
|
49
|
+
raise PathRefused(
|
|
50
|
+
f'`{path}` crosses `{field.name}`, which {model._meta.label} inherits from {field.model._meta.label}, '
|
|
51
|
+
f'so the column is in the table of {field.model._meta.label}',
|
|
52
|
+
code='postgres_denormalize.E007',
|
|
53
|
+
hint=f"Copy from '{suggestion}' instead.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def can_cross(model, field):
|
|
58
|
+
"""
|
|
59
|
+
Say whether a lookup path from ``model`` may cross ``field`` as a hop: ``is_forward_hop`` accepts it, and
|
|
60
|
+
``refuse_hop`` does not refuse it.
|
|
61
|
+
"""
|
|
62
|
+
if not is_forward_hop(field):
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
refuse_hop(model, field, field.name, 0)
|
|
66
|
+
except PathRefused:
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parent_link_names(model, ancestor):
|
|
72
|
+
"""
|
|
73
|
+
Return the names of the parent links that lead from ``model`` to its concrete parent ``ancestor``.
|
|
74
|
+
"""
|
|
75
|
+
names = []
|
|
76
|
+
while model._meta.concrete_model is not ancestor._meta.concrete_model:
|
|
77
|
+
link = model._meta.get_ancestor_link(ancestor)
|
|
78
|
+
names.append(link.name)
|
|
79
|
+
model = link.related_model
|
|
80
|
+
return names
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_chain(model, path, allow_column_terminal=False):
|
|
84
|
+
"""
|
|
85
|
+
Walk a lookup path from ``model`` and return the field that each segment names, starting with the first hop.
|
|
86
|
+
|
|
87
|
+
Every segment except the last must be a hop that ``is_forward_hop`` accepts. The last segment must be one too,
|
|
88
|
+
unless ``allow_column_terminal`` is set. In that case, it may also be a concrete column that is not a relation: the
|
|
89
|
+
column that a ``DenormalizedField`` copies.
|
|
90
|
+
|
|
91
|
+
Raises ``FieldDoesNotExist`` if a segment names no field on the model that it is resolved against. Raises
|
|
92
|
+
``ValueError`` if a segment names a field that the walk cannot cross: a reverse accessor, a many-to-many, or a plain
|
|
93
|
+
column anywhere except at the end. Walking one of those yields either a set of rows or no row at all, and the
|
|
94
|
+
trigger needs exactly one value to copy. Raises ``PathRefused``, which is a ``ValueError``, for the fields that
|
|
95
|
+
``refuse_hop`` refuses.
|
|
96
|
+
"""
|
|
97
|
+
segments = path.split('__')
|
|
98
|
+
chain = []
|
|
99
|
+
current = model
|
|
100
|
+
for position, segment in enumerate(segments):
|
|
101
|
+
field = current._meta.get_field(segment)
|
|
102
|
+
is_last = position == len(segments) - 1
|
|
103
|
+
if is_forward_hop(field):
|
|
104
|
+
refuse_hop(current, field, path, position)
|
|
105
|
+
if isinstance(field.related_model, str):
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f'`{path}` traverses `{segment}`, which points at {field.related_model}, '
|
|
108
|
+
'a model that is not installed'
|
|
109
|
+
)
|
|
110
|
+
chain.append(field)
|
|
111
|
+
current = field.related_model
|
|
112
|
+
continue
|
|
113
|
+
if is_last and allow_column_terminal and field.concrete and not field.is_relation:
|
|
114
|
+
refuse_hop(current, field, path, position)
|
|
115
|
+
chain.append(field)
|
|
116
|
+
continue
|
|
117
|
+
raise ValueError(f'`{path}` traverses `{segment}`, which is not a forward foreign key')
|
|
118
|
+
return chain
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ['PathRefused', 'can_cross', 'is_forward_hop', 'refuse_hop', 'resolve_chain']
|