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