[Rust] SeaORMでDBアクセスする

2023.06.21

Introduction

RustでORMといえばDieselの一択でしたが、
しばらく見ないうちに他のプロダクトもけっこうでてきてました。
本稿ではその中の1つ、SeeORMを使ってみます。

SeaORM?

SeaORMは、Rustと各種RDBを接続するライブラリです。
コードからDBにアクセスするライブラリとしてだけでなく、Migration機能や
SeaORM用のRustエンティティ生成などの機能をもっています。

最初から非同期&動的な扱いが可能で、
使いやすさを重視しているライブラリみたいです。

今回はMySQLをつかって
SeaORMのMigration機能をつかってスキーマを定義し、
エンティティの生成とCRUDアクセスをやってみましょう。

Environment

今回試した環境は以下のとおりです。

  • MacBook Pro (13-inch, M1, 2020)
  • OS : MacOS 12.4
  • Rust : 1.70.0
  • MySQL : 8.0.32

Setup

まずはMySQLの準備をします。
CLIでデータベースを作成しましょう。

% mysql -u<ユーザー名> -p
・
・
mysql > create database my_db;

次にCargoでプロジェクトの作成をします。

% cargo new seaorm-example && cd seaorm-example

Cargo.tomlで依存ライブラリの設定。

[dependencies]
futures = "0.3.28"
sea-orm = { version = "0.11.3", features = [ "sqlx-mysql", "runtime-async-std-native-tls", "macros" ] }

今回はMySQLを使うのでsqlx-mysqlを指定します。
runtimeについてはこのあたりをご確認ください。

とりあえずmain.rsを↓のように記述します。

use futures::executor::block_on;
use sea_orm::{Database, DbErr};

const DATABASE_URL: &str = "mysql://<ユーザー名>:<パスワード>@localhost:3306";
const DB_NAME: &str = "my_db";

async fn run() -> Result<(), DbErr> {
    let db = Database::connect(DATABASE_URL).await?;
    println!("{:?}",db);
    Ok(())
}

fn main() {
    if let Err(err) = block_on(run()) {
        panic!("{}", err);
    }
}

実行して問題なく終了すればセットアップはOKです。

% cargo run
   Compiling seaorm v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 1.14s
     Running `target/debug/seaorm`

SqlxMySqlPoolConnection

Migration Database

次に、sea-orm-cliでMigrationしてみます。 CargoでCLIツールをインストールしましょう。

% cargo install sea-orm-cli

プロジェクトのルートでmigrate initを実行して初期化します。
あたらしくmgrationディレクトリ以下にRustプロジェクトが作成されました。

% sea-orm-cli migrate init
Initializing migration directory...
Creating file `./migration/src/lib.rs`
Creating file `./migration/src/m20220101_000001_create_table.rs`
Creating file `./migration/src/main.rs`
Creating file `./migration/Cargo.toml`
Creating file `./migration/README.md`
Done!

現在のディレクトリ構成はこんな感じです。

% tree  -I target
.
├── Cargo.lock
├── Cargo.toml
├── migration
│   ├── Cargo.toml
│   ├── README.md
│   └── src
│       ├── lib.rs
│       ├── m20220101_000001_create_table.rs
│       └── main.rs
└── src
    └── main.rs

migrationファイルを作成してDBの更新をします。
今回は↓のようにBookとAuthorのシンプルなテーブルを2つ作成しましょう。

ファイル名は
m_<6-digit-index>_.rs
という形式にする必要があるみたいです。

migration/Cargo.tomlも先程のCargo.tomlと同じライブラリを設定します。

[dependencies.sea-orm-migration]
version = "0.11.0"
features = [
  "sqlx-mysql",
  "runtime-async-std-native-tls",
]

作成するテーブルに対応したmigrationファイルを作成します。
まずはAuthor用のファイル。
idとnameだけもつシンプルなテーブルです。

//seaorm/migration/src/m20230621_000001_create_author_table.rs
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Author::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Author::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Author::Name).string().not_null())
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Author::Table).to_owned())
            .await
    }
}

#[derive(Iden)]
pub enum Author {
    Table,
    Id,
    Name,
}

Book用のmigrationファイルです。
Authorとの外部キーを設定している分、少しだけコードが多いです。

//seaorm/migration/src/m20230621_000002_create_book_table.rs
use sea_orm_migration::prelude::*;
use super::m20230621_000001_create_author_table::Author;


#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Book::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Book::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Book::Name).string().not_null())
                    .col(ColumnDef::new(Book::AuthorId).integer().not_null())
                    .foreign_key(
                        ForeignKey::create()
                            .name("fk-book-author_id")
                            .from(Book::Table, Book::AuthorId)
                            .to(Author::Table, Author::Id),
                    )
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Book::Table).to_owned())
            .await
    }
}

#[derive(Iden)]
enum Book {
    Table,
    Id,
    Name,
    AuthorId
}

lib.rsで↑のmigrationファイルをmodして対象モジュールとして
Vecに格納します。

pub use sea_orm_migration::prelude::*;

mod m20230621_000001_create_author_table;
mod m20230621_000002_create_book_table;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(m20230621_000001_create_author_table::Migration),
            Box::new(m20230621_000002_create_book_table::Migration)
        ]
    }
}

migrate refreshコマンドをプロジェクトのルートで実行します。

% DATABASE_URL="mysql://<ユーザー名>:<パスワード>@localhost:3306/my_db" sea-orm-cli migrate refresh

Running `cargo run --manifest-path ./migration/Cargo.toml -- refresh -u mysql:・・・
   Compiling migration v0.1.0 (/seaorm/migration)
    Finished dev [unoptimized + debuginfo] target(s) in 3.37s
     Running `migration/target/debug/migration refresh -u 'mysql:・・・/my_db'`
Rolling back all applied migrations
No applied migrations
Applying all pending migrations
Applying migration 'm20230621_000001_create_author_table'
Migration 'm20230621_000001_create_author_table' has been applied
Applying migration 'm20230621_000002_create_book_table'
Migration 'm20230621_000002_create_book_table' has been applied

MySQLのCLIで確認してみましょう。

% mysql -u<ユーザー名> -p
・
・
mysql> use my_db;
mysql> show tables;
+------------------+
| Tables_in_my_db  |
+------------------+
| author           |
| book             |
| seaql_migrations |
+------------------+

mysql> show create table book;

| book  | CREATE TABLE `book` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `author_id` int NOT NULL,
  PRIMARY KEY (`id`),
  KEY `fk-book-author_id` (`author_id`),
  CONSTRAINT `fk-book-author_id` FOREIGN KEY (`author_id`) REFERENCES `author` (`id`)
)

テーブルが作成され、外部キーも設定されてます。

Generate Entity from Database

migrationでMySQLにテーブルができたので、
次はRustプログラムで使用するエンティティを生成します。

CLIのgenerate entityを使えば生成されます。

% sea-orm-cli generate entity \
    -u mysql://<ユーザー名>:<パスワード>@localhost:3306/my_db \
    -o src/entities


sea-orm-cli generate entity \
    -u mysql://・・・/my_db \
    -o src/entities
Generating author.rs
    > Column `id`: i32, auto_increment, not_null
    > Column `name`: String, not_null
Generating book.rs
    > Column `id`: i32, auto_increment, not_null
    > Column `name`: String, not_null
    > Column `author_id`: i32, not_null

ディレクトリ構成は↓のようになりました。

% tree  -I target
.
├── Cargo.lock
├── Cargo.toml
├── migration
│   ├── Cargo.lock
│   ├── Cargo.toml
│   ├── README.md
│   └── src
│       ├── lib.rs
│       ├── m20230621_000001_create_author_table.rs
│       ├── m20230621_000002_create_book_table.rs
│       └── main.rs
└── src
    ├── entities
    │   ├── author.rs
    │   ├── book.rs
    │   ├── mod.rs
    │   └── prelude.rs
    └── main.rs

4 directories, 14 files

Write code to access DB

ではmain.rsにCRUD操作を記述していきます。
まずは必要モジュールのimportなど。

    mod entities;

    use futures::executor::block_on;
    use sea_orm::*;
    use entities::{prelude::*, *};
    use sea_orm::InsertResult;
    use entities::author::ActiveModel as AuthorModel;
    use entities::book::ActiveModel as BookModel;
    use crate::book::Model;

    const DATABASE_URL: &str = "mysql://<ユーザー名>:<パスワード>@localhost:3306/";
    const DB_NAME: &str = "my_db";

run関数の中に各種処理を記述していきます。
まずはAuthorとBookをinsertする処理です。

ActiveModelを使ってプロパティを設定してinsert関数を実行します。

    async fn run() -> Result<(), DbErr> {
        let db:DatabaseConnection = Database::connect(DATABASE_URL.to_owned() + DB_NAME).await?;
        let author_res = insert_author(&db,"Donald E. Knuth").await?;
        println!("author_res : {:?}",author_res);
        let book_res = insert_book(&db,author_res.last_insert_id,"The Art of Computer Programming").await;
        println!("book : {:?}",book_res);

        Ok(())
    }

    async fn insert_author(db:&DatabaseConnection,name:&str) -> Result<InsertResult<AuthorModel>, DbErr>{
        let author = author::ActiveModel {
            name: ActiveValue::Set(name.to_owned()),
            ..Default::default()
        };
        let res = Author::insert(author).exec(db).await?;

        println!("{}", type_of(&res));
        Ok(res)
    }

    async fn insert_book(db:&DatabaseConnection,author_id:i32,name:&str) -> Result<InsertResult<BookModel>, DbErr>{
        let book = book::ActiveModel {
            name: ActiveValue::Set(name.to_owned()),
            author_id: ActiveValue::Set(author_id),
            ..Default::default()
        };
        let res = Book::insert(book).exec(db).await?;
        println!("{}", type_of(&res));

        Ok(res)
    }

selectはこんなかんじです。

    async fn run() -> Result<(), DbErr> {
        ・・・・・・・・・・・・・・
        //insert処理の後
        select(&db).await;
        let book_id = book_res.unwrap().last_insert_id;
        println!("book_id : {:?}",book_id);

        Ok(())

    }

    async fn select(db:&DatabaseConnection) {
        // find all models
        let books: Vec<book::Model> = Book::find().all(db).await.unwrap();
        for item in &books {
            println!("find all = {:?}", item);
        }
        // find and filter
        let filter_books: Vec<book::Model> = Book::find()
            .filter(book::Column::Name.contains("Computer"))
            .all(db)
            .await.unwrap();
        for item in &filter_books {
            println!("filter = {:?}", item);
        }
    }

PK検索、update、deleteは下記です。
どの処理も直感的にわかりやすいものになってます。

    async fn run() -> Result<(), DbErr> {
      ・・・・・・・・・・・・・・
      //insert処理の後

      let book = find_book_by_id(&db,book_id).await;
      println!("find by id = {:?}", book);

      let active_model_book = book.unwrap().into_active_model();
      update(&db,&active_model_book);

      let book2 = find_book_by_id(&db,book_id).await;
      let active_model_book2 = book2.unwrap().into_active_model();
      let delete = delete(&db,&active_model_book2).await.unwrap();
      println!("delete= {:?}", delete);

      Ok(())

    }

    async fn find_book_by_id(db:&DatabaseConnection,id:i32) -> Option<book::Model> {
        Book::find_by_id(id).one(db).await.unwrap()
    }

    async fn delete(db:&DatabaseConnection,book:&book::ActiveModel) -> Result<DeleteResult, DbErr>{
        Ok(book.clone().delete(db).await.unwrap())
    }

    async fn update(db:&DatabaseConnection,book:&book::ActiveModel) -> Result<Model, DbErr>{
        let mut b= book.clone();
        b.name = Set("update book".to_owned());
        let res = b.update(db).await;
        println!("{}", type_of(&res));
        res

    }

実行すると↓のような感じでCRUD処理が実行されます。

%cargo run 

&sea_orm::executor::insert::InsertResult<seaorm::entities::author::ActiveModel>
author_res : InsertResult { last_insert_id: 39 }
&sea_orm::executor::insert::InsertResult<seaorm::entities::book::ActiveModel>
book_res : Ok(InsertResult { last_insert_id: 34 })
find all = Model { id: 34, name: "The Art of Computer Programming", author_id: 39 }
book_id : 34
find by id = Some(Model { id: 34, name: "The Art of Computer Programming", author_id: 39 })
delete= DeleteResult { rows_affected: 1 }

シンプルなCRUDを実行してみました。
チュートリアルcookbookには
いろいろな使いかたやクエリの記述方法が書いてあるのでご確認ください。

Sumarry

今回はRustのORM、SeaORMを使ってみました。
シンプルで直感的、ツールも整っていて使いやすいと思います。
じゃあDieselかSeaORMどっちがいいのって話になりますが、
これは一概にどっちともいえません。
ここで比較されてますが、
どっちを選ぶかはプロジェクトの仕様によって異なるとの結論です。
Dieselは堅牢な機能とパフォーマンスに優れており、
コンパイル時のチェック、安全性、シンプルさを優先する場合は、Diesel。   SeaORMは多くのDBエンジンと遅延読み込みをサポートしており、
直感的な操作と生産性が高いです。
それらを考慮したうえで選択しましょう。

References