Exposedとは

JetBrainsが作っているKotlin向けのSQL Frameworkです。
SpringBoot+KotlinでWebアプリ作る時は今までSprindDataJpaを使っていたのですが、JPAでハマる事が多かったりと微妙だったので乗り換えを考えてみました。

リポジトリ: https://github.com/JetBrains/Exposed

ライブラリを追加する

ライブラリはこちらからダウンロードできます。
https://bintray.com/kotlin/exposed/exposed/view#
簡単にGradleでの例を載せておきます。

group 'net.orekyuu'
version '1.0-SNAPSHOT'

buildscript {
    ext {
        kotlinVersion = '1.0.2'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
    }
}

apply plugin: 'kotlin'

sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
    mavenCentral()
    maven {
        url('https://dl.bintray.com/kotlin/exposed/')
    }
}

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}"
    compile 'org.jetbrains.exposed:exposed:0.5.0'
    compile 'com.h2database:h2:1.4.192'

}

テーブルを作成する

テーブルの定義はorg.jetbrains.exposed.sql.Tableを継承したobjectを作って行います。
今回は学生テーブルを作ってみます。

object Student : Table("student") {
    val id = integer("student_id").autoIncrement().primaryKey()
    val name = varchar("name", 50)
    val grade = integer("grade")
}

Tableのコンストラクタにはテーブルの名前を渡します。
テーブルの名前を与えなければ、クラス名の接尾からTableを削除した名前が使用されます。

カラムの定義はTableに定義されているメソッドを使ってColumn型のフィールドを用意していきます。

DB上での型 メソッド
INT integer(name: String)
CHAR char(name: String)
DECIMAL(10, 10) decimal(name: String, scale: Int, precision: Int)
BIGINT long(name: String)
DATE date(name: String)
BOOLEAN bool(name: String)
DATETIME datetime(name: String)
BLOB blob(name: String)
TEXT text(name: String)
VARBINARY(1000) binary(name: String, length: Int)
UUID uuid(name: String)
VARCHAR(50) varchar(name: String, length: Int, collate: String? = null)
INT (列挙型を扱う) enumeration(name: String, klass: Class)

上の型の定義に続いてメソッドチェーンでnullの許可などの情報を追加していくことが出来ます。

メソッド 意味
autoIncrement() AUTO_INCREMENT
references(ref: Column) 引数のカラムを親とした外部キー
nullable() nullの許可
default(defaultValue: T) DEFAULT defaultValue
clientDefault(defaultValue: () -> T) よくわからない。多分コード側でデフォルト値を設定する?
index(isUnique: Boolean = false) インデックスを貼る
uniqueIndex() index(true)と同じ

次にDBに接続し、テーブルを作成します。

//DBに接続
Database.connect("jdbc:h2:mem:test", "org.h2.Driver")
transaction {
        //SQLの内容を標準出力に出す
        logger.addLogger(StdOutSqlLogger())
        //テーブルを作成
        SchemaUtils.create(Student)
}

今回はH2を使ってみました。

Database.connectでDBに接続します。
第一引数にJDBCのURL、第二引数にドライバ、次にユーザー名とパスワードが続きます。
次にSchemaUtils.createでテーブルを作成します。
引数にテーブルを定義したobjectを与えればテーブルを作ってくれます。

実行されたSQLは以下のようになっています。

CREATE TABLE IF NOT EXISTS student (
  student_id INT AUTO_INCREMENT NOT NULL,
  name       VARCHAR(50)        NOT NULL,
  grade      INT                NOT NULL,
  CONSTRAINT pk_student PRIMARY KEY (student_id)
)
CREATE INDEX student_grade ON student (grade)

Exposedで用意されているテーブルには4つ種類があります。用途に分けて使い分けるのが良いと思います。

クラス コンストラクタ 用途
Table Table(name: String = “”) テーブルの基本的なクラス
IdTable IdTable(name: String) Idを持つテーブル
IntIdTable IntIdTable(name: String = “”) 整数のidという名前のキーを持つテーブル
Alias Alias(val delegate: T, val alias: String) 使い方分からない…

基本的なCURD

Studentを例に基本的なCURDを書いてみます。

//Insert
Student.insert {
    it[name] = "orekyuu"
    it[grade] = 4
}

//Update
Student.update({ Student.name.eq("orekyuu") }) {
    it[grade] = 3
}

//Delete
//全て削除
Student.deleteAll()
//条件を指定して削除
Student.deleteWhere { Student.grade.less(2).and(Student.name.like("orekyuu")) } }

//Read
//条件を指定して検索
Student.select { Student.grade.greaterEq(2) }
//すべて取得して文字列に変換して出力
Student.selectAll()
                .map {
                    "id=${it[Student.id]}, name=${it[Student.name]}, grade=${it[Student.grade]}"
                }.forEach {
                    println(it)
                }

リレーション

学生テーブルの他に、学科テーブルを新しく作ってみます。

object Student : Table("student") {
    val id = integer("id").primaryKey().autoIncrement()
    val name = varchar("name", 50)
    val grade = integer("grade").index()
    val course = integer("course_id").references(Course.id)
}

object Course : Table() {
    val id = integer("id").primaryKey().autoIncrement()
    val name = varchar("name", 50)
}

内部結合はTable#innerJoin(otherTable: ColumnSet)
外部結合はTable#leftJoin(otherTable: ColumnSet)
というメソッドが用意されています。

Course.insert {
    it[name] = "Hoge学科"
}

val id = Course.insert {
    it[name] = "刑務所"
}[Course.id]

Student.insert {
    it[name] = "orekyuu"
    it[grade] = 4
    it[course] = id
}

Student.innerJoin(Course) .selectAll().forEach {
    println("id=${it[Student.id]}, name=${it[Student.name]}, grade=${it[Student.grade]}, course=${it[Course.name]}")
}

出力は”id=1, name=orekyuu, grade=4, course=刑務所”となります。

ちなみに実行されたSQLは以下のようになっています。

CREATE TABLE IF NOT EXISTS Course (
  id   INT AUTO_INCREMENT NOT NULL,
  name VARCHAR(50)        NOT NULL,
  CONSTRAINT pk_Course PRIMARY KEY (id)
)
CREATE TABLE IF NOT EXISTS student (
  id        INT AUTO_INCREMENT NOT NULL,
  name      VARCHAR(50)        NOT NULL,
  grade     INT                NOT NULL,
  course_id INT                NOT NULL,
  CONSTRAINT pk_student PRIMARY KEY (id)
)
CREATE INDEX student_grade ON student (grade)
ALTER TABLE student
  ADD FOREIGN KEY (course_id) REFERENCES Course (id)
INSERT INTO Course (name) VALUES ('Hoge学科')
INSERT INTO Course (name) VALUES ('刑務所')
INSERT INTO student (name, grade, course_id) VALUES ('orekyuu', 4, 2)
SELECT
  student.id,
  student.name,
  student.grade,
  student.course_id,
  Course.id,
  Course.name
FROM student
  INNER JOIN Course ON Course.id = student.course_id

DAOを使う

今まではカラムの値を取ってきて~のように面倒なコードを書いていましたが、ライブラリでDAOの機能が用意されているのでそちらを使って操作をしてみます。

まずはテーブルの定義を書き換えます。

object StudentTable : IntIdTable() {
    val name = varchar("name", 50)
    val grade = integer("grade").index()
    val course = reference("course", CourseTable)
}

object CourseTable : IntIdTable() {
    val name = varchar("name", 50)
}

referenceは外部キーを作るためのメソッドで、第一引数にカラム名、第二引数にIdTableを与えます。
referenceの他にoptReferenceというものがありますが、これはreference(name, foreign).nullable()とイコールです。

次にEntityを作成します。このEntityがDAOになります。

class Student(id: EntityID<Int>): IntEntity(id) {
    companion object: IntEntityClass<Student>(StudentTable)

    var name by StudentTable.name
    var grade by StudentTable.grade
    var course by Course.referencedOn(StudentTable.course)
}

class Course(id: EntityID<Int>): IntEntity(id) {
    companion object: IntEntityClass<Course>(CourseTable)

    var name by CourseTable.name
}

変数にデリゲートでテーブルのカラムを指定します。
外部キーの場合はEntity#referencedOn(column: Column<EntityID>)をデリゲートに指定します。
テーブルでoptReferenceとしていた場合はEntity#optionalReferencedOn(column: Column<EntityID?>)を使います。

DAOを使ったCURD

DAOを使ったものへ置き換えてみました。
Deleteで条件を指定して削除するメソッドが見つからなかったのでtable経由で行いました。間違っていたら教えて下さい修正します。

//Insert
val hoge = Course.new {
    name = "Hoge学科"
}

val taiho = Course.new {
    name = "刑務所"
}

val orekyuu = Student.new {
    name = "orekyuu"
    grade = 4
    course = taiho
}

//Update
orekyuu.name = "俺九番"

//Delete
orekyuu.delete()
Student.table.deleteWhere { StudentTable.grade.less(2).and(StudentTable.name.like("orekyuu")) }
Student.table.deleteAll()

//Read
Student.all()
Student.find { StudentTable.grade.greaterEq(2) }
Student.findById(1)

DAOで多対多を表現する

RDBで多対多を表現する時は間に中間テーブルを作ります。

今回の例ではユーザーとチームを作成します。
ユーザーは複数のチームに参加でき、チームには複数のユーザーが所属しています。
diagram

テーブルの定義とDAOは以下のように書くことが出来ます。

// ===== テーブル =====
object AppUserTable : IntIdTable("app_user"){
    val userName = varchar("user_name", 50).index()
}

object TeamUserTable : Table("team_user") {
    val team = reference("team_id", TeamTable).primaryKey()
    val user = reference("user_id", AppUserTable).primaryKey()
}

object TeamTable : IdTable<String>("team") {
    override val id = varchar("team_id", 100).primaryKey().entityId()
    val teamName = varchar("teamName", 100)
}

// ===== DAO =====
class AppUser(id: EntityID<Int>) : Entity<Int>(id) {
    companion object: EntityClass<Int, AppUser>(AppUserTable)

    var userName by AppUserTable.userName
}

class Team(id: EntityID<String>): Entity<String>(id) {
    companion object: EntityClass<String, Team>(TeamTable)
    var teamName by TeamTable.teamName
    var member by AppUser via TeamUserTable
}

DAOに生えているviaを使うことで一対多ができるので、これを使って表現します。

値の更新などは以下の様な形で書くことが出来ます。

transaction {
    val team = Team.new("teamId") {
        this.teamName = "teamName"
    }
    team.member = SizedCollection(listOf(AppUser()))
}

触った感じよさ気だったのでしばらく使ってみたいと思います。