From 74b31cd26b9ad4ad85f131850a734f02aae988bb Mon Sep 17 00:00:00 2001 From: Clifford Gama Date: Fri, 16 May 2025 23:48:38 +0200 Subject: [PATCH] Fixed #32770 -- Added system check to ensure django.contrib.postgres is installed when using its features. Added postgres.E005 to validate 'django.contrib.postgres' is in INSTALLED_APPS when using: * PostgreSQL-specific fields (ArrayField, HStoreField, range fields, SearchVectorField), * PostgreSQL indexes (PostgresIndex and all subclasses), and * ExclusionConstraint The check provides immediate feedback during system checks rather than failing later with obscure runtime and database errors. Thanks to Simon Charette and Sarah Boyce for reviews. --- django/contrib/postgres/constraints.py | 8 +- django/contrib/postgres/fields/array.py | 6 +- django/contrib/postgres/fields/hstore.py | 4 +- django/contrib/postgres/fields/ranges.py | 3 +- django/contrib/postgres/indexes.py | 4 +- django/contrib/postgres/search.py | 6 +- django/contrib/postgres/utils.py | 24 +++ docs/ref/checks.txt | 4 +- docs/releases/6.0.txt | 4 +- .../test_deprecated_fields.py | 3 +- .../test_app_installed_check.py | 142 ++++++++++++++++++ tests/runtests.py | 1 + 12 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 tests/postgres_tests/test_app_installed_check.py diff --git a/django/contrib/postgres/constraints.py b/django/contrib/postgres/constraints.py index e030c1190b..90441a51ce 100644 --- a/django/contrib/postgres/constraints.py +++ b/django/contrib/postgres/constraints.py @@ -9,6 +9,8 @@ from django.db.models.indexes import IndexExpression from django.db.models.lookups import PostgresOperatorLookup from django.db.models.sql import Query +from .utils import CheckPostgresInstalledMixin + __all__ = ["ExclusionConstraint"] @@ -16,7 +18,7 @@ class ExclusionConstraintExpression(IndexExpression): template = "%(expressions)s WITH %(operator)s" -class ExclusionConstraint(BaseConstraint): +class ExclusionConstraint(CheckPostgresInstalledMixin, BaseConstraint): template = ( "CONSTRAINT %(name)s EXCLUDE USING %(index_type)s " "(%(expressions)s)%(include)s%(where)s%(deferrable)s" @@ -77,12 +79,14 @@ class ExclusionConstraint(BaseConstraint): return ExpressionList(*expressions).resolve_expression(query) def check(self, model, connection): + errors = super().check(model, connection) references = set() for expr, _ in self.expressions: if isinstance(expr, str): expr = F(expr) references.update(model._get_expr_references(expr)) - return self._check_references(model, references) + errors.extend(self._check_references(model, references)) + return errors def _get_condition_sql(self, compiler, schema_editor, query): if self.condition is None: diff --git a/django/contrib/postgres/fields/array.py b/django/contrib/postgres/fields/array.py index 88df368c73..ac663830f8 100644 --- a/django/contrib/postgres/fields/array.py +++ b/django/contrib/postgres/fields/array.py @@ -9,13 +9,13 @@ from django.db.models.fields.mixins import CheckFieldDefaultMixin from django.db.models.lookups import Exact, In from django.utils.translation import gettext_lazy as _ -from ..utils import prefix_validation_error +from ..utils import CheckPostgresInstalledMixin, prefix_validation_error from .utils import AttributeSetter __all__ = ["ArrayField"] -class ArrayField(CheckFieldDefaultMixin, Field): +class ArrayField(CheckPostgresInstalledMixin, CheckFieldDefaultMixin, Field): empty_strings_allowed = False default_error_messages = { "item_invalid": _("Item %(nth)s in the array did not validate:"), @@ -73,6 +73,8 @@ class ArrayField(CheckFieldDefaultMixin, Field): "%s (%s)" % (base_check.msg, base_check.id) for base_check in base_checks if isinstance(base_check, checks.Error) + # Prevent duplication of E005 in an E001 check. + and not base_check.id == "postgres.E005" ) if error_messages: errors.append( diff --git a/django/contrib/postgres/fields/hstore.py b/django/contrib/postgres/fields/hstore.py index 1120a78edd..e28b9f7dbe 100644 --- a/django/contrib/postgres/fields/hstore.py +++ b/django/contrib/postgres/fields/hstore.py @@ -7,10 +7,12 @@ from django.db.models import Field, TextField, Transform from django.db.models.fields.mixins import CheckFieldDefaultMixin from django.utils.translation import gettext_lazy as _ +from ..utils import CheckPostgresInstalledMixin + __all__ = ["HStoreField"] -class HStoreField(CheckFieldDefaultMixin, Field): +class HStoreField(CheckPostgresInstalledMixin, CheckFieldDefaultMixin, Field): empty_strings_allowed = False description = _("Map of strings to strings/nulls") default_error_messages = { diff --git a/django/contrib/postgres/fields/ranges.py b/django/contrib/postgres/fields/ranges.py index fbb6012660..8c3295244b 100644 --- a/django/contrib/postgres/fields/ranges.py +++ b/django/contrib/postgres/fields/ranges.py @@ -12,6 +12,7 @@ from django.db.backends.postgresql.psycopg_any import ( from django.db.models.functions import Cast from django.db.models.lookups import PostgresOperatorLookup +from ..utils import CheckPostgresInstalledMixin from .utils import AttributeSetter __all__ = [ @@ -51,7 +52,7 @@ class RangeOperators: ADJACENT_TO = "-|-" -class RangeField(models.Field): +class RangeField(CheckPostgresInstalledMixin, models.Field): empty_strings_allowed = False def __init__(self, *args, **kwargs): diff --git a/django/contrib/postgres/indexes.py b/django/contrib/postgres/indexes.py index ce9e2cee14..516641d48d 100644 --- a/django/contrib/postgres/indexes.py +++ b/django/contrib/postgres/indexes.py @@ -1,6 +1,8 @@ from django.db.models import Func, Index from django.utils.functional import cached_property +from .utils import CheckPostgresInstalledMixin + __all__ = [ "BloomIndex", "BrinIndex", @@ -12,7 +14,7 @@ __all__ = [ ] -class PostgresIndex(Index): +class PostgresIndex(CheckPostgresInstalledMixin, Index): @cached_property def max_name_length(self): # Allow an index name longer than 30 characters when the suffix is diff --git a/django/contrib/postgres/search.py b/django/contrib/postgres/search.py index 2135c9bb88..24f7a497da 100644 --- a/django/contrib/postgres/search.py +++ b/django/contrib/postgres/search.py @@ -11,6 +11,8 @@ from django.db.models import ( from django.db.models.expressions import CombinedExpression, register_combinable_fields from django.db.models.functions import Cast, Coalesce +from .utils import CheckPostgresInstalledMixin + class SearchVectorExact(Lookup): lookup_name = "exact" @@ -29,12 +31,12 @@ class SearchVectorExact(Lookup): return "%s @@ %s" % (lhs, rhs), params -class SearchVectorField(Field): +class SearchVectorField(CheckPostgresInstalledMixin, Field): def db_type(self, connection): return "tsvector" -class SearchQueryField(Field): +class SearchQueryField(CheckPostgresInstalledMixin, Field): def db_type(self, connection): return "tsquery" diff --git a/django/contrib/postgres/utils.py b/django/contrib/postgres/utils.py index e4f4d81514..77d483f389 100644 --- a/django/contrib/postgres/utils.py +++ b/django/contrib/postgres/utils.py @@ -1,3 +1,5 @@ +from django.apps import apps +from django.core import checks from django.core.exceptions import ValidationError from django.utils.functional import SimpleLazyObject from django.utils.text import format_lazy @@ -27,3 +29,25 @@ def prefix_validation_error(error, prefix, code, params): return ValidationError( [prefix_validation_error(e, prefix, code, params) for e in error.error_list] ) + + +class CheckPostgresInstalledMixin: + def _check_postgres_installed(self, *args): + # When subclassed by Index or BaseConstraint subclasses, args is + # (model, connection). + obj = args[0] if args else self + if not apps.is_installed("django.contrib.postgres"): + return [ + checks.Error( + "'django.contrib.postgres' must be in INSTALLED_APPS in " + "order to use %s." % self.__class__.__name__, + obj=obj, + id="postgres.E005", + ) + ] + return [] + + def check(self, *args, **kwargs): + errors = super().check(*args, **kwargs) + errors.extend(self._check_postgres_installed(*args)) + return errors diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 699a3d34c0..b9cb1d19cf 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -912,7 +912,7 @@ The following checks are performed when a model contains a ------------ The following checks are performed on :mod:`django.contrib.postgres` model -fields: +fields, indexes, and constraints: * **postgres.E001**: Base field for array has errors: ... * **postgres.E002**: Base field for array cannot be a related field. @@ -920,6 +920,8 @@ fields: instance so that it's not shared between all field instances. *This check was changed to* ``fields.E010`` *in Django 3.1*. * **postgres.W004**: Base field for array has warnings: ... +* **postgres.E005**: ``'django.contrib.postgres'`` must be in + ``INSTALLED_APPS`` in order to use ````. .. _sites-system-checks: diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index 118ad43cc9..362ea70ea0 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -84,7 +84,9 @@ Minor features :mod:`django.contrib.postgres` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* Model fields, indexes, and constraints from :mod:`django.contrib.postgres` + now include system checks to verify that ``django.contrib.postgres`` is an + installed app. :mod:`django.contrib.redirects` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/invalid_models_tests/test_deprecated_fields.py b/tests/invalid_models_tests/test_deprecated_fields.py index 65f65e7528..e3c7d2e12f 100644 --- a/tests/invalid_models_tests/test_deprecated_fields.py +++ b/tests/invalid_models_tests/test_deprecated_fields.py @@ -3,7 +3,7 @@ from unittest import skipUnless from django.core import checks from django.db import connection, models from django.test import SimpleTestCase -from django.test.utils import isolate_apps +from django.test.utils import isolate_apps, modify_settings @isolate_apps("invalid_models_tests") @@ -87,6 +87,7 @@ class DeprecatedFieldsTests(SimpleTestCase): ) @skipUnless(connection.vendor == "postgresql", "PostgreSQL specific SQL") + @modify_settings(INSTALLED_APPS={"append": "django.contrib.postgres"}) def test_postgres_ci_fields_deprecated(self): from django.contrib.postgres.fields import ( ArrayField, diff --git a/tests/postgres_tests/test_app_installed_check.py b/tests/postgres_tests/test_app_installed_check.py new file mode 100644 index 0000000000..95315384e6 --- /dev/null +++ b/tests/postgres_tests/test_app_installed_check.py @@ -0,0 +1,142 @@ +from django.core import checks +from django.db import models +from django.test import modify_settings +from django.test.utils import isolate_apps + +from . import PostgreSQLTestCase +from .fields import ( + BigIntegerRangeField, + DateRangeField, + DateTimeRangeField, + DecimalRangeField, + HStoreField, + IntegerRangeField, + SearchVectorField, +) +from .models import IntegerArrayModel, NestedIntegerArrayModel, PostgreSQLModel + +try: + from django.contrib.postgres.constraints import ExclusionConstraint + from django.contrib.postgres.fields.ranges import RangeOperators + from django.contrib.postgres.indexes import GinIndex, PostgresIndex + from django.contrib.postgres.search import SearchQueryField +except ImportError: + pass + + +@isolate_apps("postgres_tests") +class TestPostgresAppInstalledCheck(PostgreSQLTestCase): + + def _make_error(self, obj, klass_name): + """Helper to create postgres.E005 error for specific objects.""" + return checks.Error( + "'django.contrib.postgres' must be in INSTALLED_APPS in order to " + f"use {klass_name}.", + obj=obj, + id="postgres.E005", + ) + + def assert_model_check_errors(self, model_class, expected_errors): + errors = model_class.check(databases=self.databases) + self.assertEqual(errors, []) + with modify_settings(INSTALLED_APPS={"remove": "django.contrib.postgres"}): + errors = model_class.check(databases=self.databases) + self.assertEqual(errors, expected_errors) + + def test_indexes(self): + class IndexModel(PostgreSQLModel): + field = models.IntegerField() + + class Meta: + indexes = [ + PostgresIndex(fields=["id"], name="postgres_index_test"), + GinIndex(fields=["field"], name="gin_index_test"), + ] + + self.assert_model_check_errors( + IndexModel, + [ + self._make_error(IndexModel, "PostgresIndex"), + self._make_error(IndexModel, "GinIndex"), + ], + ) + + def test_exclusion_constraint(self): + class ExclusionModel(PostgreSQLModel): + value = models.IntegerField() + + class Meta: + constraints = [ + ExclusionConstraint( + name="exclude_equal", + expressions=[("value", RangeOperators.EQUAL)], + ) + ] + + self.assert_model_check_errors( + ExclusionModel, [self._make_error(ExclusionModel, "ExclusionConstraint")] + ) + + def test_array_field(self): + field = IntegerArrayModel._meta.get_field("field") + self.assert_model_check_errors( + IntegerArrayModel, + [self._make_error(field, "ArrayField")], + ) + + def test_nested_array_field(self): + """Inner ArrayField does not cause a postgres.E001 error.""" + field = NestedIntegerArrayModel._meta.get_field("field") + self.assert_model_check_errors( + NestedIntegerArrayModel, + [ + self._make_error(field, "ArrayField"), + ], + ) + + def test_hstore_field(self): + class HStoreFieldModel(PostgreSQLModel): + field = HStoreField() + + field = HStoreFieldModel._meta.get_field("field") + self.assert_model_check_errors( + HStoreFieldModel, + [ + self._make_error(field, "HStoreField"), + ], + ) + + def test_range_fields(self): + class RangeFieldsModel(PostgreSQLModel): + int_range = IntegerRangeField() + bigint_range = BigIntegerRangeField() + decimal_range = DecimalRangeField() + datetime_range = DateTimeRangeField() + date_range = DateRangeField() + + expected_errors = [ + self._make_error(field, field.__class__.__name__) + for field in [ + RangeFieldsModel._meta.get_field("int_range"), + RangeFieldsModel._meta.get_field("bigint_range"), + RangeFieldsModel._meta.get_field("decimal_range"), + RangeFieldsModel._meta.get_field("datetime_range"), + RangeFieldsModel._meta.get_field("date_range"), + ] + ] + self.assert_model_check_errors(RangeFieldsModel, expected_errors) + + def test_search_vector_field(self): + class SearchModel(PostgreSQLModel): + search_vector = SearchVectorField() + search_query = SearchQueryField() + + vector_field = SearchModel._meta.get_field("search_vector") + query_field = SearchModel._meta.get_field("search_query") + self.assert_model_check_errors( + SearchModel, + [ + self._make_error(vector_field, "SearchVectorField"), + self._make_error(query_field, "SearchQueryField"), + ], + ) diff --git a/tests/runtests.py b/tests/runtests.py index e9052ca4a9..ceb88853b7 100755 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -247,6 +247,7 @@ def setup_collect_tests(start_at, start_after, test_labels=None): settings.LOGGING = log_config settings.SILENCED_SYSTEM_CHECKS = [ "fields.W342", # ForeignKey(unique=True) -> OneToOneField + "postgres.E005", # django.contrib.postgres must be installed to use feature. ] # Load all the ALWAYS_INSTALLED_APPS.