SQLite データベース操作 (CRUD)

SQL プラグインを導入し(sqlite 機能と権限 sql:allow-execute)、execute() と select() で追加・参照・更新・削除する。真偽値や数値の渡し方と、トランザクションを Rust で組む理由も示す。

データ保存 対象: Tauri 2.x 更新日: 読了目安: 約10分 db-001
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. DB を開いて CRUD する
  4. JS の値がどう保存されるか
  5. 複数の書き込みをまとめて確定したいとき
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

件数の多いデータを検索・並べ替えするなら、アプリ内に SQLite のファイルを持つのが手堅い方法です。公式の SQL プラグインを入れると、フロントエンドから execute()(書き込み)と select()(読み取り)で SQL を実行できます。導入と CRUD に加えて、JS の値がどう保存されるかと、複数の書き込みをまとめて確定するときの注意を説明します。値の埋め込み方は プリペアドステートメント、テーブル定義の変更は マイグレーション で扱います。

前提条件

npm run tauri add sql
cd src-tauri
cargo add tauri-plugin-sql --features sqlite

tauri add は機能を指定せずに追加するので、ドライバーを features で選びます(sqlite / mysql / postgres)。忘れると Database.load() が「invalid connection url: sqlite:app.db - No database driver enabled!」で失敗します。

[dependencies]
tauri-plugin-sql = { version = "2", features = ["sqlite"] }

core:default に SQL の権限は含まれません。sql:default は allow-load / allow-select / allow-close だけなので、execute() 用に sql:allow-execute を足します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "sql:default",
    "sql:allow-execute"
  ]
}

この権限では開ける DB を絞れません。sql:allow-execute を外しても select() に渡した DELETE や DROP TABLE は実行されるので、読み取り専用の仕組みにもなりません。外部のページを表示するウィンドウには付けないでください。

1. フロントエンドから実装する (TypeScript)

DB を開いて CRUD する

sqlite: の後の相対パスはアプリの設定フォルダーが基準で、ファイルが無ければ作られます。load() は呼ぶたびに接続を開き直すので、1 回だけ呼んで使い回します。接続を作らない Database.get() は、load() の後でないと「database sqlite:app.db not loaded」になります。

import Database from '@tauri-apps/plugin-sql';

export type Todo = { id: number; title: string; done: number; created_at: string };

// load() は 1 回だけ。以降は同じ Promise を返す
let dbPromise: Promise<Database> | null = null;
export function getDb(): Promise<Database> {
  dbPromise ??= Database.load('sqlite:app.db').then(async (db) => {
    // 試すための簡易版。配布するアプリではマイグレーションで作る
    await db.execute(`CREATE TABLE IF NOT EXISTS todos (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      done INTEGER NOT NULL DEFAULT 0,
      created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    )`);
    return db;
  });
  return dbPromise;
}

// 追加: 採番された id を返す
export async function addTodo(title: string): Promise<number> {
  const db = await getDb();
  const r = await db.execute('INSERT INTO todos (title) VALUES ($1)', [title]);
  return r.lastInsertId ?? 0; // UPDATE / DELETE の後の lastInsertId は当てにしない
}

// 参照: 列名がキーのオブジェクトの配列。0 件なら []
export async function listTodos(): Promise<Todo[]> {
  const db = await getDb();
  return db.select<Todo[]>('SELECT id, title, done, created_at FROM todos ORDER BY id DESC');
}

// 更新: 真偽値は 1 / 0 に直して渡す
export async function setDone(id: number, done: boolean): Promise<boolean> {
  const db = await getDb();
  const r = await db.execute('UPDATE todos SET done = $1 WHERE id = $2', [done ? 1 : 0, id]);
  return r.rowsAffected === 1; // 該当なしでもエラーにはならない
}

// 削除
export async function removeTodo(id: number): Promise<void> {
  const db = await getDb();
  await db.execute('DELETE FROM todos WHERE id = $1', [id]);
}

JS の値がどう保存されるか

渡す値保存される値読み戻したとき
'abc'文字列'abc'
30(整数でも)小数 30.0。INTEGER 列では 30、TEXT 列では '30.0'30 / '30.0'
true / false文字列 'true' / 'false''true'
nullNULLnull
オブジェクト・配列JSON の文字列文字列

true のまま渡すと WHERE done = 1 に一致しないので、setDone() のように 1 / 0 で渡します。BOOLEAN と宣言した列も読み戻すと 1 / 0 です。郵便番号のような数字だけの文字列は文字列のまま渡します。CURRENT_TIMESTAMP は UTC の 2026-09-12 02:15:07 形式です。

複数の書き込みをまとめて確定したいとき

execute('BEGIN') と execute('COMMIT') を別々に送る書き方は使いません。プラグインは 1 つの DB に複数の接続を持ち、呼び出しごとに空いている接続を使うので、後の文が別の接続に届くことがあります。並行して読み書きした後だと ROLLBACK が「cannot rollback - no transaction is active」で失敗し、途中の INSERT が残ります。1 回の execute() に BEGIN から COMMIT まで並べても、途中で失敗するとトランザクションが開いたまま残り、以後の書き込みが「database is locked」になります。

複数行の INSERT(VALUES ($1), ($2))のように 1 文で書けるものは 1 文にします(1 文なら失敗時に全体が取り消されます)。それを超える処理は Rust で行います。

2. バックエンドから実装する (Rust)

プラグインは lib.rs で登録します(tauri add が書き足していたら二重にしない)。Rust から同じ DB を触るときは、プラグインが開いた接続を DbInstances から借りて sqlx のトランザクションを使います。commit() の前に ? や return で抜けると、それまでの変更は取り消されます。src-tauri で cargo add sqlx@0.8 --features sqlite,runtime-tokio を実行し、cargo tree -i sqlx でプラグインと同じ版か確かめます。

use tauri::Manager;
use tauri_plugin_sql::{DbInstances, DbPool};

const DB_URL: &str = "sqlite:app.db"; // JS の Database.load() と同じ文字列

// プラグインが開いた接続を借りる(先に load() か preload が済んでいること)
async fn plugin_pool(app: &tauri::AppHandle) -> Result<sqlx::SqlitePool, String> {
    let instances = app.state::<DbInstances>();
    let map = instances.0.read().await;
    match map.get(DB_URL) {
        Some(DbPool::Sqlite(pool)) => Ok(pool.clone()),
        _ => Err(format!("database {DB_URL} not loaded")),
    }
}

/// 複数件をまとめて追加する。1 件でも失敗したら全部取り消す
#[tauri::command]
async fn import_todos(app: tauri::AppHandle, titles: Vec<String>) -> Result<Vec<i64>, String> {
    let pool = plugin_pool(&app).await?;
    let mut tx = pool.begin().await.map_err(|e| e.to_string())?;
    let mut ids = Vec::new();
    for title in titles {
        if title.trim().is_empty() {
            return Err("空のタイトルがあるので中止しました".into()); // tx が破棄され、INSERT も取り消される
        }
        let r = sqlx::query("INSERT INTO todos (title) VALUES (?)")
            .bind(title)
            .execute(&mut *tx)
            .await
            .map_err(|e| e.to_string())?;
        ids.push(r.last_insert_rowid());
    }
    tx.commit().await.map_err(|e| e.to_string())?;
    Ok(ids)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_sql::Builder::default().build())
        .invoke_handler(tauri::generate_handler![import_todos])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

JS の load() より先にコマンドが呼ばれうるなら、tauri.conf.json で起動時に開かせます。

{
  "plugins": {
    "sql": {
      "preload": ["sqlite:app.db"]
    }
  }
}
import { invoke } from '@tauri-apps/api/core';

const ids = await invoke<number[]>('import_todos', { titles: ['牛乳を買う', '請求書を送る'] });
console.log(ids); // [3, 4] のように、追加された id が返る

動作確認

npm run tauri dev で起動し、1 章の関数を呼んだ後に次のコードで中身と保存先を確かめます。load() は済んでいるので get() で足ります。

import Database from '@tauri-apps/plugin-sql';
import { appConfigDir } from '@tauri-apps/api/path';

const db = Database.get('sqlite:app.db'); // load() 済みの DB を指すだけ
console.log('保存先:', await appConfigDir());
console.log(await db.select('SELECT id, title, done, created_at FROM todos ORDER BY id'));
保存先: C:\Users\me\AppData\Roaming\com.example.myapp
[{ id: 1, title: "牛乳を買う", done: 1, created_at: "2026-09-12 02:15:07" }, { id: 2, title: "請求書を送る", done: 0, created_at: "2026-09-12 02:15:09" }]

実行中は app.db の横に app.db-wal と app.db-shm も並びます。直近の書き込みは -wal 側にあることがあるので、ファイルの複製はアプリを閉じてから行います。

よくあるエラーと対処法

  • 「sql.execute not allowed. Permissions associated with this command: sql:allow-execute」: 権限の追加漏れです。リリースビルドでは「Command plugin:sql|execute not allowed by ACL」だけになります。
  • 「plugin sql not found」: lib.rs でプラグインを登録していません。
  • 「database sqlite:app.db not loaded」: get() や Rust のコマンドを load() より先に使いました。load() を待つか preload を設定します。
  • 「error returned from database: (code: 1) no such table: todos」: テーブルが無いか、sqlite:./app.db のように別の文字列で開いて別のファイルを見ています。
  • 「error returned from database: (code: 2067) UNIQUE constraint failed: users.email」: 一意制約の違反です。(code: 2067) の部分で種類を見分けられます。

OS ごとの違いと注意点

  • 保存先: Windows は %APPDATA%\<identifier>、macOS は ~/Library/Application Support/<identifier>、Linux は ~/.config/<identifier> です。tauri.conf.json の identifier を変えると空の DB から始まります。
  • close() の範囲: 引数なしの db.close() は開いている全 DB を閉じます。以後の呼び出しは load() し直すまで「attempted to acquire a connection on a closed pool」で失敗します。
  • 外部キー: SQLite 単体では既定で無効ですが、このプラグインの接続では有効です。ON DELETE CASCADE の子の行は親と一緒に消えます。
  • 数個の設定値なら Store プラグイン、消えてもよいキャッシュなら LocalStorage / IndexedDB の方が手軽です。DB ファイルは暗号化されないので、パスワードなどの秘密は Stronghold に置きます。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する