CLOVER🍀

That was when it all began.

QuarkusのReactive MySQL Clientを試す

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

Quarkusで、ReactiveなMySQLクライアントを使えるというので、試してみようかなと。

Quakus Reactive SQL Clients

Quarkusでデータベースアクセスを行う際にまず挙がってくるのはJPAHibernate)かなと思いますが、ReactiveなSQLクライアントも
備えています。

Quarkus - Reactive SQL Clients

1.8.1の時点だと、以下のデータベースに対応しているようです。

ドキュメントは、PostgreSQLを使って書かれています。

この機能は、SmallRye MunityとVert.xのReactive SQL Clientを使用して作成されています。

SmallRye Mutiny

Data access

https://github.com/smallrye/smallrye-reactive-utils/tree/1.1.0/vertx-mutiny-clients

Vert.xのReactive SQL Clientが、SmallRye Munityでラップされている感じですね。

今回は、この中のReactive MySQL Clientを使用します。JDBCドライバを使っているわけではないんですよねぇ…。

Reactive MySQL Client - Vert.x

https://github.com/smallrye/smallrye-reactive-utils/tree/1.1.0/vertx-mutiny-clients/vertx-mutiny-mysql-client

データベースごとに、Extensionおよび使用するクラスが異なります。

Database Clients details

では、試してみましょう。

環境

今回の環境は、こちら。

$ java --version
openjdk 11.0.8 2020-07-14
OpenJDK Runtime Environment (build 11.0.8+10-post-Ubuntu-0ubuntu120.04)
OpenJDK 64-Bit Server VM (build 11.0.8+10-post-Ubuntu-0ubuntu120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.8, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "4.15.0-112-generic", arch: "amd64", family: "unix"

MySQLは8.0.21を使用し、172.17.0.2で動作しているものとします。

'mysql  Ver 8.0.21 for Linux on x86_64 (MySQL Community Server - GPL)' started.

プロジェクトの作成

最初に、プロジェクトを作りましょう。

書籍をお題に、Reactive MySQL Clientを使ってテーブルにアクセスし、JAX-RSで操作可能なアプリケーションを作成しましょう。

$ mvn io.quarkus:quarkus-maven-plugin:1.8.1.Final:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=resteasy-reactive-mysql \
    -Dextensions="resteasy-mutiny, resteasy-jsonb, reactive-mysql-client"

今回、特にポイントとなるのはreactive-mysql-clientですね。PostgreSQLDB2の場合は、mysqlの部分を変更することになります。

作成したプロジェクトのディレクトリ内に移動。

$ cd resteasy-reactive-mysql

雛形

お題を書籍にするので、まずはテーブルを作成します。

mysql> create table book(
    ->      isbn varchar(14),
    ->      title varchar(255),
    ->      price int,
    ->      primary key(isbn)
    -> );
Query OK, 0 rows affected (0.10 sec)

Reactive SQL Clientにもスキーマ定義や初期データを登録するための方法はあるのですが、今回はパス。

Database schema and seed data

プロダクション環境ではFlywayを使った方が良いと書いていますし、このための処理を書くのもなぁ、と。

書籍クラス。JAX-RSリソースクラスで扱うものになります。
src/main/java/org/littlewings/quarkus/reactive/mysql/Book.java

package org.littlewings.quarkus.reactive.mysql;

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は省略
}

続いて、JAX-RSリソースクラス。
src/main/java/org/littlewings/quarkus/reactive/mysql/BookResource.java

package org.littlewings.quarkus.reactive.mysql;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
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.RowIterator;
import io.vertx.mutiny.sqlclient.SqlClientHelper;
import io.vertx.mutiny.sqlclient.Tuple;

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

    // ここに処理を書く
}

主要な処理は、この後で記載していきます。

MySQLを操作するのは、こちらのMySQLPooLクラスを使用して行います。

    @Inject
    MySQLPool client;

クラスパス上に同じような名前のクラスがいくつかあるのですが、io.vertx.mutiny.mysqlclient.MySQLPoolを使用してください。

他のデータベースの場合は、利用するクラスも変更になります。

Database Clients details

データベースごとにクラスがあるわけですが、いずれもio.vertx.mutiny.sqlclient.Poolクラスを継承しているので…JDBCのように、
同じ型で扱えないものでしょうか…?

今回、ここの確認はパスします。

ちなみに、ドキュメントはPostgreSQLの例で記述されているので、MySQLの場合は以下あたりを参考にしました。

https://github.com/quarkusio/quarkus/tree/1.8.1.Final/integration-tests/reactive-mysql-client

https://github.com/eclipse-vertx/vertx-sql-client/blob/3.9.2/vertx-mysql-client/src/test/java/io/vertx/mysqlclient/MySQLQueryTest.java

https://github.com/eclipse-vertx/vertx-sql-client/blob/3.9.2/vertx-mysql-client/src/test/java/io/vertx/mysqlclient/MySQLTransactionTest.java

設定

Reactive MySQL Clientの設定を行います。

今回は、このように設定。
src/main/resources/application.properties

# Configuration file
# key = value

quarkus.datasource.db-kind=mysql
quarkus.datasource.username=kazuhira
quarkus.datasource.password=password
quarkus.datasource.reactive.url=mysql://172.17.0.2:3306/practice

quarkus.datasource.reactive.mysql.charset=utf8mb4

Configuration Reference

quarkus.datasource.db-kindというのはデータベースの種類に応じた値を設定する必要があるので、今回はmysqlを指定。
MySQL以外のデータベースの場合の値は、こちらを参照。

https://github.com/quarkusio/quarkus/blob/1.8.1.Final/extensions/datasource/common/src/main/java/io/quarkus/datasource/common/runtime/DatabaseKind.java#L16-L22

その他の設定項目については、ドキュメントを参照してください。

Configuration Reference

ソースコードだと、このあたりを見るとよいでしょう。

https://github.com/quarkusio/quarkus/blob/1.8.1.Final/extensions/reactive-datasource/runtime/src/main/java/io/quarkus/reactive/datasource/runtime/DataSourceReactiveRuntimeConfig.java

https://github.com/quarkusio/quarkus/blob/1.8.1.Final/extensions/reactive-mysql-client/runtime/src/main/java/io/quarkus/reactive/mysql/client/runtime/DataSourceReactiveMySQLConfig.java

https://github.com/quarkusio/quarkus/blob/1.8.1.Final/extensions/reactive-mysql-client/runtime/src/main/java/io/quarkus/reactive/mysql/client/runtime/MySQLPoolRecorder.java

https://github.com/eclipse-vertx/vertx-sql-client/blob/3.9.2/vertx-sql-client/src/main/java/io/vertx/sqlclient/PoolOptions.java#L34

データアクセスを行う処理を書く

では、データアクセスを行う処理を書いていきましょう。

このあたりを見ながら。

Using

MunityのUniMultiを使って、処理を書いていくことになります。

検索系クエリ

まずは、シンプルなクエリを使った例を見つつ

Query results traversal

PreparedQueryPreparedStatement)を使った例を見ていきます。

Prepared queries

?で、パラメーターをバインドするようです。

ドキュメントと違うのでは?と思うのですが、これは利用するSQL Clientによって差があるみたいですね。

Reactive MySQL Client / Prepared queries

Reactive PostgreSQL Client / Prepared queries

Quarkusのドキュメントは、PostgreSQLを使った例なので。

では、続けます。

パラメーターなしで、全件取得。

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> findAll() {
        return client
                .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> find(@PathParam("isbn") String isbn) {
        return client
                .preparedQuery("select isbn, title, price from book where isbn = ?")
                .execute(Tuple.of(isbn))
                .onItem()
                .transform(rows -> {
                    RowIterator<Row> iterator = rows.iterator();
                    Row row = iterator.next();

                    return Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price"));
                });
    }

MySQLPool#preparedQueryでクエリを作成し、PreparedQuery#executeTupleを渡すことでパラメーターをバインドします。

        return client
                .preparedQuery("select isbn, title, price from book where isbn = ?")
                .execute(Tuple.of(isbn))

結果はRowSetになり、RowIteratorから結果を取得することができます。

                .transform(rows -> {
                    RowIterator<Row> iterator = rows.iterator();
                    Row row = iterator.next();

                    return Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price"));
                });
更新系クエリ

続いて、更新系のクエリを書いていきます。

トランザクションも使ってみましょう。

Transactions

SqlClientHelperを使った簡単なトランザクション管理と、Transactionを使った手動のトランザクション管理の2つの方法が
あります。

1件insert。

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> put(Book book) {
        return SqlClientHelper
                .inTransactionUni(client, tx ->
                        tx
                                .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)")
                                .execute(Tuple.of(book.getIsbn(), book.getTitle(), book.getPrice()))
                                .onItem()
                                .transform(rows -> book)
                );
    }

SqlClientHelper#inTransactionUniSqlClientHelper#inTransactionMulti内で、トランザクションをコントロールします。

バッチ更新。

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> register(List<Book> books) {
        return SqlClientHelper
                .inTransactionMulti(client, tx -> {
                    List<Tuple> tuples =
                            books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList());

                    return tx
                            .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)")
                            .executeBatch(tuples)
                            .onItem()
                            .transformToMulti(rows -> Multi.createFrom().items(books.stream()));
                });
    }

PreparedQuery#executeBatchで、List<Tuple>を使ってバッチ更新を行うことができます。

                    List<Tuple> tuples =
                            books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList());

                    return tx
                            .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)")
                            .executeBatch(tuples)

トランザクション管理は、こちらはSqlClientHelper#inTransactionMultiを使用しています。

        return SqlClientHelper
                .inTransactionMulti(client, tx -> {

begincommitrollbackを使う方法も試してみましょう。

こちらは、ロールバックするようにしてあります。

    @POST
    @Path("manual")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> manualTransactionRegister(List<Book> books) {
        List<Tuple> tuples =
                books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList());

        return client
                .begin()
                .onItem()
                .transformToMulti(tx ->
                        tx
                                .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)")
                                .executeBatch(tuples)
                                .onItem()
                                .<Book>transformToMulti(v -> {
                                    throw new RuntimeException("oops!");  // -> rollback
                                })
                                // .transformToMulti(v1 -> tx.commit().onItem().transformToMulti(v2 -> Multi.createFrom().items(books.stream())))  // -> commit
                                .onFailure()
                                .recoverWithMulti(th ->
                                    tx.rollback().onItem().transformToMulti(v -> Multi.createFrom().failure(th))
                                )
                );
    }

こうすると、コミットするコードになります。

                                /*
                                .<Book>transformToMulti(v -> {
                                    throw new RuntimeException("oops!");  // -> rollback
                                })
                                */
                                .transformToMulti(v1 -> tx.commit().onItem().transformToMulti(v2 -> Multi.createFrom().items(books.stream())))  // -> commit

このAPIを使ったサンプルがなくて困ったのですが、SqlClientHelper#inTransaction〜の中で使われていたので、こちらを
参考にしました。

https://github.com/smallrye/smallrye-reactive-utils/blob/1.1.0/vertx-mutiny-clients/vertx-mutiny-sql-client/src/main/java/io/vertx/mutiny/sqlclient/SqlClientHelper.java#L13-L54

最後に、truncateも。

    @DELETE
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Map<String, String>> truncate() {
        return client
                .preparedQuery("truncate table book")
                .execute()
                .onItem()
                .transform(rows -> Map.of("message", "OK"));
    }

こんな感じで。

確認

では、確認していきましょう。

ビルドして、起動。

$ mvn package
$ java -jar target/resteasy-reactive-mysql-1.0-SNAPSHOT-runner.jar

データを1件登録。

$ 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 200 OK
Content-Length: 90
Content-Type: application/json

{"isbn":"978-4798161488","price":4180,"title":"MySQL徹底入門 第4版 MySQL 8.0対応"}

複数件登録。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/book -d '[
>   {
>     "isbn": "978-4621303252",
>     "title": "Effective Java 第3版",
>     "price": 4400
>   },
>   {
>     "isbn": "978-4295008477",
>     "title": "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]",
>     "price": 2860
>   },
>   {
>     "isbn": "978-4798151120",
>     "title": "独習Java 新版",
>     "price": 3278
>   }
> ]'
HTTP/1.1 200 OK
Content-Length: 287
Content-Type: application/json

[{"isbn":"978-4621303252","price":4400,"title":"Effective Java 第3版"},{"isbn":"978-4295008477","price":2860,"title":"新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"},{"isbn":"978-4798151120","price":3278,"title":"独習Java 新版"}]

1件取得。

$ curl -i localhost:8080/book/978-4798161488
HTTP/1.1 200 OK
Content-Length: 90
Content-Type: application/json

{"isbn":"978-4798161488","price":4180,"title":"MySQL徹底入門 第4版 MySQL 8.0対応"}

全件取得。

$ curl -s localhost:8080/book | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4400,
    "title": "Effective Java 第3版"
  },
  {
    "isbn": "978-4798161488",
    "price": 4180,
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応"
  },
  {
    "isbn": "978-4798151120",
    "price": 3278,
    "title": "独習Java 新版"
  },
  {
    "isbn": "978-4295008477",
    "price": 2860,
    "title": "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"
  }
]

ロールバックする例。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/book/manual -d '[
>   {
>     "isbn": "978-4798147406",
>     "title": "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド",
>     "price": 3960
>   },
>   {
>     "isbn": "978-4873116389",
>     "title": "実践ハイパフォーマンスMySQL 第3版",
>     "price": 5280
>   }
> ]'
HTTP/1.1 500 Internal Server Error
content-length: 0

データは増えていません。

$ curl -s localhost:8080/book | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4400,
    "title": "Effective Java 第3版"
  },
  {
    "isbn": "978-4798161488",
    "price": 4180,
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応"
  },
  {
    "isbn": "978-4798151120",
    "price": 3278,
    "title": "独習Java 新版"
  },
  {
    "isbn": "978-4295008477",
    "price": 2860,
    "title": "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"
  }
]

ログはこんな感じになります。

2020-09-26 03:48:16,822 ERROR [org.jbo.res.res.i18n] (vert.x-eventloop-thread-14) RESTEASY002020: Unhandled asynchronous exception, sending back 500: java.lang.RuntimeException: oops!
    at org.littlewings.quarkus.reactive.mysql.BookResource.lambda$manualTransactionRegister$8(BookResource.java:107)
    at io.smallrye.mutiny.operators.UniOnItemTransformToMulti$FlatMapPublisherSubscriber.onItem(UniOnItemTransformToMulti.java:117)
    at io.smallrye.mutiny.context.ContextPropagationUniInterceptor$1.lambda$onItem$1(ContextPropagationUniInterceptor.java:35)
    at io.smallrye.context.SmallRyeThreadContext.lambda$withContext$0(SmallRyeThreadContext.java:217)
    at io.smallrye.mutiny.context.ContextPropagationUniInterceptor$1.onItem(ContextPropagationUniInterceptor.java:35)
    at io.smallrye.mutiny.operators.UniSerializedSubscriber.onItem(UniSerializedSubscriber.java:72)
    at io.smallrye.mutiny.vertx.AsyncResultUni.lambda$subscribing$1(AsyncResultUni.java:34)
    at io.vertx.mutiny.sqlclient.PreparedQuery$3.handle(PreparedQuery.java:161)
    at io.vertx.mutiny.sqlclient.PreparedQuery$3.handle(PreparedQuery.java:158)
    at io.vertx.sqlclient.impl.SqlResultHandler.complete(SqlResultHandler.java:97)
    at io.vertx.sqlclient.impl.SqlResultHandler.handle(SqlResultHandler.java:86)
    at io.vertx.sqlclient.impl.SqlResultHandler.handle(SqlResultHandler.java:33)
    at io.vertx.sqlclient.impl.TransactionImpl.lambda$wrap$2(TransactionImpl.java:139)

最後にtruncate。

$ curl -i -XDELETE localhost:8080/book
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: application/json

{"message":"OK"}

0件になりました。

$ curl -s localhost:8080/book | jq
[]

まとめ

QuarkusのReactive MySQL Clientを試してみました。

リアクティブなSQLクライアントを使うのは初めてだったので、だいぶてこずりましたが、なんとかなりました…。

練習を繰り返さないと使いこなせない気がとてもとてもするのですが、Munity含め、少しずつ頑張ってみましょう。

最後に、作成したJAX-RSリソースクラス全体を載せておきます。
src/main/java/org/littlewings/quarkus/reactive/mysql/BookResource.java

package org.littlewings.quarkus.reactive.mysql;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
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.RowIterator;
import io.vertx.mutiny.sqlclient.SqlClientHelper;
import io.vertx.mutiny.sqlclient.Tuple;

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

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> findAll() {
        return client
                .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> find(@PathParam("isbn") String isbn) {
        return client
                .preparedQuery("select isbn, title, price from book where isbn = ?")
                .execute(Tuple.of(isbn))
                .onItem()
                .transform(rows -> {
                    RowIterator<Row> iterator = rows.iterator();
                    Row row = iterator.next();

                    return Book.create(row.getString("isbn"), row.getString("title"), row.getInteger("price"));
                });
    }

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Book> put(Book book) {
        return SqlClientHelper
                .inTransactionUni(client, tx ->
                        tx
                                .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)")
                                .execute(Tuple.of(book.getIsbn(), book.getTitle(), book.getPrice()))
                                .onItem()
                                .transform(rows -> book)
                );
    }

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> register(List<Book> books) {
        return SqlClientHelper
                .inTransactionMulti(client, tx -> {
                    List<Tuple> tuples =
                            books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList());

                    return tx
                            .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)")
                            .executeBatch(tuples)
                            .onItem()
                            .transformToMulti(rows -> Multi.createFrom().items(books.stream()));
                });
    }

    @POST
    @Path("manual")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Multi<Book> manualTransactionRegister(List<Book> books) {
        List<Tuple> tuples =
                books.stream().map(b -> Tuple.of(b.getIsbn(), b.getTitle(), b.getPrice())).collect(Collectors.toList());

        return client
                .begin()
                .onItem()
                .transformToMulti(tx ->
                        tx
                                .preparedQuery("insert into book(isbn, title, price) values(?, ?, ?)")
                                .executeBatch(tuples)
                                .onItem()
                                .<Book>transformToMulti(v -> {
                                    throw new RuntimeException("oops!");  // -> rollback
                                })
                                // .transformToMulti(v1 -> tx.commit().onItem().transformToMulti(v2 -> Multi.createFrom().items(books.stream())))  // -> commit
                                .onFailure()
                                .recoverWithMulti(th ->
                                    tx.rollback().onItem().transformToMulti(v -> Multi.createFrom().failure(th))
                                )
                );
    }

    @DELETE
    @Produces(MediaType.APPLICATION_JSON)
    public Uni<Map<String, String>> truncate() {
        return client
                .preparedQuery("truncate table book")
                .execute()
                .onItem()
                .transform(rows -> Map.of("message", "OK"));
    }
}