データベースと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/DELETE | query().execute() |
| SELECT(単一) | query_as().fetch_one() / fetch_optional() |
| SELECT(複数) | query_as().fetch_all() |
| トランザクション | pool.begin() → tx.commit() |
| コマンド | 用途 |
|---|---|
sqlx database create | DB作成 |
sqlx migrate add | マイグレーション作成 |
sqlx migrate run | マイグレーション実行 |
cargo sqlx prepare | オフラインモード準備 |
確認テスト
Q1. SQLxで複数の行を取得するメソッドは?
正解: C) fetch_all() は複数行をVecで返します。fetch_one() は1行(なければエラー)、fetch_optional() は1行(なければNone)、execute() はINSERT/UPDATE/DELETEに使います。
Q2. SQLxでINSERT後に挿入されたIDを取得するには?
正解: B) execute() は結果を返し、SQLiteでは last_insert_rowid() でINSERT後のIDを取得できます。PostgreSQLでは RETURNING 句を使います。
Q3. fetch_optional(&pool).await? でIDが存在しない場合の戻り値は?
正解: B) fetch_optional() は行が見つからない場合 None を返します。fetch_one() だとエラーになります。
Q4. 構造体が id, name のみで、SQLが "SELECT id, name, email FROM users" の場合の問題は?
正解: A) query_as は構造体のフィールドとSQLのカラムをマッピングするため、構造体に存在しないカラムを取得するとエラーになります。
Q5. SQLxでブックマークを削除した後、削除されたかどうかを確認する正しい方法は?
正解: D) execute() の結果から rows_affected() で影響を受けた行数を確認できます。0より大きければ削除成功です。
次のドキュメント: 05_authentication.md