Django ORM — это инструмент фреймворка Django, который позволяет взаимодействовать с базами данных, используя высокоуровневые методы Python, а не SQL-запросы. Он относится к типу ORM, который реализует шаблон Active Record. Общая суть шаблона в том, что каждой таблице в приложении соответствует одна модель.
В этой статье мы рассмотрим несколько интересных фич Django ORM, которые помогают эффективно управлять данными в проектах Django.
Содержание:
1. Основы Django ORM
2. Плюсы и минусы Django ORM
3. Проверка запросов
4. Фильтрация данных
5. QuerySet – результаты в виде именованного кортежа
6. Пользовательские функции ORM Django
7. Ограничение времени выполнения запроса
8. Установка лимита
9. Использование кэшированных внешних ключей
10. Использование select_related
11. Индексы внешних ключей
12. Порядок столбцов в составном индексе
13. BRIN-индексы
Заключение
Вот пример модели пользователя:
from django.db import models class User(models.Model): email = models.EmailField(unique=True) nickname = models.CharField(max_length=100, null=True)
Каждый объект соответствует записи в таблице. ORM отвечает за преобразование табличных данных в объекты и обратно. Разработчику остается только выбирать подходящие методы.
Например, можно найти пользователя по идентификатору:
user = User.objects.get(id=2)
Или обновить его никнейм:
user.nickname = 'High and load'
Изменения сохраняются в базе данных после использования метода save
:
user.save()
Можно посчитать количество пользователей в БД:
User.objects.count()
Главный плюс Django ORM — существенное упрощение запросов. Это возможно благодаря Query Builder — абстракции поверх SQL.
Сравните:
users = User.objects.order_by('email')[:20]
С SQL-запросом:
SELECT "user"."id", ... FROM "user" ORDER BY "user"."email" ASC LIMIT 20
В Django ORM существует возможность делать SQL-запросы напрямую. Однако это лучше оставить для тех крайних случаев, с которыми Query Builder не справляется.
Главные плюсы использования Django ORM — миграция и транзакции.
Недостатки у Django ORM тоже есть. Главным источником проблем становится чрезмерная простота инструмента. Разработчикам не обязательно знать, какие SQL-запросы генерируются. Из-за этого может значительно увеличиваться нагрузка на сервер.
Чтобы грамотно пользоваться возможностями Django ORM, нужно понимать, что происходит «под капотом». Сделать это можно несколькими способами.
Если в настройках Django-проекта установлен параметр debug = True
, то можно смотреть выполненные запросы с помощью connection.queries
. Пример:
from django.db import connection ost.objects.all() connection.queries [ { 'sql': 'SELECT "blogposts_post"."id", "blogposts_post"."title", ' '"blogposts_post"."content", "blogposts_post"."blog_id", ' '"blogposts_post"."published" FROM "blogposts_post" LIMIT 21', 'time': '0.000' } ]
SQL-запросы отображаются в виде словарей с указанием кода и затраченного времени. При постоянной проверке количество словарей может стать очень большим. Исправить это можно очисткой:
from django.db import reset_queries reset_queries()
Shell_plus
— одна из функций библиотеки расширений для Django. При вызове с параметром —print-sql
она отображает SQL-запросы по мере их выполнения. Например:
manage.py shell_plus --print-sql post = Post.objects.get(id=1) SELECT "blogposts_post"."id", "blogposts_post"."title", "blogposts_post"."content", "blogposts_post"."blog_id", "blogposts_post"."published" FROM "blogposts_post" ORDER BY "blogposts_post"."id" ASC LIMIT 1
Silk — инструмент профилирования Django. Он записывает и визуализирует выполненные SQL-запросы. Это позволяет разработчику видеть, какие запросы отработали, изучать подробности по каждому обращению к БД, в том числе контролировать, какая строка кода инициировала запрос.
Debug Toolbar добавляет в браузер отладочную панель. Она предоставляет много возможностей для проверки проекта и исправления ошибок, в том числе отображает выполненные SQL-запросы. Можно проверить каждый запрос, посмотреть порядок их выполнения, а также затраченное время (профилирование).
Аргумент filter
появился еще в версии Django 2.0. Это заметно упростило получение данных по нескольким условиям. Например, вот так просто можно увидеть общее количество пользователей и общее количество активных пользователей.
from django.contrib.auth.models import User from django.db.models import Count, F User.objects.aggregate( total_users=Count('id'), total_active_users=Count('id', filter=F('is_active')), )
Сравните, насколько больше кода нужно написать для решения той же задачи без использования filter
:
from django.contrib.auth.models import User from django.db.models import ( Count, Sum, Case, When, Value, IntegerField, ) User.objects.aggregate( total_users=Count('id'), total_active_users=Sum(Case( When(is_active=True, then=Value(1)), default=Value(0), output_field=IntegerField(), )), )
В PostgreSQL та же операция выглядит следующим образом:
SELECT COUNT(id) AS total_users, SUM(CASE WHEN is_active THEN 1 ELSE 0 END) AS total_active_users FROM auth_users; SELECT COUNT(id) AS total_users, COUNT(id) FILTER (WHERE is_active) AS total_active_users FROM auth_users;
Ничего сложного, но с ORM все равно намного удобнее.
Еще один полезный атрибут — named
. Если он равен True
, то QuerySet
отображается в виде именованного кортежа:
user.objects.values_list( 'first_name', 'last_name', )[0] (‘High’, ‘Load’) user_names = User.objects.values_list( 'first_name', 'last_name', named=True, ) user_names[0] Row(first_name='High', last_name='Load') user_names[0].first_name 'High' user_names[0].last_name 'Load'
В Django ORM доступно добавление пользовательских функций. Это помогает расширить его возможности и не дожидаться обновлений от вендоров баз данных.
Например, нужно найти среднюю продолжительность. Сделать это легко:
from django.db.models import Avg Report.objects.aggregate(avg_duration=Avg('duration')) {'avg_duration': datetime.timedelta(0, 0, 55432)}
Но само по себе среднее значение ничего не дает. Допустим, для анализа требуется еще среднеквадратичное отклонение:
from django.db.models import Avg, StdDev Report.objects.aggregate( avg_duration=Avg('duration'), std_duration=StdDev('duration'), ) ProgrammingError: function stddev_pop(interval) does not exist LINE 1: SELECT STDDEV_POP("report"."duration") AS "std_dura... HINT: No function matches the given name and argument types. You might need to add explicit type casts.
Здесь PostgreSQL
сообщает об ошибке. Stddev
не поддерживается на поле типа interval
. Сначала нужно привести interval
к числу. Можно сделать это с помощью функции Extract
:
SELECT AVG(duration), STDDEV_POP(EXTRACT(EPOCH FROM duration)) FROM report; avg | stddev_pop ----------------+------------------ 00:00:00.55432 | 1.06310113695549 (1 row)
То же самое можно реализовать в Django с помощью пользовательских функций:
# common/db.py from django.db.models import Func class Epoch(Func): function = 'EXTRACT' template = "%(function)s('epoch' from %(expressions)s)"
В итоге функция для определения среднеарифметического значения и среднеквадратичного отклонения будет выглядеть так:
from django.db.models import Avg, StdDev, F from common.db import Epoch Report.objects.aggregate( avg_duration=Avg('duration'), std_duration=StdDev(Epoch(F('duration'))), ) {'avg_duration': datetime.timedelta(0, 0, 55432), 'std_duration': 1.06310113695549}
В Django используются синхронные процессы. Пока пользователь выполняет длительную операцию, рабочий процесс приостанавливается. В большинстве случаев время тратится на запросы к БД. Поэтому будет нелишним ограничить время выполнения запросов.
Например, вот так устанавливается глобальный таймаут:
# wsgi.py from django.db.backends.signals import connection_created from django.dispatch import receiver @receiver(connection_created) def setup_postgres(connection, **kwargs): if connection.vendor != 'postgresql': return # Таймаут через 40 секунд. with connection.cursor() as cursor: cursor.execute(""" SET statement_timeout TO 40000; """)
Используется файл wsgi.py
, чтобы ограничить только рабочие процессы.
Таймаут также можно настроить на уровне пользователя:
postgresql=#> alter user app_user set statement_timeout TO 40000; ALTER ROLE
Еще одна хорошая практика — устанавливать таймаут на вызов удаленной службы, чтобы не ждать бесконечно ответа:
import requests response = requests.get( 'https://api.very-slow.com', timeout=4000, )
Еще один способ оптимизации запросов — установка лимитов.
Например, пользователю нужно получить список всех продаж с начала работы компании. Разработчик понимает, что такая ситуация может случиться, и поэтому устанавливает лимит: не более 100 записей.
Простой способ:
data = Sale.objects.all()[:100]
Оператор limit
гарантирует, что пользователь получит только 100 записей. Но здесь возникает проблема. Пользователь хотел получить все записи. Получил 100. Он может подумать, что в базе данных всего 100 записей. Но что если это не так? Как об этом сообщить пользователю?
Логичное решение — выбрасывать исключение, если записей больше, чем разрешено лимитом:
LIMIT = 100 if Sales.objects.count() > LIMIT: raise ExceededLimit(LIMIT) return Sale.objects.all()[:LIMIT]
Можно сделать еще удобнее.
LIMIT = 100 data = Sale.objects.all()[:(LIMIT + 1)] if len(data) > LIMIT: raise ExceededLimit(LIMIT) return data
Вместо того чтобы запрашивать первые 100 записей, код запрашивает 100 + 1 запись. Если есть запись 101, то всего записей уже точно больше 100. Значит, будет выброшено исключение.
Если необходимо получить идентификатор внешнего ключа, можно использовать кэшированный ID с помощью <field_name>_id
.
Например, пусть будет такой запрос:
Post.objects.first().blog.id
Вот какие SQL-запросы выполняются:
SELECT "blogposts_post"."id", "blogposts_post"."title", "blogposts_post"."content", "blogposts_post"."blog_id", "blogposts_post"."published" FROM "blogposts_post" ORDER BY "blogposts_post"."id" ASC LIMIT 1 Execution time: 0.001668s [Database: default] SELECT "blogposts_blog"."id", "blogposts_blog"."name", "blogposts_blog"."url" FROM "blogposts_blog" WHERE "blogposts_blog"."id" = 1 LIMIT 21 Execution time: 0.000197s [Database: default]
После обращения к id
объекта blog
создается еще один запрос, который возвращает весь объект blog
. Но если не требуется доступ к другим атрибутам объекта blog
, то и возвращать его целиком нет смысла.
Использование кэшированного id
:
Post.objects.first().blog_id
При таком вызове будет на один запрос меньше:
SELECT "blogposts_post"."id", "blogposts_post"."title", "blogposts_post"."content", "blogposts_post"."blog_id", "blogposts_post"."published" FROM "blogposts_post" ORDER BY "blogposts_post"."id" ASC LIMIT 1 Execution time: 0.000165s [Database: default]
Еще одна хорошая практика — заранее рассказывать Django, что нужно делать. Например, для этого есть функция select_related
. Она позволяет точно указать, какие связанные модели потребуются, чтобы Django мог выполнить JOIN
.
Например, есть модель Post
. Каждый Post
принадлежит определенному Blog, а отношения эти выражены в базе данных через внешние ключи.
Допустим, нужно получить определенный Post
:
post = Post.objects.get(id=1)
Выполненные запросы:
SELECT "blogposts_post"."id", "blogposts_post"."title", "blogposts_post"."content", "blogposts_post"."blog_id", "blogposts_post"."published" FROM "blogposts_post" ORDER BY "blogposts_post"."id" ASC LIMIT 1
А теперь нужно получить доступ к Blog
из Post
:
post.blog
Выполненные запросы:
SELECT "blogposts_blog"."id", "blogposts_blog"."name", "blogposts_blog"."url" FROM "blogposts_blog" WHERE "blogposts_blog"."id" = 1 LIMIT 21 Execution time: 0.000602s [Database: default] <Blog: Rocio's Blog>
Чтобы получить информацию из Blog
, ORM выполнил новый запрос. Этого можно избежать, используя select_related
:
post = Post.objects.select_related("blog").get(id=1)
Выполненные запросы:
SELECT "blogposts_post"."id", "blogposts_post"."title", "blogposts_post"."content", "blogposts_post"."blog_id", "blogposts_post"."published", "blogposts_blog"."id", "blogposts_blog"."name", "blogposts_blog"."url" FROM "blogposts_post" INNER JOIN "blogposts_blog" ON ("blogposts_post"."blog_id" = "blogposts_blog"."id") WHERE "blogposts_post"."id" = 1 LIMIT 21 Execution time: 0.000150s [Database: default]
Кроме того, что количество запросов уменьшилось, было выполнено кэширование. Дополнительный запрос теперь не нужен. Функция select_related
работает также для набора запросов. Не нужно каждый раз обращаться к базе данных, чтобы проверить связь — достаточно сделать это один раз.
Django создает B-Tree индексы для внешних ключей в модели. Они не всегда нужны и при этом занимают много места. Типичный пример — модель с отношением многие-ко-многим:
class Membership(Model): group = ForeignKey(Group) user = ForeignKey(User)
Здесь будет два внешних индекса — для user
и group
.
Еще одна стандартная ситуация — уникальные ограничения. Например, один пользователь может быть членом одной группы только один раз:
class Membership(Model): group = ForeignKey(Group) user = ForeignKey(User) class Meta: unique_together = ( 'group', 'user', )
Unique_together
создает индекс для обоих полей: group
и user
. В итоге есть одна модель, два поля и целых три индекса.
Если ситуация позволяет, можно отбросить ненужные индексы внешних ключей и оставить только тот индекс, который был создан для уникального ограничителя:
class Membership(Model): group = ForeignKey(Group, db_index=False) user = ForeignKey(User, db_index=False) class Meta: unique_together = ( 'group', 'user', )
Цель этих манипуляций — оптимизация. Без лишних индексов вставка и обновление данных будут проходить быстрее, а база данных будет весить меньше.
Если в индексе более одного столбца, то он называется составным. В таких индексах первый столбец индексируется с использованием древовидной структуры. Листья первого уровня формируют деревья второго уровня и так далее.
Порядок столбцов важен. В примере выше сначала создавалось бы дерево для групп и только затем — для пользователей. Скорее всего, пользователей будет больше, чем групп. Поэтому столбец с ними должен быть выше.
class Membership(Model): group = ForeignKey(Group, db_index=False) user = ForeignKey(User, db_index=False) class Meta: unique_together = ( 'user', 'group', )
Секрет в том, чтобы делать вторичные индексы меньше. Чем больше значений в столбце, тем выше он должен быть. Но это не строгое правило, а лишь совет, который заставляет задуматься об оптимизации.
Основная проблема индексов B-Tree
в том, что они занимают много места. Выше мы рассмотрели, как можно их оптимизировать. Но есть и альтернативные способы — например, в PostgreSQL можно использовать BRIN (Block Range Index). В некоторых случаях этот тип индексов эффективнее, чем B-Tree
.
BRIN
подходит для обработки огромных таблиц. Важно, чтобы значение индексируемого столбца имело естественную корреляцию с физическим расположением строки в таблице.
Фактически BRIN
создает мини-индекс, используя ряд соседних блоков в таблице.Каждый такой мини-индекс может сказать, находится определенное значение в диапазоне блоков или нет.
Например, есть девять блоков:
1, 2, 3, 4, 5, 6, 7, 8, 9
Их можно объединить по три:
[1,2,3], [4,5,6], [7,8,9]
Для простоты в каждом диапазоне будет храниться минимальное и максимальное значения:
[1–3], [4–6], [7–9]
Например, нужно найти блок 8. Процесс будет выглядеть следующим образом:
[1–3]
— здесь такого точно нет;
[4–6]
— здесь такого точно нет;
[7–9]
— возможно, здесь.
Благодаря такому разделению поиск ограничивается одним диапазоном.
Но все ломается, если значения не отсортированы. Например,
[2,8, 4], [3,5,9], [1,7,6]
Диапазоны с минимальными и максимальными значениями будут выглядеть так:
[2–8], [3–9], [1–7]
Например, нужно найти блок 5.
[2–8]
— возможно, здесь;
[3–9]
— возможно, здесь;
[1–7]
— возможно, здесь.
Разделение на диапазоны становится не только бесполезным, но и вредным. Одну и ту же работу приходится выполнять несколько раз.
Для максимально полезного использования BRIN
данные должны быть отсортированы или сгруппированы. Например, можно использовать поле auto_now_add
:
class SomeModel(Model): created = DatetimeField( auto_now_add=True, )
Теперь при добавлении данных Django будет автоматически записывать время. Это значит, что можно использовать BRIN
-индекс.
from django.contrib.postgres.indexes import BrinIndex class SomeModel(Model): created = DatetimeField( auto_now_add=True, ) class Meta: indexes = ( BrinIndex(fields=['created']), )
При небольшом количестве записей разница в размере БД будет незаметной. Но если добавить в таблицу два миллиона записей и отсортировать их по дате, то разница будет значительная:
B-Tree
-индекс — 37 MB
BRIN
-индекс — 49 KB
В этой статье мы разобрали основы, плюсы и минусы Django ORM, научились проверять запросы, а также познакомились с некоторыми полезными функциями.
Чтобы закрепить материал и узнать еще больше о работе с Django ORM, посмотрите этот часовой видеокурс:
Прокси (proxy), или прокси-сервер — это программа-посредник, которая обеспечивает соединение между пользователем и интернет-ресурсом. Принцип…
Согласитесь, было бы неплохо соединить в одно сайт и приложение для смартфона. Если вы еще…
Повсеместное распространение смартфонов привело к огромному спросу на мобильные игры и приложения. Миллиарды пользователей гаджетов…
В перечне популярных чат-ботов с искусственным интеллектом Google Bard (Gemini) еще не пользуется такой популярностью…
Скрипт (англ. — сценарий), — это небольшая программа, как правило, для веб-интерфейса, выполняющая определенную задачу.…
Дедлайн (от англ. deadline — «крайний срок») — это конечная дата стачи проекта или задачи…