🫢

[Rust] sqlx使ってみる vol.2 マクロを使って型チェックしてみる

2023/06/09に公開

前回からだいぶ時間が経っていますが
sqlxの query_as! にオフラインモードがあることを知ったので試してみたいと思います。

環境

  • Rust: 1.70
  • PostgreSQL: 15
Cargo.toml
[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を使ってみます

docker-compose.yml
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!で書いてみる

main.rs
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の型をちょっと変えてみましょう

main.rs
#[derive(Debug, Clone, PartialEq, sqlx::FromRow)]
struct User {
    pub id: i64,
    pub name: String,
    pub created_at: chrono::NaiveDateTime,
    pub updated_at: chrono::NaiveDateTime,
}

nameOption<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を使います。

Makefile.toml
[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個入ってます。

query-c6b37fc8c7116e4a7c9e4671cdbecfa52e2121def2eec61d8ebed0fec2375314.json
{
  "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