初めに
これは ZOZO Advent Calendar 2022 カレンダー Vol.2 の 19 日目の記事です。
昨日の投稿は @masa517 さんの「ComposeのSideEffectまとめ」でした!
この記事ではSlickにおいてcreated_at, updated_atを意識しないようにする方法について紹介します。
やりたいこと
RDBを使った開発を行う場合に
DBドメインに依存するシステムカラムを定義して
レコードの作成日時や更新日時を永続化し
トレーサビリティを向上するパターンを採用することがあると思います。
しかし、アプリケーションレイヤがシステムカラムの存在を
知っているのは望ましくなく意識しない状態になるのが理想です。
実装時に使用するORMがシステムカラムのことを知らない状態にすることを本記事では目指します。
想定するテーブル構造
架空のテーブルを題材とします。
CREATE TABLE suppliers (
id int NOT NULL,
name VARCHAR(255) NOT NULL,
street VARCHAR(255) NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
);
システムカラムとは
この記事におけるシステムカラムとは
レコードの作成日時や更新日時など
レコードが保存された日時を表すDBドメインのカラムを対象とします。
命名としては
- 作成日時を表すcreated_at
- 更新日時を表すupdated_at
などで定義されることが多い印象です。
これらのカラムは困った時の調査用のログとして活用できたりと
定義しておくと便利なことが多いと思います。
これらのシステムカラムはDBドメインのカラムなため
取り扱いの責務はDBに持たせるようにします。
MySQLではTIMESTAMP型もしくはDATETIME型であれば
自動初期化および更新機能(トリガー機能)を使って
DBの方でカラムを現在の日時で永続化することが可能です。
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
補足) アプリケーションカラムとシステムカラムは分けて考える
ドメインとして仕入れ業者を表すSupplierを例に考えてみます。
要件としてSupplierの登録日時の表示が必要となりました。
この場合、登録日時の表示にcreated_atの様な
レコード作成日時を表すカラムを使用するのではなく
registered_atのように別でドメイン知識を表すカラムを用意し
アプリケーションカラムとシステムカラムを分離した方が良いです。
また、DBレイヤーのトリガーは使用せず
アプリケーションレイヤのタイムスタンプを利用するようにします。
これにより、DBレイヤーのトリガー機能に
アプリケーションロジックが依存することを回避でき
DBマイグレーションが必要なタイミングなどでも
意図しないバグを引き起こさないようにするなどメリットがあります。
システムカラムの入力を隠蔽する
システムカラムはDBドメインなカラムなため
永続化時に入力を意識することは望ましくありません。
対応策としては以下が考えられます。
- ORマッパーが参照するスキーマコードからシステムカラムを除外する
- ORマッパーが参照するスキーマコードのデフォルト引数に現在日時を定義する
どちらの対応でもシステムカラムを意識する必要が無くなります。
例として、Slickにおけるインサート処理は以下の記述となります。
// before
Suppliers += SuppliersRow(id, name, street, created_at, updated_at)
// after
Suppliers += SuppliersRow(id, name, street)
対応策の違いとして
システムカラムを除外するパターンでは現在日時がDBに依存し
システムカラムにデフォルト引数を与えるパターンはアプリケーションの現在日時に依存します。
どちらを採用するかはプロジェクトによって検討すると良さそうです。
この記事ではシステムカラムを除外するパターンについて後述します。
デフォルト引数を付与するパターンについては以下の記事が参考になりました。
https://qiita.com/GrDolphium/items/fb14dcea37bb601cd151
手動でSchema構造を定義する場合
手動でSchema構造を定義する場合は
対象のシステムカラムを定義しないことで実現できます。
Query側のソーティング処理などでシステムカラムを参照したい場合は
別途Schemaを定義することで対応が可能であり柔軟に記述することが可能です。
class Suppliers(tag: Tag) extends Table[(Int, String, String)](tag, "SUPPLIERS") {
val id: Rep[Int] = column[Int]("ID", O.PrimaryKey)
val name: Rep[String] = column[String]("NAME")
val street: Rep[String] = column[String]("STREET")
// 定義しない val createdAt: Rep[Timestamp] = column[Timestamp]("created_at")
// 定義しない val updatedAt: Rep[Timestamp] = column[Timestamp]("updated_at")
def * = (id, name, street)
}
val suppliers = TableQuery[Suppliers]
SlickCodeGeneratorによる自動生成
Slickでは既存のデータベーススキーマから
リバースエンジニアリングを実現するSlickCodeGeneratorを使って
スキーマコードを自動生成することが可能です。
面倒なスキーマの記述処理を代替してくれる便利なライブラリなので
Scalaを使ったことのある人であれば一度は使ったことがあると思います。
スキーマコードの生成
SlickCodeGeneratorによるコード生成方法はシンプルで
SourceCodeGeneratorに対象のDBModelを渡すことで生成が可能です。
new SourceCodeGenerator(DBModels).writeToMultipleFiles(
profile,
outputFolder,
packageName
)
DBModelの概要
SlickCodeGeneratorに渡されるDBModelは以下の構造となっています。
クラス内メソッドなど詳細は省きますが
Modelは複数のテーブルを持ち
テーブルはスキーマ構造を持ち
カラムや外部キーなどのメタ情報を持っています。
case class Model(
tables: Seq[Table],
options: Set[ModelOption[_]] = Set()
)
case class Table(
name: QualifiedName,
columns: Seq[Column],
primaryKey: Option[PrimaryKey],
foreignKeys: Seq[ForeignKey],
indices: Seq[Index],
options: Set[TableOption[_]] = Set()
)
システムカラムを除外する
今回のケースでは特定のカラムをテーブルから取り除く処理を行うと良さそうです。
DBModelのコードは既存データベースを参照して生成されるため
生成後のDBModelに対してカラムの削除処理を行います。
本記事ではコマンドではシステムカラムを除外し
クエリではシステムカラムの参照を許可するようにしました。
実装は以下となります。
import slick.codegen.SourceCodeGenerator
import slick.dbio.DBIO
import slick.jdbc.JdbcBackend.Database
import slick.model.Model
import scala.concurrent.duration.Duration
import scala.concurrent.{Await, ExecutionContext, Future}
object CustomCodeGenerator extends App {
implicit val ec: ExecutionContext = ExecutionContext.global
val jdbcDriver = "com.mysql.cj.jdbc.Driver"
val profile = "slick.jdbc.MySQLProfile"
val url = ???
val outputFolder = ???
val user = ???
val password = ???
val db = Database.forURL(
url = url,
user = user,
password = password,
driver = jdbcDriver
)
private def generateModels: DBIO[Model] =
slick.jdbc.MySQLProfile
.createModel(None, ignoreInvalidDefaults = true)
.withPinnedSession
private def removeSystemColumns(model: Model): Model =
model.copy(tables =
model.tables.map(table =>
table.copy(columns =
table.columns
.filterNot(_.name == "created_at")
.filterNot(_.name == "updated_at")
)
)
)
private val codeGenerateResult: Future[Unit] = for {
commandModels <- db.run(generateModels.map(removeSystemColumns))
queryModels <- db.run(generateModels)
_ <- Future.successful(
new SourceCodeGenerator(commandModels).writeToMultipleFiles(
profile,
outputFolder,
"command"
)
)
_ <- Future.successful(
new SourceCodeGenerator(queryModels).writeToMultipleFiles(
profile,
outputFolder,
"query"
)
)
} yield ()
Await.result(codeGenerateResult, Duration.Inf)
}
上記を実行すると以下のスキーマコードが生成されます。
schema
├── command
│ ├── SuppliersTable.scala
│ ├── SystemColumnTestTable.scala
│ ├── Tables.scala
│ └── TablesRoot.scala
└── query
├── SuppliersTable.scala
├── SystemColumnTestTable.scala
├── Tables.scala
└── TablesRoot.scala
package command
// AUTO-GENERATED Slick data model for table Suppliers
trait SuppliersTable {
self:TablesRoot =>
import profile.api._
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}
/** Entity class storing rows of table Suppliers
* @param id Database column id SqlType(INT), PrimaryKey
* @param name Database column name SqlType(VARCHAR), Length(255,true)
* @param street Database column street SqlType(VARCHAR), Length(255,true) */
case class SuppliersRow(id: Int, name: String, street: String)
/** GetResult implicit for fetching SuppliersRow objects using plain SQL queries */
implicit def GetResultSuppliersRow(implicit e0: GR[Int], e1: GR[String]): GR[SuppliersRow] = GR{
prs => import prs._
SuppliersRow.tupled((<<[Int], <<[String], <<[String]))
}
/** Table description of table suppliers. Objects of this class serve as prototypes for rows in queries. */
class Suppliers(_tableTag: Tag) extends profile.api.Table[SuppliersRow](_tableTag, Some("database"), "suppliers") {
def * = (id, name, street).<>(SuppliersRow.tupled, SuppliersRow.unapply)
/** Maps whole row to an option. Useful for outer joins. */
def ? = ((Rep.Some(id), Rep.Some(name), Rep.Some(street))).shaped.<>({r=>import r._; _1.map(_=> SuppliersRow.tupled((_1.get, _2.get, _3.get)))}, (_:Any) => throw new Exception("Inserting into ? projection not supported."))
/** Database column id SqlType(INT), PrimaryKey */
val id: Rep[Int] = column[Int]("id", O.PrimaryKey)
/** Database column name SqlType(VARCHAR), Length(255,true) */
val name: Rep[String] = column[String]("name", O.Length(255,varying=true))
/** Database column street SqlType(VARCHAR), Length(255,true) */
val street: Rep[String] = column[String]("street", O.Length(255,varying=true))
}
/** Collection-like TableQuery object for table Suppliers */
lazy val Suppliers = new TableQuery(tag => new Suppliers(tag))
}
package query
// AUTO-GENERATED Slick data model for table Suppliers
trait SuppliersTable {
self:TablesRoot =>
import profile.api._
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}
/** Entity class storing rows of table Suppliers
* @param id Database column id SqlType(INT), PrimaryKey
* @param name Database column name SqlType(VARCHAR), Length(255,true)
* @param street Database column street SqlType(VARCHAR), Length(255,true)
* @param createdAt Database column created_at SqlType(TIMESTAMP)
* @param updatedAt Database column updated_at SqlType(TIMESTAMP) */
case class SuppliersRow(id: Int, name: String, street: String, createdAt: java.sql.Timestamp, updatedAt: java.sql.Timestamp)
/** GetResult implicit for fetching SuppliersRow objects using plain SQL queries */
implicit def GetResultSuppliersRow(implicit e0: GR[Int], e1: GR[String], e2: GR[java.sql.Timestamp]): GR[SuppliersRow] = GR{
prs => import prs._
SuppliersRow.tupled((<<[Int], <<[String], <<[String], <<[java.sql.Timestamp], <<[java.sql.Timestamp]))
}
/** Table description of table suppliers. Objects of this class serve as prototypes for rows in queries. */
class Suppliers(_tableTag: Tag) extends profile.api.Table[SuppliersRow](_tableTag, Some("database"), "suppliers") {
def * = (id, name, street, createdAt, updatedAt).<>(SuppliersRow.tupled, SuppliersRow.unapply)
/** Maps whole row to an option. Useful for outer joins. */
def ? = ((Rep.Some(id), Rep.Some(name), Rep.Some(street), Rep.Some(createdAt), Rep.Some(updatedAt))).shaped.<>({r=>import r._; _1.map(_=> SuppliersRow.tupled((_1.get, _2.get, _3.get, _4.get, _5.get)))}, (_:Any) => throw new Exception("Inserting into ? projection not supported."))
/** Database column id SqlType(INT), PrimaryKey */
val id: Rep[Int] = column[Int]("id", O.PrimaryKey)
/** Database column name SqlType(VARCHAR), Length(255,true) */
val name: Rep[String] = column[String]("name", O.Length(255,varying=true))
/** Database column street SqlType(VARCHAR), Length(255,true) */
val street: Rep[String] = column[String]("street", O.Length(255,varying=true))
/** Database column created_at SqlType(TIMESTAMP) */
val createdAt: Rep[java.sql.Timestamp] = column[java.sql.Timestamp]("created_at")
/** Database column updated_at SqlType(TIMESTAMP) */
val updatedAt: Rep[java.sql.Timestamp] = column[java.sql.Timestamp]("updated_at")
}
/** Collection-like TableQuery object for table Suppliers */
lazy val Suppliers = new TableQuery(tag => new Suppliers(tag))
}
コマンド側のスキーマではシステムカラムが除外され
クエリ側では参照できる状態になっていることが分かります。
おわりに
この記事ではシステムカラムをアプリケーションから
意識しない様にする方法をSlickを用いて記述しました。
この記事では紹介していませんが
SlickCodeGeneratorでは細かなカラムごとの調整も可能です。
それについては別記事で書こうと思います!
明日は @naoya_s さんによる記事です。