Архитектура
За Querio стоит одна мысль: запрос - это объект, а не строка. Всё остальное следует из неё.
Форма
Querio (ядро)
|
схема + spec + построитель + валидатор + QueryChoices + QueryRenderer<T>
|
+--------+---------+---------+---------+---------+---------+
| | | | | | |
.Sql .OneC .Text .Linq .Http .Language
Звезда, а не цепочка. Каждая цель зависит от ядра и больше ни от чего - ни друг от друга, ни от чего-либо вне базовой библиотеки классов. Это проверяется тестом, а не дисциплиной, потому что такое свойство теряется по одной удобной ссылке за раз.
Почему модель семантическая, а не SQL-образная
Большинство построителей запросов - построители SQL. Они исходят из того, что соединение выглядит
как JOIN, постраничность - как LIMIT ... OFFSET, перцентиль - как вызов функции. Это держится,
пока бэкенд не возразит.
Язык запросов 1С возражает почти против всего: другие ключевые слова, другой способ добраться до
связанного поля, никакого OFFSET. Поддержка 1С была не приятным дополнением, а тем ограничением,
которое заставило модель описывать смысл, а не синтаксис. Четыре следствия, каждое видно в
типах:
Значения путешествуют как данные
QueryCondition.Value - это QueryOperand, и литеральный операнд хранит строку в инвариантной
культуре, а не фрагмент текста запроса. Рендерер превращает её в параметр.
// что хранит модель
new QueryCondition(new QueryFieldRef("r", "status"), QueryOperator.Equals)
{
Value = QueryOperand.Literal("500")
}
// что получает SQL Server
// ... WHERE [r].[status] = @p0 при @p0 = 500
Ни одно значение нигде не вклеивается в запрос - ни в одной цели. Это не защитная мера, прикрученная сверху, а следствие того, что значение изначально не текст.
Имена логические
QueryEntity.Source, QueryField.Column и QueryFunction.Source несут физические имена. Запрос
ссылается на requests и apiKeyId, а рендерер выдаёт dbo.RequestLog и api_key_id. Направьте
тот же сохранённый запрос в хранилище с другими именами - он по-прежнему означает то же самое.
Агрегаты и периоды - перечисления
QueryAggregate.Percentile, QueryDateTruncation.Day. Не "PERCENTILE_CONT" и не
"date_trunc('day', ...)". Каждая цель отображает перечисление на то, что у неё действительно есть,
а если нет ничего - говорит об этом, а не изобретает похожее.
Относительное время остаётся относительным
QueryOperand.Ago(30, QueryTimeUnit.Day) хранит смещение, а не момент, в который оно разрешилось.
Сохранённые «последние 30 дней» и через год означают последние 30 дней. SQL отрисовывает это
арифметикой движка (DATEADD, now() + INTERVAL), 1С разрешает в параметр при отрисовке, потому что
эквивалента у неё нет. Один запрос, два честных ответа.
Возможности: падать громко
IQueryCapabilities - это то, как цель сообщает, чего она не умеет. Строится через
QueryCapabilities.All.Without(...):
public static IQueryCapabilities Capabilities { get; } = QueryCapabilities.All.Without(
QueryFeature.Percentile, // у SQLite его нет
QueryFeature.TableFunctions);
Попросите всё равно - получите QueryRenderException с названием возможности. Это важнейшее
проектное решение в проекте. Абстракция, которая молча приближает (считает перцентиль средним,
отбрасывает невыразимый OFFSET), хуже отсутствия абстракции: расхождение всплывает в проде, а не в
месте вызова.
Те же возможности читаются вперёд через QueryChoices, поэтому конструктор не предлагает того, что
упадёт позже:
var choices = QueryChoices.For(spec, schema, SqliteDialect.Instance);
choices.AggregatesFor(durationField); // перцентиля в списке нет
Общий обход
QueryRenderer<TExpression> держит всё, что одинаково для любой цели: разбор участников и типов
полей, определение стороны, к которой цепляется связь, сборку дерева условий, проверку
возможностей. Цель поставляет только смысл узла:
protected abstract TExpression Field(string alias, QueryField field);
protected abstract TExpression Literal(object? value, QueryFieldType type);
protected abstract TExpression Relative(QueryRelativeValue offset);
protected abstract TExpression Call(QueryFunction function, IReadOnlyList<TExpression> arguments);
protected abstract TExpression Comparison(TExpression left, QueryOperator op, QueryFieldType type,
TExpression? right, TExpression? upper);
protected abstract TExpression Membership(TExpression left, QueryOperator op, IReadOnlyList<TExpression> values);
protected abstract TExpression Combine(bool or, IReadOnlyList<TExpression> parts);
TExpression намеренно открыт. Querio.Text использует string. Querio.Linq -
System.Linq.Expressions.Expression. Цель для документного хранилища взяла бы свой тип узла. Ничто в
базе не предполагает, что отрисовка порождает текст, - и в этом разница между моделью запросов и
генератором SQL с лишними шагами.
Валидация не знает диалектов
QueryValidator проверяет, что запрос осмыслен относительно схемы: псевдонимы разрешаются, поля
существуют, сгруппированный запрос не возвращает несгруппированную колонку, условие на вычисляемый
агрегат живёт в HAVING, а не в WHERE. Он ничего не знает ни об одной цели: чего не умеет
конкретный бэкенд - забота проверки возможностей, и эти две вещи разведены намеренно.
Одно правило стоит назвать отдельно, оно тонкое: соединения проверяются по одному, наращивая
множество достижимых участников. Соединение может цепляться только к тому, что объявлено раньше него.
Проверка против всех участников сразу позволила бы саморефлексивной связи поручиться за себя -
from requests join users on user_manager прошло бы валидацию и не значило бы ничего.
Двусторонние цели
Querio.Http и Querio.Text читают обратно то, что записали. Именно поэтому в них безопасно хранить
запрос - ссылка, сохранённый отчёт, одобренная кем-то фраза.
Несимметричность здесь несущая:
- Запись тотальна. Записать можно любой запрос.
- Чтение частично. Текст волен говорить то, для чего в модели нет места.
Поэтому читатель либо восстанавливает ровно записанное, либо отказывается и говорит где, указывая позицию в тексте. Он никогда не спасает то, что понял наполовину: парсер, тихо отбрасывающий непонятое, вернул бы запрос, означающий другое.
Querio.Language читает, но не восстанавливает собственный сахар: путь по внешнему ключу
([r].[apiKeyId].[name]) разворачивается в соединения, и нигде не записано, что соединение было
набрано точкой. Обратная запись покажет соединения. Тот же запрос, другие символы.
Сериализация
QuerySpec и всё под ним - позиционные записи с размеченными объединениями вместо иерархий типов,
поэтому любой сериализатор проходит их без своих конвертеров:
var json = JsonSerializer.Serialize(spec, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
});
Одно следствие знать обязательно: раз типы - позиционные записи, сериализатор сопоставляет JSON с
именами параметров конструктора, а тримминг эти имена срезает. Ядро везёт дескриптор ILLink,
сохраняющий сборку целиком, плюс тесты на то, что дескриптор действительно вложен и что каждый
параметр конструктора совпадает со свойством, которое он заполняет. Без этого публикация с триммингом
падает с ConstructorContainsNullParameterNames - и только в Release, во время работы, задолго после
того, как все тесты прошли.