Table of Contents

Архитектура

За 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, во время работы, задолго после того, как все тесты прошли.