13
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ScalaAdvent Calendar 2017

Day 10

slick-codegenの基本使用例、DEFAULT値の付いたTIMESTAMPをOption型として出力する

Last updated at Posted at 2017-12-24

この記事は Scala Advent Calendar 2017 の 12月10日 の記事です。(すでに日がたっていますが枠が空いていたので飛び入りしました。)
slick-codegen に関する記事はすでに、slick-codegenの利用例と中身の説明 で記述されており、ネタが被ったなーと思いましたが、自分が一番やりたかった CURRENT_TIMESTAMPのデフォルト値がついたtimestamp の扱いに関する記述が無かったため、記事を書くことにしました。
なお、Scala Advent Calendarに参加するのは今回が初めてなので優しくして下さい(ガクブル

はじめに

前提

slick-codegen とは、PlayでDataBaseに接続する時に使用するSlickのTableと入出力で使用するインスタンスのcase classのコードを、DBのスキーマから自動生成してくれるツールです。
つまり、今まで手動で書いていたinfrastructure層のコードの一部を、DBのスキーマより自動化してくれる便利ツールです。

さて、slick-codegenですが、現状いろいろな古いサンプル、h2のサンプルやPostgreSQLのサンプルがあり、MySQLで使いたいと思っても、自分も初めに調べた時に訳が分からなくなりました。
そこでまずは簡単に整理をしたいと思います。(Slick 3.2.0をベースに話します。)

最も簡単なサンプルは、 https://github.com/slick/slick-codegen-example
https://github.com/playframework/play-scala-isolated-slick-example にありますが、
MySQLではなくh2ベースです。しかも改造しようとするとこのソースでは build.sbt に記述されており面倒です。

最も基本的な使い方

なので初めに、
カスタマイズや機能拡張なんて高度な事には興味ない! いいから実行させてくれ!
という人のために、最も基本的な書き方を提示します。

build.sbt
import sbt._

lazy val root = (project in file("."))
  .settings(
    inThisBuild(List(scalaVersion := "2.12.4")),
    libraryDependencies ++= List(
      "com.typesafe.slick" %% "slick" % "3.2.0",
      "com.typesafe.slick" %% "slick-codegen" % "3.2.0",
      "mysql" % "mysql-connector-java" % "6.0.5"
    )
  )

url,user,passwordは各自の環境に合わせて下さい。

SlickTableCodeGenerator.scala
package generator

import slick.codegen.SourceCodeGenerator

object SlickTableCodeGenerator extends App {
  SourceCodeGenerator.run(
    profile = "slick.jdbc.MySQLProfile",
    jdbcDriver = "com.mysql.cj.jdbc.Driver",
    url = "jdbc:mysql://localhost/database?useSSL=false&nullNamePatternMatchesAll=true",
    outputDir = "./output",
    pkg = "infrastructure.sample",
    user = Some("root"),
    password = Some(""),
    ignoreInvalidDefaults = true
  )
}

これだけで動きます。ねっ、簡単でしょう。

どこを機能拡張するべきかを調べる

改造するには、元のソースを読むのが一番! ということで、runメソッド内の以下を読みます。
https://github.com/slick/slick/blob/3.2.0/slick-codegen/src/main/scala/slick/codegen/SourceCodeGenerator.scala#L71-L72

val m = Await.result(db.run(profileInstance.createModel(None, ignoreInvalidDefaults)(ExecutionContext.global).withPinnedSession), Duration.Inf)
new SourceCodeGenerator(m).writeToFile(profile,outputDir,pkg)

ここで行っていることは、MySQLProfile.createModelからslick.model.Modelを生成し、それをSourceCodeGen(slick-codegenの事)に渡しているだけです。
つまりスキーマ自体は MySQLProfile.createModelが生成していることがわかります。

つまり、機能拡張対象には、MySQLProfileSourceCodeGenが想定できます。

カスタマイズ

MySQLProfile を拡張する

さて、最初の話のtimestampのデフォルト値に話を戻します。
残念ながら、CURRENT_TIMESTAMPという形式は現在時刻という特殊なデフォルト値で、普通のデフォルト値によくある0やnullのように表現できません。(現在時刻なので当たり前ですね。)
そのため、MySQLProfileさんはこのデフォルト値を無視します・・・ 値が入りません・・・
値が入らず関連するフラグすらもないため、SourceCodeGeneratorをいくら改造したところで意味がありません。

そこで、Timestampのデフォルト値がある時にOption型が付与されるようにMySQLProfileで使われるCustomColumnBuilder.tpeを書き換えます。

Timestamp,Date,Timeの型が来た時に、Joda-TimeDateTime,LocalDate,LocalTimeに変換しています。さらに、ColumnBuilder.rawDefaultメソッドでデフォルト値が存在するかがわかる(Some("CURRENT_TIMESTAMP")が返る)ため、デフォルト値がある場合はOption型で返すようにします。
SourceCodeGeneratorはDateTime型ではOption型が付与できる挙動のため、二重Optionにならないようにmeta.typeNameも確認しています。

  class CustomColumnBuilder(tableBuilder: TableBuilder, meta: MColumn) extends ColumnBuilder(tableBuilder, meta) {

    override def tpe: String =
      jdbcTypeToScala(meta.sqlType, meta.typeName).toString match {
        case "java.lang.String" => if (meta.size.contains(1)) "Char" else "String"
        case "java.sql.Timestamp" =>

          // If a [TimeStamp] default value is set such as CURRENT_TIMESTAMP, [Option] is added.
          rawDefault match {
            case Some(_) if meta.typeName.toUpperCase == "TIMESTAMP" => "Option[DateTime]"
            case _                                                   => "DateTime"
          }
        case "java.sql.Date" => "LocalDate"
        case "java.sql.Time" => "LocalTime"
        case jdbcType        => jdbcType
      }
  }

全体としては、CustomColumnBuilderをoverrideするために、以下のようにJdbcModelBuilderMySQLProfileのクラス、メソッドもoverrideします。

object CustomMySQLProfile extends MySQLProfile with CustomJdbcModelComponent {
  def RowNum(sym: AnonSymbol, inc: Boolean) = MySQLProfile.RowNum(sym, inc)

  def RowNumGen(sym: AnonSymbol, init: Long) = MySQLProfile.RowNumGen(sym, init)
}

trait CustomJdbcModelComponent extends JdbcModelComponent {
  self: JdbcProfile =>
  override def createModelBuilder(tables: Seq[MTable], ignoreInvalidDefaults: Boolean)(implicit ec: ExecutionContext): CustomJdbcModelBuilder =
    new CustomJdbcModelBuilder(tables, ignoreInvalidDefaults)
}

class CustomJdbcModelBuilder(mTables: Seq[MTable], ignoreInvalidDefaults: Boolean)(implicit ec: ExecutionContext)
  extends JdbcModelBuilder(mTables, ignoreInvalidDefaults) {

  override def createColumnBuilder(tableBuilder: TableBuilder, meta: MColumn): ColumnBuilder = new CustomColumnBuilder(tableBuilder, meta)

  class CustomColumnBuilder(tableBuilder: TableBuilder, meta: MColumn) extends ColumnBuilder(tableBuilder, meta) {(上記と同じため略)}

これでようやく、CustomMySQLProfile.createModel を使ってModelを出力できます。

ModelデータからSchema情報を消す

この記事の一番やりたい事と外れますが、CustomMySQLProfile.createModel で出力されたコードは、よく見るとDB名(スキーマ)が入っていることがあります。
残念なことにDB名が入っていると、テストのために別のDBやh2のDBを使用した時にエラーで動かず、テストできません。
そこで以下のようなメソッドを作り、出力結果のmodel: Modelから、model.name.schema をNoneで消します。

  private[this] def createModelWithoutSchema(model: Model): Model = {
    val tables: Seq[Table] = model.tables.map((table: Table) => Table(
      name = QualifiedName(table = table.name.table, schema = None, catalog = table.name.catalog),
      columns = table.columns,
      primaryKey = table.primaryKey,
      foreignKeys = table.foreignKeys,
      indices = table.indices,
      options = table.options
    ))
    Model(tables, model.options)
  }

SourceCodeGeneratorを拡張する

上記でこの記事の一番やりたかったtimestampのOption化はだいたい終わりましたが、SourceCodeGeneratorに関してもまだ拡張する必要があります。
Joda-Timeのimportを解決するために、org.joda.time._MySQLJodaSupportを追加します。

また、AUTO_INCREMENTなカラムもOption化したいため、Column.asOption = autoInc を追加します。

他の記事でよく見る def autoIncLastAsOption@deprecatedのため使用しないほうが良いです。同じことをやるのであれば、Table.autoIncLast = true, Column.asOption = autoIncを追加しましょう。
なお、自分はAUTO_INCREMENTなカラムをcase classの変数のラストにしたくないため、autoIncLastは使用しません。

SourceCodeGeneratorのカスタマイズでよく見るコードと同様に、以下のようにoverrideします。

class CustomSourceCodeGenerator(model: Model) extends SourceCodeGenerator(model) {

  override def code = "import com.github.tototoshi.slick.MySQLJodaSupport._\n" + "import org.joda.time._\n" + super.code

  override def Table = new Table(_) {
    override def Column = new Column(_) {
      override def asOption: Boolean = autoInc
    }
  }
}

各拡張クラスを結合する

これでようやく準備ができました。 DB設定(url,user,password等)は各自の環境に合わせて下さい。

  val db: DatabaseDef = Database.forURL(
    url = "jdbc:mysql://localhost/database?useSSL=false&nullNamePatternMatchesAll=true",
    driver = "com.mysql.cj.jdbc.Driver",
    user = "root",
    password = ""
  )

  val model: Model = Await.result(
    db.run(CustomMySQLProfile.createModel().withPinnedSession)
      .map(createModelWithoutSchema),
    Duration.Inf
  )

  new CustomSourceCodeGenerator(model).writeToFile(
    profile = "slick.jdbc.MySQLProfile",
    folder = "./output",
    pkg = "infrastructure.sample",
    container = "SampleTables",
    fileName = "SampleTables.scala"
  )

実行例

以下のテーブルからコードを自動生成してみます。

CREATE TABLE `sample` (                                                                                 
  `id` int(11) NOT NULL AUTO_INCREMENT,                                                                 
  `default_null` timestamp NULL DEFAULT NULL,                                                           
  `default_current` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,                                       
  `default_current_on_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 
  PRIMARY KEY (`id`)                                                                                    
) ENGINE=InnoDB DEFAULT CHARSET=utf8                                                                    

このテーブルを食わせて出力すると以下のようなコードが得られます。

package infrastructure.sample
// AUTO-GENERATED Slick data model
/** Stand-alone Slick data model for immediate use */
object SampleTables extends {
  val profile = slick.jdbc.MySQLProfile
} with SampleTables

/** Slick data model trait for extension, choice of backend or usage in the cake pattern. (Make sure to initialize this late.) */
trait SampleTables {
  val profile: slick.jdbc.JdbcProfile
  import profile.api._
  import com.github.tototoshi.slick.MySQLJodaSupport._
  import org.joda.time._
  import slick.model.ForeignKeyAction
  // NOTE: GetResult mappers for plain SQL are only generated for tables where Slick knows how to map the types of all columns.
  import slick.jdbc.{GetResult => GR}

  /** DDL for all tables. Call .create to execute. */
  lazy val schema: profile.SchemaDescription = Sample.schema
  @deprecated("Use .schema instead of .ddl", "3.0")
  def ddl = schema

  /** Entity class storing rows of table Sample
   *  @param id Database column id SqlType(INT), AutoInc, PrimaryKey
   *  @param defaultNull Database column default_null SqlType(TIMESTAMP)
   *  @param defaultCurrent Database column default_current SqlType(TIMESTAMP)
   *  @param defaultCurrentOnUpdate Database column default_current_on_update SqlType(TIMESTAMP) */
  final case class SampleRow(id: Option[Int] = None, defaultNull: Option[DateTime], defaultCurrent: Option[DateTime], defaultCurrentOnUpdate: Option[DateTime])
  /** GetResult implicit for fetching SampleRow objects using plain SQL queries */
  implicit def GetResultSampleRow(implicit e0: GR[Option[Int]], e1: GR[Option[DateTime]]): GR[SampleRow] = GR{
    prs => import prs._
    SampleRow.tupled((<<?[Int], <<?[DateTime], <<[Option[DateTime]], <<[Option[DateTime]]))
  }
  /** Table description of table sample. Objects of this class serve as prototypes for rows in queries. */
  class Sample(_tableTag: Tag) extends profile.api.Table[SampleRow](_tableTag, "sample") {
    def * = (Rep.Some(id), defaultNull, defaultCurrent, defaultCurrentOnUpdate) <> (SampleRow.tupled, SampleRow.unapply)
    /** Maps whole row to an option. Useful for outer joins. */
    def ? = (Rep.Some(id), defaultNull, Rep.Some(defaultCurrent), Rep.Some(defaultCurrentOnUpdate)).shaped.<>({r=>import r._; _1.map(_=> SampleRow.tupled((_1, _2, _3.get, _4.get)))}, (_:Any) =>  throw new Exception("Inserting into ? projection not supported."))

    /** Database column id SqlType(INT), AutoInc, PrimaryKey */
    val id: Rep[Int] = column[Int]("id", O.AutoInc, O.PrimaryKey)
    /** Database column default_null SqlType(TIMESTAMP) */
    val defaultNull: Rep[Option[DateTime]] = column[Option[DateTime]]("default_null")
    /** Database column default_current SqlType(TIMESTAMP) */
    val defaultCurrent: Rep[Option[DateTime]] = column[Option[DateTime]]("default_current")
    /** Database column default_current_on_update SqlType(TIMESTAMP) */
    val defaultCurrentOnUpdate: Rep[Option[DateTime]] = column[Option[DateTime]]("default_current_on_update")
  }
  /** Collection-like TableQuery object for table Sample */
  lazy val Sample = new TableQuery(tag => new Sample(tag))
}

この例では、SampleテーブルとSampleRowを使ってSlickのデータのやり取りが出来ます。

その他

なお、このソースの修正版は、 https://github.com/peutes/slick-table-code-generator にあります。
環境ごとの修正は全てapplication.confで設定できます。
sbt run を実行するとSlick Tableコードが自動生成できるため使って下さい。

以上です。これで幸せSlickライフを刻みましょう。

13
5
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
13
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?