2025, Sep 28 21:16

Как безопасно кавычить идентификаторы в сыром SQL с SQLAlchemy Core

Безопасное кавычение идентификаторов в сыром SQL на СУБД с SQLAlchemy Core: используйте IdentifierPreparer и quote_identifier вместо ручного экранирования.

Безопасное кавычение идентификаторов в «сыром» SQL — постоянная боль, когда в SQLAlchemy Core нужны динамические имена таблиц, схем или столбцов. Ручное экранирование хрупкое и часто привязано к одному типу СУБД. Однако есть способ, учитывающий диалект, который позволяет обойтись без угадывания правил каждого бэкенда.

Problem

Представьте, что вы выполняете сырой SQL с переменными именами таблиц, схем и столбцов и пытаетесь экранировать идентификаторы вручную. Цель — получить кросс-СУБД‑безопасный запрос, но самодельные функции экранирования обычно завязаны на конкретный бэкенд и не дают уверенности.

import re, sqlalchemy
def _safe_ident(token, force_quotes=False):
    # ВНИМАНИЕ: работает только в Postgres и не предназначено для продакшена.
    if not force_quotes and re.match(r'^[a-z_][a-z0-9$_]*$', token, re.IGNORECASE):
        return token
    return '"' + token.replace('"', '""') + '"'
def fetch_row(tbl_name, pk_col, pk_value, sch_name='public'):
    qi = _safe_ident
    stmt = sqlalchemy.sql.text(f'''
                                SELECT *
                                FROM {qi(sch_name)}.{qi(tbl_name)}
                                WHERE {qi(pk_col)} = :k;
                                ''')
    args = dict(k=pk_value)
    # выполнить выражение с параметрами и вернуть данные
    # ...

Такой подход имитирует поведение Postgres QUOTE_IDENT и плохо переносится на другие диалекты, например SQLite, MySQL или MSSQL. Даже в случае с Postgres сложно быть уверенным, что учтены все крайние случаи, а использование серверных функций для предварительного кавычения добавляет лишний сетевой проход, который обычно ни к чему.

What’s really going on

Правила кавычения идентификаторов не унифицированы. Разные бэкенды используют свои символы кавычек и правила экранирования. Жёстко зашитые регэкспы и логика кавычек привязывают код к конкретной СУБД и чреваты тонкими багами, когда имена пересекаются с ключевыми словами, содержат пробелы или смешанный регистр. В SQLAlchemy эти правила уже инкапсулированы на уровне диалектов; ручная сборка строк просто обходит этот механизм.

Solution

Используйте IdentifierPreparer диалекта через SQLAlchemy. Он предоставляет методы, применяющие корректные правила кавычения для целевой базы. Точка входа доступна из Engine; ей можно пользоваться и для отдельных идентификаторов, и для имён схем.

from sqlalchemy import create_engine
# Создайте engine для целевой БД; убедитесь, что драйвер установлен
# например, psycopg2 для PostgreSQL или pyodbc для MSSQL
eng = create_engine('postgresql://')
prep = eng.dialect.identifier_preparer
# Кавычьте идентификаторы по правилам диалекта
quoted_tbl = prep.quote_identifier('foo bar')   # даст «foo bar»
quoted_sch = prep.quote_schema('public')
quoted_col = prep.quote_identifier('id')
# Безопасно собираем сырой SQL из заковыченных фрагментов
from sqlalchemy import sql
stmt = sql.text(f'''
                 SELECT *
                 FROM {quoted_sch}.{quoted_tbl}
                 WHERE {quoted_col} = :key
                 ''')
# выполните выражение с параметрами, как обычно

Так сохраняется привычная работа с сырым SQL, а сложную часть — кавычение идентификаторов — вы отдаёте нужному уровню. Для создаваемого engine потребуется подходящий драйвер, например psycopg2 для PostgreSQL или pyodbc для MSSQL.

Dialects without creating an Engine

Если создавать Engine нельзя или не хочется, иногда можно собрать диалект напрямую. Это избавляет от живого подключения, но опирается на детали реализации и не гарантируется между версиями.

from sqlalchemy import URL
url = URL.create('mssql')
entrypoint = url._get_entrypoint()
dialect = entrypoint.get_dialect_cls(url)()  # или get_async_dialect_cls
quoted = dialect.identifier_preparer.quote_identifier('foo bar')  # даст [foo bar]

Здесь используется приватный метод, поэтому способ хрупкий. При возможности отдавайте предпочтение варианту через Engine.

Why this matters

Динамический SQL с подстановкой идентификаторов часто нужен в инструментах, миграциях и билдерах запросов. Ошибки в кавычении ломают переносимость, приводят к трудноразбираемым синтаксическим ошибкам и даже создают риски инъекций. Используя IdentifierPreparer диалекта, вы продолжаете работать с сырым SQL и при этом следуете правилам конкретного бэкенда.

Practical wrap-up

Когда в SQLAlchemy Core требуются переменные имена таблиц, схем или столбцов, не изобретайте своё кавычение. Пользуйтесь eng.dialect.identifier_preparer.quote_identifier и при необходимости — quote_schema. Проверьте, что нужный драйвер доступен, чтобы диалект можно было собрать. Если Engine создавать нельзя, диалект можно получить через URL и приватную точку входа, но относитесь к этому как к нестабильной детали реализации. Значения оставляйте связанными параметрами, а строковую интерполяцию используйте только для идентификаторов, подготовленных диалектом.

Статья основана на вопросе на StackOverflow от doekman и ответе от snakecharmerb.