воскресенье, 23 июля 2017 г.

Правильная обработка choices в полях моделей Django

Перевод: Handle choices the right way
Автор: Джеймс Беннет (James Bennett)

Примечания переводчика:
Обычно не задумывался над тем, как лучше оформлять кортежи для параметра choices в полях моделей Django.
Не задумывался, видимо, потому что никогда до этого не приходилось фильтровать записи по значениям этого поля. И вот однажды такая необходимость возникла. Подумал, что использование в QuerySet'ах "магических чисел" не способствует наглядности кода и решил поискать - как же лучше оформить эти кортежи? В результате нашёл эту статью, которую и решил впоследствии перевести - вдруг кому-нибудь ещё пригодится? Многие ссылки в исходной статье протухли, поэтому заменил их на то, что удалось найти из ещё доступного.
Часто бывает нужно воспользоваться полем модели, которое будет принимать ограниченный набор значений. Например, поле выбора штата США, что логично, должно разрешать только значения, соответствующие действительно существующим штатам США. В ORM Django это реализуется при помощи аргумента choices в определении поля. В общем случае это - наиболее простое решение.

Но это решение не всегда идеальное: хотя со строковыми значениями (упомянутые выше штаты США в Django могут быть реализованы в виде простого двухбуквенного почтового кода вроде "VA" или "KS") не возникает проблем, с числовыми значениями возникают сложности. Например, в моём блоге используется модель Entry (если интересно - см. полный исходный текст в репозитории Google Code), в которой есть поле "status", позволяющее отличать три разных типа записей:
  1. "Live" - записи, которые опубликованы на сайте.
  2. "Draft" - записи, которые находятся в процессе подготовки и ещё не опубликованы. Когда они будут дописаны, их статус можно будет поменять на "Live".
  3. "Hidden" - записи, которые по какой-либо причине были скрыты. Если записи нужно оставить в базе данных, но они не должны быть опубликованными.
К слову, я настоятельно рекомендую не удалять содержимое из базы данных. Никогда не знаешь, когда оно может потребоваться снова. И хотя можно реализовать функцию восстановления записей, гораздо проще просто включить или выключить признак публикации.

Теперь запишем этот набор опций в виде кортежа choices наиболее очевидным образом:
STATUS_CHOICES = (
    (1, 'Live'),
    (2, 'Draft'),
    (3, 'Hidden'),
)
Для этих значений не так-то просто придумать строковые аббревиатуры (по крайней мере, ни одно из них не может быть интернационализировано), поэтому выходом может оказаться использование целочисленных значений. В таком случае его можно просто добавить к модели:
class Entry(models.Model)
    # ...несколько других полей...
    status = models.IntegerField(choices=STATUS_CHOICES)
Можно чуть-чуть улучшить этот код, так чтобы по умолчанию у записей проставлялось значение “Live”:
status = models.IntegerField(choices=STATUS_CHOICES, default=1)
Теперь можно легко выбрать только опубликованные записи, выполнив такой запрос:
live_entries = Entry.objects.filter(status=1)
Можно даже реализовать собственный менеджер запросов, который заменит метод get_query_set() так, чтобы он возвращал только опубликованные записи (как было сделано здесь).

Но у этого подхода есть большая проблема: теперь в приложении повсюду используются "магические числа".

Плохая магия

"Магические числа" - это любые числовые константы (или любые другие непонятные значения), которые явным образом встречаются в коде. В данном случае значение статуса "Live" - 1 - используется по меньшей мере уже в двух местах: первый раз в объявлении поля модели (в качестве значения по умолчанию) и, даже если мы напишем собственный менеджер запросов для выбора опубликованных записей, как минимум ещё один раз для фильтрации в условии status=1. И это даже если мы просто хотим удобно работать с опубликованными записями. Если же нам когда-нибудь понадобится, скажем, метод-менеджер или QuerySet для черновиков, нам придётся разбросать во множестве мест ещё одно число.

Это плохо по нескольким причинам. Во-первых, что самое важное, нарушается принцип DRY (Don't repeat yourself - не повторяйся), так как нужно повторять одно и то же "магическое" число во множестве мест. Во-вторых, есть риск нарушения родственного принципа "Однажды и только единожды", поскольку легко попасть в ловушку при написании filter(status=1) более чем в одном месте. Наконец, в-третьих, возникают проблемы с поддержкой: нужно держать в уме - что означает это "магическое" значение и список всех мест, где оно используется (поскольку любое будущее изменение потребуется внести во все эти места одновременно).

Удаляем магию

Но остаётся проблема: как ссылаться на это значение без жёстко-закодированных повсюду "магических" чисел? В языках с перечисляемыми типами (такими как enum в C и его родственниках), это - легко решаемая проблема. В одном из этих языков нужно просто определить enum с именами различных вариантов и позволить языку использовать значениями.

Но в Python нет подходящей замены для enum. Мы можем импортировать кортеж STATUS_CHOICES и записать фильтрацию следующим образом:
live_entries = Entry.objects.filter(status=STATUS_CHOICES[0][0])
Но, несмотря на то, что мы больше не используем жёстко закодированные числовые значения, теперь мы опираемся на точное определение кортежа STATUS_CHOICES. Если в первый слот будет помещено какое-то другое значение (например, статус подтверждения записи главным редактором), то сломается всё, что ссылается на STATUS_CHOICES[0][0].

Однако, можно просто определить набор констант:
LIVE_STATUS = 1
DRAFT_STATUS = 2
HIDDEN_STATUS = 3
Теперь можно переопределить кортеж STATUS_CHOICES так, чтобы он опирался эти константы:
STATUS_CHOICES = (
    (LIVE_STATUS, 'Live'),
    (DRAFT_STATUS, 'Draft'),
    (HIDDEN_STATUS, 'Hidden'),
)
А также, теперь можно импортировать и ссылаться на эти константы. Например, поле статуса теперь должно быть записано следующим образом:
status = models.IntegerField(choices=STATUS_CHOICES, default=LIVE_STATUS)
Этими константами можно воспользоваться и для фильтрации только опубликованных записей:
live_entries = Entry.objects.filter(status=LIVE_STATUS)
Черновики теперь можно отбирать, фильтруя их по условию status=DRAFT_STATUS. А скрытые записи можно фильтровать по условию status=HIDDEN_STATUS. Код становится более наглядным в двух аспектах:
  1. Больше нет разложенных повсюду "магических чисел", которые нужно специально обновлять. Одно изменение в определении одной или более констант - это всё, что нужно, чтобы обновить код (важно также обновить и базу данных, но это можно легко исправить одним запросом UPDATE).
  2. Код теперь стал гораздо понятнее: запрос с условием status=1 может значить что угодно, но запрос с условием status=LIVE_STATUS практически объясняет сам себя.

Инкапсуляция

Однако, можно внести ещё одно улучшение: инкапсулировать варианты статуса внутри модели Entry. Эти варианты логически "принадлежат" модели Entry и поэтому не стоит определять их отдельно или импортировать их отдельно от самой модели Entry. Поэтому можно переместить константы и кортеж с вариантами вовнутрь класса Entry:
class Entry(models.Model):
    LIVE_STATUS = 1
    DRAFT_STATUS = 2
    HIDDEN_STATUS = 3
    STATUS_CHOICES = (
        (LIVE_STATUS, 'Live'),
        (DRAFT_STATUS, 'Draft'),
        (HIDDEN_STATUS, 'Hidden'),
    )
    # ...несколько других полей...
    status = models.IntegerField(choices=STATUS_CHOICES, default=LIVE_STATUS)
Теперь можно просто импортировать модель Entry и писать запросы следующим образом:
live_entries = Entry.objects.filter(status=Entry.LIVE_STATUS)
draft_entries = Entry.objects.filter(status=Entry.DRAFT_STATUS)
Можно также сравнивать записи со значениями констант:
if entry_object.status == Entry.LIVE_STATUS:
    # делаем что-то с опубликованной записью

Подведём итоги

Хотя нужно набрать немного больше текста (поскольку сначала нужно определить константы, а затем объявить кортеж choices на их основе), это - лучший способ работы с целочисленными вариантами в полях моделей Django. Возможность ссылаться на константы как на атрибуты класса модели, наподобие Entry.LIVE_STATUS, вместо жёсткого кодирования "магических" чисел или работы с отдельной от класса структурой данных - это почти настолько наглядно, насколько возможно.

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