- Kotlin
Kotlin Exposed入門|DSLとDAOの書き方とSQLの対応
Exposed は、JetBrains が作っている Kotlin 用のデータベースライブラリです。SQL に近い形で書く「DSL」と、行をオブジェクトとして扱う「DAO」の2つの書き方があります。 どちらも同じテーブル定義(object Employees : IntIdTable(...))を使い、transaction { } の中で実行します。
// DSL:SQL に近い書き方
Employees.selectAll()
.where { (Employees.salary greaterEq 300_000) and Employees.email.isNull() }
.orderBy(Employees.salary, SortOrder.DESC)
// DAO:行をオブジェクトとして扱う書き方
val e = Employee.new { name = "高橋"; salary = 350_000; dept = soumu }
e.salary = 360_000
上の DSL を SQLite で実行したときに、Exposed が出した SQL です。
SQL: SELECT employees.id, employees."name", employees.salary, employees.email, employees.dept_id FROM employees WHERE (employees.salary >= 300000) AND (employees.email IS NULL) ORDER BY employees.salary DESC
| 項目 | DSL | DAO |
|---|---|---|
| 書き方 | Employees.selectAll().where { … } |
Employee.find { … }・Employee.new { … } |
| 結果の形 | ResultRow(row[Employees.name] で読む) |
エンティティ(e.name で読む) |
| 出る SQL | 書いたとおりの1本 | 関連を読むたびに追加の SELECT が出ることがある |
| 向いている処理 | 集計・JOIN・一括更新・細かい SQL の制御 | 1件ずつの作成・更新、関連のたどり |
| 必要なモジュール | exposed-core・exposed-jdbc | 上に加えて exposed-dao |
確認環境:Exposed 1.5.1(GitHub のタグ 1.5.1、2026年10月9日のコミット)の exposed-core・exposed-jdbc・exposed-dao のソースを Kotlin コンパイラ kotlinc 2.4.20 でコンパイルし(日時型の列のパッケージを除く)、OpenJDK 21.0.10(Linux)で実行しました。データベースは GitHub リリースから取得した sqlite-jdbc 3.53.2.1(SQLite 3.53.0)と H2 2.5.252 です。この記事の SQL とエラー文は、そのとき出力されたものです。1.0 の変更点と Spring Boot の設定は、jetbrains.com の Exposed ドキュメントと、リポジトリのソース・CHANGELOG で確かめました(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Kotlin Exposed入門|DSLとDAOの書き方とSQLの対応」
最新版と 1.0 での変更点
GitHub のタグと CHANGELOG では 1.5.1 が最新です(Releases ページの「Latest」表示は 1.5.0)。1.0.0 は、0.x から大きく変わった版です。jetbrains.com の移行ガイド(Migrating from 0.61.0 to 1.0.0)の要点です。
| 変わったところ | 0.x | 1.0 以降 |
|---|---|---|
| パッケージ | org.jetbrains.exposed.sql.* |
org.jetbrains.exposed.v1.core.* |
| JDBC 用の関数 | sql パッケージ |
org.jetbrains.exposed.v1.jdbc.*(Database・SchemaUtils・selectAll・insert・update など) |
| DAO | org.jetbrains.exposed.dao.* |
org.jetbrains.exposed.v1.dao.*(EntityID は v1.core.dao.id) |
eq・less などの演算子 |
SqlExpressionBuilder の中 |
トップレベル関数。import org.jetbrains.exposed.v1.core.eq などが要る |
deleteWhere { } の中 |
it が式ビルダー |
テーブルが受け手(deleteWhere { salary less 0 }) |
| コルーチン | newSuspendedTransaction |
suspendTransaction(旧名は非推奨) |
| 非同期ドライバー | なし | R2DBC 対応(exposed-r2dbc・R2dbcDatabase) |
| Spring Boot 4 | なし | exposed-spring-boot4-starter |
| Kotlin | ― | 2.2 以上が必要 |
0.x の記事のコードは、import を直さないと 1.x ではコンパイルできません。 検索で見つかるコードの import が org.jetbrains.exposed.sql なら、0.x 向けです。
1.1〜1.5 の主な追加は、CHANGELOG によると次のとおりです。
- 1.4.0:クエリのタイムアウトの全体設定、Maven 用のマイグレーションプラグイン、
exposed-version-catalog - 1.5.0:複数行の VALUES による一括 INSERT を全データベースに拡大、Amazon Redshift の JDBC 対応
- 1.5.1:
locate()・concat()などの文字列の引数を、SQL に埋め込まずバインド変数で渡すよう修正
依存の書き方(Gradle)
jetbrains.com の Get started では、バージョンカタログ(gradle/libs.versions.toml)に次のように書いています(ドキュメントの例は 1.5.0)。
[versions]
exposed = "1.5.1"
h2 = "2.4.240"
[libraries]
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
h2 = { module = "com.h2database:h2", version.ref = "h2" }
// build.gradle.kts
dependencies {
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.dao) // DAO を使うときだけ
implementation(libs.h2) // 使うデータベースの JDBC ドライバー
}
日時の列は exposed-java-time(java.time)か exposed-kotlin-datetime を足します。バージョンカタログの書き方は、関連記事「build.gradle.kts書き方|Groovy対応表とバージョンカタログ」で扱っています。
完成コード
部署(departments)と社員(employees)の2つのテーブルを、DSL と DAO の両方で操作する1ファイルです。main の引数で sqlite と h2 を切り替えます。
// Main.kt
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import org.jetbrains.exposed.v1.dao.IntEntity
import org.jetbrains.exposed.v1.dao.IntEntityClass
import org.jetbrains.exposed.v1.dao.with
import org.jetbrains.exposed.v1.jdbc.*
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
// ---------- テーブル定義(DSL と DAO で共通) ----------
object Departments : IntIdTable("departments") {
val name = varchar("name", 50).uniqueIndex()
}
object Employees : IntIdTable("employees") {
val name = varchar("name", 50)
val salary = integer("salary")
val email = varchar("email", 100).nullable()
val dept = reference("dept_id", Departments)
}
// ---------- DAO のエンティティ ----------
class Department(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Department>(Departments)
var name by Departments.name
val employees by Employee referrersOn Employees.dept
}
class Employee(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Employee>(Employees)
var name by Employees.name
var salary by Employees.salary
var email by Employees.email
var dept by Department referencedOn Employees.dept
}
fun section(t: String) = println("\n===== $t")
fun main(args: Array<String>) {
val db = when (args.firstOrNull() ?: "sqlite") {
"h2" -> Database.connect("jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
else -> Database.connect("jdbc:sqlite:demo.db", driver = "org.sqlite.JDBC")
}
transaction(db) {
println("dialect=${db.dialect.name} version=${db.version}")
addLogger(StdOutSqlLogger)
section("テーブル作成")
SchemaUtils.create(Departments, Employees)
section("INSERT(DSL)")
val dev = Departments.insertAndGetId { it[name] = "開発" }
val sales = Departments.insertAndGetId { it[name] = "営業" }
Employees.insert {
it[name] = "田中"; it[salary] = 400_000; it[dept] = dev; it[email] = "tanaka@example.com"
}
Employees.batchInsert(listOf("佐藤" to 320_000, "鈴木" to 280_000)) { (n, s) ->
this[Employees.name] = n; this[Employees.salary] = s; this[Employees.dept] = sales
}
section("SELECT(DSL)")
Employees.selectAll()
.where { (Employees.salary greaterEq 300_000) and Employees.email.isNull() }
.orderBy(Employees.salary, SortOrder.DESC)
.forEach { println(" -> ${it[Employees.name]} ${it[Employees.salary]}") }
Employees.select(Employees.name)
.where { Employees.name like "佐%" }
.map { it[Employees.name] }
.also { println(" -> $it") }
section("UPDATE / DELETE(DSL)")
val updated = Employees.update({ Employees.name eq "鈴木" }) {
it[salary] = 300_000
}
println(" -> updated=$updated")
val deleted = Employees.deleteWhere { salary less 0 }
println(" -> deleted=$deleted")
section("JOIN と集計(DSL)")
val count = Employees.id.count()
val avg = Employees.salary.avg()
(Employees innerJoin Departments)
.select(Departments.name, count, avg)
.groupBy(Departments.name)
.orderBy(Departments.name)
.forEach { println(" -> ${it[Departments.name]} 人数=${it[count]} 平均=${it[avg]}") }
section("LEFT JOIN(社員のいない部署も出す)")
Departments.insert { it[name] = "総務" }
(Departments leftJoin Employees)
.select(Departments.name, Employees.name)
.orderBy(Departments.id)
.forEach { println(" -> ${it[Departments.name]} / ${it[Employees.name]}") }
}
section("トランザクションのロールバック")
runCatching {
transaction(db) {
addLogger(StdOutSqlLogger)
Employees.update({ Employees.name eq "田中" }) { it[salary] = 0 }
error("途中で失敗")
}
}.onFailure { println(" -> 例外: ${it.message}") }
transaction(db) {
println(" -> 田中の給与 = " + Employees.selectAll().where { Employees.name eq "田中" }.single()[Employees.salary])
}
section("DAO:作成・読み・更新")
transaction(db) {
addLogger(StdOutSqlLogger)
val soumu = Department.find { Departments.name eq "総務" }.single()
val e = Employee.new {
name = "高橋"; salary = 350_000; dept = soumu
}
println(" -> new id=${e.id.value}")
e.salary = 360_000 // 変更は次のクエリの前かトランザクションの終わりに UPDATE される
println(" -> 総務の人数=${soumu.employees.count()}")
}
section("DAO:N+1(関連を1件ずつ読む)")
transaction(db) {
addLogger(StdOutSqlLogger)
Employee.all().forEach { println(" -> ${it.name} (${it.dept.name})") }
}
section("DAO:with() で先に読む(eager loading)")
transaction(db) {
addLogger(StdOutSqlLogger)
Employee.all().with(Employee::dept).forEach { println(" -> ${it.name} (${it.dept.name})") }
}
transaction(db) { SchemaUtils.drop(Employees, Departments) }
}
以下、この完成コードを動かして出た SQL を、処理ごとに見ていきます。
テーブル定義と CREATE TABLE
IntIdTable は、id(自動採番の整数の主キー)を持つテーブルです。reference() は外部キー、nullable() は NULL を許す列、uniqueIndex() は一意の索引になります。SchemaUtils.create() が出した SQL です。
-- SQLite
SQL: CREATE TABLE IF NOT EXISTS departments (id INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR(50) NOT NULL, CONSTRAINT chk_departments_signed_integer_id CHECK (id BETWEEN -2147483648 AND 2147483647))
SQL: CREATE UNIQUE INDEX departments_name ON departments ("name")
SQL: CREATE TABLE IF NOT EXISTS employees (id INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR(50) NOT NULL, salary INT NOT NULL, email VARCHAR(100) NULL, dept_id INT NOT NULL, CONSTRAINT fk_employees_dept_id__id FOREIGN KEY (dept_id) REFERENCES departments(id) ON DELETE RESTRICT ON UPDATE RESTRICT, CONSTRAINT chk_employees_signed_integer_id CHECK (id BETWEEN -2147483648 AND 2147483647), CONSTRAINT chk_employees_signed_integer_salary CHECK (salary BETWEEN -2147483648 AND 2147483647))
-- H2
SQL: CREATE TABLE IF NOT EXISTS DEPARTMENTS (ID INT AUTO_INCREMENT PRIMARY KEY, "name" VARCHAR(50) NOT NULL)
SQL: ALTER TABLE DEPARTMENTS ADD CONSTRAINT DEPARTMENTS_NAME_UNIQUE UNIQUE ("name")
SQL: CREATE TABLE IF NOT EXISTS EMPLOYEES (ID INT AUTO_INCREMENT PRIMARY KEY, "name" VARCHAR(50) NOT NULL, SALARY INT NOT NULL, EMAIL VARCHAR(100) NULL, DEPT_ID INT NOT NULL, CONSTRAINT FK_EMPLOYEES_DEPT_ID__ID FOREIGN KEY (DEPT_ID) REFERENCES DEPARTMENTS(ID) ON DELETE RESTRICT ON UPDATE RESTRICT)
同じ定義でも、データベースごとに SQL が変わります。SQLite では、整数の列に Kotlin の Int の範囲を守る CHECK 制約が付きました(SQLite の INTEGER は 64 ビットまで入るため)。name は予約語と重なるので、どちらも "name" と引用符で囲まれました。
よく使う列の型
| 定義 | Kotlin の型 | 用途 |
|---|---|---|
integer("c")・long("c") |
Int・Long |
整数 |
varchar("c", 50)・text("c") |
String |
文字列(長さ指定あり・なし) |
bool("c") |
Boolean |
真偽値 |
decimal("c", 10, 2) |
BigDecimal |
金額など |
enumerationByName<Role>("c", 20) |
enum | enum を名前で保存 |
reference("c", Other) |
EntityID<Int> |
外部キー(optReference で NULL 可) |
uuid("c") |
kotlin.uuid.Uuid |
UUID(java.util.UUID は javaUUID) |
datetime("c")(exposed-java-time) |
LocalDateTime |
日時 |
列の後ろに付ける指定は、.nullable()・.default(値)・.clientDefault { }(Kotlin 側で値を作る)・.uniqueIndex()・.index() です。本番のテーブル変更は SchemaUtils.create ではなく、Flyway などのマイグレーションツールで管理する形が一般的です。
CRUD:DSL と SQL の対応
完成コードの DSL と、実際に出た SQL(SQLite)を並べます。
| 処理 | DSL | 出た SQL |
|---|---|---|
| 追加して id を得る | Departments.insertAndGetId { it[name] = "開発" } |
INSERT INTO departments ("name") VALUES ('開発') |
| まとめて追加 | Employees.batchInsert(list) { … } |
1件ごとの INSERT(ログ上は2行) |
| 条件で検索 | selectAll().where { … }.orderBy(…) |
SELECT … WHERE (…) AND (…) ORDER BY … DESC |
| 列を選ぶ | select(Employees.name).where { … like "佐%" } |
SELECT employees."name" FROM employees WHERE … LIKE '佐%' |
| 更新 | update({ Employees.name eq "鈴木" }) { it[salary] = 300_000 } |
UPDATE employees SET salary=300000 WHERE … |
| 削除 | deleteWhere { salary less 0 } |
DELETE FROM employees WHERE employees.salary < 0 |
===== UPDATE / DELETE(DSL)
SQL: UPDATE employees SET salary=300000 WHERE employees."name" = '鈴木'
-> updated=1
SQL: DELETE FROM employees WHERE employees.salary < 0
-> deleted=0
update と deleteWhere は、変わった行の数を返します。
ログの SQL は値を埋め込んだ形ですが、実際はバインド変数(?)で送られます。 StdOutSqlLogger は、expandArgs()(パラメーターを文字にして埋めた文)を出力するしくみです(exposed-core の SQLLog.kt)。'鈴木' のような文字が SQL に直接入るわけではありません。
WHERE で使う演算子
| Exposed | SQL |
|---|---|
a eq b・a neq b |
=・<> |
a less b・lessEq・greater・greaterEq |
<・<=・>・>= |
a like "佐%" |
LIKE |
a inList listOf(1, 2) |
IN (1, 2) |
a.between(1, 10) |
BETWEEN 1 AND 10 |
a.isNull()・a.isNotNull() |
IS NULL・IS NOT NULL |
(条件1) and (条件2)・or・not(…) |
AND・OR・NOT |
1.0 以降、これらは org.jetbrains.exposed.v1.core のトップレベル関数です。完成コードは import org.jetbrains.exposed.v1.core.* で一度に読み込んでいます。1つずつ書く場合は import org.jetbrains.exposed.v1.core.eq のようにします。
JOIN と集計
innerJoin・leftJoin は、reference() で外部キーを定義していれば、結合の条件(ON)を自動で付けます。
===== JOIN と集計(DSL)
SQL: SELECT departments."name", COUNT(employees.id), AVG(employees.salary) FROM employees INNER JOIN departments ON departments.id = employees.dept_id GROUP BY departments."name" ORDER BY departments."name" ASC
-> 営業 人数=2 平均=310000.00
-> 開発 人数=1 平均=400000.00
===== LEFT JOIN(社員のいない部署も出す)
SQL: SELECT departments."name", employees."name" FROM departments LEFT JOIN employees ON departments.id = employees.dept_id ORDER BY departments.id ASC
-> 開発 / 田中
-> 営業 / 佐藤
-> 営業 / 鈴木
-> 総務 / null
- 集計の値は、
val count = Employees.id.count()のように変数に入れておき、selectとit[count]の両方で同じものを使います。 LEFT JOINで相手が無い行は、it[Employees.name]がnullになりました。- 外部キーが無いテーブル同士や、条件を変えたいときは
Employees.join(Departments, JoinType.INNER, onColumn = …, otherColumn = …)の形で書きます。
トランザクション
Exposed の操作は、すべて transaction { } の中で行います。ブロックが最後まで進むとコミット、例外が出るとロールバックです。
===== トランザクションのロールバック
SQL: UPDATE employees SET salary=0 WHERE employees."name" = '田中'
-> 例外: 途中で失敗
-> 田中の給与 = 400000
UPDATE は実行されましたが、そのあとの例外でロールバックされ、給与は 400000 のままでした。
入れ子の transaction は既定で外側と同じ
transaction { } の中にもう一つ transaction { } を書き、内側で例外を出して runCatching で受け止めた結果です(H2)。
-- useNestedTransactions = false(既定)
SQL: INSERT INTO DEPARTMENTS ("name") VALUES ('外側')
SQL: INSERT INTO DEPARTMENTS ("name") VALUES ('内側')
内側の例外: 内側で失敗
useNestedTransactions=false → 残った行: [内側, 外側]
-- useNestedTransactions = true
SQL: INSERT INTO DEPARTMENTS ("name") VALUES ('外側')
内側の例外: 内側で失敗
useNestedTransactions=true → 残った行: [外側]
既定では、内側の transaction は外側と同じトランザクションです。 内側で例外が出ても、外側で受け止めると、内側の INSERT もコミットされました。受け止めずに例外が外側まで届けば、jetbrains.com のドキュメントのとおり、外側の変更ごとロールバックされます。内側だけを取り消したいときは、Database.connect(…, databaseConfig = DatabaseConfig { useNestedTransactions = true }) にします(db.useNestedTransactions = true でも設定できます)。このときは内側の変更だけがロールバックされました(内側は別のトランザクションの扱いなので、外側で付けた addLogger のログは出ていません)。
コルーチンの中では suspendTransaction { } を使います(1.0 で newSuspendedTransaction から名前が変わりました)。
DAO:エンティティで書く
DAO は、IntEntity を継承したクラスに、列を by でつなぎます。Employee.new { } で追加、プロパティへの代入で更新です。
===== DAO:作成・読み・更新
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments."name" = '総務'
SQL: INSERT INTO employees ("name", salary, dept_id) VALUES ('高橋', 350000, 3)
-> new id=4
SQL: UPDATE employees SET salary=360000 WHERE id = 4
SQL: SELECT COUNT(*) FROM employees WHERE employees.dept_id = 3
-> 総務の人数=1
e.salary = 360_000 を書いた行では SQL は出ず、次のクエリ(人数の COUNT)の直前に UPDATE が出ました。DAO は変更をためておき、まとめて書き込みます。
| DAO の書き方 | 意味 |
|---|---|
Employee.new { … } |
1件追加 |
Employee.findById(1) |
主キーで1件(無ければ null) |
Employee.find { Employees.salary greater 300_000 } |
条件で検索 |
Employee.all() |
全件 |
e.salary = 1 |
更新(あとでまとめて UPDATE) |
e.delete() |
削除 |
var dept by Department referencedOn Employees.dept |
多対一(社員 → 部署) |
val employees by Employee referrersOn Employees.dept |
一対多(部署 → 社員) |
N+1 と with()
DAO で関連をたどると、1件ごとに SELECT が出ることがあります(N+1 問題)。
===== DAO:N+1(関連を1件ずつ読む)
SQL: SELECT employees.id, employees."name", employees.salary, employees.email, employees.dept_id FROM employees
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments.id = 1
-> 田中 (開発)
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments.id = 2
-> 佐藤 (営業)
-> 鈴木 (営業)
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments.id = 3
-> 高橋 (総務)
===== DAO:with() で先に読む(eager loading)
SQL: SELECT employees.id, employees."name", employees.salary, employees.email, employees.dept_id FROM employees
SQL: SELECT departments.id, departments."name" FROM departments WHERE departments.id IN (1, 2, 3)
社員4人で、部署の SELECT が3本出ました(同じ部署の2人目はキャッシュから読まれ、SQL は出ていません)。.with(Employee::dept) を付けると、部署は IN (1, 2, 3) の1本にまとまりました。 一覧画面のように関連を何件も読む処理では、with() を付けるか、DSL の JOIN で書きます。1件のエンティティの関連を先に読むときは、ドキュメントでは Employee.findById(1)?.load(Employee::dept) の load() が紹介されています。
DSL と DAO の使い分け
| 場面 | 向いている方 | 理由 |
|---|---|---|
| 一覧・検索・集計の画面 | DSL | 出る SQL が1本で、列も選べる |
| 一括の UPDATE・DELETE | DSL | 1本の SQL で済む。DAO だと1件ずつ |
| 1件の作成・編集の画面 | DAO | プロパティへの代入で書ける |
| 関連をたどる処理 | DAO+with() |
with() を忘れると N+1 |
| SQL を細かく決めたい | DSL | 書いたものがそのまま SQL になる |
同じテーブル定義を共有しているので、1つのアプリの中で DSL と DAO を混ぜて使えます。 完成コードでも、DAO の Department.find { Departments.name eq "総務" } の条件は DSL の式です。
Spring Boot との組み合わせ
Spring Boot 4 では exposed-spring-boot4-starter、Spring Boot 3 では exposed-spring-boot-starter を使います。jetbrains.com のドキュメントには、Spring Boot 3 向けのスターターは次のメジャー版で外す予定と書かれています。リポジトリのサンプル(exposed-spring)のアプリケーションクラスです。
import org.jetbrains.exposed.v1.spring.boot4.autoconfigure.ExposedAutoConfiguration
import org.springframework.boot.autoconfigure.ImportAutoConfiguration
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration
import org.springframework.boot.runApplication
@SpringBootApplication
@ImportAutoConfiguration(
value = [ExposedAutoConfiguration::class],
exclude = [DataSourceTransactionManagerAutoConfiguration::class]
)
class ExposedSpringApplication
fun main() {
runApplication<ExposedSpringApplication>()
}
サービスでは transaction { } を書かず、Spring の @Transactional を使います。スターターが Exposed 用の SpringTransactionManager を登録するためです。
@Service
@Transactional
class EmployeeService {
fun findRich(): List<String> =
Employees.selectAll().where { Employees.salary greaterEq 300_000 }.map { it[Employees.name] }
}
application.properties で使える設定です(スターターのソースと設定メタデータで確かめたもの)。
| プロパティ | 既定 | 意味 |
|---|---|---|
spring.datasource.url など |
― | 接続先(Spring Boot の標準の設定) |
spring.exposed.show-sql |
false |
実行した SQL をログに出す |
spring.exposed.generate-ddl |
false |
起動時に Table を継承したテーブルを作る |
spring.exposed.excluded-packages |
空 | 自動作成から外すパッケージ |
DatabaseConfig の Bean を定義すると、useNestedTransactions などの設定を変えられます。Spring Boot の @Transactional の注意点は、関連記事「Spring @Transactionalが効かない原因8選|ロールバックの直し方」で扱っています。Boot 4 への移行は、関連記事「Spring Boot 4移行の手順|3から4の変更点一覧とチェックリスト」で扱っています。
よくあるエラー(実際に出た原文)
H2 で、わざと間違えて実行したときの例外です。
| 原因 | 例外とメッセージ |
|---|---|
transaction { } の外で SELECT した |
IllegalStateException: No transaction in context. |
transaction から Query を返し、外で toList() した |
IllegalStateException: No transaction in context. |
エンティティを外へ持ち出し、外で e.dept を読んだ |
IllegalStateException: Can't init value outside the transaction |
select(Employees.name) の結果で salary を読んだ |
IllegalStateException: Employees.salary is not in record set |
| 一意の列に同じ値を入れた | ExposedSQLException: …JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation: … |
| 無い部署の id を入れた | ExposedSQLException: …Referential integrity constraint violation: "FK_EMPLOYEES_DEPT_ID__ID: …" |
| 必須の列(salary)を入れ忘れた | ExposedSQLException: …NULL not allowed for column "SALARY" |
H2 で db.dialect.name を transaction の外で読んだ |
IllegalStateException: No transaction in context. |
Queryは遅延評価です。selectAll()は SQL を組み立てるだけで、toList()・map・forEachで初めて実行されます。transaction の中でmap { … }してリストにして返すと、外でも使えました。- DAO のエンティティを画面やAPIに返すときは、transaction の中で data class に詰め替えると、外での読み込みエラーを避けられます。
- 必須の列の入れ忘れは、Exposed ではなくデータベースが止めます。メッセージの形はデータベースごとに違います。
よくある質問
Q. Exposed と JPA(Spring Data JPA)は何が違いますか。
A. Exposed の DSL は SQL をそのまま Kotlin で書く形で、エンティティの状態管理がありません。DAO は JPA に近いですが、アノテーションではなく object のテーブル定義と by の委譲で書きます。JPA は関連記事「Spring Data JPA使い方|クエリメソッド命名一覧と@Query実例」で扱っています。
Q. SQL を直接書けますか。
A. transaction { exec("SELECT …") { rs -> … } } で書けます。ラムダに JDBC の ResultSet が渡ります。値は exec(sql, args = listOf(IntegerColumnType() to 1)) のように ? で渡せます(exposed-jdbc の JdbcTransaction.kt による)。
Q. SQLite でファイルを使わずにメモリーで動かせますか。
A. jdbc:sqlite::memory: で試すと、同じ transaction の中では読めましたが、次の transaction では no such table: departments になりました。Exposed は transaction ごとに接続を取り、SQLite のメモリー上のデータベースは接続ごとに別になるためです。この記事ではファイル(demo.db)を使いました。H2 は jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1 で、接続が閉じても中身が残ります。
Q. by の委譲や object の書き方がよく分かりません。
A. 関連記事「Kotlin by 委譲の使い方 クラス委譲とby lazy・監視」と「Kotlin companion object|static相当とobject」で扱っています。
まとめ
- Exposed は DSL(SQL に近い)と DAO(オブジェクト) の2つの書き方。テーブル定義は共通
- 1.0 で import が
org.jetbrains.exposed.v1.*に変わった。eqなどはトップレベル関数。0.x の記事のコードは import の書き換えが要る - GitHub のタグ・CHANGELOG の最新は 1.5.1
- すべての操作は
transaction { }の中。例外でロールバック。入れ子は既定で外側と同じで、useNestedTransactions = trueで内側だけ取り消せる Queryは遅延評価。transaction の中でリストにしてから返す- DAO の関連は N+1 になりやすい。
with()でIN (…)の1本にまとまった - Spring Boot 4 は
exposed-spring-boot4-starterと@Transactional。spring.exposed.show-sql=trueで SQL が出る
参考・出典
確認日はいずれも 2026年10月13日です。
- Exposed「Get started with Exposed」:https://www.jetbrains.com/help/exposed/get-started-with-exposed.html
- Exposed「Migrating from 0.61.0 to 1.0.0」:https://www.jetbrains.com/help/exposed/migration-guide-1-0-0.html
- Exposed「Spring Boot integration」:https://www.jetbrains.com/help/exposed/spring-boot-integration.html
- Exposed「Working with transactions」:https://www.jetbrains.com/help/exposed/transactions.html
- Exposed「DAO API」:https://www.jetbrains.com/help/exposed/dao-crud-operations.html
- JetBrains/Exposed(リポジトリ・CHANGELOG):https://github.com/JetBrains/Exposed
- JetBrains/Exposed Releases:https://github.com/JetBrains/Exposed/releases
- xerial/sqlite-jdbc Releases:https://github.com/xerial/sqlite-jdbc/releases
- H2 Database Engine Releases:https://github.com/h2database/h2database/releases
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Kotlin Exposed入門|DSLとDAOの書き方とSQLの対応」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。