Статьи · Данные и ИИ

Как машина читает SQL

Синтаксические деревья, диалекты и почему CTE ломает парсеры

~9 мин · парсинг, AST, диалекты

SQL выглядит просто. «Выбери всех студентов из таблицы студентов, где факультет — математика». По-русски — одно предложение. На SQL — три строки:

SELECT *
FROM SIS_STUDENT
WHERE DEPARTMENT = 'Mathematics'

Интуитивно, читабельно, почти как обычный язык. Именно поэтому SQL придумали ещё в 1970-х — чтобы с базами данных могли работать не только программисты.

Но попросите компьютер понять этот запрос — и выяснится, что «простота» была иллюзией.

Что такое парсер и зачем он нужен

Когда человек читает SQL, он понимает структуру сразу: вот что берём, вот откуда, вот условие. Мозг справляется с этим настолько легко, что кажется — задача тривиальная.

Для компьютера задача нетривиальна от слова совсем. Компьютер видит строку символов. Чтобы понять, что это запрос, а не случайный текст — и понять его смысл — нужен парсер: программа, которая разбирает текст по грамматике языка.

Парсер превращает SQL-запрос в дерево разбора — AST (Abstract Syntax Tree, абстрактное синтаксическое дерево). Дерево отражает структуру запроса: верхний узел — это весь запрос, дочерние узлы — его части (SELECT, FROM, WHERE), у каждой части — свои дочерние узлы (имена таблиц, колонок, условия).

Для простого запроса выше дерево небольшое и красивое. Три ветки, несколько листьев. Парсер справляется мгновенно.

Когда SQL перестаёт быть простым

Реальные аналитические запросы к корпоративным базам данных не выглядят как три строчки.

Вот типичный запрос из реального хранилища данных университета:

WITH dept_stats AS (
    SELECT d.DEPT_CODE,
           COUNT(DISTINCT s.STUDENT_ID) as student_count,
           AVG(e.CREDIT_HOURS) as avg_credits
    FROM SIS_DEPARTMENT d
    JOIN SIS_ENROLLMENT e ON d.DEPT_CODE = e.DEPT_CODE
    JOIN SIS_STUDENT s ON e.STUDENT_ID = s.STUDENT_ID
    WHERE e.ACADEMIC_YEAR = 2024
    GROUP BY d.DEPT_CODE
),
ranked AS (
    SELECT *,
           RANK() OVER (ORDER BY student_count DESC) as dept_rank
    FROM dept_stats
)
SELECT * FROM ranked WHERE dept_rank <= 10

Тот же вопрос — «топ-10 факультетов по числу студентов» — только теперь это 15 строк, две вложенные части, оконная функция и три таблицы. И это ещё умеренно сложный запрос: в реальных хранилищах встречаются запросы на 200–300 строк с десятью уровнями вложенности.

CTE: таблица, которой нет

Конструкция WITH ... AS (...) называется CTE — Common Table Expression, обобщённое табличное выражение. По виду она похожа на таблицу: у неё есть имя (dept_stats), и дальше к ней обращаются как к таблице (FROM dept_stats).

Но это не таблица. Это временный именованный подзапрос, который существует только внутри текущего запроса. В базе данных таблицы dept_stats нет — она вычисляется на лету.

Для парсера это ловушка. Наивный алгоритм, который ищет в запросе имена таблиц после FROM и JOIN, найдёт и dept_stats — и решит, что это реальная таблица в базе. Это ошибка: dept_stats нужно исключить из списка таблиц и заглянуть внутрь CTE, чтобы найти настоящие таблицы (SIS_DEPARTMENT, SIS_ENROLLMENT, SIS_STUDENT).

Правильный парсер должен это понять. Неправильный — нет. И разница между «правильным» и «неправильным» здесь оказывается значительно меньше, чем хотелось бы.

UNION: два запроса в одном

Другая головная боль — конструкция UNION и UNION ALL. Она объединяет результаты двух разных SELECT-запросов в один набор строк:

SELECT STUDENT_ID, 'undergraduate' as type
FROM UG_STUDENTS
UNION ALL
SELECT STUDENT_ID, 'graduate' as type
FROM GRAD_STUDENTS

Для человека всё понятно: берём студентов из двух таблиц, помечаем тип, объединяем.

Для парсера это структурный вызов. Запрос больше не имеет одной точки входа — их две, и каждая равноправна. Дерево разбора раздваивается. Если внутри каждой ветки ещё есть вложенные подзапросы — дерево начинает ветвиться непредсказуемо.

А теперь совместите CTE и UNION:

WITH combined AS (
    SELECT * FROM UG_STUDENTS
    UNION ALL
    SELECT * FROM GRAD_STUDENTS
)
SELECT COUNT(*) FROM combined

Это уже конструкция, которую многие парсеры разбирают некорректно. Имя combined выглядит как таблица. Внутри него — UNION из двух настоящих таблиц. Корректный парсер должен пройти три уровня понимания, чтобы добраться до реальных имён.

Пятьдесят лет диалектов

К структурной сложности добавляется историческая: SQL существует с 1974 года и за это время оброс диалектами.

MySQL, Oracle, PostgreSQL, Microsoft SQL Server, BigQuery, Snowflake — каждая система говорит на своём «диалекте» SQL. Синтаксис CTE появился в разных системах в разное время. Оконные функции по-разному поддерживаются в разных версиях. Некоторые конструкции, абсолютно законные в Oracle, вызывают синтаксическую ошибку в MySQL.

Это значит, что парсер, написанный для одного диалекта, может неверно разобрать запрос, написанный для другого. Особенно если конструкция использует специфику конкретной СУБД — хранимые процедуры, специальные функции, нестандартные расширения языка.

Что говорят числа

В рамках эксперимента с реальным хранилищем данных (100 вопросов аналитиков, ~100 таблиц в рабочем наборе) парсер на основе библиотеки sqlglot в MySQL-диалекте показал:

  • Recall = 0.96: в 96% случаев нужные таблицы были найдены. Почти всегда парсер видит правильные таблицы.
  • F1 = 0.77: итоговая оценка с учётом и пропущенных, и лишних таблиц — 0.77.

Разрыв между 0.96 и 0.77 говорит сам за себя: парсер почти не пропускает нужные таблицы, но регулярно добавляет лишние — те самые CTE-псевдотаблицы, подзапросные алиасы, виртуальные имена. Почти правильно — и не совсем.

23% ошибок — это не катастрофа для простой утилиты. Но для системы, которая на основе распознанных таблиц строит граф знаний о схеме и учит ИИ работать с данными, каждая ошибка накапливается. Ложный узел в графе — это ложная связь, которая потянет за собой неверные выводы.

Почему это важно для ИИ

Зачем вообще разбирать SQL, если есть языковые модели, которые могут прочитать запрос напрямую?

Ответ в масштабе. Языковая модель может «понять» один запрос. Но чтобы научиться работать с конкретным хранилищем данных — понять, как между собой связаны таблицы, какие поля используются в каких контекстах — нужно проанализировать тысячи исторических запросов. Вручную это невозможно.

Если удаётся правильно разобрать эти тысячи запросов в деревья и превратить деревья в граф — получается структурированное знание о схеме, которое не нужно каждый раз выводить заново. Именно этот граф позволяет системе Seidr Studio отвечать на вопросы не только через поиск похожих слов, но через понимание того, как данные реально используются.

Но всё это работает только если парсер разбирает запросы правильно. CTE остаётся CTE, UNION-ветки раскрываются, алиасы отделяются от настоящих имён.

Дерево или хаос

Есть соблазн думать, что SQL — это простой язык с простой грамматикой. Три ключевых слова, несколько правил — и готово.

На практике SQL — это язык с полувековой историей, десятками диалектов, бесконечными угловыми случаями и конструкциями, которые выглядят как одно, а значат другое.

Правильно разобрать сложный аналитический запрос — это не тривиальная задача. И разрыв между «почти правильно» и «правильно» в этой области стоит дорого.

Деревья разбора не врут. Но чтобы построить правильное дерево, нужно очень хорошо знать грамматику — а SQL за пятьдесят лет превратился в язык с множеством исключений и без единого стандарта.

Читать также