本文へスキップ
Hironobu Iga

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

Ktor と組み合わせてよく使われる ORM である Exposed で、テーブル定義をどう書くかをまとめた記事です。

公開日

この記事は別サイトにも掲載しています。 https://iganin.hatenablog.com/entry/2020/04/13/004744

はじめに

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 図を添付します。なお、主キーにはナチュラルキーではなくサロゲートキーを使用しています。また、従業員と部署の関係性は多対多のため中間テーブルを作成しています。

companies / departments / employees / departments_employees の 4 テーブルからなる ER 図

※ 本題とはそれますが、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()
  // references() で外部キーを設定します。 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 は選択肢としてありなのではないでしょうか。

参考

  • Exposed - wiki
    • Exposed の wiki です。基本的にはこのページを調べながら開発することになるかと思います。
  • Exposed - GitHub
    • Exposed の GitHub リポジトリです。
  • PlantUML
    • PlantUML の(おそらく)公式 HP です。
  • Doma2