diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a2a7ce9c8b..bb7434979c 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -22,7 +22,7 @@ Changelog ~~~~~~~~~~~~~~~~~~ * Fix: Prevent error when filtering by locale and searching with Elasticsearch (Sage Abdullah) - + * Fix: Support searching `none()` querysets (Matt Westcott) 6.4 (03.02.2025) ~~~~~~~~~~~~~~~~ diff --git a/docs/releases/6.4.1.md b/docs/releases/6.4.1.md index 5bc1190c68..48972ce4df 100644 --- a/docs/releases/6.4.1.md +++ b/docs/releases/6.4.1.md @@ -14,3 +14,4 @@ depth: 1 ### Bug fixes * Prevent error when filtering by locale and searching with Elasticsearch (Sage Abdullah) + * Support searching `none()` querysets (Matt Westcott) diff --git a/wagtail/search/backends/base.py b/wagtail/search/backends/base.py index f1ee7e2627..def91fda5f 100644 --- a/wagtail/search/backends/base.py +++ b/wagtail/search/backends/base.py @@ -5,7 +5,7 @@ from django.db.models.functions.datetime import Extract as ExtractDate from django.db.models.functions.datetime import ExtractYear from django.db.models.lookups import Lookup from django.db.models.query import QuerySet -from django.db.models.sql.where import SubqueryConstraint, WhereNode +from django.db.models.sql.where import NothingNode, SubqueryConstraint, WhereNode from wagtail.search.index import class_is_indexed, get_indexed_models from wagtail.search.query import MATCH_ALL, PlainText @@ -69,6 +69,9 @@ class BaseSearchQueryCompiler: def _process_lookup(self, field, lookup, value): raise NotImplementedError + def _process_match_none(self): + raise NotImplementedError + def _connect_filters(self, filters, connector, negated): raise NotImplementedError @@ -179,6 +182,9 @@ class BaseSearchQueryCompiler: field_attname, lookup, value, check_only=check_only ) + elif isinstance(where_node, NothingNode): + return self._process_match_none() + elif isinstance(where_node, SubqueryConstraint): raise FilterError( "Could not apply filter on search results: Subqueries are not allowed." diff --git a/wagtail/search/backends/database/fallback.py b/wagtail/search/backends/database/fallback.py index e8cf203944..940c41b8bd 100644 --- a/wagtail/search/backends/database/fallback.py +++ b/wagtail/search/backends/database/fallback.py @@ -56,6 +56,9 @@ class DatabaseSearchQueryCompiler(BaseSearchQueryCompiler): **{field.get_attname(self.queryset.model) + "__" + lookup: value} ) + def _process_match_none(self): + return models.Q(pk__in=[]) + def _connect_filters(self, filters, connector, negated): if connector == "AND": q = models.Q(*filters) diff --git a/wagtail/search/backends/database/mysql/mysql.py b/wagtail/search/backends/database/mysql/mysql.py index 94db1de72c..05b24f484d 100644 --- a/wagtail/search/backends/database/mysql/mysql.py +++ b/wagtail/search/backends/database/mysql/mysql.py @@ -522,6 +522,9 @@ class MySQLSearchQueryCompiler(BaseSearchQueryCompiler): lhs = field.get_attname(self.queryset.model) + "__" + lookup return Q(**{lhs: value}) + def _process_match_none(self): + return Q(pk__in=[]) + def _connect_filters(self, filters, connector, negated): if connector == "AND": q = Q(*filters) diff --git a/wagtail/search/backends/database/postgres/postgres.py b/wagtail/search/backends/database/postgres/postgres.py index 7eab3c1e61..a30a7f27d8 100644 --- a/wagtail/search/backends/database/postgres/postgres.py +++ b/wagtail/search/backends/database/postgres/postgres.py @@ -561,6 +561,9 @@ class PostgresSearchQueryCompiler(BaseSearchQueryCompiler): lhs = field.get_attname(self.queryset.model) + "__" + lookup return Q(**{lhs: value}) + def _process_match_none(self): + return Q(pk__in=[]) + def _connect_filters(self, filters, connector, negated): if connector == "AND": q = Q(*filters) diff --git a/wagtail/search/backends/database/sqlite/sqlite.py b/wagtail/search/backends/database/sqlite/sqlite.py index 67e77d0c12..d2f40bcb40 100644 --- a/wagtail/search/backends/database/sqlite/sqlite.py +++ b/wagtail/search/backends/database/sqlite/sqlite.py @@ -580,6 +580,9 @@ class SQLiteSearchQueryCompiler(BaseSearchQueryCompiler): lhs = field.get_attname(self.queryset.model) + "__" + lookup return Q(**{lhs: value}) + def _process_match_none(self): + return Q(pk__in=[]) + def _connect_filters(self, filters, connector, negated): if connector == "AND": q = Q(*filters) diff --git a/wagtail/search/backends/elasticsearch7.py b/wagtail/search/backends/elasticsearch7.py index 6d5411e07c..f6e0c1e69a 100644 --- a/wagtail/search/backends/elasticsearch7.py +++ b/wagtail/search/backends/elasticsearch7.py @@ -565,6 +565,9 @@ class Elasticsearch7SearchQueryCompiler(BaseSearchQueryCompiler): } } + def _process_match_none(self): + return {"bool": {"mustNot": {"match_all": {}}}} + def _connect_filters(self, filters, connector, negated): if filters: if len(filters) == 1: diff --git a/wagtail/search/tests/test_backends.py b/wagtail/search/tests/test_backends.py index 33c5c65956..ab84acdeef 100644 --- a/wagtail/search/tests/test_backends.py +++ b/wagtail/search/tests/test_backends.py @@ -638,6 +638,13 @@ class BackendTests(WagtailTestUtils): ], ) + def test_filter_none(self): + results = self.backend.search(MATCH_ALL, models.Book.objects.none()) + self.assertListEqual(list(results), []) + + results = self.backend.search("JavaScript", models.Book.objects.none()) + self.assertListEqual(list(results), []) + # FACET TESTS def test_facet(self):