CLOVER🍀

That was when it all began.

Quarkus × Flywayを試す

これは、なにをしたくて書いたもの?

以前に、Flywayを試してみました。

データベースマイグレーションツール、Flywayを試してみる - CLOVER🍀

今回は、フレームワークに組み込んで使うパターンとしてQuarkusのExtensionがあるのでこちらを試してみました。

Quarkus - Using Flyway

Quarkus Flyway Extension

QuarkusのFlyway Extensionについてのドキュメントはこちら。

Quarkus - Using Flyway

実行方法は、以下の2つがあります。

  • quarkus.flyway.migrate-at-startをtrueに設定し、アプリケーションの起動時にFlywayを自動実行する
  • Flywayのインスタンスをインジェクションし、Flyway#migrateをプログラム内で呼び出して実行する

マイグレーションのデフォルトの配置先は、db/migrationです。

Flywayを使うために必要なExtensionはDatasource、というかJDBCです。

Quarkus - Datasources

あとは複数のデータソースを扱う方法や、Hibernateと組み合わせるといった話もあるようですが。

Schema Migration with Flyway / Multiple datasources

Schema Migration with Flyway / Flyway and Hibernate ORM

Using Hibernate ORM and JPA / Automatically transitioning to Flyway to Manage Schemas

今回は、以下の内容で扱ってみたいと思います。

  • Flywayはアプリケーションの起動時に自動実行し、単一のデータソースに適用する
  • 通常の起動とテストでの起動で、別々の設定にする
  • アプリケーションは、JDBCではなくReactive SQL Clientを使ってデータベースにアクセスする
  • 使用するデータベースは、MySQLとする

Quarkus - Reactive SQL Clients

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.1 2021-10-19
OpenJDK Runtime Environment (build 17.0.1+12-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.1+12-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.4 (9b656c72d54e5bacbed989b64718c159fe39b537)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.1, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-96-generic", arch: "amd64", family: "unix"

MySQLのバージョンはこちらで、172.17.0.2で動作しているものとします。

$ mysql --version
mysql  Ver 8.0.28 for Linux on x86_64 (MySQL Community Server - GPL)

プロジェクトを作成する

では、Quarkusプロジェクトを作成します。Flyway Extensionを使うのに最低限必要なのは、flyway、そしてjdbc-[使用するデータベース]です。

$ mvn io.quarkus.platform:quarkus-maven-plugin:2.6.2.Final:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=flyway-migration \
    -DprojectVersion=0.0.1-SNAPSHOT \
    -Dextensions="resteasy-reactive,resteasy-reactive-jackson,flyway,jdbc-mysql,reactive-mysql-client"

その他は、今回のアプリケーションを書くのに選んだものです。

Extensionの表示。

[INFO] Looking for the newly published extensions in registry.quarkus.io
[INFO] -----------
[INFO] selected extensions: 
- io.quarkus:quarkus-jdbc-mysql
- io.quarkus:quarkus-flyway
- io.quarkus:quarkus-resteasy-reactive
- io.quarkus:quarkus-reactive-mysql-client
- io.quarkus:quarkus-resteasy-reactive-jackson

[INFO] 
applying codestarts...
[INFO] 📚  java
🔨  maven
📦  quarkus
📝  config-properties
🔧  dockerfiles
🔧  maven-wrapper
🚀  resteasy-reactive-codestart

プロジェクト内に移動。

$ cd flyway-migration

Maven依存関係は、こちら。

  <dependencies>                                                                                                                                                             
    <dependency>                                                                                                                                                             
      <groupId>io.quarkus</groupId>                                                                                                                                          
      <artifactId>quarkus-jdbc-mysql</artifactId>                                                                                                                            
    </dependency>                                                                                                                                                            
    <dependency>                                                                                                                                                             
      <groupId>io.quarkus</groupId>                                                                                                                                          
      <artifactId>quarkus-flyway</artifactId>                                                                                                                                
    </dependency>                                                                                                                                                            
    <dependency>                                                                                                                                                             
      <groupId>io.quarkus</groupId>                                                                                                                                          
      <artifactId>quarkus-resteasy-reactive</artifactId>                                                                                                                     
    </dependency>                                                                                                                                                            
    <dependency>                                                                                                                                                             
      <groupId>io.quarkus</groupId>                                                                                                                                          
      <artifactId>quarkus-reactive-mysql-client</artifactId>                                                                                                                 
    </dependency>                                                                                                                                                            
    <dependency>                                                                                                                                                             
      <groupId>io.quarkus</groupId>                                                                                                                                          
      <artifactId>quarkus-resteasy-reactive-jackson</artifactId>                                                                                                             
    </dependency>                                                                                                                                                            
    <dependency>                                                                                                                                                             
      <groupId>io.quarkus</groupId>                                                                                                                                          
      <artifactId>quarkus-arc</artifactId>                                                                                                                                   
    </dependency>                                                                                                                                                            
    <dependency>                                                                                                                                                             
      <groupId>io.quarkus</groupId>                                                                                                                                          
      <artifactId>quarkus-junit5</artifactId>                                                                                                                                
      <scope>test</scope>                                                                                                                                                    
    </dependency>                                                                                                                                                            
    <dependency>                                                                                                                                                             
      <groupId>io.rest-assured</groupId>                                                                                                                                     
      <artifactId>rest-assured</artifactId>                                                                                                                                  
      <scope>test</scope>                                                                                                                                                    
    </dependency>                                                                                                                                                            
  </dependencies>

生成されたソースコードは、削除しておきます。

$ rm src/main/java/org/littlewings/* src/test/java/org/littlewings/*

あとはプログラムとマイグレーションを作成していきます。

マイグレーションとプログラムを書く

お題はFlywayなので、まずはマイグレーションを作りましょう。

src/main/resources/db/migration/V1__create_book_table.sql

create table book (
  isbn varchar(14),
  title varchar(255),
  price int,
  primary key(isbn)
);

書籍をテーマにしましょう。

こちらのテーブルに対して、Entity的なクラスと

src/main/java/org/littlewings/quarkus/flyway/Book.java

package org.littlewings.quarkus.flyway;

public class Book {
    String isbn;
    String title;
    int price;

    public static Book create(String isbn, String title, int price) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    // getter/setterは省略
}

RESTEasy Reactiveを使ったリソースクラスを作成。

src/main/java/org/littlewings/quarkus/flyway/BookResource.java

package org.littlewings.quarkus.flyway;

import java.net.URI;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;

import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import io.vertx.mutiny.mysqlclient.MySQLPool;
import io.vertx.mutiny.sqlclient.Row;
import io.vertx.mutiny.sqlclient.RowSet;
import io.vertx.mutiny.sqlclient.Tuple;
import org.jboss.resteasy.reactive.RestPath;
import org.jboss.resteasy.reactive.RestResponse;

@Path("book")
public class BookResource {
    @Inject
    MySQLPool mysqlClient;

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> findAll() {
        return mysqlClient
                .preparedQuery("select isbn, title, price from book order by price desc")
                .execute()
                .onItem()
                .transformToMulti(Multi.createFrom()::iterable)
                .onItem()
                .transform(row -> Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price")));
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> findOne(@RestPath String isbn) {
        return mysqlClient
                .preparedQuery("select isbn, title, price from book where isbn = ?")
                .execute(Tuple.of(isbn))
                .onItem()
                .transform(RowSet::iterator)
                .onItem()
                .transform(iterator -> {
                    if (iterator.hasNext()) {
                        Row row = iterator.next();
                        return Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price"));
                    } else {
                        return null;
                    }
                });
    }

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<RestResponse<?>> put(Book book) {
        return mysqlClient
                .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)")
                .execute(Tuple.of(book.getIsbn(), book.getTitle(), book.getPrice()))
                .onItem()
                .transform(rows -> RestResponse.created(URI.create("/book/" + book.isbn)));
    }

    @DELETE
    @Path("{isbn}")
    public Uni<Boolean> delete(@RestPath String isbn) {
        return mysqlClient
                .preparedQuery("delete from book where isbn = ?")
                .execute(Tuple.of(isbn))
                .onItem()
                .transform(rows -> rows.rowCount() == 1);
    }
}

アプリケーションの設定は、このようにしました。

src/main/resources/application.properties

# JDBC, Reactive SQL Client Common
quarkus.datasource.db-kind=mysql
quarkus.datasource.username=kazuhira
quarkus.datasource.password=password

# JDBC
quarkus.datasource.jdbc.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_bin

# Flyway
quarkus.flyway.migrate-at-start=true

# Reactive SQL Client
quarkus.datasource.reactive.url=mysql://172.17.0.2:3306/practice?characterEncoding=utf8mb4&charset=utf8mb4&collation=utf8mb4_bin

データベースの種類やユーザー名、パスワードはJDBC、Reactive SQL Clientを問わず同じ項目になります。

# JDBC, Reactive SQL Client Common
quarkus.datasource.db-kind=mysql
quarkus.datasource.username=kazuhira
quarkus.datasource.password=password

JDBC接続URL。こちらは、今回はFlywayが使います。

# JDBC
quarkus.datasource.jdbc.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_bin

Reactive SQL Clientの場合はこちら。

# Reactive SQL Client
quarkus.datasource.reactive.url=mysql://172.17.0.2:3306/practice?characterEncoding=utf8mb4&charset=utf8mb4&collation=utf8mb4_bin

Vert.x Reactive MySQL Clientの方が、より厳密にMySQLのCharsetとCollationを扱うようなのですが。

Reactive MySQL Client / Configuration

残念ながら、今回使おうと思ったCollation(utf8mb4_0900_bin)はサポートしていませんでした…。

https://github.com/eclipse-vertx/vertx-sql-client/blob/4.2.2/vertx-mysql-client/src/main/java/io/vertx/mysqlclient/impl/MySQLCollation.java

なので、utf8mb4_binを指定しています。

指定しないと、Vert.x Reactive MySQL ClientのデフォルトのCharsetとCollationになるみたいなんですよね。

// set connection collation to utf8_general_ci instead of the default collation utf8mb4_general_ci

ちなみに、ドキュメントを見るとURLで指定できる項目はやや少なめに見えるのですが

Currently, the client supports the following parameter keywords in connection uri (keys are case-insensitive):

実際はもっと指定できそうです。

https://github.com/eclipse-vertx/vertx-sql-client/blob/4.2.2/vertx-mysql-client/src/main/generated/io/vertx/mysqlclient/MySQLConnectOptionsConverter.java

ムリにcollation=utf8mb4_0900_binと指定すると、例外がスローされます。

Caused by: java.lang.IllegalArgumentException: Unsupported collation: utf8mb4_0900_bin
    at io.vertx.mysqlclient.MySQLConnectOptions.setCollation(MySQLConnectOptions.java:137)
    at io.vertx.mysqlclient.MySQLConnectOptionsConverter.fromJson(MySQLConnectOptionsConverter.java:40)
    at io.vertx.mysqlclient.MySQLConnectOptions.<init>(MySQLConnectOptions.java:90)
    at io.vertx.mysqlclient.MySQLConnectOptions.fromUri(MySQLConnectOptions.java:55)
    at io.quarkus.reactive.mysql.client.runtime.MySQLPoolRecorder.toMySQLConnectOptions(MySQLPoolRecorder.java:100)
    at io.quarkus.reactive.mysql.client.runtime.MySQLPoolRecorder.initialize(MySQLPoolRecorder.java:63)
    at io.quarkus.reactive.mysql.client.runtime.MySQLPoolRecorder.configureMySQLPool(MySQLPoolRecorder.java:45)
    at io.quarkus.deployment.steps.ReactiveMySQLClientProcessor$build360423335.deploy_0(Unknown Source)
    at io.quarkus.deployment.steps.ReactiveMySQLClientProcessor$build360423335.deploy(Unknown Source)
    ... 51 more

ちょっと余談でした。

Flywayの設定は、今回はこれだけです。

# Flyway
quarkus.flyway.migrate-at-start=true

これで、アプリケーションの起動時にFlywayが実行されます。

動作確認してみる

それでは、動作確認してみましょう。

まだこの時点では、対象のデータベースにはなにもありません。

mysql> use practice;
Database changed
mysql> show tables;
Empty set (0.01 sec)

パッケージングして

$ mvn package

起動。

$ java -jar target/quarkus-app/quarkus-run.jar

自動するとすぐにマイグレーションが適用されます。

2022-01-21 01:33:24,502 INFO  [org.fly.cor.int.lic.VersionPrinter] (main) Flyway Community Edition 8.1.0 by Redgate
2022-01-21 01:33:24,520 INFO  [org.fly.cor.int.dat.bas.BaseDatabaseType] (main) Database: jdbc:mysql://172.17.0.2:3306/practice (MySQL 8.0)
2022-01-21 01:33:24,624 INFO  [org.fly.cor.int.sch.JdbcTableSchemaHistory] (main) Creating Schema History table `practice`.`flyway_schema_history` ...
2022-01-21 01:33:25,120 INFO  [org.fly.cor.int.com.DbMigrate] (main) Current version of schema `practice`: << Empty Schema >>
2022-01-21 01:33:25,140 INFO  [org.fly.cor.int.com.DbMigrate] (main) Migrating schema `practice` to version "1 - create book table"
2022-01-21 01:33:25,420 INFO  [org.fly.cor.int.com.DbMigrate] (main) Successfully applied 1 migration to schema `practice`, now at version v1 (execution time 00:00.322s)

適用されたExtension。

2022-01-21 01:33:25,719 INFO  [io.quarkus] (main) Installed features: [agroal, cdi, flyway, jdbc-mysql, narayana-jta, reactive-mysql-client, resteasy-reactive, resteasy-reactive-jackson, smallrye-context-propagation, vertx]

データベースを確認すると、テーブルが追加され、マイグレーションの履歴も登録されています。

mysql> show tables;
+-----------------------+
| Tables_in_practice    |
+-----------------------+
| book                  |
| flyway_schema_history |
+-----------------------+
2 rows in set (0.00 sec)

mysql> select * from flyway_schema_history;
+----------------+---------+-------------------+------+----------------------------------------+-----------+--------------+---------------------+----------------+---------+
| installed_rank | version | description       | type | script                                 | checksum  | installed_by | installed_on        | execution_time | success |
+----------------+---------+-------------------+------+----------------------------------------+-----------+--------------+---------------------+----------------+---------+
|              1 | 1       | create book table | SQL  | db/migration/V1__create_book_table.sql | 657681960 | kazuhira     | 2022-01-20 16:33:25 |            208 |       1 |
+----------------+---------+-------------------+------+----------------------------------------+-----------+--------------+---------------------+----------------+---------+
1 row in set (0.00 sec)

データを登録して動作確認。

$ curl -i -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4798161488 -d '{
  "isbn": "978-4798161488",
  "title": "MySQL徹底入門 第4版 MySQL 8.0対応",
  "price": 4180
}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /book/978-4798161488
content-length: 0


$ curl -i -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4798147406 -d '{
  "isbn": "978-4798147406",
  "title": "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド",
  "price": 3960
}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /book/978-4798147406
content-length: 0


$ curl -i -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4295000297 -d '{
  "isbn": "978-4295000297",
  "title": "MySQL 即効クエリチューニング",
  "price": 1980
}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /book/978-4295000297
content-length: 0

データを見てみます。

$ curl -s localhost:8080/book | jq
[
  {
    "isbn": "978-4798161488",
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応",
    "price": 4180
  },
  {
    "isbn": "978-4798147406",
    "title": "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド",
    "price": 3960
  },
  {
    "isbn": "978-4295000297",
    "title": "MySQL 即効クエリチューニング",
    "price": 1980
  }
]


$ curl -s localhost:8080/book/978-4798147406 | jq
{
  "isbn": "978-4798147406",
  "title": "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド",
  "price": 3960
}

削除。

$ curl -i -XDELETE localhost:8080/book/978-4295000297
HTTP/1.1 200 OK
content-length: 4
Content-Type: text/plain;charset=UTF-8

確認。

$ curl -s localhost:8080/book | jq
[
  {
    "isbn": "978-4798161488",
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応",
    "price": 4180
  },
  {
    "isbn": "978-4798147406",
    "title": "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド",
    "price": 3960
  }
]

OKですね。

1度ここでアプリケーションを終了させます。

マイグレーションを追加してみる

もうひとつ、マイグレーションを追加してみましょう。

src/main/resources/db/migration/V2__create_account_table.sql

create table account (
  id int,
  name varchar(50),
  registered datetime,
  about varchar(255),
  primary key(id)
);

パッケージングして

$ mvn package

実行。

$ java -jar target/quarkus-app/quarkus-run.jar

起動時に、新しいマイグレーションが適用されました。

2022-01-21 01:41:07,579 INFO  [org.fly.cor.int.lic.VersionPrinter] (main) Flyway Community Edition 8.1.0 by Redgate
2022-01-21 01:41:07,595 INFO  [org.fly.cor.int.dat.bas.BaseDatabaseType] (main) Database: jdbc:mysql://172.17.0.2:3306/practice (MySQL 8.0)
2022-01-21 01:41:07,707 INFO  [org.fly.cor.int.com.DbMigrate] (main) Current version of schema `practice`: 1
2022-01-21 01:41:07,732 INFO  [org.fly.cor.int.com.DbMigrate] (main) Migrating schema `practice` to version "2 - create account table"
2022-01-21 01:41:07,949 INFO  [org.fly.cor.int.com.DbMigrate] (main) Successfully applied 1 migration to schema `practice`, now at version v2 (execution time 00:00.277s)

テーブルと履歴の確認。

mysql> show tables;
+-----------------------+
| Tables_in_practice    |
+-----------------------+
| account               |
| book                  |
| flyway_schema_history |
+-----------------------+
3 rows in set (0.00 sec)

mysql> select * from flyway_schema_history;
+----------------+---------+----------------------+------+-------------------------------------------+------------+--------------+---------------------+----------------+---------+
| installed_rank | version | description          | type | script                                    | checksum   | installed_by | installed_on        | execution_time | success |
+----------------+---------+----------------------+------+-------------------------------------------+------------+--------------+---------------------+----------------+---------+
|              1 | 1       | create book table    | SQL  | db/migration/V1__create_book_table.sql    |  657681960 | kazuhira     | 2022-01-20 16:33:25 |            208 |       1 |
|              2 | 2       | create account table | SQL  | db/migration/V2__create_account_table.sql | 1435889679 | kazuhira     | 2022-01-20 16:41:07 |            130 |       1 |
+----------------+---------+----------------------+------+-------------------------------------------+------------+--------------+---------------------+----------------+---------+
2 rows in set (0.00 sec)

OKですね。

この確認は、ここまでにしておきます。

テストで使ってみる

最後に、テストでも使ってみたいと思います。

まず、テスト時と通常のアプリケーションの実行時で、設定を分けてみます。

src/main/resources/application.properties

# JDBC, Reactive SQL Client Common
quarkus.datasource.db-kind=mysql
quarkus.datasource.username=kazuhira
quarkus.datasource.password=password

# JDBC
quarkus.datasource.jdbc.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_bin

# Flyway
quarkus.flyway.migrate-at-start=true

# Reactive SQL Client
quarkus.datasource.reactive.url=mysql://172.17.0.2:3306/practice?characterEncoding=utf8mb4&charset=utf8mb4&collation=utf8mb4_bin


## test
%test.quarkus.datasource.username=testuser
%test.quarkus.datasource.password=password
%test.quarkus.datasource.jdbc.url=jdbc:mysql://172.17.0.2:3306/test?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_bin
%test.quarkus.datasource.reactive.url=mysql://172.17.0.2:3306/test?characterEncoding=utf8mb4&charset=utf8mb4&collation=utf8mb4_bin

%test.quarkus.flyway.locations=db/migration,test/db/migration

%testというのは、test Profile時に有効になる設定ですね。

Configuration Reference / Profiles

接続先は、テスト用にここまでに出てきた項目を定義しているのですが

%test.quarkus.datasource.username=testuser
%test.quarkus.datasource.password=password
%test.quarkus.datasource.jdbc.url=jdbc:mysql://172.17.0.2:3306/test?characterEncoding=utf-8&characterSetResults=utf-8&connectionCollation=utf8mb4_bin
%test.quarkus.datasource.reactive.url=mysql://172.17.0.2:3306/test?characterEncoding=utf8mb4&charset=utf8mb4&collation=utf8mb4_bin

今回は、さらにFlyway用の設定を追加しました。

%test.quarkus.flyway.locations=db/migration,test/db/migration

この設定で、db/migrationとtest/db/migrationの2つのクラスパス上のマイグレーションが対象になります。

テスト用のマイグレーションとして、初期データ登録を入れてみました。

src/test/resources/test/db/migration/V99__insert_test_data.sql

insert into book(isbn, title, price) values('978-4798161488', 'MySQL徹底入門 第4版 MySQL 8.0対応', 4180);

また、以下の設定はProfile共通で有効になるので、テスト時にもFlywayは自動実行されます。

# Flyway
quarkus.flyway.migrate-at-start=true

作成したテストコードは、こちら。

src/test/java/org/littlewings/quarkus/flyway/BookResourceTest.java

package org.littlewings.quarkus.flyway;

import java.util.List;

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;

@QuarkusTest
@TestHTTPEndpoint(BookResource.class)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class BookResourceTest {
    @Test
    @Order(1)
    public void initialFindAll() {
        given()
                .when()
                .get()
                .then()
                .statusCode(200)
                .body("$", hasSize(1))
                .body("isbn", hasItem("978-4798161488"))
                .body("title", hasItem("MySQL徹底入門 第4版 MySQL 8.0対応"))
                .body("price", hasItem(4180));
    }

    @Test
    @Order(2)
    public void putBooks() {
        List<Book> books = List.of(
                Book.create("978-4621303252", "Effective Java 第3版", 4400),
                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278),
                Book.create("978-4295010333", "いちばんやさしいJavaの教本 人気講師が教えるプログラミングの基礎", 2640)
        );

        books.forEach(book -> {
            given()
                    .contentType(ContentType.JSON)
                    .body(book)
                    .when()
                    .put("/" + book.getIsbn())
                    .then()
                    .statusCode(201)
                    .header("Location", "/book/" + book.getIsbn());
        });

        given()
                .when()
                .get()
                .then()
                .statusCode(200)
                .body("$", hasSize(4));
    }

    @Test
    @Order(3)
    public void findOne() {
        given()
                .when()
                .get("/978-4774189093")
                .then()
                .statusCode(200)
                .body("isbn", is("978-4774189093"))
                .body("title", is("Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで"))
                .body("price", is(3278));
    }

    @Test
    @Order(4)
    public void deleteBooks() {
        List<Book> books = List.of(
                Book.create("978-4621303252", "Effective Java 第3版", 4400),
                Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278),
                Book.create("978-4295010333", "いちばんやさしいJavaの教本 人気講師が教えるプログラミングの基礎", 2640)
        );

        books.forEach(book -> {
            when()
                    .delete("/" + book.getIsbn())
                    .then()
                    .statusCode(200);
        });

        given()
                .when()
                .get()
                .then()
                .statusCode(200)
                .body("$", hasSize(1));
    }
}

テスト前のデータベースの状態を確認。

mysql> use test;
Database changed
mysql> show tables;
Empty set (0.01 sec)

では、テストを実行します。

$ mvn test

テスト実行時にマイグレーションが適用されていることが確認できます。

[INFO] Running org.littlewings.quarkus.flyway.BookResourceTest
2022-01-21 01:53:20,698 INFO  [org.fly.cor.int.lic.VersionPrinter] (main) Flyway Community Edition 8.1.0 by Redgate
2022-01-21 01:53:20,706 INFO  [org.fly.cor.int.dat.bas.BaseDatabaseType] (main) Database: jdbc:mysql://172.17.0.2:3306/test (MySQL 8.0)
2022-01-21 01:53:20,758 INFO  [org.fly.cor.int.sch.JdbcTableSchemaHistory] (main) Creating Schema History table `test`.`flyway_schema_history` ...
2022-01-21 01:53:20,995 INFO  [org.fly.cor.int.com.DbMigrate] (main) Current version of schema `test`: << Empty Schema >>
2022-01-21 01:53:21,040 INFO  [org.fly.cor.int.com.DbMigrate] (main) Migrating schema `test` to version "1 - create book table"
2022-01-21 01:53:21,411 INFO  [org.fly.cor.int.com.DbMigrate] (main) Migrating schema `test` to version "2 - create account table"
2022-01-21 01:53:21,539 INFO  [org.fly.cor.int.com.DbMigrate] (main) Migrating schema `test` to version "99 - insert test data"
2022-01-21 01:53:21,573 INFO  [org.fly.cor.int.com.DbMigrate] (main) Successfully applied 3 migrations to schema `test`, now at version v99 (execution time 00:00.594s)

テスト終了後、データベース側を確認。

mysql> show tables;
+-----------------------+
| Tables_in_test        |
+-----------------------+
| account               |
| book                  |
| flyway_schema_history |
+-----------------------+
3 rows in set (0.01 sec)

mysql> select * from flyway_schema_history;
+----------------+---------+----------------------+------+---------------------------------------------+------------+--------------+---------------------+----------------+---------+
| installed_rank | version | description          | type | script                                      | checksum   | installed_by | installed_on        | execution_time | success |
+----------------+---------+----------------------+------+---------------------------------------------+------------+--------------+---------------------+----------------+---------+
|              1 | 1       | create book table    | SQL  | db/migration/V1__create_book_table.sql      |  657681960 | testuser     | 2022-01-20 16:53:21 |            332 |       1 |
|              2 | 2       | create account table | SQL  | db/migration/V2__create_account_table.sql   | 1435889679 | testuser     | 2022-01-20 16:53:21 |             85 |       1 |
|              3 | 99      | insert test data     | SQL  | test/db/migration/V99__insert_test_data.sql |  905141387 | testuser     | 2022-01-20 16:53:21 |              4 |       1 |
+----------------+---------+----------------------+------+---------------------------------------------+------------+--------------+---------------------+----------------+---------+
3 rows in set (0.00 sec)

2つのディレクトリのマイグレーションが適用されていることが確認できました。

まとめ

今回はFlywayをQuarkusに組み込んで(Flyway Extension)を使って試してみました。

簡単に使えて良いですね。Flyway自体は前に試していたので、そんなに困りませんでした。

Node.js × TypeScriptのORM、PrismaをMySQLで試す

これは、なにをしたくて書いたもの?

Node.js+TypeScript環境でのORMはどれを使ったらいいのかな?ということで。

このあたりみたいです。

情報を見ていると、Node.js環境ではSequelizeが有名だと思いますが、TypeScriptで使う場合はTypeORMかPrismaを選ぶようです。

今回は、Prismaを使ってみたいと思います。

Prisma

Prismaは、次世代のNode.jsとTypeScriptのORMと謳っています。

Prisma - Next-generation Node.js and TypeScript ORM for Databases

サポートしているデータベースは、以下です。

Prismaとはなにか?というのは、こちらに書かれています。

What is Prisma? (Overview) | Prisma Docs

主に以下の3つで構成されているみたいです。

また、プレビューですがPrisma Data Platformというものもあり、こちらは文字通りプラットフォームサービスの
ようで、上記とはまた性格が異なりそうです。

Prismaのいずれのツールも、Prisma schemaというものが起点になるようです。

Prisma schema (Reference) | Prisma Docs

Prisma schemaには、以下が定義されます。

このPrisma schemaの存在が、他のORMと大きく異なるものみたいです。たとえば、TypeORMではデータモデルの定義にTypeScriptの
デコレーターを使用します。

Prisma自身のドキュメントからですが、なぜPrismaが良いのか?というのはこちらを見るとよいみたいです。

Why Prisma? Comparison with SQL query builders & ORMs | Prisma Docs

Should you use Prisma as a Node.js/TypeScript ORM? | Prisma Docs

Prisma自身による、他のORMとの比較はこちらに記載されています。

Comparing Prisma to other ORMs and ODMs. | Prisma Docs

Best 11 ORMs for Node.js, Query Builders & Database Libraries in 2021

今回は、まずはPrisma Clientを中心に見ていこうかなと思います。

Prisma Client

Prisma Clientは、自動生成された、型安全なクエリービルダーです。

Prisma Client - Auto-generated query builder for your data

Go向けのものもありますが、もうメンテナンスされないようですね。

Prisma Client Go will not be Officially Maintained any more · Issue #707 · prisma/prisma-client-go · GitHub

機能は?というと、ドキュメントの項目を見た方が早そうですが。

f:id:Kazuhira:20220117221744p:plain

Prisma Client (Reference) | Prisma Docs

まずは、こちらのスクラッチで始めるGetting Startedで試していきたいと思います。

Start from scratch with relational databases (15 min) | Prisma Docs

なおQuick Startは、GitHubリポジトリをベースに進めていくようです。

Quickstart: Getting started with TypeScript & SQLite | Prisma Docs

GitHub - prisma/quickstart: 🏁 Starter templates for the 5min Quickstart in the Prisma docs.

環境

今回の環境は、こちらです。

$ node --version
v16.13.2


$ npm --version
8.1.2

データベースはMySQLを使用します。

バージョンは8.0.27で、172.17.0.2で動作しているものとします。

ちなみに、依存関係については最終的にはこうなりました。

  "devDependencies": {
    "@types/jest": "^27.4.0",
    "@types/node": "^16.11.20",
    "esbuild": "^0.14.11",
    "esbuild-jest": "^0.5.0",
    "jest": "^27.4.7",
    "prettier": "2.5.1",
    "prisma": "^3.8.1",
    "ts-node": "^10.4.0",
    "typescript": "^4.5.4"
  },
  "dependencies": {
    "@prisma/client": "^3.8.1"
  }

セットアップ

まずは、こちらの手順に従ってプロジェクトをセットアップしていきましょう。

Start from scratch with relational databases (15 min) | Prisma Docs

$ npm init -y
$ npm i -D prisma typescript ts-node @types/node@v16

ここでインストールしたprismaというパッケージは、Prisma CLIですね。

Prisma CLI | Prisma Docs

$ npx prisma --version
prisma                  : 3.8.1
@prisma/client          : Not found
Current platform        : debian-openssl-1.1.x
Query Engine (Node-API) : libquery-engine 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f (at node_modules/@prisma/engines/libquery_engine-debian-openssl-1.1.x.so.node)
Migration Engine        : migration-engine-cli 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f (at node_modules/@prisma/engines/migration-engine-debian-openssl-1.1.x)
Introspection Engine    : introspection-core 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f (at node_modules/@prisma/engines/introspection-engine-debian-openssl-1.1.x)
Format Binary           : prisma-fmt 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f (at node_modules/@prisma/engines/prisma-fmt-debian-openssl-1.1.x)
Default Engines Hash    : 34df67547cf5598f5a6cd3eb45f14ee70c3fb86f
Studio                  : 0.452.0

tsconfig.jsonは、このように設定。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

prisma initを実行してみます。

$ npx prisma init

prisma/schema.prismaというファイルができたようです。

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver or mongodb (Preview).
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

このようなファイルが生成されます。

prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

PostgreSQL向けの設定が出力されています。

schema.prismaを修正する

ドキュメントに沿って、生成されたschema.prismaを修正していきます。

Connect your database | Prisma Docs

まずはproviderを修正。

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

envというのは環境変数を参照する仕組みのようです。

Environment variables | Prisma Docs

OS側の環境変数を参照しますが、.envというファイルからもルックアップできます。このファイルは、実はprisma initの時点でプロジェクトの
ルートディレクトリに生成されています。

.env

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server and MongoDB (Preview).
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

.envファイルは、いくつか探索するルールがあります。

Environment variables / Using .env files

ただ、.envファイルはバージョン管理システムにコミットしてはならないとされていて、以下の内容の.gitignoreファイルも同時に生成
されています。

.gitignore

node_modules
# Keep environment variables out of version control
.env

では、用意したMySQLに接続できるようにDATABASE_URLを修正します。

.env

# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server and MongoDB (Preview).
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="mysql://appuser:password@172.17.0.2:3306/example"

Connection URLの記述については、こちらを参照。

Connection URLs (Reference) | Prisma Docs

次に、こちらを参照しながらデータモデルを書いていきます。

Prisma schema (Reference) | Prisma Docs

Data model (Reference) | Prisma Docs

Relations (Reference) | Prisma Docs

Names in the underlying database | Prisma Docs

インデックスの定義については、プレビュー機能のようです。

Indexes | Prisma Docs

最終的にできあがったファイルは、こちら。

prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Post {
  id Int @id @default(autoincrement())
  title String @db.VarChar(255)
  url String @unique
  user User @relation(fields: [userId], references: [id])
  userId Int @map("user_id")

  @@map("post")
}
model User {
  id Int @id @default(autoincrement())
  name String  @db.VarChar(30)
  age Int
  posts Post[]

  @@map("user")
}

@の部分は属性で、だいたい意味は予想がつく気はしますが、詳しくはリファレンスに書かれています。

Prisma schema API (Reference) | Prisma Docs

これでPrisma Migrateを使ってマイグレーションします。

$ npx prisma migrate dev --name init

Prisma Migrateのドキュメントは、こちら。

Prisma Migrate | Database, Schema, SQL Migration Tool | Prisma Docs

devというのは開発コマンドのことで、本番環境での利用は想定していません。--nameの後には任意の名前を指定し、マイグレーションの名前に
反映されるようです。--nameの値はユニークである必要はなさそうです。 ※本番環境向けにはdeployというコマンドを使うようです

ちなみに、prisma migrate devの時点でデータベースに接続するようなのですが、いわゆる管理者権限のないユーザーだとエラーに
なりました。グローバルにデータベースを操作できる必要がありそうです。

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "example" at "172.17.0.2:3306"

Error: P3014

Prisma Migrate could not create the shadow database. Please make sure the database user has permission to create databases. Read more about the shadow database (and workarounds) at https://pris.ly/d/migrate-shadow

Original error: Error code: P1010

User `appuser` was denied access on the database `example`

権限設定をしなおして、もう1度実行。

$ npx prisma migrate dev --name init
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": MySQL database "example" at "172.17.0.2:3306"

MySQL database example created at 172.17.0.2:3306

Applying migration `20220118133834_init`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20220118133834_init/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client (3.8.1 | library) to ./node_modules/@prisma/client in 615ms

対象のデータベースが存在しない場合は、作成するようです。

今度はうまくいき、以下のようなファイルが生成されました。

$ tree prisma
prisma
├── migrations
│   ├── 20220118133834_init
│   │   └── migration.sql
│   └── migration_lock.toml
└── schema.prisma

2 directories, 3 files

生成されたSQLファイル。

prisma/migrations/20220118133834_init/migration.sql

-- CreateTable
CREATE TABLE `post` (
    `id` INTEGER NOT NULL AUTO_INCREMENT,
    `title` VARCHAR(255) NOT NULL,
    `url` VARCHAR(191) NOT NULL,
    `user_id` INTEGER NOT NULL,

    UNIQUE INDEX `post_url_key`(`url`),
    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- CreateTable
CREATE TABLE `user` (
    `id` INTEGER NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(30) NOT NULL,
    `age` INTEGER NOT NULL,

    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- AddForeignKey
ALTER TABLE `post` ADD CONSTRAINT `post_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

Collationがutf8mb4_unicode_ci固定になっているのが気になるのですが(使っているサーバーの設定は別のCollation)、これはPrismaでは
変更できなさそうです。

Is there a way to specify table collation in Prisma schema? · Discussion #4743 · prisma/prisma · GitHub

内部で使っているMariaDBのドライバーのデフォルトが、utf8mb4_unicode_ciであることに起因してそうですね。

GitHub - mariadb-corporation/mariadb-connector-nodejs: MariaDB Connector/Node.js is used to connect applications developed on Node.js to MariaDB and MySQL databases. MariaDB Connector/Node.js is LGPL licensed.

もうひとつのファイルには、Providerの情報が書かれていました。

prisma/migrations/migration_lock.toml

# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

また、node_modules/.prismaディレクトリには、型宣言などが生成され、こちらをプログラムの作成に利用できます。

$ tree node_modules/.prisma
node_modules/.prisma
└── client
    ├── index-browser.js
    ├── index.d.ts
    ├── index.js
    ├── libquery_engine-debian-openssl-1.1.x.so.node
    ├── package.json
    └── schema.prisma

1 directory, 6 files

そして、テーブルもこの時点で作成されています。

mysql> use example;
Database changed
mysql> show tables;
+--------------------+
| Tables_in_example  |
+--------------------+
| _prisma_migrations |
| post               |
| user               |
+--------------------+
3 rows in set (0.00 sec)

_prisma_migrationsというテーブルは、マイグレーションの履歴のようです。

mysql> select * from _prisma_migrations;
+--------------------------------------+------------------------------------------------------------------+-------------------------+---------------------+------+----------------+-------------------------+---------------------+
| id                                   | checksum                                                         | finished_at             | migration_name      | logs | rolled_back_at | started_at              | applied_steps_count |
+--------------------------------------+------------------------------------------------------------------+-------------------------+---------------------+------+----------------+-------------------------+---------------------+
| f9c4ddb8-cc8e-4353-a550-3684530697b0 | 11b11399bfa8bbd1c555a1548b87ea473d8eac9aa23aaee3c12cb6e14fcb3e83 | 2022-01-18 13:38:35.440 | 20220118133834_init | NULL | NULL           | 2022-01-18 13:38:34.679 |                   1 |
+--------------------------------------+------------------------------------------------------------------+-------------------------+---------------------+------+----------------+-------------------------+---------------------+
1 row in set (0.00 sec)

こう書くとPrismaを使うと、新規にデータベースを作る場合でないと使えないのかな?とも思うのですが、既存のプロジェクトに対しても
適用できるようです。そのパターンは、こちらを参照。

Add Prisma to an existing project that uses a relational database (15 min) | Prisma Docs

Prisma Clientを使う

では、生成された型宣言を使ってプログラムを書いていきましょう。ドキュメントは、こちらを足がかりに。

Install Prisma Client | Prisma Docs

Prisma Clientをインストールします。

$ npm i @prisma/client

確認は、テストコードで行うことにします。Jestをインストール。あと、Prettierも。

$ npm i -D jest @types/jest esbuild-jest esbuild
$ npm i -D -E prettier

設定はこんな感じです。

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

.prettierrc.json

{
  "singleQuote": true
}

あとは、テスト用のディレクトリにテストを作成していきます。

$ mkdir test

まずは、宣言部分だけ。

test/prisma.test.ts

import { Prisma, PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// ここに、テストを書く!

あとはGetting Startedと

Querying the database | Prisma Docs

Prisma Clientのドキュメントを見ながらテストコードを書いていきます。

Prisma Client (Reference) | Prisma Docs

データの登録。

CRUD (Reference) | Prisma Docs

test('insert data', async () => {
  const katsuo = await prisma.user.create({
    data: {
      name: '磯野 カツオ',
      age: 11,
      posts: {
        create: [
          {
            title:
              'はじめてのTypeScript(+TypeScript ESLint、Prettier、Emacs lsp-mode)',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/30/222106',
          },
          {
            title: 'JestでTypeScriptのテストを書く',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/10/31/173852',
          },
          {
            title: 'TypeScriptでExpress',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
          },
        ],
      },
    },
  });

  expect(katsuo.name).toBe('磯野 カツオ');
  expect(katsuo.age).toBe(11);

  const wakame = await prisma.user.create({
    data: {
      name: '磯野 ワカメ',
      age: 9,
    },
  });

  expect(wakame.name).toBe('磯野 ワカメ');
  expect(wakame.age).toBe(9);

  const createdPosts = await prisma.post.createMany({
    data: [
      {
        title: 'Node.jsの管理ツール、nvmをインストールする',
        url: 'https://kazuhira-r.hatenablog.com/entry/2021/03/22/223042',
        userId: wakame.id,
      },
      {
        title: 'Node.jsアプリケーションのログ出力に、winstonを使ってみる',
        url: 'https://kazuhira-r.hatenablog.com/entry/2019/05/21/235843',
        userId: wakame.id,
      },
    ],
  });

  expect(createdPosts.count).toBe(2);
});

test('search single data', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
  });
  expect(katsuo?.age).toBe(11);

  const katsuo2 = await prisma.user.findUnique({ where: { id: katsuo?.id } });
  expect(katsuo2).not.toBeNull();
});

使うとちょっと驚くのですが、findUniqueは補完候補にユニークなもの(主キーなど)しか現れないようになっています。
すごいですね。

リレーションのあるレコードを後から追加するパターンも行っていますが、新規データを登録する際にconnectすれば関連付けもできるようです。
※今回は意図的にpostを先に作ってしまったのでconnectは行っていませんが、userから作成すればOKです

Relation queries (Concepts) | Prisma Docs

1件検索。

test('search single data', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
  });
  expect(katsuo?.age).toBe(11);

  const katsuo2 = await prisma.user.findUnique({ where: { id: katsuo?.id } });
  expect(katsuo2).not.toBeNull();
});

リレーションのある検索。ソートも入れています。

Relation queries (Concepts) | Prisma Docs

Filtering and sorting (Concepts) | Prisma Docs

test('search related data', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
    include: { posts: true },
  });
  expect(katsuo?.age).toBe(11);

  const katsuoIncludePosts = await prisma.user.findUnique({
    where: { id: katsuo?.id },
    include: { posts: { orderBy: { url: 'desc' } } },
  });
  expect(katsuoIncludePosts?.posts).toHaveLength(3);
  expect(katsuoIncludePosts?.posts[0].title).toBe('TypeScriptでExpress');
  expect(katsuoIncludePosts?.posts[0].url).toBe(
    'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345'
  );
});

トランザクションは、xxxManyがバッチ更新的な扱いになるようですが、$transactionを使うことでPromiseをトランザクションとして
まとめることもできるようです。

Transactions and batch queries (Reference) | Prisma Docs

Transactions | Prisma Docs

$transactionを使う場合は、失敗したPromiseがあるとロールバックするようです。

ロールバックするパターン。片方のPromiseは、ユニークキーを重複させるレコードを登録して失敗させます。

test('transaction rollback', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
  });

  if (katsuo) {
    const currentCount = await prisma.post.count();
    expect(currentCount).toBe(5);

    try {
      const [post1, post2] = await prisma.$transaction([
        prisma.post.create({
          data: {
            title: 'TypeScript+Node.jsで、Echo Server/Clientを書いてみる',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/222927',
            userId: katsuo.id,
          },
        }),
        prisma.post.create({
          data: {
            title: 'TypeScriptでExpress',
            url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/184345',
            userId: katsuo.id,
          },
        }),
      ]);

      const rollbackedCount = await prisma.post.count();
      expect(rollbackedCount).toBe(5);
    } catch (e) {
      expect((e as Error).message).toContain(
        'Unique constraint failed on the constraint: `post_url_key`'
      );
    }
  } else {
    throw new Error('katsuo is null');
  }
});

コミットするパターン。

test('transaction commit', async () => {
  const katsuo = await prisma.user.findFirst({
    where: { name: '磯野 カツオ' },
  });

  if (katsuo) {
    const currentCount = await prisma.post.count();
    expect(currentCount).toBe(5);

    const [post1, post2] = await prisma.$transaction([
      prisma.post.create({
        data: {
          title: 'TypeScript+Node.jsで、Echo Server/Clientを書いてみる',
          url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/20/222927',
          userId: katsuo.id,
        },
      }),
      prisma.post.create({
        data: {
          title:
            'TypeScript+Node.jsプロジェクトを自動ビルドする(--watch、nodemon+ts-node)',
          url: 'https://kazuhira-r.hatenablog.com/entry/2021/11/28/221448',
          userId: katsuo.id,
        },
      }),
    ]);

    const committedCount = await prisma.post.count();
    expect(committedCount).toBe(7);
  } else {
    throw new Error('katsuo is null');
  }
});

最後に、データを削除。

test('delete data', async () => {
  const deletedPosts = await prisma.post.deleteMany();
  const deletedUsers = await prisma.user.deleteMany();

  expect(deletedPosts.count).toBe(7);
  expect(deletedUsers.count).toBe(2);
});

とりあえず、こんなところでしょうか。

まとめ

TypeScriptのORMである、Prismaを試してみました。

補完がかなり強力で、型宣言をschema.prismaから生成しているからか、クエリーのパラメータに指定するオブジェクトの内容をほぼ補完できます。 これには驚きました。

一方で、schema.prismaがとっつきにくいというか、ネイティブなSQLからはできなくなっていることもあったりするので、ちょっと微妙な
感じもします…。
ちょっと独自の世界観も強いような印象なのですが、どうなのでしょう。使うのがPrismaだけなら、問題ないのかなぁとも。

最近はPrismaが人気らしいので、Prismaを扱ったら終わりにしようかと思っていたのですが、1度TypeORMも試して感覚を掴んでみようかな?
という気分になりました。