【Kotlin】Exposedでテーブル定義を実装する

はじめに

Server Side で Kotlinを使用する際に候補として上がるフレームワークとしてSpringとKtorがあります。 SpringはJavaの頃から親しまれているWeb Frameworkです。それに対し、Ktorは最近できたKotlinベースのWeb Frameworkであり、CoroutineなどKotlinの機能が活用されています。

Spring採用時に使用するORMとしてDoma2などが有名ですが、Ktorの場合はExposedがよく使われるようです。最近、ktor + Exposedで開発を行っており、Exposedを使用してテーブル定義を作成することがありましたので、備忘もかね記載します。

書くこと

  • Exposedを使用したテーブル定義

書かないこと

  • Exposedの導入方法
  • データベースとの接続方法
  • ※このあたりはすでに記事がいくつかあるためそちらをご参照ください。チュートリアルもわかりやすいです。

環境設定

以下の環境を使用しています。

  • Exposed 0.23.1

内容

使用するライブラリ

以下のライブラリを使用しています。

  • org.jetbrains.exposed: exposed-core
  • org.jetbrains.exposed: exposed-dao
  • org.jetbrains.exposed: exposed-jdbc
  • org.jetbrains.exposed: exposed-java-time ※1

  • ※1 Exposedのgetting started を見ると core, dao, jdbcのみしか記載がありません。後述しますが、生成日、更新日などをカラムとして持たせたい場合にdatetimeやdate、timestampを指定したいかと思います。それらはcore, dao, jdbcには含まれていません。そのため、 java-timeやjodatimeなどを別途implementationする必要があります。

テーブル定義例

UML

以下に簡単なテーブル定義の例を記載します。会社テーブルがあり、会社には1つ以上の部署が紐づきます。一つの部署には0人以上の従業員がおり、従業員は複数部署に所属する可能性があるとします。以下にPlantUMLで簡単に作成したER図を添付します。なお、主キーにはナチュラルキーではなくサロゲートキーを使用しています。また、従業員と部署の関係性は多対多のため中間テーブルを作成しています。

f:id:Iganin:20200413002501p:plain
ER Diagram

※本題とはそれますが、PlantUMLはER図などのUMLを書く際に非常に重宝しましたのでご存じない方はこの機会に一度調べてみてください。上記の図もPlantUMLで5分程度で作成しています。

実装

上記のテーブルを実際に実装してみます。まずはCompaniesテーブルを作成し概要をコメントで記載します。

// tableはobjectで定義します。 Table("name")のname部分にDBでのテーブルの名称を記載します。
object Companies: Table("companies") {
  // 型名("name")のname部分にテーブルでのカラム名を記載します。
  // .autoincrement()を使用すると生成時に自動的に1ずつincrementされます。
  val id = long("id").autoIncrement()
  // 文字列にはchar, varchar, textが使用できます。
  // varchar使用時には文字数の指定が必要です。
  val name = varchar("name", 255)
  // 生成日にdatetimeを使用しています。他にdate, timestampが使用できます。
  // dateはyyyy-MM-ddのように日付までのみ保持し、datetimeはyyyy-MM-dd HH:mm:ssSSSSSSを保持します。
  // .default()によって値が明示的に入力されなかった場合のデフォルト値が設定されます。下記の例では現在時刻が入ります。
  val createdAt = datetime("created_at").default(LocalDateTime.now())
  val updatedAt = datetime("updated_at").default(LocalDateTime.now())
  // 論理削除された日付を入力とします。
  // カラムの値は何も指定しないとnot nullとなりますが、 nullable()を付与することでnull許容となります。
  val deletedAt = datetime("deleted_at").nullable()

  // primaryKeyをoverrideすることでテーブルの主キーを決めることができます。
  override val primaryKey = PrimaryKey(id, name = "pk_company_id")
}

long("id")のように定義すると Column<T> 型の変数が生成されます。これがテーブルにおけるカラムに相当します。 ここでTableを使用しprimaryKeyを自身で定義していますが、 IntIdTableLongIdTableを継承すると EntityID<Int>EntityID<Long>をidとして持ち、主キーとして指定された状態のテーブルを生成できます。

他のテーブルの定義を記載していきます。created_at, updated_at, deleted_atは冗長となるため省略します。

object DepartmentsEmployees: Table("departments_employees") {
  val id = long("id").autoIncrement()
  // referencies()で外部キーを設定します。 fkName = ""  によって任意の名前を外部キーにつけることができます。
  // 外部キーのアップデート時、削除時の制約を明示的につけることができます。デフォルトは ReferenceOption.RESTRICTです。
  // 外部キー制約において外部キーとして参照されているキーを持つレコードが更新されたり削除された場合の挙動を示します。
  // 一例ですが、RESTRICTは外部キーとして参照されているレコードは外部キーの参照元のレコードが全てなくならない限り削除することができません。
  val departmentsId = long("department_id").index("idx_department_id").references(Departments.id, fkName = "fk_department_id", onUpdate = ReferenceOption.CASCADE, onDelete = ReferenceOption.RESTRICT)
  val employeesId = long("employee_id").index().references(Employees.id, fkName = "fk_emploee_id")

  override val primaryKey = PrimaryKey(id, name = "pk_departments_employees_id") 
}

object Departments: Table("departments") {
  val id = long("id").autoIncrement()
  // indexによってインデックスを生成することができます。index(name)のname部分を入力することで任意の名前を付与できます。
  // 外部キーを辿ってレコードを引っ張ることが多いため、一般的に外部キーにindexを貼るようです。(理解が間違っていましたらご指摘ください)
  val companyId = long("company_id").index("idx_company_id").references(Comapnies.id)

  val name = varchar("name", 255)
  override val primaryKey = PrimaryKey(id, name = "pk_department_id") 
}

object Employees: Table("emploees") {
  val id = long("id").autoIncrement()
  
  val familyName = varchar("family_name", 255)
  val givenName = varchar("given_name", 255)
  override val primaryKey = PrimaryKey(id, name = "pk_employee_id") 
}

以下まとめと上記で記載できなかったことです。

  • referencesで外部キーを付与します。 onUpdated, onDeletedで外部制約を明示的に指定できます。 (CASCADE, SET_NULL, RESTRICT, NO_ACTIONがあります)
  • indexでインデックスを作成します。uniqueIndex()とすることでユニーク制約をつけることができます。
  • (long("fkId").references("FkEmtity.id")).nullable() とすることで外部制約を付与しながらnull許容なカラムを生成できます。

以上雑多ではありますが、現状テーブル作成周りで学んだことを記載しました。

まとめ

Spring + Doma2など Doma2 をORMと使用することも検討しましたが、 Kotlinのサポートが実験的であるため不安がありました。またDaoのインターフェースをJavaで定義する必要があるようです。 Exposedは100% Kotlinで作成されており、プロジェクト全体をKotlinで統一したいと考えた際に非常に魅力的に映りました。 まだDML側の部分の理解が追いついていませんが、今のところ非常に直感的に書くことができ良いなというのが感想です。 サーバーサイドの開発をJVM言語で行う場合はKtor + Exposedは選択肢としてありなのではないでしょうか。

参考