本页译自英文。 阅读英文页面
zeyn::sql 把 entity 保存在 SQL 数据库中。它根据表格发送的 DataQuery(筛选、排序、分页、合计、分组)列出 entity,并通过行版本检查来保存它们。PostgreSQL 位于 postgres Cargo feature 之后,Oracle 位于 oracle 之后,因此不使用数据库的应用不会编译其中任何内容。
zeyn = { version = "0.0.7", features = ["postgres"] } # or ["oracle"], or both设置
把一个 Db 注册为服务,并在 action 中接收它:
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 的 scheme 决定驱动。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 驱动是基于 ODPI-C 的 oracle crate。它编译时不需要 Oracle 软件,但运行时会加载 Oracle Client 库(oci.dll、libclntsh.so)。Oracle Instant Client 可以免费使用:
- 从 Oracle 的 Instant Client 页面下载适用于你所在平台的 Instant Client Basic Light。在 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 沿用了 EF Core 的 UseOracleSQLCompatibility("11") 的思路:当 URL 没有指定时,由服务器版本决定它。在每个 Oracle 版本上,标识符都限制在 30 个字符以内,因为 12.1 与 11g 有同样的限制;更长的 entity 或字段名会产生一个指明它的错误。
Oracle 把空字符串存为 NULL。Zeyn 把必填 String 字段中的 NULL 读作 "",把文本列写为可空,并把文本作为可空列来排序和分页,因此游标保持精确。保存了 Some("") 的 Option<String> 读回来是 None。
Oracle 的驱动是阻塞式的。每次调用都在 Tokio 的阻塞线程上运行,连接取自有界连接池,因此绝不会拖住异步运行时;同时最多运行 max_connections 个调用,其余的最多等待 acquire_timeout_ms。
表
列以字段命名。类型:
| 字段 | PostgreSQL | Oracle |
|---|---|---|
#[key] entity id | BIGINT GENERATED BY DEFAULT AS IDENTITY | NUMBER(19) identity(12c+)或配合序列(11g) |
String | VARCHAR(max_len) 或 TEXT | VARCHAR2(max_len CHAR) 或 VARCHAR2(4000),可空 |
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 | 保存 HH:MM:SS 的 VARCHAR2(8) |
DateTime | TIMESTAMPTZ(3) | 保存 UTC 的 TIMESTAMP(3) |
LocalDateTime | TIMESTAMP | TIMESTAMP(0) |
#[choice] 枚举 | 保存变体名称的 VARCHAR | VARCHAR2 |
| 自定义字段类型 | 保存 JSON 的 TEXT | 保存 JSON 的 CLOB |
Option<T> | 同一列,可空 | 同一列,可空 |
币种存储为 TRY、USD 和 EUR。日期和时间以 ISO 文本传输,SQL 用固定格式转换它们(TO_DATE(:1, 'YYYY-MM-DD')、to_char(...)),因此 NLS_DATE_FORMAT 或 PostgreSQL 时区等会话设置不会改变结果。Decimal 以不含小数分隔符的精确文本绑定(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,用于脚手架和测试;每个 #[rule(unique)] 字段都有一个名为 <table>_<field>_key 的约束,在 11g 上还有键序列。生产环境的 schema 应放在迁移中。在 PostgreSQL 上:
db.migrate(&sqlx::migrate!("./migrations")).await?;
db.migrate_dir("./migrations").await?;第一种在编译期嵌入迁移,第二种在运行时读取文件夹。sqlx::migrate! 需要在应用自己的 Cargo.toml 中为 sqlx 启用 macros 和 migrate feature;migrate_dir 不需要更多。Oracle 的 schema 用团队自己的工具迁移;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 子句只为可空列书写,因此普通索引可以服务其他排序。
get(key) 读取一行,没有时以 Error::NotFound 失败。任何表或视图都可以通过 entity 读取,例如 Oracle 的 user_sequences。
游标
编号分页使用偏移量。无限滚动和虚拟滚动使用键集(keyset)游标,它在列表深处不会变慢,也不会在他人插入数据时跳过或重复行:
WHERE "code" <= $1 AND (("code" < $2) OR ("code" = $3 AND "id" > $4))
ORDER BY "code" DESC, "id" ASC LIMIT $5一页会多取一行,以便得知后面是否还有数据。随后 Page::next 携带一个游标:base64url 编码的 JSON,内含各排序字段、它们的方向,以及最后一行在每个排序键上的值(键排在最后)。该游标对客户端是不透明的,但并非机密,服务器也绝不信任它。解码时有大小限制,顺序与查询的顺序不一致时会被拒绝,并且每个值在绑定前都会按其列类型进行检查。错误的游标是校验错误,而不是一次查询。它只说明从哪里继续;筛选和权限会重新应用。
分组分页的游标使用偏移量。
合计与计数
with_total 最多统计 database.count_cap 行(对受限查询执行 COUNT(*)),因此对巨大表的总数仍然开销很小;当计数达到上限时,Page::total 就是该上限。
列合计是对所有符合筛选条件的行执行的一条聚合语句。它们遵循内存中的规则:空集合的和为 0,空集合的平均值为空,金额平均值按四舍五入(远离零方向)处理,涉及多种币种的金额没有合计。
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]字段永远不会从 model 写入,因此伪造的表单无法修改它们。服务器代码用自己的 SQL 修改它们:db.execute("UPDATE products SET stock = stock - $1 WHERE id = $2", args)。execute接受字符串字面量,所以值只能以参数形式传入。
唯一约束冲突(PostgreSQL 23505,Oracle ORA-00001)会变成该字段上的校验错误:约束名中包含其名称的 #[rule(unique)] 字段,或唯一的那个 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 注入
- 标识符(表名和列名)只来自 entity:
E::TABLE及其FieldMeta列表。客户端在筛选和排序中发送的名称会在该列表中查找,不在其中的会被丢弃。每个标识符都会被检查(字母、数字、_,长度不超过方言的限制)并加引号。 - 值始终是绑定参数,包括
LIKE模式、游标值、limit 和 offset。任何值都不会被写入 SQL 文本。 - 手写的 SQL 通过
execute(&'static str, args)执行:语句必须是字面量。 - 限制在服务器上被钳制(
MAX_PAGE_SIZE、MAX_FILTERS、MAX_SORT、筛选文本长度、any_of大小),无论客户端要求什么。 - 驱动错误会作为
Error::Internal记录日志,绝不展示给用户。连接 URL 作为Secret读取,绝不记录日志。
性能
- 语句按连接预编译并缓存(sqlx 的语句缓存,Oracle 的语句缓存为 64)。
- 行被读入少数几种简单的单元格类型,并由 serde 反序列化器移入 model:没有中间映射,也没有文本的拷贝。
- 连接池是有界的,每个
find为其两三条语句使用同一个连接。 - Oracle 每次往返获取 128 行。一页(最多 501 行)在阻塞线程上读取并一次性交出;流则逐行进行。
- 流最多保存
STREAM_BUFFER行。
测试
SQL 构建器有针对两种方言的单元测试,不需要数据库。集成测试把 Db::find 与 DataQuery::apply 在同样的行上进行比较,遍历每个键集游标,并检查保存、冲突、唯一性错误、事务和流。当它们的变量包含一个或多个 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 oracleOracle 容器需要几分钟才能启动;请等待 docker logs 中出现 DATABASE IS READY TO USE!。