この記事は 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 に記述されており面倒です。
最も基本的な使い方
なので初めに、
カスタマイズや機能拡張なんて高度な事には興味ない! いいから実行させてくれ!
という人のために、最も基本的な書き方を提示します。
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は各自の環境に合わせて下さい。
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
が生成していることがわかります。
つまり、機能拡張対象には、MySQLProfile
とSourceCodeGen
が想定できます。
カスタマイズ
MySQLProfile を拡張する
さて、最初の話のtimestampのデフォルト値に話を戻します。
残念ながら、CURRENT_TIMESTAMP
という形式は現在時刻という特殊なデフォルト値で、普通のデフォルト値によくある0やnullのように表現できません。(現在時刻なので当たり前ですね。)
そのため、MySQLProfileさんはこのデフォルト値を無視します・・・ 値が入りません・・・
値が入らず関連するフラグすらもないため、SourceCodeGeneratorをいくら改造したところで意味がありません。
そこで、Timestamp
のデフォルト値がある時にOption型が付与されるようにMySQLProfileで使われるCustomColumnBuilder.tpe
を書き換えます。
Timestamp
,Date
,Time
の型が来た時に、Joda-Time
のDateTime
,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するために、以下のようにJdbcModelBuilder
、MySQLProfile
のクラス、メソッドも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ライフを刻みましょう。