Написать интерпретатор языка запросов на C# — задача, которая пугает до тех пор, пока не разбить её на части. В этой статье разобрана архитектура и ключевые решения PdfQL — открытой C# библиотеки, реализующей конвейерный язык запросов для извлечения структурированных данных из PDF-документов.
PdfQL — это исследование, а не продукт и не production-библиотека. Мы хотели проверить, насколько практичен единый язык запросов для документов (сначала PDF, потом другие форматы), и сама концепция нам по-прежнему нравится. В реализации есть рабочий сканер, парсер, AST и исполнитель конвейера, написанные на C# под .NET 10 — что делает его полезным справочным примером для тех, кто строит DSL или интерпретатор на .NET.
| Язык | C# |
| Фреймворк | .NET 10 |
| Тип проекта | Библиотека |
| Статус | Исследование, на паузе; демо-сервис остановлен |
| Лицензия | AGPL-3.0 |
| NuGet | |
| Загрузки | |
| GitHub | Laraue.PdfQL |
| Демо-приложение | Laraue.Apps.PdfQL (в архиве) |
Зачем вообще писать язык запросов?
Извлечение структурированных данных из PDF на C# — таблиц, строк, конкретных ячеек — обычно выглядит как набор повторяющегося, хрупкого императивного кода. Открыть документ, найти элементы по позиции, пройтись по строкам циклом, вручную применить условия. Каждая новая задача по извлечению — это снова бойлерплейт.
Идея PdfQL — выражать такие операции декларативно, как SQL выражает запросы к базе данных. Вместо того чтобы писать императивный C# для поиска таблиц, где в колонке 4 значение "Name", и возврата колонки 1, достаточно написать:
select(tables)
->filter((item) => item.GetCell(4).Text() == 'Name')
->selectMany(tableRows)
->map((item) => item.GetCell(1))
Библиотека парсит эту строку, строит план выполнения и запускает его на PDF-документе. Это и есть основной цикл любого интерпретатора — и именно о его построении эта статья.
Почему не SQL-синтаксис
Первый инстинкт при проектировании языка запросов — взять SQL-подобный синтаксис. Он знаком, широко понятен, и у него хорошая поддержка инструментами.
Но SQL оптимизирован под реляционные данные с джойнами. Документы не имеют такой структуры. PDF-файлы редко "джойнятся". Основные операции: выбрать тип элемента, отфильтровать по условию, преобразовать результат. Это не SELECT ... FROM ... WHERE — это конвейер стадий.
Ближайший аналог — агрегационный конвейер MongoDB: каждая стадия получает вывод предыдущей, применяет трансформацию и передаёт результат дальше. PdfQL напрямую принимает эту модель, используя -> как оператор пайпа между стадиями. Синтаксис намеренно ближе к функциональному method chaining, чем к SQL.
Если концепция выйдет за рамки PDF, язык будет переименован в DocQL — та же модель конвейера, применённая к любому формату документов.
Архитектура: три слоя
Интерпретатор языка запросов решает три отдельные задачи:
- Сканер — превращает строку запроса в плоский список токенов
- Парсер — превращает список токенов в структурированное дерево (AST)
- Исполнитель — обходит AST и выполняет каждую операцию над данными
PdfQL добавляет четвёртый слой, специфичный для предметной области: DocumentObjectsExtractor, который конвертирует сырые байты PDF в типизированную последовательность объектов документа (таблицы, абзацы, изображения, формы) до запуска исполнителя запроса.
Слой 1: Сканер
Задача сканера — токенизация: разбить строку запроса на осмысленные единицы, отбросив пробелы и переносы строк.
Полный словарь токенов определён в TokenType.cs:
public enum TokenType
{
Identifier, String, Integer,
Comma, Dot,
LessThan, GreaterThan, LessOrEqualThan, GreaterOrEqualThan,
Assign, Equal, NotEqual, Not,
Minus, Plus, Divide, Multiply,
LeftParentheses, RightParentheses,
LeftBracket, RightBracket,
Lambda, // =>
NextPipeline, // ->
False, True, Null,
New, Eof,
}
Scanner обрабатывает входную строку посимвольно внутри приватного класса ScannerImplementation. Ключевой паттерн: отслеживаются и абсолютная позиция (смещение в сырой строке для нарезки лексем), и относительная позиция (столбец в текущей строке для сообщений об ошибках). Двухсимвольные операторы — ->, =>, ==, != — обрабатываются вспомогательным методом PopNextCharIf, который условно поглощает следующий символ, только если предикат совпадает:
case '-':
AddToken(PopNextCharIf(c => c == '>') ? TokenType.NextPipeline : TokenType.Minus);
break;
case '=':
AddToken(PopNextCharIf(c => c == '>')
? TokenType.Lambda
: PopNextCharIf(c => c == '=')
? TokenType.Equal
: TokenType.Assign);
break;
Сканер возвращает ScanResult, содержащий и массив токенов, и объекты ScanError — так вызывающий код получает структурированную информацию об ошибках вместо исключений на некорректный ввод.
Слой 2: Парсер и AST
Парсер берёт плоский поток токенов и строит Abstract Syntax Tree (AST) — иерархическую структуру, отражающую грамматику запроса.
Базовый узел AST намеренно минимален — Expr.cs — это просто пустой базовый record:
public record Expr
{
}
Все типы выражений наследуют от него. Использование C# record даёт структурное равенство бесплатно, что делает юнит-тесты парсера чистыми — ожидаемые и фактические деревья корректно сравниваются без кастомной логики равенства.
Конкретный пример — BinaryExpr.cs, представляющий любое двуоперандное выражение (==, !=, <, >, + и т.д.):
public record BinaryExpr : Expr
{
public BinaryExpr(Expr left, Token @operator, Expr right)
{
Left = left;
Operator = @operator;
Right = right;
}
public Expr Left { get; init; }
public Token Operator { get; init; } // хранит TokenType и фактический lexeme
public Expr Right { get; init; }
public override string ToString() => $"{Left} {Operator.Lexeme} {Right}";
}
Поле Operator хранит полный Token — не только тип оператора — чтобы сообщения об ошибках могли указывать точный текст источника и позицию. Рекурсивный ToString() делает всё дерево распечатываемым для отладки, что бесценно при тестировании парсера в изоляции.
Parser использует рекурсивный спуск — каждое грамматическое правило становится методом, вызывающим другие методы для подправил. Это наиболее читаемый подход для написанных вручную парсеров и хорошо ложится на C# без инструментов генерации парсеров вроде ANTLR.
Слой 3: Исполнитель
Исполнитель обходит AST и выполняет его. Для конвейерной модели PdfQL это означает:
- Начать с полной последовательности объектов документа, извлечённых из PDF
- Для каждой стадии конвейера применить операцию стадии к текущей последовательности
- Вернуть финальную последовательность
Каждый тип стадии соответствует известной операции над последовательностью:
| Стадия | Операция | Эквивалент в LINQ |
|---|---|---|
select(tables) |
Фильтр по типу | OfType<Table>() |
filter((x) => ...) |
Применение предиката | Where(x => ...) |
selectMany(tableRows) |
Разворачивание вложенной коллекции | SelectMany(x => x.Rows) |
map((x) => ...) |
Проекция в новое значение | Select(x => ...) |
Узлы лямбда-выражений в AST компилируются в C# делегаты Func<> во время выполнения. Имя параметра маппится на аргумент делегата, а тело выражения — которое может включать BinaryExpr, вызовы методов, доступ к свойствам и литералы — вычисляется рекурсивно.
Генерация анонимных типов в рантайме через Reflection.Emit
Одна из наиболее нестандартных частей реализации — AnonymousTypeRegistry.cs. Когда стадия map проецирует объекты документа в новые формы — например, возвращает только текстовое содержимое ячейки, а не весь объект ячейки — исполнителю нужно конструировать результирующие типы, структура которых не известна до парсинга запроса в рантайме.
Стандартный анонимный тип C# (new { Name = "x" }) здесь не подходит — он требует, чтобы форма была известна на этапе компиляции. Вместо этого AnonymousTypeRegistry генерирует настоящие CLR-типы динамически с помощью System.Reflection.Emit:
public class AnonymousTypeRegistry
{
private readonly string _moduleName;
private readonly HashSet<AnonymousTypeProperties> _declaredProperties = new();
private readonly Dictionary<AnonymousTypeProperties, Type> _anonymousClassNames = new();
public Type GetAnonymousType(AnonymousTypeProperties anonymousTypeProperties)
{
if (_declaredProperties.Add(anonymousTypeProperties))
{
var typeName = $"PdfQLAnonymousType_{_declaredProperties.Count}";
var type = AnonymousTypeBuilder.CreateType(_moduleName, typeName, anonymousTypeProperties);
_anonymousClassNames.Add(anonymousTypeProperties, type);
}
return _anonymousClassNames[anonymousTypeProperties];
}
}
AnonymousTypeProperties расширяет ReadOnlyDictionary<string, Type> — ключи это имена свойств, значения — их CLR-типы. Реализации GetHashCode и Equals используют XOR-хэширование по всем парам имя/тип, чтобы реестр корректно дедуплицировал запросы к идентичным формам: два запроса, оба проецирующие в { Text: string }, будут переиспользовать один сгенерированный тип, а не эмитировать его дважды.
Этот паттерн — генерация типов в рантайме и их кэширование по структурному равенству — плохо задокументирован в экосистеме .NET. Большинство статей о Reflection.Emit покрывают генерацию методов; генерация полноценных именованных типов со свойствами, их регистрация в модуле и дедупликация по форме встречается крайне редко. Реализация в PdfQL — рабочий, читаемый справочный пример этой техники.
Демо-приложение
У PdfQL было живое демо: можно было загрузить PDF, написать запрос и получить JSON-вывод; готовые пресеты (извлечь все таблицы, извлечь все изображения) компилировались в PdfQL внутри. Мы его убрали и остановили сервис: не нашли способа сделать из проекта продукт, а времени на него сейчас нет. Сама идея нам по-прежнему кажется интересной, но мы пока не знаем, для чего она могла бы пригодиться.
Веб-API, на котором работало демо, лежит в архивном репозитории: github.com/Laraue/Laraue.Apps.PdfQL. Это тонкая ASP.NET Core обёртка над библиотекой — по-прежнему полезная как референс для хостинга PdfQL в веб-контексте. Сама библиотека не в архиве и находится в организации Laraue: github.com/Laraue/Laraue.PdfQL.
Исходный код
Полная реализация на github.com/Laraue/Laraue.PdfQL. Ключевые файлы:
Interpreter/Scanning/TokenType.cs— полный словарь токеновInterpreter/Scanning/Scanner.cs— реализация сканераInterpreter/Parsing/Expressions/Expr.cs— базовый узел ASTInterpreter/Parsing/Expressions/BinaryExpr.cs— узел бинарного выраженияInterpreter/Parsing/Parser.cs— рекурсивно-спускающий парсерInterpreter/DelegateCompiling/AnonymousTypeRegistry.cs— генерация типов в рантайме
Проект распространяется под лицензией AGPL-3.0.
Что дальше
Текущая реализация покрывает извлечение таблиц с фильтрацией. Проект на паузе, поэтому расширения ниже — это идеи, а не план:
- Поддержка обычного текста —
select(textRows),select(words),select(sentences) - Поддержка изображений — возврат изображений по условиям; функции вроде
resize(600, 400) - Рефакторинг — отвязать исполнитель от PDF-специфичных типов для полноценной реализации концепции DocQL
- Пользовательские функции — регистрация собственных функций, вызываемых из лямбд запроса