Custom lookups и функции | Курс Django ORM урок 7.3
Цель урока
Научиться создавать собственные 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="беспроводные наушники")
Практическое задание
-
Создайте
Funcобертку для PostgreSQL функцииage(timestamp)которая возвращает возраст (интервал). Используйте её для аннотации заказов полемorder_age, время с момента создания. -
Создайте custom lookup
__betweenдля числовых полей:Product.objects.filter(price__between=(100, 500)). SQL:WHERE price BETWEEN 100 AND 500. -
Создайте
Transform__monthдля полей дата/время:Order.objects.filter(created_at__month=12). SQL:WHERE EXTRACT(MONTH FROM created_at) = 12. -
Создайте lookup
__array_containsдляIntegerField: принимает список id и проверяет что поле входит в массив. Это должно генерироватьWHERE id = ANY(ARRAY[1, 2, 3]). -
Зарегистрируйте свои 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 доступными через менеджер.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru