Как писать пользовательские поисковые запросы

Django предлагает широкий выбор built-in lookups для фильтрации (например, exact и icontains). Эта документация объясняет, как писать пользовательские поисковые запросы и как изменять работу существующих поисковых запросов. Ссылки на API для поиска смотрите в Справочник по API поиска.

Пример поиска

Давайте начнем с небольшого пользовательского поиска. Мы напишем пользовательский поиск ne, который работает противоположно exact. Author.objects.filter(name__ne='Jack') будет переводиться в SQL:

"author"."name" <> 'Jack'

Этот SQL не зависит от бэкенда, поэтому нам не нужно беспокоиться о различных базах данных.

Есть два шага, чтобы заставить это работать. Сначала нам нужно реализовать поиск, а затем сообщить об этом Django:

from django.db.models import Lookup

class NotEqual(Lookup):
    lookup_name = 'ne'

    def as_sql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params
        return '%s <> %s' % (lhs, rhs), params

Для регистрации поиска NotEqual нам нужно будет вызвать register_lookup на классе поля, для которого мы хотим, чтобы поиск был доступен. В данном случае поиск имеет смысл для всех подклассов Field, поэтому мы зарегистрируем его с помощью Field непосредственно:

from django.db.models import Field
Field.register_lookup(NotEqual)

Регистрация поиска также может быть выполнена с помощью шаблона декоратора:

from django.db.models import Field

@Field.register_lookup
class NotEqualLookup(Lookup):
    # ...

Теперь мы можем использовать foo__ne для любого поля foo. Вам необходимо убедиться, что эта регистрация произошла до того, как вы попытаетесь создать какие-либо наборы запросов с ее использованием. Вы можете поместить реализацию в файл models.py или зарегистрировать поиск в методе ready() метода AppConfig.

При более детальном рассмотрении реализации, первым требуемым атрибутом является lookup_name. Это позволяет ORM понять, как интерпретировать name__ne и использовать NotEqual для генерации SQL. По соглашению, эти имена всегда являются строчными строками, содержащими только буквы, но единственным жестким требованием является то, что оно не должно содержать строку __.

Затем нам нужно определить метод as_sql. Он принимает объект SQLCompiler, называемый compiler, и активное соединение с базой данных. Объекты SQLCompiler не документированы, но единственное, что нам нужно знать о них, это то, что у них есть метод compile(), который возвращает кортеж, содержащий строку SQL и параметры, которые должны быть интерполированы в эту строку. В большинстве случаев вам не нужно использовать его напрямую, и вы можете передать его в process_lhs() и process_rhs().

Lookup работает против двух значений, lhs и rhs, обозначающих левую сторону и правую сторону. Левая сторона обычно является ссылкой на поле, но это может быть что угодно, реализующее query expression API. Правая сторона - это значение, заданное пользователем. В примере Author.objects.filter(name__ne='Jack') левая сторона - это ссылка на поле name модели Author, а 'Jack' - это правая сторона.

Мы вызываем process_lhs и process_rhs, чтобы преобразовать их в значения, необходимые нам для SQL, используя объект compiler, описанный ранее. Эти методы возвращают кортежи, содержащие некоторый SQL и параметры, которые должны быть интерполированы в этот SQL, точно так же, как мы должны возвращать из нашего метода as_sql. В приведенном выше примере process_lhs возвращает ('"author"."name"', []), а process_rhs возвращает ('"%s"', ['Jack']). В этом примере не было параметров для левой стороны, но это будет зависеть от объекта, который у нас есть, поэтому нам все равно нужно включить их в возвращаемые параметры.

Наконец, мы объединяем части в SQL-выражение с помощью <>, и предоставляем все параметры для запроса. Затем мы возвращаем кортеж, содержащий сгенерированную строку SQL и параметры.

Пример трансформатора

Приведенный выше пользовательский поиск - это замечательно, но в некоторых случаях вам может понадобиться возможность объединять поиск в цепочку. Например, предположим, что мы создаем приложение, в котором хотим использовать оператор abs(). У нас есть модель Experiment, которая записывает начальное значение, конечное значение и изменение (начало - конец). Мы хотели бы найти все эксперименты, в которых изменение было равно определенной величине (Experiment.objects.filter(change__abs=27)) или не превышало определенную величину (Experiment.objects.filter(change__abs__lt=27)).

Примечание

Этот пример несколько надуман, но он хорошо демонстрирует диапазон функциональности, которая возможна в независимой от бэкенда базы данных манере, и без дублирования функциональности, уже имеющейся в Django.

Мы начнем с написания трансформатора AbsoluteValue. Он будет использовать SQL-функцию ABS() для преобразования значения перед сравнением:

from django.db.models import Transform

class AbsoluteValue(Transform):
    lookup_name = 'abs'
    function = 'ABS'

Далее зарегистрируем его для IntegerField:

from django.db.models import IntegerField
IntegerField.register_lookup(AbsoluteValue)

Теперь мы можем запустить запросы, которые у нас были раньше. Experiment.objects.filter(change__abs=27) сгенерирует следующий SQL:

SELECT ... WHERE ABS("experiments"."change") = 27

Использование Transform вместо Lookup означает, что мы можем выстроить цепочку дальнейших поисков. Таким образом, Experiment.objects.filter(change__abs__lt=27) сгенерирует следующий SQL:

SELECT ... WHERE ABS("experiments"."change") < 27

Обратите внимание, что в случае, если не указан другой поиск, Django интерпретирует change__abs=27 как change__abs__exact=27.

Это также позволяет использовать результат в предложениях ORDER BY и DISTINCT ON. Например, Experiment.objects.order_by('change__abs') генерирует:

SELECT ... ORDER BY ABS("experiments"."change") ASC

А в базах данных, поддерживающих различия по полям (например, PostgreSQL), генерируется Experiment.objects.distinct('change__abs'):

SELECT ... DISTINCT ON ABS("experiments"."change")

При поиске того, какие поиски допустимы после применения Transform, Django использует атрибут output_field. Нам не нужно указывать его здесь, так как он не меняется, но если предположить, что мы применяем AbsoluteValue к некоторому полю, которое представляет более сложный тип (например, точка относительно начала координат или комплексное число), то мы, возможно, захотим указать, что преобразование возвращает тип FloatField для дальнейшего поиска. Это можно сделать, добавив к преобразованию атрибут output_field:

from django.db.models import FloatField, Transform

class AbsoluteValue(Transform):
    lookup_name = 'abs'
    function = 'ABS'

    @property
    def output_field(self):
        return FloatField()

Это гарантирует, что дальнейшие поиски типа abs__lte будут вести себя так же, как и для FloatField.

Написание эффективного поиска abs__lt

При использовании вышеописанного поиска abs, созданный SQL не будет эффективно использовать индексы в некоторых случаях. В частности, когда мы используем change__abs__lt=27, это эквивалентно change__gt=-27 И change__lt=27. (Для случая lte мы могли бы использовать SQL BETWEEN).

Поэтому мы хотели бы, чтобы Experiment.objects.filter(change__abs__lt=27) генерировал следующий SQL:

SELECT .. WHERE "experiments"."change" < 27 AND "experiments"."change" > -27

Реализация такова:

from django.db.models import Lookup

class AbsoluteValueLessThan(Lookup):
    lookup_name = 'lt'

    def as_sql(self, compiler, connection):
        lhs, lhs_params = compiler.compile(self.lhs.lhs)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params + lhs_params + rhs_params
        return '%s < %s AND %s > -%s' % (lhs, rhs, lhs, rhs), params

AbsoluteValue.register_lookup(AbsoluteValueLessThan)

Есть несколько примечательных моментов. Во-первых, AbsoluteValueLessThan не вызывает process_lhs(). Вместо этого он пропускает преобразование lhs, выполняемое AbsoluteValue, и использует исходное lhs. То есть, мы хотим получить "experiments"."change", а не ABS("experiments"."change"). Обращение непосредственно к self.lhs.lhs безопасно, так как доступ к AbsoluteValueLessThan возможен только из поиска AbsoluteValue, то есть lhs всегда является экземпляром AbsoluteValue.

Обратите внимание, что поскольку обе стороны используются несколько раз в запросе, параметры должны содержать lhs_params и rhs_params несколько раз.

Последний запрос выполняет инверсию (27 в -27) непосредственно в базе данных. Причина этого заключается в том, что если self.rhs является чем-то иным, чем обычным целым значением (например, ссылкой F()), мы не сможем выполнить преобразования в Python.

Примечание

На самом деле, большинство поисков с __abs могут быть реализованы как запросы к диапазону, подобные этому, и в большинстве бэкендов баз данных это, вероятно, будет более разумно, так как вы можете использовать индексы. Однако в PostgreSQL вы можете захотеть добавить индекс для abs(change), что позволит сделать эти запросы очень эффективными.

Пример двустороннего трансформатора

Пример AbsoluteValue, который мы рассматривали ранее, является преобразованием, которое применяется к левой части поиска. В некоторых случаях требуется, чтобы преобразование применялось как к левой, так и к правой части. Например, если вы хотите отфильтровать набор запросов на основе равенства левой и правой частей нечувствительно к некоторой функции SQL.

Давайте рассмотрим здесь преобразования, нечувствительные к регистру. Это преобразование не очень полезно на практике, так как Django уже поставляется со встроенными средствами поиска без учета регистра, но это будет хорошей демонстрацией двусторонних преобразований, не зависящих от базы данных.

Мы определяем трансформатор UpperCase, который использует SQL-функцию UPPER() для преобразования значений перед сравнением. Мы определяем bilateral = True, чтобы указать, что это преобразование должно применяться как к lhs, так и к rhs:

from django.db.models import Transform

class UpperCase(Transform):
    lookup_name = 'upper'
    function = 'UPPER'
    bilateral = True

Далее, давайте зарегистрируем его:

from django.db.models import CharField, TextField
CharField.register_lookup(UpperCase)
TextField.register_lookup(UpperCase)

Теперь кверисет Author.objects.filter(name__upper="doe") будет генерировать запрос без учета регистра следующим образом:

SELECT ... WHERE UPPER("author"."name") = UPPER('doe')

Написание альтернативных реализаций для существующих поисков

Иногда разные поставщики баз данных требуют разный SQL для одной и той же операции. В данном примере мы перепишем пользовательскую реализацию оператора NotEqual для MySQL. Вместо оператора <> мы будем использовать оператор !=. (Обратите внимание, что в действительности почти все базы данных поддерживают оба оператора, включая все официальные базы данных, поддерживаемые Django).

Мы можем изменить поведение на конкретном бэкенде, создав подкласс NotEqual с методом as_mysql:

class MySQLNotEqual(NotEqual):
    def as_mysql(self, compiler, connection, **extra_context):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params
        return '%s != %s' % (lhs, rhs), params

Field.register_lookup(MySQLNotEqual)

Затем мы можем зарегистрировать его с помощью Field. Он занимает место исходного класса NotEqual, так как имеет тот же lookup_name.

При компиляции запроса Django сначала ищет методы as_%s % connection.vendor, а затем возвращается к as_sql. Имена производителей для встроенных бэкендов: sqlite, postgresql, oracle и mysql.

Как Django определяет используемый поиск и преобразования

В некоторых случаях вы можете пожелать динамически изменять возвращаемое значение Transform или Lookup на основе переданного имени, а не фиксировать его. Например, вы можете иметь поле, хранящее координаты или произвольное измерение, и хотите разрешить синтаксис типа .filter(coords__x7=4) для возврата объектов, где 7-я координата имеет значение 4. Чтобы сделать это, вы должны переопределить get_lookup чем-то вроде:

class CoordinatesField(Field):
    def get_lookup(self, lookup_name):
        if lookup_name.startswith('x'):
            try:
                dimension = int(lookup_name[1:])
            except ValueError:
                pass
            else:
                return get_coordinate_lookup(dimension)
        return super().get_lookup(lookup_name)

Затем вы определите get_coordinate_lookup соответствующим образом, чтобы вернуть подкласс Lookup, который обрабатывает соответствующее значение dimension.

Существует метод с аналогичным названием get_transform(). get_lookup() всегда должен возвращать Lookup подкласс, а get_transform() - Transform подкласс. Важно помнить, что объекты Transform можно подвергать дальнейшей фильтрации, а объекты Lookup - нет.

При фильтрации, если осталось разрешить только одно имя поиска, мы будем искать Lookup. Если имен несколько, то будет искаться Transform. В ситуации, когда есть только одно имя и Lookup не найдено, мы ищем Transform, а затем exact ищем по нему Transform. Все последовательности вызовов всегда заканчиваются символом Lookup. Для пояснения:

  • .filter(myfield__mylookup) вызовет myfield.get_lookup('mylookup').
  • .filter(myfield__mytransform__mylookup) вызовет myfield.get_transform('mytransform'), а затем mytransform.get_lookup('mylookup').
  • .filter(myfield__mytransform) сначала вызовет myfield.get_lookup('mytransform'), что приведет к неудаче, поэтому он вернется к вызову myfield.get_transform('mytransform'), а затем mytransform.get_lookup('exact').
Вернуться на верх