summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClifford Gama <cliffygamy@gmail.com>2025-05-16 23:48:38 +0200
committerSarah Boyce <42296566+sarahboyce@users.noreply.github.com>2025-06-18 08:36:49 +0200
commit74b31cd26b9ad4ad85f131850a734f02aae988bb (patch)
treeceabf0b4c985ae8159d44cf969e17c31bd38a337
parent8c56e939750ae785b806dfa4e043590760c90ab6 (diff)
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.
-rw-r--r--django/contrib/postgres/constraints.py8
-rw-r--r--django/contrib/postgres/fields/array.py6
-rw-r--r--django/contrib/postgres/fields/hstore.py4
-rw-r--r--django/contrib/postgres/fields/ranges.py3
-rw-r--r--django/contrib/postgres/indexes.py4
-rw-r--r--django/contrib/postgres/search.py6
-rw-r--r--django/contrib/postgres/utils.py24
-rw-r--r--docs/ref/checks.txt4
-rw-r--r--docs/releases/6.0.txt4
-rw-r--r--tests/invalid_models_tests/test_deprecated_fields.py3
-rw-r--r--tests/postgres_tests/test_app_installed_check.py142
-rwxr-xr-xtests/runtests.py1
12 files changed, 197 insertions, 12 deletions
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 ``<django.contrib.postgres feature>``.
.. _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.