Отримуємо максимум від Django ORM

20 хв. читання

Найперше дамо визначення: ORM (Object-Relational Mapping) — об'єктно-реляційне відображення, яке суттєво допомагає у роботі з базами даних. Django ORM передбачає інтерфейс Python для роботи з даними в БД. Ми отримуємо дві основні можливості:

  1. Спрощення налаштувань та підтримки структури БД — завдяки визначеним моделям та міграціям.
  2. Легше створення запитів до БД — завдяки менеджерам та queryset.

За що Django ORM не відповідає, то це за забезпечення інтерфейсу для написання власного користувацького SQL. Інтерфейс базується на визначеній вами моделі. З таким підходом дійсно легше почати, але з іншого боку — з ORM деякі запити стануть складніші в реалізації чи навіть неможливі.

Тестові моделі даних

У статті ми використовуватимемо такі моделі:

from django import models
class Customer(models.Model):
    name = models.CharField(...)
class Product(models.Model):
    name = models.CharField(...)
    price = models.DecimalField(...)
class Order(models.Model):
    customer = models.ForeignKey(Customer, ...)
    created_at = models.DateTimeField()
    is_shipped = models.BooleanField()
class OrderLine(models.Model):
    order = models.ForeignKey(Order, ...)
    product = models.ForeignKey(Product, ...)
    gross_amount = models.DecimalField(...)
class SalesTarget(models.Model):
    year = models.IntegerField()
    month = models.IntegerField()
    target = models.DecimalField(...)

Користувацькі менеджери та QuerySet

В реальних проектах для моделей даних дуже часто використовуються користувацькі менеджери та QuerySet. З ними ви можете зберігати логіку для повторного використання. Наприклад, ми могли б додати метод, який повертає перелік невідправлених замовлень, до нашого OrderQuerySet.

class OrderQuerySet(models.QuerySet):
    def unshipped(self):
        return self.filter(is_shipped=False)

У той же ж спосіб ми можемо створити користувацького менеджера, наприклад, з хелпер-методом, щоб спростити створення нових замовлень:

class OrderManager(models.Manager):
    def create(self, *, products, **kwargs):
        order = super().create(**kwargs)
        for product in products:
            ...
        return order

Щоб встановити менеджера за замовчуванням для моделі Order, ми встановлюємо атрибут objects:

class Order(models.Model):
    ...
    objects = OrderManager.from_queryset(OrderQuerySet)()

Перевірка QuerySet

Припустимо, ви не можете зрозуміти, чому певний QuerySet повертає неочікувані значення. В таких випадках ви можете відкрити консоль та перевірити запит до БД. Тут у вас можуть виникнути запитання: як побачити SQL-запити, згенеровані певним QuerySet, та як запустити EXPLAIN запит в БД:

>>> orders = Order.objects.all()
>>> str(orders.query)
SELECT ... FROM "orders_order"
>>> print(order.explain(verbose=True))
Seq Scan on public.orders_order  (cost=0.00..28.10 rows=1810 width=17)\u2028  Output: id, customer_id, created_at, is_shipped

Ви також можете отримати доступ до обгортки підімкнення до БД в Django та оглянути останні запити, запущені під час поточного з'єднання:

from django.db import connection
connection.queries

Так ви отримаєте перелік виконаних SQL-запитів і час виконання для кожного з них.

Уникаємо зайвих запитів

Під час використання Django ORM легко опинитися в ситуації, коли ваші представлення генерують надмірну кількість запитів. Якщо у вас є відповідна модель та права доступу, то для кожного екземпляру в QuerySet при кожному запиті за замовчуванням буде отримано модель:

for order in Order.objects.all():
    # Тут запускається один запит на кожен order
    print(order.customer.name)
    # І тут
    for line in order.lines.all():
        print(line)

Щоб уникнути такої проблеми, ми можемо використати select_related та prefetch_related. Окрім схожості в назвах, функції подібні за своєю поведінкою.

Перша використовується для отримання об'єктів, коли один об'єкт пов'язаний із кожним рядком у БД і генерує запит у форматі JOIN, де всі пов'язані об'єкти об'єднуються в єдиному SQL-запиті.

Друга функція використовується у випадках, коли декілька об'єктів пов'язані з кожним рядком. Замість того щоб створювати JOIN одразу, всі об'єкти будуть витягнуті з вашого первинного QuerySet. Потім виконається другий запит, щоб отримати всі пов'язані об'єкти і об'єднати їх вже в коді Python, а не в БД. Тож, додавши два наведених рядки в QuerySet для Order, ми в результаті отримаємо два запити:

Order.objects.select_related('customer').prefetch_related('lines')

Лише пам'ятайте про те, що не варто займатись оптимізацією наосліп. Іноді швидше запустити два чи більше запитів, ніж один великий.

Уникаємо стану перегонів

Якщо в базі даних ви працюєте з об'єктами, які можуть бути модифіковані декількома конкурентними запитами, вам необхідно переконатися, що зміни застосуються в правильному порядку.

Наприклад, якщо ми зберігаємо кількість певного продукту в моделі, нам важливо переконатися, що ця кількість збільшується та зменшується коректно. Одне з рішень — зробити блокування на рядок в БД, коли ми отримуємо об'єкт з БД. Так ми можемо гарантувати, що інші запити не зможуть отримувати та модифікувати той самий рядок:

with transaction.atomic():
    product = (
        Product.objects
        .select_for_update()
        .get(id=1)
    )
    product.inventory -= 1
    product.save()

З погляду продуктивності це досить неоптимальне рішення, адже ми забороняємо доступ до рядка для всіх інших підключень БД, поки наша транзакція не завершиться. Тож майте це на увазі під час проектування ваших даних.

Підзапити

Підзапити корисні, коли вам треба отримати деякі дані з іншої таблиці (або іноді навіть з тієї ж таблиці), але у вас немає жодних пов'язаних полів в моделі Django. В такому випадку Django не дасть вам зробити join. Інший привід використовувати підзапити — коли звичайний join буде дуже повільним через кількість отримуваних даних.

У першому прикладі можна побачити, як анотувати одне значення з одного рядка в QuerySet. Тут ми додаємо анотацію до кожного об'єкта customer з часом розміщення останнього замовлення:

customers = Customer.objects.annotate(
  latest_order_time=Subquery(
    Order.objects.filter(
      customer=OuterRef('pk'),
    ).order_by(
      '-created_at'
    ).values(
      'created_at'
    )[:1]
  )
)
>>> customers.first().latest_order_time
1

В коді можна помітити два цікавих моменти: класи Subquery та OuterRef. Перший — це обгортка, яка приймає звичайний QuerySet та вбудовує його як підзапит в інший QuerySet. Клас OuterRef використовується для посилання на поля, які вбудовані ззовні нашого підзапиту. В прикладі ми фільтруємо замовлення відповідно до клієнта поточного рядка. Далі ми розподіляємо їх за датою, робимо вибірку лише колонки date та повертаємо перший результат.

Крім вибору певного значення з іншого QuerySet, ми також можемо перевірити наявність об'єкта, використовуючи клас Exists:

customers = Customer.objects.annotate(
  has_orders=Exists(
    Order.objects.filter(
      customer_id=OuterRef('pk'),
    )
  ),
).filter(
  has_orders=True,
)
>>> customers.first().has_orders
True

Якщо вам не потрібно робити вибірку, а лише відфільтрувати дані, то це, на жаль, не підтримується Django, тому призводить до уповільнення запитів. На щастя, вже є pull request з розв'язанням цієї проблеми, тому в майбутніх версіях Django така фіча, найімовірніше, підтримуватиметься.

У двох прикладах вище ми просто отримуємо одне значення з об'єкта в БД, однак ми також можемо виконувати більш складні запити з агрегованим результатом. Нижче приклад, як порахувати суму рядків, що відповідають умові, використовуючи підзапит:

budgets = SalesTarget.objects.annotate(
  gross_total_sales=Subquery(
    Order.objects.filter(
      created_at__year=OuterRef('year'),
      created_at__month=OuterRef('month'),
    ).values_list(
      ExtractYear('created_at'),
      ExtractMonth('created_at')
    ).annotate(
      gross_total=Sum('lines__gross_amount'),
    ).values_lists(
      'gross_total',
    )
  ),
)
>>> budgets.first().gross_total_sales
12.00

Перше, що ми робимо — фільтруємо набір рядків, які нас цікавлять. Потім використовуємо values_list, щоб вибрати лише значення року та місяця. Коли ми використаємо анотацію з агрегуючою функцією далі, обрані рядки будуть згруповані й унікальні для будь-якого відфільтрованого рядка. Потім ми отримуємо агреговане значення та анотуємо його для зовнішнього QuerySet.

Тут у нас не було проблем з генерацією запиту, використовуючи примітиви Django, доступні за замовчуванням. Але якщо б ми хотіли агрегувати суму певних рядків, де немає унікального критерію, за яким ми могли б все групувати? Ми не можемо використовувати aggregate всередині підзапиту, адже так ми одразу виконуємо запит до БД.

Насправді нам треба зробити один трюк, аби обійти ORM. Припустимо, у нас є така таблиця з замовленнями і ми хочемо підрахувати суму всіх продажів щосуботи й неділі:

 id | week_day | lines__gross_amount
  1 |        7 |                 7.5
  2 |        1 |                 2.5
  3 |        3 |                 2.0

У нас немає можливості тут згрупувати рядки, однак ми могли б спробувати викликати values_list. На жаль, так ми не отримаємо очікуваного результату:

targets = SalesTarget.objects.annotate(
  weekend_revenue=Subquery(
    Order.objects.filter(
      created_at__week_day__in=[7, 1],
    ).values_list(
      Sum('lines__gross_amount'),
    )
  ),
)
>>> targest.first().weekend_revenue
7.50 # Упс, це не те, що ми очікували

Все це не працює тому, що кожного разу, коли ми додаємо агрегацію в QuerySet з annotate, Django додає вираз group by. За замовчуванням він групує рядки за первинним ключем моделі QuerySet (в нашому прикладі це Order.pk). В результаті отримуємо суму першого замовлення.

Натомість нам треба використовувати функцію БД, яка не наслідується від класу Aggregate. Функція SQL SUM не доступна в Django ніяк, окрім як підклас Aggregate. Однак ми можемо відносно легко обійти таке обмеження, використовуючи клас Func напряму:

targets = SalesTargets.objects.annotate(
  weekend_revenue=Subquery(
    Order.objects.filter(
      created_at__week_day__in=[7, 1],
    ).values_list(
      Func(
        'lines__gross_amount',
        function='SUM',
      ),
    )
  ),
)
>>> targets.first().weekend_revenue
10.00

Хоч такий код має не дуже привабливий вигляд, та ми отримуємо очікуваний результат, тож варто звернути увагу на цей спосіб.

Користувацькі обмеження та індекси

Django тривалий час підтримував обмеження унікальності, але тільки якщо ви хотіли, щоб декілька полів були унікальними серед усіх рядків в таблиці:

class SalesTarget(Model):
  year = models.IntegerField()
  month = models.IntegerField()
  target = models.DecimalField(...)
  class Meta:
    unique_together = [('year', 'month'), ]

Також була можливість приєднувати додаткові індекси до певних колонок в таблиці, але, знову ж таки, лише у всій таблиці. Починаючи з Django 2.2, ми отримали більше контролю і над обмеженнями унікальності, і над створенням користувацьких індексів. Тепер ми можемо зробити так, що унікальне обмеження перевірятиме лише підмножину рядків таблиці.

class Order(Model):
  ...
  class Meta:
    constraints = [
      UniqueConstraint(
        name='limit_pending_orders',
        fields=['customer', 'is_shipped'],
        condition=Q(is_shipped=False),
      )
    ]

У цьому прикладі ми обмежуємо кожного клієнта тільки одним невідправленим замовленням. Хоч цей приклад може здатися відносно простим, умовні обмеження унікальності дають нам багато гнучких можливостей.

Припустимо, у нас є таблиця в БД, де ми хочемо дозволити мати значення NULL лише одному рядку. Оскільки це значення не еквівалентне NULL в SQL, кожен рядок розглядається як унікальний і ми не можемо контролювати це звичайним unique=True або unique_together. Однак ми можемо додати обмеження, яке перевірятиме унікальність поля, якщо воно набуває значення NULL.

Ми також можемо створити користувацьке обмеження. Ви могли помітити схожість з валідаторами для полів в Django, однак вони виконуються БД. Так ми можемо уникнути непередбачуваних багів в нашому Python-коді. Перевірки запускатимуться при кожному bulk_create та подібних, а також якщо ви отримуєте доступ до тієї ж БД з іншого, не Django, проекту. Наприклад, ми можемо створити обмеження, яке б перевіряло валідність значення місяця:

class SalesTarget(Model):
  ...
  class Meta:
    constraints = [
      CheckConstraint(
        check=Q(month__in=range(1, 13)),
        name='check_valid_month',
      )
    ]

Так само ми можемо створити користувацькі індекси, які охоплюють лише частину таблиці. Ця фіча може бути корисна, якщо в запиті ви звертаєтесь лише до підмножини таблиці, або якщо велика частина таблиці вміщує NULL-значення. В наших моделях з прикладу ми, найімовірніше, частіше будемо робити запити до невідправлених замовлень.

Наведемо приклад, як створити індекс, котрий поширюється лише на невідправлені замовлення. Так ми можемо значно пришвидшити найбільш використовувані запити до БД:

class Order(Model):
  ...
  class Meta:
    indexes = [
      Index(
        name='unshipped_orders',
        fields=['pk', ],
        condition=Q(is_shipped=False),
      )
    ]

Віконні функції

В Django 2.0 з'явилася можливість запуску запитів з віконними функціями, використовуючи Django ORM. Для цього генерується вираз OVER для перегляду розділів рядків. Така фіча може бути корисною, якщо нам треба знайти щось в попередньому замовленні клієнта, отримати значення часу, коли клієнт розмістив замовлення, або підрахувати суму замовлень того самого клієнта. Нижче наведемо приклад, як правильно анотувати QuerySet замовлень з id попереднього замовлення того самого клієнта:

orders = Order.objects.annotate(
  prev_order_id=Window(
    expression=Lag('order_id', 1),
    partition_by=[F('customer_id')],
    order_by=F('created_at').asc(),
  ),
)
>>> orders.first().prev_order_id
1

Ми використовуємо клас Window, щоб згенерувати вираз OVER, а клас Lag — щоб зробити вибірку з певного попереднього рядка (в нашому прикладі це перший попередній рядок, оскільки n набуває значення 1).

На жаль, через SQL-стандарт ми не можемо фільтрувати віконні функції. Це можна виправити за допомогою Загальних Табличних Виразів (CTE), однак поки такий функціонал не підтримується в Django.

Наслідування з користувацькими функціями БД

Іноді Django ORM обмежує ваші можливості в побудові запитів. Саме тому в більшості випадків ви можете додати власний функціонал, віднаслідувавшись від вбудованих примітивів. Якщо ви, наприклад, хочете використовувати користувацькі SQL-функції, що не передбачені в Django, віднаслідуйтесь від класу Func:

class Round(Func):
    function = 'ROUND'

Тепер ви можете використовувати таку функцію, подібно до інших функцій, передбачених Django.

Ми також можемо наслідуватися і для створення більш складних функцій. Припустимо, у нас є модель, з окремими колонками для дати та часу, однак нам треба порівняти ці значення зі значеннями з іншої таблиці, де ці колонки об'єднані в одну. Тут на допомогу приходить користувацька функція.

Немає загального способу для розв'язання такої проблеми для всіх БД, однак Django дає нам можливість реалізувати таку функцію окремо для кожної БД, яку ми хочемо підтримувати. Нижче наведемо реалізацію для PostgreSQL та Sqlite:

class AsDateTimeWithTZ(Func):
    arity = 2
    output_field = DateTimeField()
    def as_postgresql(self, compiler, connection, **extra_context):
        extra_context['tz'] = settings.TIME_ZONE
        template = "(%(expressions)s || ' %(tz)s')::timestamptz"
        return self.as_sql(
            compiler, connection, arg_joiner='+', template=template, **extra_context
        )
    def as_sqlite(self, compiler, connection):
        template = "datetime(%(expressions)s, 'utc')"
        return self.as_sql(compiler, connection, arg_joiner=',', template=template)

Тепер ми можемо використовувати цю функцію, щоб анотувати datetime в QuerySet:

qs.annotate(
  datetime=AsDateTime('date_field', 'time_field'),
)

Запускаємо користувацький SQL

Якщо те, що ми хочемо зробити, виходить за межі функціоналу Django, можемо написати власний SQL-запит. Django дає декілька способів це зробити. Ми можемо, наприклад, анотувати QuerySet користувацьким SQL-виразом:

Order.objects.annotate(
  age=RawSQL('age(created_at)'),
)

Те ж можна зробити з методом QuerySet extra:

Order.objects.extra(
  select={
    'age': 'age(created_at)',
  },
)

У extra є ще багато можливостей, які можна знайти в документації.

Якщо ми хочемо написати повний SQL-запит самостійно — це також варіант. Повернені колонки будуть зіставлені з полями моделі за їх назвою. Ті колонки, що не відповідають наявним назвам полів, будуть додані як анотовані поля.

Order.objects.raw(
  '''
  SELECT *, age(created_at) as age
  FROM orders_order
  '''
)

Якщо ми не хочемо повертати об'єкт моделі, ми також можемо запустити користувацькі SQL-запити прямо в курсорі БД:

with connection.cursor() as cursor:
  cursor.execute('SELECT 2')
  cursor.fetchone()

Тут повернеться кортеж з колонками.

Користувацькі міграції

Ще один спосіб додати додаткову функціональність БД в Django — створити користувацькі міграції. Перед тим як Django отримав підтримку користувацьких обмежень та індексів, використання RunSQL було єдиним варіантом додавання подібного функціоналу.

Інший варіант використання — додати користувацьке представлення за допомогою RunSQL та використовувати його як модель.

class Migration(migrations.Migration):
    ...
    operations = [
        migrations.RunSQL(
            sql='''
            CREATE VIEW unshipped_orders AS
            SELECT * FROM orders_order
            WHERE is_shipped = false;
            ''',
            reverse_sql='''
            DROP VIEW unshipped_orders;
            ''',
        )
    ]

Ще одна причина використовувати користувацькі міграції — міграції даних. Якщо у вас є первинний набір даних, який завжди повинен бути доступним у вашій БД, ви можете використовувати міграції даних. Якщо ви використовуєте міграції, коли запускаєте тести, цей первинний набір даних також буде доступний в них. Для цього необхідно звернутися до класу RunPython у файлі міграції:

class Migration(migrations.Migration):
    ...
    operations = [
        migrations.RunPython(
            code=some_python_function,
            reverse_code=some_other_python_function,
        )
    ]

Для здійснення міграцій також використовується клас SeparateDatabaseAndState. При запуску міграцій, Django зберігає внутрішній стан, щоб представити очікуваний шаблон моделей на додачу до фактичного стану в БД. Зі згаданим класом ви можете модифікувати ці стани окремо.

Така особливість може бути корисною, якщо вам необхідно тримати стани окремо з метою високої доступності. Для цього потрібні списки: один з операціями для виконання над внутрішнім станом, а інший — для операцій у БД:

class Migration(migrations.Migration):
    ...
    operations = [
        migrations.SeparateDatabaseAndState(
            state_operations=[],
            database_operations=[],
        )
    ]
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.8K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація