Эта страница переведена с английского. Читать страницу на английском
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_connections | 10 | Размер пула; запросы ждут свободного соединения |
database.min_connections | 0 | Соединения, остающиеся открытыми в простое |
database.acquire_timeout_ms | 5000 | Максимальное ожидание соединения |
database.idle_timeout_s | 600 | Простаивающие соединения сверх минимума закрываются через это время |
database.statement_timeout_ms | 30000 | Максимальная длительность выполнения запроса; 0 оставляет значение сервера по умолчанию |
database.count_cap | 1000000 | Наибольший итог, подсчитываемый для страницы |
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 postgresOracle Instant Client
Драйвер Oracle это crate oracle поверх ODPI-C. Он компилируется без программного обеспечения Oracle, но во время выполнения загружает клиентскую библиотеку Oracle (oci.dll, libclntsh.so). Oracle Instant Client можно использовать бесплатно:
- Скачайте Instant Client Basic Light для своей платформы со страницы Instant Client на сайте Oracle. Для Windows x64 возьмите zip
instantclient-basiclite-windows.x64-19.*.zip. Версия 19 подключается к Oracle Database от 11.2 до 23ai; более новые клиенты к 11g уже не подключаются. - Распакуйте архив в отдельную папку, например
C:\oracle\instantclient_19. - Поставьте эту папку первой в
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 }:
| PostgreSQL | Oracle 12c и новее (V12) | Oracle 11g (V11) | |
|---|---|---|---|
| Параметры | $1 | :1 | :1 |
| Идентификаторы | "code", до 63 символов | "CODE", до 30 символов | "CODE", до 30 символов |
| Страницы | LIMIT $n OFFSET $m | OFFSET :m ROWS FETCH NEXT :n ROWS ONLY | окно ROWNUM над упорядоченным запросом |
| Новые ключи | identity, RETURNING | identity, RETURNING id INTO :n | <table>_seq.NEXTVAL |
| Чтение после записи | RETURNING строки | чтение по ключу | чтение по ключу |
| Да/нет | BOOLEAN | NUMBER(1) | NUMBER(1) |
| Пустой текст | значение | NULL | NULL |
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.
Таблицы
Столбцы называются по полям. Типы:
| Поле | PostgreSQL | Oracle |
|---|---|---|
#[key] идентификатор сущности | BIGINT GENERATED BY DEFAULT AS IDENTITY | NUMBER(19) identity (12c+) или с sequence (11g) |
String | VARCHAR(max_len) или TEXT | VARCHAR2(max_len CHAR) или VARCHAR2(4000), допускает NULL |
i32, i64 | BIGINT (столбец INTEGER тоже работает) | NUMBER(19) |
bool | BOOLEAN | NUMBER(1) |
Money | BIGINT с суммой в наименьшей единице, плюс <field>_currency VARCHAR(3) | NUMBER(19), плюс <field>_currency |
Decimal | NUMERIC(precision, scale) по правилам поля, NUMERIC(18, 2) без них | NUMBER(precision, scale) |
Date | DATE | DATE |
Time | TIME | VARCHAR2(8) со значением HH:MM:SS |
DateTime | TIMESTAMPTZ(3) | TIMESTAMP(3) со значением в UTC |
LocalDateTime | TIMESTAMP | TIMESTAMP(0) |
enum с #[choice] | VARCHAR с именем варианта | VARCHAR2 |
| Пользовательские типы полей | TEXT с JSON | CLOB с 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_with | LOWER(col) LIKE $1 ESCAPE '\', % и _ в тексте экранируются |
equals | LOWER(col) = $1 |
from, to для чисел и денег | col >= $1, col <= $2 |
from, to для дат и времени | col >= CAST($1 AS date); граница-дата для момента времени охватывает весь день в часовом поясе пользователя |
any_of | col IN ($1, $2, ...) |
is | col = $1 |
blank | col 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.