Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

データベースとSQLx

RustでデータベースにアクセスするためのSQLxを学びます。

SQLxとは

SQLxは、コンパイル時にSQLクエリを検証できるRustのデータベースライブラリです。

特徴

  • コンパイル時検証: SQLの誤りをコンパイル時に検出
  • 非同期対応: async/awaitに対応
  • マイグレーション: スキーマ管理機能内蔵
  • 複数DB対応: SQLite, PostgreSQL, MySQL

このカリキュラムでは SQLite を使用します(セットアップが簡単)。

セットアップ

Cargo.toml

[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1", features = ["full"] }

SQLx CLI のインストール

cargo install sqlx-cli --no-default-features --features sqlite

データベースの作成

# 環境変数を設定
export DATABASE_URL="sqlite:./database.db"

# データベースファイルを作成
sqlx database create

マイグレーション

マイグレーションファイルの作成

sqlx migrate add create_users_table

migrations/YYYYMMDDHHMMSS_create_users_table.sql が作成されます。

マイグレーションの内容

-- migrations/20240101000000_create_users_table.sql

CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

マイグレーションの実行

sqlx migrate run

基本的な使い方

接続

use sqlx::sqlite::SqlitePool;

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let pool = SqlitePool::connect("sqlite:./database.db").await?;

    // poolを使ってクエリを実行

    Ok(())
}

INSERT

#![allow(unused)]
fn main() {
use sqlx::sqlite::SqlitePool;

async fn create_user(pool: &SqlitePool, name: &str, email: &str) -> Result<i64, sqlx::Error> {
    let result = sqlx::query(
        "INSERT INTO users (name, email) VALUES (?, ?)"
    )
    .bind(name)
    .bind(email)
    .execute(pool)
    .await?;

    Ok(result.last_insert_rowid())
}
}

SELECT(単一行)

#![allow(unused)]
fn main() {
use sqlx::{FromRow, sqlite::SqlitePool};

#[derive(Debug, FromRow)]
struct User {
    id: i64,
    name: String,
    email: String,
}

async fn get_user(pool: &SqlitePool, id: i64) -> Result<Option<User>, sqlx::Error> {
    let user = sqlx::query_as::<_, User>(
        "SELECT id, name, email FROM users WHERE id = ?"
    )
    .bind(id)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}
}

SELECT(複数行)

#![allow(unused)]
fn main() {
async fn list_users(pool: &SqlitePool) -> Result<Vec<User>, sqlx::Error> {
    let users = sqlx::query_as::<_, User>(
        "SELECT id, name, email FROM users ORDER BY id"
    )
    .fetch_all(pool)
    .await?;

    Ok(users)
}
}

UPDATE

#![allow(unused)]
fn main() {
async fn update_user(
    pool: &SqlitePool,
    id: i64,
    name: &str,
    email: &str,
) -> Result<bool, sqlx::Error> {
    let result = sqlx::query(
        "UPDATE users SET name = ?, email = ? WHERE id = ?"
    )
    .bind(name)
    .bind(email)
    .bind(id)
    .execute(pool)
    .await?;

    Ok(result.rows_affected() > 0)
}
}

DELETE

#![allow(unused)]
fn main() {
async fn delete_user(pool: &SqlitePool, id: i64) -> Result<bool, sqlx::Error> {
    let result = sqlx::query("DELETE FROM users WHERE id = ?")
        .bind(id)
        .execute(pool)
        .await?;

    Ok(result.rows_affected() > 0)
}
}

query_as マクロ

構造体にマッピングする場合は query_as を使います。

#![allow(unused)]
fn main() {
use sqlx::FromRow;

#[derive(Debug, FromRow)]
struct User {
    id: i64,
    name: String,
    email: String,
}

// query_as を使用
let users: Vec<User> = sqlx::query_as("SELECT * FROM users")
    .fetch_all(&pool)
    .await?;
}

コンパイル時検証

SQLxは sqlx::query! マクロでコンパイル時にSQLを検証できます。

準備(オフラインモード)

# .envファイルを作成
echo "DATABASE_URL=sqlite:./database.db" > .env

# SQLx用のメタデータを生成
cargo sqlx prepare

使用例

#![allow(unused)]
fn main() {
// コンパイル時にSQLが検証される
let user = sqlx::query!(
    "SELECT id, name, email FROM users WHERE id = ?",
    id
)
.fetch_optional(&pool)
.await?;

// 存在しないカラムはコンパイルエラー
// let user = sqlx::query!(
//     "SELECT invalid_column FROM users"  // コンパイルエラー!
// );
}

Axumとの統合

プロジェクト構造

src/
├── main.rs
├── db.rs        # データベース関連
├── models.rs    # データモデル
└── handlers.rs  # APIハンドラー

db.rs

#![allow(unused)]
fn main() {
use sqlx::sqlite::SqlitePool;

pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
    SqlitePool::connect(database_url).await
}
}

models.rs

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use sqlx::FromRow;

#[derive(Debug, Clone, Serialize, FromRow)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
}

#[derive(Debug, Deserialize)]
pub struct CreateUser {
    pub name: String,
    pub email: String,
}

#[derive(Debug, Deserialize)]
pub struct UpdateUser {
    pub name: Option<String>,
    pub email: Option<String>,
}
}

handlers.rs

#![allow(unused)]
fn main() {
use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use sqlx::sqlite::SqlitePool;

use crate::models::{CreateUser, UpdateUser, User};

type Pool = SqlitePool;

// ユーザー一覧
pub async fn list_users(
    State(pool): State<Pool>,
) -> Result<Json<Vec<User>>, StatusCode> {
    let users = sqlx::query_as::<_, User>("SELECT id, name, email FROM users")
        .fetch_all(&pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(users))
}

// ユーザー取得
pub async fn get_user(
    State(pool): State<Pool>,
    Path(id): Path<i64>,
) -> Result<Json<User>, StatusCode> {
    let user = sqlx::query_as::<_, User>(
        "SELECT id, name, email FROM users WHERE id = ?"
    )
    .bind(id)
    .fetch_optional(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .ok_or(StatusCode::NOT_FOUND)?;

    Ok(Json(user))
}

// ユーザー作成
pub async fn create_user(
    State(pool): State<Pool>,
    Json(payload): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), StatusCode> {
    let result = sqlx::query(
        "INSERT INTO users (name, email) VALUES (?, ?)"
    )
    .bind(&payload.name)
    .bind(&payload.email)
    .execute(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let user = User {
        id: result.last_insert_rowid(),
        name: payload.name,
        email: payload.email,
    };

    Ok((StatusCode::CREATED, Json(user)))
}

// ユーザー削除
pub async fn delete_user(
    State(pool): State<Pool>,
    Path(id): Path<i64>,
) -> Result<StatusCode, StatusCode> {
    let result = sqlx::query("DELETE FROM users WHERE id = ?")
        .bind(id)
        .execute(&pool)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if result.rows_affected() > 0 {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}
}

main.rs

mod db;
mod handlers;
mod models;

use axum::{routing::{get, post, delete}, Router};

#[tokio::main]
async fn main() {
    let database_url = std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "sqlite:./database.db".to_string());

    let pool = db::create_pool(&database_url)
        .await
        .expect("Failed to create pool");

    // マイグレーションを実行
    sqlx::migrate!()
        .run(&pool)
        .await
        .expect("Failed to run migrations");

    let app = Router::new()
        .route("/users", get(handlers::list_users).post(handlers::create_user))
        .route("/users/:id", get(handlers::get_user).delete(handlers::delete_user))
        .with_state(pool);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

トランザクション

#![allow(unused)]
fn main() {
use sqlx::sqlite::SqlitePool;

async fn transfer_funds(
    pool: &SqlitePool,
    from_id: i64,
    to_id: i64,
    amount: i64,
) -> Result<(), sqlx::Error> {
    // トランザクション開始
    let mut tx = pool.begin().await?;

    // 送金元から減額
    sqlx::query("UPDATE accounts SET balance = balance - ? WHERE id = ?")
        .bind(amount)
        .bind(from_id)
        .execute(&mut *tx)
        .await?;

    // 送金先に加算
    sqlx::query("UPDATE accounts SET balance = balance + ? WHERE id = ?")
        .bind(amount)
        .bind(to_id)
        .execute(&mut *tx)
        .await?;

    // コミット
    tx.commit().await?;

    Ok(())
}
}

エラーハンドリング

#![allow(unused)]
fn main() {
use sqlx::Error as SqlxError;
use axum::http::StatusCode;

fn map_db_error(err: SqlxError) -> StatusCode {
    match err {
        SqlxError::RowNotFound => StatusCode::NOT_FOUND,
        SqlxError::Database(db_err) => {
            // ユニーク制約違反など
            if db_err.is_unique_violation() {
                StatusCode::CONFLICT
            } else {
                StatusCode::INTERNAL_SERVER_ERROR
            }
        }
        _ => StatusCode::INTERNAL_SERVER_ERROR,
    }
}
}

まとめ

操作メソッド
INSERT/UPDATE/DELETEquery().execute()
SELECT(単一)query_as().fetch_one() / fetch_optional()
SELECT(複数)query_as().fetch_all()
トランザクションpool.begin()tx.commit()
コマンド用途
sqlx database createDB作成
sqlx migrate addマイグレーション作成
sqlx migrate runマイグレーション実行
cargo sqlx prepareオフラインモード準備

確認テスト

Q1. SQLxで複数の行を取得するメソッドは?

Q2. SQLxでINSERT後に挿入されたIDを取得するには?

Q3. fetch_optional(&pool).await? でIDが存在しない場合の戻り値は?

Q4. 構造体が id, name のみで、SQLが "SELECT id, name, email FROM users" の場合の問題は?

Q5. SQLxでブックマークを削除した後、削除されたかどうかを確認する正しい方法は?


次のドキュメント: 05_authentication.md