【kotlin】kotlin製ORM Exposed小技集
はじめに
本記事は Kotlin Advent Calendar 2019の8日目の記事です。 Jetbrain社製のkotlinで書かれたORMについて話していきます。
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のクエリも揃っていますが、 jooq
や Hibernate
に比べるとsyntaxにまだまだ不揃いなだったり、情報がたらない時があります。
そんな漢字の、僕が初めて使ったときに困ったところと、それを切り抜ける技を紹介していきます。
ただ、困ったらまずは以下のgithubのwikiをみましょう。
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
ってなんでしょ?
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ですが、要は、 縦結合
です。
分析用途や正規化されてないテーブル同士を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体験をすることできます。
ただ、まだまだ他の jooq
や Hibernate
などのORMに比べると些か実装が足らない部分もあります。
そんなところは思い切ってPRを出すか、生SQLなどを組み合わせてできるんだということを忘れなければ、
十分使っていけるORMだなと思っております。