本文へスキップ

データベース

このページは英語から翻訳されています。 英語のページを読む

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_connections10プールサイズ。リクエストは空き接続を待ちます
database.min_connections0アイドル時にも開いたままにしておく接続数
database.acquire_timeout_ms5000接続を待つ最長時間
database.idle_timeout_s600最小数を超えるアイドル接続は、この時間が過ぎると閉じられます
database.statement_timeout_ms300001 つの文が実行できる最長時間。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 ドライバーは、ODPI-C 上の oracle crate です。Oracle のソフトウェアがなくてもコンパイルできますが、実行時に Oracle Client ライブラリ(oci.dll、libclntsh.so)を読み込みます。Oracle Instant Client は無償で利用できます。

  1. Oracle の Instant Client のページから、お使いのプラットフォーム向けの Instant Client Basic Light をダウンロードします。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 ホームがインストールされている場合に役立ちます。Linux では、RPM をインストールするか LD_LIBRARY_PATH を設定します。Basic Light には libaio も必要です。

ライブラリがない場合、最初の接続は DPI-1047: Cannot locate a 64-bit Oracle Client library で失敗します。

Dialect

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 は、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 まで待ちます。

テーブル

列名はフィールド名から付けられます。型は次のとおりです。

フィールドPostgreSQLOracle
#[key] のエンティティ idBIGINT GENERATED BY DEFAULT AS IDENTITYNUMBER(19) の identity(12c 以降)、またはシーケンス付き(11g)
StringVARCHAR(max_len) または TEXTVARCHAR2(max_len CHAR) または VARCHAR2(4000)、NULL 許容
i32、i64BIGINT(INTEGER 列でも動作します)NUMBER(19)
boolBOOLEANNUMBER(1)
Money最小単位の金額を BIGINT で、加えて <field>_currency VARCHAR(3)NUMBER(19)、加えて <field>_currency
Decimalフィールドのルールから決まる NUMERIC(precision, scale)、ルールがなければ NUMERIC(18, 2)NUMBER(precision, scale)
DateDATEDATE
TimeTIMEHH:MM:SS を保持する VARCHAR2(8)
DateTimeTIMESTAMPTZ(3)UTC を保持する TIMESTAMP(3)
LocalDateTimeTIMESTAMPTIMESTAMP(0)
#[choice] の enumバリアント名を保持する VARCHARVARCHAR2
カスタムのフィールド型JSON を保持する TEXTJSON を保持する 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_withLOWER(col) LIKE $1 ESCAPE '\'。テキスト中の % と _ はエスケープされます
equalsLOWER(col) = $1
数値と金額に対する from、tocol >= $1、col <= $2
日付と時刻に対する from、tocol >= CAST($1 AS date)。ある時点に対する日付の境界は、ユーザーのタイムゾーンでの丸 1 日をカバーします
any_ofcol IN ($1, $2, ...)
iscol = $1
blankcol 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 oracle

Oracle のコンテナは起動に数分かかります。docker logs に DATABASE IS READY TO USE! と表示されるまで待ってください。