Pull to refresh

Django ORM. Добавим сахарку

Reading time 6 min
Views 27K


Фреймворк 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-объект, корректны ли имена полей и так далее. Также, модуль будет работать как во второй, так и третьей версиях питона.
Если у кого-то есть какие-то идеи, замечания или предложения — пишите в комментариях или шлите пул реквесты.

Спасибо за внимание!
Tags:
Hubs:
+14
Comments 11
Comments Comments 11

Articles