Kotlin Exposed パターン は何をしますか?
JetBrains Exposed ORMを使用したデータベースアクセスのための包括的なパターン集。DSLクエリ、DAO、トランザクション、本番環境対応の設定を網羅します。
いつ使用するか
- Exposedを使用したデータベースアクセスのセットアップ
- Exposed DSLまたはDAOを使用したSQLクエリの記述
- HikariCPを使用したコネクションプーリングの構成
- Flywayを使用したデータベースマイグレーションの作成
- Exposedを使用したリポジトリパターンの実装
- JSONカラムと複雑なクエリの処理
仕組み
Exposedは、DSL(直接的なSQLライクな式)とDAO(エンティティライフサイクル管理)の2つのクエリスタイルを提供します。HikariCPは、HikariConfigを介して構成された再利用可能なデータベース接続のプールを管理します。Flywayは、起動時にバージョン管理されたSQLマイグレーションスクリプトを実行し、スキーマを同期させます。すべてのデータベース操作は、コルーチンセーフティとアトミック性のためにnewSuspendedTransactionブロック内で実行されます。リポジトリパターンは、Exposedクエリをインターフェースの背後にラップすることで、ビジネスロジックをデータ層から疎結合に保ち、テストではインメモリH2データベースを使用できます。
例
DSLクエリ
suspend fun findUserById(id: UUID): UserRow? =
newSuspendedTransaction {
UsersTable.selectAll()
.where { UsersTable.id eq id }
.map { it.toUser() }
.singleOrNull()
}
DAOエンティティの使用
suspend fun createUser(request: CreateUserRequest): User =
newSuspendedTransaction {
UserEntity.new {
name = request.name
email = request.email
role = request.role
}.toModel()
}
HikariCP構成
val hikariConfig = HikariConfig().apply {
driverClassName = config.driver
jdbcUrl = config.url
username = config.username
password = config.password
maximumPoolSize = config.maxPoolSize
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
validate()
}
データベースセットアップ
HikariCPコネクションプーリング
// DatabaseFactory.kt
object DatabaseFactory {
fun create(config: DatabaseConfig): Database {
val hikariConfig = HikariConfig().apply {
driverClassName = config.driver
jdbcUrl = config.url
username = config.username
password = config.password
maximumPoolSize = config.maxPoolSize
isAutoCommit = false
transactionIsolation = "TRANSACTION_READ_COMMITTED"
validate()
}
return Database.connect(HikariDataSource(hikariConfig))
}
}
data class DatabaseConfig(
val url: String,
val driver: String = "org.postgresql.Driver",
val username: String = "",
val password: String = "",
val maxPoolSize: Int = 10,
)
Flywayマイグレーション
// FlywayMigration.kt
fun runMigrations(config: DatabaseConfig) {
Flyway.configure()
.dataSource(config.url, config.username, config.password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.load()
.migrate()
}
// アプリケーション起動
fun Application.module() {
val config = DatabaseConfig(
url = environment.config.property("database.url").getString(),
username = environment.config.property("database.username").getString(),
password = environment.config.property("database.password").getString(),
)
runMigrations(config)
val database = DatabaseFactory.create(config)
// ...
}
マイグレーションファイル
-- src/main/resources/db/migration/V1__create_users.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
role VARCHAR(20) NOT NULL DEFAULT 'USER',
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);
テーブル定義
DSLスタイルのテーブル
// tables/UsersTable.kt
object UsersTable : UUIDTable("users") {
val name = varchar("name", 100)
val email = varchar("email", 255).uniqueIndex()
val role = enumerationByName<Role>("role", 20)
val metadata = jsonb<UserMetadata>("metadata", Json.Default).nullable()
val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone)
val updatedAt = timestampWithTimeZone("updated_at").defaultExpression(CurrentTimestampWithTimeZone)
}
object OrdersTable : UUIDTable("orders") {
val userId = uuid("user_id").references(UsersTable.id)
val status = enumerationByName<OrderStatus>("status", 20)
val totalAmount = long("total_amount")
val currency = varchar("currency", 3)
val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone)
}
object OrderItemsTable : UUIDTable("order_items") {
val orderId = uuid("order_id").references(OrdersTable.id, onDelete = ReferenceOption.CASCADE)
val productId = uuid("product_id")
val quantity = integer("quantity")
val unitPrice = long("unit_price")
}
複合テーブル
object UserRolesTable : Table("user_roles") {
val userId = uuid("user_id").references(UsersTable.id, onDelete = ReferenceOption.CASCADE)
val roleId = uuid("role_id").references(RolesTable.id, onDelete = ReferenceOption.CASCADE)
override val primaryKey = PrimaryKey(userId, roleId)
}
DSLクエリ
基本的なCRUD
// 挿入
suspend fun insertUser(name: String, email: String, role: Role): UUID =
newSuspendedTransaction {
UsersTable.insertAndGetId {
it[UsersTable.name] = name
it[UsersTable.email] = email
it[UsersTable.role] = role
}.value
}
// IDによる選択
suspend fun findUserById(id: UUID): UserRow? =
newSuspendedTransaction {
UsersTable.selectAll()
.where { UsersTable.id eq id }
.map { it.toUser() }
.singleOrNull()
}
// 条件付き選択
suspend fun findActiveAdmins(): List<UserRow> =
newSuspendedTransaction {
UsersTable.selectAll()
.where { (UsersTable.role eq Role.ADMIN) }
.orderBy(UsersTable.name)
.map { it.toUser() }
}
// 更新
suspend fun updateUserEmail(id: UUID, newEmail: String): Boolean =
newSuspendedTransaction {
UsersTable.update({ UsersTable.id eq id }) {
it[email] = newEmail
it[updatedAt] = CurrentTimestampWithTimeZone
} > 0
}
// 削除
suspend fun deleteUser(id: UUID): Boolean =
newSuspendedTransaction {
UsersTable.deleteWhere { UsersTable.id eq id } > 0
}
// 行マッピング
private fun ResultRow.toUser() = UserRow(
id = this[UsersTable.id].value,
name = this[UsersTable.name],
email = this[UsersTable.email],
role = this[UsersTable.role],
metadata = this[UsersTable.metadata],
createdAt = this[UsersTable.createdAt],
updatedAt = this[UsersTable.updatedAt],
)
高度なクエリ
// 結合クエリ
suspend fun findOrdersWithUser(userId: UUID): List<OrderWithUser> =
newSuspendedTransaction {
(OrdersTable innerJoin UsersTable)
.selectAll()
.where { OrdersTable.userId eq userId }
.orderBy(OrdersTable.createdAt, SortOrder.DESC)
.map { row ->
OrderWithUser(
orderId = row[OrdersTable.id].value,
status = row[OrdersTable.status],
totalAmount = row[OrdersTable.totalAmount],
userName = row[UsersTable.name],
)
}
}
// 集計
suspend fun countUsersByRole(): Map<Role, Long> =
newSuspendedTransaction {
UsersTable
.select(UsersTable.role, UsersTable.id.count())
.groupBy(UsersTable.role)
.associate { row ->
row[UsersTable.role] to row[UsersTable.id.count()]
}
}
// サブクエリ
suspend fun findUsersWithOrders(): List<UserRow> =
newSuspendedTransaction {
UsersTable.selectAll()
.where {
UsersTable.id inSubQuery
OrdersTable.select(OrdersTable.userId).withDistinct()
}
.map { it.toUser() }
}
// LIKEとパターンマッチング — ワイルドカードインジェクションを防ぐため、ユーザー入力を常にエスケープする
private fun escapeLikePattern(input: String): String =
input.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
suspend fun searchUsers(query: String): List<UserRow> =
newSuspendedTransaction {
val sanitized = escapeLikePattern(query.lowercase())
UsersTable.selectAll()
.where {
(UsersTable.name.lowerCase() like "%${sanitized}%") or
(UsersTable.email.lowerCase() like "%${sanitized}%")
}
.map { it.toUser() }
}
ページネーション
data class Page<T>(
val data: List<T>,
val total: Long,
val page: Int,
val limit: Int,
) {
val totalPages: Int get() = ((total + limit - 1) / limit).toInt()
val hasNext: Boolean get() = page < totalPages
val hasPrevious: Boolean get() = page > 1
}
suspend fun findUsersPaginated(page: Int, limit: Int): Page<UserRow> =
newSuspendedTransaction {
val total = UsersTable.selectAll().count()
val data = UsersTable.selectAll()
.orderBy(UsersTable.createdAt, SortOrder.DESC)
.limit(limit)
.offset(((page - 1) * limit).toLong())
.map { it.toUser() }
Page(data = data, total = total, page = page, limit = limit)
}
バッチ操作
// バッチ挿入
suspend fun insertUsers(users: List<CreateUserRequest>): List<UUID> =
newSuspendedTransaction {
UsersTable.batchInsert(users) { user ->
this[UsersTable.name] = user.name
this[UsersTable.email] = user.email
this[UsersTable.role] = user.role
}.map { it[UsersTable.id].value }
}
// Upsert(競合時に挿入または更新)
suspend fun upsertUser(id: UUID, name: String, email: String) {
newSuspendedTransaction {
UsersTable.upsert(UsersTable.email) {
it[UsersTable.id] = EntityID(id, UsersTable)
it[UsersTable.name] = name
it[UsersTable.email] = email
it[updatedAt] = CurrentTimestampWithTimeZone
}
}
}
DAOパターン
エンティティ定義
// entities/UserEntity.kt
class UserEntity(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<UserEntity>(UsersTable)
var name by UsersTable.name
var email by UsersTable.email
var role by UsersTable.role
var metadata by UsersTable.metadata
var createdAt by UsersTable.createdAt
var updatedAt by UsersTable.updatedAt
val orders by OrderEntity referrersOn OrdersTable.userId
fun toModel(): User = User(
id = id.value,
name = name,
email = email,
role = role,
metadata = metadata,
createdAt = createdAt,
updatedAt = updatedAt,
)
}
class OrderEntity(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<OrderEntity>(OrdersTable)
var user by UserEntity referencedOn OrdersTable.userId
var status by OrdersTable.status
var totalAmount by OrdersTable.totalAmount
var currency by OrdersTable.currency
var createdAt by OrdersTable.createdAt
val items by OrderItemEntity referrersOn OrderItemsTable.orderId
}
DAO操作
suspend fun findUserByEmail(email: String): User? =
newSuspendedTransaction {
UserEntity.find { UsersTable.email eq email }
.firstOrNull()
?.toModel()
}
suspend fun createUser(request: CreateUserRequest): User =
newSuspendedTransaction {
UserEntity.new {
name = request.name
email = request.email
role = request.role
}.toModel()
}
suspend fun updateUser(id: UUID, request: UpdateUserRequest): User? =
newSuspendedTransaction {
UserEntity.findById(id)?.apply {
request.name?.let { name = it }
request.email?.let { email = it }
updatedAt = OffsetDateTime.now(ZoneOffset.UTC)
}?.toModel()
}
トランザクション
サスペンドトランザクションのサポート
// 良い: コルーチンサポートのために newSuspendedTransaction を使用
suspend fun performDatabaseOperation(): Result<User> =
runCatching {
newSuspendedTransaction {
val user = UserEntity.new {
name = "Alice"
email = "[email protected]"
}
// このブロック内のすべての操作はアトミックです
user.toModel()
}
}
// 良い: セーブポイントを使ったネストされたトランザクション
suspend fun transferFunds(fromId: UUID, toId: UUID, amount: Long) {
newSuspendedTransaction {
val from = UserEntity.findById(fromId) ?: throw NotFoundException("User $fromId not found")
val to = UserEntity.findById(toId) ?: throw NotFoundException("User $toId not found")
// 引き落とし
from.balance -= amount
// 入金
to.balance += amount
// 両方成功するか、両方失敗するか
}
}
トランザクション分離
suspend fun readCommittedQuery(): List<User> =
newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_READ_COMMITTED) {
UserEntity.all().map { it.toModel() }
}
suspend fun serializableOperation() {
newSuspendedTransaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) {
// クリティカルな操作のための最も厳格な分離レベル
}
}
リポジトリパターン
インターフェース定義
interface UserRepository {
suspend fun findById(id: UUID): User?
suspend fun findByEmail(email: String): User?
suspend fun findAll(): List<User>
suspend fun save(user: User): User
suspend fun delete(id: UUID)
}
実装
class ExposedUserRepository : UserRepository {
override suspend fun findById(id: UUID): User? = newSuspendedTransaction {
UserEntity.findById(id)?.toModel()
}
override suspend fun findByEmail(email: String): User? = newSuspendedTransaction {
UserEntity.find { UsersTable.email eq email }.firstOrNull()?.toModel()
}
override suspend fun findAll(): List<User> = newSuspendedTransaction {
UserEntity.all().map { it.toModel() }
}
override suspend fun save(user: User): User = newSuspendedTransaction {
val entity = if (user.id != null) {
UserEntity.findById(UUID.fromString(user.id))?.apply {
name = user.name
email = user.email
role = user.role
} ?: throw NotFoundException("User with id ${user.id} not found")
} else {
UserEntity.new {
name = user.name
email = user.email
role = user.role
}
}
entity.toModel()
}
override suspend fun delete(id: UUID) {
newSuspendedTransaction {
UserEntity.findById(id)?.delete() ?: throw NotFoundException("User with id $id not found")
}
}
}
JSONカラムの処理
カスタムカラムタイプ
// カスタムJSONカラムタイプの定義
class JsonbColumn<T>(private val columnName: String, private val serializer: KSerializer<T>) : ColumnType() {
override fun sqlType(): String = "JSONB"
override fun valueFromDB(value: Any): T? {
val json = when (value) {
is String -> value
is PGobject -> value.value
else -> throw IllegalArgumentException("Unexpected value: $value")
}
return Json.Default.decodeFromString(serializer, json ?: "null")
}
override fun valueToDB(value: Any?): Any? {
if (value == null) return null
@Suppress("UNCHECKED_CAST")
return Json.Default.encodeToString(serializer, value as T)
}
override fun notNullValueToDB(value: Any): Any {
@Suppress("UNCHECKED_CAST")
return Json.Default.encodeToString(serializer, value as T)
}
}
// テーブルでの使用例
object EventsTable : UUIDTable("events") {
val eventType = varchar("event_type", 100)
val payload = jsonb<JsonElement>("payload", Json.Default)
val createdAt = timestampWithTimeZone("created_at").defaultExpression(CurrentTimestampWithTimeZone)
private fun jsonb<T>(name: String, json: Json) = registerColumn<T>(name, JsonbColumn(name, json.serializersModule.serializer()))
}
JSONBカラムのクエリ
// JSONBカラムの挿入とクエリ
suspend fun createEvent(type: String, payload: JsonElement) = newSuspendedTransaction {
EventsTable.insert {
it[eventType] = type
it[this@createEvent.payload] = payload
}
}
suspend fun findEventsByType(type: String): List<Event> = newSuspendedTransaction {
EventsTable.selectAll()
.where { EventsTable.eventType eq type }
.map { row ->
Event(
id = row[EventsTable.id].value,
eventType = row[EventsTable.eventType],
payload = row[EventsTable.payload],
createdAt = row[EventsTable.createdAt]
)
}
}
パフォーマンスに関する考慮事項
N+1クエリ問題を避ける
// 悪い: N+1クエリ — 各ユーザーの注文をフェッチするために個別のクエリを実行
suspend fun getUsersWithOrdersBad(): List<UserWithOrders> = newSuspendedTransaction {
UserEntity.all().map { user ->
UserWithOrders(
user = user.toModel(),
orders = user.orders.map { it.toModel() } // ユーザーごとに個別クエリ
)
}
}
// 良い: 結合を使用してすべてのデータを一度に取得
suspend fun getUsersWithOrdersGood(): List<UserWithOrders> = newSuspendedTransaction {
(UsersTable innerJoin OrdersTable)
.selectAll()
.map { row ->
UserWithOrders(
user = row.toUser(),
orders = listOf(row.toOrder()) // 結合結果からのデータ
)
}
.groupBy { it.user.id }
.map { (_, userWithOrders) ->
UserWithOrders(
user = userWithOrders.first().user,
orders = userWithOrders.flatMap { it.orders }
)
}
}
バッチ処理
// (前のセクションからの継続)
suspend fun processLargeDataset() = newSuspendedTransaction {
var offset = 0L
val batchSize = 1000
while (true) {
val batch = UsersTable.selectAll()
.limit(batchSize)
.offset(offset)
.map { it.toUser() }
if (batch.isEmpty()) break
// バッチを処理
processBatch(batch)
offset += batchSize
}
}
テスト
// インメモリH2データベースを使用したテスト
class UserRepositoryTest {
private lateinit var database: Database
private lateinit var repository: UserRepository
@BeforeEach
fun setUp() {
database = Database.connect(
url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver"
)
// テーブルを作成
transaction {
SchemaUtils.create(UsersTable)
}
repository = ExposedUserRepository()
}
@Test
fun `should create and find user`() = runBlocking {
val user = repository.save(User(name = "Test", email = "[email protected]"))
val found = repository.findById(user.id!!)
assertEquals(user.name, found?.name)
}
@AfterEach
fun tearDown() {
transaction {
SchemaUtils.drop(UsersTable)
}
}
}
ベストプラクティス
-
トランザクション管理: 常に
newSuspendedTransactionを使用して、コルーチンセーフなトランザクションを確保します。特に指定がない限り、分離レベルはTRANSACTION_READ_COMMITTEDを使用します。 -
接続プール: 本番環境ではHikariCPを使用し、プールサイズやタイムアウトなどの設定を適切に調整します。
-
マイグレーション: スキーマのバージョン管理にFlywayを使用し、マイグレーションは冪等にし、必要に応じてベースラインを設定します。
-
リポジトリパターン: ビジネスロジックをデータアクセスから分離し、テスタビリティを向上させるためにリポジトリパターンを実装します。
-
パフォーマンス: N+1クエリ問題を回避し、大規模なデータセットにはバッチ処理を使用します。
-
セキュリティ: LIKEクエリでユーザー入力をエスケープして、ワイルドカードインジェクションを防ぎます。プレースホルダーを使用してSQLインジェクションを防止します。
-
テスト: シンプルで高速なテストのためにインメモリH2データベースを使用します。各テスト実行前にテストデータベースがクリーンな状態であることを確認します。