[Rust] sqlx使ってみる vol.2 マクロを使って型チェックしてみる
前回からだいぶ時間が経っていますが
sqlxの query_as!
にオフラインモードがあることを知ったので試してみたいと思います。
環境
- Rust: 1.70
- PostgreSQL: 15
[package]
name = "rust_sqlx_examples"
version = "0.1.0"
authors = ["yagince <xxxx@gmail.com>"]
edition = "2021"
publish = false
[dependencies]
sqlx = { version = "0.7.0-alpha.3", features = [ "runtime-tokio-rustls", "postgres", "chrono" ] }
tokio = { version = "1.28.2", features = ["full"] }
anyhow = "=1.0.71"
once_cell = "=1.18.0"
chrono = "=0.4.26"
※sqlxは0.7.0からprepareしたときに生成されるファイルが変わっているっぽいので2023/06/08現在はalpha版ですが0.7.0を使ってみます
version: "3.7"
x-environment: &environment
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: postgres
POSTGRES_DB_TEST: postgres_test
PGSSLMODE: disable
# DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
services:
app:
build:
context: .
container_name: app
working_dir: /app
command: bash
tty: true
environment: *environment
volumes:
- ./:/app
ports:
- 3000:3000
depends_on:
- postgres
postgres:
image: postgres:15.3-alpine3.17
container_name: postgres
environment: *environment
ports:
- "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
driver: local
FROM rust:1.70.0-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive \
LC_CTYPE=ja_JP.utf8 \
LANG=ja_JP.utf8 \
SQLDEF_VERSION=v0.16.1
RUN apt-get update \
&& apt-get install -y -q \
ca-certificates \
locales \
libpq-dev \
gnupg \
apt-transport-https\
libssl-dev \
pkg-config \
curl \
build-essential \
git \
wget \
&& echo "ja_JP UTF-8" > /etc/locale.gen \
&& locale-gen \
\
&& echo "install sqldef" \
&& curl -L -O https://github.com/k0kubun/sqldef/releases/download/${SQLDEF_VERSION}/psqldef_linux_amd64.tar.gz \
&& tar xf psqldef_linux_amd64.tar.gz \
&& rm psqldef_linux_amd64.tar.gz \
&& mv psqldef /usr/local/bin \
\
&& echo "install rust tools" \
&& rustup component add rustfmt \
&& cargo install cargo-watch cargo-make cargo-edit sqlx-cli@0.7.0-alpha.3
WORKDIR /app
CMD ["cargo", "run"]
query_asで構造体にマッピングするコードの問題点
コンパイル時に型チェックされないのでクエリの結果とマッピングする構造体が合っているかどうか実行してみないとわからない
sqlxを使う上でこれが結構難しい所だなぁと感じてました。
sqlxはクエリービルダー的なものを使わずに生SQLを書いて行くので
select id from users;
を
struct User {
pub name String,
}
にマッピングしてもコンパイルエラーにはならない
実行時まで気づけ無いっていうのはせっかくRust使っているのに、、、って感じでした。
dieselはマクロでスキーマ定義を書くのである程度コンパイル時に気づけますね。
これが query_as!
マクロを使うと、 コンパイル時にDBを見てチェックしてくれる ようなんですが
ビルド時に常にDBサーバが動いてないといけないんだとすると コンテナイメージのビルド時 とかに困りますよね。
これをオフラインモードが解決してくれるっぽいのでやってみたいと思います。
まずはデータベースを準備する
CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY,
name VARCHAR(255),
created_at TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL default CURRENT_TIMESTAMP
);
CREATE TABLE posts (
id BIGSERIAL NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
visibility INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT,
created_at TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
CONSTRAINT fk_posts_user_id FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE UNIQUE INDEX posts_user_id ON posts (user_id);
DATABASE_URLを使ってUserを取得するクエリをquery_as!で書いてみる
use once_cell::sync::Lazy;
struct Config {
postgres_host: String,
postgres_port: String,
postgres_user: String,
postgres_password: String,
postgres_database: String,
}
impl Config {
pub fn database_url(&self) -> String {
format!(
"postgres://{}:{}@{}:{}/{}",
self.postgres_user,
self.postgres_password,
self.postgres_host,
self.postgres_port,
self.postgres_database
)
}
}
static CONFIG: Lazy<Config> = Lazy::new(|| Config {
postgres_host: std::env::var("POSTGRES_HOST").unwrap(),
postgres_port: std::env::var("POSTGRES_PORT").unwrap(),
postgres_user: std::env::var("POSTGRES_USER").unwrap(),
postgres_password: std::env::var("POSTGRES_PASSWORD").unwrap(),
postgres_database: std::env::var("POSTGRES_DB").unwrap(),
});
#[derive(Debug, Clone, PartialEq, sqlx::FromRow)]
struct User {
pub id: i64,
pub name: Option<String>,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.connect(&CONFIG.database_url())
.await?;
let users = sqlx::query_as!(User, "select * from users")
.fetch_all(&pool)
.await?;
println!("{:?}", users.len());
println!("{:?}", users);
Ok(())
}
User
に対してマッピングされるようにselectしてみました。
実行してみましょう
root@957525ae85a3:/app# cargo build
Compiling rust_sqlx_examples v0.1.0 (/app)
error: `DATABASE_URL` must be set, or `cargo sqlx prepare` must have been run and .sqlx must exist, to use query macros
--> src/main.rs:80:17
|
80 | let users = sqlx::query_as!(User, "select * from users")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
error: could not compile `rust_sqlx_examples` (bin "rust_sqlx_examples") due to previous error
怒られました。
-
DATABASE_URL
をセットする -
cargo sqlx prepare
する
どちらかが必要らしいですね。
とりあえず DATABASE_URL
をセットしてやってみます
root@957525ae85a3:/app# DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres cargo build
Compiling rust_sqlx_examples v0.1.0 (/app)
Finished dev [unoptimized + debuginfo] target(s) in 1.78s
無事ビルドできました
試しにUserの型をちょっと変えてみましょう
#[derive(Debug, Clone, PartialEq, sqlx::FromRow)]
struct User {
pub id: i64,
pub name: String,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
name
を Option<String>
-> String
にしてみました。
ビルドしてみます
root@957525ae85a3:/app# DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres cargo build
Compiling rust_sqlx_examples v0.1.0 (/app)
error[E0277]: the trait bound `std::string::String: From<Option<std::string::String>>` is not satisfied
--> src/main.rs:80:17
|
80 | let users = sqlx::query_as!(User, "select * from users")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `From<Option<std::string::String>>` is not implemented for `std::string::String`
|
= help: the following other types implement trait `From<T>`:
<std::string::String as From<&mut str>>
<std::string::String as From<&std::string::String>>
<std::string::String as From<&str>>
<std::string::String as From<Box<str>>>
<std::string::String as From<Cow<'a, str>>>
<std::string::String as From<char>>
<std::string::String as From<url::Url>>
= note: required for `Option<std::string::String>` to implement `Into<std::string::String>`
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
error: could not compile `rust_sqlx_examples` (bin "rust_sqlx_examples") due to previous error
エラーになりました。
エラー内容からは、どのフィールドが駄目なのかはわかりにくそうですね...
オフラインモードでやってみる
sqlx-cliを使います。
[tasks.sqlx-prepare]
command = "cargo"
args = [
"sqlx",
"prepare",
"--database-url",
"postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}",
]
↑こんな感じでタスクを書いてみたので、実行してみます
root@957525ae85a3:/app# cargo make sqlx-prepare
[cargo-make] INFO - cargo make 0.36.9
[cargo-make] INFO - Calling cargo metadata to extract project info
[cargo-make] INFO - Cargo metadata done
[cargo-make] INFO - Project: rust_sqlx_examples
[cargo-make] INFO - Build File: Makefile.toml
[cargo-make] INFO - Task: sqlx-prepare
[cargo-make] INFO - Profile: development
[cargo-make] INFO - Execute Command: "cargo" "sqlx" "prepare" "--database-url" "postgres://postgres:postgres@postgres:5432/postgres"
Checking rust_sqlx_examples v0.1.0 (/app)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
query data written to .sqlx in the current directory; please check this into version control
[cargo-make] INFO - Build Done in 1.01 seconds.
.sqlx
っていうディレクトリが作成されました
❯ tree .sqlx
.sqlx
└── query-c6b37fc8c7116e4a7c9e4671cdbecfa52e2121def2eec61d8ebed0fec2375314.json
0 directories, 1 file
JSONファイルが1個入ってます。
{
"db_name": "PostgreSQL",
"query": "select * from users",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamp"
},
{
"ordinal": 3,
"name": "updated_at",
"type_info": "Timestamp"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
true,
false,
false
]
},
"hash": "c6b37fc8c7116e4a7c9e4671cdbecfa52e2121def2eec61d8ebed0fec2375314"
}
この状態で DATABASE_URL
なしでビルドしてみましょう
root@957525ae85a3:/app# cargo build
Compiling rust_sqlx_examples v0.1.0 (/app)
Finished dev [unoptimized + debuginfo] target(s) in 1.65s
できました。
試しにコンパイルエラーになるようにしてみます。
root@957525ae85a3:/app# cargo build
Compiling rust_sqlx_examples v0.1.0 (/app)
error[E0277]: the trait bound `std::string::String: From<Option<std::string::String>>` is not satisfied
--> src/main.rs:80:17
|
80 | let users = sqlx::query_as!(User, "select * from users")
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `From<Option<std::string::String>>` is not implemented for `std::string::String`
|
= help: the following other types implement trait `From<T>`:
<std::string::String as From<&mut str>>
<std::string::String as From<&std::string::String>>
<std::string::String as From<&str>>
<std::string::String as From<Box<str>>>
<std::string::String as From<Cow<'a, str>>>
<std::string::String as From<char>>
<std::string::String as From<url::Url>>
= note: required for `Option<std::string::String>` to implement `Into<std::string::String>`
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
error: could not compile `rust_sqlx_examples` (bin "rust_sqlx_examples") due to previous error
おぉ。ちゃんと同じエラーになりましたね。
ちょっと複雑なマッピングもオフラインモードでやってみる
#[derive(Debug, Clone, PartialEq, sqlx::Type, Default)]
#[repr(i32)]
enum PostVisibility {
#[default]
Public = 1,
Private = 2,
}
impl From<i32> for PostVisibility {
fn from(value: i32) -> Self {
Self::try_from(value).unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, sqlx::FromRow)]
struct Post {
pub id: i64,
pub visibility: PostVisibility,
pub user_id: i64,
pub title: String,
pub body: Option<String>,
pub created_at: chrono::NaiveDateTime,
pub updated_at: chrono::NaiveDateTime,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
...
let posts = sqlx::query_as!(Post, "select * from posts")
.fetch_all(&pool)
.await?;
println!("{:?}", posts.len());
println!("{:?}", posts);
...
}
こんなのを追加してみました。
visibility
というEnumを定義して、デシリアライズさせてみます。
※ query_as
で書く場合は From<i32>
は repr(i32)
だけで出来たはずなんですが、今回は無いと怒られました
prepareすると
{
"db_name": "PostgreSQL",
"query": "select * from posts",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "visibility",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "title",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamp"
},
{
"ordinal": 6,
"name": "updated_at",
"type_info": "Timestamp"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "f255945757f6dc2b47f76a8648c4a1dbbdec9b8ad275e31afa3cda5c667368ab"
}
こんなJSONファイルができました
root@957525ae85a3:/app# cargo build
Compiling rust_sqlx_examples v0.1.0 (/app)
Finished dev [unoptimized + debuginfo] target(s) in 1.86s
ビルドできました。
JOINしたい
let users = sqlx::query_as!((User, Post),
r#"
select
users.id, users.name, users.created_at, users.updated_at,
posts.id, posts.user_id, posts.title, posts.body, posts.visibility, posts.created_at, posts.updated_at
from users
inner join posts on users.id = posts.user_id
"#,
)
.fetch_all(&mut transaction)
.await?;
さて、こんな感じでいけるんでしょうか?
root@957525ae85a3:/app# cargo build
Compiling rust_sqlx_examples v0.1.0 (/app)
error: no rules expected the token `(`
--> src/main.rs:141:33
|
141 | let users = sqlx::query_as!((User, Post),
| ^ no rules expected this token in macro call
|
note: while trying to match meta-variable `$out_struct:path`
--> /usr/local/cargo/registry/src/index.crates.io-6f17d22bba15001f/sqlx-0.7.0-alpha.3/src/macros/mod.rs:560:6
|
560 | ($out_struct:path, $query:expr) => ( {
| ^^^^^^^^^^^^^^^^
error[E0425]: cannot find value `transaction` in this scope
--> src/main.rs:150:25
|
150 | .fetch_all(&mut transaction)
| ^^^^^^^^^^^ not found in this scope
For more information about this error, try `rustc --explain E0425`.
error: could not compile `rust_sqlx_examples` (bin "rust_sqlx_examples") due to 2 previous errors
なるほど $out_struct:path
になってますね。
path
であればいいのか?
#[derive(Debug, Clone, PartialEq, sqlx::FromRow)]
struct A {
pub users: User,
pub posts: Post,
}
let users = sqlx::query_as!(A,
r#"
select
users.id, users.name, users.created_at, users.updated_at,
posts.id, posts.user_id, posts.title, posts.body, posts.visibility, posts.created_at, posts.updated_at
from users
inner join posts on users.id = posts.user_id
"#,
)
.fetch_all(&pool)
.await?;
println!("{:#?}", users);
error[E0560]: struct `A` has no field named `id`
--> src/main.rs:146:17
|
146 | let users = sqlx::query_as!(A,
| _________________^
147 | | r#"
148 | | select
149 | | users.id, users.name, users.created_at, users.updated_at,
... |
153 | | "#,
154 | | )
| |_____^ `A` does not have this field
|
= note: available fields are: `users`, `posts`
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query_as` (in Nightly builds, run with -Z macro-backtrace for more info)
...
うん、駄目ですね。
let user_posts = sqlx::query!(r#"
select
users.id as user_id, users.name, users.created_at as user_created_at, users.updated_at as user_updated_at,
posts.id, posts.title, posts.body, posts.visibility, posts.created_at, posts.updated_at
from users
inner join posts on users.id = posts.user_id
"#)
.fetch_all(&pool)
.await?;
println!("{:#?}", user_posts);
こんな感じにしたら通りました。
- Structにマッピングしない(これに対応するStructを用意することはできるけど、JOINする度に書くのは面倒なので)
- ASでカラム名がかぶらないようにする
これでもちゃんと型がついて補完効くのがすばらしい。
ただし、型がmacroで動的に生成されるので、関数の戻り値などにするのは難しい
async fn get_user(pool: Pool<Postgres>) -> bool {
sqlx::query!(r#"
select
users.id as user_id, users.name, users.created_at as user_created_at, users.updated_at as user_updated_at,
posts.id, posts.title, posts.body, posts.visibility, posts.created_at, posts.updated_at
from users
inner join posts on users.id = posts.user_id
"#)
.fetch_all(&pool)
.await
}
error[E0308]: mismatched types
--> src/main.rs:157:5
|
157 | / sqlx::query!(r#"
158 | | select
159 | | users.id as user_id, users.name, users.created_at as user_created_at, users.updated_at as user_updated_at,
160 | | posts.id, posts.title, posts.body, posts.visibility, posts.created_at, posts.updated_at
... |
164 | | .fetch_all(&pool)
165 | | .await
| |______________^ expected `bool`, found `Result<Vec<Record>, Error>`
|
= note: expected type `bool`
found enum `Result<Vec<get_user::{closure#0}::Record>, sqlx::Error>`
get_user::{closure#0}::Record
なるほど。
async fn get_user(pool: Pool<Postgres>) -> anyhow::Result<Vec<(User, Post)>> {
let records = sqlx::query!(r#"
select
users.id as user_id, users.name, users.created_at as user_created_at, users.updated_at as user_updated_at,
posts.id, posts.title, posts.body, posts.visibility, posts.created_at, posts.updated_at
from users
inner join posts on users.id = posts.user_id
"#)
.fetch_all(&pool)
.await?;
Ok(records
.into_iter()
.map(|record| {
let user = User {
id: record.user_id,
name: record.name,
created_at: record.user_created_at,
updated_at: record.user_updated_at,
};
let post = Post {
id: record.id,
visibility: record.visibility.into(),
user_id: record.user_id,
title: record.title,
body: record.body,
created_at: record.created_at,
updated_at: record.updated_at,
};
(user, post)
})
.collect())
}
こんな風にすることはできますね。
※Copilotが書いてくれました。
SQLの構文ミスってる時はどうなる?
let records = sqlx::query!(r#"
select
users.id as user_id, users.name, users.created_at as user_created_at, users.updated_at as user_updated_at,
posts.id, posts.title, posts.body, posts.visibility, posts.created_at, posts.updated_at
from users
inne join posts on users.id = posts.user_id
"#)
.fetch_all(&pool)
.await?;
inner
-> inne
にしてみました。
prepare
してみるとエラーになりました。
error: error returned from database: invalid reference to FROM-clause entry for table "users"
--> src/main.rs:157:19
|
157 | let records = sqlx::query!(r#"
| ___________________^
158 | | select
159 | | users.id as user_id, users.name, users.created_at as user_created_at, users.updated_at as user_updated_at,
160 | | posts.id, posts.title, posts.body, posts.visibility, posts.created_at, posts.updated_at
161 | | from users
162 | | inne join posts on users.id = posts.user_id
163 | | "#)
| |_______^
|
= note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info)
まぁ、当然といえば当然ですね。
ただ、エラーの内容からはどこがミスってるか分かりづらいですね...
まとめ
sqlxやっぱり結構良い気がしてます。
RailsやDiesel使ってるとクエリービルダーっぽい感じで書きたくなる気持ちもあるんですが、複雑なクエリ書くのは結構面倒だったりします。
- 生SQLで書けて
- しかも型チェックもちゃんとしてくれて
- 補完も効く
っていうのは結構良い体験ですね。
Discussion