Перейти к содержимому

База данных

Эта страница переведена с английского. Читать страницу на английском

zeyn::sql хранит сущности (entities) в SQL-базе данных. Модуль выбирает их по DataQuery, который отправляет таблица (фильтры, сортировка, страницы, итоги, группы), и сохраняет их с проверкой версии строки. PostgreSQL подключается через Cargo-feature postgres, Oracle через oracle, поэтому приложение без базы данных не компилирует ничего из этого.

zeyn = { version = "0.0.7", features = ["postgres"] }   # or ["oracle"], or both

Настройка

Зарегистрируйте один Db как сервис и принимайте его в действиях (actions):

let db = Db::from_config(&config).await?;
App::new("Stock").config(config).service(db)

#[action(permission = "stock.product.read")]
pub async fn search_products(q: DataQuery, db: Db) -> Result<Page<Product>> {
    db.find(&q).await
}

#[action(permission = "stock.product.write")]
pub async fn save_product(p: Valid<Product>, db: Db) -> Result<Product> {
    if p.id.0 == 0 { db.insert(&*p).await } else { db.update(&*p).await }
}

Db::from_config читает database.url как секрет, а ограничения пула из database.*:

КлючПо умолчаниюЗначение
database.urlобязателенpostgres://user:password@host:5432/db или oracle://user:password@host:1521/service
database.max_connections10Размер пула; запросы ждут свободного соединения
database.min_connections0Соединения, остающиеся открытыми в простое
database.acquire_timeout_ms5000Максимальное ожидание соединения
database.idle_timeout_s600Простаивающие соединения сверх минимума закрываются через это время
database.statement_timeout_ms30000Максимальная длительность выполнения запроса; 0 оставляет значение сервера по умолчанию
database.count_cap1000000Наибольший итог, подсчитываемый для страницы

Db::connect(url, PoolLimits { .. }) делает то же самое без Config. Драйвер выбирается по схеме URL. Символы вроде @ или : в пароле Oracle записываются в процентном кодировании (%40).

Соединения PostgreSQL открываются лениво, поэтому приложение запускается, даже когда база данных недоступна, а первый запрос сообщает о проблеме. Oracle при подключении запрашивает у сервера его версию, если URL не указывает, какой SQL писать (?compat=11 или ?compat=12); при заданном compat приложение запускается и без базы данных.

Пример inventory хранит товары в памяти или в базе данных, которую называет ZEYN_DATABASE_URL (соберите его с --features postgres или --features oracle):

docker run -d --name zeyn-pg -e POSTGRES_PASSWORD=zeyn -p 55432:5432 postgres:17-alpine
ZEYN_DATABASE_URL=postgres://postgres:zeyn@127.0.0.1:55432/postgres cargo run -p zeyn-example-inventory --features postgres

Oracle Instant Client

Драйвер Oracle это crate oracle поверх ODPI-C. Он компилируется без программного обеспечения Oracle, но во время выполнения загружает клиентскую библиотеку Oracle (oci.dll, libclntsh.so). Oracle Instant Client можно использовать бесплатно:

  1. Скачайте Instant Client Basic Light для своей платформы со страницы Instant Client на сайте Oracle. Для Windows x64 возьмите zip instantclient-basiclite-windows.x64-19.*.zip. Версия 19 подключается к Oracle Database от 11.2 до 23ai; более новые клиенты к 11g уже не подключаются.
  2. Распакуйте архив в отдельную папку, например C:\oracle\instantclient_19.
  3. Поставьте эту папку первой в PATH или задайте путь к ней в ZEYN_ORACLE_CLIENT_DIR. Переменная имеет приоритет над PATH, что помогает, когда установлен другой Oracle home. В Linux установите RPM или задайте LD_LIBRARY_PATH; Basic Light дополнительно требует libaio.

Без библиотеки первое подключение завершается ошибкой DPI-1047: Cannot locate a 64-bit Oracle Client library.

Диалекты

Построитель SQL общий. Dialect предоставляет только то, что различается, и его реализуют Postgres и Oracle { compat }:

PostgreSQLOracle 12c и новее (V12)Oracle 11g (V11)
Параметры$1:1:1
Идентификаторы"code", до 63 символов"CODE", до 30 символов"CODE", до 30 символов
СтраницыLIMIT $n OFFSET $mOFFSET :m ROWS FETCH NEXT :n ROWS ONLYокно ROWNUM над упорядоченным запросом
Новые ключиidentity, RETURNINGidentity, RETURNING id INTO :n<table>_seq.NEXTVAL
Чтение после записиRETURNING строкичтение по ключучтение по ключу
Да/нетBOOLEANNUMBER(1)NUMBER(1)
Пустой текстзначениеNULLNULL

compat следует идее UseOracleSQLCompatibility("11") из EF Core: если URL его не задаёт, значение выбирается по версии сервера. Идентификаторы укладываются в 30 символов во всех версиях Oracle, потому что у 12.1 такое же ограничение, как у 11g; более длинное имя сущности или поля вызывает ошибку с этим именем.

Oracle хранит пустую строку как NULL. Zeyn читает NULL в обязательном поле String как "", записывает текстовые столбцы как допускающие NULL, а текст сортирует и разбивает на страницы как столбец, допускающий NULL, поэтому курсоры остаются точными. Option<String> со значением Some("") читается обратно как None.

Драйвер Oracle блокирующий. Каждый вызов выполняется в блокирующих потоках Tokio с соединением из ограниченного пула, поэтому он никогда не останавливает асинхронную среду выполнения; одновременно выполняется не более max_connections вызовов, остальные ждут до acquire_timeout_ms.

Таблицы

Столбцы называются по полям. Типы:

ПолеPostgreSQLOracle
#[key] идентификатор сущностиBIGINT GENERATED BY DEFAULT AS IDENTITYNUMBER(19) identity (12c+) или с sequence (11g)
StringVARCHAR(max_len) или TEXTVARCHAR2(max_len CHAR) или VARCHAR2(4000), допускает NULL
i32, i64BIGINT (столбец INTEGER тоже работает)NUMBER(19)
boolBOOLEANNUMBER(1)
MoneyBIGINT с суммой в наименьшей единице, плюс <field>_currency VARCHAR(3)NUMBER(19), плюс <field>_currency
DecimalNUMERIC(precision, scale) по правилам поля, NUMERIC(18, 2) без нихNUMBER(precision, scale)
DateDATEDATE
TimeTIMEVARCHAR2(8) со значением HH:MM:SS
DateTimeTIMESTAMPTZ(3)TIMESTAMP(3) со значением в UTC
LocalDateTimeTIMESTAMPTIMESTAMP(0)
enum с #[choice]VARCHAR с именем вариантаVARCHAR2
Пользовательские типы полейTEXT с JSONCLOB с JSON
Option<T>тот же столбец, допускает NULLтот же столбец, допускает NULL

Валюты хранятся как TRY, USD и EUR. Даты и время передаются как текст ISO, а SQL преобразует их фиксированными форматами (TO_DATE(:1, 'YYYY-MM-DD'), to_char(...)), поэтому настройки сеанса, такие как NLS_DATE_FORMAT или часовой пояс PostgreSQL, не меняют результат. Десятичные числа привязываются как точный текст без десятичного разделителя (12345E-2, читается через CAST(... AS NUMERIC) и TO_NUMBER(...)) и читаются обратно через CAST(... AS TEXT) или TO_CHAR(..., 'TM9', 'NLS_NUMERIC_CHARACTERS=''.,'''), поэтому NLS_NUMERIC_CHARACTERS их тоже не меняет.

create_table::<E>(dialect) (или db.create_table::<E>()) пишет CREATE TABLE для заготовок и тестов, с ограничением по имени <table>_<field>_key для каждого поля #[rule(unique)] и, в 11g, с sequence для ключа. Рабочие схемы должны жить в миграциях. В PostgreSQL:

db.migrate(&sqlx::migrate!("./migrations")).await?;
db.migrate_dir("./migrations").await?;

Первый вариант встраивает миграции при компиляции, второй читает папку во время выполнения. sqlx::migrate! требует sqlx с features macros и migrate в собственном Cargo.toml приложения; для migrate_dir больше ничего не нужно. Схемы Oracle мигрируют собственным инструментом команды; create_table даёт начальный скрипт.

Чтение

db.find::<E>(&query) выполняет не более трёх запросов на одном соединении: страница, количество и итоги. Фильтры читаются тем же кодом, что и DataQuery::apply, поэтому список ведёт себя одинаково на данных в памяти и в SQL:

ФильтрSQL
contains, starts_withLOWER(col) LIKE $1 ESCAPE '\', % и _ в тексте экранируются
equalsLOWER(col) = $1
from, to для чисел и денегcol >= $1, col <= $2
from, to для дат и времениcol >= CAST($1 AS date); граница-дата для момента времени охватывает весь день в часовом поясе пользователя
any_ofcol IN ($1, $2, ...)
iscol = $1
blankcol IS NULL, col IS NOT NULL

contains для столбцов с деньгами и да/нет в SQL игнорируется. Текст сортируется по правилам сопоставления базы данных (Oracle: NLS_SORT).

Каждая сортировка заканчивается ключом, поэтому порядок полный. NULL идут первыми при сортировке по возрастанию и последними по убыванию, как в памяти; предложение NULLS пишется только для столбцов, допускающих NULL, чтобы обычные индексы обслуживали остальные сортировки.

get(key) читает одну строку и завершается ошибкой Error::NotFound, если строки нет. Через сущность можно читать любую таблицу или представление, например user_sequences в Oracle.

Курсоры

Нумерованные страницы используют смещения. Бесконечная и виртуальная прокрутка используют курсоры keyset, которые не замедляются в глубине списка и не пропускают и не повторяют строки, когда другие вставляются:

WHERE "code" <= $1 AND (("code" < $2) OR ("code" = $3 AND "id" > $4))
ORDER BY "code" DESC, "id" ASC LIMIT $5

Страница получает на одну строку больше запрошенного, чтобы узнать, есть ли продолжение. Тогда Page::next несёт курсор: JSON в base64url с полями сортировки, их направлениями и значением последней строки для каждого ключа сортировки (ключ последним). Курсор непрозрачен для клиентов, но не секретен, и сервер ему никогда не доверяет. Он декодируется с ограничением размера, отклоняется, если его порядок отличается от порядка запроса, а каждое значение проверяется на соответствие типу столбца до привязки. Плохой курсор это ошибка валидации, а не запрос. Он лишь говорит, откуда продолжать; фильтры и разрешения применяются снова.

Сгруппированные страницы используют смещения для своих курсоров.

Итоги и количества

with_total считает не более database.count_cap строк (COUNT(*) по ограниченному запросу), поэтому итог по огромной таблице остаётся дешёвым; когда счёт достигает предела, Page::total равен пределу.

Итоги по столбцам это один агрегирующий запрос по всем строкам, подходящим под фильтры. Они следуют правилам работы в памяти: пустая сумма равна 0, пустое среднее пусто, среднее денег округляется half away from zero (от нуля), а у денег в более чем одной валюте итога нет.

DataQuery::group превращается в GROUP BY с количеством и запрошенными итогами по каждой группе.

Запись

  • insert(&row) оставляет новый ключ (0) базе данных, устанавливает версию строки в 1 и возвращает сохранённую строку.
  • update(&row) выполняет UPDATE ... SET version = version + 1 WHERE id = $1 AND version = $2. Ноль строк означает, что кто-то другой сохранил раньше (или удалил строку): Error::Conflict.
  • delete::<E>(key) завершается ошибкой Error::NotFound, если ничего не удалено.
  • Поля #[readonly] никогда не записываются из модели, поэтому поддельная форма не может их изменить. Серверный код меняет их собственным SQL: db.execute("UPDATE products SET stock = stock - $1 WHERE id = $2", args). execute принимает строковый литерал, поэтому значения могут поступать только как параметры.

Нарушение уникальности (PostgreSQL 23505, Oracle ORA-00001) становится ошибкой валидации поля: поля #[rule(unique)], чьё имя входит в имя ограничения, или единственного уникального поля. Форма показывает её под полем ввода, как любое другое правило. Называйте ограничения <table>_<column>_key.

Транзакции

let mut tx = db.begin().await?;
let order = tx.insert(&order).await?;
tx.execute("UPDATE products SET stock = stock - $1 WHERE id = $2", vec![qty.into(), id.0.into()]).await?;
tx.commit().await?;

У Tx те же вызовы, что у Db, на одном соединении. Если его отбросить без commit, происходит откат. Вызовы самого Db фиксируются по одному.

Потоки

db.stream::<E>(&query) возвращает все подходящие строки по порядку без разбиения на страницы, для экспорта. Задача читает строки в канал на STREAM_BUFFER (64) строки и ждёт, пока читатель отстаёт, поэтому память остаётся ровной при любом размере результата. Если отбросить поток, запрос останавливается и соединение освобождается. RowStream реализует futures_core::Stream.

Безопасность: SQL-инъекции

  • Идентификаторы (имена таблиц и столбцов) берутся только из сущности: E::TABLE и её списка FieldMeta. Имена, которые клиент присылает в фильтрах и сортировках, ищутся в этом списке и отбрасываются, если их там нет. Каждый идентификатор проверяется (буквы, цифры, _, не длиннее, чем допускает диалект) и заключается в кавычки.
  • Значения всегда являются привязанными параметрами, включая шаблоны LIKE, значения курсоров, лимиты и смещения. Ни одно значение никогда не записывается в текст SQL.
  • Рукописный SQL проходит через execute(&'static str, args): запрос должен быть литералом.
  • Лимиты ограничиваются на сервере (MAX_PAGE_SIZE, MAX_FILTERS, MAX_SORT, длина текста фильтра, размер any_of), что бы ни запросил клиент.
  • Ошибки драйвера записываются в журнал как Error::Internal и никогда не показываются пользователям. URL соединения читается как Secret и никогда не попадает в журнал.

Производительность

  • Запросы подготавливаются и кэшируются для каждого соединения (кэш запросов sqlx, кэш запросов Oracle на 64).
  • Строки читаются в несколько простых типов ячеек и переносятся в модель десериализатором serde: без промежуточной карты и без копирования текста.
  • Пулы ограничены, и каждый find использует одно соединение для своих двух или трёх запросов.
  • Oracle получает 128 строк за один обмен с сервером. Страница (не более 501 строки) читается в блокирующем потоке и передаётся сразу; потоки идут строка за строкой.
  • Потоки хранят не более STREAM_BUFFER строк.

Тесты

У построителя SQL есть модульные тесты для обоих диалектов, которым не нужна база данных. Интеграционные тесты сравнивают Db::find с DataQuery::apply на одних и тех же строках, проходят каждый курсор keyset и проверяют сохранения, конфликты, ошибки уникальности, транзакции и потоки. Они запускаются, когда их переменная содержит один или несколько URL (через пробел), иначе сообщают, что пропущены:

docker run -d --name zeyn-pg -e POSTGRES_PASSWORD=zeyn -p 55432:5432 postgres:17-alpine
ZEYN_TEST_PG=postgres://postgres:zeyn@127.0.0.1:55432/postgres cargo test -p zeyn --features postgres --test postgres

docker run -d --name zeyn-ora11 -p 15211:1521 -e ORACLE_PASSWORD=zeyn -e APP_USER=zeyn -e APP_USER_PASSWORD=zeyn gvenzl/oracle-xe:11-slim
docker run -d --name zeyn-ora21 -p 15221:1521 -e ORACLE_PASSWORD=zeyn -e APP_USER=zeyn -e APP_USER_PASSWORD=zeyn gvenzl/oracle-xe:21-slim
ZEYN_TEST_ORACLE="oracle://zeyn:zeyn@127.0.0.1:15211/XE oracle://zeyn:zeyn@127.0.0.1:15221/XEPDB1" cargo test -p zeyn --features oracle --test oracle

Контейнерам Oracle нужно несколько минут на запуск; дождитесь DATABASE IS READY TO USE! в docker logs.