Rust | SQLx の macro `query_file!` と `query_file_as!` で複雑な SQL クエリを分離する
まえがき
今回は別ファイルとして切り出した SQL クエリを読み込むことができる query_file!
と query_file_as!
に焦点をあてていきます!
query!
と query_as!
については、
以下の記事で触れていますので、ぜひチェックしてみてください😙
query_file! と query_file_as! の特徴
A variant of query! where the SQL query is stored in a separate file.
Useful for large queries and potentially cleaner than multiline strings.
The syntax and requirements (see query!) are the same except the SQL string is replaced by a file path.query!の一種で、SQLクエリを別のファイルに格納する。
大きなクエリに便利で、複数行の文字列よりもきれいになる可能性があります。
構文と要件(query!を参照)は同じですが、SQL文字列がファイルパスに置き換えられています。
SQL クエリを複数行の文字列で記述していると、どうしてもコードが長くなりがちです。
そのような場合に、SQL クエリ部分を別の .sql
に分離できるというわけです!
実装サンプル
データベースの準備
テーブルとサンプルデータは以下のクエリで作成しました。
create table if not exists mountains (
id serial primary key,
"name" varchar not null,
created_at timestamp not null default now(),
updated_at timestamp not null default now()
);
insert into
mountains ("name")
values
('富士山'),
('高尾山'),
('大雪山');
クレートと .env の準備
非同期処理ランタイムには tokio を使います。
cargo add sqlx --features "postgres runtime-tokio-native-tls chrono"
cargo add tokio --features=full
cargo add dotenv
データベースの接続 URL を定義しておきます。
DATABASE_URL=postgres://postgres:postgres@localhost:5432/sample_db
SQL ファイル
以下のようなディレクトリ構成でファイルを格納してみました。
今回はサンプルなので、queries
のみとしたが、
ファイルの数が増えることを想定して、階層は上手に切った方が良さげですね...
src
├── main.rs
└── queries
├── mountain-by-id.sql
└── mountain-insert.sql
せっかく分離したのに、ファイル管理は煩雑になっては意味がないので
ファイル名には規則性を持たせるべきでしょう。
mountain-by-id.sql
SELECT クエリを分離させました。
select
m."id",
m."name",
m."created_at",
m."updated_at"
from
mountains as m
where
m."id" = $1
mountain-insert.sql
INSERT クエリを分離させました。
insert into
mountains ("name")
values
($1)
main.rs
SELECT で query_file!
、INSERTで query_file_as!
を使用しました。
短い SQL 文ですが、ファイルパスに置き換えられたことで、かなりスッキリしました🥳🎉
use sqlx::postgres::{PgPoolOptions, PgQueryResult};
use sqlx::types::chrono::NaiveDateTime;
use sqlx::{Pool, Postgres};
use std::env;
#[tokio::main]
async fn main() {
// 環境変数を読み取り、URLを生成
dotenv::dotenv().expect("Failed to read .env file");
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
// DBコネクションを生成
let db = PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await
.unwrap_or_else(|_| panic!("Cannot connect to the database"));
match get_one(&db, 1).await {
Ok(m) => println!("{:?}", m),
Err(e) => println!("{:?}", e),
}
match create(&db, "高野山").await {
Ok(res) => println!("{:?}", res),
Err(e) => println!("{:?}", e),
}
}
#[derive(Debug)]
struct Mountain {
id: i32,
name: String,
created_at: NaiveDateTime,
updated_at: NaiveDateTime,
}
async fn get_one(db: &Pool<Postgres>, id: i32) -> Result<Mountain, sqlx::Error> {
// 返り値の型、SQL ファイルのパス、引数を指定する
// src からのパス指定
sqlx::query_file_as!(Mountain, "src/queries/mountain-by-id.sql", id)
.fetch_one(db)
.await
}
async fn create(db: &Pool<Postgres>, name: &str) -> Result<PgQueryResult, sqlx::Error> {
// SQL ファイルのパス、引数を指定
// src からのパス指定
sqlx::query_file!("src/queries/mountain-insert.sql", name.to_string())
.execute(db) // insert なので fetch_one ではなく execute
.await
}
query_as! と query_file_as! の比較
比較してみると、スッキリしてますね🫶🫶🫶
Before: query_as!
async fn get_one_2(db: &Pool<Postgres>, id: i32) -> Result<Mountain, sqlx::Error> {
sqlx::query_as!(
Mountain,
r#"
select
m."id",
m."name",
m."created_at",
m."updated_at"
from
mountains as m
where
m."id" = $1
"#,
id
)
.fetch_one(db)
.await
}
After: query_file_as!
async fn get_one(db: &Pool<Postgres>, id: i32) -> Result<Mountain, sqlx::Error> {
// 返り値の型、SQL ファイルのパス、引数を指定する
// src からのパス指定
sqlx::query_file_as!(Mountain, "src/queries/mountain-by-id.sql", id)
.fetch_one(db)
.await
}
JOIN が多い場合や WITH 句がある場合などは、
より query_file_as!
の恩恵を受けることができそうです。
また、query_file_as!
は返り値の型が保証されているので、
SQL クエリが外部の別ファイルにあっても、分離したことによって返り値はわからないということもなさそうです。
拡張子の縛りはあるの?
ドキュメントを見ていると、別ファイル という記載があるので、
拡張子は .sql
以外でも動作するのか検証してみました笑
結論:問題なし!
async fn get_one(db: &Pool<Postgres>, id: i32) -> Result<Mountain, sqlx::Error> {
// 返り値の型、SQL ファイルのパス、引数を指定する
// src からのパス指定
sqlx::query_file_as!(Mountain, "src/queries/mountain-by-id.txt", id)
.fetch_one(db)
.await
}
テキスト .txt
でも、問題なく動作することが確認できました。
ファイルの中身を文字列として読み込んでいると考えれば、当然と言えば当然の結果です。
ただ、.sql
以外を採用するメリットはなさそうです🤫
所感・使いどころ
複数行になるとコードの可読性が落ちるので、ファイルを分離できるのは良いなと思いました!
IntelliJ IDEA で開発をしていますが、
SQL の入力補完は働くので文字列より楽に記述できる点も良かったです。
当然、$1
の箇所ではエラーになるので、
エラーにならないように回避ができると開発が捗りそう...😗😗
実践導入にあたっては、以下を検討したいです🤔
-
queries
ディレクトリ配下の構成- ファイルが増えた場合のことを考慮しておきたい
- フォルダ名やファイル名の命名規則
- SELECT, INSERT, UPDATE, DELETE はフォルダ名やファイル名で判断がつくと良さそう
- すべて分離させるか、一部を分離させるか
- 一部の場合、何を基準として分離させるのか
- 中途半端になるくらいなら、すべて分離が分かりやすい?
まとめ
SQLx は本当に使いやすい!
よく考えられているなと感じます👏✨
Discussion
JetBrainsのSQLエディター内で
$1
、$2
などのプレイスホルダーがエラーになってしまう対策ですが、JetBrainsの設定より「データベース | データベース | クエリ実行 | ユーザーパラメーター」から「パラメーターパターン」を追加。パターンを
\$\d+
と設定するとSQLエディターでエラーにならなくなります。参考: https://www.jetbrains.com/help/pycharm/settings-tools-database-user-parameters.html
あと、記事中の
dotenv
クレートですが、あまりメンテナンスされていないようでdotenvy
クレートを使おうという動きもあります。参考: https://github.com/allan2/dotenvy
別の記事で
dotenvy
をお使いでしたね。失礼いたしました。返信が遅れまして、申し訳ありません!!!!
いづれも記事に追記させていただきました🙇♂️✨
dotenv
事情については、Zennの別の記事を拝見しておりました。おっしゃる通り、今は
dotenvy
を使う方が望ましいかと思います!