📝

Rustでsqlxを使ってRDBMSに接続する

2023/08/17に公開

はじめに

こんにちは。現在、僕はRustでアプリケーションを実装するために必要な個別のトピックについてひとつずつ学習を進めています。前回は、RustでgRPCを実装するサンプルを書いてみた という記事を書きましたが、今回はDB接続を扱います。DB接続に関するRustのライブラリにはいくつか有力な選択肢があるようです。今回はその中の一つであるsqlxを取り上げます。

メモとしてざっくり調べた有力候補のライブラリについて簡単に記録しておきます。

  • Diesel : GitHubのスター数から見るに最も著名なORMのように思われます。
  • sqlx : スター数はDieselとほぼ横ばいです。非ORM。
  • SeaORM : sqlx上に構築されたORMらしいです。そこそこのスター数です。
  • ormx : SeaORM同様sqlxで実装されているようです。

※スター数については2023年8月時点のものです

Dieselにするか迷ったのですが、今回はsqlxを使用することにしました。理由はsqlxがORMではないことです。ある程度複雑なアプリケーションを実装する場合、どうしてもORMで全てをカバーするのは難しい場面があります。だったら最初から素直にSQLで書けばええやろ、と思ってしまうんですよね。

そういうわけで今回はsqlxを扱いますが、そのうちDieselについても調べる、かもしれません。

ソースはこちら。

https://github.com/kengo-k/rust-sqlx-example

sqlxについて

ORMではない普通のDB接続ライブラリです。ゴリゴリと自分でSQL文を書く必要があります。ORMとは違って覚えることは少なそうなので簡単に使い始めることができそうです。

ビルド時にデータベースを参照し、Rustコード内で利用されているSQLクエリに不正がないかを静的に検証してくれる機能を持っています。

環境構築

なるべく簡単にやりたいのでSQLiteを使います。

sqlx-cli

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

依存ライブラリのインストール

$ cargo add sqlx --features "sqlite runtime-tokio-native-tls chrono"
$ cargo add tokio --features=full
$ cargo add dotenv

データベースの作成

sqlxコマンドを使用してSQLiteデータベースを作成します。

$ sqlx database create --database-url "sqlite:./database.db"

マイグレーションの作成

下記コマンドを実行するとmigrationsディレクトリにSQLファイルが生成されます。-rオプションを使用するとup用とdown用の二種類のSQLファイルが生成されます。

$ sqlx migrate add -r create_users_table

生成されたSQLファイルにDDLを記述します。

<datetime>_create_uesrs_table.up.sql
CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    address TEXT,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
<datetime>_create_uesrs_table.down.sql
DROP TABLE users;

マイグレーションの実行

下記コマンドを実行してマイグレーションSQLを適用します。

$ sqlx migrate run --database-url sqlite:./database.db

実際にテーブルが作成されたかを一応確認しておきます。

$ sqlite3 database.db 
SQLite version 3.42.0 2023-05-16 12:36:15
Enter ".help" for usage hints.
sqlite> .tables
_sqlx_migrations  users           
sqlite> pragma table_info(users);
0|id|INTEGER|0||1
1|name|TEXT|1||0
2|email|TEXT|1||0
3|address|TEXT|0||0
4|created_at|DATETIME|1|CURRENT_TIMESTAMP|0

テーブルが正しく作成されていることを確認しました。

実装

src/main.rs(最小の実装)

とりあえずデータを取得するだけの最小のサンプルになります。

src/main.rs
use dotenv;
use sqlx::sqlite::SqlitePool;
use std::env;

#[derive(Debug)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub address: Option<String>,
    pub created_at: chrono::NaiveDateTime,
}

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    dotenv::dotenv().expect("Failed to read .env file");
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = SqlitePool::connect(&database_url).await?;
    let users = sqlx::query_as!(
        User,
        "select id, name, email, address, created_at from users"
    )
    .fetch_all(&pool)
    .await?;
    for user in users {
        println!("{:?}", user);
    }

    Ok(())
}

まずは動作させてみます。まだInsert機能がないのでデータは手動で投入します。下記の内容でCSVを作っておきます。

seed.csv
id,name,email,address,created_at
1,John Doe,john.doe@example.com,address1,2023-08-14 12:45:00
2,Jane Smith,jane.smith@example.com,address2,2023-08-14 12:45:00
3,Alice Johnson,alice.johnson@example.com,address3,2023-08-14 12:45:00
4,Bob Brown,bob.brown@example.com,address4,2023-08-14 12:45:00
5,Charlie Davis,charlie.davis@example.com,address5,2023-08-14 12:45:00

sqlite3でcsvをインポートします。

$ sqlite3 database.db
.mode csv
.import seed.csv users --skip 1

mainを実行します。

$ cargo run
Error: Database(SqliteError { code: 14, message: "unable to open database file" })

エラーになってしまいました。データベースをオープンできないと怒られているので、どうやらビルド時の静的クエリ検証を試みたが失敗した、ようです。これはデータベースがどこにあるかを指示していないのが原因です。下記の内容で.envを作成します。

.env
DATABASE_URL=sqlite:./database.db

再度実行しましょう。

$ cargo run
User { id: 1, name: "John Doe", email: "john.doe@example.com", address: Some("address1"), created_at: 2023-08-14T12:45:00 }
User { id: 2, name: "Jane Smith", email: "jane.smith@example.com", address: Some("address2"), created_at: 2023-08-14T12:45:00 }
User { id: 3, name: "Alice Johnson", email: "alice.johnson@example.com", address: Some("address3"), created_at: 2023-08-14T12:45:00 }
User { id: 4, name: "Bob Brown", email: "bob.brown@example.com", address: Some("address4"), created_at: 2023-08-14T12:45:00 }
User { id: 5, name: "Charlie Davis", email: "charlie.davis@example.com", address: Some("address5"), created_at: 2023-08-14T12:45:00 }

今度は正常に動作しました。

静的ビルド検証

このソースにおいて一番重要な箇所は下記の部分です。

let users = sqlx::query_as!(
    User,
    "select id, name, email, address, created_at from users"
)

query_as!というマクロにSQL文を記述しています。このマクロを使用することでビルド時にクエリが検証されます。

ここでクエリの列名を存在しない列名に変更(email -> emaill)してビルドしてみます。

$ cargo build
error: error returned from database: (code: 1) no such column: emaill
  --> src/main.rs:17:17

列名emaillが存在しないとエラーを出してくれました。

structへの自動マッピング

先ほどのコードにおいて変数usersの型はVec<User>になります。つまりSQLから取得した結果はUser型に自動でマッピングされたようです。ということはマッピングできない型の場合はおそらくビルドエラーになるのでしょう。

試してみます。Userの定義を以下のように変更します。

pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
+    pub address: String,
-    pub address: Option<String>,
    pub created_at: chrono::NaiveDateTime,
}

Optionをやめてみました。addressはNULLになる可能性があるためエラーになるはずです。

$ cargo build
error[E0277]: the trait bound `std::string::String: From<Option<std::string::String>>` is not satisfied
  --> src/main.rs:19:17
   |
19 |       let users = sqlx::query_as!(
   |  _________________^
20 | |         User,
21 | |         "select id, name, email, address, created_at from users"
22 | |     )
   | |_____^ the trait `From<Option<std::string::String>>` is not implemented for `std::string::String`

このエラーが何を伝えているのか理解するのが難しいですが、しっかりビルドエラーを出してくれることがわかりました。

クエリキャッシュ

ここまで見てきたようにsqlxはビルド時にデータベースを参照してクエリを検証します。これは素晴らしい機能ですがデータベースに接続できないとビルドすらできないのでは困る場合があります。わかりやすい例としてはCI/CDパイプラインの中でのビルドです。

このような場合のため事前にクエリキャッシュを作成しておくことでオフラインで(データベースに接続せずに)ビルドできるようになります。

クエリキャッシュを作成するには下記のコマンドを実行します。

DATABASE_URL=sqlite:./database.db cargo sqlx prepare

コマンドを実行すると.sqlx/query-<HASH値>.jsonのような名前でファイルが作成されます。作成されるファイルは1クエリ1ファイルとなります。クエリキャッシュは下記のような内容を持ちます。

{
  "db_name": "SQLite",
  "query": "select id, name, email, address, created_at from users",
  "describe": {
    "columns": [
      {
        "name": "id",
        "ordinal": 0,
        "type_info": "Int64"
      },
      {
        "name": "name",
        "ordinal": 1,
        "type_info": "Text"
      },
      {
        "name": "email",
        "ordinal": 2,
        "type_info": "Text"
      },
      {
        "name": "address",
        "ordinal": 3,
        "type_info": "Text"
      },
      {
        "name": "created_at",
        "ordinal": 4,
        "type_info": "Datetime"
      }
    ],
    "parameters": {
      "Right": 0
    },
    "nullable": [
      false,
      false,
      false,
      true,
      false
    ]
  },
  "hash": "f1275d42ab93a2cbfb8bafe96ffe27759006d62470697e67a6cd844f17d37cd5"
}

クエリキャッシュが存在する場合は環境変数が存在しなくても(データベースを参照しなくても)ビルドできるようになります。

CRUDの実装

CRUD処理の中でまだREADしか実装していないので、のこりも実装していきます。今度はテストとして実装したいのでsrc/lib.rsに記述することにします。

src/lib.rs
use dotenv;
use sqlx::sqlite::SqlitePool;
use sqlx::QueryBuilder;
use std::env;

#[derive(Debug)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub address: Option<String>,
    pub created_at: chrono::NaiveDateTime,
}

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

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

pub async fn create_pool() -> Result<SqlitePool, sqlx::Error> {
    dotenv::dotenv().expect("Failed to read .env file");
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = SqlitePool::connect(&database_url).await?;
    Ok(pool)
}

pub async fn truncate_table(pool: &SqlitePool, table_name: &str) -> Result<(), sqlx::Error> {
    let query = format!("delete from {}", table_name);
    sqlx::query(&query).execute(pool).await?;
    Ok(())
}

pub async fn get_users(pool: &SqlitePool) -> Result<Vec<User>, sqlx::Error> {
    let users = sqlx::query_as!(
        User,
        "select id, name, email, address, created_at from users"
    )
    .fetch_all(pool)
    .await?;

    Ok(users)
}

pub async fn create_user(
    pool: &sqlx::SqlitePool,
    request: CreateUserRequest,
) -> Result<i64, sqlx::Error> {
    let result = sqlx::query!(
        "insert into users (name, email, address) values (?, ?, ?)",
        request.name,
        request.email,
        request.address
    )
    .execute(pool)
    .await?;

    Ok(result.last_insert_rowid())
}

pub async fn update_user(
    pool: &SqlitePool,
    id: i64,
    request: UpdateUserRequest,
) -> Result<u64, sqlx::Error> {
    let mut builder: QueryBuilder<sqlx::Sqlite> = QueryBuilder::new("update users set");

    if let Some(name) = &request.name {
        builder.push(" name = ");
        builder.push_bind(name);
    }
    if let Some(email) = &request.email {
        builder.push(" email = ");
        builder.push_bind(email);
    }
    if let Some(address) = &request.address {
        builder.push(" address = ");
        builder.push_bind(address);
    }

    builder.push(" where id = ");
    builder.push_bind(id);

    let query = builder.build();
    let result = query.execute(pool).await?;

    Ok(result.rows_affected())
}

pub async fn delete_user(pool: &SqlitePool, id: i64) -> Result<u64, sqlx::Error> {
    let result = sqlx::query!("delete from users where id = ?", id)
        .execute(pool)
        .await?;
    Ok(result.rows_affected())
}

src/lib.rsで定義した関数を使用してテストを実装します。

tests/test.rs
use rust_sqlx_example::{
    create_pool, create_user, delete_user, get_users, truncate_table, update_user,
    CreateUserRequest, UpdateUserRequest,
};

#[tokio::test]
async fn test() -> Result<(), sqlx::Error> {
    let pool = create_pool().await?;
    truncate_table(&pool, "users").await?;

    let users = get_users(&pool).await?;
    assert_eq!(users.len(), 0);

    let req1 = CreateUserRequest {
        name: "John".to_string(),
        email: "john@example.com".to_string(),
        address: None,
    };
    let new_id = create_user(&pool, req1).await?;

    let users1 = get_users(&pool).await?;
    assert_eq!(users1.len(), 1);

    let user1 = users1.get(0).unwrap();
    assert_eq!(user1.id, new_id);
    assert_eq!(user1.name, "John".to_string());
    assert_eq!(user1.email, "john@example.com".to_string());
    assert_eq!(user1.address, None);

    let req2 = UpdateUserRequest {
        name: None,
        email: None,
        address: Some("tokyo".to_string()),
    };
    let updated_count = update_user(&pool, user1.id, req2).await?;
    assert_eq!(updated_count, 1);

    let users2 = get_users(&pool).await?;
    assert_eq!(users2.len(), 1);

    let user2 = users2.get(0).unwrap();
    assert_eq!(user2.id, user1.id);
    assert_eq!(user2.name, user1.name);
    assert_eq!(user2.email, user1.email);
    assert_eq!(user2.address, Some("tokyo".to_string()));

    let deleted_count = delete_user(&pool, user2.id).await?;
    assert_eq!(deleted_count, 1);

    let users3 = get_users(&pool).await?;
    assert_eq!(users3.len(), 0);

    Ok(())
}

cargo testで正常にテストがパスしました。

注意点としてupdate_userquery!ではなくqueryを使っているので静的クエリ検証はされていません。これはUpdateRequest型の中のNoneではないフィールドのみを更新対象にするために動的にSQLを組み立てる仕様としたためです。

ソースの内容で特筆すべきことはあまりないのですが、気になった点としては、

  • Create用やUpdate用に都度、専用のstructを定義するのはだるい
    • → TypeScriptみたいに既存の型から導出できればよいのに
    • あとで調べる
  • 動的SQLを使わざるを得ない場面はどうしてもありそう
    • → 動的SQLのカンマや空白をいい感じに考慮して組み立てるのだるい

結論:やはり素直にORMの方が良いのでは・・?

おわりに

いかがでしたでしょうか。

ORMの面倒くささを避けようと思ってsqlxを触ってみたら、ORMが登場する前の昔のだるい記憶が蘇ってきてしまって、やはりORMを使うべきだよなあ、という結論になってしまいました。動的SQLを使わない小さなプログラムであればキレイにハマりそうだなとは思いました。

とりあえず、後日ORMの使い方を改めて調べることにします。

Discussion