Фреймворк Django, пожалуй, самый популярный для языка Python. Однако, при всей его популярности, часто критикуют его ORM — а именно lookup синтаксис через подчеркивания. На самом деле, такой выбор синтаксиса вполне обоснован — он легок в понимании, расширяем, а главное — прост, как швабра. Тем не менее, хочется красоты, или даже прямо изящества. Но красота — понятие относительное, поэтому будем отталкиваться из конкретных задач. Если заинтриговал — добро пожаловать под кат.

На самом деле, у lookup через подчеркивания есть два основных недостатка:
1. Плохая читаемость строки, если она достаточно длинная.
Пример:

>>> query = SomeModel.objects.filter(user__savepoint__created_datetime__gte=last_week)

Строка плохо читаема, т. к. с первого взгляда created_datetime можно спутать с created__datetime, либо наоборот — можно не поставить второе подчеркивание между user и savepoint. К тому же lookup параметр можно спутать с полем модели. Конечно, при более детальном рассмотрении видно, что имеется ввиду «больше либо равно», но при чтении кода — это ведь потеря драгоценных секунд!

2. Трудно переиспользовать строку поиска. Возьмем в качестве примера запрос выше и попытаемся сортировать результаты по полю created_datetime.

>>> query.order_by('user__savepoint__created_datetime')

Как видите, длинную строку нам пришлось набрать заново, при чем мы не можем ее сохранить в качестве переменной, т. к. в одном случае мы используем строку, а выше — keyword аргумент.

Придирчивый читатель заметит, что мы могли бы сохранить основную часть строки в переменной query_param = 'user__savepoint_created_datetime' и сделать такой хак:

>>> SomeModel.objects.filter(**{'{}_gte'.format(query_param): last_week}).order_by(query_param)

Но такой код еще более запутанный, т. е. главную задачу рефакторинга — упростить код, он не выполнил.
Из первого пункта, следует, что нам нужно каким-то образом заменить подчеркивания на точку. Также мы знаем, что мы можем переопределить поведение операций сравнения: __eq__, __gt__, __lt__ и др.
Чтобы использовать его похожим образом:

>>> SomeModel.user.savepoint.created_datetime >= last_week

Однако, первое решение не такое уж и замечательное, т. к. придется патчить джанговские классы моделей и полей, а это уже накладывает большие ограничения для использования решения в реальном мире. К тому же, имя модели может быть довольно длинным, а значит наше решение будет слишком многословным, когда придется комбинировать несколько фильтров. На помощь нам придет класс Q — очень краткий, не содержит ничего лишнего. Сделаем что-нибудь подобное — и назовем его S (от Sugar). Будем его использовать для генерации строк.

>>> S.user.savepoint.created_datetime >= last_week 
{'user__savepoint__created_datetime__gte': last_week}

Однако использовать его все еще не так уж и удобно:

>>> SomeModel.objects.filter(**(S.user.savepoint.created_datetime >= last_week))

На помощь нам снова придет класс Q — его можно прямо передавать в filter, так будем возвращать готовый его экземпляр!

>>> S.user.savepoint.created_datetime >= last_week
Q(user__savepoint__created_datetime__gte=last_week)

>>> SomeModel.objects.filter(S.user.savepoint.created_datetime >= last_week)

Итак, API использования у нас есть, дело осталось за реализацией. Нетерпеливые могут сразу открыть репозиторий github.com/Nepherhotep/django-orm-sugar.

Задача №1. Переопределение операций сравнения


Открываем документацию здесь docs.python.org/2/reference/datamodel.html#object.__lt__ и смотрим, какие функции нам доступны. Это __lt__, __le__, __gt__, __ge__, __eq__, __ne__. Переопределяем их, чтобы они возвращали соответствующий Q объект:

def __ge__(self, value):
    return Q(**{'{}__gte'.format(self.get_path()): value})

и т. д.

Однако, операцию is переопределить нельзя, также будут сложности с проверкой на contains в питоновском стиле:

'substr' in S.user.username

Поэтому для таких операций создаем одноименные функции:

def contains(self, value):
    return Q(**{'{}__contains'.format(self.get_path()): value})

def in_list(self, value):
    return Q(**{'{}__value'.format(self.get_path()): value})

В качестве демонстрации удобства, добавляем полезный метод in_range:

def in_range(self, min_value, max_value):
     return (self <= min_value) & (self >= max_value)

Его удобство в том, что можно передать сразу два параметра, что было невозможно при использовании keyword аргументов.

>>> SomeModel.objects.filter(S.user.savepoint.created_datetime.in_range(month_ago, week_ago))


Задача №2. Создание дочерних экземпляров при доступе по точке


>>> S.user.savepoint.create_datetime

Во-первых, будем все-таки работать с атрибутами объекта, а не класса. Но т. к. выше мы использовали класс без вызова конструктора, то просто создадим на уровне модуля объект. Во-вторых, сам исходный класс назовем более вменяемо — SugarQueryHelper.

class SugarQueryHelper(object):
    pass

S = SugarQueryHelper()

Чтобы генерировать атрибуты на лету, нужно переопределить метод __getattr__ — он будет вызываться в последнюю очередь, если атрибут не найден другими способами.

    def __getattr__(self, item):
        return SugarQueryHelper()

Но нам нужно также запомнить имя переданного параметра, чтобы на основе его генерировать Q объект, а также ссылку на родительский класс.

class SugarQueryHelper(object):
    def __init__(self, parent=None, name=''):
        self.__parent = parent
        self.__name = name

    def __getattr__(self, item):
        return SugarQueryHelper(self, item)

Теперь осталось добавить генерацию путей, и модуль готов!

    def get_path(self):
        if self.__parent:
            parent_param = self.__parent.get_path()
            if parent_param:
                # объединяем строки, если получили непустой путь от родителя
                return '__'.join([parent_param, self.__name])
        # в ином случае просто возвращаем имя текущего объекта
        return self.__name

Теперь этот метод можно использовать не только внутри SugarQueryHelper, но и для тех случаев, когда нужно передать строку запроса в order_by или select_related.
Покажем, что это решает озвученную проблему выше — переиспользование строки запроса.

>>> sdate = S.user.savepoint.created_datetime
>>> SomeModel.filter(sdate >= last_week).order_by(sdate.get_path())


Дальнейшее развитие


Модуль получился вполне неплох, несмотря на тривиальность исполнения. Но есть вещи, которые можно было бы улучшить.

Если внимательно посмотреть, то S объект не позволяет обращаться к полям, которые названы так же, как вспомогательные функции — contains, icontains, exact и т. д. Конечно, маловероятно, что кому-нибудь придет в голову так называть поля, но по закону Мерфи такие случаи когда-нибудь произойдут.

Что тут можно сделать? Немного поменять схему работы — переопределить метод __call__, и если он вызывается, то имя последнего объекта в пути можно пропустить. При этом сами вспомогательные функции начинать с подчеркивания, а регистрацию (без подчеркивания) осуществлять через декоратор:

@register_hook('icontains')
def _icontains(self, value):
    return Q(...)

Однако, такое решение мне показалось менее очевидным в некоторых ситуациях. В итоге, т.к. библиотека всего лишь генерирует Q объекты, и обычный способ через keywords по-прежнему доступен, я решил не дорабатывать эту реализацию (версия доступна в ветке special_names).

Следующее, что можно было сделать — реализовать его Q совместимым. Т.е. вместо того, чтобы импортировать S и Q, можно было использовать только Q в обоих случаях. Однако, у Q объекта довольно сложная реализация, к тому же есть публичные методы, которые будут непонятными для пользователя библиотеки. Решить проблему нахрапом не получилось, поэтому оставил как есть.

Еще можно сделать аналогичным фильтрацию прямо из кастомизированного менеджера запросов docs.djangoproject.com/en/1.8/topics/db/managers/#custom-managers. Тогда, переопределив его, можно делать запрос вида:

>>> SomeModel.s_objects.user.age >= 16
<QuerySet>

Однако, это ломает понимание происходящего в рамках действующего в джанге подхода. К тому же, польза такого подхода теряется полностью, если придется скомбинировать два и более фильтра:

>>> (SomeModel.s_objects.user.age >= 16) & (SomeModel.s_objects.user.is_active == True)
vs
>>> SomeModel.objects.filter((S.user.age >= 16) & (S.user.is_active == True))

Не так уж и кратко, не правда ли? Не говоря уже о возможных проблемах, если попытаться скомбинировать запросы из разных моделей — синтаксис ведь позволяет!

Послесловие


Как видите, написать полезную штуку бывает не так уж и сложно. В данном случае, все тяжелые операции ложаться на плечи стандартных функций джанги — ведь менеджер запросов сам проверяет, что ему передали — число, дату или F-объект, корректны ли имена полей и так далее. Также, модуль будет работать как во второй, так и третьей версиях питона.
Если у кого-то есть какие-то идеи, замечания или предложения — пишите в комментариях или шлите пул реквесты.

Спасибо за внимание!

Комментарии (11)


  1. kstep
    29.07.2015 20:35
    +10

    Поздравляю, вы только что изобрели велосипед SQLAlchemy!


    1. Nepherhotep Автор
      29.07.2015 20:50
      +2

      Вообще, на алхимию я в некотором роде и ориентировался :)
      Только здесь — это надстройка над джанговской ORM, а у ахлимии все — от пула соединений и до генерации запросов реализовано само в себе.
      Не знаю, как живут нынешние попытки подружить Django и SQLAlchemy, но раньше все сводилось к тому, что открывалось дополнительное соединение к базе, что тоже не ах. Но самая большая проблема (для меня по крайней мере) — это то, что они в общем-то во многом похожи, но разные штуки реализовали по-разному. После джанги смотришь SQLAlchemy — и ощущение, будто читаешь на польском языке. А обратный переход и того хуже. Так что в одном проекте и то и другое — еще тот когнитивный диссонанс :)


      1. Deepwalker
        29.07.2015 22:54

        Не обязательно новое, в github.com/Deepwalker/aldjemy я просто беру готовое.


        1. Nepherhotep Автор
          29.07.2015 23:13

          И как оно, кстати, в продакшне? Можно использовать?


          1. Deepwalker
            30.07.2015 01:04

            Ну я использовал. Судя по постоянным пул реквестам, кто-то еще использует. Но я джанго последний раз видел давно и на библиотеку честно говоря давно забил. А никого другого на горизонте не нарисовалось чтобы сплавить её к черту.


          1. ildus
            30.07.2015 11:59

            Да, отлично работает. Я вот как раз недавно портировал эту либу на джангу 1.8


        1. Nepherhotep Автор
          29.07.2015 23:18

          В любом случае, воспользоваться алхимией — это уже задача другого рода. К примеру, если понадобились нормальные человеческие group by, или явные джойны (предположим, мы пошардили базу). Но если у вас есть кастомные поля или еще какой-то специфический код, реализованный в рамках джанговской ормки — будет дублирования кода (если конечно, нет возможности полностью его выкосить).
          Описанный в статье самокат — только сахарку добавляет, никаких advanced usage. Зато это будет полезно при обычном использовании джанговской ормки и не требует дублирования кода в случае упомянутого специфического кода вроде кастомных полей.


          1. Deepwalker
            30.07.2015 01:09
            +3

            Ну никто со статьей не спорит – выглядит ничего так. Единственное меня в принципе раздражают стописят слоев перекладывания строк, включая собственно сам SQL, который уходит через сокет, и еще там парсится, а потом там планер, а потом, по окончании всей этой херни включается наконец-то вожделенное – достать уже чертовы строки из таблиц.

            Но тут ведь не заканчивается бал безумия – теперь мы их пакуем и шлем в зад. Тут создается долбанная тонна объектов на каждую строку вернувшегося запроса, а может и больше если это был joinedload. А потом вся эта бодяга тупо перекладывается в dict, list, str, int и прочее, а потом приходит json сериалайзер, и наконец-то сборщик мусора может с удовольствие пожевать, и пусть весь мир подождет, ять.

            Так что ну ок, S выглядит красиво, практически бесконечность. Еще одна маленькая игра со строками ничего особо в картине мира не испортит.


  1. immaculate
    30.07.2015 13:05
    +1

    Проблема с таким сахаром в том, что из-за применения подобных подсластителей, в проекте появляется еще один мини-фреймворк. Только как правило недокументированный и толком непротестированный. Приходит, например, новый человек в проект, ожидает, что будет работать с обычным Django кодом, а тут какие-то непонятные конструкции, для разбирания в которых надо смотреть в код.

    Овчинка выделки не стоит, по-моему.


    1. Nepherhotep Автор
      30.07.2015 14:24
      +1

      Тут, конечно, каждый выбирает сам. Кто-то тащит все пакеты подряд ради мелкой фичи, которую можно было написать самому. В результате при попытке их обновить, получает неожиданные падения, в худшем случае — dependency hell. Кто-то пишет все сам — это как правило касается крупных компаний, где каждая зависимость должна тщательно изучаться.
      Я предпочитаю не ставить зависимости без большой необходимости, но обычно решает код. Если библиотека хорошо читаема и покрыта тестами — то почему бы и нет?
      Но вообще статья скорее про программирование, а не про библиотеку. Я вот хотел подискутировать про выбранное апи.


      1. immaculate
        30.07.2015 14:45
        +1

        Тут нет единственно правильного ответа, конечно. Я просто много обжигался на том, что люди городят свои фреймворки поверх стандартных фреймворков, но им:
        — не хватает опыта, чтобы понять, что то же самое легко делается стандартными средствами
        — не хватает времени и терпения, чтобы задокументировать хотя бы минимально свои поделки
        — тем более не хватает времени на тесты и поддержку (портирование под новые версии основного фреймворка хотя бы).

        В итоге, в моей практике все подобные решения тихо загнивали создавая кучу проблем тем, кто приходил после автора гениальной «упрощающей жизнь» надстройки. А авторы эти почему-то не задерживаются подолгу в одной компании, и расспросить их о той ли иной недокументированной стороне их поделки уже нет возможности.