nがひとつ多い。

えぬなおの技術的なことを書いていくとこ。

【kotlin】kotlin製ORM Exposed小技集

はじめに

本記事は Kotlin Advent Calendar 2019の8日目の記事です。 Jetbrain社製のkotlinで書かれたORMについて話していきます。

github.com

Exposedの軽い紹介

exposedでは以下のようにクエリを書くことができます

object program : IntIdTable() {
    val name = varchar("name", 255)
    val event_id = (entityId("event_id",
        event
    ) references event.id)
    val start_at = datetime("start_at")
    val end_at = datetime("end_at")
    val created = datetime("created")
    val updated = datetime("updated")
}
program
  .select {  (program.start_at lessEq now) and (program.end_at greaterEq now)  }
  .count()

// SELECT count(*) FROM `program` WHERE `start_at` <= @now AND @now <= `end_at`

kotlinのinline関数なども相まって、 kotlinのコードに関わらず、もとのSQLにとても似ているコードを書くことができます。
可読性がとてもよく、最低限のSQLのクエリも揃っていますが、 jooqHibernate に比べるとsyntaxにまだまだ不揃いなだったり、情報がたらない時があります。
そんな漢字の、僕が初めて使ったときに困ったところと、それを切り抜ける技を紹介していきます。

ただ、困ったらまずは以下のgithubwikiをみましょう。

https://github.com/JetBrains/Exposed/wiki

やっていき

そもそもSELECT文で取り出したEntityをData Classに射影するのどうするの?

これは意外とREADMEとかパッと見で書いてなくて困りました。

// Exposed は↓のように ` IntIdTable()` や ` UUIDTable()` を継承したobjectを定義してそれをスキーマ定義とします。
object question : IntIdTable() {
    val name = varchar("name", 255).default("anonymous")
    val created = datetime("created")
}

data class Question(
    val id: EntityID<Int>,
    val name: String,
    val created: Date
)

fun findByID(id: Int): Question?= transaction {
  question
    .select { question.id eq id }
    .firstOrNull()
    // ???
}

困りました、 question.select { question.id eq id }.firstOrNull() だけだと戻り値が ResultRow? ですね。 ここから Question に戻したいわけです。 さてこの ResultRow ですが、

class ResultRow(internal val fieldIndex: Map<Expression<*>, Int>) = // 実装

コードを見てみると見てみると、ハッシュマップになっていて、キーを与えると取り出せる構造になっているようです。 このキーが、 object question : IntIdTable() でORMとして定義したテーブルのプロパティと対応しているので、それをあてこんで引き出す事となります。 つまり以下のような関数と定義してやって

fun ResultRow.toQuestion(): Question =
    Question(
        this[question.id],
        this[question.name],
        this[question.created],
    )

さっきの findById関数にこれをつなげればいいということになります。

fun findByID(id: Int): Question?= transaction {
  question
    .select { question.id eq id }
    .firstOrNull()?.let { it.toQuestion() }
}

iterableな戻り値だった場合は、

fun findByID(id: Int): List<Question>= transaction {
  question
    .selectAll()
    .map { it.toQuestion() }
}

と書けます、どちらも可読性が高くていい感じですね。

WHERE句を繰り返しANDで繋げるのをkotlinでやりたい

Exposedでwhere句を書く時には以下のように書くのが基本です。

question
  .select { question.id eq 1 }

// SELECT * FROM question WHERE `id` = 1 

しかし、WHERE句が WHERE id = 1 AND id = 2 AND id = 3だと大変です。 当然このDSL記法だと書ききれませんし、
もしこれらを生SQLでやろうとすると1番目の条件だけ WHERE で書いて、残りは AND でやらないといけないので面倒ですね。 それにkotlinで書ければ単純にコード内の型同士で値比較できるので、なるべくkotlinで書きたいです。

Exposedには andWhere という便利なメソッドがあります。

val ids = listOf(1, 2, 3)
question
  .selectAll()
  .also { prepare -> ids.map { prepare.andWhere { question.id eq it } } }

// SELECT * FROM question WHERE `id` = 1 AND  `id` = 2 AND  `id` = 3

(あくまで例なので、こういう場合普通 IN句でやるだろっていうのは置いておいてくださいw)

非常に便利ですね、このようにkotlinの得意なfunctionalな書きっぷりとExposedは非常に相性がいいです。

Exposedで生SQLを扱う

そもそもExposedで生SQLをどう扱うんだって話です。 できれば、 SELECT... みたいなSQL文の文字列からSQL文にして実行したいですね。

実はしれっとFAQにあります。

https://github.com/JetBrains/Exposed/wiki/FAQ#q-is-it-possible-to-use-native-sql--sql-as-a-string

import java.sql.ResultSet
import org.jetbrains.exposed.sql.transactions.TransactionManager

fun <T : Any> String.execAndMap(transform: (ResultSet) -> T): List<T> {
    val result = arrayListOf<T>()
    TransactionManager.current().exec(this) { rs ->
        while (rs.next()) {
            result += transform(rs)
        }
    }
    return result
}

こんな感じでやれば、MySQLのクエリの結果をバイナリとして受け取ることができます。 ・・・ java.sql.ResultSet ってなんでしょ?

docs.oracle.com

javaの昔からあるインターフェースのようで、JDBCから汎用的なSQL結果からカラム名ごとの値のゲッターを実装しているクラスのようです。 これはこれで便利なので特にラップせず使ってもいいと思います。

さてサンプルコードです。

class Question(f1: ByteArray, f2: String, f3: Date) {
    val question_id: UUID
    val name: String
    val created: Date

    init {
        question_id = UUID.nameUUIDFromBytes(f1)
        name = f2
        created = f3
    }
}

// 実際のコード
fun findAll(): QuestionAggrList = transaction {
   QuestionList(
        "SELECT * FROM question".execAndMap { rs ->
            Question(
                rs.getBytes("id"),
                rs.getString("name"),
                rs.getDate("created"),
            )
        }
    )
}

resultSetのゲッターは getDate(String!): Date! のようにnullableを強制展開する戻り値を持っているので、本番で使う場合は runCatching() などを使って安全に値を取り出すことをお勧めします。

UNION ALLやりたい

残念ながら201912月現在 Exposed には UNION ALLに該当するDAL、DSLは存在しません。 さてUNION ALLですが、要は、 縦結合 です。

sql55.com

分析用途や正規化されてないテーブル同士を1つのテーブルとして使いたい時に重宝します。 さてどうするかというと prepare生SQL実行 を両用する方法があります。

先ほどの Question テーブルに TodoQuestion テーブルと DoneQuestion テーブルがあって、 これらを UNION ALLしたいとします。

QuestionList(
  "$done_question_sql UNION ALL $todo_question_sql ORDER BY created DESC "
      .execAndMap { rs ->      
         Question(
            rs.getBytes("id"),
            rs.getString("name"),
            rs.getDate("created"),
         )
      }
)

なんかこんな感じで TodoQuestion テーブルと DoneQuestion テーブル を持ってくるSQL文を、 $todo_question_sql$done_question_sql に代入すれば良さそうです。

しかし、

val done_question_sql = 
  done_question
    .selectAll()

こんなのをやってしまうと、この時点でクエリは発火されてしまいます。 できればせめてこの DoneQuestion テーブル のSQLだけ文字列で欲しいです。 そんな時は prepareSQL メソッドをつけてあげると解決します。

val done_question_sql = 
  done_question
    .selectAll()
    .prepareSQL(QueryBuilder(false))

こうすることで、 発火されず、そのままクエリを文字列として持ってくることができます。 これで、

val done_question_sql = 
  done_question
    .selectAll()
    .prepareSQL(QueryBuilder(false))

val todo_question_sql = 
  todo_question
    .selectAll()
    .prepareSQL(QueryBuilder(false))

val result = QuestionList(
  "$done_question_sql UNION ALL $todo_question_sql ORDER BY created DESC "
      .execAndMap { rs ->      
         Question(
            rs.getBytes("id"),
            rs.getString("name"),
            rs.getDate("created"),
         )
  }
)

なるべくkotlinでコード書きながら、Exposedにないsyntaxで書くことができました。

最後に

実際 他にJava製のORMがある中、pure kotlinのチャレンジングなORMであるExposedですが、 そのふんkotlinの柔軟なsyntaxとcoroutineにより、従来にないORM体験をすることできます。

ただ、まだまだ他の jooqHibernate などのORMに比べると些か実装が足らない部分もあります。 そんなところは思い切ってPRを出すか、生SQLなどを組み合わせてできるんだということを忘れなければ、 十分使っていけるORMだなと思っております。