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,139 @@
1
+ """
2
+ The migration operation that makes stored denormalized fields equal to the values at the end of their lookup paths.
3
+ """
4
+
5
+ from django.db import migrations
6
+ from django.db.models import Case, Exists, F, OuterRef, Subquery, When
7
+
8
+ from postgres_denormalize.fields import DenormalizedMixin, IsDistinctFrom, get_denormalized_fields
9
+
10
+
11
+ def lookup_path_value(model, field):
12
+ """
13
+ Return an expression for the value at the end of ``field``'s lookup path for a row of ``model``, starting from the
14
+ row's own first hop. When the first hop points at no row, the expression is the row's current value.
15
+
16
+ ``model`` is the leaf, or an excluded model that has a copy of the leaf's field, such as an audit table. Such a
17
+ model has the same first hop column as the leaf, so the same path leads from it. The row of the first hop is read in
18
+ a subquery rather than through a join, because an audit table usually declares its foreign keys without a database
19
+ constraint, and its first hop can point at a row that does not exist anymore.
20
+ """
21
+ chain = field.resolve_chain(model)
22
+ first = chain[0]
23
+ ancestor = first.related_model._base_manager.filter(**{first.target_field.attname: OuterRef(first.attname)})
24
+ rest = '__'.join(hop.name for hop in chain[1:])
25
+ # The subquery reads a key or a column of the ancestor, and the default reads the denormalized column itself. For a
26
+ # foreign key, those are fields of different classes with the same value. Using the field of the column that is
27
+ # written as the output field makes the two types match.
28
+ output_field = field.target_field if field.is_relation else field
29
+ return Case(
30
+ When(Exists(ancestor), then=Subquery(ancestor.values(rest)[:1], output_field=output_field)),
31
+ default=F(field.attname),
32
+ output_field=output_field,
33
+ )
34
+
35
+
36
+ def backfill_denormalized(model, also_fill=None, using=None):
37
+ """
38
+ Correct every denormalized field of ``model`` that differs from its lookup path, with one set-based UPDATE per
39
+ field. Rows that already have the right value are not written. For an excluded model, this does nothing.
40
+
41
+ ``also_fill`` maps model classes to lists of field names, and fills those fields on every row of each model, with
42
+ the current value at the end of its own lookup path. It is meant for a new column on an excluded model, such as an
43
+ audit table. ``using`` names the database. Without it, each model is written to the database that the router chooses
44
+ for it.
45
+
46
+ The function runs no transaction of its own. To correct several leaves together, wrap the calls in one::
47
+
48
+ with transaction.atomic():
49
+ backfill_denormalized(Task)
50
+ backfill_denormalized(Comment)
51
+
52
+ :doc:`/guides/drift` describes how to find and correct stale copies. ``BackfillDenormalized`` runs this function in
53
+ a migration; see :doc:`/guides/migrations`.
54
+ """
55
+ for field in get_denormalized_fields(model):
56
+ value = lookup_path_value(model, field)
57
+ model._base_manager.db_manager(using).filter(IsDistinctFrom(F(field.attname), value)).update(
58
+ **{field.attname: value}
59
+ )
60
+
61
+ for other, names in (also_fill or {}).items():
62
+ for name in names:
63
+ field = other._meta.get_field(name)
64
+ if not isinstance(field, DenormalizedMixin):
65
+ raise TypeError(
66
+ f'{other._meta.label}.{name} is not a denormalized field, so it has no path to fill from.'
67
+ )
68
+ other._base_manager.db_manager(using).update(**{field.attname: lookup_path_value(other, field)})
69
+
70
+
71
+ class BackfillDenormalized(migrations.RunPython):
72
+ """
73
+ Set every denormalized field of a leaf model to the value at the end of its lookup path.
74
+
75
+ A migration runs this before it installs or changes ``denormalize_set``, in two situations:
76
+
77
+ - An existing column becomes a denormalized field. Rows whose stored value differs from the lookup path are
78
+ corrected. The trigger only recomputes a row when the row is written, so without the correction, a row that
79
+ differs and is never written again would keep the wrong value. The rows that match are not written, so they get
80
+ no history rows.
81
+ - A new denormalized field is added. Its leaf rows are filled in the same way. ``also_fill`` fills the copy of the
82
+ field on excluded models that have it, such as an audit table: ``also_fill={'taskaudit': ['project_name']}``.
83
+ Every row of those models gets the current value at the end of its own lookup path. The value that an audit row
84
+ had when it was recorded is not known, so this current value is the closest record of the past.
85
+
86
+ Model names in ``also_fill`` are resolved in the migration's own app, unless they are given as
87
+ ``'app_label.model_name'``.
88
+
89
+ The correction is one set-based UPDATE per field. It runs on the databases that the routers allow for the model, as
90
+ the ``AddField`` that created the column did: the routers' ``allow_migrate`` receives the model's name as the
91
+ ``model_name`` hint. ``hints`` adds to that hint, and a ``model_name`` in ``hints`` replaces it. When the model is
92
+ given as ``'app_label.model_name'``, the operation passes no ``model_name``, because ``allow_migrate`` receives the
93
+ migration's own app label and the pair would name another model. The models in ``also_fill`` are not part of the
94
+ hint, so they must be on the same databases as the model.
95
+
96
+ When the correction of a column can violate a unique constraint, the migration needs a step before this one that
97
+ lists the collisions and raises, because the Postgres error names only the first collision.
98
+
99
+ Reversing the migration runs nothing: the corrected value is correct whether the migration is applied or not.
100
+ """
101
+
102
+ def __init__(self, model_name, also_fill=None, hints=None):
103
+ self.model_name = model_name
104
+ self.also_fill = dict(also_fill or {})
105
+ # The migration file records only the hints that were passed. The model_name hint is added again each time the
106
+ # operation is built.
107
+ self.given_hints = dict(hints or {})
108
+ router_hints = {} if '.' in model_name else {'model_name': model_name.lower()}
109
+ super().__init__(self.backfill, migrations.RunPython.noop, hints={**router_hints, **self.given_hints})
110
+
111
+ def deconstruct(self):
112
+ kwargs = {'model_name': self.model_name}
113
+ if self.also_fill:
114
+ kwargs['also_fill'] = self.also_fill
115
+ if self.given_hints:
116
+ kwargs['hints'] = self.given_hints
117
+ return self.__class__.__qualname__, [], kwargs
118
+
119
+ def describe(self):
120
+ return f'Backfill the denormalized fields of {self.model_name}'
121
+
122
+ def database_forwards(self, app_label, schema_editor, from_state, to_state):
123
+ # RunPython passes the registry to its function, but not the app label, which backfill needs to find the models.
124
+ self.app_label = app_label
125
+ super().database_forwards(app_label, schema_editor, from_state, to_state)
126
+
127
+ def backfill(self, apps, schema_editor):
128
+ # The migration runs on the database of the schema editor, such as the one that `migrate --database` names. When
129
+ # a project migrates several databases in turn, the router's choice for writing the model would send every run
130
+ # to the same database.
131
+ also_fill = {self._get_model(apps, name): fields for name, fields in self.also_fill.items()}
132
+ backfill_denormalized(self._get_model(apps, self.model_name), also_fill, using=schema_editor.connection.alias)
133
+
134
+ def _get_model(self, apps, name):
135
+ app_label, _, model_name = name.rpartition('.')
136
+ return apps.get_model(app_label or self.app_label, model_name)
137
+
138
+
139
+ __all__ = ['BackfillDenormalized', 'backfill_denormalized', 'lookup_path_value']
File without changes