Рассматривая стек Luminus, я наткнулся на простую и в то же время шикарную, на мой вкус, библиотеку Yesql для организации SQL-запросов в проекте на Clojure и я не увидел чего-то похожего для Python (может плохо искал). Идея этой библиотеки простая — не морочьте себе голову, используйте обычные SQL-запросы, у вас есть возможность именования этих запросов и мапинга на соответствующие динамические функции. Всё это выглядит как набор микро-шаблонов с SQL и их рендер по какому-то контексту. Просто, эффективно, хочу такое у себя в проекте на Python.
Вообще в последнее время мне импонирует мысль, что ORM не нужны. Они переусложняют, на самом деле, работу с реляционными БД, скрывают «адский» SQL за ширмой сложных конструкций собственных объектов, а зачастую выдают и крайне неэффективный результат. Наверняка кто-то поспорит с этим выводом, но моя практика показала, что Django ORM ужасающе простой чуть более чем всегда (и доступен только если вы используете Django, конечно), SQLAlchemy ужасающе сложный, Peewee — ни разу не встречал в дикой природе, к тому же ещё немного и он станет как Alchemy по своему порогу вхождения. SQL — сам по себе мощный и выразительный DSL, вам не нужен ещё один уровень абстракции над ним, серьёзно. Под другим углом я задумался о целесообразности ORM во время очередного проекта на Tornado. Алхимия чудесным алхимическим образом убивает всю асинхронность выполнения обработчика блокирующими вызовами в базу. И вариантов кроме как использовать тот же Momoko с сырыми запросами я не увидел.
Всё, что нам нужно для полного счастья — это разведение SQL-строк и Python-кода по разным углам и некоторая гибкость в построении конструкций по условиям или контексту. Ну и перестать бояться писать SQL, конечно. Изучить SQL до необходимого уровня реально проще чем все нюансы Алхимии для того же результата.
Попробовав и немного переосмыслив Yesql у меня родилась крохотная библиотека Snaql, которая решает описанную выше проблему, хоть и немного по-своему. Я решил вообще не завязываться на клиенты к базам и использовать Jinja2 в качестве движка для парсинга и рендеринга шаблонов с SQL-блоками (со всеми вытекающими возможностями использовать её шаблонную логику). Вот как это выглядит.
1. Ставим Snaql.
$ pip install snaql
2. Создаём в своём проекте папку, куда будем складывать файлы с SQL-блоками. Или несколько таких папок.
/queries
users.sql
3. В users.sql у нас, например, все запросы, связанные с сущностью пользователя.
{% sql 'users_by_country', note='counts users' %}
SELECT count(*) AS count
FROM user
WHERE country_code = ?
{% endsql %}
Как можно догадаться, SQL помещается внутри блока {%sql%}{%endsql%}, «users_by_country» это название функции, на которую навешивается данный SQL (создаётся динамически), а «note» — это docstring к этой функции, он опционален.
Таких блоков в одном файле может быть сколь угодно много. Главное, чтобы их имена были уникальны.
4. Теперь нам нужна фабрика, которая распарсит такие файлы и создаст набор одноимённых функций.
from snaql.factory import Snaql
# корень проекта
root_location = os.path.abspath(os.path.dirname(__file__))
# регистрация директории с шаблонами
snaql_factory = Snaql(root_location, 'queries')
# регистрация шаблона с SQL-блоками
# users_queries = snaql_factory.load_queries('users.sql')
Извлечь в коде необходимый SQL теперь можно просто вызвав
your_sql = users_queries.users_by_country()
# SELECT count(*) AS count
# FROM user
# WHERE country_code = ?
На самом деле уже этого может быть достаточно. Но не в случае с генерируемыми условиями запроса. В этом случае можно добавить в шаблон всю то логику, которую предоставляет Jinja. Например:
{% sql 'users_select_cond', note='select users with condition' %}
SELECT *
FROM user
{% if users_ids %}
WHERE user_id IN ({{ users_ids|join(', ') }})
{% endif %}
{% endsql %}
Если вызвать функцию без контекста:
your_sql = users_queries.users_select_cond()
# SELECT *
# FROM user
И если с контекстом:
your_sql = users_queries.users_select_cond(users_ids=[1, 2, 3])
# SELECT *
# FROM user
# WHERE user_id IN (1, 2, 3)
Получив сформированный SQL, остальное — дело техники. Вроде неплохо, да? В любом случае пишите свои «за» и «против» в комментариях, мне интересно мнение сообщества, насколько это может быть удобным кому-то кроме меня.
GitHub, PyPi
UPD: Спасибо за конструктивные комментарии. Теперь у меня есть с чего формировать roadmap на 0.2. Не стесняйтесь присылать issues и requests на GitHub.
UPD2: Благодаря вашим конструктивным замечаниям, я обновил Snaql до версии 0.2, там теперь есть guards и conditions blocks, расширена поддержка версий интерпретатора до 2.6, 2.7, 3.3, 3.4, 3.5.
Комментарии (33)
Joes
13.09.2015 09:06+2Очень неплохая идея, но вот синтаксис не очень.
Было бы отлично если бы тег работал аналогично {% macro foo() %} — т.е. определялся как функция, был список аргументов и т.д. При этом macro уже есть и получить список доступных макросов из шаблона можно используя доступный API.artifex
13.09.2015 22:40Напишите развёрнутый пример, пожалуйста. Мне кажется я не до конца понял вашу мысль.
Joes
14.09.2015 05:36У Jinja2 есть конструкция macro, которая выглядит как функция. Определяется так:
{% macro hello_world(a, b, c=None, d='test') %} Hello World {{ a }} {{ b }} {{ c }} {{ d }} {% endmacro %}
Далее мы ее можем вызвать:
{{ hello_world(1, 2, 3, d='Hi')
У Jinja2 есть API для получения и вызова макросов как функций. Например так: github.com/mitsuhiko/jinja2/blob/master/jinja2/environment.py#L1036
Вроде еще какой-то способ был, но я так сразу не вспомню.
Почему это лучше:
1. Формальное определение функции с соответствующей проверкой аргументов, документацией и т.п.
2. На самом деле прослойка не нужна — Jinja2 уже умеет все из коробки. Разве что надо шаблон загрузить, получить модуль из него и вызывать макросы как обычные функции
batment
13.09.2015 11:56Выглядит очень интересно, но в коммерческом проекте, который нужно поддерживать другим людям, я бы такое не применял, разве что для каких-то специфических задач, где ORM действительно будет палкой в колесах.
DmitryKoterov
13.09.2015 12:29+1Посмотрите синтаксис и идеологию конструирования запросов из блоков (часть из которых опциональна) в старинной библиотеке DbSimpe.
Jinja тут вообще никаким боком, не того уровня абстракция совсем — вот и получатся sql injection и т.д. с ней. И параметры хорошо бы у каждого запроса явно описывать, а не втобы они брались по именам из середины запросов и непредсказуемо.
А вообще, описанный подход — в чистом виде хранимые процедуры в СУБД, только их нельзя джойнить и использовать внутри других процедур.
mgremlin
13.09.2015 14:42А мне лень. Я тупо sql пишу, да и все. И пока не видел ни одного решения, которое было бы удобнее — включая любые ормы.
jam31
13.09.2015 19:54Алхимия чудесным алхимическим образом убивает всю асинхронность выполнения обработчика блокирующими вызовами в базу
Вы уверены, что правильно используете Tornado.gen.coroutine и алхимические scoped_session?bosha
16.09.2015 08:38+1Я не автор поста, но всё же.
Насколько мне известно, одно использование gen.coroutine не делает магическим образом код асинхронным, выполнение так же происходит в одном треде. Он просто ожидает генератор на выходе. Чтобы код был асинхронным, необходимо всё таки написать его используя торнадовский eventloop (ioloop в терминологии tornado) и генераторы. Поправьте, если ошибаюсь.
Относительно scoped_session, ребята сделавшие SQLA пишут, что для каждого треда нужно нужна отдельнаяSession()
, которую нельзя забывать возвращать в Pool используяSession.remove()
:
As discussed in the section When do I construct a Session, when do I commit it, and when do I close it?, a web application is architected around the concept of a web request, and integrating such an application with the Session usually implies that the Session will be associated with that request. As it turns out, most Python web frameworks, with notable exceptions such as the asynchronous frameworks Twisted and Tornado, use threads in a simple way, such that a particular web request is received, processed, and completed within the scope of a single worker thread. When the request ends, the worker thread is released to a pool of workers where it is available to handle another request.
Но к tornado это не относится никак, т.к. он работает в одном треде. В остальных же случаях SQLA «thread-local» и, соответственно, блокирует tornado до выполнения запроса.
Единственный способ подружить tornado с SQLAlchemy который я нашёл — это aiopg, но он поддерживает только postgres :(artifex
16.09.2015 16:11Спасибо, что ответили за меня.
bosha
16.09.2015 20:34Честно говоря, как раз сегодня встала задача выполнить долгую, блокирующую операцию в tornado. Сделать её неблокирующей не выходило никак. Решением оказалось выделить выполнение этой операции в отдельный тред. При таком подоходе весь смысл использования tornado теряется. Проще сразу на cherrypy писать :)
jam31
16.09.2015 22:02Попробуйте вот так.
bosha
16.09.2015 22:10Я как раз выше об этом написал. Какой в этом случае смысл использовать торнадо?
jam31
16.09.2015 22:32Мощь Tornado в переиспользовании долгоживущих соединений. С другой стороны, базе данных этот факт совершенно безразличен, и встраивать её в парадигму Tornado является задачей для разработчика, которую он волен решать руководствуясь своей фантазией.
jam31
16.09.2015 22:42Вы здесь совершенно правы, упомянутые мной Tornado.gen.coroutine и scoped_session не решают задачу стыковки Tornado и SQLA в общем смысле. Корректное решение — Thread pool.
Crandel
13.09.2015 21:06Вместо Jinja можно использовать Mako, очень легкий шаблонизатор, в котором можно задействовать всю мощь питона для обработки данных( генераторы, list comprehension и тд.)
artifex
13.09.2015 22:38Не люблю Mako именно за это. Слишком много свободы в шаблонах, этим часто злоупотребляют. Вплоть до того, что пихают туда половину бизнес-логики, а то и всю. Логика представления должна оставаться таковой.
Crandel
14.09.2015 09:29+1так тут надо гибкость для sql запросов, или я неправильно понял вашу мысль?
CJay
14.09.2015 11:21К сожалению, в БД как и в ORM глубоких познаний нет. Описанный подход мне понравился… Подумываю, не заменить ли таким подходом в своём проекте PeeWee. Но… в силу недостаточности знаний… имеется какое-то неосязаемое опасение… ряд вопросов…
- А безопасно-ли с точки зрения sql-инъекций? (хотя с чего я взял, что ORM безопасны, если и в них, по-сути, происходит конкатенация и форматирование строк)
- А есть ли выйгрыш в скорости при генерации sql-запроса шаблонизатором (Jinja2) и ORM (Sql-Alchemy)?
- А на сколько это удобно с точки зрения рефакторинга?! Если мы переименуем сущность (таблицу), это ж менять по всем шаблонам вручную?!
ffsdmad
14.09.2015 14:47а что если сделать вот такой декоратор (Flask)
def _sql(fn): def wrapped(*args): return db.session.execute( fn(*args) ) return wrapped
а затем декорировать sql код моделей
@_sql def page_count(self): return "select count(*) from pages"
а затем можно этот sql код можно легко вызывать прямо из шаблонов типа:
{{ page.page_count() }}
nwalker
15.09.2015 00:54Я, честно говоря, не понимаю восторга от yesql, особенно в среде Clojure. SQL же не композабелен вообще, они идеологически несовместимы.
NElias
15.09.2015 12:36ORM хорош не запросами, а менеджментом БД — создание таблиц и связей, модификация, версионность, всякие эвенты, миграции и бог знает что ещё. Допилите всё это к своей либе, тогда посмотрим порог вхождения и сложность. ) А так, обычный import sql.
devel787
28.09.2015 12:36Хотел бы задать вопрос автору статьи по поводу:
>> Sphinxit
>
> I don't support this product any more.
> If you need some fixes or new features — fork and play with that, please.
> Snaql with native SphinxQL are recommended now.
( github.com/semirook/sphinxit/commit/13bc1228a0a30a01e04f0cf5e5037db4e4140017 )
У вас есть в планах написать раздел документации,
аналогичный sphinxit.readthedocs.org/en/latest/usage.html,
только с примерами на Snaql?
KoVexPulThul
Мне кажется такой подход будет не очень удобен для описания условий посложнее. Например, когда передается несколько независимых условий. Приведу в качестве примера генерацию такого запроса с использованием SQLAlchemy:
to_climb
Что-то типа (не знаком с jinja для написания примера кода):
artifex
Решение в лоб:
KoVexPulThul
Мне кажется, здесь произойдет ошибка т.к. будет составлен некорректный запрос, когда будут переданы параметры date_from и date_to, но не ids
artifex
Ну это легко поправить. Опять же, в лоб.
Я бы разнёс conditions по своим блокам.
Ну как-то так. Мысль, думаю, уловили.
BlessMaster
Имхо, Jinja — лишнее звено в данной схеме.
Jinja — очень узкое подмножество Python, дополненное средствами безопасности (песочница для кода от третьих лиц), кешированием фрагментов, средствами перевода и прочей обработки текста.
Мы не используем эти дополнительные возможности, поскольку они нам бесполезны, при этом сильно ограничиваемся подмножеством и спецификой работы с шаблонами. В то же время в самом Python вполне достаточно средств манипулирования строками для этой задачи и при этом вся мощь этого языка для комбинирования условий.
То же решение «в лоб» в чистом Python (ох плачет по нему напильник: делать работу драйвера по вставке данных и нарываться на SQL-injection — неблагодарное занятие)
Какие преимущества перед Jinja?
1. Всё компактно — не нужно продираться через дебри разметки, не нужно бегать по коду и файлам, не нужно держать в памяти (и ошибаться) из чего же устроен наш запрос.
2. Работает навигация IDE и анализатор кода с рефакторингами и автодополнениями — в любую функцию можно прыгнуть, ошибки и подозрительные места подсвечиваются.
3. Это поддаётся пошаговой отладке, сюда можно поставить точку остановки и проинспектировать состояние.
Возможно я отстал от жизни и последние два пункта с Jinja тоже не проблема в передовых IDE, но первый пункт — никуда не денешь. Ну и всю мощь «питона с батарейками» в противовес «песочнице» я уже упоминал (можно, конечно, перенастроить песочницу в швейцарский нож, благо она это позволяет, но создавалась она явно с противоположной целью и умолчания — против нас).
Как-то так.
BlessMaster
P.S. справедливости ради: функции из sqlutils также можно встроить и в окружение Jinja и сильно упростить пример из комментариев выше.
Но, к сожалению, останется сбивающая с толку специфика вроде 'ids != None' вместо 'ids is not None', отсутствие списочных выражений и тому подобные «мелочи», вместо которых придётся писать по старинке map/filter.
ef_end_y
Я поступаю проще:
WHERE TRUE и дальше AND condition… AND condition…
cinic
Тестировал в одном проекте >=|<= и BETWEEN для аналогичной выборки. Использование BETWEEN отработало быстрее.