django-postgres-denormalize 1.0.0__tar.gz → 1.0.1__tar.gz

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.
Files changed (81) hide show
  1. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/CHANGELOG.rst +5 -0
  2. {django_postgres_denormalize-1.0.0/src/django_postgres_denormalize.egg-info → django_postgres_denormalize-1.0.1}/PKG-INFO +1 -1
  3. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/guides/drift.rst +3 -4
  4. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/guides/exclusions.rst +8 -3
  5. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/integrations/djanquiltdb.rst +3 -3
  6. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/reference/checks.rst +6 -0
  7. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/reference/settings.rst +32 -1
  8. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1/src/django_postgres_denormalize.egg-info}/PKG-INFO +1 -1
  9. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/__init__.py +1 -1
  10. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/checks.py +3 -1
  11. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/conf.py +32 -4
  12. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/fields.py +62 -32
  13. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/isolated/test_fields.py +171 -1
  14. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/test_conf.py +43 -0
  15. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/LICENSE +0 -0
  16. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/MANIFEST.in +0 -0
  17. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/README.rst +0 -0
  18. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon-128.png +0 -0
  19. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon-16.png +0 -0
  20. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon-256.png +0 -0
  21. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon-32.png +0 -0
  22. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon-48.png +0 -0
  23. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon-512.png +0 -0
  24. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon-64.png +0 -0
  25. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon-small.svg +0 -0
  26. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/icon.svg +0 -0
  27. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/social-preview.png +0 -0
  28. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/assets/social-preview.svg +0 -0
  29. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/Makefile +0 -0
  30. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/_static/custom.css +0 -0
  31. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/_templates/layout.html +0 -0
  32. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/conf.py +0 -0
  33. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/getting_started/alternatives.rst +0 -0
  34. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/getting_started/installation.rst +0 -0
  35. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/getting_started/overview.rst +0 -0
  36. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/getting_started/when_to_use.rst +0 -0
  37. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/guides/batch_updates.rst +0 -0
  38. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/guides/fields.rst +0 -0
  39. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/guides/indexing.rst +0 -0
  40. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/guides/migrations.rst +0 -0
  41. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/guides/triggers.rst +0 -0
  42. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/index.rst +0 -0
  43. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/reference/api.rst +0 -0
  44. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/docs/reference/changelog.rst +0 -0
  45. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/pyproject.toml +0 -0
  46. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/setup.cfg +0 -0
  47. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/django_postgres_denormalize.egg-info/SOURCES.txt +0 -0
  48. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/django_postgres_denormalize.egg-info/dependency_links.txt +0 -0
  49. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/django_postgres_denormalize.egg-info/requires.txt +0 -0
  50. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/django_postgres_denormalize.egg-info/top_level.txt +0 -0
  51. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/apps.py +0 -0
  52. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/batch.py +0 -0
  53. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/chains.py +0 -0
  54. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/operations.py +0 -0
  55. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/py.typed +0 -0
  56. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/src/postgres_denormalize/triggers.py +0 -0
  57. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/catalog/__init__.py +0 -0
  58. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/catalog/apps.py +0 -0
  59. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/catalog/exclusions.py +0 -0
  60. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/catalog/migrations/0001_initial.py +0 -0
  61. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/catalog/migrations/__init__.py +0 -0
  62. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/catalog/models.py +0 -0
  63. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/config/__init__.py +0 -0
  64. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/config/settings.py +0 -0
  65. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/isolated/__init__.py +0 -0
  66. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/isolated/apps.py +0 -0
  67. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/isolated/test_checks.py +0 -0
  68. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/isolated/test_operations.py +0 -0
  69. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/isolated/test_triggers.py +0 -0
  70. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/manage.py +0 -0
  71. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/tenancy/__init__.py +0 -0
  72. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/tenancy/apps.py +0 -0
  73. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/tenancy/migrations/0001_initial.py +0 -0
  74. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/tenancy/migrations/__init__.py +0 -0
  75. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/tenancy/models.py +0 -0
  76. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/test_batch.py +0 -0
  77. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/test_chains.py +0 -0
  78. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/test_checks.py +0 -0
  79. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/test_fields.py +0 -0
  80. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/test_operations.py +0 -0
  81. {django_postgres_denormalize-1.0.0 → django_postgres_denormalize-1.0.1}/tests/test_triggers.py +0 -0
@@ -1,3 +1,8 @@
1
+ v1.0.1 (2026-10-19)
2
+ -------------------
3
+ Altered:
4
+ * Fixed system checks incorrectly firing on excluded models.
5
+
1
6
  v1.0.0 (2026-10-18)
2
7
  -------------------
3
8
  Added:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-postgres-denormalize
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Trigger-maintained copies of ancestor columns for Django on PostgreSQL.
5
5
  Author-email: DjanQuiltDB Project <djanquiltdb@portal42.net>
6
6
  License-Expression: BSD-3-Clause
@@ -76,10 +76,9 @@ Before you run it on a large table, note the following:
76
76
  leaf, gets a history row.
77
77
  * **Unique constraints.** When a copy is part of a unique constraint, the correction can make two rows collide, and the
78
78
  PostgreSQL error names only the first collision (see :ref:`collisions <migration_collisions>`).
79
- * **Excluded models.** The call skips a model that ``EXCLUDE_MODELS`` lists, such as an audit table, because its rows
80
- record past values. ``also_fill`` writes the copies of such a model, but it overwrites every row with the current
81
- value. It is meant for filling a new column, not for repairs (see :ref:`copies on excluded models
82
- <excluded_model_copies>`).
79
+ * **Excluded models.** The call skips an excluded model, such as an audit table, because its rows record past values.
80
+ ``also_fill`` writes the copies of such a model, but it overwrites every row with the current value. It is meant for
81
+ filling a new column, not for repairs (see :ref:`copies on excluded models <excluded_model_copies>`).
83
82
 
84
83
 
85
84
  In a migration
@@ -12,9 +12,14 @@ This package skips an excluded model entirely:
12
12
  * it gets no triggers, so nothing ever rewrites its copies;
13
13
  * :func:`~postgres_denormalize.fields.get_denormalized_fields` returns nothing for it, so the drift filter and the
14
14
  backfill skip it (unless ``also_fill`` names it explicitly, see :doc:`/guides/migrations`);
15
- * the project-wide system checks do not look at it.
16
-
17
- Its denormalized columns still get their type, so the table is created and migrated like any other.
15
+ * the project-wide system checks do not look at it;
16
+ * its fields skip the field checks that compare the declaration with the lookup path, such as ``W009`` for an
17
+ ``on_delete`` rule that differs from the path. ``EXCLUDE_MODELS_SILENCED_CHECKS`` chooses which ones (see
18
+ :doc:`/reference/settings`).
19
+
20
+ Its denormalized columns still get their type, so the table is created and migrated like any other. The type, and the
21
+ target of a foreign key copy, come from the lookup path, so the path must resolve on the excluded model too. The field
22
+ checks that report a path that does not resolve therefore still run by default.
18
23
 
19
24
  Without either key, no model is excluded. An ``EXCLUDE_MODELS`` entry that names no installed model is reported
20
25
  (``E401``, see :doc:`/reference/checks`).
@@ -7,9 +7,9 @@ Django project across PostgreSQL schemas and nodes. Both libraries work on their
7
7
  does not need DjanQuiltDB, or any familiarity with it.
8
8
 
9
9
  In a sharded project, the copies have to be checked and corrected in each shard, and a lookup path has to stay inside
10
- the shard. DjanQuiltDB's ``postgres-denormalize`` extra installs a plugin with helpers that find and correct out-of-date
11
- copies in one shard or in every shard. DjanQuiltDB's own system checks report a lookup path whose cascade trigger would
12
- write across shards::
10
+ the shard. DjanQuiltDB's own system checks report a lookup path whose cascade trigger or fill trigger would write across
11
+ shards. DjanQuiltDB's ``postgres-denormalize`` extra installs a plugin with helpers that find and correct out-of-date
12
+ copies in one shard or in every shard::
13
13
 
14
14
  pip install djanquiltdb[postgres-denormalize]
15
15
 
@@ -36,6 +36,12 @@ Each denormalized field checks its own declaration:
36
36
  See :doc:`/guides/fields`.
37
37
  ================================ =======================================================================================
38
38
 
39
+ A field on an excluded model (see :doc:`/guides/exclusions`) skips the checks that ``EXCLUDE_MODELS_SILENCED_CHECKS``
40
+ lists. By default, these are ``E002``, ``E003``, ``E004`` and ``W009``, which compare the declaration with the lookup
41
+ path. No trigger writes the column of an excluded model, so they say nothing useful there. The field still infers its
42
+ target, column type and ``on_delete`` rule from the path, so the other checks still run. See
43
+ :doc:`/reference/settings`.
44
+
39
45
 
40
46
  Project checks
41
47
  --------------
@@ -22,7 +22,8 @@ EXCLUDE_MODELS
22
22
  --------------
23
23
 
24
24
  A list of model labels, ``'app_label.ModelName'``, matched case-insensitively. This package skips the models that it
25
- lists: they get no triggers, no drift filter, no backfill and no project checks. By default it lists nothing. An entry
25
+ lists: they get no triggers, no drift filter, no backfill and no project checks, and their fields skip the checks that
26
+ ``EXCLUDE_MODELS_SILENCED_CHECKS`` lists. By default it lists nothing. An entry
26
27
  that is not a string with exactly one dot raises ``ImproperlyConfigured``, and an entry that names no installed model is
27
28
  reported by ``postgres_denormalize.E401``. See :doc:`/guides/exclusions`.
28
29
 
@@ -36,6 +37,36 @@ Use it when the models to exclude follow a rule, and not a list. A model is excl
36
37
  callable, raise ``ImproperlyConfigured``. See :doc:`/guides/exclusions` for what the callback is called with.
37
38
 
38
39
 
40
+ EXCLUDE_MODELS_SILENCED_CHECKS
41
+ ------------------------------
42
+
43
+ A list of the field checks (see :doc:`/reference/checks`) that a field on an excluded model skips, as check ids such as
44
+ ``'postgres_denormalize.W009'``, the way ``SILENCED_SYSTEM_CHECKS`` names them. It does not change the checks of a
45
+ field on any other model. The default skips the checks that compare the declaration with the lookup path:
46
+
47
+ .. code-block:: python
48
+
49
+ # settings.py
50
+ POSTGRES_DENORMALIZE = {
51
+ 'EXCLUDE_MODELS_SILENCED_CHECKS': [
52
+ 'postgres_denormalize.E002',
53
+ 'postgres_denormalize.E003',
54
+ 'postgres_denormalize.E004',
55
+ 'postgres_denormalize.W009',
56
+ ],
57
+ }
58
+
59
+ No trigger writes the column of an excluded model, so these checks say nothing useful there. An audit package, for
60
+ example, declares ``on_delete=DO_NOTHING`` on every foreign key that it copies, and W009 would report each copy. The
61
+ field still infers its target, its column type and its ``on_delete`` rule from the lookup path, so by default it still
62
+ reports a path that cannot give them: ``E001`` and ``E005`` to ``E008``. An empty list runs every field check on
63
+ excluded models, and a list of all nine skips them all.
64
+
65
+ Only the ids of the field checks, ``E001`` to ``E008`` and ``W009``, can be listed, because the project checks skip
66
+ excluded models altogether. Any other entry, and a value that is not a list, raise ``ImproperlyConfigured``. Changing
67
+ the list changes no trigger, so ``makemigrations`` writes no migration for it.
68
+
69
+
39
70
  REDUNDANT_COPY_CHECK
40
71
  --------------------
41
72
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-postgres-denormalize
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Trigger-maintained copies of ancestor columns for Django on PostgreSQL.
5
5
  Author-email: DjanQuiltDB Project <djanquiltdb@portal42.net>
6
6
  License-Expression: BSD-3-Clause
@@ -33,4 +33,4 @@ The package has these modules:
33
33
  and ``EXCLUDE_MODELS_CALLBACK``, which name the models that this package skips.
34
34
  """
35
35
 
36
- __version__ = '1.0.0'
36
+ __version__ = '1.0.1'
@@ -2,7 +2,9 @@
2
2
  System checks that look at the project as a whole rather than at one field.
3
3
 
4
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:
5
+ part of each field's own ``check()``. A field on an excluded model skips the field checks that
6
+ ``EXCLUDE_MODELS_SILENCED_CHECKS`` lists. The checks here skip excluded models altogether. They are registered under the
7
+ ``postgres_denormalize`` tag:
6
8
 
7
9
  - ``E301``: pgtrigger neither writes triggers into migrations nor installs them after ``migrate``, so the triggers that
8
10
  this package derives would never reach the database.
@@ -5,11 +5,15 @@ package reads it.
5
5
  Every key is optional::
6
6
 
7
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.
8
+ # Models that this package skips: no triggers, no drift filter, no backfill, no project checks, and only some
9
+ # field checks. This is for models that have denormalized fields on purpose without keeping them in sync, such
10
+ # as audit tables. A model is excluded when either key says so.
11
11
  'EXCLUDE_MODELS': ['tenancy.TaskAudit'], # model labels, matched case-insensitively
12
12
  'EXCLUDE_MODELS_CALLBACK': 'myproject.audit.is_audit_model', # a callable, or a dotted path to one
13
+ # The field checks that the fields of excluded models skip, as check ids. By default, these are E002, E003,
14
+ # E004 and W009, which compare the declaration with the lookup path. That matters only for a field that a
15
+ # trigger writes. Changing this list changes no trigger, so makemigrations writes no migration for it.
16
+ 'EXCLUDE_MODELS_SILENCED_CHECKS': ['postgres_denormalize.W009'],
13
17
  'REDUNDANT_COPY_CHECK': {
14
18
  'LEVEL': 'warning', # or 'error', or 'off'
15
19
  'APPS': None, # app labels to check; None checks every app outside site-packages
@@ -36,6 +40,12 @@ SETTING_NAME = 'POSTGRES_DENORMALIZE'
36
40
 
37
41
  LEVELS = ('off', 'warning', 'error')
38
42
 
43
+ # The ids of the checks that each denormalized field runs on its own declaration. EXCLUDE_MODELS_SILENCED_CHECKS can
44
+ # list only these, because the project checks skip excluded models altogether.
45
+ FIELD_CHECKS = tuple(
46
+ f'postgres_denormalize.{code}' for code in ('E001', 'E002', 'E003', 'E004', 'E005', 'E006', 'E007', 'E008', 'W009')
47
+ )
48
+
39
49
  DEFAULT_REDUNDANT_COPY_CHECK = {
40
50
  'LEVEL': 'warning',
41
51
  'APPS': None,
@@ -46,6 +56,14 @@ DEFAULT_REDUNDANT_COPY_CHECK = {
46
56
  DEFAULTS = {
47
57
  'EXCLUDE_MODELS': (),
48
58
  'EXCLUDE_MODELS_CALLBACK': None,
59
+ # The checks that compare the declaration with the lookup path. The others find a path that gives the field no
60
+ # target, column type or on_delete rule, which a field on an excluded model needs too.
61
+ 'EXCLUDE_MODELS_SILENCED_CHECKS': (
62
+ 'postgres_denormalize.E002',
63
+ 'postgres_denormalize.E003',
64
+ 'postgres_denormalize.E004',
65
+ 'postgres_denormalize.W009',
66
+ ),
49
67
  'REDUNDANT_COPY_CHECK': DEFAULT_REDUNDANT_COPY_CHECK,
50
68
  'ORDERING_CHECK_LEVEL': 'warning',
51
69
  'ISOLATION_CHECK_LEVEL': 'warning',
@@ -137,6 +155,16 @@ def validate_settings():
137
155
  f'{SETTING_NAME}["EXCLUDE_MODELS_CALLBACK"] must be a callable or a dotted path to one.'
138
156
  )
139
157
 
158
+ silenced = get_setting('EXCLUDE_MODELS_SILENCED_CHECKS')
159
+ if isinstance(silenced, str) or not isinstance(silenced, (list, tuple, set, frozenset)):
160
+ raise ImproperlyConfigured(f'{SETTING_NAME}["EXCLUDE_MODELS_SILENCED_CHECKS"] must be a list of check ids.')
161
+ unknown = [check_id for check_id in silenced if check_id not in FIELD_CHECKS]
162
+ if unknown:
163
+ raise ImproperlyConfigured(
164
+ f'{SETTING_NAME}["EXCLUDE_MODELS_SILENCED_CHECKS"] contains {", ".join(map(repr, unknown))}, but it can '
165
+ f'list only the field checks: {", ".join(FIELD_CHECKS)}.'
166
+ )
167
+
140
168
  redundant = configured.get('REDUNDANT_COPY_CHECK', {})
141
169
  if not isinstance(redundant, dict):
142
170
  raise ImproperlyConfigured(f'{SETTING_NAME}["REDUNDANT_COPY_CHECK"] must be a dict.')
@@ -167,4 +195,4 @@ def _validate_level(key, value):
167
195
  raise ImproperlyConfigured(f'{SETTING_NAME}["{key}"] is {value!r}; use one of {", ".join(LEVELS)}.')
168
196
 
169
197
 
170
- __all__ = ['DEFAULTS', 'LEVELS', 'get_setting', 'is_excluded', 'validate_settings']
198
+ __all__ = ['DEFAULTS', 'FIELD_CHECKS', 'LEVELS', 'get_setting', 'is_excluded', 'validate_settings']
@@ -216,8 +216,26 @@ class DenormalizedMixin:
216
216
  def check(self, **kwargs):
217
217
  return [*super().check(**kwargs), *self._check_denormalize_from()]
218
218
 
219
+ def _silenced_checks(self):
220
+ """
221
+ Return the ids of the checks that this field skips.
222
+
223
+ A field on an excluded model skips the checks that ``EXCLUDE_MODELS_SILENCED_CHECKS`` lists. No trigger writes
224
+ its column, so by default it skips the checks that compare the declaration with the lookup path. Its target,
225
+ column type and ``on_delete`` rule still come from the path, so the checks that find a path that cannot give
226
+ them still run.
227
+ """
228
+ if conf.is_excluded(self.model):
229
+ return frozenset(conf.get_setting('EXCLUDE_MODELS_SILENCED_CHECKS'))
230
+ return frozenset()
231
+
219
232
  def _check_denormalize_from(self):
233
+ # Each check reports the first problem that it finds. A silenced check is skipped, rather than its message
234
+ # dropped, so that the checks after it still run.
235
+ silenced = self._silenced_checks()
220
236
  if '__' not in self.denormalize_from:
237
+ if 'postgres_denormalize.E001' in silenced:
238
+ return []
221
239
  return self._error(
222
240
  'postgres_denormalize.E001',
223
241
  f'`denormalize_from` is `{self.denormalize_from}`, which is a column on the same row, not a column '
@@ -227,25 +245,30 @@ class DenormalizedMixin:
227
245
  try:
228
246
  chain = self.resolve_chain()
229
247
  except PathRefused as refusal:
248
+ if refusal.code in silenced:
249
+ return []
230
250
  return self._error(refusal.code, f'{refusal}.', refusal.hint)
231
251
  except (FieldDoesNotExist, ValueError) as exception:
252
+ if 'postgres_denormalize.E001' in silenced:
253
+ return []
232
254
  return self._error(
233
255
  'postgres_denormalize.E001',
234
256
  f'`denormalize_from` is `{self.denormalize_from}`, which does not resolve: {exception}.',
235
257
  )
236
258
 
237
- if chain[0].null:
259
+ if chain[0].null and 'postgres_denormalize.E002' not in silenced:
238
260
  return self._error(
239
261
  'postgres_denormalize.E002',
240
262
  f'`denormalize_from` is `{self.denormalize_from}`, which starts at the nullable foreign key '
241
263
  f'`{chain[0].name}`, so the trigger computes NULL for every row where `{chain[0].name}` is empty.',
242
264
  )
243
265
 
244
- return self._check_end_of_chain(chain)
266
+ return self._check_end_of_chain(chain, silenced)
245
267
 
246
- def _check_end_of_chain(self, chain):
268
+ def _check_end_of_chain(self, chain, silenced):
247
269
  """
248
- Check the field at the end of the lookup path. Each concrete class allows different fields there.
270
+ Check the field at the end of the lookup path, skipping the checks in ``silenced``. Each concrete class allows
271
+ different fields there.
249
272
  """
250
273
  raise NotImplementedError
251
274
 
@@ -428,39 +451,42 @@ class DenormalizedForeignKey(DenormalizedMixin, models.ForeignKey):
428
451
  return self._check_denormalize_from()
429
452
  return super().check(**kwargs)
430
453
 
431
- def _check_end_of_chain(self, chain):
454
+ def _check_end_of_chain(self, chain, silenced):
432
455
  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
- )
456
+ if 'postgres_denormalize.E003' not in silenced:
457
+ if not self.target_is_known:
458
+ return self._error(
459
+ 'postgres_denormalize.E003',
460
+ f'The target of the field is not known, but `{self.denormalize_from}` ends at {end}.',
461
+ )
462
+ if chain[-1].related_model is not self.related_model:
463
+ return self._error(
464
+ 'postgres_denormalize.E003',
465
+ f'The field points at {self.related_model._meta.label}, but `{self.denormalize_from}` ends at '
466
+ f'{end}.',
467
+ )
468
+ if chain[-1].target_field != self.target_field:
469
+ return self._error(
470
+ 'postgres_denormalize.E003',
471
+ f'The field points at {end}.{self.target_field.name}, but `{self.denormalize_from}` ends at '
472
+ f'{end}.{chain[-1].target_field.name}. Declare the field with the same to_field as the last hop, '
473
+ f'or without one.',
474
+ )
450
475
 
451
476
  nullable = [hop.name for hop in chain[1:] if hop.null]
452
- if nullable and not self.null:
477
+ if nullable and not self.null and 'postgres_denormalize.E004' not in silenced:
453
478
  return self._error(
454
479
  'postgres_denormalize.E004',
455
480
  f'`{self.denormalize_from}` crosses the nullable foreign key `{nullable[0]}`, so the trigger writes '
456
481
  'NULL for every row where it is empty. Declare the field with null=True, or without null.',
457
482
  )
458
483
 
459
- return self._check_on_delete_rule(chain)
484
+ return self._check_on_delete_rule(chain, silenced)
460
485
 
461
- def _check_on_delete_rule(self, chain):
486
+ def _check_on_delete_rule(self, chain, silenced):
462
487
  """
463
- Check the ``on_delete`` rule against the rule that the lookup path implies (see ``infer_on_delete``).
488
+ Check the ``on_delete`` rule against the rule that the lookup path implies (see ``infer_on_delete``), skipping
489
+ the checks in ``silenced``.
464
490
  """
465
491
  inferred, deciding_hop = infer_on_delete(chain)
466
492
  hop_label = f'{deciding_hop.model._meta.label}.{deciding_hop.name}'
@@ -468,6 +494,8 @@ class DenormalizedForeignKey(DenormalizedMixin, models.ForeignKey):
468
494
  if self.on_delete_is_known:
469
495
  # A declared rule cannot be compared with a custom rule along the path.
470
496
  return []
497
+ if 'postgres_denormalize.E008' in silenced:
498
+ return []
471
499
  return self._error(
472
500
  'postgres_denormalize.E008',
473
501
  f'`{self.denormalize_from}` crosses {hop_label}, which has the custom on_delete rule '
@@ -475,7 +503,7 @@ class DenormalizedForeignKey(DenormalizedMixin, models.ForeignKey):
475
503
  hint='Declare on_delete on the field.',
476
504
  )
477
505
  declared = self.remote_field.on_delete
478
- if declared is not inferred:
506
+ if declared is not inferred and 'postgres_denormalize.W009' not in silenced:
479
507
  return self._warning(
480
508
  'postgres_denormalize.W009',
481
509
  f'The field declares on_delete={describe_rule(declared)}, but `{self.denormalize_from}` implies '
@@ -575,7 +603,9 @@ class DenormalizedField(DenormalizedMixin, models.Field):
575
603
  kwargs['base_field'] = self._base_field
576
604
  return name, path, args, kwargs
577
605
 
578
- def _check_end_of_chain(self, chain):
606
+ def _check_end_of_chain(self, chain, silenced):
607
+ if 'postgres_denormalize.E005' in silenced:
608
+ return []
579
609
  source = chain[-1]
580
610
  if source.is_relation:
581
611
  return self._error(
@@ -681,10 +711,10 @@ def get_denormalized_fields(model):
681
711
  proxy model, therefore get no triggers, no drift filter and no backfill for the fields of the parent, because the
682
712
  triggers of the parent already keep those columns correct.
683
713
 
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.
714
+ For an excluded model (see ``EXCLUDE_MODELS`` and ``EXCLUDE_MODELS_CALLBACK``) this returns no fields, so the model
715
+ gets no triggers, no drift filter and no backfill. The usual reason to exclude a model is an audit table that copies
716
+ the fields of a tracked model, including the denormalized ones. Its rows record the value that a copy had at the
717
+ time, so no trigger may rewrite them.
688
718
  """
689
719
  if conf.is_excluded(model):
690
720
  return []
@@ -8,9 +8,10 @@ from django.db.migrations.autodetector import MigrationAutodetector
8
8
  from django.db.migrations.operations import AlterField
9
9
  from django.db.migrations.state import ProjectState
10
10
  from django.db.models.functions import Length
11
- from django.test import SimpleTestCase
11
+ from django.test import SimpleTestCase, override_settings
12
12
  from django.test.utils import isolate_apps
13
13
 
14
+ from postgres_denormalize import conf
14
15
  from postgres_denormalize.fields import (
15
16
  INFERRED_TARGET,
16
17
  DenormalizedField,
@@ -868,3 +869,172 @@ class OnDeleteInferenceTestCase(SimpleTestCase):
868
869
  chain = field.resolve_chain()
869
870
 
870
871
  self.assertEqual(infer_on_delete(chain), (models.PROTECT, chain[0]))
872
+
873
+
874
+ AUDIT_CALLBACK = 'catalog.exclusions.is_audit_model'
875
+
876
+
877
+ @isolate_apps('isolated')
878
+ @override_settings(POSTGRES_DENORMALIZE={'EXCLUDE_MODELS_CALLBACK': AUDIT_CALLBACK})
879
+ class ExcludedModelChecksTestCase(SimpleTestCase):
880
+ """
881
+ The callback excludes every model whose name ends in ``audit``. Each test declares the same field on a leaf and on
882
+ an audit model of that leaf, and compares the checks of the two copies.
883
+ """
884
+
885
+ def declare_copies(self, suffix, make_field, owner_rule=models.PROTECT, owner_null=False, first_hop_null=False):
886
+ """
887
+ Declare Owner, Parent, Leaf and LeafAudit, each with ``suffix`` in its name, and return the field ``copy`` of
888
+ Leaf and of LeafAudit.
889
+
890
+ Parent.owner has the rule ``owner_rule``, and is nullable when ``owner_null`` is true. Leaf and LeafAudit each
891
+ have the foreign key ``parent``, which is nullable when ``first_hop_null`` is true, and the field that
892
+ ``make_field`` returns.
893
+ """
894
+ owner = type(f'Owner{suffix}', (models.Model,), {'__module__': __name__})
895
+ parent = type(
896
+ f'Parent{suffix}',
897
+ (models.Model,),
898
+ {'__module__': __name__, 'owner': models.ForeignKey(owner, on_delete=owner_rule, null=owner_null)},
899
+ )
900
+ copies = []
901
+ for name in (f'Leaf{suffix}', f'Leaf{suffix}Audit'):
902
+ leaf = type(
903
+ name,
904
+ (models.Model,),
905
+ {
906
+ '__module__': __name__,
907
+ 'parent': models.ForeignKey(parent, on_delete=models.CASCADE, null=first_hop_null),
908
+ 'copy': make_field(),
909
+ },
910
+ )
911
+ copies.append(leaf._meta.get_field('copy'))
912
+ return copies
913
+
914
+ def check_ids(self, field):
915
+ return [message.id for message in field.check()]
916
+
917
+ def test_a_declared_rule_that_differs_from_the_path_is_muted_on_an_excluded_model(self):
918
+ """
919
+ Case: Parent.owner is PROTECT. Leaf and LeafAudit copy parent__owner with on_delete=DO_NOTHING, which an audit
920
+ package declares on every foreign key that it copies.
921
+ Expected: The copy on Leaf reports warning W009. The copy on LeafAudit reports nothing.
922
+ """
923
+ leaf, audit = self.declare_copies(
924
+ '', lambda: DenormalizedForeignKey('parent__owner', on_delete=models.DO_NOTHING, related_name='+')
925
+ )
926
+
927
+ self.assertEqual(self.check_ids(leaf), ['postgres_denormalize.W009'])
928
+ self.assertEqual(self.check_ids(audit), [])
929
+
930
+ def test_the_checks_that_compare_the_declaration_with_the_path_are_muted_on_an_excluded_model(self):
931
+ """
932
+ Case: Leaf and LeafAudit copy parent__owner three times: with a nullable first hop, with to=Parent, and with
933
+ null=False across the nullable hop Parent.owner.
934
+ Expected: The copy on Leaf reports E002, E003 and E004 in turn. The copy on LeafAudit reports nothing.
935
+ """
936
+ cases = [
937
+ (
938
+ 'postgres_denormalize.E002',
939
+ {'first_hop_null': True},
940
+ lambda: DenormalizedForeignKey('parent__owner', on_delete=models.PROTECT, null=True, related_name='+'),
941
+ ),
942
+ (
943
+ 'postgres_denormalize.E003',
944
+ {},
945
+ lambda: DenormalizedForeignKey(
946
+ 'parent__owner', on_delete=models.PROTECT, to='isolated.Parent1', related_name='+'
947
+ ),
948
+ ),
949
+ (
950
+ 'postgres_denormalize.E004',
951
+ {'owner_null': True},
952
+ lambda: DenormalizedForeignKey('parent__owner', on_delete=models.PROTECT, null=False, related_name='+'),
953
+ ),
954
+ ]
955
+ for index, (check_id, options, make_field) in enumerate(cases):
956
+ with self.subTest(check_id=check_id):
957
+ leaf, audit = self.declare_copies(index, make_field, **options)
958
+
959
+ self.assertEqual(self.check_ids(leaf), [check_id])
960
+ self.assertEqual(self.check_ids(audit), [])
961
+
962
+ def test_the_checks_that_find_no_target_type_or_rule_are_still_reported_on_an_excluded_model(self):
963
+ """
964
+ Case: Leaf and LeafAudit copy parent__missing as a DenormalizedForeignKey and as a DenormalizedField. Then they
965
+ copy parent__owner without on_delete, while Parent.owner has a custom on_delete rule.
966
+ Expected: Both copies report E001, E001 and E008 in turn.
967
+ """
968
+ cases = [
969
+ ('postgres_denormalize.E001', {}, lambda: DenormalizedForeignKey('parent__missing', related_name='+')),
970
+ ('postgres_denormalize.E001', {}, lambda: DenormalizedField('parent__missing')),
971
+ (
972
+ 'postgres_denormalize.E008',
973
+ {'owner_rule': custom_rule},
974
+ lambda: DenormalizedForeignKey('parent__owner', related_name='+'),
975
+ ),
976
+ ]
977
+ for index, (check_id, options, make_field) in enumerate(cases):
978
+ with self.subTest(index=index, check_id=check_id):
979
+ for field in self.declare_copies(index, make_field, **options):
980
+ self.assertEqual(self.check_ids(field), [check_id])
981
+
982
+ def test_a_muted_check_does_not_hide_the_checks_after_it(self):
983
+ """
984
+ Case: Leaf and LeafAudit copy parent__owner as a DenormalizedField, through a nullable first hop. The path
985
+ ends at a foreign key, which a DenormalizedField cannot copy.
986
+ Expected: The copy on Leaf reports E002, the first problem. The copy on LeafAudit skips the muted E002 and
987
+ reports E005.
988
+ """
989
+ leaf, audit = self.declare_copies('', lambda: DenormalizedField('parent__owner'), first_hop_null=True)
990
+
991
+ self.assertEqual(self.check_ids(leaf), ['postgres_denormalize.E002'])
992
+ self.assertEqual(self.check_ids(audit), ['postgres_denormalize.E005'])
993
+
994
+ def test_the_setting_chooses_the_checks_that_are_muted(self):
995
+ """
996
+ Case: EXCLUDE_MODELS_SILENCED_CHECKS is empty, and then lists every field check. Leaf and LeafAudit copy
997
+ parent__owner with on_delete=DO_NOTHING while Parent.owner is PROTECT, and copy parent__missing.
998
+ Expected: With the empty list, the copy on LeafAudit reports W009 and E001, as the copy on Leaf does. With every
999
+ field check listed, the copies on LeafAudit report nothing, and the copies on Leaf still report W009
1000
+ and E001.
1001
+ """
1002
+ rule_copies = self.declare_copies(
1003
+ 'Rule', lambda: DenormalizedForeignKey('parent__owner', on_delete=models.DO_NOTHING, related_name='+')
1004
+ )
1005
+ path_copies = self.declare_copies('Path', lambda: DenormalizedForeignKey('parent__missing', related_name='+'))
1006
+
1007
+ def setting(silenced):
1008
+ return override_settings(
1009
+ POSTGRES_DENORMALIZE={
1010
+ 'EXCLUDE_MODELS_CALLBACK': AUDIT_CALLBACK,
1011
+ 'EXCLUDE_MODELS_SILENCED_CHECKS': silenced,
1012
+ }
1013
+ )
1014
+
1015
+ with setting([]):
1016
+ for field in rule_copies:
1017
+ self.assertEqual(self.check_ids(field), ['postgres_denormalize.W009'])
1018
+ for field in path_copies:
1019
+ self.assertEqual(self.check_ids(field), ['postgres_denormalize.E001'])
1020
+
1021
+ with setting(list(conf.FIELD_CHECKS)):
1022
+ self.assertEqual(self.check_ids(rule_copies[0]), ['postgres_denormalize.W009'])
1023
+ self.assertEqual(self.check_ids(path_copies[0]), ['postgres_denormalize.E001'])
1024
+ self.assertEqual(self.check_ids(rule_copies[1]), [])
1025
+ self.assertEqual(self.check_ids(path_copies[1]), [])
1026
+
1027
+ def test_the_checks_of_django_still_run_on_an_excluded_model(self):
1028
+ """
1029
+ Case: Leaf and LeafAudit copy parent__owner with on_delete=SET_NULL and null=False while Parent.owner is
1030
+ PROTECT. Django's own checks refuse SET_NULL on a column that cannot be NULL.
1031
+ Expected: The copy on Leaf reports Django's fields.E320 and W009. The copy on LeafAudit reports only
1032
+ fields.E320.
1033
+ """
1034
+ leaf, audit = self.declare_copies(
1035
+ '',
1036
+ lambda: DenormalizedForeignKey('parent__owner', on_delete=models.SET_NULL, null=False, related_name='+'),
1037
+ )
1038
+
1039
+ self.assertEqual(self.check_ids(leaf), ['fields.E320', 'postgres_denormalize.W009'])
1040
+ self.assertEqual(self.check_ids(audit), ['fields.E320'])
@@ -15,6 +15,15 @@ class GetSettingTestCase(SimpleTestCase):
15
15
  """
16
16
  self.assertEqual(conf.get_setting('EXCLUDE_MODELS'), ())
17
17
  self.assertIsNone(conf.get_setting('EXCLUDE_MODELS_CALLBACK'))
18
+ self.assertEqual(
19
+ conf.get_setting('EXCLUDE_MODELS_SILENCED_CHECKS'),
20
+ (
21
+ 'postgres_denormalize.E002',
22
+ 'postgres_denormalize.E003',
23
+ 'postgres_denormalize.E004',
24
+ 'postgres_denormalize.W009',
25
+ ),
26
+ )
18
27
  self.assertEqual(conf.get_setting('ORDERING_CHECK_LEVEL'), 'warning')
19
28
  self.assertEqual(conf.get_setting('ISOLATION_CHECK_LEVEL'), 'warning')
20
29
  self.assertEqual(conf.get_setting('REDUNDANT_COPY_CHECK'), conf.DEFAULT_REDUNDANT_COPY_CHECK)
@@ -157,6 +166,40 @@ class ValidateSettingsTestCase(SimpleTestCase):
157
166
  """
158
167
  conf.validate_settings()
159
168
 
169
+ def test_refuses_silenced_checks_that_are_not_a_list_of_field_checks(self):
170
+ """
171
+ Case: EXCLUDE_MODELS_SILENCED_CHECKS is a string, and then a list with the project check W101 and the
172
+ misspelt 'postgres_denormalize.W9'.
173
+ Expected: For the string, an ImproperlyConfigured error that asks for a list of check ids. For the list, an
174
+ ImproperlyConfigured error that names both entries and the field checks that can be listed.
175
+ """
176
+ self.assertRefused(
177
+ {'EXCLUDE_MODELS_SILENCED_CHECKS': 'postgres_denormalize.W009'}, 'must be a list of check ids'
178
+ )
179
+ with override_settings(
180
+ POSTGRES_DENORMALIZE={
181
+ 'EXCLUDE_MODELS_SILENCED_CHECKS': ['postgres_denormalize.W101', 'postgres_denormalize.W9']
182
+ }
183
+ ):
184
+ with self.assertRaises(ImproperlyConfigured) as raised:
185
+ conf.validate_settings()
186
+ message = str(raised.exception)
187
+ self.assertIn("'postgres_denormalize.W101', 'postgres_denormalize.W9'", message)
188
+ self.assertIn('postgres_denormalize.E001', message)
189
+ self.assertIn('postgres_denormalize.W009', message)
190
+
191
+ def test_accepts_silenced_checks_from_none_to_every_field_check(self):
192
+ """
193
+ Case: EXCLUDE_MODELS_SILENCED_CHECKS is an empty list, and then a list of every field check.
194
+ Expected: No error for either.
195
+ """
196
+ for silenced in ([], list(conf.FIELD_CHECKS)):
197
+ with (
198
+ self.subTest(silenced=silenced),
199
+ override_settings(POSTGRES_DENORMALIZE={'EXCLUDE_MODELS_SILENCED_CHECKS': silenced}),
200
+ ):
201
+ conf.validate_settings()
202
+
160
203
  def test_refuses_a_redundant_copy_check_that_is_not_a_dict(self):
161
204
  """
162
205
  Case: REDUNDANT_COPY_CHECK is the string 'error'.