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,716 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The two kinds of denormalized field, and the helpers that find a model's denormalized fields and check their stored
|
|
3
|
+
values.
|
|
4
|
+
|
|
5
|
+
``DenormalizedForeignKey`` copies a foreign key at the end of a chain of foreign keys. ``DenormalizedField`` copies any
|
|
6
|
+
other column of an ancestor row, such as its name. Both share ``DenormalizedMixin``, which has everything that does not
|
|
7
|
+
depend on what is copied: the lookup path, the rule that no code writes the column, and reading the value that the
|
|
8
|
+
trigger computes back onto the instance.
|
|
9
|
+
|
|
10
|
+
The lookup path is an argument of the field, not a class attribute of the model. pgtrigger compiles a trigger's SQL
|
|
11
|
+
against the model that Django rebuilds from migration state, and that model has fields and ``Meta`` options but no other
|
|
12
|
+
class attributes. For the same reason, ``deconstruct`` writes ``denormalize_from`` into every migration that touches the
|
|
13
|
+
field.
|
|
14
|
+
|
|
15
|
+
Both fields infer what they can from the lookup path in model code, such as the target and the ``on_delete`` rule of a
|
|
16
|
+
foreign key or the type of a copied column, and write the result into migrations. A field that Django rebuilds from
|
|
17
|
+
migration state receives the recorded value, so migrations never depend on the live models.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import operator
|
|
21
|
+
from functools import reduce
|
|
22
|
+
|
|
23
|
+
from django.apps import apps as global_apps
|
|
24
|
+
from django.core import checks
|
|
25
|
+
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
|
26
|
+
from django.db import models
|
|
27
|
+
from django.db.models import BooleanField, F, Func, Q
|
|
28
|
+
from django.db.models.fields.related import lazy_related_operation
|
|
29
|
+
|
|
30
|
+
from postgres_denormalize import conf
|
|
31
|
+
from postgres_denormalize.chains import PathRefused, is_forward_hop, refuse_hop, resolve_chain
|
|
32
|
+
|
|
33
|
+
# The model reference that a DenormalizedForeignKey passes to ForeignKey.__init__ until the target is known from the
|
|
34
|
+
# lookup path. No app can provide a model with this reference, so nothing ever resolves it by accident.
|
|
35
|
+
INFERRED_TARGET = 'postgres_denormalize.__inferred_from_path__'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def INFERRED_ON_DELETE(collector, field, sub_objs, using):
|
|
39
|
+
"""
|
|
40
|
+
The ``on_delete`` rule that a DenormalizedForeignKey passes to ForeignKey.__init__ until the rule is inferred from
|
|
41
|
+
the lookup path. ForeignKey.__init__ requires a callable. A delete that reaches this rule fails loudly, rather than
|
|
42
|
+
doing nothing to the rows that store the copy.
|
|
43
|
+
"""
|
|
44
|
+
raise ImproperlyConfigured(
|
|
45
|
+
f'{field} cannot infer its on_delete rule, because `{field.denormalize_from}` does not resolve, or crosses a '
|
|
46
|
+
f'foreign key with a custom on_delete rule. The system checks say why.'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# The rules of a hop that delete the row that the hop belongs to, when the row that the hop points at is deleted. The
|
|
51
|
+
# database variants exist from Django 6.1 on.
|
|
52
|
+
CASCADE_RULES = tuple(rule for rule in (models.CASCADE, getattr(models, 'DB_CASCADE', None)) if rule is not None)
|
|
53
|
+
|
|
54
|
+
# The rules of a hop that the copy takes over unchanged: the copy is set to NULL, or the delete is refused.
|
|
55
|
+
COPIED_RULES = tuple(
|
|
56
|
+
rule
|
|
57
|
+
for rule in (models.SET_NULL, getattr(models, 'DB_SET_NULL', None), models.PROTECT, models.RESTRICT)
|
|
58
|
+
if rule is not None
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# The rules of a hop that keep the row that the hop belongs to, and leave the hop pointing at another row, or at the
|
|
62
|
+
# deleted row. The trigger then writes the copy, so the copy itself needs no rule.
|
|
63
|
+
REPOINTING_RULES = tuple(
|
|
64
|
+
rule
|
|
65
|
+
for rule in (models.SET_DEFAULT, getattr(models, 'DB_SET_DEFAULT', None), models.DO_NOTHING)
|
|
66
|
+
if rule is not None
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_set_rule(rule):
|
|
71
|
+
"""
|
|
72
|
+
Say whether ``rule`` is a rule that ``models.SET(value)`` returns.
|
|
73
|
+
"""
|
|
74
|
+
deconstruct = getattr(rule, 'deconstruct', None)
|
|
75
|
+
return deconstruct is not None and deconstruct()[0] == 'django.db.models.SET'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def describe_rule(rule):
|
|
79
|
+
"""
|
|
80
|
+
Return the name of an ``on_delete`` rule as a declaration spells it, such as ``CASCADE`` or ``SET(0)``.
|
|
81
|
+
"""
|
|
82
|
+
if is_set_rule(rule):
|
|
83
|
+
_path, args, _kwargs = rule.deconstruct()
|
|
84
|
+
return f'SET({args[0]!r})'
|
|
85
|
+
return getattr(rule, '__name__', repr(rule))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def infer_on_delete(chain):
|
|
89
|
+
"""
|
|
90
|
+
Return the ``on_delete`` rule that a copy of the foreign key at the end of ``chain`` needs, and the hop whose rule
|
|
91
|
+
decides it. The rule is ``None`` when that hop has a custom rule, which says nothing about what happens to the copy.
|
|
92
|
+
|
|
93
|
+
When the row at the end of the lookup path is deleted, the hops decide what happens to a leaf row, starting at the
|
|
94
|
+
hop next to that row. A hop that cascades deletes the row that it belongs to, so the next hop toward the leaf
|
|
95
|
+
decides. The first hop that does not cascade decides for the leaf:
|
|
96
|
+
|
|
97
|
+
- ``SET_NULL`` sets that hop to NULL, so the copy becomes NULL too, and the copy gets the same rule.
|
|
98
|
+
- ``PROTECT`` and ``RESTRICT`` refuse the delete, and the copy gets the same rule.
|
|
99
|
+
- ``SET``, ``SET_DEFAULT`` and ``DO_NOTHING`` keep the leaf row. The trigger writes the copy, so the copy gets
|
|
100
|
+
``DO_NOTHING``.
|
|
101
|
+
|
|
102
|
+
When every hop cascades, the leaf row is deleted, and the copy gets the rule of the last hop. The copy's rule is
|
|
103
|
+
therefore never stricter than the path: it deletes, refuses or sets to NULL only the rows that the path would.
|
|
104
|
+
Django requires the hops along a path to use either the Python rules or the database rules, so the copy's rule uses
|
|
105
|
+
the same kind as the path.
|
|
106
|
+
"""
|
|
107
|
+
for hop in reversed(chain):
|
|
108
|
+
rule = hop.remote_field.on_delete
|
|
109
|
+
if rule in CASCADE_RULES:
|
|
110
|
+
continue
|
|
111
|
+
if rule in COPIED_RULES:
|
|
112
|
+
return rule, hop
|
|
113
|
+
if rule in REPOINTING_RULES or is_set_rule(rule):
|
|
114
|
+
return models.DO_NOTHING, hop
|
|
115
|
+
return None, hop
|
|
116
|
+
return chain[-1].remote_field.on_delete, chain[-1]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# The keyword arguments of the source column that a DenormalizedField does not copy onto its own column. Each one does
|
|
120
|
+
# one of three things. It describes the source as a form or admin input (Field.non_db_attrs). Or it gives the copy a
|
|
121
|
+
# constraint or index that the source only needs on its own table (unique, db_index, primary_key). Or it lets the copy
|
|
122
|
+
# get a value in some other way than through the trigger (default, db_default, auto_now, auto_now_add). null is inferred
|
|
123
|
+
# from the whole lookup path.
|
|
124
|
+
OMITTED_SOURCE_KWARGS = frozenset(
|
|
125
|
+
{
|
|
126
|
+
*models.Field.non_db_attrs,
|
|
127
|
+
'auto_now',
|
|
128
|
+
'auto_now_add',
|
|
129
|
+
'db_comment',
|
|
130
|
+
'db_default',
|
|
131
|
+
'db_index',
|
|
132
|
+
'db_tablespace',
|
|
133
|
+
'default',
|
|
134
|
+
'null',
|
|
135
|
+
'primary_key',
|
|
136
|
+
'unique',
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class IsDistinctFrom(Func):
|
|
142
|
+
"""
|
|
143
|
+
``lhs IS DISTINCT FROM rhs``: true when the two differ, counting NULL as a value of its own.
|
|
144
|
+
|
|
145
|
+
``~Q(column=F(path))`` does not say that. SQL's ``<>`` is unknown when either side is NULL. A filter that is built
|
|
146
|
+
from it therefore misses a copy that has a value while its lookup path ends at NULL, and the other way around.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
template = '(%(expressions)s)'
|
|
150
|
+
arg_joiner = ' IS DISTINCT FROM '
|
|
151
|
+
output_field = BooleanField()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DenormalizedMixin:
|
|
155
|
+
"""
|
|
156
|
+
What every denormalized field shares: a column that stores a copy of the value at the end of ``denormalize_from``,
|
|
157
|
+
and that is written by triggers and never by code.
|
|
158
|
+
|
|
159
|
+
On an INSERT, Django sends the columns of the instance. For an ordinary field, it reads nothing back, so the
|
|
160
|
+
instance keeps the value that the caller passed to the constructor. Application code never assigns a denormalized
|
|
161
|
+
field, so that value would be missing. A denormalized field therefore reports ``db_returning``. This makes Django
|
|
162
|
+
add the column to the INSERT's ``RETURNING`` clause and assign the value that the trigger computes to the instance.
|
|
163
|
+
|
|
164
|
+
On an UPDATE, Django returns only generated columns and columns that are assigned an expression, never
|
|
165
|
+
``db_returning`` ones. ``pre_save`` therefore assigns the column to itself as an expression on every update. When
|
|
166
|
+
the row moves under another ancestor, the trigger replaces that value before the row is written. Django reads the
|
|
167
|
+
computed value back from the UPDATE's ``RETURNING`` clause, so the instance has the new value. Two kinds of write do
|
|
168
|
+
not update the instance: a ``save(update_fields=...)`` that does not list the field, and ``QuerySet.update()``,
|
|
169
|
+
which updates no instance at all. After either, the caller calls ``refresh_from_db`` to read the new value.
|
|
170
|
+
|
|
171
|
+
The field is never editable, so no ModelForm or admin form offers it as an input whose value the trigger would
|
|
172
|
+
discard. It is always blank, so ``full_clean()`` does not demand a value from code that never assigns one.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
# Whether the lookup path may end at a column that is not a foreign key. Each concrete class sets it.
|
|
176
|
+
copies_column = False
|
|
177
|
+
|
|
178
|
+
# A different lookup path produces a different trigger, but it never changes the column itself. Changing
|
|
179
|
+
# `denormalize_from` must therefore not make Django drop and recreate the column's constraints.
|
|
180
|
+
non_db_attrs = (*models.Field.non_db_attrs, 'denormalize_from')
|
|
181
|
+
|
|
182
|
+
def __init__(self, *args, denormalize_from, **kwargs):
|
|
183
|
+
self.denormalize_from = denormalize_from
|
|
184
|
+
kwargs['editable'] = False
|
|
185
|
+
kwargs['blank'] = True
|
|
186
|
+
super().__init__(*args, **kwargs)
|
|
187
|
+
|
|
188
|
+
def deconstruct(self):
|
|
189
|
+
name, path, args, kwargs = super().deconstruct()
|
|
190
|
+
kwargs['denormalize_from'] = self.denormalize_from
|
|
191
|
+
# __init__ always sets both, so writing them into migrations would only add noise.
|
|
192
|
+
kwargs.pop('editable', None)
|
|
193
|
+
kwargs.pop('blank', None)
|
|
194
|
+
return name, path, args, kwargs
|
|
195
|
+
|
|
196
|
+
def pre_save(self, model_instance, add):
|
|
197
|
+
if add:
|
|
198
|
+
return super().pre_save(model_instance, add)
|
|
199
|
+
return F(self.attname)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def db_returning(self):
|
|
203
|
+
# Django's own comment calls this private API. If a future Django ignores it, the stored column is still
|
|
204
|
+
# correct, because the trigger computes it. Only the write-back to the instance breaks, which leaves the
|
|
205
|
+
# attribute unset on created instances. The test suite asserts the write-back, so that break shows up as a
|
|
206
|
+
# failing test rather than as an unexplained RelatedObjectDoesNotExist.
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def resolve_chain(self, model=None):
|
|
210
|
+
"""
|
|
211
|
+
Return the fields that the lookup path crosses, starting on ``model``, or by default on the model that declares
|
|
212
|
+
the field.
|
|
213
|
+
"""
|
|
214
|
+
return resolve_chain(model or self.model, self.denormalize_from, allow_column_terminal=self.copies_column)
|
|
215
|
+
|
|
216
|
+
def check(self, **kwargs):
|
|
217
|
+
return [*super().check(**kwargs), *self._check_denormalize_from()]
|
|
218
|
+
|
|
219
|
+
def _check_denormalize_from(self):
|
|
220
|
+
if '__' not in self.denormalize_from:
|
|
221
|
+
return self._error(
|
|
222
|
+
'postgres_denormalize.E001',
|
|
223
|
+
f'`denormalize_from` is `{self.denormalize_from}`, which is a column on the same row, not a column '
|
|
224
|
+
'that is reached through a foreign key.',
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
chain = self.resolve_chain()
|
|
229
|
+
except PathRefused as refusal:
|
|
230
|
+
return self._error(refusal.code, f'{refusal}.', refusal.hint)
|
|
231
|
+
except (FieldDoesNotExist, ValueError) as exception:
|
|
232
|
+
return self._error(
|
|
233
|
+
'postgres_denormalize.E001',
|
|
234
|
+
f'`denormalize_from` is `{self.denormalize_from}`, which does not resolve: {exception}.',
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if chain[0].null:
|
|
238
|
+
return self._error(
|
|
239
|
+
'postgres_denormalize.E002',
|
|
240
|
+
f'`denormalize_from` is `{self.denormalize_from}`, which starts at the nullable foreign key '
|
|
241
|
+
f'`{chain[0].name}`, so the trigger computes NULL for every row where `{chain[0].name}` is empty.',
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return self._check_end_of_chain(chain)
|
|
245
|
+
|
|
246
|
+
def _check_end_of_chain(self, chain):
|
|
247
|
+
"""
|
|
248
|
+
Check the field at the end of the lookup path. Each concrete class allows different fields there.
|
|
249
|
+
"""
|
|
250
|
+
raise NotImplementedError
|
|
251
|
+
|
|
252
|
+
def _error(self, check_id, message, hint=None):
|
|
253
|
+
return [checks.Error(message, hint=hint, obj=self, id=check_id)]
|
|
254
|
+
|
|
255
|
+
def _warning(self, check_id, message, hint=None):
|
|
256
|
+
return [checks.Warning(message, hint=hint, obj=self, id=check_id)]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class DenormalizedForeignKey(DenormalizedMixin, models.ForeignKey):
|
|
260
|
+
"""
|
|
261
|
+
A foreign key that stores a copy of the foreign key at the end of ``denormalize_from``::
|
|
262
|
+
|
|
263
|
+
tenant = DenormalizedForeignKey('project__tenant')
|
|
264
|
+
|
|
265
|
+
The target model is the model that the last hop points at, so ``to`` is omitted. The field resolves the target once
|
|
266
|
+
every model along the lookup path is loaded, the way Django resolves a lazy ``'app_label.Model'`` reference.
|
|
267
|
+
``deconstruct`` writes the target into migrations, so a migration names the target like for any other foreign key.
|
|
268
|
+
Passing ``to`` explicitly is allowed as well; the system check then requires it to match the lookup path.
|
|
269
|
+
|
|
270
|
+
``on_delete`` and ``null`` are inferred from the hops in the same walk, when they are omitted. ``on_delete`` follows
|
|
271
|
+
the rules of the hops (see ``infer_on_delete``). The copy can be NULL only when a hop after the first is nullable,
|
|
272
|
+
and then the field is nullable too, because the trigger writes NULL whenever that hop is empty. ``deconstruct``
|
|
273
|
+
writes both into migrations. A declared ``on_delete`` is kept, and the system check warns when it differs from the
|
|
274
|
+
inferred one. A declared ``null`` is kept as well, and the system check requires it on a path that can be NULL.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(self, denormalize_from, on_delete=None, *, to=None, **kwargs):
|
|
278
|
+
self.target_from_path = to is None
|
|
279
|
+
self.on_delete_from_path = on_delete is None
|
|
280
|
+
self.null_from_path = 'null' not in kwargs
|
|
281
|
+
self.null_was_inferred = False
|
|
282
|
+
super().__init__(
|
|
283
|
+
INFERRED_TARGET if to is None else to,
|
|
284
|
+
INFERRED_ON_DELETE if on_delete is None else on_delete,
|
|
285
|
+
denormalize_from=denormalize_from,
|
|
286
|
+
**kwargs,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def target_is_known(self):
|
|
291
|
+
"""
|
|
292
|
+
Whether the target model is known: either it was passed as ``to``, or it is already inferred from the lookup
|
|
293
|
+
path.
|
|
294
|
+
"""
|
|
295
|
+
return self.remote_field.model != INFERRED_TARGET
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def on_delete_is_known(self):
|
|
299
|
+
"""
|
|
300
|
+
Whether the ``on_delete`` rule is known: either it was passed, or it is already inferred from the lookup path.
|
|
301
|
+
"""
|
|
302
|
+
return self.remote_field.on_delete is not INFERRED_ON_DELETE
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def null_is_known(self):
|
|
306
|
+
"""
|
|
307
|
+
Whether ``null`` is known: either it was passed, or it is already inferred from the lookup path.
|
|
308
|
+
"""
|
|
309
|
+
return not self.null_from_path or self.null_was_inferred
|
|
310
|
+
|
|
311
|
+
def contribute_to_class(self, cls, name, private_only=False, **kwargs):
|
|
312
|
+
if not self.target_from_path:
|
|
313
|
+
super().contribute_to_class(cls, name, private_only=private_only, **kwargs)
|
|
314
|
+
if (self.on_delete_from_path or self.null_from_path) and not cls._meta.abstract:
|
|
315
|
+
# The target is known, but on_delete or null still has to be inferred from the hops.
|
|
316
|
+
lazy_related_operation(self._walk_to_target, cls, cls, index=0)
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
# This does what RelatedField.contribute_to_class and ForeignObject.contribute_to_class do, except that it does
|
|
320
|
+
# not schedule the resolution of `to`. The column has to exist on the model right away, because code that runs
|
|
321
|
+
# while the class is created copies the model's fields (an audit package's decorator, for example). The target
|
|
322
|
+
# is not known until every model along the lookup path is loaded.
|
|
323
|
+
models.Field.contribute_to_class(self, cls, name, private_only=private_only, **kwargs)
|
|
324
|
+
self.opts = cls._meta
|
|
325
|
+
if not cls._meta.abstract:
|
|
326
|
+
self._format_related_names(cls)
|
|
327
|
+
# The first hop is a field of `cls`, which may be declared after this field, so the walk to the target
|
|
328
|
+
# starts once the class is complete and registered.
|
|
329
|
+
lazy_related_operation(self._walk_to_target, cls, cls, index=0)
|
|
330
|
+
setattr(cls, self.name, self.forward_related_accessor_class(self))
|
|
331
|
+
|
|
332
|
+
def _format_related_names(self, cls):
|
|
333
|
+
if self.remote_field.related_name:
|
|
334
|
+
related_name = self.remote_field.related_name
|
|
335
|
+
else:
|
|
336
|
+
related_name = self.opts.default_related_name
|
|
337
|
+
if related_name:
|
|
338
|
+
self.remote_field.related_name = related_name % {
|
|
339
|
+
'class': cls.__name__.lower(),
|
|
340
|
+
'model_name': cls._meta.model_name.lower(),
|
|
341
|
+
'app_label': cls._meta.app_label.lower(),
|
|
342
|
+
}
|
|
343
|
+
if self.remote_field.related_query_name:
|
|
344
|
+
self.remote_field.related_query_name = self.remote_field.related_query_name % {
|
|
345
|
+
'class': cls.__name__.lower(),
|
|
346
|
+
'app_label': cls._meta.app_label.lower(),
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
def _walk_to_target(self, leaf, owner, index, hops=()):
|
|
350
|
+
"""
|
|
351
|
+
Follow the hop at ``index``, which belongs to ``owner``, and schedule the next step once the model that the hop
|
|
352
|
+
points at is loaded. ``hops`` are the hops that the walk has crossed so far.
|
|
353
|
+
|
|
354
|
+
Each hop's target may still be a lazy reference, relative to the app of the model that has the hop. Every step
|
|
355
|
+
therefore waits for its own target through a ``lazy_related_operation`` that is scoped to that model. A segment
|
|
356
|
+
that names no hop that a lookup path can cross ends the walk without a target, and the system check reports the
|
|
357
|
+
lookup path.
|
|
358
|
+
"""
|
|
359
|
+
segments = self.denormalize_from.split('__')
|
|
360
|
+
try:
|
|
361
|
+
hop = owner._meta.get_field(segments[index])
|
|
362
|
+
if not is_forward_hop(hop):
|
|
363
|
+
return
|
|
364
|
+
refuse_hop(owner, hop, self.denormalize_from, index)
|
|
365
|
+
except FieldDoesNotExist, PathRefused:
|
|
366
|
+
return
|
|
367
|
+
hops = (*hops, hop)
|
|
368
|
+
if index == len(segments) - 1:
|
|
369
|
+
lazy_related_operation(self._complete_from_path, owner, hop.remote_field.model, leaf=leaf, hops=hops)
|
|
370
|
+
else:
|
|
371
|
+
lazy_related_operation(
|
|
372
|
+
self._walk_from_target, owner, hop.remote_field.model, leaf=leaf, index=index + 1, hops=hops
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _walk_from_target(self, owner, target, leaf, index, hops):
|
|
376
|
+
self._walk_to_target(leaf, target, index, hops)
|
|
377
|
+
|
|
378
|
+
def _complete_from_path(self, owner, target, leaf, hops):
|
|
379
|
+
"""
|
|
380
|
+
Infer what the declaration omits from ``hops``, the hops of the whole lookup path: the target, ``on_delete`` and
|
|
381
|
+
``null``.
|
|
382
|
+
|
|
383
|
+
The target is ``target``, and the target column is the column that the last hop points at. A last hop that is
|
|
384
|
+
declared with ``to_field`` stores a value of that column, so the copy points at the same column. A field that is
|
|
385
|
+
declared with its own ``to_field`` keeps it, and the system check compares it with the lookup path.
|
|
386
|
+
|
|
387
|
+
A hop with a custom ``on_delete`` rule leaves the rule of the copy unknown, and the system check reports it.
|
|
388
|
+
"""
|
|
389
|
+
if self.on_delete_from_path:
|
|
390
|
+
rule, _hop = infer_on_delete(hops)
|
|
391
|
+
if rule is not None:
|
|
392
|
+
self.remote_field.on_delete = rule
|
|
393
|
+
if self.null_from_path:
|
|
394
|
+
self.null = any(hop.null for hop in hops[1:])
|
|
395
|
+
self.null_was_inferred = True
|
|
396
|
+
if not self.target_from_path:
|
|
397
|
+
return
|
|
398
|
+
self.remote_field.model = target
|
|
399
|
+
to_field = hops[-1].remote_field.field_name
|
|
400
|
+
if self.remote_field.field_name is None and to_field not in (None, target._meta.pk.name):
|
|
401
|
+
self.remote_field.field_name = to_field
|
|
402
|
+
self.to_fields = [to_field]
|
|
403
|
+
# This does what resolve_related_class in RelatedField.contribute_to_class does once a lazy `to` resolves.
|
|
404
|
+
self.do_related_class(target, leaf)
|
|
405
|
+
|
|
406
|
+
def deconstruct(self):
|
|
407
|
+
name, path, args, kwargs = super().deconstruct()
|
|
408
|
+
# Whatever is not known yet is omitted. A copy of the field that is made now, by an audit package that copies
|
|
409
|
+
# fields while the model class is created, infers it from the same lookup path.
|
|
410
|
+
if not self.target_is_known:
|
|
411
|
+
kwargs.pop('to', None)
|
|
412
|
+
if not self.on_delete_is_known:
|
|
413
|
+
kwargs.pop('on_delete', None)
|
|
414
|
+
# ForeignKey.deconstruct omits null=False, because it is the default. A field that is rebuilt without null would
|
|
415
|
+
# infer it again from the lookup path, and in a migration that is the path in the historical state. When a later
|
|
416
|
+
# migration makes a hop nullable, the rebuilt field would then be nullable before the migration that makes the
|
|
417
|
+
# column nullable, and that migration would change nothing. So null is always written once it is known.
|
|
418
|
+
if self.null_is_known:
|
|
419
|
+
kwargs['null'] = self.null
|
|
420
|
+
return name, path, args, kwargs
|
|
421
|
+
|
|
422
|
+
def check(self, **kwargs):
|
|
423
|
+
if not self.target_is_known or not self.on_delete_is_known:
|
|
424
|
+
# The target or the rule is unknown only when the lookup path does not lead to a loaded model, or crosses a
|
|
425
|
+
# custom on_delete rule, and the path check reports both. Django's own relation checks would report
|
|
426
|
+
# INFERRED_TARGET as a missing model, or compare INFERRED_ON_DELETE with the rules of the target, which says
|
|
427
|
+
# nothing useful.
|
|
428
|
+
return self._check_denormalize_from()
|
|
429
|
+
return super().check(**kwargs)
|
|
430
|
+
|
|
431
|
+
def _check_end_of_chain(self, chain):
|
|
432
|
+
end = chain[-1].related_model._meta.label
|
|
433
|
+
if not self.target_is_known:
|
|
434
|
+
return self._error(
|
|
435
|
+
'postgres_denormalize.E003',
|
|
436
|
+
f'The target of the field is not known, but `{self.denormalize_from}` ends at {end}.',
|
|
437
|
+
)
|
|
438
|
+
if chain[-1].related_model is not self.related_model:
|
|
439
|
+
return self._error(
|
|
440
|
+
'postgres_denormalize.E003',
|
|
441
|
+
f'The field points at {self.related_model._meta.label}, but `{self.denormalize_from}` ends at {end}.',
|
|
442
|
+
)
|
|
443
|
+
if chain[-1].target_field != self.target_field:
|
|
444
|
+
return self._error(
|
|
445
|
+
'postgres_denormalize.E003',
|
|
446
|
+
f'The field points at {end}.{self.target_field.name}, but `{self.denormalize_from}` ends at '
|
|
447
|
+
f'{end}.{chain[-1].target_field.name}. Declare the field with the same to_field as the last hop, or '
|
|
448
|
+
f'without one.',
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
nullable = [hop.name for hop in chain[1:] if hop.null]
|
|
452
|
+
if nullable and not self.null:
|
|
453
|
+
return self._error(
|
|
454
|
+
'postgres_denormalize.E004',
|
|
455
|
+
f'`{self.denormalize_from}` crosses the nullable foreign key `{nullable[0]}`, so the trigger writes '
|
|
456
|
+
'NULL for every row where it is empty. Declare the field with null=True, or without null.',
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return self._check_on_delete_rule(chain)
|
|
460
|
+
|
|
461
|
+
def _check_on_delete_rule(self, chain):
|
|
462
|
+
"""
|
|
463
|
+
Check the ``on_delete`` rule against the rule that the lookup path implies (see ``infer_on_delete``).
|
|
464
|
+
"""
|
|
465
|
+
inferred, deciding_hop = infer_on_delete(chain)
|
|
466
|
+
hop_label = f'{deciding_hop.model._meta.label}.{deciding_hop.name}'
|
|
467
|
+
if inferred is None:
|
|
468
|
+
if self.on_delete_is_known:
|
|
469
|
+
# A declared rule cannot be compared with a custom rule along the path.
|
|
470
|
+
return []
|
|
471
|
+
return self._error(
|
|
472
|
+
'postgres_denormalize.E008',
|
|
473
|
+
f'`{self.denormalize_from}` crosses {hop_label}, which has the custom on_delete rule '
|
|
474
|
+
f'{describe_rule(deciding_hop.remote_field.on_delete)}, so the field cannot infer its own rule.',
|
|
475
|
+
hint='Declare on_delete on the field.',
|
|
476
|
+
)
|
|
477
|
+
declared = self.remote_field.on_delete
|
|
478
|
+
if declared is not inferred:
|
|
479
|
+
return self._warning(
|
|
480
|
+
'postgres_denormalize.W009',
|
|
481
|
+
f'The field declares on_delete={describe_rule(declared)}, but `{self.denormalize_from}` implies '
|
|
482
|
+
f'{describe_rule(inferred)}, which follows from the on_delete rule of {hop_label}.',
|
|
483
|
+
hint=f'Declare the field without on_delete, or with on_delete={describe_rule(inferred)}.',
|
|
484
|
+
)
|
|
485
|
+
return []
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def copy_column_definition(source, null):
|
|
489
|
+
"""
|
|
490
|
+
Return an unbound field with the same class, type, length, precision and collation as the column ``source``.
|
|
491
|
+
|
|
492
|
+
The field is nullable when ``null`` is true. It has none of the attributes that describe ``source`` as a form input
|
|
493
|
+
or that add a constraint or index to the table of ``source``.
|
|
494
|
+
"""
|
|
495
|
+
_name, _path, args, kwargs = source.deconstruct()
|
|
496
|
+
for omitted in OMITTED_SOURCE_KWARGS:
|
|
497
|
+
kwargs.pop(omitted, None)
|
|
498
|
+
return type(source)(*args, null=null, **kwargs)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class DenormalizedField(DenormalizedMixin, models.Field):
|
|
502
|
+
"""
|
|
503
|
+
A column that stores a copy of a column at the end of a chain of foreign keys, such as the name of an ancestor::
|
|
504
|
+
|
|
505
|
+
project_name = DenormalizedField('project__name')
|
|
506
|
+
|
|
507
|
+
The column has the type of the source column. ``base_field`` is a field with the same type as the source column
|
|
508
|
+
(see ``copy_column_definition``). This field passes everything that depends on the type to ``base_field``: the
|
|
509
|
+
column's database type, collation and check, the conversion of values to and from the database, and the lookups and
|
|
510
|
+
transforms that a query can apply. A query also reads the column as the base field (see ``get_col``). A filter, an
|
|
511
|
+
expression or an index on the copy therefore behaves as it would on the source column.
|
|
512
|
+
|
|
513
|
+
``base_field`` is inferred from the source column the first time it is needed, through the registry of the model
|
|
514
|
+
that declares the field. ``PostgresDenormalizeConfig.ready()`` does that for every model. ``deconstruct`` writes the
|
|
515
|
+
result into the migration, so a migration records the type that the column had when the migration was generated. A
|
|
516
|
+
field that Django rebuilds from migration state uses that recorded type and does not look it up. Changing the
|
|
517
|
+
source column, such as widening it, therefore changes the copy's ``deconstruct`` output, and ``makemigrations``
|
|
518
|
+
alters the copy in the same run.
|
|
519
|
+
|
|
520
|
+
The copy is nullable when the source column is nullable, or when a hop after the first is nullable. An empty hop
|
|
521
|
+
leaves no row to read from, and the trigger then writes NULL. ``null`` reads this from ``base_field``.
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
copies_column = True
|
|
525
|
+
|
|
526
|
+
def __init__(self, denormalize_from, *, base_field=None, **kwargs):
|
|
527
|
+
# The column's nullability always follows from the lookup path, never from the declaration.
|
|
528
|
+
kwargs.pop('null', None)
|
|
529
|
+
self._base_field = base_field
|
|
530
|
+
super().__init__(denormalize_from=denormalize_from, **kwargs)
|
|
531
|
+
|
|
532
|
+
@property
|
|
533
|
+
def null(self):
|
|
534
|
+
return self.base_field.null if self.resolve_base_field() else False
|
|
535
|
+
|
|
536
|
+
@null.setter
|
|
537
|
+
def null(self, value):
|
|
538
|
+
# Field.__init__ assigns null. The getter derives it from the source column, so the assignment is discarded.
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
@property
|
|
542
|
+
def base_field(self):
|
|
543
|
+
if self._base_field is None and not self.resolve_base_field():
|
|
544
|
+
raise ImproperlyConfigured(
|
|
545
|
+
f'{self} cannot infer its column type, because `{self.denormalize_from}` does not resolve to a '
|
|
546
|
+
f'column. The system checks say why.'
|
|
547
|
+
)
|
|
548
|
+
return self._base_field
|
|
549
|
+
|
|
550
|
+
def resolve_base_field(self):
|
|
551
|
+
"""
|
|
552
|
+
Infer ``base_field`` from the source column, unless it is already known. Return whether it is known now.
|
|
553
|
+
|
|
554
|
+
When the lookup path does not resolve, ``base_field`` stays unknown, so that the system check reports the
|
|
555
|
+
problem and this method does not.
|
|
556
|
+
"""
|
|
557
|
+
if self._base_field is not None:
|
|
558
|
+
return True
|
|
559
|
+
model = getattr(self, 'model', None)
|
|
560
|
+
if model is None or not model._meta.apps.models_ready:
|
|
561
|
+
return False
|
|
562
|
+
try:
|
|
563
|
+
chain = self.resolve_chain()
|
|
564
|
+
except FieldDoesNotExist, ValueError:
|
|
565
|
+
return False
|
|
566
|
+
if not _is_copyable_column(chain[-1]):
|
|
567
|
+
return False
|
|
568
|
+
self._base_field = copy_column_definition(chain[-1], null=any(hop.null for hop in chain[1:]))
|
|
569
|
+
return True
|
|
570
|
+
|
|
571
|
+
def deconstruct(self):
|
|
572
|
+
name, path, args, kwargs = super().deconstruct()
|
|
573
|
+
kwargs.pop('null', None)
|
|
574
|
+
if self.resolve_base_field():
|
|
575
|
+
kwargs['base_field'] = self._base_field
|
|
576
|
+
return name, path, args, kwargs
|
|
577
|
+
|
|
578
|
+
def _check_end_of_chain(self, chain):
|
|
579
|
+
source = chain[-1]
|
|
580
|
+
if source.is_relation:
|
|
581
|
+
return self._error(
|
|
582
|
+
'postgres_denormalize.E005',
|
|
583
|
+
f'`{self.denormalize_from}` ends at the foreign key `{source.name}`. A copy of a foreign key is a '
|
|
584
|
+
f'DenormalizedForeignKey.',
|
|
585
|
+
)
|
|
586
|
+
if source.primary_key:
|
|
587
|
+
return self._error(
|
|
588
|
+
'postgres_denormalize.E005',
|
|
589
|
+
f'`{self.denormalize_from}` ends at the primary key `{source.name}`, whose value the foreign key '
|
|
590
|
+
'before it already stores. A copy of that key is a DenormalizedForeignKey.',
|
|
591
|
+
)
|
|
592
|
+
if isinstance(source, models.GeneratedField):
|
|
593
|
+
return self._error(
|
|
594
|
+
'postgres_denormalize.E005',
|
|
595
|
+
f'`{self.denormalize_from}` ends at the generated column `{source.name}`, which this field does not '
|
|
596
|
+
f'copy.',
|
|
597
|
+
)
|
|
598
|
+
return []
|
|
599
|
+
|
|
600
|
+
def get_internal_type(self):
|
|
601
|
+
return self.base_field.get_internal_type()
|
|
602
|
+
|
|
603
|
+
def db_type(self, connection):
|
|
604
|
+
return self.base_field.db_type(connection)
|
|
605
|
+
|
|
606
|
+
def db_parameters(self, connection):
|
|
607
|
+
return self.base_field.db_parameters(connection)
|
|
608
|
+
|
|
609
|
+
def cast_db_type(self, connection):
|
|
610
|
+
return self.base_field.cast_db_type(connection)
|
|
611
|
+
|
|
612
|
+
def get_col(self, alias, output_field=None):
|
|
613
|
+
# A query reads the column through a Col whose output field is the base field, the way a ForeignKey's Col
|
|
614
|
+
# reports its target field. Expressions, lookups and value conversion therefore see the class of the source
|
|
615
|
+
# column. Concat and Coalesce resolve their type without a "mixed types" error. Concat skips the cast to text
|
|
616
|
+
# that it applies to anything that is not a CharField or TextField. Lookups that are registered on the class of
|
|
617
|
+
# the source apply as they would to the source column. The Col converts values with the base field alone,
|
|
618
|
+
# because this field has no converter of its own. As a result, Field.cached_col is never used, because Django
|
|
619
|
+
# only returns it for a Col whose output field is the field itself.
|
|
620
|
+
if output_field is None or output_field is self:
|
|
621
|
+
output_field = self.base_field
|
|
622
|
+
return super().get_col(alias, output_field)
|
|
623
|
+
|
|
624
|
+
def get_prep_value(self, value):
|
|
625
|
+
return self.base_field.get_prep_value(value)
|
|
626
|
+
|
|
627
|
+
def get_db_prep_value(self, value, connection, prepared=False):
|
|
628
|
+
return self.base_field.get_db_prep_value(value, connection, prepared)
|
|
629
|
+
|
|
630
|
+
def to_python(self, value):
|
|
631
|
+
return self.base_field.to_python(value)
|
|
632
|
+
|
|
633
|
+
def value_to_string(self, obj):
|
|
634
|
+
# The serializers write a value that is not a number, a date or None with value_to_string, and read it back with
|
|
635
|
+
# to_python. Field.value_to_string would write str(value), which to_python of an ArrayField or a JSONField
|
|
636
|
+
# cannot read back, so the base field writes it. The base field reads the value from `obj` through its own
|
|
637
|
+
# attname. It is never added to a model, so it gets the attname of this field first, as ArrayField does for the
|
|
638
|
+
# field of its items.
|
|
639
|
+
base_field = self.base_field
|
|
640
|
+
if getattr(base_field, 'attname', None) != self.attname:
|
|
641
|
+
base_field.set_attributes_from_name(self.name)
|
|
642
|
+
return base_field.value_to_string(obj)
|
|
643
|
+
|
|
644
|
+
@property
|
|
645
|
+
def empty_strings_allowed(self):
|
|
646
|
+
return self.base_field.empty_strings_allowed
|
|
647
|
+
|
|
648
|
+
def get_lookup(self, lookup_name):
|
|
649
|
+
return self.base_field.get_lookup(lookup_name)
|
|
650
|
+
|
|
651
|
+
def get_transform(self, lookup_name):
|
|
652
|
+
return self.base_field.get_transform(lookup_name)
|
|
653
|
+
|
|
654
|
+
def formfield(self, **kwargs):
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _is_copyable_column(field):
|
|
659
|
+
return not (field.is_relation or field.primary_key or isinstance(field, models.GeneratedField))
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def resolve_denormalized_fields(apps=global_apps):
|
|
663
|
+
"""
|
|
664
|
+
Infer ``base_field`` for every ``DenormalizedField`` in ``apps``, including those on excluded models, because their
|
|
665
|
+
columns need a type too.
|
|
666
|
+
|
|
667
|
+
``PostgresDenormalizeConfig.ready()`` calls this once every model is loaded. Django reads ``null`` when it builds
|
|
668
|
+
queries, so ``null`` has to be known before the first query runs.
|
|
669
|
+
"""
|
|
670
|
+
for model in apps.get_models():
|
|
671
|
+
for field in model._meta.concrete_fields:
|
|
672
|
+
if isinstance(field, DenormalizedField):
|
|
673
|
+
field.resolve_base_field()
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def get_denormalized_fields(model):
|
|
677
|
+
"""
|
|
678
|
+
Return the denormalized fields of both kinds that ``model`` declares, in declaration order.
|
|
679
|
+
|
|
680
|
+
Only the fields whose column is in the table of ``model`` count. A model that inherits from a concrete parent, and a
|
|
681
|
+
proxy model, therefore get no triggers, no drift filter and no backfill for the fields of the parent, because the
|
|
682
|
+
triggers of the parent already keep those columns correct.
|
|
683
|
+
|
|
684
|
+
For an excluded model (see ``EXCLUDE_MODELS``) this returns no fields, so the model gets no triggers, no drift
|
|
685
|
+
filter and no backfill. The usual reason to exclude a model is an audit table that copies the fields of a tracked
|
|
686
|
+
model, including the denormalized ones. Its rows record the value that a copy had at the time, so no trigger may
|
|
687
|
+
rewrite them.
|
|
688
|
+
"""
|
|
689
|
+
if conf.is_excluded(model):
|
|
690
|
+
return []
|
|
691
|
+
return [field for field in model._meta.local_concrete_fields if isinstance(field, DenormalizedMixin)]
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def drift_condition(model):
|
|
695
|
+
"""
|
|
696
|
+
Build a filter that matches the rows of ``model`` where any denormalized field differs from its lookup path.
|
|
697
|
+
|
|
698
|
+
For a model without denormalized fields, such as an excluded model, the filter matches no row. An empty ``Q()``
|
|
699
|
+
would match every row instead.
|
|
700
|
+
"""
|
|
701
|
+
fields = get_denormalized_fields(model)
|
|
702
|
+
if not fields:
|
|
703
|
+
return Q(pk__in=[])
|
|
704
|
+
return reduce(operator.or_, (Q(IsDistinctFrom(F(field.attname), F(field.denormalize_from))) for field in fields))
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
__all__ = [
|
|
708
|
+
'DenormalizedField',
|
|
709
|
+
'DenormalizedForeignKey',
|
|
710
|
+
'DenormalizedMixin',
|
|
711
|
+
'IsDistinctFrom',
|
|
712
|
+
'drift_condition',
|
|
713
|
+
'get_denormalized_fields',
|
|
714
|
+
'infer_on_delete',
|
|
715
|
+
'resolve_denormalized_fields',
|
|
716
|
+
]
|