← ./articles-ja

tauri-plugin-sql v2を使っていてもRust command側のqueryはsqlxを使う

tauri-plugin-sql は、frontendがJavaScriptからdatabaseへアクセスしたい時に便利です。WebViewにDB APIを提供し、Rust側で一般的なSQL backendを支えます。

しかしTauriアプリにRust commandがあると、別の疑問が出ます。Rust commandは tauri-plugin-sql 経由でDBを呼ぶべきか、それともRust database crateを直接使うべきか。

実用的な答えは、frontend-side DB accessには tauri-plugin-sql、Rust command handler内では sqlx を直接使う、です。

判断基準

分け方です。

React / TypeScriptが簡単なDB accessを必要とする
  -> tauri-plugin-sql JavaScript API

Rust commandがDB accessを必要とする
  -> Rust内でsqlx

両方が必要
  -> schemaとmigrationは共有し、DB callはそれぞれnativeにする

pluginはRust command用の汎用database layerではありません。主にfrontendへdatabase operationを公開するbridgeです。すでにRust内にいるなら、Rust database libraryを直接使うほうが明確でtestしやすいです。

Rust command内ではsqlxを使う

Rust commandはapp stateからpoolを受け取り、直接queryできます。

use tauri::State;
use sqlx::{Row, SqlitePool};

pub struct AppState {
    pub db: SqlitePool,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Campaign {
    pub id: i64,
    pub name: String,
    pub point_value: i64,
}

#[tauri::command]
pub async fn list_campaigns(
    state: State<'_, AppState>,
) -> Result<Vec<Campaign>, String> {
    let rows = sqlx::query(
        "SELECT id, name, point_value FROM campaigns ORDER BY id DESC"
    )
    .fetch_all(&state.db)
    .await
    .map_err(|error| error.to_string())?;

    Ok(rows
        .into_iter()
        .map(|row| Campaign {
            id: row.get("id"),
            name: row.get("name"),
            point_value: row.get("point_value"),
        })
        .collect())
}

commandはtyped Rust structを返し、frontendは通常のTauri command responseを受け取ります。

databaseは1回初期化する

定期的にDBを使うなら、commandごとに再接続しないようにします。startupでpoolを作り、managed stateに入れます。

pub async fn create_db_pool(app_data_dir: &std::path::Path) -> Result<SqlitePool, sqlx::Error> {
    let db_path = app_data_dir.join("app.sqlite");
    let url = format!("sqlite:{}?mode=rwc", db_path.display());
    SqlitePool::connect(&url).await
}

Tauri builderでmanageします。

tauri::Builder::default()
    .manage(AppState { db })
    .invoke_handler(tauri::generate_handler![list_campaigns])
    .run(tauri::generate_context!())?;

DB lifecycleをUI component内に隠さず、所有者を明確にします。

Rustがschemaを持つならmigrationもRust側に寄せる

Rust commandがbusiness ruleやaggregationを持つなら、schema initializationもRust側に寄せます。

pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
    sqlx::migrate!("./migrations").run(pool).await
}

schemaに依存するcommandが使われる前にmigrationを走らせます。

JavaScript plugin APIが向く場面

plugin APIが向く場面です。

  • UIが軽いlocal preferenceやCRUDを扱う
  • operationがpresentation寄り
  • Rust-only parsingやfilesystem権限が不要
  • 低リスク画面でRust commandを増やしたくない

Rust-side sqlx が向く場面です。

  • queryがTauri commandの一部
  • Rust-only libraryを使う
  • filesystem、parsing、aggregation、batch performanceが必要
  • DB behaviorをRust testで確認したい
  • command responseをtyped Rust structにしたい

business ruleを両側に分散しない

危険なのは「plugin plus sqlx」ではありません。同じbusiness ruleを両側に重複実装することです。

たとえばcampaign status validationをある画面ではJavaScript、別画面ではRustで行うのは避けます。source of truthを1つにします。

検証チェック

  • database file pathはapp data directoryから作る
  • query command前にmigrationが走る
  • Rust commandは必要に応じてsqlxを直接使う
  • frontend plugin queryがRust business ruleを重複しない
  • return structに明示的なserde namingがある
  • migrationとcommand queryのtestがある

参考