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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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']