Skip to content

Repository files navigation

open-sdbl

CI Rust 2024 License: MIT

open-sdbl — библиотека и интерактивная консоль для запросов к информационным базам 1С на PostgreSQL и Microsoft SQL Server. Проект читает служебные метаданные 1С, связывает имена объектов и реквизитов с физической схемой, а затем преобразует запросы SDBL в SQL.

Ядро open-sdbl не выполняет I/O и не имеет production-зависимостей. Оно декодирует переданные приложением DBNames, Config и SchemaStorage, строит снимок метаданных и генерирует SQL в выбранном диалекте. Подключения к СУБД, транзакции, чтение метаданных и прав доступа вынесены в библиотеку open-sdbl-db, которой может пользоваться любое приложение; интерактивный терминал, разбор командной строки и работа с паролями остались в приложении open-sdbl-cli.

Поддерживаются обе раскладки служебных таблиц платформы. В базах 8.2 и ранних 8.3 (до формата 8.3.8) Params/Config не имеют колонки PartNo, а таблиц ConfigCAS и _ExtensionsRestruct нет; в современных базах крупные ресурсы разбиты на части с номерами от нуля. Консоль определяет раскладку одним каталожным запросом (StorageLayout), выбирает подходящие SELECT-выражения, собирает части ресурса перед распаковкой и пропускает чтение расширений, если их таблиц нет. База без SchemaStorage отклоняется типизированной ошибкой: без неё физическую схему восстановить нельзя.

Возможности и ограничения

Компилируются только читающие запросы. Построчная сверка с Синтакс-помощником платформы 8.3.27 — в отдельном документе: Язык запросов 1С: статус поддержки в open-sdbl. Для каждой конструкции там указан статус (✅ поддерживается, 🟡 частично, ❌ нет), диагностика компилятора и страница справки, по которой конструкция проверена. Проект развивается и пока не является полной заменой языка запросов платформы 1С.

CLI в работе

Описание объекта показывает логическое имя, GUID, физическую таблицу, реквизиты и индексы:

Описание метаданных командой \d

Перед исполнением консоль показывает сгенерированный SQL-запрос и отдельно измеряет генерацию SQL и выполнение в СУБД:

Преобразование SDBL в SQL и выполнение запроса

Виртуальные таблицы и представления ссылок компилируются с учётом реальных метаданных информационной базы:

Остатки регистра накопления и представление ссылки

При работе с Microsoft SQL Server консоль генерирует T-SQL, читает данные из таблиц расширений конфигурации и показывает время выполнения на MSSQL:

Запуск open-sdbl из PowerShell и выполнение запроса на Microsoft SQL Server

Начать использовать

Требуется Rust 1.85 или новее:

git clone https://github.com/dobpilot/open-sdbl.git
cd open-sdbl
cargo build --release

Обычная release-сборка из корня создаёт CLI в target/release/open-sdbl. Для сборки только библиотеки используйте cargo build --release --package open-sdbl.

Поддерживаются два провайдера:

Провайдер Порт Драйвер Источник пароля
postgres 5432 tokio-postgres PGPASSWORD, PGPASSFILE, $HOME/.pgpass
mssql 1433 Tiberius/TDS MSSQL_PASSWORD

PostgreSQL

PGPASSFILE="$HOME/.pgpass" ./target/release/open-sdbl console postgres \
  --host db.example.local \
  --database onec \
  --user reader

Из PowerShell:

$securePassword = Read-Host "PostgreSQL password" -AsSecureString
$env:PGPASSWORD = [System.Net.NetworkCredential]::new("", $securePassword).Password

try {
    .\target\release\open-sdbl.exe console postgres `
        --host db.example.local `
        --database onec `
        --user reader
}
finally {
    Remove-Item Env:PGPASSWORD
}

Пароль читается из PGPASSWORD, PGPASSFILE или $HOME/.pgpass. Команда работает через tokio-postgres, не требует установленного psql и выполняет запросы в проверенной read-only транзакции READ COMMITTED.

TLS включён по умолчанию в режиме verify-full: проверяются цепочка сертификата и имя сервера. Доступны --sslmode require, verify-ca и verify-full; значение PGSSLMODE используется, только если флаг не задан. Для корпоративного или собственного CA укажите --trust-ca-file company-ca.pem вместе с verify-ca или verify-full; в этом случае доверие ограничивается сертификатами из указанного файла. Отключение TLS возможно лишь явной парой --sslmode disable --insecure-plaintext и не рекомендуется вне изолированной сети разработки.

Microsoft SQL Server

Рекомендуется отдельный SQL login, включённый в db_datareader, но не в db_datawriter, db_owner или серверную роль sysadmin. Консоль этого не требует и не проверяет: она запрашивает режим «только чтение», оборачивает чтение в собственную транзакцию и откатывает её, а при неудачном откате помечает сессию непригодной — компилятор же порождает только SELECT. Права учётной записи выбирает администратор:

MSSQL_PASSWORD='secret' ./target/release/open-sdbl console mssql \
  --host 192.168.122.222 \
  --database demo \
  --user open_sdbl_reader

Из PowerShell:

$securePassword = Read-Host "MSSQL password" -AsSecureString
$env:MSSQL_PASSWORD = [System.Net.NetworkCredential]::new("", $securePassword).Password

try {
    .\target\release\open-sdbl.exe console mssql `
        --host sql.example.local `
        --database demo `
        --user open_sdbl_reader
}
finally {
    Remove-Item Env:MSSQL_PASSWORD
}

Для тестового сервера с самоподписанным сертификатом к команде можно добавить --trust-server-certificate; этот флаг отключает проверку сертификата и не подходит для production. Предпочтительный вариант для частного CA — --trust-ca-file company-ca.pem: шифрование и проверка сертификата при этом сохраняются.

CLI подключается через TDS, запрашивает ApplicationIntent=ReadOnly и исполняет только фиксированные metadata-SELECT и SELECT, созданные компилятором. SQL Server не имеет эквивалента PostgreSQL READ ONLY для обычной транзакции, поэтому ограниченные права login — обязательная граница безопасности. Перед каждым чтением CLI проверяет на сервере @@TRANCOUNT, уровень изоляции и членство в read-only ролях; сессия с незавершённой транзакцией или write-capable ролью отвергается. Уровень диалекта T-SQL консоль определяет по SERVERPROPERTY('ProductVersion'): SQL Server 2008/2008 R2 получают уровень 2008 (без функций 2012 года, НАЧАЛОПЕРИОДА через DATEADD/DATEDIFF), 2012 и новее — 2012. Выбранный уровень печатается при старте консоли (MSSQL dialect: 2008 (server 10.50.6000.34)), а флаг --mssql-dialect 2008|2012 переопределяет детекцию. Поддерживаются SQL Server 2008 R2 и новее и PostgreSQL 13 и новее — версии, которые поставляет 1С. Чтение каталога сохраняет запасной вариант без LATERAL для серверов до 9.4, но генерируемый SQL рассчитан на 13+.

Смещение дат 1С читается из dbo._YearOffset: консоль автоматически преобразует физические MSSQL datetime-значения и литералы в логические даты 1С. Системная колонка _Version (timestamp/rowversion) проецируется без серверного CAST/CONVERT; CLI отображает полученные восемь байт как 0x0123456789ABCDEF. Такое значение можно использовать как нативный бинарный литерал в фильтре: ГДЕ Version > 0x00000000000007D6. После 0x требуется ненулевое чётное число шестнадцатеричных цифр. Если расширение конфигурации 1С перенаправило строки объекта в таблицу с суффиксом X/X1, MSSQL-компилятор автоматически объединяет её с канонической таблицей. Это применяется к обычным источникам, разыменованию и функциям ПРЕДСТАВЛЕНИЕ()/ПРЕДСТАВЛЕНИЕССЫЛКИ(). Пользователю базы нужны SELECT на схему dbo и видимость определений объектов для чтения sys.tables, sys.columns и sys.indexes; не добавляйте его в db_datawriter и не выдавайте ALTER, CONTROL или EXECUTE.

Сертификат TLS проверяется по умолчанию. Опциональную integration-проверку метаданных можно запустить так:

OPEN_SDBL_MSSQL_TEST_USER=open_sdbl_reader MSSQL_PASSWORD='secret' \
  cargo test -p open-sdbl-db reads_metadata_from_the_mssql_demo_database \
  -- --ignored

По умолчанию тест использует 192.168.122.222:1433/demo; хост, порт и базу можно переопределить через OPEN_SDBL_MSSQL_TEST_HOST, OPEN_SDBL_MSSQL_TEST_PORT и OPEN_SDBL_MSSQL_TEST_DATABASE.

Секреты и SOCKS5

Пароли не принимаются аргументами командной строки. При старте CLI забирает PGPASSWORD, MSSQL_PASSWORD и SOCKS5_PASSWORD в очищаемую память и удаляет переменные из окружения процесса. Файл .pgpass читается через один открытый дескриптор; на Unix он должен быть обычным файлом текущего пользователя с правами 0600 или строже.

Оба провайдера поддерживают --socks5-proxy HOST:PORT. Для прокси с username/password укажите --socks5-user USER, а пароль передайте только через SOCKS5_PASSWORD. Безопасность соединения с базой по-прежнему определяется TLS-режимом провайдера: SOCKS5 сам по себе не заменяет TLS.

Общие возможности CLI

При запуске в терминале загрузка метаданных показывает progress bar с фазой, числом ресурсов Config и объёмом сжатых данных. Config читается потоково и декодируется параллельно на blocking-пуле Tokio с ограниченным числом задач. Progress выводится в stderr, поэтому табличный вывод metadata в stdout можно по-прежнему безопасно перенаправлять или обрабатывать скриптом.

Если база доступна через SOCKS5-прокси (например, через ssh -D), добавьте --socks5-proxy 127.0.0.1:1080. Прокси получает исходное имя из --host и разрешает его на своей стороне. Поддерживаются и анонимный доступ, и аутентификация по RFC 1929: имя задаётся --socks5-user, пароль читается только из SOCKS5_PASSWORD, и при заданных учётных данных клиент не предлагает анонимный метод. Пароль выбранного провайдера по-прежнему читается только из источников выше.

Команда Назначение
\dt список таблиц и объектов метаданных
\di список индексов
\d <имя> реквизиты и индексы объекта
\refresh перечитать метаданные
\set <имя> <литерал> сохранить параметр запроса &имя (число, строка, ИСТИНА/ЛОЖЬ, NULL, ДАТАВРЕМЯ(…), 0x…, ЗНАЧЕНИЕ(Перечисление.X.Y), ЗНАЧЕНИЕ(….ПустаяСсылка), список в скобках или таблица значений с типизированными колонками ТАБЛИЦА(Код КАК СТРОКА, Количество КАК ЧИСЛО, Ссылка КАК Справочник.Номенклатура)(("A", 1, ЗНАЧЕНИЕ(…)), …) для ИЗ &имя; виды колонок — ЧИСЛО, СТРОКА, ДАТА, БУЛЕВО, ЛЮБАЯССЫЛКА, Вид.Объект или (Вид.Объект, …))
\params список сохранённых параметров: имя, литерал как введён, вид
\unset <имя> удалить параметр
\session [<имя> [=] <литерал> | clear] параметры сеанса: сохранить (литералы как у \set), показать список или очистить; их видят все запросы и ограничения доступа, параметр запроса с тем же именем имеет приоритет
\restrict [<Вид>.<Объект>[.<ТабЧасть>] <условие> | clear] ограничения доступа для ВЫБРАТЬ РАЗРЕШЕННЫЕ: сохранить условие на SDBL для таблицы, показать список или очистить; к запросу применяются только ограничения таблиц, которые он читает
\tables список временных таблиц сессии: имя и колонки с видами
\help, \? справка
\q выход

В консоли работают история по стрелкам, подсветка синтаксиса и Tab-дополнение команд, ключевых слов, объектов, полей, виртуальных и временных таблиц.

ВЫБРАТЬ РАЗРЕШЕННЫЕ без сохранённых ограничений выполняется без фильтра. \restrict Справочник.Номенклатура Организация = &Орг вместе с \session Орг = ЗНАЧЕНИЕ(…) оборачивает справочник в отфильтрованную производную таблицу; имя объекта проверяется сразу, текст условия — при первом запросе, ошибка указывает позицию внутри условия и называет таблицу.

Временные таблицы живут в сессии консоли: оператор с ПОМЕСТИТЬ или ДОБАВИТЬ показывает строку Количество, УНИЧТОЖИТЬ печатает список удалённых таблиц и ничего не выполняет, \tables показывает доступные таблицы, а \refresh забывает их вместе с метаданными. Пакет из конфигуратора можно вставлять целиком: консоль выполняет операторы по одному и приходит к тому же результату.

Значения читаются из СУБД в нативных типах и форматируются самой консолью по одним правилам для PostgreSQL и MSSQL: бинарные данные и ссылки — 0x и шестнадцатеричные цифры в верхнем регистре, булевы — true/false, даты — YYYY-MM-DD HH:MM:SS без дробных секунд, числа — с объявленным масштабом (15.50), UUID — канонически в нижнем регистре, отсутствующее значение — NULL. Для PostgreSQL декодеры numeric, timestamp, date и uuid реализованы в самой консоли без дополнительных зависимостей; колонка неподдерживаемого типа приводит к ошибке данных с именем типа, а не к печати мусора.

Подключение open-sdbl к Rust-проекту

Пока crate не опубликован на crates.io, подключите Git-репозиторий или локальный путь:

[dependencies]
open-sdbl = { git = "https://github.com/dobpilot/open-sdbl.git" }

# Для разработки рядом с репозиторием:
# open-sdbl = { path = "../open-sdbl" }

Приложение само получает бинарные ресурсы и каталоги СУБД, затем передаёт их в ядро:

use open_sdbl::metadata::{
    LiveTable, MetadataError, ResolvedMetadata, parse_config_descriptors,
    parse_db_names, parse_schema_storage, resolve_metadata,
};

fn build_metadata(
    db_names_blob: &[u8],
    config_rows: &[(String, Vec<u8>)],
    schema_blob: &[u8],
    live_tables: Vec<LiveTable>,
) -> Result<ResolvedMetadata, MetadataError> {
    let db_names = parse_db_names(db_names_blob)?;
    let schema = parse_schema_storage(schema_blob)?;
    let mut descriptors = Vec::new();

    for (file_name, binary_data) in config_rows {
        descriptors.extend(parse_config_descriptors(file_name, binary_data)?);
    }

    Ok(resolve_metadata(
        db_names,
        descriptors,
        schema,
        live_tables,
    ))
}

Аргументы build_metadata читает ваше приложение; config_rows содержит ресурсы с голым GUID в FileName, собранные из всех частей PartNo (или единственную строку в legacy-базе). Готовые SELECT-only выражения находятся в PostgresMetadataQueries и MsSqlMetadataQueries: сначала выполните LAYOUT, постройте StorageLayout::from_flags, затем берите выражения через db_names(&layout), config(&layout), extension_resources(&layout); ядро намеренно не знает о сети, паролях и async runtime. Полные варианты загрузки через tokio-postgres и Tiberius есть в open-sdbl-db: по модулю на провайдера — db/postgres и db/mssql, где ведение сессии, чтение метаданных и декодирование значений разнесены по отдельным файлам.

Если приложению нужны не только имена, но и сами ресурсы конфигурации, open-sdbl-db читает их одним согласованным чтением:

use open_sdbl_db::{DatabaseSession, Limits, NoProgress};

let mut session = DatabaseSession::connect(&connection, &credentials, Limits::default()).await?;
let configuration = session.configuration(&mut NoProgress).await?;

// Снимок и отчёт — те же, что вернул бы `metadata()`.
let snapshot = &configuration.metadata;
// Каждый ресурс таблицы `Config`, собранный из всех `PartNo`, байтами как
// их хранит база: сырой DEFLATE, без распаковки и перекодирования.
for resource in &configuration.config_resources {
    let _ = (&resource.file_name, &resource.compressed);
}
// Расширения в порядке применения платформой, каждое со своими ресурсами.
// `active` — `Option<bool>`: `None` означает, что запись расширения не в той
// форме, в которой флаг был измерен, и решение остаётся за потребителем.
for extension in &configuration.extensions {
    if extension.active == Some(false) {
        continue;
    }
    let _ = (&extension.identity, &extension.name, extension.order, &extension.resources);
}

Всё это читается в одной read-only транзакции, которую открывает обычное получение метаданных, и возвращаются те самые ресурсы, из которых разрешён снимок: конфигурация читается один раз, а не дважды. Это не атомарный снимок базы — транзакция идёт на READ COMMITTED, каждый оператор видит зафиксированное на момент своего начала, и запись в базу во время чтения может развести соседние операторы. Что операция убирает — это второе чтение Config уже после закрытия сеанса; чтобы узнать, сдвинулась ли конфигурация, сравните CONFIG_FINGERPRINT до и после.

Результат публикуется только после успешного завершения всего чтения; любая ошибка откатывает транзакцию и не отдаёт частичный результат. Ресурсы расширений группируются по корневому индексу каждого расширения, поэтому одинаковые имена в двух расширениях не сливаются, а байты разделяются через Arc<[u8]> и не копируются на каждого держателя. Цена — весь Config в памяти, а рядом с ним хранилище расширений. Limits::config_resource_limit и Limits::config_retained_byte_limit ограничивают каждое хранилище по отдельности: сначала Config, затем самостоятельно ConfigCAS, — так что пик равен потолку столько раз, сколько хранилищ собирается, а не одному потолку на всё чтение. При превышении возвращается типизированная ошибка, а не OOM. Тем, кому нужны только имена, по-прежнему подходит metadata().

resolve_metadata* возвращает ResolvedMetadata: поле snapshot используется для компиляции, а report содержит детерминированный список ResolutionFinding. Отчёт нужно проверять после каждого обновления метаданных: он сообщает об отсутствующих Config-дескрипторах и физических таблицах, неизвестных тегах колонок, повреждённых декларациях, дубликатах GUID и расхождениях индексов. Такие находки не обязательно делают весь снимок непригодным: согласованные объекты и поля остаются доступны, а обращение к неживому объекту завершается типизированной диагностикой.

Коллекции MetadataSnapshot закрыты от внешней мутации и доступны как срезы через db_names(), descriptors(), schema(), live_tables(), objects(), fields(), values() и indexes().

Кроме имени, объект и поле несут то, что Config даёт им для показа: локализованные синонимы в исходном порядке (synonyms) и комментарий дескриптора (comment). Выбирать синоним вручную не нужно — synonym("ru") сравнивает код языка без учёта регистра, обрезает пробелы и считает пустой синоним отсутствующим, а presentation("ru") подставляет имя метаданных, когда синонима нет. То же для объекта по идентификатору: snapshot.object_synonym(id, "ru") и snapshot.object_presentation(id, "ru"). Синоним ничего не переименовывает: язык запросов по-прежнему адресует имя метаданных.

let object = snapshot.object_by_id(id).unwrap();
assert_eq!(object.name.as_deref(), Some("КоррСчет"));
assert_eq!(object.presentation("ru"), Some("Корр. счет"));
assert_eq!(object.presentation("fr"), Some("КоррСчет")); // синонима нет — имя
``` `Prepared<B>` запоминает fingerprint
снимка: попытка завершить подготовленный запрос с другим снимком возвращает
`QueryDiagnosticKind::SnapshotMismatch`. Компиляция также имеет общий бюджет
работы для веток, проекций и разыменований, поэтому патологически большой
запрос завершается типизированной диагностикой вместо неограниченной работы.
Корневая библиотека собирается с `#![forbid(unsafe_code)]`; I/O, сеть и работа
с секретами остаются в CLI-crate.

Изменилась ли конфигурация, можно спросить, не загружая её:
`PostgresMetadataQueries::CONFIG_FINGERPRINT` и одноимённая константа для
MS SQL Server считают на сервере `(число ресурсов, сжатые байты, сумма
хешей)` по тем же ресурсам `Config`, что читает загрузка. Содержимое по
сети не передаётся, запрос один, обе раскладки хранилища работают.
`CONFIG_TOTALS` для этого не годится: перезапись ресурса тем же размером
его не меняет, а хеш — меняет.

`MetadataSnapshot::fingerprint()` отвечает на другой вопрос — «тот ли это
снимок, против которого компилировали»; вычислить его без полной загрузки
нельзя, поэтому для инвалидации кэша нужен запрос выше.

Основной API компиляции — `QueryCompiler<B>`, параметризованный
неизменяемым backend-value. PostgreSQL не имеет состояния, а MSSQL
хранит смещение дат и уровень диалекта:

```rust
use open_sdbl::{
    metadata::MetadataSnapshot,
    query::{
        CompiledQuery, MsSqlBackend, PostgresBackend, QueryCompiler,
        QueryDiagnostic,
    },
};

fn compile_postgres(metadata: &MetadataSnapshot) -> Result<CompiledQuery, QueryDiagnostic> {
    let query = "ВЫБРАТЬ Код, Наименование ИЗ Справочник.Договоры";
    QueryCompiler::new(metadata, PostgresBackend).compile(query)
}

fn compile_mssql(
    metadata: &MetadataSnapshot,
    year_offset: i32,
) -> Result<CompiledQuery, Box<dyn std::error::Error>> {
    let query = "ВЫБРАТЬ ПЕРВЫЕ 10 Код, Наименование ИЗ Справочник.Договоры";
    let backend = MsSqlBackend::new(year_offset)?;
    Ok(QueryCompiler::new(metadata, backend).compile(query)?)
}

year_offset — значение dbo._YearOffset.Offset (0 или 2000). CLI читает его автоматически; при встраивании библиотеки это делает вызывающее приложение. MsSqlBackend::new возвращает Result и отклоняет смещения вне 0..=10_000; backend со смещением ноль можно получить через MsSqlBackend::default().

Вторая часть значения backend — уровень диалекта MsSqlDialectLevel: Sql2012 (по умолчанию) разрешает функции SQL Server 2012+, Sql2008 ограничивается SQL Server 2008/2008 R2 и эмулирует НАЧАЛОПЕРИОДА через DATEADD/DATEDIFF от базы datetime2 '0001-01-01'; логические значения на обоих уровнях совпадают, а все остальные выражения генерируются одинаково. Уровень выбирается builder-ом и не влияет на смещение дат:

use open_sdbl::query::{MsSqlBackend, MsSqlDialectLevel};

let backend = MsSqlBackend::new(2000)?.with_dialect_level(MsSqlDialectLevel::Sql2008);
assert_eq!(backend.dialect_level(), MsSqlDialectLevel::Sql2008);

MsSqlDialectLevel::from_product_version("10.50.6000.34") сопоставляет строку SERVERPROPERTY('ProductVersion') с уровнем (major < 11 → Sql2008); enum помечен #[non_exhaustive]. Генерируемый PostgreSQL-SQL рассчитан на PostgreSQL 13 и новее без отдельного уровня. Legacy free functions удалены: компиляция для обеих СУБД выполняется только через QueryCompiler<B>. CompiledQuery содержит SQL, описание выходных колонок, маркеры отложенных представлений, служебные колонки и вложенные результаты табличных частей (nested); структура помечена #[non_exhaustive]. Исполнение остаётся ответственностью приложения.

Параметры запроса

&Имя в тексте запроса связывается со значением через CompileOptions: значения инлайнятся в SQL типизированными литералами целевого диалекта, плейсхолдеров нет. Имена сравниваются без учёта регистра; отсутствующее, лишнее или продублированное значение, а также список вне В (…) дают QueryDiagnosticKind::Parameter. prepare работает без значений (параметр получает wildcard-вид), они нужны только на compile_with.

use open_sdbl::query::{
    CompileOptions, ParameterDate, ParameterValue, PostgresBackend, QueryCompiler,
    QueryParameter,
};

let parameters = [
    QueryParameter::new("Начало", ParameterValue::Date(ParameterDate::new(2024, 1, 1, 0, 0, 0)?)),
    QueryParameter::new("Сумма", ParameterValue::Number { unscaled: 1550, scale: 2 }),
    QueryParameter::new("Склады", ParameterValue::List(vec![
        ParameterValue::Reference { object: warehouse_object, id: warehouse_id },
    ])),
];
let compiled = QueryCompiler::new(&snapshot, PostgresBackend).compile_with(
    "ВЫБРАТЬ Ссылка ИЗ Документ.Реализация ГДЕ Дата >= &Начало И Сумма > &Сумма И Склад В (&Склады)",
    &CompileOptions::new().parameters(&parameters),
)?;

ParameterValue::Number хранит число как unscaled × 10^-scale (как BigDecimal), Date — проверенную дату (на MSSQL применяется смещение лет), Reference — 16 байт RRRef и объект-цель (нужен для guard по RTRef), Binary — сырые байты, List — список скаляров для В (&Список) (пустой список даёт всегда ложный предикат, NULL внутри допустим), Table — таблица значений для ИЗ &Таблица КАК Т: колонки ParameterColumn { name, kind } с объявленным видом и строки скаляров, проверяемых по нему; строки встраиваются в CTE оператора (SELECT 1 AS "__row", CAST(… AS тип) … UNION ALL SELECT 2, …), так что колонка типизирована и у пустой таблицы. Скалярные параметры рендерятся по типу колонки, как литералы, без проверки вида; строгость только у ссылок.

Ссылочное поле можно сравнивать со значением в формате вывода консоли: Ссылка = 0x9EBC4CED… (16 байт) для одночленной ссылки и 20 байт RTRef ‖ RRRef для runtime-typed поля (Регистратор = 0x00000039…), сравнение раскладывается на _RTRef/_RRRef; иная длина отклоняется диагностикой. ЗНАЧЕНИЕ(<Вид>.<Объект>.ПустаяСсылка) даёт 16 нулевых байт для любого ссылочного вида.

Параметры сеанса

SessionParameters — значения, которые видит каждый запрос и каждое ограничение доступа сеанса, аналог параметров сеанса 1С. &Имя сначала ищется среди параметров запроса, затем среди параметров сеанса; параметр сеанса, который запрос не упоминает, ошибкой не считается. Имена уникальны без учёта регистра, set заменяет значение на месте:

use open_sdbl::query::{CompileOptions, ParameterValue, QueryParameter, SessionParameters};

let mut session = SessionParameters::new();
session.set(QueryParameter::new("ТекущийПользователь", ParameterValue::Reference {
    object: users_object,
    id: current_user_id,
}));
let options = CompileOptions::new().session(&session);

Таблица констант: Константы

ИЗ Константы (FROM Constants) — источник с одним полем на каждую живую константу, как в 1С; стандартных полей нет, * допускается, ссылочные константы разыменовываются обычным LEFT JOIN. В SQL читаются только константы, которые упоминает оператор: по одной ветви UNION ALL на константу из её таблицы _Const<N>, где колонки других констант — CAST(NULL AS <тип колонки>), и MAX по каждой колонке без GROUP BY, поэтому результат — ровно одна строка, а незаписанная константа даёт NULL. На PostgreSQL для 1С MAX определён для всех типов хранения (boolean, bytea, mchar, mvarchar платформа создаёт в схеме public), на SQL Server булевы значения хранятся как binary(1). Оператор без полей констант (КОЛИЧЕСТВО(*)) читает однострочную заглушку. Разделители применяются внутри каждой ветви; отключённый разделитель у используемой константы — диагностика UnsupportedFeature, потому что строк по областям тогда несколько. Отдельная константа читается и как таблица Константа.<Имя> (Constant.<Name>) с единственным полем Значение / Value — именно так её называет платформа; имя самой константы полем этой таблицы не является.

В консоли \d Константы перечисляет константы, а автодополнение предлагает Константы в ИЗ. Имя таблицы имеет приоритет над временной таблицей с таким же именем.

Разделители данных

В базе с общими реквизитами-разделителями (Разделять) каждый индекс каждой разделённой таблицы начинается с колонки разделителя _Fld<N>, и без условия по ней отбор или соединение по ссылке не попадает в индекс. Компилятор читает из Config настройки каждого разделителя: режим (Независимо / Независимо и совместно) и привязанные параметры сеанса значения и использования (в БСП это ОбластьДанныхЗначение и ОбластьДанныхИспользование). Значение берётся из SessionParameters по имени привязанного параметра, а если привязки в метаданных нет — по имени самого общего реквизита. Для каждой физической таблицы, объявляющей колонку разделителя, в SQL добавляется <псевдоним>._Fld<N> = <значение>: для первого источника и сохраняемой стороны одностороннего внешнего соединения — в WHERE, для присоединяемой стороны, разыменований и представлений — в ON, для виртуальных таблиц, ветвей UNION ALL расширений, вложенных запросов и обёрток РАЗРЕШЕННЫЕ — внутри их подзапросов. Литерал повторяется у каждой таблицы, поэтому каждая получает поиск по индексу.

Без значения в сеансе разделитель Независимо и совместно фильтруется пустым значением своего типа (0, "", ЛОЖЬ, пустая дата), то есть запрос видит только общие данные; разделитель Независимо без значения — диагностика Parameter с именем ожидаемого параметра сеанса. Параметр использования, равный ЛОЖЬ, отключает предикат этого разделителя, как ИспользованиеРазделителя = НеИспользовать в 1С. Параметры запроса значение разделителя не задают. База без разделителей и таблица без колонки разделителя дают прежний SQL байт в байт. В консоли достаточно \session ОбластьДанныхЗначение = 7; \d показывает разделители как поля вида DataSeparator, а MetadataField::separation и MetadataSnapshot::separators отдают их настройки приложению.

Ограничения доступа: РАЗРЕШЕННЫЕ

ВЫБРАТЬ РАЗРЕШЕННЫЕ (SELECT ALLOWED) включает построчную фильтрацию таблиц оператора по правилам приложения. Слово допустимо только после первого ВЫБРАТЬ оператора верхнего уровня, перед РАЗЛИЧНЫЕ и ПЕРВЫЕ, и действует на все его вложенные запросы, подзапросы В (…), ветки объединения и виртуальные таблицы; операторы пакета независимы, поэтому ПОМЕСТИТЬ под этим словом кладёт во временную таблицу уже отфильтрованные строки. Библиотека не знает прав пользователя и не выполняет ввод-вывод, так что фильтры запрашиваются у приложения в две фазы, как представления:

QueryCompiler::prepare()
        │
        ├── RestrictionRequest { RestrictionTarget { object, table_part }, … }
        │                                      │
        │                         callback приложения
        │                                      │
        ◄── [AccessRestriction { object, table_part, условие на SDBL }]
        │
        ▼
Prepared::compile_with(&CompileOptions::new().restrictions(&…).session(&…))

Условие пишется на языке запросов в стиле шаблонов ограничений ролей 1С: поля целевой таблицы без квалификатора, одношаговые разыменования (Владелец.Ответственный = &ТекущийПользователь), В (ВЫБРАТЬ …) и параметры сеанса; параметры запроса ограничению не видны, а таблицы, которые читает само условие, не фильтруются. Принимается и полная форма платформы ТекущаяТаблица [КАК Т] ГДЕ …: ТекущаяТаблица и псевдоним квалифицируют поля целевой таблицы, в том числе внутри вложенных запросов условия (… ГДЕ Ключи.Объект = ТекущаяТаблица.Ссылка); соединения перед ГДЕ не поддерживаются. Сырой SQL в ограничении невозможен.

Сами тексты ограничений ролей библиотека читает из конфигурации и разворачивает (см. «Пользователи, роли и ограничения» ниже).

use open_sdbl::query::{AccessRestriction, CompileOptions, PostgresBackend, QueryCompiler};

let prepared = QueryCompiler::new(&snapshot, PostgresBackend)
    .prepare("ВЫБРАТЬ РАЗРЕШЕННЫЕ Ссылка, Наименование ИЗ Справочник.Номенклатура")?;
let restrictions = prepared
    .restriction_request()
    .targets
    .iter()
    .map(|target| AccessRestriction::new(target.object, "Организация В (&Организации)"))
    .collect::<Vec<_>>();
let compiled = prepared.compile_with(
    &snapshot,
    &CompileOptions::new().restrictions(&restrictions).session(&session),
)?;

Обычная таблица оборачивается в производную: (SELECT <все колонки> FROM "_Reference31" AS "__restricted" [LEFT JOIN …] WHERE <условие>) AS <псевдоним> — одинаково для ИЗ и всех видов соединений, включая таблицы с расширениями и табличные части (цель тогда содержит имя табличной части). Срезы, остатки и обороты добавляют условие конъюнкцией к предикату виртуальной таблицы и принимают только прямые поля, как и собственное условие таблицы. С отборами запроса ничего не сливается: цель без ограничения даёт байт в байт тот же SQL, что и без слова РАЗРЕШЕННЫЕ. Ошибка в тексте условия, ограничение для таблицы, которую ни один оператор с РАЗРЕШЕННЫЕ не читает, или два ограничения одной таблицы дают QueryDiagnosticKind::Restriction; позиция указывает внутрь текста условия, сообщение называет таблицу. В этом режиме разыменованные таблицы (Т.Контрагент.Наименование) не фильтруются — для гарантии защищённого чтения есть отдельный режим, описанный ниже.

Ограничения доступа: принудительный режим

РАЗРЕШЕННЫЕ — свойство текста запроса: приложение не может потребовать фильтрации произвольного текста, а разыменования остаются вне запроса ограничений. RestrictionMode::Restricted переносит защиту с текста на саму компиляцию и выбирается при подготовке:

use open_sdbl::query::{
    AccessDecision, CompileOptions, PostgresBackend, PrepareOptions, QueryCompiler,
};

let prepared = QueryCompiler::new(&snapshot, PostgresBackend)
    .prepare_with_options(source, &PrepareOptions::new().restricted())?;

// Каждая цель требует явного ответа: можно всё, можно по условию, нельзя.
let decisions = prepared
    .restriction_request()
    .targets
    .iter()
    .map(|target| AccessDecision::unrestricted(target.clone()))
    .collect::<Vec<_>>();

let compiled = prepared.compile_with(
    &snapshot,
    &CompileOptions::new().decisions(&decisions).session(&session),
)?;

Режим хранится в Prepared и применяется при каждой компиляции; понизить его нечем — CompileOptions поля режима не имеет. Он действует на каждый оператор пакета, вложенные запросы, ветки объединения, явные соединения, виртуальные таблицы и чтение временных таблиц, независимо от наличия слова РАЗРЕШЕННЫЕ, и ключевое слово в текст не подставляется. Одноразовые compile/compile_with остаются нерестриктивными: у них нет фазы запроса, а значит и решений.

Что попадает в запрос ограничений дополнительно к источникам оператора: цель разыменования (Т.Контрагент.Наименование), каждый кандидат составной ссылки и цель представления ссылки. Решение по такой цели действительно применяется — соединение читает обёрнутую таблицу, поэтому строки, которых решение не допускает, не дают значений.

Цель без решения — QueryDiagnosticKind::Restriction с именем объекта и, если цель — табличная часть, её именем. Запрет (AccessDecision::Denied) даёт ту же обёртку с ложным предикатом, а не отсутствие фильтра; ошибка в условии прекращает компиляцию, а не снимает фильтр.

Конструкции, безопасное чтение которых пока не реализовано, режим отклоняет QueryDiagnosticKind::UnsupportedFeature до генерации SQL: обход иерархии (В ИЕРАРХИИ, ИТОГИ … ПО … ИЕРАРХИЯ), источник КритерийОтбора, источник Константы, проекция вложенной табличной части, отложенное представление ссылки и временная таблица, определённая вне такого же режима. Временная таблица, заполненная внутри того же пакета, читается: её определяющий оператор сам был отфильтрован. Последние две конструкции отклоняются потому, что их строки приходят вторым запросом, который выполняет само приложение (CompiledQuery::nested и compile_presentation_lookup), — решения этой компиляции до него не доходят. Сам compile_presentation_lookup режима не имеет и ничего не фильтрует.

Граница доверия сохраняется: условие ограничения — вход приложения, а не пользователя. Таблицы, которые читает само условие, не фильтруются рекурсивно и в запрос не попадают, а параметры запроса условию не видны; текст пользователя в этот путь не попадает. За условия отвечает приложение.

open-sdbl-db отвечает на запрос функцией user_decisions: роли без ограничения дают Unrestricted, с ограничением — развёрнутое условие, отсутствие права — Denied. Отсутствие текущего пользователя, роль с непрочитанными правами и любая ошибка разворачивания прекращают ответ целиком: частично развёрнутый набор не считается достаточным, а нехватка данных не читается как разрешение. Аутентификация пользователя остаётся за приложением.

Пользователи, роли и ограничения

Библиотека читает права из самой базы, без платформы, и складывает их в три слоя.

  • Роли конфигурации. Список ролей — коллекция корневого дескриптора Config, имена — дескрипторы <guid>; права роли — запись <guid>.0 в скобочном формате. parse_role_rights отдаёт RoleRights: по каждому объекту (или его реквизиту, табличной части, команде) права с признаком «дано/отказано» и текстами ограничений на уровне записей, шаблоны ограничений и флаги шапки. Right называет стандартные права (Read/Чтение, Insert, Update, Delete, View, права проведения, истории данных, конфигурации…); соответствие гвидов именам выведено из порядка, в котором платформа пишет права (порядок редактора ролей), и подтверждено ролями-одиночками БСП (ЗапускТонкогоКлиента и т. п.) на 8.3.27; неизвестный гвид остаётся Right::Other. PostgresMetadataQueries::role_rights / MsSqlMetadataQueries::role_rights строят запрос только по нужным ролям — записей .0 в базе десятки тысяч.
  • Пользователи ИБ. USERS читает v8users; decode_user_data разбирает колонку Data (XOR с ключом из самого блоба, затем скобочная запись) в идентификатор, имя, полное имя и гвиды ролей, хеши паролей не отдаёт. InfoBaseUser соединяет строку таблицы с данными и называет роли через RoleCatalog.
  • Разворачивание ограничений (open_sdbl::access). parse_template разбирает текст ограничения или тело шаблона в узлы TemplateNode — текст, #Если с ветвями, вызовы шаблонов с аргументами, #Параметр(N) и прочие имена, — каждый со смещением в байтах; текст с несбалансированными директивами даёт ошибку с позицией. expand_restriction подставляет тела шаблонов роли (#ДляОбъекта("Владелец"), #Параметр(N) и именованные параметры), #ТекущаяТаблица (имя таблицы), #ИмяТекущейТаблицы (оно же строковым значением, в кавычках), #ИмяТекущегоПраваДоступа, ## как один #, разрешает #Если … #Тогда … #ИначеЕсли … #Иначе … #КонецЕсли по параметрам сеанса (строки, булевы, СтрСодержит, +, =, <>, Не, И, Или, Значение(…ПустаяСсылка)); параметр без значения — ошибка с его именем, сообщение шаблона вида Ошибка: … — ошибка с этим текстом. Результат — условие в полной форме платформы ТекущаяТаблица [КАК Т] ГДЕ …, которое принимает компилятор; полная форма платформы может описывать таблицу после ИЗ (ТекущаяТаблица ИЗ #ТекущаяТаблица КАК ТекущаяТаблица ГДЕ …) — так пишут шаблоны «1С:Документооборота», — и тогда псевдоним берётся оттуда. Соединения полной формы (ЛЕВОЕ/ВНУТРЕННЕЕ СОЕДИНЕНИЕ … ПО …) компилятор разворачивает в коррелированный EXISTS с одной строкой-якорем: строка ограничиваемой таблицы проходит, если среди присоединённых к ней строк есть удовлетворяющая условию, — так соединение не размножает строки таблицы. Ограничения двух ролей объединяются через ИЛИ, только если соединяет не больше одного из них. read_access объединяет роли пользователя по объекту и праву: право есть, если его даёт хоть одна роль; роль без ограничения снимает все; ограничения ролей соединяются через ИЛИ. Проверено на шаблонах БСП конфигурации «Бухгалтерия предприятия 3.0».

Консоль показывает всё это и применяет к запросам:

\users                    пользователи базы
\user Петрова (бухгалтер) реквизиты и роли пользователя
\roles Продаж             роли конфигурации (с фильтром по подстроке)
\role ПолныеПрава Справочник.Организации
                          права роли на объекте и тексты ограничений
                          (без объекта — ещё и сигнатуры шаблонов)
\template ЧтениеЭД ДляРегистра
                          из чего состоит тело шаблона и само тело; без
                          имени — шаблоны роли, размер тела и разбирается
                          ли оно
\rls Справочник.Номенклатура [Чтение]
                          ограничения ролей и развёрнутый доступ
                          текущего пользователя (или всех ролей)
\rls                      список ограничений в уже прочитанных правах:
                          роль, объект, право
\as Петрова (бухгалтер)   дальше ВЫБРАТЬ РАЗРЕШЕННЫЕ выполняется от её
                          имени: ограничения её ролей на Чтение
                          разворачиваются в \restrict, остальные таблицы
                          получают ограничение ролей при компиляции,
                          а приглашение консоли называет пользователя

Параметры сеанса, которые читают шаблоны БСП, \as берёт из самой базы: регистр сведений ПараметрыОграниченияДоступа хранит их в ХранилищеЗначения (на MSSQL — прямо в колонке, на PostgreSQL — ссылкой STORHDR на части в binarydata; дальше скобочная сериализация), оттуда приходят ВерсииШаблоновОграниченияДоступа и четыре списка с ограничением; ТекущийПользователь — элемент Справочник.Пользователи по идентификатору пользователя ИБ, ТекущийВнешнийПользователь — пустую ссылку. Значение, заданное руками через \session, не перетирается. Если регистр в конфигурации есть, а прочитать его не удалось — например, он разделён и значение разделителя не задано, — \as называет причину, чтобы её можно было устранить и повторить. На демо-базе УНФ этого хватает, чтобы развернуть 375 ограничений из 470 — остальным нужны параметры, которых в базе нет.

\as разворачивает ограничения ролей пользователя по параметрам сеанса и кладёт их в тот же список, что и \restrict, — одной строкой на объект, на языке 1С:

open-sdbl=> \as Петрова (бухгалтер)
Current user: Петрова (бухгалтер) (4 roles): …
17 restrictions derived into \restrict.
  3 objects not expanded: session parameter «ТекущийВнешнийПользователь» has no value
Петрова (бухгалтер)=> \restrict
* Справочник.Номенклатура  ИСТИНА В (ВЫБРАТЬ ПЕРВЫЕ 1 ИСТИНА ИЗ …)
# * derived from the roles of the current user

Так видно, какое именно условие получит запрос, и любое из них можно заменить своим: \restrict для того же объекта перекрывает развёрнутое и становится обычным ограничением. \as clear и \refresh забывают развёрнутые, \session разворачивает их заново — условие никогда не старше параметров, по которым оно получено. Объекты, которые роли не ограничивают, и табличные части по-прежнему получают доступ при компиляции запроса.

Пользователей и права ролей консоль читает при первой команде, которой они нужны, тем же путём «только чтение», что и запросы, и забывает при \refresh. Роль, ресурса прав которой нет в Config — удалённая из конфигурации или принадлежащая расширению, — ничего не даёт: \as называет такие роли и продолжает, \role и \template сообщают об этом по имени. Права вне Чтения в запросах не участвуют, но в модели сохраняются. Табличная часть получает доступ владельца через Ссылка В (ВЫБРАТЬ … ИЗ <владелец> ГДЕ …); запрещённая таблица даёт условие ЛОЖЬ; ошибка разворачивания (нет параметра сеанса, устаревший шаблон) прерывает запрос с сообщением — параметры задаются \session. Роли расширений читаются из их хранилища: _ExtensionsInfo хранит ключ корневого ресурса расширения, корень — индекс имя → ключ (base64 от двадцати байт), а ConfigCas адресует ресурсы по шестнадцатеричному ключу. Роль, которой нет в Config, консоль ищет там: берёт <guid>.0 как права и <guid> как описатель, поэтому роль расширения и называется, и ограничивает.

Временные таблицы

Базы-копии открыты только на чтение, поэтому ПОМЕСТИТЬ не создаёт таблицу в СУБД: определение компилируется в CTE vt1, vt2, … и подставляется в WITH каждого следующего оператора, который её читает. ДОБАВИТЬ образует новую CTE SELECT … FROM vtK UNION ALL <оператор> и переключает имя на неё, УНИЧТОЖИТЬ SQL не порождает и лишь скрывает имя. Определения хранит TempTablesManager — аналог МенеджерВременныхТаблиц:

use open_sdbl::query::{CompileOptions, PostgresBackend, QueryCompiler, TempTablesManager};

let compiler = QueryCompiler::new(&snapshot, PostgresBackend);
let options = CompileOptions::new();
let mut tables = TempTablesManager::new();

// Возвращает одну строку «Количество», как Запрос.Выполнить() в 1С.
compiler.compile_batch(
    "ВЫБРАТЬ Ссылка КАК Товар, СУММА(Количество) КАК Итог ПОМЕСТИТЬ Обороты
     ИЗ РегистрНакопления.Продажи СГРУППИРОВАТЬ ПО Ссылка;",
    &options,
    &mut tables,
)?;

// WITH "vt1" AS (…) SELECT … FROM "vt1" AS "Т"
let query = compiler
    .compile_batch("ВЫБРАТЬ Т.Товар, Т.Итог ИЗ Обороты КАК Т;", &options, &mut tables)?
    .expect("оператор возвращает строки");

compile_batch возвращает None, когда пакет заканчивается УНИЧТОЖИТЬ: исполнять нечего, как Неопределено в 1С. Менеджер обновляется только при успешной компиляции всего пакета и привязывается к диалекту и снимку метаданных первого определения. prepare_with собирает цели представлений с учётом уже существующих таблиц, Prepared::compile_batch компилирует подготовленный пакет. Обычные compile и prepare тоже принимают пакет, но работают с временным менеджером и дают диагностику TemporaryTable, если пакет не возвращает строк.

Правила определения совпадают с правилами вложенного запроса: * и отложенные представления запрещены, УПОРЯДОЧИТЬ ПО — только вместе с ПЕРВЫЕ, даты остаются в домене хранения, поэтому смещение лет MSSQL применяется ровно один раз в итоговой проекции. ДОБАВИТЬ требует точного совпадения структуры: то же число колонок, совместимые виды, у ссылок одинаковые цели и ширина; расширения типа, в отличие от ОБЪЕДИНИТЬ, не происходит. ИНДЕКСИРОВАТЬ ПО и ИНДЕКСИРОВАТЬ ПО НАБОРАМ разбираются и проверяются по списку выборки, но SQL не порождают: у CTE индексов нет. В WITH попадают только те таблицы, которые достижимы из последнего оператора.

Значения параметров инлайнятся в определение в момент компиляции, поэтому последующее чтение таблицы не требует значений, но и не видит изменений \set: чтобы обновить данные, таблицу нужно уничтожить и создать заново.

Вложенные результаты

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

let compiled = QueryCompiler::new(&snapshot, PostgresBackend)
    .compile("ВЫБРАТЬ Д.Номер, Д.Товары ИЗ Документ.Продажа КАК Д;")?;

for nested in &compiled.nested {
    // nested.sql       — SELECT строк табличной части, уже ограниченный
    //                    владельцами главного оператора и упорядоченный
    //                    по владельцу и номеру строки;
    // nested.columns   — её колонки;
    // nested.label     — имя колонки в логическом результате;
    // nested.position  — её место среди колонок;
    // nested.owner_column / nested.key_column — индексы колонок, по
    //                    которым строки связываются с главным результатом.
}

Ключ владельца добавляется в главный оператор служебной колонкой, её индекс перечислен в CompiledQuery::service_columns — потребитель, показывающий результат человеку, её не печатает. Связь описана данными, а не текстом запроса: приложение со своим планировщиком (коннектор БД) связывает два результата, не читая сгенерированный SQL. ПОМЕСТИТЬ, ИТОГИ и объединение вместе с проекцией табличной части — диагностика.

Типы колонок результата

Ядро не приводит значения к тексту: колонки, скаляры и агрегаты возвращаются в нативных типах СУБД, а форматирование выполняет клиент. Каждый элемент CompiledQuery::columns — это CompiledColumn { label, kind }, где ColumnKind описывает значение структурно:

Вариант Данные Источник
Reference { targets, runtime_typed } ObjectId возможных целей; пусто для универсальной ссылки SchemaStorage R и индекс снимка
Binary { length } длина, если задана каталогом bytea, binary(n), varbinary(n), rowversion
String { length } длина, если задана character varying(n), mvarchar(n), nvarchar(n)
Number { precision, scale } точность и масштаб, если заданы numeric(p,s), целые (scale = 0), float без параметров
Boolean, DateTime, Uuid — boolean/bit, timestamp/datetime2, uuid/uniqueidentifier
Null — литерал NULL; совместим с любым типом в ОБЪЕДИНИТЬ
Undefined — литерал НЕОПРЕДЕЛЕНО; тоже совместим с любым типом
Type — ТИП(…) и ТИПЗНАЧЕНИЯ(…): пять байт, разбирает TypeValue::decode
Unknown { data_type } имя типа каталога всё остальное

Ссылка всегда занимает одну колонку. Поле без члена _RTRef возвращает 16 байт RRRef; поле с _RTRef (составной тип или универсальная ссылка) — 20 байт: 4 байта номера таблицы (big-endian) и 16 байт ссылки, а kind помечен runtime_typed. Прочие члены составного поля (_TYPE, _N, _S, _L, _T) остаются отдельными колонками со своими типами.

Исключения из «нативных типов» ровно два: для MSSQL к датам применяется DATEADD(year, -YearOffset, …), чтобы вернуть логическую дату, а строковые значения PostgreSQL приводятся к text, так как бинарный формат передачи 1С-типов mchar/mvarchar не документирован. Приведение применяется и к колонке такого типа, и к вычисляемому выражению вида String (ЕСТЬNULL, ВЫБОР, агрегат), потому что операнд-расширение делает таким весь результат, причём и во вложенных запросах. ПРЕДСТАВЛЕНИЕ по-прежнему возвращает строку. Ветви ОБЪЕДИНИТЬ с разными вариантами ColumnKind в одной позиции отклоняются типизированной диагностикой до выполнения.

Backend — sealed trait, реализованный библиотекой для PostgresBackend и MsSqlBackend. Он позволяет писать общий код без динамической диспетчеризации, но намеренно не является точкой расширения для сторонних SQL-диалектов:

use open_sdbl::{
    metadata::MetadataSnapshot,
    query::{Backend, CompiledQuery, QueryCompiler, QueryDiagnostic},
};

fn compile<B: Backend>(
    metadata: &MetadataSnapshot,
    backend: B,
    source: &str,
) -> Result<CompiledQuery, QueryDiagnostic> {
    QueryCompiler::new(metadata, backend).compile(source)
}

Типизированные отчёты и диагностики

Не классифицируйте ошибки по тексту. QueryDiagnostic::kind() возвращает QueryDiagnosticKind для лексической или синтаксической ошибки, неизвестного или неоднозначного объекта, поля или значения, неживой таблицы, неподдерживаемой возможности, ошибок presentation-плана и пакета представлений, связывания параметров (Parameter), временных таблиц (TemporaryTable), ограничений доступа (Restriction), исчерпания бюджета разбора или компиляции (TooDeep, WorkBudgetExceeded) и компиляции против чужого снимка метаданных (SnapshotMismatch). Enum помечен #[non_exhaustive], поэтому при match необходима fallback-ветка. offset() измеряется в байтах исходного SDBL, line() и column() — позиции для вывода пользователю. Для обёрнутых lexer/lookup-ошибок стандартный std::error::Error::source() сохраняет исходную причину.

Ошибки декодирования метаданных аналогично доступны через MetadataError::kind() и MetadataErrorKind. Если offset() присутствует, offset_unit() явно различает битовое смещение DEFLATE и байтовое смещение brace-serialized/UTF-8 данных. ResolutionReport отличается от MetadataError: первый описывает восстановимые расхождения уже построенного снимка, второй означает, что конкретный вход декодировать или проверить не удалось.

Callback ABI представлений

Представление ссылки зависит от прикладной политики: одному приложению нужен Наименование (Код), другому — другой шаблон или язык. Поэтому .Представление, ПРЕДСТАВЛЕНИЕССЫЛКИ() и ПРЕДСТАВЛЕНИЕ() компилируются в две фазы:

В проекции соединённого запроса функция может принимать поле, полученное одним разыменованием, например Ссылка.ДоговорКонтрагента или ЦФО.Сам_БизнесРегион. Компилятор переиспользует JOIN разыменования и строит следующий presentation-JOIN от его alias, а не от исходной таблицы.

Аргументом может быть агрегат (ПРЕДСТАВЛЕНИЕ(МАКСИМУМ(Т.Клиент))): такая проекция считается агрегатной, и ветвь агрегирует. Может быть и ВЫБОР с ветвями разных типов — представление проталкивается в ветви, и строковая отвечает своей строкой, а ссылочная — представлением по плану приложения.

SDBL + MetadataSnapshot
        │
        ▼
QueryCompiler::new(snapshot, backend).prepare()
        │
        ├── PresentationRequest { ObjectId/GUID возможных типов ссылок }
        │                                      │
        │                         callback приложения
        │                                      │
        ◄── PresentationPlan { FieldId[], структурированный шаблон }
        │
        ▼
Prepared<PostgresBackend>::compile()
или Prepared<MsSqlBackend>::compile()
        │
        ▼
CompiledQuery { sql, columns, deferred_presentations }

Если SchemaStorage оставляет цель R пустой, конкретный тип универсальной ссылки находится в _RTRef каждой строки. В таком случае CompiledQuery::deferred_presentations отмечает колонки с 20-байтовым бинарным значением RTRef ‖ RRRef (ColumnKind::Reference с runtime_typed): CLI после основного ограниченного запроса группирует только фактически возвращённые ссылки по типу и получает их представления пакетами до 512 значений; ключевая колонка __reference пакетного запроса — сырые 16 байт _IDRRef. ПЕРВЫЕ/LIMIT и фильтры остаются в основном SQL; предварительного сканирования таблицы и JOIN ко всем объектам конфигурации нет. SQL пакетного lookup строит QueryCompiler::compile_presentation_lookup(), а конкретный SQL определяется типом backend, поэтому приложение не склеивает физические идентификаторы самостоятельно.

Контракт использует идентификаторы, а не имена:

Тип Стабильное значение Назначение
ObjectId 16 байт реального GUID 1С возможный тип ссылочного значения
AttributeId 16 байт реального GUID 1С пользовательский реквизит
StandardFieldId #[repr(u32)] стандартное поле без GUID: код, наименование, номер, дата и другие
FieldId Metadata(AttributeId) или Standard(StandardFieldId) любое поле шаблона
PresentationExpression Field, Literal, Concat безопасное дерево выражения без сырого SQL

Lookup-методы MetadataSnapshot::object_id() и MetadataSnapshot::field_id() преобразуют имя в ID при настройке политики. Индексы снимка дают ожидаемый поиск O(1), не считая нормализации имени.

Пример callback-политики Наименование (Код):

use open_sdbl::{
    metadata::{LookupError, MetadataSnapshot},
    query::{
        CompiledQuery, PostgresBackend, PresentationExpression,
        PresentationPlan, QueryCompiler,
    },
};

fn compile_with_presentations(
    source: &str,
    metadata: &MetadataSnapshot,
) -> Result<CompiledQuery, Box<dyn std::error::Error>> {
    let prepared = QueryCompiler::new(metadata, PostgresBackend).prepare(source)?;

    // Это callback приложения. Запрос содержит дедуплицированный набор GUID
    // всех возможных типов ссылок, найденных ядром в SDBL.
    let plans = prepared
        .presentation_request()
        .targets
        .iter()
        .map(|target| -> Result<PresentationPlan, LookupError> {
            let name = metadata.field_id(target.object, "Наименование")?;
            let code = metadata.field_id(target.object, "Код")?;

            Ok(PresentationPlan {
                object: target.object,
                fields: vec![name, code],
                expression: PresentationExpression::Concat(vec![
                    PresentationExpression::Field(name),
                    PresentationExpression::Literal(" (".into()),
                    PresentationExpression::Field(code),
                    PresentationExpression::Literal(")".into()),
                ]),
            })
        })
        .collect::<Result<Vec<_>, _>>()?;

    Ok(prepared.compile(metadata, &plans)?)
}

Для MSSQL используются те же PresentationRequest, PresentationPlan и callback-политика. На первой фазе вызовите QueryCompiler::new(metadata, MsSqlBackend::new(year_offset)?).prepare(source), передав значение _YearOffset в backend. Конструктор отклоняет значения вне диапазона 0..=10000.

Ядро проверяет, что на каждый запрошенный ObjectId получен ровно один план, все FieldId действительно принадлежат объекту, а шаблон использует только разрешённые поля. Для типов, известных из SchemaStorage, оно добавляет необходимые LEFT JOIN, CASE и SQL-выражение представления. Для универсальных ссылок та же проверка выполняется при пакетном lookup после основного запроса. Callback вызывается для типа, а не для каждой строки результата, поэтому приложение может кешировать планы по GUID и поколению метаданных. CLI использует для этого ограниченный кеш Moka.

Note

Здесь ABI означает типизированный контракт между ядром и приложением. Сейчас это публичный Rust API; стабильного extern "C" ABI для подключения из других языков в проекте пока нет. ObjectId::as_bytes() и AttributeId::as_bytes() позволяют построить такой адаптер без передачи строковых имён.

Лицензия

MIT License.

About

Open tooling for the 1C query language (SDBL)

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages