はじめに
以前作ったpostgres用のsqlcプラグインを書き直して、sqlxにも対応させたので紹介します。リポジトリは以下です
https://github.com/tunamaguro/sqlc-gen-rust
特徴
4つのPostgresql系クレートに対応
postgres,tokio-postgres,deadpool-postgres,sqlx(postgres)という計4つのクレートに対応しています。とくに書き直し前はsqlxに対応していなかったため、ここは差別化ポイントです。
各クレートごとに生成されるコードはRustのトレイトを活用して、なるべく汎用的なコードになるよう意識しました。生成されるコードへのリンクを貼ります
構造体とビルダーベースのクエリ実行
もともとは関数をベースとしたコードを生成していましたが、Rustっぽく構造体とビルダーをベースとしたコードを生成します。もちろん、Stringなどは&strのような参照を取りコストが低くなるようになっています
pub struct GetAuthor { id: i64,}impl GetAuthor { pub const QUERY: &'static str = r"SELECT id, name, bio FROM authorsWHERE id = $1 LIMIT 1"; pub async fn query_one( &self, client: &impl tokio_postgres::GenericClient, ) -> Result<GetAuthorRow, tokio_postgres::Error> { let row = client.query_one(Self::QUERY, &[&self.id]).await?; GetAuthorRow::from_row(&row) } pub async fn query_opt( &self, client: &impl tokio_postgres::GenericClient, ) -> Result<Option<GetAuthorRow>, tokio_postgres::Error> { let row = client.query_opt(Self::QUERY, &[&self.id]).await?; match row { Some(row) => Ok(Some(GetAuthorRow::from_row(&row)?)), None => Ok(None), } }}impl GetAuthor { pub const fn builder() -> GetAuthorBuilder<'static, ((),)> { GetAuthorBuilder { fields: ((),), _phantom: std::marker::PhantomData, } }}pub struct GetAuthorBuilder<'a, Fields = ((),)> { fields: Fields, _phantom: std::marker::PhantomData<&'a ()>,}impl<'a> GetAuthorBuilder<'a, ((),)> { pub fn id(self, id: i64) -> GetAuthorBuilder<'a, (i64,)> { let ((),) = self.fields; let _phantom = self._phantom; GetAuthorBuilder { fields: (id,), _phantom, } }}impl<'a> GetAuthorBuilder<'a, (i64,)> { pub const fn build(self) -> GetAuthor { let (id,) = self.fields; GetAuthor { id } }}これを使う場合次のようになります。型状態ベースのビルダーのため、引数を忘れている場合エラーになります。型状態ビルダーの実装はtyped-builderを参考にしました
let author = GetAuthor::builder() .id(1) .build() .query_one(&client) .await?;
// let author = GetAuthor::builder() // 引数を設定しない場合`GetAuthorBuilder<'a,(())>`が推論され、これに`build`は実装されていないためエラー// .build()// .query_one(&client)// .await?;実装的に工夫した箇所
エラーメッセージがわかりやすくなるように、Greptimeのブログを参考に、StackErrorを実装しています。
実際に生成されるエラーメッセージの例を以下に示します
error generating code: generation failed.0:Cannot map type `timestamptz` of table `users.created_at` to a Rust type. Consider add entry to overrides. , at src/lib.rs:3311: at src/query.rs:3722: at src/query.rs:2213:Cannot map type `timestamptz` of table `users.created_at` to a Rust type. Consider add entry to overrides. , at src/query.rs:219エラーが発生した位置まで、どのように関数が呼び出されていたか一目瞭然です。実装としてはそれぞれのエラーに対してstd::panic::Locationを持たせており、
そこで得た位置情報をStackErrorトレイトを経由して表示する形です。それぞれのエラーがStackErrorを実装しているため、再帰的にformat_stackを呼び出すことで上のエラーメッセージができます。
pub trait StackError: std::error::Error { /// format each error stack fn format_stack(&self, layer: usize, buf: &mut Vec<String>); /// next error fn next(&self) -> Option<&dyn StackError>;
/// last error fn last(&self) -> &dyn StackError where Self: Sized, { let Some(mut result) = self.next() else { return self; }; while let Some(err) = result.next() { result = err; } result }}
pub trait StackErrorExt: StackError { fn stack_error(&self) -> Vec<String> where Self: Sized, { let mut buf = Vec::new(); let mut layer = 0; let mut current: &dyn StackError = self;
loop { current.format_stack(layer, &mut buf); match current.next() { Some(next) => { current = next; layer += 1; } None => break, } }
buf }}
impl<E: StackError> StackErrorExt for E {}詳しい解説はGreptimeのブログに譲りますが、wasm環境というスタックトレースを取れない環境では良い選択肢だと考えています。
改善点
現状考えている改善点を挙げます
:copyfromに未対応
行う方法は調査して分かっているのですが、どのような形のAPIとするのが良いのか悩んでおり実装に入れていません
sqlxでStreamを返せていない
postgres系では:many指定されたクエリに対して、Streamを返すメソッドquery_streamが実装されますが、sqlx系は未対応です。
これはsqlxのfetchなどが外部クレートであるfutures::Streamを直接返しているためです。使用する際に新しくクレートを追加せずサッと使えるように、生成されるコードは外部クレートへの依存を0を前提にしているので、
実装が行えませんでした。代替としてQueryAsを返す関数を実装し利用者側でfetchを呼んでもらうことで、この問題の回避を試みています
impl ListUsers { pub const QUERY: &'static str = r"SELECT id, username, email, full_name, created_at FROM usersORDER BY created_at DESCLIMIT $1OFFSET $2"; pub fn query_as<'a>( &'a self, ) -> sqlx::query::QueryAs< 'a, sqlx::Postgres, ListUsersRow, <sqlx::Postgres as sqlx::Database>::Arguments<'a>, > { sqlx::query_as::<_, ListUsersRow>(Self::QUERY) .bind(self.limit) .bind(self.offset) } pub fn query_many<'a, 'b, A>( &'a self, conn: A, ) -> impl Future<Output = Result<Vec<ListUsersRow>, sqlx::Error>> + Send + 'a where A: sqlx::Acquire<'b, Database = sqlx::Postgres> + Send + 'a, { async move { let mut conn = conn.acquire().await?; let vals = self.query_as().fetch_all(&mut *conn).await?; Ok(vals) } }}利用者側は次のようになり、利用者がStreamを使うかどうかの選択を委ねています
use futures::TryStreamExt;
let user_query = sqlx_query::ListUsers::builder() .limit(100) .offset(0) .build();
let mut user_stream = user_query.query_as().fetch(&pool);
while let Some(user) = user_stream.try_next().await.unwrap() { todo!("Do something")}生成されたコード: https://github.com/tunamaguro/sqlc-gen-rust/blob/main/examples/e-commerce/src/sqlx_query.rs#L257-L287
sqlxのドキュメント: https://docs.rs/sqlx/latest/sqlx/query/struct.QueryAs.html#method.fetch
列レベルでの型のオーバライドに未対応
本家sqlcは列レベルで型を上書きできます。このプラグインでも同じことができれば、とくにJSONを格納している列に対して自動でデシリアライズができるので、かなり便利になると考えています
終わりに
本家sqlcの開発が最近活発でなさそうなので、本番利用は怖いですが趣味レベルではSQLの知識をフルに使えるsqlcは良いアプローチだと思っています。 今後どうなるかはわかりませんが、おそらく使用者は自分しかいないのでこつこつメンテナンスしていきます