ExposedのDAOで任意の文字列をプライマリーキーとして使用する

# はじめに

技術評価として`kotlin`を試しています。
その中でORMとして[Exposed](https://github.com/JetBrains/Exposed)を評価しました。

[DSL](https://github.com/JetBrains/Exposed/wiki/DSL)と[DAO](https://github.com/JetBrains/Exposed/wiki/DAO)の機能をそれぞれ試しましたが、
[DAO](https://github.com/JetBrains/Exposed/wiki/DAO)を使用するうえでプライマリーキーに制約があったため、調査しました。

プライマリーキーに使用できる型には制限があり、Int, Long, UUIDの3種類のみになっています。
アプリケーションによりますが、任意の文字列をプライマリーキーとして使用したい場合の対応方法を調査、実装してみました。

# DBの準備
 docker-compose
  
```yaml:docker-compose.yml
version: '3'
services:
  mysql:
    image: mysql:8.0
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_bin --skip-character-set-client-handshake
    ports:
      - "3306:3306"
    cap_add:
      - SYS_NICE  # CAP_SYS_NICE
    environment:
      MYSQL_DATABASE: testdb
      MYSQL_ROOT_USER: root
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: test
      MYSQL_PASSWORD: test
```

# gradleの依存関係

```kotlin:build.gradle.kts
dependencies {
    implementation("org.jetbrains.exposed:exposed-core:0.28.1")
    implementation("org.jetbrains.exposed:exposed-dao:0.28.1")
    implementation("org.jetbrains.exposed:exposed-jdbc:0.28.1")
    implementation("org.jetbrains.exposed:exposed-java-time:0.28.1")
    implementation("com.zaxxer:HikariCP:3.4.5")
    runtimeOnly("mysql", "mysql-connector-java", "8.0.21")
}
```

# 接続

## 接続プール
   HikariCPを使用する。

```kotlin
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource

object DatabaseFactory {
    fun create(url: String, driver: String, user: String, password: String): HikariDataSource {
        val config = HikariConfig()
        config.driverClassName = driver
        config.jdbcUrl = url
        config.username = user
        config.password = password
        config.maximumPoolSize = 3
        config.isAutoCommit = false
        config.validate()
        return HikariDataSource(config)
    }
}
```
```kotlin
val url = "jdbc:mysql://localhost:3306/testdb?characterEncoding=utf8&connectionCollation=utf8mb4_bin&useSSL=false"
val driver = "com.mysql.cj.jdbc.Driver"
val user = "test"
val pass = "test"
Database.connect(datasource = DatabaseFactory.create(url, driver, user, pass))
```

# テーブル定義
 [DSL](https://github.com/JetBrains/Exposed/wiki/DSL)でも[DAO](https://github.com/JetBrains/Exposed/wiki/DAO)でも、テーブル定義を作成する必要があります。

## [DSL](https://github.com/JetBrains/Exposed/wiki/DSL)
  [DSL](https://github.com/JetBrains/Exposed/wiki/DSL)の場合、[Table](https://github.com/JetBrains/Exposed/blob/master/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt)クラスを継承します。

```kotlin
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.`java-time`.datetime

object ExampleTable: Table("examples") {
    val id = varchar("example_id", 255)
    val name = varchar("name", 255).nullable()
    val createdAt = datetime("created_at")
    val updatedAt = datetime("updated_at")
    val deletedAt = datetime("deleted_at").nullable()
    override val primaryKey = PrimaryKey(id)
}
```
## [DAO](https://github.com/JetBrains/Exposed/wiki/DAO)
[DAO](https://github.com/JetBrains/Exposed/wiki/DAO)の場合、`id` というカラムを持つ[IdTable](https://github.com/JetBrains/Exposed/blob/master/exposed-core/src/main/kotlin/org/jetbrains/exposed/dao/id/IdTable.kt)を継承する必要があり、プライマリーキーの型毎に抽象クラスが用意されています。
文字列をプライマリーキーにするクラスはありません。

```kotlin:org.jetbrains.exposed.dao.id.IdTable
abstract class IdTable<T:Comparable<T>>(name: String = ""): Table(name) {
    abstract val id : Column<EntityID<T>>
}

open class IntIdTable(name: String = "", columnName: String = "id") : IdTable<Int>(name) {
    override val id: Column<EntityID<Int>> = integer(columnName).autoIncrement().entityId()
    override val primaryKey by lazy { super.primaryKey ?: PrimaryKey(id) }
}

open class LongIdTable(name: String = "", columnName: String = "id") : IdTable<Long>(name) {
    override val id: Column<EntityID<Long>> = long(columnName).autoIncrement().entityId()
    override val primaryKey by lazy { super.primaryKey ?: PrimaryKey(id) }
}

open class UUIDTable(name: String = "", columnName: String = "id") : IdTable<UUID>(name) {
    override val id: Column<EntityID<UUID>> = uuid(columnName)
            .autoGenerate()
            .entityId()
    override val primaryKey by lazy { super.primaryKey ?: PrimaryKey(id) }
}
```

## 文字列をプライマリーキーとするクラスを作成する
### StringIdTable
任意の文字列をプライマリーキーにするための[IdTable](https://github.com/JetBrains/Exposed/blob/master/exposed-core/src/main/kotlin/org/jetbrains/exposed/dao/id/IdTable.kt)サブクラスを作成します

```kotlin
open class StringIdTable(name: String = "", columnName: String = "id", length: Int, defaultValueFun: (() -> String)? = null) : IdTable<String>(name) {
    override val id: Column<EntityID<String>> = if (defaultValueFun != null) {
        varchar(columnName, length).clientDefault( defaultValueFun).uniqueIndex().entityId()
    } else {
        varchar(columnName, length).uniqueIndex().entityId()
    }
    override val primaryKey by lazy { super.primaryKey ?: PrimaryKey(id) }
}
```

- 継承例

```kotlin
object ExampleTable: StringIdTable("examples", "example_id", 255) {
    val name = varchar("name", 255).nullable()
    val createdAt = datetime("created_at")
    val updatedAt = datetime("updated_at")
    val deletedAt = datetime("deleted_at").nullable()
}
```

# Entity
 [DAO](https://github.com/JetBrains/Exposed/wiki/DAO)の場合、[Entity](https://github.com/JetBrains/Exposed/blob/master/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/Entity.kt)を継承したエンティティクラスを作成する必要があり、プライマリーキーの型に応じて抽象クラスが用意されています。

```kotlin:org.jetbrains.exposed.dao.IntEntity
abstract class IntEntity(id: EntityID<Int>) : Entity<Int>(id)
abstract class IntEntityClass<out E: IntEntity>(table: IdTable<Int>, entityType: Class<E>? = null) : EntityClass<Int, E>(table, entityType)
```
```kotlin:org.jetbrains.exposed.dao.LongEntity
abstract class LongEntity(id: EntityID<Long>) : Entity<Long>(id)
abstract class LongEntityClass<out E: LongEntity>(table: IdTable<Long>, entityType: Class<E>? = null) : EntityClass<Long, E>(table, entityType)
```
```kotlin:org.jetbrains.exposed.dao.UUIDEntity
abstract class UUIDEntity(id: EntityID<UUID>) : Entity<UUID>(id)
abstract class UUIDEntityClass<out E: UUIDEntity>(table: IdTable<UUID>, entityType: Class<E>? = null) : EntityClass<UUID, E>(table, entityType)
```

## 文字列をキーとする抽象エンティティクラスを作成する

### StringEntity
```kotlin
abstract class StringEntity(id: EntityID<String>) : Entity<String>(id)
abstract class StringEntityClass<out E: StringEntity>(table: IdTable<String>, entityType: Class<E>? = null) : EntityClass<String, E>(table, entityType)
```

- 継承例

```kotlin
class Examples(id: EntityID<String>) : StringEntity(id) {
    companion object : StringEntityClass<Examples>(ExampleTable)

    var name by ExampleTable.name
    var createdAt by ExampleTable.createdAt
    var updatedAt by ExampleTable.updatedAt
    var deletedAt by ExampleTable.deletedAt
}
```

文字列をプライマリーキーにするためのクラスを作成しました。

## insert
 [DAO](https://github.com/JetBrains/Exposed/wiki/DAO)のinsertは [new](https://github.com/JetBrains/Exposed/blob/master/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityClass.kt#L230)メソッドで行います。 
 テーブル定義のdefaultValueFunを設定していれば、都度生成することも可能ですが、設定しない場合はメソッドの引数にIDを渡す必要があります。

```kotlin
val id = "randomstring"
val now = LocalDateTime.now()
Examples.new(id){
    name = "onamae"
    createdAt = now
    updatedAt = now
}
```

# まとめ
DAOには気になった制約がほかにもあり、[複数カラムのプライマリーキーができません](https://github.com/JetBrains/Exposed/issues/964)。
[EntityClass](https://github.com/JetBrains/Exposed/blob/master/exposed-dao/src/main/kotlin/org/jetbrains/exposed/dao/EntityClass.kt#L42)に定義されている`findById`メソッドでは、`id` カラムのみを決め打ちで条件にしています。

```kotlin:org.jetbrains.exposed.dao.EntityClass
/**
* Get an entity by its [id].
*
* @param id The id of the entity
*
* @return The entity that has this id or null if no entity was found.
*/
open fun findById(id: EntityID<ID>): T? = testCache(id) ?: find{table.id eq id}.firstOrNull()
```

継承してオーバーライドするなど、提供されていない機能を独自に実装することは可能です。しかし、今後のバージョンと競合し、のちのちのバージョンで苦労する可能性も否定できません。

[DSL](https://github.com/JetBrains/Exposed/wiki/DSL)を使うか、[DAO](https://github.com/JetBrains/Exposed/wiki/DAO)を使うかは判断に迷うところではあるとおもいます。

- 任意の文字列をプライマリーキーにする必要がある
- 複数のカラムを組み合わせてプライマリーキーにする必要がある

このようなテーブルの場合にはDSLを使うというのが、一つの判断基準になるのではないでしょうか。

# おまけ

## raw SQL

`for update nowait`を使いたくなった際に、DSLに機能を見つけられなかったため直接SQLを記述。

```kotlin
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.TransactionManager

fun lock(id: String) {
  ...
  val args = listOf(Pair(ExampleTable.id.columnType as ColumnType, id))
  TransactionManager.current().exec("select * from examples where example_id = ? for update nowait", args) { rs ->
    ...
  }
  ...
}
```