このページは英語から翻訳されています。 英語のページを読む
zeyn::sql は、エンティティを SQL データベースに保持します。グリッドが送る DataQuery(フィルター、並べ替え、ページ、合計、グループ)から一覧を取得し、行バージョンのチェック付きで保存します。PostgreSQL は postgres Cargo feature、Oracle は oracle の背後にあるため、データベースを使わないアプリには、これらは一切コンパイルされません。
zeyn = { version = "0.0.7", features = ["postgres"] } # or ["oracle"], or bothセットアップ
Db を 1 つサービスとして登録し、アクションで受け取ります。
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 | 1 つの文が実行できる最長時間。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 ドライバーは、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 ホームがインストールされている場合に役立ちます。Linux では、RPM をインストールするかLD_LIBRARY_PATHを設定します。Basic Light にはlibaioも必要です。
ライブラリがない場合、最初の接続は DPI-1047: Cannot locate a 64-bit Oracle Client library で失敗します。
Dialect
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 で指定されていない場合は、サーバーのバージョンから決まります。12.1 も 11g と同じ制限であるため、識別子はどの Oracle バージョンでも 30 文字以内に収まる必要があります。それより長いエンティティ名やフィールド名は、その名前を示すエラーになります。
Oracle は空文字列を NULL として保存します。Zeyn は、必須の String フィールドの NULL を "" として読み取り、テキスト列を NULL 許容として書き込み、テキストの並べ替えとページングも NULL 許容列として扱うため、カーソルは正確なままです。Some("") を保持する Option<String> は、読み戻すと None になります。
Oracle のドライバーはブロッキングです。すべての呼び出しは、上限付きプールから取得した接続を使って Tokio のブロッキングスレッド上で実行されるため、非同期ランタイムを止めることはありません。同時に実行されるのは最大でも max_connections 件で、残りは最大 acquire_timeout_ms まで待ちます。
テーブル
列名はフィールド名から付けられます。型は次のとおりです。
| フィールド | PostgreSQL | Oracle |
|---|---|---|
#[key] のエンティティ id | BIGINT GENERATED BY DEFAULT AS IDENTITY | NUMBER(19) の identity(12c 以降)、またはシーケンス付き(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 | HH:MM:SS を保持する VARCHAR2(8) |
DateTime | TIMESTAMPTZ(3) | UTC を保持する TIMESTAMP(3) |
LocalDateTime | TIMESTAMP | TIMESTAMP(0) |
#[choice] の enum | バリアント名を保持する VARCHAR | VARCHAR2 |
| カスタムのフィールド型 | JSON を保持する TEXT | JSON を保持する CLOB |
Option<T> | 同じ列、NULL 許容 | 同じ列、NULL 許容 |
通貨は 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 ではキーのシーケンスも作られます。本番のスキーマはマイグレーションで管理してください。PostgreSQL では次のようにします。
db.migrate(&sqlx::migrate!("./migrations")).await?;
db.migrate_dir("./migrations").await?;1 つ目はマイグレーションをコンパイル時に埋め込み、2 つ目は実行時にフォルダーを読み取ります。sqlx::migrate! を使うには、アプリ自身の Cargo.toml で sqlx の macros と migrate の feature を有効にする必要があります。migrate_dir にはそれ以上必要なものはありません。Oracle のスキーマは、チーム独自のツールでマイグレーションします。create_table は出発点となるスクリプトを提供します。
読み取り
db.find::<E>(&query) は、1 つの接続上で最大 3 つの文を実行します。ページ、件数、合計です。フィルターは 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)。ある時点に対する日付の境界は、ユーザーのタイムゾーンでの丸 1 日をカバーします |
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) は 1 行を読み取り、該当する行がなければ Error::NotFound で失敗します。テーブルやビューは、エンティティを通じてどれでも読み取れます。たとえば Oracle の user_sequences です。
カーソル
番号付きページはオフセットを使います。無限スクロールと仮想スクロールはキーセットカーソルを使います。これは、リストの深い位置でも遅くならず、他の人が行を挿入しても行を飛ばしたり重複させたりしません。
WHERE "code" <= $1 AND (("code" < $2) OR ("code" = $3 AND "id" > $4))
ORDER BY "code" DESC, "id" ASC LIMIT $5ページは、後続があるかどうかを知るために、要求より 1 行多く取得します。そのうえで Page::next がカーソルを運びます。カーソルは base64url の JSON で、並べ替えのフィールド、その方向、各並べ替えキーについての最後の行の値(キーは最後)を含みます。カーソルはクライアントにとっては不透明ですが秘密ではなく、サーバーはこれを決して信用しません。サイズ制限付きでデコードされ、順序がクエリと異なる場合は拒否され、バインドされる前にすべての値が列の型に対して検証されます。不正なカーソルは、クエリではなくバリデーションエラーになります。カーソルが示すのは、どこから続けるかだけです。フィルターと権限は再び適用されます。
グループ化されたページは、カーソルにオフセットを使います。
合計と件数
with_total は、最大 database.count_cap 行まで数えます(上限付きクエリに対する COUNT(*))。そのため、巨大なテーブルでも合計のコストは小さく抑えられ、件数が上限に達した場合、Page::total は上限の値になります。
列の合計は、フィルターに一致するすべての行に対する 1 回の集計文です。メモリ上の規則に従います。空の合計は 0、空の平均は空、金額の平均は 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を実行します。0 行の場合は、他の誰かが先に保存した(または行を削除した)ということで、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)] フィールド、またはユニークなフィールドが 1 つだけの場合はそのフィールドです。フォームは、他のルールと同様に、これを入力欄の下に表示します。制約名は <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 は、1 つの接続上で Db と同じ呼び出しを持ちます。commit せずにドロップするとロールバックされます。Db 自体への呼び出しは、1 件ずつコミットされます。
ストリーム
db.stream::<E>(&query) は、エクスポート向けに、一致するすべての行をページングなしで順番に返します。タスクが STREAM_BUFFER(64)行のチャネルに行を読み込み、読み手が遅れている間は待機するため、結果がどれだけ大きくてもメモリ使用量は一定です。ストリームをドロップするとクエリが停止し、接続が解放されます。RowStream は futures_core::Stream を実装します。
セキュリティ: SQL インジェクション
- 識別子(テーブル名と列名)は、エンティティからのみ取得されます。
E::TABLEとそのFieldMetaのリストです。クライアントがフィルターや並べ替えで送る名前は、そのリストで検索され、そこにない場合は破棄されます。すべての識別子は、検証(英字、数字、_、最大でも Dialect の長さまで)されたうえで、引用符で囲まれます。 - 値は常にバインドパラメータです。
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は 2 つまたは 3 つの文に対して 1 つの接続を使います。 - Oracle は 1 往復あたり 128 行をフェッチします。ページ(最大 501 行)はブロッキングスレッド上で読み取られ、一度に引き渡されます。ストリームは 1 行ずつ進みます。
- ストリームが保持するのは、最大でも
STREAM_BUFFER行です。
テスト
SQL ビルダーには、データベースを必要としない、両方の Dialect 向けのユニットテストがあります。統合テストは、同じ行に対して Db::find と DataQuery::apply を比較し、すべてのキーセットカーソルをたどり、保存、競合、一意制約エラー、トランザクション、ストリームを確認します。対応する変数に 1 つ以上の 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! と表示されるまで待ってください。