Как добавить новые операторы для Python выражений¶
Библиотека sqlbuilder использует перегрузку операторов языка программирования Python для создания критериев выборки, что позволяет транслировать операторы языка программирования в операторы SQL.
К сожалению, Python поддерживает не так много операторов, как PostgreSQL, например, таких операторов как @>
, &>
, -|-
, @-@
и т.д.
Это вносило неудобство и нарушало единообразность.
Разумеется, данную проблему легко решить простым использованием метода Expr.op()
или класса Binary
, например:
>>> T.user.age.op('<@')(func.int8range(25, 30))
<Binary: "user"."age" <@ INT8RANGE(%s, %s), [25, 30]>
>>> Binary(T.user.age, '<@', func.int8range(25, 30))
<Binary: "user"."age" <@ INT8RANGE(%s, %s), [25, 30]>
Но надо признать, что такой подход имеет не лучшую читабельность, и хочется более элегантного решения.
1. Первая идея была - использовать этот Infix. Для этого, правда, каждому оператору пришлось бы дать имя в соответствии с требованиями к идентификаторам.
И это большая проблема, так как семантика каждого оператора PostgreSQL может отличаться в зависимости от типа операнда, тем более, в PostgreSQL можно легко создавать свои собственные типы данных и определять их поведение.
Например, оператор @@
для геометрического типа имеет значение “center”, а для типа tsvector
- “matches”.
Оператор &&
для геометрического типа имеет значение “overlaps”, а для типа tsvector
- “and”.
2. Это натолкнуло на мысль отказаться от процедурного стиля, и использовать объектно-ориентированный. Каждое выражение может обладать типом данных (что реализуется в виде композиции с делегированием), который определяет поведение. Таким образом, каждое выражение обладает определенным набором методов, свойственных данному типу, который отображает список допустимых SQL-операторов. Аналогичный подход используется в sqlalchemy.
Данный подход также имеет свои недостатки.
Чтобы их понять, нужно понимать, как устроен полиморфизм в PostgreSQL.
Для этого нужно заглянуть в табличку pg_operator
, или выполнить в консоли мета-команду \doS+
.
В PostgreSQL существует таблица, которая хранит информацию об операторе, типах данных его операндов, и возвращаемом типе данных.
Очевидно, что эту табличку пришлось бы воспроизвести в Python в виде какого-то реестра операторов.
И тут возникают проблемы.
Если информация об операторе сосредоточена в этом реестре операторов, то тогда класс типа данных будет дублировать его функции, ведь он тоже определяет связи между операторами и типами. Данную проблему можно было бы решить с помощью мета-программирования, динамически создавая классы.
Но, во-первых, это не устранит фактического дублирования, а просто автоматизирует его.
Во-вторых, тут мы упираемся в предыдущую проблему с именами, так как имя метода должно учитывать его контекст, точнее, тип данных. Впрочем, эта проблема тоже решаемая путем небольшой избыточности реестра операторов.
В-третьих, реестр операторов может быть различным для каждой БД, и даже для каждой схемы. Это уже проблема.
В-четвертых, он может выйти из синхронизации с реальным списком операторов в базе данных.
В-пятых, это будет усложнять sqlbuilder, и возлагать на него несвойственные для него обязанности.
В-шестых, пользователь всегда может применить класс оператора напрямую.
Проблема в том, что использование простого функционального стиля лишено всех этих проблем, хотя и делает чтение SQL-выражений несколько необычным.
По этим причинам, бранч operable прекратил свое развитие.
3. На мысль о парсере меня натолкнула статья “Sqlalchemy In Reverse”. Попытка использовать pyparsing встретила определенные трудности, прежде всего с производительностью. Решение пришло само, когда мне подвернулся вот такой элегантный и легковесный нисходящий парсер. Он, правда, немного нарушает SRP принцип, совмещая класс токена и узла AST-дерева, но ради ощутимого выигрыша в компактности от такого нарушения, можно закрыть на это глаза.
Так появился модуль sqlbuilder.smartsql.contrib.evaluate, который позволяет совмещать Python-выражения и SQL-операторы.
>>> from sqlbuilder.smartsql import *
>>> from sqlbuilder.smartsql.contrib.evaluate import e
>>> required_range = func.int8range(25, 30)
>>> e("T.user.age <@ required_range AND NOT(T.user.is_staff OR T.user.is_admin)", locals())
<Binary: "user"."age" <@ INT8RANGE(%s, %s) AND NOT ("user"."is_staff" OR "user"."is_admin"), [25, 30]>
Существует еще и способ добавить новые конструкции непосредственно в синтаксис языка программирования Python, детали реализации смотрите в библиотеке pythonql.