API Django ORM прекрасен и удобен в 90% случаев. Кроме того, его можно расширять кастомными QuerySet и Manager'ами. Но когда встает необходимость составить SQL-запрос, к которому Django не приспособлен, ORM оказывается не помощником, а препятствием. К счастью, Django любезно уступает дорогу, позволяя нам писать сырой SQL.
Один из видов запросов, которые Django не позволяет выполнить - это фильтр по оконной функции (window function). Его можно получить, сделав SELECT из подзапроса, что Django не поддерживает.
В этом посте я опишу проблему и покажу, как можно сделать сырой SELECT из готового QuerySet.
Весь код был протестирован на Django 3.1.
Если у нас есть QuerySet с оконной функцией:
from django.db import models
from django.db.models.functions import window
from catalog.models import Product
products = Product.objects.all().annotate(row_number=models.Window(
expression=window.RowNumber(),
partition_by=[models.F('category_id')],
order_by=[models.F('price').asc()],
)).order_by('price')
Мы можем выполнить фильтр по ней так:
sql, params = products.query.sql_with_params()
count_per_category = 3
count = 10
products_filtered = Product.objects.raw("""
SELECT * FROM ({}) products_with_row_numbers
WHERE row_number <= %s
LIMIT %s
""".format(sql),
[*params, count_per_category, count],
)
selected_products = list(products_filtered)
Далее подробно.
Допустим в нашем интернет-магазине есть модель категории:
class Category(models.Model):
name = models.CharField(max_length=255)
И модель товара:
class Product(models.Model):
category = models.ForeignKey(Category, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
price = models.DecimalField(max_digits=12, decimal_places=2)
И перед нами стоит стоит задача сделать выборку из 10 самых недорогих товаров магазина. Не вопрос! Такой запрос можно составить в два счета:
products = Product.objects.all().order_by('price')[:10]
Но вот загвоздка: выборка должна представлять весь разнообразный ассортимент магазина. Будет не хорошо, если все 10 полученных товаров будут из категории аксессуаров, нам нужно показать, какие у нас продаются смартфоны и планшеты. Поэтому перед нами ставят еще одно условие: в полученной выборке должно быть не больше 3 товаров из одной и той же категории.
Если вы используете подходящую СУБД, вы можете решить эту задачу с помощью оконной функции. Оконные функции - полезная вещь, но объяснение их работы не входит в тему в этого поста, поэтому я просто продемонстрирую, что получилось:
from django.db import models
from django.db.models.functions import window
products = Product.objects.all().annotate(row_number=models.Window(
expression=window.RowNumber(),
partition_by=[models.F('category_id')],
order_by=[models.F('price').asc()],
)).order_by('price')
На PostgreSQL этот запрос выглядит так:
SELECT
"catalog_product"."id",
"catalog_product"."category_id",
"catalog_product"."name",
"catalog_product"."price",
ROW_NUMBER() OVER (
PARTITION BY "catalog_product"."category_id"
ORDER BY "catalog_product"."price" ASC
) AS "row_number"
FROM
"catalog_product"
ORDER BY
"catalog_product"."price" ASC
С помощью этого запроса мы дописываем в каждый товар атрибут row_number
- это порядковый номер товара среди самых дешевых товаров своей категории.
То есть у самого первого смартфона в списке row_number
будет равен 1. У следующего - равен 2. В то же время у первого планшета row_number
тоже будет 1, у второго - 2, и т.д.
Кажется, что проблема решена, да? Осталось только добавить фильтр по этому полю, чтобы в выборку не попадали 4-е, 5-е и так далее товары. Наивно и самонадеянно мы выполняем запрос:
products = products.filter(row_number__lte=3)
...и получаем исключение:
django.db.utils.NotSupportedError: Window is disallowed in the filter clause.
Оказывается, ни Django, ни даже PostgreSQL не позволяют фильтровать строки по результату оконной функции!
Однако в PostgreSQL можно обойти это ограничение, обернув запрос в новый SELECT:
SELECT *
FROM
(
SELECT
"catalog_product"."id",
"catalog_product"."category_id",
"catalog_product"."name",
"catalog_product"."price",
ROW_NUMBER() OVER (
PARTITION BY "catalog_product"."category_id"
ORDER BY "catalog_product"."price" ASC
) AS "row_number"
FROM
"catalog_product"
ORDER BY
"catalog_product"."price" ASC
) subquery
WHERE subquery.row_number < 3
Обратите внимание, что мы вычисляем колонку row_number
во внутреннем запросе, а фильтр по этой колонке добавляем во внешний запрос.
Как теперь составить подобный запрос на Django? К сожалению, никак. Точно не средствами ORM. Мне было бы интересно увидеть, как такое можно провернуть без сырого SQL, но я не знаю такого способа.
Django предоставляет Subquery
, но мы не можем использовать его тут. Ведь нам придется дописать во внутренний запрос .filter(pk=OuterRef('pk'))
, и тогда все row_number
приравняются к единице, потому что в каждом подзапросе будет одна строка.
И вот тут перед нам встает необходимость использовать сырой SQL. При этом мы бы не хотели писать запрос на SQL целиком, ведь так мы не сможем интегрировать другие QuerySet в этот запрос, не редактируя SQL.
Если вы раньше отлаживали запросы Django, возможно вы узнали про атрибут QuerySet.query
, когда пытались напечатать SQL в консоль:
print(products.query)
Получается, мы можем взять значение этого атрибута и обернуть в свой SQL, да? Нет, этого делать не стоит.
QuerySet.query
- это объект django.db.models.sql.Query
. Печатая его, мы преобразуем его в строку. Смотрим документацию метода Query.__str__
:
def __str__(self):
"""
Return the query as a string of SQL with the parameter values
substituted in (use sql_with_params() to see the unsubstituted string).
Parameter values won't necessarily be quoted correctly, since that is
done by the database interface at execution time.
"""
То есть str(products.query)
не всегда возвращает валидный SQL.
Вместо этого следует использовать метод products.query.sql_with_params
. Он возвращает текст SQL отдельно от списка его параметров:
sql, params = products.query.sql_with_params()
Мы можем использовать текст SQL для формирования своего запроса, а параметры передать Django во время его выполнения.
products_filtered = Product.objects.raw("""
SELECT * FROM ({}) products_with_row_numbers
WHERE row_number <= 3
LIMIT 10
""".format(sql),
params,
)
3 и 10 можно вынести в параметры нашего запроса, важно только положить их в правильном порядке после params
:
count_per_category = 3
count = 10
products_filtered = Product.objects.raw("""
SELECT * FROM ({}) products_with_row_numbers
WHERE row_number <= %s
LIMIT %s
""".format(sql),
[*params, count_per_category, count],
)
Обратите внимание, что подзапрос мы вставляем в наш SQL с помощью маркера {}
и метода str.format
(потому что это не параметр запроса, а часть его текста), а параметры записываем с помощью %s
, как этого требует Django.
На этом все. products_filtered
будет содержать перечисление из товаров, подходящих нашим критериям.
Полный код выглядит так:
from django.db import models
from django.db.models.functions import window
from catalog.models import Product
products = Product.objects.all().annotate(row_number=models.Window(
expression=window.RowNumber(),
partition_by=[models.F('category_id')],
order_by=[models.F('price').asc()],
)).order_by('price')
sql, params = products.query.sql_with_params()
count_per_category = 3
count = 10
products_filtered = Product.objects.raw("""
SELECT * FROM ({}) products_with_row_numbers
WHERE row_number <= %s
LIMIT %s
""".format(sql),
[*params, count_per_category, count],
)
selected_products = list(products_filtered)
Возможно, вы обратили внимание, что в этом запросе мы указываем порядок товаров дважды: в QuerySet с помощью метода order_by
, и в оконной функции в параметре order_by
:
products = Product.objects.all().annotate(row_number=models.Window(
expression=window.RowNumber(),
partition_by=[models.F('category_id')],
order_by=[models.F('price').asc()],
)).order_by('price')
Но что если мы получили QuerySet products
на вход, и мы не знаем, какой ему был задан порядок? При этом мы хотим, чтобы оконная функция соблюдала его.
Это реализуемо. Порядок QuerySet'а записывается в атрибут QuerySet.query.order_by
- это тюпл из строк, которые мы передали методу QuerySet.order_by
:
products = Product.objects.all().order_by('price')
print(products.query.order_by) # ('price',)
products = Product.objects.all().order_by('-price', 'name')
print(products.query.order_by) # ('-price', 'name')
products = Product.objects.all()
# пустой тюпл - порядок не задан
print(products.query.order_by) # ()
Нужно преобразовать этот тюпл в список, который поддерживает models.Window
. Важно учесть разницу в нотации: убывающий порядок в query.order_by
записывается как '-price'
, но в models.Window
нам надо передать models.F('price').desc()
- без дефиса.
Код преобразования выглядит так:
order_by = []
for field in products.query.order_by:
if field.startswith('-'):
# Убывающий порядок
desc = True
field = field[1:] # убираем дефис
else:
# Возрастающий порядок
desc = False
order_field = models.F(field)
if desc:
order_field = order_field.desc()
else:
order_field = order_field.asc()
order_by.append(order_field)
Получившийся список можно передать в models.Window
:
products = products.annotate(row_number=models.Window(
expression=window.RowNumber(),
partition_by=[models.F('category_id')],
order_by=order_by,
))