ProtoQuill 使ってみた — Scala3 におけるエンティティの永続化

Tomoki Mizogami
nextbeat-engineering
22 min readDec 28, 2022

こんにちは、ネクストビートでエンジニアをしている溝上と申します。

ネクストビートではサーバーサイドの言語として Scala を採用しており、Scala 3 のプロダクトへの導入も粛々と進めています。

本日は、Scala 3 でデータベースアクセスのためのクエリ記述/実行を行えるライブラリ ProtoQuillを使って、エンティティを永続化する機能を実装していきます。

1. 本記事でのゴール

本記事では、以下の3つができるようになることを目指します。

  • ProtoQuill を使った基本的なコマンド(SELECT, INSERT, UPDATE, DELETE)の実行
  • Enum 型をカラムに持つエンティティの永続化
  • Opaque Type Alias で定義された独自型をカラムに持つエンティティの永続化

よろしくお願いします!

2. はじめに

ProtoQuill は、Quill というライブラリの Scala 3 用後継ライブラリです。

ProtoQuill は、以下のような QDSL (Quoted Domain Specific Language) というドメイン固有言語 (DSL) を使ってクエリを表現します。プレーン SQL ではなく、DSL を使ってクエリを書くところに特徴があります。

quote {
query[Person].filter(_.firstName == "Joe")
}

この他にも、ProtoQuill(というより Quill)には以下のような特徴があります。

  • Boilerplate-free mapping: データベースのスキーマは、ケースクラスを用いて表現されます。
  • Compile-time query generation: 実行するクエリがコンパイル時に生成されるので、データベースドライバを直接使用するのと同様の実行時オーバーヘッドになります。
  • Compile-time query validation: 実行するクエリはコンパイル時に検証され、不正な場合はコンパイルエラーになります。

今回使用する Scala と ProtoQuill、データベースの種類/バージョンは以下になります。ビルドツールとしては sbt を利用します。

また、今回作成したサンプルコードは以下に置いています。

3. ProtoQuill における SQL の表現

本章ではまず、ProtoQuill においてクエリをどう表現するかについてご紹介します。

3.1 コンテキスト

ProtoQuill では、データベースとの接続、クエリのパース・実行をコンテキストが担います。ProtoQuill にはいくつかのコンテキストが用意されており、同期的な MySQL コンテキスト、ZIO のコンテキスト、非同期的な Postgres コンテキスト、テスト用のコンテキストなどがあります。本記事では、テスト用の MirrorContext/SqlMirrorContext と MySQL 用の MysqlJdbcContext を使います。

  • MysqlJdbcContext: MySQL JDBC ドライバー用のコンテキスト(ブロッキング)
  • MirrorContext: クエリは実行せず、Quotation AST の情報を返すテスト用コンテキスト
  • SqlMirrorContext: クエリは実行せず、SQL 文の情報を返すテスト用コンテキスト

3.2 Quotation

ProtoQuill において、クエリは quote { } ブロックの内部にて定義されます。この quote メソッドによって定義されたコードブロックのことを Quotation と呼びます。Quotation はコンパイル時にパースされ、抽象構文木(AST)に変換されます。

Quill における AST については、以下のスライドで説明されています。興味のある方はご覧ください:

3.3 基本的な SQL の実行

では、テスト用のコンテキスト SqlMirrorContext を使って、実際にクエリを書いていきましょう。

SBT プロジェクトの libraryDependencies に ProtoQuill の quill-jdbc (同期的な JDBC ライブラリ)、HikariCP、mysql-connector-java を追加します:

libraryDependencies ++= Seq(
"io.getquill" %% "quill-jdbc" % "4.6.0",
"com.zaxxer" % "HikariCP" % "4.0.3",
"mysql" % "mysql-connector-java" % "8.0.26"
)

ProtoQuill においてクエリをパース・実行するためには、コンテキストが必要です。ここでは、MySQL 用のテストコンテキストを生成します。

import io.getquill._
val ctx = new SqlMirrorContext(MySQLDialect, Literal)
import ctx._

以下のような Person モデルに対して、SELECT/INSERT/UPDATE/DELETE 文を書いていきます。

case class Person(firstName: String, lastName: String, age: Int)

ProtoQuill ではケースクラスとテーブルスキーマが1対1対応しており、 query[Person] とするだけで SELECT * FROM person を表現できます。スキーマ情報を取得すれば、あとは Scala のコレクションを扱うのと同様に絞り込むことができます。

// SELECT の例
inline def select = quote {
query[Person].filter(_.firstName == "Joe")
}

上記の Quotation をテストコンテキスト上で実行すると、生成される SQL 文が得られます!

ctx.run(select)
// => SELECT x1.firstName, x1.lastName, x1.age FROM Person x1 WHERE x1.firstName = 'Joe'

INSERT、UPDATE、DELETE に関しても同様に、スキーマ情報を取得して絞り込みをした後、それぞれの操作を実行することができます。

// INSERT の例
ctx.run(quote {
query[Person].insertValue(lift(Person("tomoki", "mizogami", 25)))
})
// => INSERT INTO Person (firstName,lastName,age) VALUES (?, ?, ?)
// UPDATE の例
val updated = Person("友貴", "溝上", 25)
ctx.run(quote {
query[Person].filter(p => p.firstName == "tomoki" && p.lastName == "mizogami").updateValue(lift(updated))
})

// => UPDATE Person p SET firstName = ?, lastName = ?, age = ? WHERE p.firstName = 'tomoki' AND p.lastName = 'mizogami'
// DELETE の例
ctx.run(quote {
query[Person].filter(_.lastName == "mizogami").delete
})
// => DELETE FROM Person x4 WHERE x4.lastName = 'mizogami'

クエリを表現するためのメソッドは他にもたくさんあり、バッチ処理もサポートされているので、興味がある方は Quill の公式ドキュメントをご覧いただければと思います。

4. データベース接続

では、実際にデータベースにアクセスしてデータの取得/保存をしてみましょう。

サンプルプロジェクトでは、Docker 上に以下のようなデータベース(MySQL 8.0)を用意しています。

$ pwd
/path/to/protoquill-sandbox
$ docker-compose up -d
$ mysql -uadmin -h127.0.0.1 -P13306 -p
password: admin
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sandbox |
| sys |
+--------------------+
5 rows in set (0.04 sec)

mysql> show tables from sandbox;
+-------------------+
| Tables_in_sandbox |
+-------------------+
| person |
| test_table |
+-------------------+
2 rows in set (0.01 sec)

mysql> desc sandbox.test_table;
+-------+-----------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------+-----------------+------+-----+---------+----------------+
| id | bigint unsigned | NO | PRI | NULL | auto_increment |
| name | varchar(32) | NO | | NULL | |
+-------+-----------------+------+-----+---------+----------------+
2 rows in set (0.01 sec)

mysql> desc sandbox.person;
+------------+-----------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+-----------------+------+-----+---------+----------------+
| id | bigint unsigned | NO | PRI | NULL | auto_increment |
| first_name | varchar(32) | NO | | NULL | |
| last_name | varchar(32) | NO | | NULL | |
| age | smallint | NO | | NULL | |
| gender | smallint | NO | | NULL | |
+------------+-----------------+------+-----+---------+----------------+
5 rows in set (0.00 sec)

mysql> select * from sandbox.test_table;
+----+--------------+
| id | name |
+----+--------------+
| 1 | Name |
| 2 | 名前 |
+----+--------------+
3 rows in set (0.01 sec)

mysql> select * from sandbox.person;
+----+------------+-----------+-----+--------+
| id | first_name | last_name | age | gender |
+----+------------+-----------+-----+--------+
| 1 | 太郎 | 山田 | 25 | 1 |
| 2 | 花子 | 山田 | 28 | 2 |
| 3 | 太郎 | 佐藤 | 28 | 1 |
+----+------------+-----------+-----+--------+
3 rows in set (0.00 sec)

本章では、 test_table に関してデータの取得/保存をしていきます。

4.1 MySQL 用コンテキストの作成

MySQL サーバーにアクセスするには MysqlJdbcContext を使います。

val ctx = new MysqlJdbcContext(SnakeCase, "sample.context")

データベースとの接続設定(HikariCP の DataSource の設定)は設定ファイルに記載します。

application.conf:

sample.context.dataSourceClassName=com.mysql.cj.jdbc.MysqlDataSource
sample.context.dataSource.url="jdbc:mysql://127.0.0.1:13306/sandbox"
sample.context.dataSource.user=admin
sample.context.dataSource.password=admin
sample.context.dataSource.cachePrepStmts=true
sample.context.dataSource.prepStmtCacheSize=250
sample.context.dataSource.prepStmtCacheSqlLimit=2048
sample.context.connectionTimeout=30000

設定ファイルの書き方はデフォルトで決まっていますが、HikariDataSource を作ってから MysqlJdbcContext を生成することによって、設定ファイルをカスタマイズすることもできます。次の create メソッドは、HikariDataSource から MysqlJdbcContext を作る例です。

def create(naming: NamingStrategy, dataSource: HikariDataSource): Context =
new MysqlJdbcContext(naming, dataSource)

4.2 データの取得/保存

この MysqlJdbcContext を使えば、実際のデータベースからデータを取得することができます。TestTable 用のモデルを作って実行してみると、データが取得されます。

sbt:ProtoQuill Sandbox> runMain getTestTable
The result set is:
List(TestTable(1,Name), TestTable(2,名前))

INSERT も同様に可能です。

sbt:ProtoQuill Sandbox> runMain insertTestTable
The result of insert is:
1
The result set is:
List(TestTable(1,Name), TestTable(2,名前), TestTable(3,サンプル))

5. エンティティの永続化

ここまでで、テスト用コンテキストを使って基本的なコマンドの実行方法を学び、MySQL 用コンテキストを使って実際にデータベースに接続してみました。

本章では、独自の型定義を持つエンティティを定義し、その永続化を行なっていきます。独自の型をデータベースの型にマッピングする方法についてもご紹介します。

5.1 対象モデル

3章で使った Person クラスを拡張し、Person エンティティを永続化の対象とします。Person エンティティの定義は以下のようになっています。

Person のカラムは、Opaque Type Alias や Enum、ケースクラスを使った独自型にしています。

各カラムにはそれぞれ、以下のような特徴をつけています。

  • Id : Option[Long] 型の Opaque Type Alias です。AUTO INCREMENT で入る Id にするので、デフォルトでは None になるよう定義しています。
  • Name: FirstName と LastName からなるケースクラスです。FirstName と LastName はそれぞれ String 型の Opaque Type Alias です。
  • Age: Int 型の Opaque Type Alias です。
  • Gender: Enum 型です。ただし、カスタマイズした値を保存できるよう code: Short というカラムを追加しています。

5.2 Personモデルのマッピング

では、データベースから Person エンティティのデータを取得してみましょう。既に以下のデータがあるものとします。

mysql> select * from sandbox.person;
+----+------------+-----------+-----+--------+
| id | first_name | last_name | age | gender |
+----+------------+-----------+-----+--------+
| 1 | 太郎 | 山田 | 25 | 1 |
| 2 | 花子 | 山田 | 28 | 2 |
| 3 | 太郎 | 佐藤 | 28 | 1 |
+----+------------+-----------+-----+--------+
3 rows in set (0.00 sec)

先ほどのように、単純に全データを取得する Quotation を書いてもうまくいきません。マッピングのエラーになっていますが、これは OR マッパーを使っていれば当然の事象かと思います。

import io.getquill._
val ctx = new MysqlJdbcContext(SnakeCase, "sample.context")
import ctx._
ctx.run(query[Person])
// => The Co-Product element protoquill.sandbox.entity.Person.Gender.MALE was not a Case Class or Value Type. Value-level Co-Products are not supported. Please write a decoder for it's parent-type protoquill.sandbox.entity.Person.Gender.

ここでは、以下の2点が課題になります。

  1. Opaque Type Alias, Enum 型等によって定義した独自型のマッピングを定義する必要があること
  2. Name クラスと DB スキーマのマッピングが必要になること

1つ目に対しては型の変換を与える必要があり、2つ目に対してはスキーマのマッピング定義を行う必要があります。

① 独自型のマッピング

Opaque Type Alias、Enum 型等によって定義した独自型に対してのマッピングを与えために、 MappedEncoding を定義します。2つの型パラメータ I と O の変換式を渡してあげれば、コンパイラは独自型のマッピングを認識することができます。

case class MappedEncoding[I, O](f: I => O)

例えば、Person.Id 型と Option[Long] 型のマッピングは以下のようになります。

given MappedEncoding[Person.Id, Option[Long]](_.toOption)
given MappedEncoding[Option[Long], Person.Id](Person.Id.apply _)

Gender 型と Short 型のマッピングは以下のように定義できます。

given MappedEncoding[Person.Gender, Short](_.code)
given MappedEncoding[Short, Person.Gender](Person.Gender.apply _)

Person に関する全ての MappedEncoding については、以下をご参照ください。

② スキーマのマッピング

次に、Name クラスと DB スキーマとのマッピングを与える必要があります。テーブル構造を見ると、first_name と last_name で別カラムになっていることがわかります。

mysql> desc sandbox.person;
+------------+-----------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+-----------------+------+-----+---------+----------------+
| id | bigint unsigned | NO | PRI | NULL | auto_increment |
| first_name | varchar(32) | NO | | NULL | |
| last_name | varchar(32) | NO | | NULL | |
| age | smallint | NO | | NULL | |
| gender | smallint | NO | | NULL | |
+------------+-----------------+------+-----+---------+----------------+
5 rows in set (0.00 sec)

このような場合にスキーマ情報を取得するには、query メソッドではなく querySchema メソッドを使う必要があります。query メソッドのときは、ケースクラスの構造がそのままスキーマ情報になっていました。一方で querySchema メソッドを使えば、独自のスキーマのマッピングを定義することができます(テーブル名も指定できます)。

1つ目の引数にはテーブル名を渡し、2つ目の引数には Person クラスからカラム名へのマッピング(タプル)を渡します。

inline def schema = quote {
querySchema[Person]("person",
_.name.firstName -> "first_name",
_.name.lastName -> "last_name"
)
}

上記のスキーマ定義と MappedEncoding を使えば、Person のデータを取得することができます!

5.3 Person の永続化

ここまでで、Person を永続化する準備はできました。最後に、Person に対する基本的な操作を実装して本記事を締めたいと思います。

  • Person の全件取得
  • Id による Person の取得
  • Person の追加
  • Person の更新
  • Person の削除

Person を永続化するために必要なものは以下の2つです。

  • コンテキスト(データベースとの接続部分)
  • Person のカラムマッピングの定義

前者は4章で作成し、後者は先ほど実装しました。

コンテキストはアプリケーションで共通利用する想定ですので、別のクラスとして切り出しておきます。

import io.getquill.*

class Database:

val ctx = new MysqlJdbcContext(SnakeCase, "sample.context")

Database クラスは暗黙的に渡すとすると、Person を永続化のクラス PersonRepository は以下のようにまとめられます。

実際のプロダクトの要件に応じて Database の構成や PersonRepository の要件は変わるかと思いますが、簡単に Person を永続化できるようになりました。あとは以下のようにこのクラスのメソッドを呼び出せば、Person の取得/追加/更新/削除が可能です。

# サンプルの実行
sbt:protoQuillSandbox> runMain getPerson
sbt:protoQuillSandbox> runMain insertPerson
sbt:protoQuillSandbox> runMain updatePerson
sbt:protoQuillSandbox> runMain deletePerson

6. おわりに

いかがでしたでしょうか。

本記事では、Scala 3 でデータベースアクセスをするために、ProtoQuill を使ってエンティティを永続化する方法を学びました。具体的には、以下の3つについてご紹介しました。

  • ProtoQuill を使った基本的なコマンド(SELECT, INSERT, UPDATE, DELETE)の実行
  • Enum 型をカラムに持つエンティティの永続化
  • Opaque Type Alias で定義された独自型をカラムに持つエンティティの永続化

これを機に、Scala 3 に少しでも興味を持っていただけると幸いです。

Scala 3 がリリースされて1年半経ち、フレームワークの対応も以前より進んできました。ネクストビートでも Scala 3 の本番導入を進めているものの、まだまだ手探りな状況です。今後も Scala 3 の導入と技術調査を進めていきつつ、テックブログに知見を共有していきたいと思います。

ここまで読んでいただき、ありがとうございましたっ!

We are hiring!

株式会社ネクストビートでは

「人口減少社会において必要とされるインターネット事業を創造し、ニッポンを元気にする。」
を理念に掲げ一緒に働く仲間を募集しております。

https://www.nextbeat.co.jp/recruit

--

--