♻️

[Rust] sqlxで再利用可能なクエリを使う

2024/10/01に公開

はじめに

Sqlxはとても柔軟性が高く、細かい要件が必要な場合等にとても重宝します
(生SQLを書くのだから当たり前)

しかしながら、Sqlxにもいくつか問題点があります。

今回はsqlxを導入後1年程度経って顕在化してきた問題とその解決策について書いていきます。

sqlxを使う場合、sqlx::query!マクロ、あるいはsqlx::query_as!マクロを使うのが一般的です。
このマクロを使うことでクエリをコンパイル時に検証し、強力な型チェックを利用することができます。

しかしながらプロジェクトの成長に伴って行数、関わる人数が増えてきたときにこのマクロによって以下の問題が発生してきました。

  1. クエリを再利用・動的に利用できない
  2. QueryBuilderで型チェックが効かない

問題1) クエリを再利用・動的に利用できない

本プロジェクトではクリーンアーキテクチャを採用しており、レポジトリ層で使われるデータベースモデルとドメイン層で使われるエンティティが存在します。
例えば以下のようなものです。

users_repository.rs
#[async_trait]
impl UsersRepositoryAbstract for UsersRepository {
    async fn get_user_by_id(
        &self,
        organization_id: OrganizationId,
        user_id: UserId,
    ) -> Result<UserEntity, RepositoryError> {
        let organization_uuid: Uuid = organization_id.into();
        let user_id: Uuid = client_id.into();

        let model = sqlx::query_as!(
            UserModel,
            r#"
                SELECT
                    users.id,
                    users.name,
                    users.name_kana,
                    users.email,
                    users.organization_id
                FROM users
                WHERE organization_id = $1 AND id = $2
            "#,
            organization_uuid,
            user_id
        )
        .fetch_one(&self.pool)
        .await
        .map_err(to_repository_error)?;

        let entity = model.into();

        Ok(entity)
    }
}

Sqlxではこのようにマクロ内に生SQLを書き、呼び出しとともにモデルへのマッピングが行われます。

しかしながら、例示したコードではget_user_by_idしかありませんが、もしここにget_users_by_organization_idであったり、get_user_by_emailのような関数が追加されるとしたらどうでしょうか。

そのような場合だとWHERE句の先だけが異なるのにもかかわらず、都度上のようなSQLをコピーして書く必要があり非常に見づらいコードとなってしまいます。

さらにデータベースのスキーマを変更した場合にその全てのマクロについてSqlを変更する必要があり、エディタが真っ赤になりとてもつらい思いをします。

しかしならが、単純に文字をconstなどの変数で代入することはできません。
sqlxのドキュメントにあるようにsqlx::query!マクロの引数は文字リテラルである必要があります。

The query must be a string literal, or concatenation of string literals using + (useful for queries generated by macro), or else it cannot be introspected (and thus cannot be dynamic or the result of another macro).

問題2) QueryBuilderで型チェックが効かない

複雑なフィルタリングなどを実装するとき、SqlxではQueryBuilderを利用します。例えば次のようなものです。

users_repository.rs
pub fn filter_users(&self,
    organization_id: OrganizationId,
    search_string: Option<&str>,
    // ...
) -> Result<Vec<UserEntity>, RepositoryError> {
    let organization_uuid: Uuid = organization_id.into();

    let mut query_builder = QueryBuilder::new(r#"
        SELECT
            users.id,
            users.name,
            users.name_kana,
            users.email,
            users.organization_id
        FROM users
        WHERE organization_id =
    "#);
    query_builder.push_bind(organization_uuid);
    
    if let Some(search_string) = search_string {
        let search_string = format!("%{}%", search_string);
        query_builder.push(" AND users.name LIKE ");
        query_builder.push_bind(search_string.clone());
        query_builder.push(" AND users.name_kana LIKE ");
        query_builder.push_bind(search_string.clone());
        query_builder.push(" AND users.email LIKE ");
        query_builder.push_bind(search_string.clone());
    }
    // ...
}

しかしならが、QueryBuilderでは型チェックがなく、エラーがあった場合は実行時に初めて気がつくことになります。

これでは正直Sqlxを使ってる理由が80%ほどないです。

解決策1) マクロを使う

上述したように、sqlx::query!マクロの引数は文字リテラルである必要があります。

そこで、マクロの出番です。
ドキュメントを見れば大体はわかると思うので説明は割愛します。

sqlx::query!を内部的によぶマクロを作ってしまえばいいのです。

user_model.rs
#[derive(Debug, sqlx::FromRow)]
pub struct UserModel {
    pub id: Uuid,
    pub name: String,
    pub name_kana: Option<String>,
    pub email: String,
    pub organization_id: uuid,
}

impl From<UserModel> for UserEntity {
    fn from(value: UserModel) -> Self {
        Self {
            // ...
        }
    }
}

#[macro_export]
macro_rules! sqlx_select_user_model {
    ($predicate:tt, $($args:tt)*) => {
        sqlx::query_as!(
            UserModel,
            r#"
                SELECT
                    users.id,
                    users.name,
                    users.name_kana,
                    users.email,
                    users.organization_id
                FROM users
            "#
                + $predicate,
            $($args)*
        )
    };
}

後はこれを

user_repository.rs
let users = sqlx_select_user_model!(
    "WHERE users.organization_id = $1 AND users.id = $2",
    organization_uuid, user_uuid
)
.fetch_all(&self.pool)
.await
.map_err(to_repository_error)?;
// ...

のように呼ぶだけです。

素晴らしい!!!たったこれだけでWHERE句を変えるだけのクエリを動的に再利用できるようになりました。

もちろん型チェックも効きます

解決策2) マクロを使う2

解決策1で使ったマクロを再利用します。

let mut query_builder = QueryBuilder::new(
    sqlx_select_user_model!("").sql()
);

ほら!これでQueryBuilderもSqlを再利用できますし、型チェックも効きます。
しかしここで問題があります。

sqlxには独自のSql構文があり、これを利用して型をより上書きしたりします。
xxxx as "fieldName?: TypeName"
のようなものですね。

QueryBuilderでは上記の独自の構文を解釈する事ができず、エラーとなっていまうため、そのSQLを最適化する補助関数を作成します。

sqlx_utils.rs
use regex::Regex;
use sqlx::query::Map;
use sqlx::{Database, Execute, IntoArguments};

pub trait SqlxUtils<'q, DB: Database>: Send + Sized {
    fn prepare_for_query_builder(&self) -> String;
}

impl<'q, DB, F: Send, A: Send> SqlxUtils<'q, DB> for Map<'q, DB, F, A>
where
    DB: Database,
    A: IntoArguments<'q, DB>,
{
    /// SqlxのQueryBuilderは?とか!とか型情報があったらエラーになるので、それを削除する
    fn prepare_for_query_builder(&self) -> String {
        // 正規表現で不要な部分を削除する
        let re_exclamations_and_questions = Regex::new(r"(!|\?)").unwrap(); // !や?を削除
        let re_types = Regex::new(r":\s*\w+::?\w+").unwrap(); // 型情報(models::Currencyのようなもの)を削除

        // ステップ1: "!"や"?"を削除
        let result = re_exclamations_and_questions.replace_all(self.sql(), "");

        // ステップ2: 型情報を削除
        let result = re_types.replace_all(&result, "");

        result.to_string()
    }
}

上で定義した関数を用いて

let mut query_builder = QueryBuilder::new(
    sqlx_select_user_model!("")
        .prepare_for_query_builder(),
);

query_builder.push("WHERE organization_id = ");
query_builder.push_bind(organization_uuid);
    
//...

のように使うことで、QueryBuilderにも再利用された型チェック済みのSqlを使えるようになります!!

結びに

Rustを用いて開発するのが好きな方!ぜひ疑問点や質問・改善案などがありましたらお気軽にコメントください!

記事を書くのに慣れておらず、駄文でしたがお付き合いいただきありがとうございました。

Discussion