目的
Scala + PlayFramework + ZIO Quillで簡単なREST APIを作成するハンズオンです。
DBはPlayFramework内蔵のH2インメモリーデータベースを使用します。
このハンズオンではAPIのフレームワークとしてPlayFrameworkを使用していますが、PlayFrameworkの記法については触れず、Quillの記法メインで解説します。
ZIO Quillとは
ScalaのライブラリであるZIOのDBアクセスライブラリです。(ORMみたいなもの)
ZIO本体が無くても使用できます。
特長
- テーブルとのマッピングはケースクラスを用意するだけ
- 生のSQLを書かずに用意されているメソッドだけでCRUD処理が書ける(値の格納はバインド変数で行う)
- コンパイル時に[2.]のメソッドを解析し、生のクエリを生成しコンパイルログに出力できる
この記事を作成した経緯
ScalaのDBアクセスライブラリの一番人気はslickですが、Scala3の対応が遅れていました。
代替案としてScala3対応が早かったQuillで開発を進めたところ難なく使えることが分かり、Quillに気軽に触れてもらいたいと思いハンズオン記事を作成しました。
構成
使用するフレームワークおよびバージョンは以下の通りです。
- Scala: 3.3.1
- PlayFramework: 2.9.x
- zio-quill: 4.8.0
前提条件
- JDK 1.7以降がインストールされていること
- ビルドツール(sbt)がインストールされていること
※ 筆者はwindows環境で実装しているため、ターミナルはコマンドプロンプトを使用しています。
ゴール
ユーザ情報のCRUDを行う簡単なREST APIを作成します。
プロジェクト作成
新規プロジェクト作成
コマンドプロンプトでプロジェクトを作成したいディレクトリに移動し、以下のコマンドを実行します。
※sbtにはsbt new
というコマンドがあり、これを使うとPlayFrameworkのひな型を作ってくれます。
> sbt new playframework/play-scala-seed.g8 --branch 2.9.x
プロジェクト名やバージョンを聞かれます。下記のように入力してください。
- name: play-quill-hands-on
- scala_version: 3.3.1
This template generates a Play Scala project
name [play-scala-seed]: play-quill-hands-on
organization [com.example]:
play_version [2.9.2]:
scala_version [2.13.13]: 3.3.1
sbt_giter8_scaffold_version [0.16.2]:
play-quill-hands-on
ディレクトリのbuild.sbt
にQuill、H2を使用するための設定を追記します。
// ↓↓↓↓ここから追加↓↓↓↓
libraryDependencies ++= Seq(
"com.h2database" % "h2" % "1.4.192",
"io.getquill" %% "quill-jdbc" % "4.8.0" exclude ("org.scala-lang.modules", "scala-java8-compat_3"),
evolutions,
jdbc
)
// ↑↑↑↑ここまで追加↑↑↑↑
DBの準備
今回はH2を使用するため、conf/application.conf
にデフォルトDBとしてH2を指定します。
db.default.driver=org.h2.Driver
db.default.url="jdbc:h2:mem:play;MODE=MYSQL"
db.default.username=sa
db.default.password=""
PlayFrameworkのEvolutions
を使用してユーザ情報テーブルの作成と初期データの登録を行うため、下記のファイルを配置します。
# Users schema
# --- !Ups
CREATE TABLE User (
id bigint(20) NOT NULL AUTO_INCREMENT,
email varchar(255) NOT NULL,
password varchar(255) NOT NULL,
name varchar(255) NOT NULL,
PRIMARY KEY (id)
);
INSERT INTO User VALUES (1, 'test@test.test', 'pass', 'テスト テスト');
INSERT INTO User VALUES (2, 'test2@test.test', 'aaaa', 'テスト2 テスト2');
# --- !Downs
DROP TABLE User;
※Evolutionsの仕様についてはPlayのドキュメントを参照してください。
起動確認
作成したplay-quill-hands-on
ディレクトリに移動し、以下のコマンドでプロジェクトを実行します。
> sbt run
ブラウザからhttp://localhost:9000
にアクセスするとEvolutionsの確認画面が表示されます。
※Evolutionsにより[DBの準備]で作成したsqlファイルのCREATEとINSERTが実行されるため、起動と同時にDBの構築も完了します。
上記画面のClick here to apply this script now!
ボタンをクリックし、画面に「Welcome to Play!」と表示されれば起動は完了です。
Quillの設定
DB接続設定
QuillでDBアクセスするため、play-quill-hands-on
プロジェクトのconf/application.conf
に以下の設定を追加します。
h2.dataSourceClassName=org.h2.jdbcx.JdbcDataSource
h2.dataSource.url="jdbc:h2:mem:play;MODE=MYSQL"
h2.dataSource.user=sa
※H2以外のDBの設定についてはQuillのドキュメントを参照してください。
ケースクラスの作成
Quillではテーブルとのマッピングを単純なケースクラスで行います。
下記のように、models
パッケージにUserテーブルのカラムを持つケースクラスを作成してください。
package models
case class User(id: Long, email: String, password: String, name: String)
ルーティングの定義
コントローラの雛形を作る
controllers
パッケージにUserController
クラスを以下のように作成します。
package controllers
import io.getquill.*
import models.User
import play.api.*
import play.api.data.Form
import play.api.data.Forms.*
import play.api.libs.json.*
import play.api.mvc.*
import javax.inject.*
@Singleton
class UserController @Inject() (val controllerComponents: ControllerComponents) extends BaseController {
val ctx = new H2JdbcContext(SnakeCase, "h2")
import ctx.*
def list = TODO
def get(id: Long) = TODO
def create = TODO
def update(id: Long) = TODO
def delete(id: Long) = TODO
// リクエスト(request -> UserForm)
object UserForm {
case class UserForm(email: String, password: String, name: String)
object UserForm {
def unapply(req: UserForm): Option[(String, String, String)] = {
Some(req.email, req.password, req.name)
}
}
val userForm = Form(
mapping(
"email" -> nonEmptyText,
"password" -> nonEmptyText,
"name" -> nonEmptyText
)(UserForm.apply)(UserForm.unapply)
)
}
// レスポンス(User -> json)
implicit val userFormat: Format[User] = Json.format
}
解説
val ctx = new H2JdbcContext(SnakeCase, "h2")
QuillでDBアクセスを行うためのコンテキストを宣言します。
H2JdbcContext
はH2用のコンテキストです。第2引数の"h2"
がapplication.conf
で定義したh2.xxxx
に対応しています。
import ctx.*
コンテキストインスタンス内のすべてのメソッドを使うための宣言です。
ルーティングの設定
APIのURLをconf/routes
に定義します。下記を追記してください。
# 参照(全件)
GET /users controllers.UserController.list
# 参照(1件)
GET /users/:id controllers.UserController.get(id: Long)
# 登録
POST /users controllers.UserController.create
# 更新
PUT /users/:id controllers.UserController.update(id: Long)
# 削除
DELETE /users/:id controllers.UserController.delete(id: Long)
参照(全件)APIの実装
コントローラ
UserController
のlist
メソッドを以下のように実装します。
def list = Action { implicit request: Request[AnyContent] =>
// Userテーブルをselect(全件)
inline def q = quote { query[User] }
val list = ctx.run(q)
// レスポンス(json)
Ok(Json.toJson(list))
}
解説
// Userテーブルをselect(全件)
inline def q = quote { query[User] }
quote
の中身に実行するクエリを定義します。
query[User]
でUserテーブルに対して操作することを示しています。
条件なしのselectの場合、これだけでUserテーブルのselect文になります。
val list = ctx.run(q)
run
でquote
の中身を実行します。
selectの場合、run
の戻り値はケースクラスのリストになります。
この状態でsbt compile
でコンパイルすると、下記のようなコンパイルログが出力され、生成される生のSQLを確認することができます。
[info] 29 | val list = ctx.run(q)
[info] | ^^^^^^^^^^
[info] |Quill Query (compiled in 199ms): SELECT x.id, x.email, x.password, x.name FROM user x
※補足:inline
について
quote
の宣言にinline
を使用していますが、val q = quote { query[User] }
のようにvalで宣言することも可能です。
ただし、valの場合はコンパイルログに上記のように生成されるSQLが出力されません。
詳細についてはzio-protoquillを確認してください。
実行
コマンドプロンプトからcurl
コマンドなどで下記のURLを実行します。
GET http://localhost:9000/users
(sbt run
を停止していた場合は[プロジェクト作成 > 起動確認]を再度実行してください)
Userテーブルのデータがjsonで全件出力されることを確認してください。
> curl http://localhost:9000/users
[{"id":1,"email":"test@test.test","password":"pass","name":"テスト テスト"},{"id":2,"email":"test2@test.test","password":"aaaa","name":"テスト2 テスト2"}]
参照(1件)APIの実装
コントローラ
UserController
のget
メソッドを以下のように実装します。
def get(id: Long) = Action { implicit request: Request[AnyContent] =>
// Userテーブルをselect(1件)
inline def q = quote {
query[User].filter(_.id == lift(id))
}
val list = ctx.run(q)
Ok(Json.toJson(list))
}
解説
// Userテーブルをselect(1件)
inline def q = quote {
query[User].filter(_.id == lift(id))
}
filter(_.id == lift(id))
でselect文のwhere句を定義しています。
lift
により引き数のidをバインドしています。
※filter
の詳細: https://zio.dev/zio-quill/writing-queries#filter
※lift
について: https://zio.dev/zio-quill/writing-queries#lifted-values
コンパイルログは下記のようになります。
[info] 35 | val list = ctx.run(q)
[info] | ^^^^^^^^^^
[info] |Quill Query (compiled in 35ms): SELECT x1.id, x1.email, x1.password, x1.name FROM user x1 WHERE x1.id = $1
実行
下記のURLを実行します。
GET http://localhost:9000/users/1
Userテーブルのid=1のデータのみ出力されることを確認してください。
> curl http://localhost:9000/users/1
[{"id":1,"email":"test@test.test","password":"pass","name":"テスト テスト"}]
登録APIの実装
コントローラ
UserController
のcreate
メソッドを以下のように実装します。
def create = Action { implicit request: Request[AnyContent] =>
import UserForm.*
def failure(badForm: Form[UserForm]) =
BadRequest(Json.parse(s"""{"error": "${badForm.errors.toString()}"}"""))
def success(form: UserForm) =
// Userテーブルをinsert
inline def q = quote {
query[User].insert(
_.email -> lift(form.email),
_.password -> lift(form.password),
_.name -> lift(form.name)
)
}
val count = ctx.run(q)
Ok(Json.parse(s"""{"count": ${count}}"""))
userForm.bindFromRequest().fold(failure, success)
}
解説
// Userテーブルをinsert
inline def q = quote {
query[User].insert(
_.email -> lift(form.email),
_.password -> lift(form.password),
_.name -> lift(form.name)
)
}
val count = ctx.run(q)
insert
メソッドでUserテーブルのinsert文を定義します。
run
の戻り値は登録件数になります。
※insert
の詳細: https://zio.dev/zio-quill/writing-queries#insertvalue--insert
コンパイルログは下記のようになります。
[info] 54 | val count = ctx.run(q)
[info] | ^^^^^^^^^^
[info] |Quill Query (compiled in 21ms): INSERT INTO user (email,password,name) VALUES ($1, $2, $3)
実行
リクエストボディに登録情報(json)を指定し、下記のURLを実行します。
POST http://localhost:9000/users
登録件数が返ってくることを確認してください。
> curl http://localhost:9000/users -X POST -H "Content-Type: application/json" -d "{\"email\":\"bbb@bbb.bbb\",\"password\":\"bbbbbbb\",\"name\":\"山田 太郎\"}"
{"count":1}
実行結果確認
登録結果を確認するため、参照(全件)APIを実行し、登録したデータが追加されていることを確認してください。
> curl http://localhost:9000/users
...
,{"id":3,"email":"bbb@bbb.bbb","password":"bbbbbbb","name":"山田 太郎"}]
更新APIの実装
コントローラ
UserController
のupdate
メソッドを以下のように実装します。
def update(id: Long) = Action { implicit request: Request[AnyContent] =>
import UserForm.*
def failure(badForm: Form[UserForm]) =
BadRequest(Json.parse(s"""{"error": "${badForm.errors.toString()}"}"""))
def success(form: UserForm) =
// Userテーブルをupdate
inline def q = quote {
query[User]
.filter(_.id == lift(id))
.update(
_.email -> lift(form.email),
_.password -> lift(form.password),
_.name -> lift(form.name)
)
}
val count = ctx.run(q)
Ok(Json.parse(s"""{"count": ${count}}"""))
userForm.bindFromRequest().fold(failure, success)
}
解説
// Userテーブルをupdate
inline def q = quote {
query[User]
.filter(_.id == lift(id))
.update(
_.email -> lift(form.email),
_.password -> lift(form.password),
_.name -> lift(form.name)
)
}
val count = ctx.run(q)
filter
メソッドでupdate文のwhere句を定義します。
update
メソッドでupdate文のset句を定義します。
run
の戻り値は更新件数になります。
※update
の詳細: https://zio.dev/zio-quill/writing-queries#updatevalue--update
コンパイルログは下記のようになります。
[info] 76 | val count = ctx.run(q)
[info] | ^^^^^^^^^^
[info] |Quill Query (compiled in 14ms): UPDATE user AS x5 SET email = $1, password = $2, name = $3 WHERE x5.id = $4
実行
リクエストボディに更新情報(json)を指定し、下記のURLを実行します。
PUT http://localhost:9000/users/1
更新件数が返ってくることを確認してください。
> curl http://localhost:9000/users/1 -X PUT -H "Content-Type: application/json" -d "{\"email\":\"mod@aaa.aaa\",\"password\":\"mod\",\"name\":\"テスト 変更\"}"
{"count":1}
実行結果確認
更新結果を確認するため参照(1件)APIを実行し、id=1のデータが更新されていることを確認してください。
> curl http://localhost:9000/users/1
[{"id":1,"email":"mod@aaa.aaa","password":"mod","name":"テスト 変更"}]
削除APIの実装
コントローラ
UserController
のdelete
メソッドを以下のように実装します。
def delete(id: Long) = Action { implicit request: Request[AnyContent] =>
// Userテーブルをdelete
inline def q = quote {
query[User].filter(_.id == lift(id)).delete
}
val count = ctx.run(q)
Ok(Json.parse(s"""{"count": ${count}}"""))
}
解説
// Userテーブルをdelete
inline def q = quote {
query[User].filter(_.id == lift(id)).delete
}
val count = ctx.run(q)
filter
メソッドでdelete文のwhere句を定義します。
run
の戻り値は削除件数になります。
コンパイルログは下記のようになります。
[info] 89 | val count = ctx.run(q)
[info] | ^^^^^^^^^^
[info] | Quill Query (compiled in 4ms): DELETE FROM user AS x9 WHERE x9.id = $1
実行
下記のURLを実行します。
DELETE http://localhost:9000/users/1
削除件数が返ってくることを確認してください。
> curl http://localhost:9000/users/1 -X DELETE
{"count":1}
実行結果確認
削除結果を確認するため参照(1件)APIを実行し、下記のようにid=1のデータが存在しないことを確認してください。
> curl http://localhost:9000/users/1
[]
おわりに
ZIO Quillを使って簡単にDBのCRUD処理が実装できました。
上記の基本的なクエリ以外にも、GROUP BY
・集約関数・テーブル結合なども簡単に実装出来ます。
また、Quillにはコネクションプールやトランザクションの仕組みも用意されています。
詳しくは、Quillのドキュメントを確認してみてください。