jimble

DB を使う#

設定する#

application.conf にデータソースを書きます。

db {
	blog_example {
		main     = true
		driver   = "org.mariadb.jdbc.Driver"
		url      = "jdbc:mariadb://127.0.0.1:3306/blog_example?useUnicode=true&characterEncoding=UTF-8"
		url      = ${?DB_URL}
		username = "blog"
		username = ${?DB_USER}
		password = ""
		password = ${?DB_PASSWORD}

		maximumPoolSize      = 10
		connection_pool_type = "hikari"
	}
}

codegen {
	package = "db"
}

秘密はファイルに書かないでください。 ${?ENV_NAME} の形にしておくと、 環境変数があればそちらが勝ちます。無ければ前の行の値が残ります。

ドライバは jimble-db が MySQL / MariaDB と PostgreSQL の両方を持っているので、 ふつうは足す必要はありません。

DB 製品を選ぶ#

product に製品を書くと、SQL ビルダーがその製品用の SQL を出します。

db {
	blog_example {
		main     = true
		product  = "postgresql"
		driver   = "org.postgresql.Driver"
		url      = "jdbc:postgresql://127.0.0.1:5432/blog_example"
		username = "blog"
		password = "blog"
		password = ${?DB_PASSWORD}
	}
}

落とし穴

**${?ENV} の行だけを書かないでください。**環境変数が無いときにキーごと消えます (上の書き方は「まず既定値、あれば環境変数で上書き」の2行組です)。 パスワードが消えたまま PostgreSQL に繋ぐと、返ってくるのは The server requested SCRAM-based authentication, but no password was provided. で、 設定の話に見えません。

データベースがまだ無いとき#

create_database_sql を書いておくと、繋がらなかったときに1度だけ流します。

db {
	blog_example {
		...
		create_database_sql = "CREATE DATABASE blog_example"
	}
}

書かなければ何もしないので、手で作ってあるなら要りません。 本番では権限を持たない利用者で繋ぐほうが普通なので、開発と CI のためのものです。

書ける値 製品
mysql(既定)/ mariadb MySQL 8.x / MariaDB
postgresqlpostgrespgsql PostgreSQL 16 以降

省略すると mysql です。知らない値を書くと起動時に落ちます

アプリのコードは1行も変わりません。

// この1行が、product に応じて
//   MySQL      SELECT `post`.`id` AS `post__id` ... LIMIT ? OFFSET ?
//   PostgreSQL SELECT "post"."id" AS "post__id" ... LIMIT ? OFFSET ?
// になります
SQL.select().from(Post.instance()).where(Post.id.eq(1L));

データソースごとに違う製品を書けます(メインは MySQL、集計用は PostgreSQL、など)。

その製品で書けないもの#

書けないものは、SQL を組み立てたところで DialectException になります。 実行してから製品の構文エラーで落ちる、ということはありません。

ビルダー PostgreSQL
Dsl.match(...)(全文検索) 書けない。to_tsvector は語彙の分割もスコアも別物なので、黙って置き換えません
Dsl.dateFormat(col, "%Y-%m-%d") 書けない。to_char は書式の言語が違うので、置き換えると例外にならずに違う文字列が返ります。Java 側で整えてください
Dsl.jsonExtract(col, "$.a[0]") 配列・ワイルドカード・引用符つきキーは書けません。$.a.b の形だけ

逆に、名前だけ違うものは黙って揃えますRAND / RANDOMIFNULL / COALESCETRUNCATE / TRUNC など)。Dsl.concat(...) は PostgreSQL では || になります (PostgreSQL の concat() は NULL を空文字として飲み込むので、 名前だけ置き換えると MySQL と結果が変わります)。

どうしてもその製品の書き方を使いたいときは Dsl.freeSql(...) で逃げられます。

気をつけること#

  • **空間関数の座標の順が違います。**MySQL 8 の SRID 4326 は緯度・経度、 PostGIS は経度・緯度です。jimble は吸収しないので、両方で使うなら自分で合わせてください
  • マイグレーションの SQL(conf/migration/...)は自分で書いたものがそのまま流れます。 両方の製品で動かすなら、001_xxx.mysql.sql / 001_xxx.postgresql.sqlファイル名の接尾辞で分けます(接尾辞なしはどちらでも当たります)。 詳しくは 製品ごとに SQL を分ける

起動時に繋ぐ#

**設定を書いただけでは繋がりません。**アプリの main で明示的に呼びます。

public static void main (String[] args) {

	Migration.install();                                // 起動時マイグレーション(要るなら。DBUtil.load より前)

	// 繋がらなければ false。ここで止めます
	if (!DBUtil.load(Conf.conf().config(), App.class)) {
		throw new IllegalStateException("DB を読み込めませんでした(このすぐ上のログに原因が出ています)");
	}

	JimbleServer.start(new App());

}

DBUtil.load の2つ目はクラスパスの起点です(マイグレーション SQL をここから探します)。 自分のアプリのクラスを渡してください。

落とし穴

戻り値を捨てないでください。DBUtil.load は繋がらなくても例外を投げません (原因をログに出して false を返します)。捨てるとサーバーは起動してしまい、 最初にリクエストが来たところで落ちます。 DBUtil.getMainDB() が「DB が読み込めていません」と言って落ちるので迷子にはなりませんが、 起動した時点で気づけるほうが早いです。

落とし穴

入口が複数あるなら、この並びを1か所にまとめてください。 Web・バッチ・スケジューラで別々に書くと、どれか1つだけ古くなります。 テストからも同じものを呼びます(落とし穴 / テスト)。

終わるときは DBUtil.stop() です(Web はグレースフルシャットダウンが呼びます。 バッチやテストでは自分で呼びます)。

データソースが複数あるとき#

db {
	main_db { main = true, ... }
	log_db  { ... }
}

DBUtil.getDB("log_db") で別のデータソースが取れます。 main = true のものは DBUtil.getMainDB() です(引数なしの getDB() はありません)。

トップレベルに並べたものは、1本ごとに独立した扱いを受けます。

  • conf/migration/<データソース名>/ のマイグレーションが当たります
  • codegen が db/<データソース名>/ を作ります
  • migration / db_lock / db_value / db_cache もそちらに作られます

設定を書き写さずにもう1本ぶら下げたいだけなら、subs を使います。

db {
	main_db {
		main = true
		...
		subs {
			archive_db { }
		}
	}
}

db.newSubDB("archive_db") で取ります。

落とし穴

接続もトランザクションも共有しません。subs は親とは別のプールです。 同じデータベースを指していても別の接続なので、親のトランザクションの中で newSubDB に書いても一緒には戻りません。片方だけ残ります。 親から設定を引き継ぐ、それ以上のことはしていないと思ってください。

落とし穴

subs にはマイグレーションも codegen も当たりません。 データソースごとの処理は、トップレベルの db { } しか回らないためです。 だから subs に置けるのは「アプリが自分で作る作業用テーブル」までです。 表と型が要るもの(別 DB の監査ログなど)は、トップレベルにもう1本書いてください。 また subs が別のデータベースを指すなら、そのデータベースが先に在ることsubs は親のデータソースを作る途中で繋ぎにいくので、あとに書いた トップレベルの create_database_sql は間に合いません (failed create subs datasource で起動が止まります)。

2本立てと subs を両方書いたものが examples/approval-data にあります。

テーブル定義を生成する#

./gradlew codegen

DB のスキーマから、テーブルごとのクラスを生成します。

Post.id       // Column
Post.title
Post.instance()   // Table

io.jimble.db プラグインを入れると、migrate → codegen → compileJava が繋がります。 DDL を書いて起動すれば、コードのほうが追いつきます。

plugins {
	id("io.jimble.db")
}

生成されたクラスはリポジトリに入れてください。 生成物をコミットしないと、DB が無い環境でビルドできなくなります。

引く#

Data row = db.select(
	SQL.select()
		.from(Post.instance())
		.where(Post.id.eq(1L))
);

// SELECT の結果はテーブル名でネストする(要件 F-D-02)
String title = row.getData("post").getString("title");

// Column で引けば、途中の文字列が出てこない
String same = row.getString(Post.title);

SELECT の結果はテーブル名でネストします。 postcomment を join したとき、両方に id があっても衝突しません。

Column で引けば、文字列のキーがコードに出てきません。 row.extractTableData(Post.instance()) で、1テーブルぶんだけ取り出せます。

エラーの見方#

try (DB db = BlogExample.db()) {

	List<Data> rows = db.selectList(SQL.select().from(Post.instance()));

	/*
	 * DB のエラーは例外ではなく戻り値で返る(要件 F-D-11)。
	 * select 系は null、更新系は -1。
	 */
	if (rows == null) {
		Log.error("引けませんでした: " + db.getError());
		return;
	}

}

DB のエラーは例外ではなく戻り値で返ります。 select 系は nullinsert-1update / delete-1 です。 理由は db.getError() に入っています。

マイグレーション#

conf/migration/<スキーマ名>/001_xxx.sql に置きます。

# --- !Ups

CREATE TABLE `note` (
	`id`         BIGINT UNSIGNED AUTO_INCREMENT COMMENT 'ID' PRIMARY KEY,
	`title`      VARCHAR(200)  NOT NULL COMMENT 'タイトル',
	`created_at` DATETIME      NOT NULL COMMENT '作成日時'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='メモ';

# --- !Downs

DROP TABLE `note`;
./gradlew migrate

適用済みのものは記録されるので、二度は走りません。 複数のサーバーが同時に起動しても、ロックを取るので1つしか流れません。

いつ何が走るか、失敗したときにどうなるか、生成される型の対応は マイグレーションとコード生成 にまとめてあります。