LoginSignup
3
3

Play + ZIO Quillハンズオン

Last updated at Posted at 2024-03-29

目的

Scala + PlayFramework + ZIO Quillで簡単なREST APIを作成するハンズオンです。
DBはPlayFramework内蔵のH2インメモリーデータベースを使用します。

このハンズオンではAPIのフレームワークとしてPlayFrameworkを使用していますが、PlayFrameworkの記法については触れず、Quillの記法メインで解説します。

ZIO Quillとは

ScalaのライブラリであるZIOのDBアクセスライブラリです。(ORMみたいなもの)
ZIO本体が無くても使用できます。

特長

  1. テーブルとのマッピングはケースクラスを用意するだけ
  2. 生のSQLを書かずに用意されているメソッドだけでCRUD処理が書ける(値の格納はバインド変数で行う)
  3. コンパイル時に[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を使用するための設定を追記します。

build.sbt
// ↓↓↓↓ここから追加↓↓↓↓
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を指定します。

application.conf
db.default.driver=org.h2.Driver
db.default.url="jdbc:h2:mem:play;MODE=MYSQL"
db.default.username=sa
db.default.password=""

PlayFrameworkのEvolutionsを使用してユーザ情報テーブルの作成と初期データの登録を行うため、下記のファイルを配置します。

conf/evolutions/default/1.sql
# 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の構築も完了します。

image.png

上記画面のClick here to apply this script now!ボタンをクリックし、画面に「Welcome to Play!」と表示されれば起動は完了です。

Quillの設定

DB接続設定

QuillでDBアクセスするため、play-quill-hands-onプロジェクトのconf/application.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テーブルのカラムを持つケースクラスを作成してください。

app/models/User.scala
package models

case class User(id: Long, email: String, password: String, name: String)

ルーティングの定義

コントローラの雛形を作る

controllersパッケージにUserControllerクラスを以下のように作成します。

app/controllers/UserController.scala
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の実装

コントローラ

UserControllerlistメソッドを以下のように実装します。

  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)

runquoteの中身を実行します。
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の実装

コントローラ

UserControllergetメソッドを以下のように実装します。

  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の実装

コントローラ

UserControllercreateメソッドを以下のように実装します。

  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の実装

コントローラ

UserControllerupdateメソッドを以下のように実装します。

  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の実装

コントローラ

UserControllerdeleteメソッドを以下のように実装します。

  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のドキュメントを確認してみてください。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3