Как добавить новые операторы для 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]>

Comments

comments powered by Disqus