Files
old-saburly-wagtail-web/env/lib/python3.10/site-packages/wagtail/search/backends/database/fallback.py

250 lines
7.6 KiB
Python
Raw Normal View History

2024-08-27 20:33:44 +02:00
from collections import OrderedDict
from warnings import warn
from django.core.exceptions import FieldDoesNotExist
from django.db import models
from django.db.models import Count
from django.db.models.expressions import Value
from wagtail.search.backends.base import (
BaseSearchBackend,
BaseSearchQueryCompiler,
BaseSearchResults,
FilterFieldError,
)
from wagtail.search.query import And, Boost, MatchAll, Not, Or, Phrase, PlainText
from wagtail.search.utils import AND, OR
# This file implements a database search backend using basic substring matching, and no
# database-specific full-text search capabilities. It will be used in the following cases:
# * The current default database is SQLite <3.19, or SQLite built without fulltext
# extensions, or something other than PostgreSQL, MySQL or SQLite
# * 'wagtail.search.backends.database.fallback' is specified directly as the search backend
MATCH_ALL = "_ALL_"
MATCH_NONE = "_NONE_"
class DatabaseSearchQueryCompiler(BaseSearchQueryCompiler):
DEFAULT_OPERATOR = "and"
OPERATORS = {
"and": AND,
"or": OR,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields_names = list(self.get_fields_names())
def get_fields_names(self):
model = self.queryset.model
fields_names = self.fields or [
field.field_name for field in model.get_searchable_search_fields()
]
# Check if the field exists (this will filter out indexed callables)
for field_name in fields_names:
try:
model._meta.get_field(field_name)
except FieldDoesNotExist:
continue
else:
yield field_name
def _process_lookup(self, field, lookup, value):
return models.Q(
**{field.get_attname(self.queryset.model) + "__" + lookup: value}
)
def _connect_filters(self, filters, connector, negated):
if connector == "AND":
q = models.Q(*filters)
elif connector == "OR":
q = OR([models.Q(fil) for fil in filters])
else:
return
if negated:
q = ~q
return q
def build_single_term_filter(self, term):
term_query = models.Q()
for field_name in self.fields_names:
term_query |= models.Q(**{field_name + "__icontains": term})
return term_query
def check_boost(self, query, boost=1.0):
if query.boost * boost != 1.0:
warn("Database search backend does not support term boosting.")
def build_database_filter(self, query, boost=1.0):
if isinstance(query, PlainText):
self.check_boost(query, boost=boost)
operator = self.OPERATORS[query.operator]
return operator(
[
self.build_single_term_filter(term)
for term in query.query_string.split()
]
)
if isinstance(query, Phrase):
q = models.Q()
for field_name in self.fields_names:
q |= models.Q(**{field_name + "__icontains": query.query_string})
return q
if isinstance(query, Boost):
boost *= query.boost
return self.build_database_filter(query.subquery, boost=boost)
if isinstance(query, MatchAll):
return MATCH_ALL
if isinstance(query, Not):
q = self.build_database_filter(query.subquery, boost=boost)
if q == MATCH_ALL:
return MATCH_NONE
elif q == MATCH_NONE:
return MATCH_ALL
else:
return ~q
if isinstance(query, And):
subqueries = [
self.build_database_filter(subquery, boost=boost)
for subquery in query.subqueries
]
# If there's a MATCH_NONE, return MATCH_NONE
if MATCH_NONE in subqueries:
return MATCH_NONE
# Ignore MATCH_ALL
subqueries = [q for q in subqueries if q != MATCH_ALL]
return AND(subqueries)
if isinstance(query, Or):
subqueries = [
self.build_database_filter(subquery, boost=boost)
for subquery in query.subqueries
]
# If there's a MATCH_ALL, return MATCH_ALL
if MATCH_ALL in subqueries:
return MATCH_ALL
# Ignore MATCH_NONE
subqueries = [q for q in subqueries if q != MATCH_NONE]
return OR(subqueries)
raise NotImplementedError(
"`%s` is not supported by the database search backend."
% query.__class__.__name__
)
class DatabaseAutocompleteQueryCompiler(DatabaseSearchQueryCompiler):
# The fallback backend doesn't handle word boundaries, so standard searches are
# essentially equivalent to autocomplete queries anyhow. However, to provide a
# consistent API with other backends, we provide both endpoints.
pass
class DatabaseSearchResults(BaseSearchResults):
iterator_chunk_size = 2000
def get_queryset(self):
queryset = self.query_compiler.queryset
# Run _get_filters_from_queryset to test that no fields that are not
# a FilterField have been used in the query.
self.query_compiler._get_filters_from_queryset()
q = self.query_compiler.build_database_filter(self.query_compiler.query)
if q == MATCH_ALL:
pass
elif q == MATCH_NONE:
queryset = queryset.none()
else:
queryset = queryset.filter(q)
return queryset.distinct()[self.start : self.stop]
def _do_search(self):
queryset = self.get_queryset()
if self._score_field:
queryset = queryset.annotate(
**{self._score_field: Value(None, output_field=models.FloatField())}
)
return queryset.iterator(self.iterator_chunk_size)
def _do_count(self):
return self.get_queryset().count()
supports_facet = True
def facet(self, field_name):
# Get field
field = self.query_compiler._get_filterable_field(field_name)
if field is None:
raise FilterFieldError(
'Cannot facet search results with field "'
+ field_name
+ "\". Please add index.FilterField('"
+ field_name
+ "') to "
+ self.query_compiler.queryset.model.__name__
+ ".search_fields.",
field_name=field_name,
)
query = self.get_queryset()
results = (
query.values(field_name).annotate(count=Count("pk")).order_by("-count")
)
return OrderedDict(
[(result[field_name], result["count"]) for result in results]
)
class DatabaseSearchBackend(BaseSearchBackend):
query_compiler_class = DatabaseSearchQueryCompiler
autocomplete_query_compiler_class = DatabaseSearchQueryCompiler
results_class = DatabaseSearchResults
def reset_index(self):
pass # Not needed
def add_type(self, model):
pass # Not needed
def refresh_index(self):
pass # Not needed
def add(self, obj):
pass # Not needed
def add_bulk(self, model, obj_list):
return # Not needed
def delete(self, obj):
pass # Not needed
# This line allows using 'wagtail.search.backends.database.fallback' as the backend, bypassing the automatic selection of the backend that would get run if the user chose 'wagtail.search.backends.database'
SearchBackend = DatabaseSearchBackend