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,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
+ ]