Scala
slick
slick3
slick-codegen
ScalaDay 10

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

この記事は 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ライフを刻みましょう。