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,381 @@
|
|
|
1
|
+
"""
|
|
2
|
+
System checks that look at the project as a whole rather than at one field.
|
|
3
|
+
|
|
4
|
+
The field-level checks (a lookup path that does not resolve, a nullable hop, a target that differs from the path) are
|
|
5
|
+
part of each field's own ``check()``. The checks here are registered under the ``postgres_denormalize`` tag:
|
|
6
|
+
|
|
7
|
+
- ``E301``: pgtrigger neither writes triggers into migrations nor installs them after ``migrate``, so the triggers that
|
|
8
|
+
this package derives would never reach the database.
|
|
9
|
+
- ``W302``: a lookup path crosses a model of an installed package, so that model gets triggers, and
|
|
10
|
+
``makemigrations`` writes them into the package's migrations. A hop that points at a proxy model in one of the
|
|
11
|
+
project's own apps moves the triggers into that app.
|
|
12
|
+
- ``W303``/``E303``: a database that stores copies runs transactions below SERIALIZABLE. A leaf write that runs at the
|
|
13
|
+
same time as an update of its ancestor can then commit a copy that differs from its lookup path.
|
|
14
|
+
- ``W101``/``E101``: a foreign key whose target the model can also reach through another chain of foreign keys. The
|
|
15
|
+
column then stores a copy that no trigger keeps equal to its source. Declare it as a ``DenormalizedForeignKey``, or
|
|
16
|
+
list it in ``REDUNDANT_COPY_CHECK['ALLOWED']`` with the reason why the two may differ.
|
|
17
|
+
- ``E102``: an ``ALLOWED`` entry that does not name such a foreign key, so the entry can be removed.
|
|
18
|
+
- ``E103``: an ``ALLOWED`` entry with an empty reason, which cannot be told apart from an oversight.
|
|
19
|
+
- ``W201``/``E201``: a ``Meta.ordering`` that walks a lookup path that the model also stores a copy of. Every query
|
|
20
|
+
with the default ordering then joins the tables that the copy is meant to avoid, and an index on the copy cannot
|
|
21
|
+
serve it.
|
|
22
|
+
- ``E401``: an ``EXCLUDE_MODELS`` entry that names no installed model, so it excludes nothing.
|
|
23
|
+
- ``E402``: a ``REDUNDANT_COPY_CHECK['APPS']`` entry that names no installed app, so it checks nothing.
|
|
24
|
+
|
|
25
|
+
``REDUNDANT_COPY_CHECK['LEVEL']``, ``ORDERING_CHECK_LEVEL`` and ``ISOLATION_CHECK_LEVEL`` choose between the warning,
|
|
26
|
+
the error, and ``'off'``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import site
|
|
30
|
+
import sysconfig
|
|
31
|
+
from collections import deque
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from django.apps import apps
|
|
35
|
+
from django.core import checks
|
|
36
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
37
|
+
from django.db import connections, router
|
|
38
|
+
from django.db.backends.postgresql.psycopg_any import IsolationLevel
|
|
39
|
+
from pgtrigger import features
|
|
40
|
+
|
|
41
|
+
from postgres_denormalize import conf
|
|
42
|
+
from postgres_denormalize.chains import can_cross, is_forward_hop
|
|
43
|
+
from postgres_denormalize.fields import DenormalizedForeignKey, get_denormalized_fields
|
|
44
|
+
from postgres_denormalize.triggers import owners_of, resolvable_fields
|
|
45
|
+
|
|
46
|
+
TAG = 'postgres_denormalize'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@checks.register(TAG)
|
|
50
|
+
def check_triggers_reach_the_database(app_configs=None, **kwargs):
|
|
51
|
+
if features.migrations() or features.install_on_migrate():
|
|
52
|
+
return []
|
|
53
|
+
return [
|
|
54
|
+
checks.Error(
|
|
55
|
+
'pgtrigger neither writes triggers into migrations nor installs them after migrate, so the triggers that '
|
|
56
|
+
'postgres_denormalize derives never reach the database.',
|
|
57
|
+
hint='Leave PGTRIGGER_MIGRATIONS on (the default), or turn PGTRIGGER_INSTALL_ON_MIGRATE on.',
|
|
58
|
+
id='postgres_denormalize.E301',
|
|
59
|
+
)
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@checks.register(TAG)
|
|
64
|
+
def check_excluded_models_exist(app_configs=None, **kwargs):
|
|
65
|
+
installed = {model._meta.label_lower for model in apps.get_models()}
|
|
66
|
+
return [
|
|
67
|
+
checks.Error(
|
|
68
|
+
f'{label} is listed in EXCLUDE_MODELS, but no installed model has that label, '
|
|
69
|
+
'so the entry excludes nothing.',
|
|
70
|
+
hint="Use the model's label, 'app_label.ModelName', or remove the entry.",
|
|
71
|
+
id='postgres_denormalize.E401',
|
|
72
|
+
)
|
|
73
|
+
for label in sorted(conf.get_setting('EXCLUDE_MODELS'))
|
|
74
|
+
if label.lower() not in installed
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@checks.register(TAG)
|
|
79
|
+
def check_redundant_copies(app_configs=None, **kwargs):
|
|
80
|
+
config = conf.get_setting('REDUNDANT_COPY_CHECK')
|
|
81
|
+
if config['LEVEL'] == 'off':
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
allowed = config['ALLOWED']
|
|
85
|
+
messages = []
|
|
86
|
+
flagged = set()
|
|
87
|
+
installed_labels = {app_config.label for app_config in apps.get_app_configs()}
|
|
88
|
+
for label in sorted(config['APPS'] or ()):
|
|
89
|
+
if label not in installed_labels:
|
|
90
|
+
messages.append(
|
|
91
|
+
checks.Error(
|
|
92
|
+
f'{label} is listed in REDUNDANT_COPY_CHECK["APPS"], but no installed app has that label, so the '
|
|
93
|
+
'entry checks nothing.',
|
|
94
|
+
hint='Use the app label, or remove the entry.',
|
|
95
|
+
id='postgres_denormalize.E402',
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
checked = _checked_models(app_configs, config['APPS'])
|
|
99
|
+
for model in checked:
|
|
100
|
+
for field in _forward_to_one_fields(model):
|
|
101
|
+
path = alternative_paths(model, field, config['MAX_DEPTH']).get(field.related_model)
|
|
102
|
+
if path is None:
|
|
103
|
+
continue
|
|
104
|
+
label = f'{model._meta.label}.{field.name}'
|
|
105
|
+
flagged.add(label)
|
|
106
|
+
if isinstance(field, DenormalizedForeignKey) or label in allowed:
|
|
107
|
+
continue
|
|
108
|
+
messages.append(
|
|
109
|
+
_warning_or_error(
|
|
110
|
+
config['LEVEL'],
|
|
111
|
+
'101',
|
|
112
|
+
f'{label} points at {field.related_model._meta.label}, which {model._meta.label} also reaches '
|
|
113
|
+
f'through `{path}`, so the column stores a copy that no trigger keeps equal to its source.',
|
|
114
|
+
hint=(
|
|
115
|
+
f"Declare it as DenormalizedForeignKey('{path}', ...), or add '{label}' to "
|
|
116
|
+
f"POSTGRES_DENORMALIZE['REDUNDANT_COPY_CHECK']['ALLOWED'] with the reason the two may differ."
|
|
117
|
+
),
|
|
118
|
+
obj=field,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
checked_labels = {model._meta.label for model in checked}
|
|
123
|
+
for label, reason in sorted(allowed.items()):
|
|
124
|
+
if label.rpartition('.')[0] in checked_labels and label not in flagged:
|
|
125
|
+
messages.append(
|
|
126
|
+
checks.Error(
|
|
127
|
+
f'{label} is listed in REDUNDANT_COPY_CHECK["ALLOWED"], but its model cannot reach its target in '
|
|
128
|
+
'any other way, so the entry is not needed.',
|
|
129
|
+
id='postgres_denormalize.E102',
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
if not reason or not str(reason).strip():
|
|
133
|
+
messages.append(
|
|
134
|
+
checks.Error(
|
|
135
|
+
f'{label} is listed in REDUNDANT_COPY_CHECK["ALLOWED"] without a reason.',
|
|
136
|
+
hint='State why the two routes may lead to different rows.',
|
|
137
|
+
id='postgres_denormalize.E103',
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return messages
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@checks.register(TAG)
|
|
144
|
+
def check_ordering_uses_copies(app_configs=None, **kwargs):
|
|
145
|
+
level = conf.get_setting('ORDERING_CHECK_LEVEL')
|
|
146
|
+
if level == 'off':
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
messages = []
|
|
150
|
+
for model in _checked_models(app_configs, None):
|
|
151
|
+
for entry in model._meta.ordering:
|
|
152
|
+
if not isinstance(entry, str):
|
|
153
|
+
continue
|
|
154
|
+
for field in get_denormalized_fields(model):
|
|
155
|
+
if crosses_lookup_path(model, entry.removeprefix('-').removeprefix('?'), field.denormalize_from):
|
|
156
|
+
messages.append(
|
|
157
|
+
_warning_or_error(
|
|
158
|
+
level,
|
|
159
|
+
'201',
|
|
160
|
+
f'{model._meta.label} orders by `{entry}`, which walks `{field.denormalize_from}`, a path '
|
|
161
|
+
f'that its field `{field.name}` stores a copy of.',
|
|
162
|
+
hint=f'Order by `{ordering_through_copy(model, entry, field)}` instead.',
|
|
163
|
+
obj=model,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
return messages
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ordering_through_copy(model, entry, field):
|
|
170
|
+
"""
|
|
171
|
+
Return the ordering ``entry`` of ``model`` with the lookup path that ``field`` copies replaced by the field.
|
|
172
|
+
|
|
173
|
+
The direction and the segments after the copied path are kept: with the copy ``tenant`` of ``project__tenant``,
|
|
174
|
+
``-project__tenant__name`` becomes ``-tenant__name``.
|
|
175
|
+
"""
|
|
176
|
+
direction = '-' if entry.startswith('-') else ''
|
|
177
|
+
names = _field_names(model, entry.removeprefix('-'))
|
|
178
|
+
rest = names[len(field.denormalize_from.split('__')) :]
|
|
179
|
+
return direction + '__'.join([field.name, *rest])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@checks.register(TAG)
|
|
183
|
+
def check_owners_outside_the_project(app_configs=None, **kwargs):
|
|
184
|
+
return third_party_owner_warnings(_checked_models(app_configs, None))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def third_party_owner_warnings(leaves):
|
|
188
|
+
"""
|
|
189
|
+
Return a W302 warning for every model in a site-packages app that gets cascade and fill triggers for one of
|
|
190
|
+
``leaves``.
|
|
191
|
+
|
|
192
|
+
pgtrigger adds a trigger to the migration state of the model that it is registered on. ``makemigrations`` would then
|
|
193
|
+
write a migration into the third-party app, inside site-packages, or report a change that never goes away. A hop
|
|
194
|
+
that points at a proxy model in the project's own app moves the triggers, and their migration, into that app.
|
|
195
|
+
"""
|
|
196
|
+
installed = _site_packages_directories()
|
|
197
|
+
messages = []
|
|
198
|
+
for leaf in leaves:
|
|
199
|
+
for owner in owners_of(resolvable_fields(leaf)):
|
|
200
|
+
config = apps.get_app_config(owner._meta.app_label)
|
|
201
|
+
if not _is_inside(Path(config.path), installed):
|
|
202
|
+
continue
|
|
203
|
+
messages.append(
|
|
204
|
+
checks.Warning(
|
|
205
|
+
f'{leaf._meta.label} copies from {owner._meta.label}, a model of an installed package, so its '
|
|
206
|
+
f'cascade and fill triggers are registered on {owner._meta.label}, and makemigrations writes them '
|
|
207
|
+
f'into the migrations of {config.label}.',
|
|
208
|
+
hint=(
|
|
209
|
+
f'Declare a proxy model of {owner._meta.label} in one of your own apps, and point the foreign '
|
|
210
|
+
f'key of {leaf._meta.label} at the proxy. The triggers and their migration then belong to that '
|
|
211
|
+
f'app.'
|
|
212
|
+
),
|
|
213
|
+
obj=leaf,
|
|
214
|
+
id='postgres_denormalize.W302',
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
return messages
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@checks.register(TAG)
|
|
221
|
+
def check_isolation_level(app_configs=None, **kwargs):
|
|
222
|
+
level = conf.get_setting('ISOLATION_CHECK_LEVEL')
|
|
223
|
+
if level == 'off':
|
|
224
|
+
return []
|
|
225
|
+
leaves = [model for model in apps.get_models() if get_denormalized_fields(model) and not conf.is_excluded(model)]
|
|
226
|
+
messages = []
|
|
227
|
+
for alias in connections:
|
|
228
|
+
connection = connections[alias]
|
|
229
|
+
if connection.vendor != 'postgresql':
|
|
230
|
+
continue
|
|
231
|
+
if not any(router.allow_migrate_model(alias, leaf) for leaf in leaves):
|
|
232
|
+
continue
|
|
233
|
+
isolation = connection.settings_dict.get('OPTIONS', {}).get('isolation_level', IsolationLevel.READ_COMMITTED)
|
|
234
|
+
if isolation == IsolationLevel.SERIALIZABLE:
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
isolation_name = IsolationLevel(isolation).name.replace('_', ' ')
|
|
238
|
+
except ValueError:
|
|
239
|
+
isolation_name = repr(isolation)
|
|
240
|
+
messages.append(
|
|
241
|
+
_warning_or_error(
|
|
242
|
+
level,
|
|
243
|
+
'303',
|
|
244
|
+
f'The database {alias!r} runs transactions at the isolation level {isolation_name}. Below '
|
|
245
|
+
f'SERIALIZABLE, a write to a leaf row that runs at the same time as an update of its ancestor row can '
|
|
246
|
+
f'commit a copy that differs from its lookup path.',
|
|
247
|
+
hint=(
|
|
248
|
+
f"Set DATABASES[{alias!r}]['OPTIONS']['isolation_level'] to IsolationLevel.SERIALIZABLE, and retry "
|
|
249
|
+
f'transactions that fail with a serialization error. Or set '
|
|
250
|
+
f"POSTGRES_DENORMALIZE['ISOLATION_CHECK_LEVEL'] to 'off', and find such copies with "
|
|
251
|
+
f'drift_condition().'
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
return messages
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def crosses_lookup_path(model, path, denormalize_from):
|
|
259
|
+
"""
|
|
260
|
+
Say whether ``path``, an ordering of ``model``, walks the whole of ``denormalize_from``.
|
|
261
|
+
|
|
262
|
+
For the lookup path ``project__tenant``, the paths ``project__tenant``, ``project__tenant_id`` and
|
|
263
|
+
``project__tenant__name`` all walk it, and each of them pays for the join that the copy is meant to avoid.
|
|
264
|
+
``project`` does not walk it, because it ends before the copied value.
|
|
265
|
+
"""
|
|
266
|
+
path = '__'.join(_field_names(model, path))
|
|
267
|
+
return path == denormalize_from or path.startswith(f'{denormalize_from}__')
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _field_names(model, path):
|
|
271
|
+
"""
|
|
272
|
+
Return the segments of ``path``, with each segment that names a field replaced by the field's name.
|
|
273
|
+
|
|
274
|
+
A foreign key's column, such as ``tenant_id``, therefore becomes the name of the foreign key, ``tenant``. A column
|
|
275
|
+
whose own name ends in ``_id`` keeps its name. Once a segment names no field, or a field that is not a relation,
|
|
276
|
+
the remaining segments are transforms or lookups, and they are returned as they are.
|
|
277
|
+
"""
|
|
278
|
+
segments = path.split('__')
|
|
279
|
+
names = []
|
|
280
|
+
current = model
|
|
281
|
+
for position, segment in enumerate(segments):
|
|
282
|
+
try:
|
|
283
|
+
field = current._meta.get_field(segment)
|
|
284
|
+
except FieldDoesNotExist:
|
|
285
|
+
return [*names, *segments[position:]]
|
|
286
|
+
names.append(field.name)
|
|
287
|
+
if not field.is_relation or isinstance(field.related_model, str):
|
|
288
|
+
return [*names, *segments[position + 1 :]]
|
|
289
|
+
current = field.related_model
|
|
290
|
+
return names
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def alternative_paths(model, excluded_first_hop, max_depth):
|
|
294
|
+
"""
|
|
295
|
+
Map each model that can be reached from ``model`` in two or more hops to one lookup path that reaches it.
|
|
296
|
+
|
|
297
|
+
The walk never starts with ``excluded_first_hop``, so a field never counts as an alternative route to itself. A
|
|
298
|
+
single hop does not make a field redundant: two foreign keys on the same model that point at the same target are
|
|
299
|
+
siblings, not copies. The walk crosses only the hops that a lookup path may cross (see ``can_cross``), so every
|
|
300
|
+
path that it returns is a path that a ``DenormalizedForeignKey`` accepts.
|
|
301
|
+
|
|
302
|
+
The walk is breadth-first, so each model is first reached, and expanded, at its smallest depth. A depth-first walk
|
|
303
|
+
could first reach a model through a long route and stop expanding it at the depth limit. It would then never
|
|
304
|
+
revisit the model through a shorter route, and miss every model that only the shorter route brings within the limit.
|
|
305
|
+
"""
|
|
306
|
+
paths = {}
|
|
307
|
+
visited = {model}
|
|
308
|
+
frontier = deque([(model, 0, '')])
|
|
309
|
+
|
|
310
|
+
while frontier:
|
|
311
|
+
current, depth, prefix = frontier.popleft()
|
|
312
|
+
if depth >= max_depth:
|
|
313
|
+
continue
|
|
314
|
+
for field in current._meta.get_fields():
|
|
315
|
+
if not can_cross(current, field) or (current is model and field is excluded_first_hop):
|
|
316
|
+
continue
|
|
317
|
+
path = f'{prefix}{field.name}'
|
|
318
|
+
if depth >= 1:
|
|
319
|
+
paths.setdefault(field.related_model, path)
|
|
320
|
+
if field.related_model not in visited:
|
|
321
|
+
visited.add(field.related_model)
|
|
322
|
+
frontier.append((field.related_model, depth + 1, f'{path}__'))
|
|
323
|
+
|
|
324
|
+
return paths
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _forward_to_one_fields(model):
|
|
328
|
+
"""
|
|
329
|
+
Return the model's own foreign keys and one-to-one fields, which are the only relations that lead to a single row.
|
|
330
|
+
|
|
331
|
+
A field that the model inherits from a concrete parent is not its own: its column is in the parent's table, and the
|
|
332
|
+
check looks at it on the parent.
|
|
333
|
+
"""
|
|
334
|
+
return [field for field in model._meta.local_fields if is_forward_hop(field)]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _checked_models(app_configs, labels):
|
|
338
|
+
"""
|
|
339
|
+
Return the models that the project-wide checks look at.
|
|
340
|
+
|
|
341
|
+
``app_configs`` is the list of apps that ``manage.py check <app_label>`` limits the run to. Without it, ``labels``
|
|
342
|
+
names the apps. Without those, every app that is not installed in a site-packages directory is checked, because the
|
|
343
|
+
project cannot change the models of a third-party app. Proxy models are skipped, because their fields belong to the
|
|
344
|
+
model that they proxy. Excluded models are skipped too.
|
|
345
|
+
"""
|
|
346
|
+
if app_configs is not None:
|
|
347
|
+
configs = app_configs
|
|
348
|
+
elif labels is not None:
|
|
349
|
+
# A label that names no installed app is skipped here. check_redundant_copies reports it as E402.
|
|
350
|
+
installed = {config.label: config for config in apps.get_app_configs()}
|
|
351
|
+
configs = [installed[label] for label in labels if label in installed]
|
|
352
|
+
else:
|
|
353
|
+
installed = _site_packages_directories()
|
|
354
|
+
configs = [config for config in apps.get_app_configs() if not _is_inside(Path(config.path), installed)]
|
|
355
|
+
|
|
356
|
+
return [
|
|
357
|
+
model
|
|
358
|
+
for config in configs
|
|
359
|
+
for model in config.get_models()
|
|
360
|
+
if not model._meta.proxy and not conf.is_excluded(model)
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _site_packages_directories():
|
|
365
|
+
candidates = [*site.getsitepackages(), site.getusersitepackages()]
|
|
366
|
+
candidates.extend(sysconfig.get_paths()[key] for key in ('purelib', 'platlib'))
|
|
367
|
+
return {Path(candidate).resolve() for candidate in candidates}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _is_inside(path, directories):
|
|
371
|
+
resolved = path.resolve()
|
|
372
|
+
return any(directory == resolved or directory in resolved.parents for directory in directories)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _warning_or_error(level, number, message, **kwargs):
|
|
376
|
+
if level == 'error':
|
|
377
|
+
return checks.Error(message, id=f'postgres_denormalize.E{number}', **kwargs)
|
|
378
|
+
return checks.Warning(message, id=f'postgres_denormalize.W{number}', **kwargs)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
__all__ = ['alternative_paths', 'crosses_lookup_path']
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The ``POSTGRES_DENORMALIZE`` setting: its defaults, its validation, and the functions through which the rest of the
|
|
3
|
+
package reads it.
|
|
4
|
+
|
|
5
|
+
Every key is optional::
|
|
6
|
+
|
|
7
|
+
POSTGRES_DENORMALIZE = {
|
|
8
|
+
# Models that this package skips: no triggers, no drift filter, no backfill, no project checks. This is for
|
|
9
|
+
# models that have denormalized fields on purpose without keeping them in sync, such as audit tables. A model is
|
|
10
|
+
# excluded when either key says so.
|
|
11
|
+
'EXCLUDE_MODELS': ['tenancy.TaskAudit'], # model labels, matched case-insensitively
|
|
12
|
+
'EXCLUDE_MODELS_CALLBACK': 'myproject.audit.is_audit_model', # a callable, or a dotted path to one
|
|
13
|
+
'REDUNDANT_COPY_CHECK': {
|
|
14
|
+
'LEVEL': 'warning', # or 'error', or 'off'
|
|
15
|
+
'APPS': None, # app labels to check; None checks every app outside site-packages
|
|
16
|
+
'ALLOWED': {'app_label.Model.field': 'why this foreign key may differ from the other route'},
|
|
17
|
+
'MAX_DEPTH': 4,
|
|
18
|
+
},
|
|
19
|
+
'ORDERING_CHECK_LEVEL': 'warning', # or 'error', or 'off'
|
|
20
|
+
# The level of the check that each database that stores copies runs transactions at SERIALIZABLE.
|
|
21
|
+
'ISOLATION_CHECK_LEVEL': 'warning', # or 'error', or 'off'
|
|
22
|
+
# Whether the WHEN condition of every cascade trigger lets ``defer_cascades()`` skip the trigger. Turning it off
|
|
23
|
+
# keeps the triggers compact. Changing it in either direction changes every cascade trigger, so makemigrations
|
|
24
|
+
# writes a migration that recreates all of them.
|
|
25
|
+
'DEFERRABLE_CASCADES': True,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
The values are read each time they are needed, not once at start-up, so ``override_settings`` in a test takes effect.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from django.conf import settings
|
|
32
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
33
|
+
from django.utils.module_loading import import_string
|
|
34
|
+
|
|
35
|
+
SETTING_NAME = 'POSTGRES_DENORMALIZE'
|
|
36
|
+
|
|
37
|
+
LEVELS = ('off', 'warning', 'error')
|
|
38
|
+
|
|
39
|
+
DEFAULT_REDUNDANT_COPY_CHECK = {
|
|
40
|
+
'LEVEL': 'warning',
|
|
41
|
+
'APPS': None,
|
|
42
|
+
'ALLOWED': {},
|
|
43
|
+
'MAX_DEPTH': 4,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
DEFAULTS = {
|
|
47
|
+
'EXCLUDE_MODELS': (),
|
|
48
|
+
'EXCLUDE_MODELS_CALLBACK': None,
|
|
49
|
+
'REDUNDANT_COPY_CHECK': DEFAULT_REDUNDANT_COPY_CHECK,
|
|
50
|
+
'ORDERING_CHECK_LEVEL': 'warning',
|
|
51
|
+
'ISOLATION_CHECK_LEVEL': 'warning',
|
|
52
|
+
'DEFERRABLE_CASCADES': True,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def user_settings():
|
|
57
|
+
"""
|
|
58
|
+
Return the project's ``POSTGRES_DENORMALIZE`` dict, empty when the project sets none.
|
|
59
|
+
"""
|
|
60
|
+
return getattr(settings, SETTING_NAME, None) or {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_setting(key):
|
|
64
|
+
"""
|
|
65
|
+
Return the value of one key, or its default when the project does not set it. ``REDUNDANT_COPY_CHECK`` is merged
|
|
66
|
+
key by key, so a project can set only ``LEVEL``.
|
|
67
|
+
"""
|
|
68
|
+
value = user_settings().get(key, DEFAULTS[key])
|
|
69
|
+
if key == 'REDUNDANT_COPY_CHECK':
|
|
70
|
+
return {**DEFAULT_REDUNDANT_COPY_CHECK, **value}
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def excluded_labels():
|
|
75
|
+
"""
|
|
76
|
+
Return the model labels in ``EXCLUDE_MODELS``, in lowercase.
|
|
77
|
+
"""
|
|
78
|
+
return {label.lower() for label in get_setting('EXCLUDE_MODELS')}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def exclusion_callback():
|
|
82
|
+
"""
|
|
83
|
+
Return the callable that ``EXCLUDE_MODELS_CALLBACK`` names, or None. A dotted path is imported.
|
|
84
|
+
"""
|
|
85
|
+
callback = get_setting('EXCLUDE_MODELS_CALLBACK')
|
|
86
|
+
if isinstance(callback, str):
|
|
87
|
+
return import_string(callback)
|
|
88
|
+
return callback
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_excluded(model):
|
|
92
|
+
"""
|
|
93
|
+
Say whether this package skips ``model``: either ``EXCLUDE_MODELS`` lists it, or ``EXCLUDE_MODELS_CALLBACK`` returns
|
|
94
|
+
a true value for it. When neither is configured, no model is excluded.
|
|
95
|
+
"""
|
|
96
|
+
if model._meta.label_lower in excluded_labels():
|
|
97
|
+
return True
|
|
98
|
+
callback = exclusion_callback()
|
|
99
|
+
return callback is not None and bool(callback(model))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate_settings():
|
|
103
|
+
"""
|
|
104
|
+
Raise ``ImproperlyConfigured`` for a ``POSTGRES_DENORMALIZE`` setting that this package cannot use.
|
|
105
|
+
|
|
106
|
+
``PostgresDenormalizeConfig.ready()`` calls this, so a misspelt key or level stops the project from starting and is
|
|
107
|
+
not ignored.
|
|
108
|
+
"""
|
|
109
|
+
configured = user_settings()
|
|
110
|
+
if not isinstance(configured, dict):
|
|
111
|
+
raise ImproperlyConfigured(f'{SETTING_NAME} must be a dict, not {type(configured).__name__}.')
|
|
112
|
+
|
|
113
|
+
unknown = sorted(set(configured) - set(DEFAULTS))
|
|
114
|
+
if unknown:
|
|
115
|
+
raise ImproperlyConfigured(f'{SETTING_NAME} has unknown keys: {", ".join(unknown)}.')
|
|
116
|
+
|
|
117
|
+
labels = get_setting('EXCLUDE_MODELS')
|
|
118
|
+
if isinstance(labels, str) or not isinstance(labels, (list, tuple, set, frozenset)):
|
|
119
|
+
raise ImproperlyConfigured(f'{SETTING_NAME}["EXCLUDE_MODELS"] must be a list of model labels.')
|
|
120
|
+
malformed = sorted(label for label in labels if not isinstance(label, str) or label.count('.') != 1)
|
|
121
|
+
if malformed:
|
|
122
|
+
raise ImproperlyConfigured(
|
|
123
|
+
f'{SETTING_NAME}["EXCLUDE_MODELS"] contains {", ".join(map(repr, malformed))}, but each entry must be a '
|
|
124
|
+
f"model label such as 'app_label.ModelName'."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
callback = get_setting('EXCLUDE_MODELS_CALLBACK')
|
|
128
|
+
if isinstance(callback, str):
|
|
129
|
+
try:
|
|
130
|
+
import_string(callback)
|
|
131
|
+
except ImportError as exception:
|
|
132
|
+
raise ImproperlyConfigured(
|
|
133
|
+
f'{SETTING_NAME}["EXCLUDE_MODELS_CALLBACK"] is {callback!r}, which cannot be imported: {exception}'
|
|
134
|
+
) from exception
|
|
135
|
+
elif callback is not None and not callable(callback):
|
|
136
|
+
raise ImproperlyConfigured(
|
|
137
|
+
f'{SETTING_NAME}["EXCLUDE_MODELS_CALLBACK"] must be a callable or a dotted path to one.'
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
redundant = configured.get('REDUNDANT_COPY_CHECK', {})
|
|
141
|
+
if not isinstance(redundant, dict):
|
|
142
|
+
raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"] must be a dict.')
|
|
143
|
+
unknown = sorted(set(redundant) - set(DEFAULT_REDUNDANT_COPY_CHECK))
|
|
144
|
+
if unknown:
|
|
145
|
+
raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"] has unknown keys: {", ".join(unknown)}.')
|
|
146
|
+
|
|
147
|
+
merged = get_setting('REDUNDANT_COPY_CHECK')
|
|
148
|
+
_validate_level('REDUNDANT_COPY_CHECK"]["LEVEL', merged['LEVEL'])
|
|
149
|
+
if merged['APPS'] is not None and not isinstance(merged['APPS'], (list, tuple, set)):
|
|
150
|
+
raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"]["APPS"] must be None or a list of labels.')
|
|
151
|
+
if not isinstance(merged['ALLOWED'], dict):
|
|
152
|
+
raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"]["ALLOWED"] must be a dict.')
|
|
153
|
+
if not isinstance(merged['MAX_DEPTH'], int) or merged['MAX_DEPTH'] < 2:
|
|
154
|
+
raise ImproperlyConfigured(
|
|
155
|
+
f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"]["MAX_DEPTH"] must be an integer of 2 or more.'
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
_validate_level('ORDERING_CHECK_LEVEL', get_setting('ORDERING_CHECK_LEVEL'))
|
|
159
|
+
_validate_level('ISOLATION_CHECK_LEVEL', get_setting('ISOLATION_CHECK_LEVEL'))
|
|
160
|
+
|
|
161
|
+
if not isinstance(get_setting('DEFERRABLE_CASCADES'), bool):
|
|
162
|
+
raise ImproperlyConfigured(f'{SETTING_NAME}["DEFERRABLE_CASCADES"] must be True or False.')
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _validate_level(key, value):
|
|
166
|
+
if value not in LEVELS:
|
|
167
|
+
raise ImproperlyConfigured(f'{SETTING_NAME}["{key}"] is {value!r}; use one of {", ".join(LEVELS)}.')
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ['DEFAULTS', 'LEVELS', 'get_setting', 'is_excluded', 'validate_settings']
|