sqlx crate でフィールド毎に独自のデコード処理を挟みたい

sqlx-with というものを作ってみたので、その経緯について書く。

github.com

sqlx crate でフィールド毎に独自のデコード処理を挟みたい

sqlx では query_as() 等の関数を使うことでデータベースから取り出した行を struct にマッピングすることができ、このマッピングsqlx::FromRow という derive macro を利用することで自動で実装できる。

#[derive(sqlx::FromRow)]
struct Row {
    x: i64,
}

このような単純なマッピングではなく、特定のカラムの値に独自の変換処理を入れてから struct に入れたいとする。とくに意味は無いが、たとえばカラム x の値に対して (x, x + 2) という値を struct のフィールド x に入れたいとする。serde であればたとえば split_x という関数を定義して #[serde(deserialize_with = "split_x")] と指定すれば実現できる。これと同じような機能が sqlx::FromRow にあればよさそうだが、現状は無い。なので serde の deserialize_with みたいなものをどうやったら実装できるか考えてみる。最終的には以下のように書けるとよさそうだ。

#[derive(sqlx::FromRow)]
struct Row {
    #[sqlx(decode = "split_x")]
    x: (i64, i64),
}

まず sqlx::FromRow が具体的にどのようなマクロ展開をしているのか実装を見てみると、Row、ColumnIndex、Type、Decode といった様々な trait を使っていることが分かる。
https://github.com/launchbadge/sqlx/blob/v0.6.1/sqlx-macros/src/derives/row.rs
最初の例は以下のような impl を生成している。

struct Row {
    x: i64,
}
impl<'r, R> sqlx::FromRow<'r, R> for Row
where
    R: sqlx::Row,
    &'r str: sqlx::ColumnIndex<R>,
    i64: sqlx::Type<R::Database> + sqlx::Decode<'r, R::Database>,
{
    fn from_row(row: &'r R) -> sqlx::Result<Self> {
        Ok(Self {
            x: row.try_get("x")?,
        })
    }
}

特定のデータベースに依存しないようになっているのが特徴で、struct のフィールド名とその型の情報から

  • &str で特定のカラムにアクセスできる
  • i64 に対応するデータベース側の型がありデコードができる

という制約をつけている。

ここに split_x という関数を挟んでみると以下のような実装になるだろう。

struct Row {
    x: (i64, i64),
}
impl<'r, R> sqlx::FromRow<'r, R> for Row
where
    R: sqlx::Row,
    &'r str: sqlx::ColumnIndex<R>,
    i64: sqlx::Type<R::Database> + sqlx::Decode<'r, R::Database>,
{
    fn from_row(row: &'r R) -> sqlx::Result<Self> {
        Ok(Self {
            x: split_x("x", row)?,
        })
    }
}

fn split_x<'r, R>(index: &'r str, row: &'r R) -> sqlx::Result<(i64, i64)>
where
    R: sqlx::Row,
    &'r str: sqlx::ColumnIndex<R>,
    i64: sqlx::Type<R::Database> + sqlx::Decode<'r, R::Database>,
{
    let n: i64 = row.try_get(index)?;
    Ok((n, n + 2))
}

あとはこのように展開されるようなマクロを作るだけ…… とはいかず、このままだと無理なことが分かる。Row の定義から impl の制約を生成しなければならないが、i64 が Type + Decode であるという条件を Row の定義から知ることができないからである。これを知るには split_x のシグネチャを知る必要があるが derive macro には不可能なはず。というわけで sqlx::FromRow ではこのような decode オプションを実装することができない。

sqlx-with

sqlx::FromRow の derive macro で decode オプションを実装できない原因は実装があまりに generic だからだと考えた。sqlx::FromRow を derive macro を使わずに手書きで実装する場合、普通は特定のデータベースを前提に実装する。実際に sqlx::FromRow のドキュメント https://docs.rs/sqlx/latest/sqlx/trait.FromRow.html#manual-implementation でもそのように案内している。 そこで具体的なデータベースを渡せるような derive macro に変えれば、decode オプションを実装できるだけの generic さを残しつつ sqlx::FromRow と同様の便利さを得られそうだ。実際にそのように sqlx_with::FromRow という derive macro を実装してみた。
https://github.com/eagletmt/sqlx-with

use sqlx::Connection as _;

#[derive(sqlx_with::FromRow)]
#[sqlx_with(db = "sqlx::Sqlite")]
struct Row {
    #[sqlx_with(decode = "split_x")]
    x: (i64, i64),
    y: String,
}

fn split_x(index: &str, row: &sqlx::sqlite::SqliteRow) -> sqlx::Result<(i64, i64)> {
    use sqlx::Row as _;
    let n: i64 = row.try_get(index)?;
    Ok((n, n + 2))
}

#[tokio::main]
async fn main() {
    let mut conn = sqlx::SqliteConnection::connect(":memory:").await.unwrap();
    let row: Row = sqlx::query_as("select 10 as x, 'hello' as y")
        .fetch_one(&mut conn)
        .await
        .unwrap();
    assert_eq!(row.x, (10, 12));
    assert_eq!(row.y, "hello");
}

db というオプションにデータベースを渡すことでそのデータベースを前提とした impl を生成でき、split_x のような関数も中で使えるようになった。sqlx::FromRow と異なり指定したデータベース以外には使えなくなってしまうが、現実的に1つの struct を複数の異なる種類のデータベース間で使いまわしたい場面はまず無いだろう。

おまけ: darling

sqlx_with::FromRow という derive macro を実装するために darling というライブラリを初めて使った。proc macro を実装するための derive macro を提供していて、derive macro を実装するには FromDeriveInput を使うと必要な syn の値にすぐにアクセスできる struct が得られて便利だった。