Spec-Zone.ru › Django 6.0

Как писать пользовательские lookup-выражения

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

Пример lookup-выражения

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

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

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

Чтобы это заработало, нужно выполнить два шага. Сначала реализовать lookup-выражение, а затем сообщить о нём 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

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

from django.db.models import Field

Field.register_lookup(NotEqual)

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

from django.db.models import Field


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

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

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

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

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

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

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

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

Приведённое выше пользовательское lookup-выражение полезно, но в некоторых случаях может понадобиться объединять lookup-выражения в цепочки. Например, предположим, что мы разрабатываем приложение, в котором хотим использовать оператор 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, мы можем добавлять последующие lookup-выражения в цепочку. Поэтому Experiment.objects.filter(change__abs__lt=27) сгенерирует следующий SQL:

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

Если другое lookup-выражение не указано, 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

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

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

Определяя, какие lookup-выражения допустимы после применения Transform, Django использует атрибут output_field. Здесь его не нужно было указывать, поскольку он не изменился. Но если бы мы применяли AbsoluteValue к полю, представляющему более сложный тип (например, точку относительно начала координат или комплексное число), то, возможно, захотели бы указать, что трансформация возвращает тип FloatField для последующих lookup-выражений. Для этого можно добавить атрибут output_field к трансформеру:

from django.db.models import FloatField, Transform


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

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

Это гарантирует, что последующие lookup-выражения, например abs__lte, будут работать так же, как для FloatField.

Создание эффективного lookup-выражения abs__lt

В некоторых случаях SQL, созданный при использовании описанного выше lookup-выражения abs, неэффективно использует индексы. В частности, при использовании change__abs__lt=27 это эквивалентно change__gt=-27 AND 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 можно получить только из lookup-выражения AbsoluteValue, то есть lhs всегда является экземпляром AbsoluteValue.

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

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

Примечание

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

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

Рассмотренный ранее пример AbsoluteValue представляет собой трансформацию, применяемую к левой части lookup-выражения. В некоторых случаях может понадобиться применять трансформацию и к левой, и к правой части. Например, если нужно отфильтровать набор запросов по равенству левой и правой частей без учёта регистра с помощью некоторой SQL-функции.

Рассмотрим здесь трансформации без учёта регистра. На практике эта трансформация не очень полезна, поскольку Django уже включает ряд встроенных lookup-выражений без учёта регистра, но она хорошо продемонстрирует двусторонние трансформации независимо от базы данных.

Определим трансформер 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')

Создание альтернативных реализаций существующих lookup-выражений

Иногда для одной и той же операции разным производителям баз данных требуется разный 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 определяет используемые lookup-выражения и трансформации

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

class CoordinatesField(Field):
    def get_lookup(self, lookup_name):
        if lookup_name.startswith("x"):
            try:
                dimension = int(lookup_name.removeprefix("x"))
            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-выражения, мы ищем Lookup. Если имён несколько, выполняется поиск Transform. Если осталось только одно имя, но Lookup не найден, мы ищем Transform, а затем lookup-выражение 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').

© Django Software Foundation and individual contributors
Licensed under the BSD License.
https://docs.djangoproject.com/en/6.0/howto/custom-lookups/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API