Django ORM

Custom lookups и функции | Курс Django ORM урок 7.3

Custom lookups и функции | Курс Django ORM урок 7.3
Mikhail
Автор
Mikhail
Опубликовано 16.03.2026
0,0
Views 2

Цель урока

Научиться создавать собственные lookup типы и оборачивать произвольные SQL функции через Func(). Понять как расширить Django ORM для специфических задач не прибегая к raw SQL.

Необходимые знания


Func() - SQL функция в ORM выражении

Func() позволяет вызвать любую SQL функцию в контексте ORM: в annotate(), filter(), order_by(), update().

Базовый синтаксис

from django.db.models import Func, F, Value

# Вызов функции UPPER
from django.db.models.functions import Upper

Product.objects.annotate(
    name_upper=Upper("name")
)


SELECT *, UPPER(name) AS name_upper FROM shop_product;

Django предоставляет готовые обертки для стандартных функций в django.db.models.functions.

Создание собственной функции через Func

Допустим нужна PostgreSQL функция unaccent() для поиска без учета диакритических знаков:

from django.db.models import Func, CharField

class Unaccent(Func):
    function = "UNACCENT"
    output_field = CharField()

# Использование
Product.objects.annotate(
    name_unaccent=Unaccent("name")
).filter(name_unaccent__icontains="cafe")


SELECT *, UNACCENT(name) AS name_unaccent
FROM shop_product
WHERE UNACCENT(name) ILIKE '%cafe%';

Структура:

  • function, имя SQL функции
  • output_field, тип возвращаемого значения (для типизации в Django)
  • Аргументы передаются при создании экземпляра

Требует расширения PostgreSQL unaccent. Добавьте в миграцию:

from django.contrib.postgres.operations import UnaccentExtension

class Migration(migrations.Migration):
    operations = [UnaccentExtension()]

Django предоставляет готовую обертку django.contrib.postgres.functions.Unaccent, для production используйте её, не пишите свою.

Функция с несколькими аргументами

from django.db.models import Func, FloatField

# RoundTo, демонстрация Func с двумя аргументами
# (в Django уже есть встроенный Round из django.db.models.functions)
class RoundTo(Func):
    function = "ROUND"
    output_field = FloatField()

# ROUND(price, 2), округлить до 2 знаков
Product.objects.annotate(
    rounded_price=RoundTo(F("price"), Value(2))
)


SELECT *, ROUND(price, 2) AS rounded_price FROM shop_product;

template - произвольный SQL шаблон

Иногда нужна не просто функция, а произвольное SQL выражение:

from django.db.models import Func, IntegerField

class DateDiff(Func):
    # EXTRACT(DAY FROM (date1 - date2))
    template = "EXTRACT(DAY FROM (%(expressions)s))"
    output_field = IntegerField()

# Количество дней с момента создания заказа
from django.db.models.functions import Now

Order.objects.annotate(
    days_since_creation=DateDiff(Now() - F("created_at"))
)


SELECT *, EXTRACT(DAY FROM (NOW() - created_at)) AS days_since_creation
FROM shop_order;

%(expressions)s, плейсхолдер для аргументов функции.

Func() в update()

from django.db.models.functions import Lower

# Привести slug к нижнему регистру для всех продуктов
Product.objects.update(slug=Lower("slug"))


UPDATE shop_product SET slug = LOWER(slug);

Встроенные функции Django

Django предоставляет обертки для стандартных SQL функций в django.db.models.functions:

Строковые:

from django.db.models.functions import (
    Upper, Lower, Length, Trim, LTrim, RTrim,
    Replace, Substr, Concat, Left, Right,
    Reverse, StrIndex, Repeat,
)

Product.objects.annotate(
    name_length=Length("name"),
    name_trimmed=Trim("name"),
    short_name=Left("name", 10),
)


SELECT LENGTH(name) AS name_length,
       TRIM(name) AS name_trimmed,
       LEFT(name, 10) AS short_name
FROM shop_product;

Числовые:

from django.db.models.functions import (
    Abs, Ceil, Floor, Round, Sign, Power, Sqrt, Log,
    Mod, Greatest, Least,
)

Product.objects.annotate(
    price_rounded=Round("price", 0),
    price_abs=Abs(F("price") - Value(500)),
)

Дата/время:

from django.db.models.functions import (
    Now, TruncDate, TruncMonth, TruncYear,
    TruncHour, TruncMinute, TruncSecond,
    ExtractYear, ExtractMonth, ExtractDay,
    ExtractHour, ExtractWeekDay,
)

Order.objects.annotate(
    year=ExtractYear("created_at"),
    month=TruncMonth("created_at"),
)

Условные:

from django.db.models.functions import Coalesce, NullIf, Greatest, Least

# Coalesce - первое непустое значение
Product.objects.annotate(
    display_price=Coalesce("discount_price", "price")
)


SELECT COALESCE(discount_price, price) AS display_price FROM shop_product;

Custom lookups - собственные типы фильтров

Custom lookups позволяют добавить новые операторы в filter() и exclude().

Базовый пример: IsNull с обратной логикой

Django имеет __isnull, но нет __notnull. Создадим:

from django.db.models import Lookup

class NotNullLookup(Lookup):
    lookup_name = "notnull"

    def as_sql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        return f"{lhs} IS NOT NULL", lhs_params

# Зарегистрировать для всех полей
from django.db.models import Field
Field.register_lookup(NotNullLookup)

# Использование
Product.objects.filter(discount_price__notnull=True)


WHERE discount_price IS NOT NULL

Lookup для специфичного PostgreSQL оператора

Добавим lookup __similar_to для проверки схожести строк:

from django.db.models import Lookup, CharField, TextField

class SimilarToLookup(Lookup):
    lookup_name = "similar_to"

    def as_sql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        return f"{lhs} SIMILAR TO {rhs}", lhs_params + rhs_params

CharField.register_lookup(SimilarToLookup)
TextField.register_lookup(SimilarToLookup)

# Использование
Product.objects.filter(name__similar_to="iPhone%")


WHERE name SIMILAR TO 'iPhone%'

Transform - преобразование поля перед lookup

Transform изменяет значение поля перед применением lookup. Например, обертка для LOWER():

from django.db.models import Transform, CharField, TextField

class LowerTransform(Transform):
    lookup_name = "lower"
    function = "LOWER"
    output_field = CharField()

CharField.register_lookup(LowerTransform)
TextField.register_lookup(LowerTransform)

# Использование: __lower__ + lookup
Product.objects.filter(name__lower__exact="iphone 15")
Product.objects.filter(name__lower__contains="iphone")


WHERE LOWER(name) = 'iphone 15'
WHERE LOWER(name) LIKE '%iphone%'

Регистрация lookup в конкретном поле

Вместо регистрации в базовом Field, можно на конкретном типе или поле модели:

from django.db.models import IntegerField

class DivisibleBy(Lookup):
    lookup_name = "divisible_by"

    def as_sql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        return f"MOD({lhs}, {rhs}) = 0", lhs_params + rhs_params

IntegerField.register_lookup(DivisibleBy)

# Продукты с четным stock
Product.objects.filter(stock__divisible_by=2)


WHERE MOD(stock, 2) = 0

Где регистрировать lookups

Регистрацию удобно делать в apps.py при запуске приложения:

# shop/apps.py
from django.apps import AppConfig

class ShopConfig(AppConfig):
    name = "shop"

    def ready(self):
        from .lookups import register_lookups
        register_lookups()


# shop/lookups.py
from django.db.models import Field, CharField, IntegerField
from .custom_lookups import NotNullLookup, SimilarToLookup, DivisibleBy

def register_lookups():
    Field.register_lookup(NotNullLookup)
    CharField.register_lookup(SimilarToLookup)
    IntegerField.register_lookup(DivisibleBy)

Django 6.0: params как tuple

В Django 6.0 изменилось поведение Func: параметры теперь ожидаются как tuple, а не list. При создании кастомных Func и Lookup учитывайте это:

# Django < 6.0
def as_sql(self, compiler, connection):
    return "SOME_FUNC(%s)", [param]  # list OK

# Django 6.0+
def as_sql(self, compiler, connection):
    return "SOME_FUNC(%s)", (param,)  # tuple предпочтительнее
    # list тоже работает, но предпочтителен tuple

Пример: полный поиск с кастомным lookup

Объединим несколько техник для удобного поискового интерфейса:

# shop/lookups.py
from django.db.models import Lookup, CharField, TextField
from django.contrib.postgres.search import SearchQuery, SearchVector

class FtsLookup(Lookup):
    """Full-text search lookup для PostgreSQL."""
    lookup_name = "fts"

    def as_sql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        # lhs - это SearchVectorField
        # rhs - поисковый запрос
        return f"{lhs} @@ plainto_tsquery('russian', {rhs})", lhs_params + rhs_params

# После регистрации:
# Product.objects.filter(search_vector__fts="беспроводные наушники")

Практическое задание

  1. Создайте Func обертку для PostgreSQL функции age(timestamp) которая возвращает возраст (интервал). Используйте её для аннотации заказов полем order_age, время с момента создания.

  2. Создайте custom lookup __between для числовых полей: Product.objects.filter(price__between=(100, 500)). SQL: WHERE price BETWEEN 100 AND 500.

  3. Создайте Transform __month для полей дата/время: Order.objects.filter(created_at__month=12). SQL: WHERE EXTRACT(MONTH FROM created_at) = 12.

  4. Создайте lookup __array_contains для IntegerField: принимает список id и проверяет что поле входит в массив. Это должно генерировать WHERE id = ANY(ARRAY[1, 2, 3]).

  5. Зарегистрируйте свои lookups в apps.py через ready(). Напишите тест который проверяет что Product.objects.filter(price__between=(100, 500)) генерирует правильный SQL.


Возможные ошибки

Func без output_field для сложных выражений

# Может вызвать FieldError при использовании в annotate с другими выражениями
class MyFunc(Func):
    function = "MY_FUNC"
    # нет output_field

# Правильно
class MyFunc(Func):
    function = "MY_FUNC"
    output_field = DecimalField(max_digits=10, decimal_places=2)

Lookup с SQL injection через lhs

# Неправильно, lhs уже содержит имя столбца от Django, не нужно форматировать
class BadLookup(Lookup):
    def as_sql(self, compiler, connection):
        field_name = self.lhs.target.column  # имя столбца
        rhs = self.rhs  # значение
        return f"{field_name} CUSTOM_OP '{rhs}'", []  # SQL injection если rhs от пользователя!

# Правильно, использовать process_lhs/process_rhs которые параметризуют значения
class GoodLookup(Lookup):
    def as_sql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        return f"{lhs} CUSTOM_OP {rhs}", lhs_params + rhs_params

Регистрация lookup в неподходящем месте

# models.py выполняется при импорте, но не в ready()
# Может вызвать AppRegistryNotReady в некоторых конфигурациях
CharField.register_lookup(MyLookup)  # в верхнем уровне models.py

# Правильно в AppConfig.ready()
class ShopConfig(AppConfig):
    def ready(self):
        CharField.register_lookup(MyLookup)

Связь со следующим уроком

В уроке 8 разберем кастомные менеджеры и классы QuerySet, как выносить бизнес-логику запросов в менеджер, как реализовать soft delete и как сделать кастомные методы цепочки QuerySet доступными через менеджер.


<< Урок 7.2

Урок 8 >>


Подписывайтесь на мой Telegram канал

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

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы