jimble

SQL DSL#

文字列で SQL を書くこともできますが、DSL のほうが型が効きます。

引く#

List<Data> posts = db.selectList(
	SQL.select()
		.from(Post.instance())
		.where(Post.published.eq(true))
		.orderBy(Post.created_at.desc())
		.limit(20)
);

limitoffset があります。select() に何も渡さなければ全列です。列を選ぶなら渡します。

SQL.select(Post.id, Post.title).from(Post.instance())

条件#

.where(Post.id.eq(1L))
.where(Post.title.like("%jimble%"))
.where(Post.created_at.ge(from).and(Post.created_at.lt(to)))
.where(Post.id.in(List.of(1L, 2L, 3L)))

where を複数回呼ぶと AND で繋がります。

結合#

SQL.select()
	.from(Post.instance())
	.left(Comment.instance()).on(Comment.post_id.eq(Post.id))
	.where(Post.id.eq(id))

内部結合は inner(...) です。on() は直前の結合に付きます。

結果はテーブル名でネストするので、row.getData("comment") で取れます。

入れる・直す・消す#

long id = db.insert(
	SQL.insert(Post.instance())
		.value(Post.title, title)
		.value(Post.created_at, new Date())      // アプリ側の時計
);

int updated = db.update(
	SQL.update(Post.instance())
		.set(Post.published, true)
		.where(Post.id.eq(id))
);

int deleted = db.delete(
	SQL.delete(Post.instance()).where(Post.id.eq(id))
);

補足

時刻はどちらの時計で入れるかを決めてください。new Date()アプリ側の時計、 Dsl.now()DB 側の時計です。

.value(Post.created_at, Dsl.now())       // DB 側の時計

アプリを複数台で動かすなら DB 側にそろえるほうが安全です—— 台ごとに時計がずれていると、あとから入れた行のほうが古いことが起こり、 作成日時で並べた一覧が入れ替わります。

insert は採番された ID を返します。ID が要らないときは insertNoReturnKey のほうが速いです。

送られた項目だけ直す、という書き方はこうなります。

patch("/posts/{id}", context -> {

	long id = id(context);
	BlogApp.findPost(id);

	Data request = context.request().bodyAll();

	UpdateBuilder builder = SQL.update(Post.instance());
	boolean hasChange = false;

	if (request.containsKey("title")) {
		builder.set(Post.title, request.getString("title"));
		hasChange = true;
	}

	if (request.containsKey("body")) {
		builder.set(Post.body, request.getString("body"));
		hasChange = true;
	}

	if (request.containsKey("published")) {
		builder.set(Post.published, request.getBoolean("published"));
		hasChange = true;
	}

	if (!hasChange) {
		throw new HttpException(400, "変える項目がありません(title / body / published)");
	}

	int updated = BlogExample.db().update(builder.where(Post.id.eq(id)));

	context.response().json("updated", updated);

});

関数を使う#

Dsl に関数が並んでいます。どれも MySQL と PostgreSQL の両方で動きます。

SQL.select(Dsl.count(), Dsl.max(Post.created_at)).from(Post.instance())

CASE WHEN のように値としても列としても使えるものは、 select に直接は渡せません。SelectQuery で包んでください。

SQL.select(new SelectQuery().dsl(Dsl.caseWhen()...)).from(...)

包まずに渡すと、列ではなくバインドされる値として扱われます。

一覧#

系統 ある関数
文字列 lower / upper / trim / ltrim / rtrim / length / byteLength / substring / replace / left / right / lpad / rpad / reverse / repeat / concat / concatWs / md5 / locate
数値 abs / mod / power / sqrt / sign / exp / ln / log10 / ceiling / floor / round / truncate / greatest / least
日付 now / curDate / curTime / date / year / month / day / hour / minute / second / quarter / dayOfWeek / dayOfYear / weekOfYear / dateAdd / dateSub / dateDiff / secondsBetween / unixTimestamp / fromUnixTime / secondsAgoyearsAfter
条件・型変換 caseWhen / ifThenElse / ifnull / coalesce / nullif / cast / castDecimal / regexp / regexpIgnoreCase
集約 count / countDistinct / sum / sumDistinct / min / max / avg / stddev / variance / groupConcat / groupConcatDistinct
ウィンドウ rowNumber / rank / denseRank / nTile / lag / lead / firstValue / lastValue / over
JSON・地理空間 jsonExtract / jsonUnquote / stGeomFromText / stDistanceSphere / stWithin / match
// 名前を整えて、空なら「(無名)」
Dsl.coalesce(Dsl.trim(Post.title), "(無名)").as("title")

// 月ごとの件数
SQL.select(Dsl.year(Post.created_at).as("y"), Dsl.month(Post.created_at).as("m"), Dsl.count())
    .from(Post.instance())
    .groupBy(Dsl.year(Post.created_at), Dsl.month(Post.created_at))

// 30 日後
Dsl.dateAdd(Post.created_at, 30, DateUnit.DAY)

// タグをまとめて1つの文字列に
Dsl.groupConcat(Tag.name, "/").as("tags")

単位(DateUnit)と型(CastType)は enum です。文字列では渡せません。 SQL にそのまま入るところなので、外から来た文字列を通せないようにしてあります。

まとめた値で絞る(having#

**集計した値は、そのまま条件にできます。**列と同じ書き方です。

SQL.select(
        Department.id
        , Dsl.sum(Request.amount).as("total"))
    .from(Request.instance())
    .groupBy(Department.id)
    // 合計が 50 万を超える部署だけ
    .having(Dsl.sum(Request.amount).ge(500_000L));

CASE の条件にも置けます。

new SelectQuery().dsl(Dsl.caseWhen()
        .when(Dsl.sum(Request.amount).ge(500_000L)).then("大")
        .elseCase("小")).as("size")

書けるのは比べるものだけです (eq / not / gt / lt / ge / le / between / is_null / is_not_null)。 likecontains は足していません。集計に対しては、書けても意味がないためです。

落とし穴

**where には書けません。**SQL の決まりで、WHERE は集計より前に評価されます。 まとめたあとの値で絞るのは having の仕事です。

ウィンドウ関数#

行をまとめずに、まとめた結果を各行に付けます。GROUP BY と違って行が減りません。

SQL.select(
        Sale.shop_id
        , Sale.amount
        // 店ごとの売上順位
        , Dsl.rank().partitionBy(Sale.shop_id).orderBy(Sale.amount.desc()).as("rank")
        // 累計
        , Dsl.over(Dsl.sum(Sale.amount))
            .orderBy(Sale.sold_at.asc())
            .rowsBetween(WindowFrame.unboundedPreceding(), WindowFrame.currentRow())
            .as("total"))
    .from(Sale.instance());

Dsl.over(...) には集約(sum / count / avg など)を渡します。 別名や計算を中に入れると例外になります(OVER のあとに付けてください)。

[!WARNING] ウィンドウ関数は where にも having にも書けません。 SQL の決まりで、評価されるのがどちらより後だからです。 Dsl.rowNumber().over(...).eq(1) と書くと組み立てた時点で例外になります。 順位で絞るなら、いったん副問い合わせで出してから外側で絞ってください。

in に空の一覧は渡せません#

where(Site.id.in(List.of()))   // ← 組み立てた時点で例外

IN () は構文エラーです。黙って DB に投げると、 エラーメッセージからどこで空を渡したのか辿れません(要件 F-D-07)。

「空なら条件を外す」ことはしません。 in(空) は「どれにも当たらない」で、条件を外すと全件です。 どちらの意味かは呼び出し側にしか分からないので、呼び出し側で分けてください。

if (ids.isEmpty()) {
	return List.of();          // 「どれにも当たらない」
}
where(Site.id.in(ids));

製品の違いで気をつけること#

名前が同じでも意味が違うものは DSL が揃えます。

どう揃えているか
length 文字数です(MySQL の LENGTH はバイト数)。バイト数は byteLength
dayOfWeek 日曜が 1(PostgreSQL の DOW は 0 なので +1 しています)
weekOfYear ISO 週(MySQL の WEEK は既定が ISO ではないので WEEKOFYEAR
second 小数秒は切り捨て(PostgreSQL は素だと四捨五入して 60 を返すことがあります)
unixTimestamp 接続のタイムゾーンで読みます(PostgreSQL の timestamp は素だと UTC 扱い)
stddev / variance 標本STDDEV_SAMP / VAR_SAMP
concat 1つでも NULL なら NULL(PostgreSQL では || になります)

揃えていないものもあります。

違い
greatest / least **MySQL は1つでも NULL なら NULL、PostgreSQL は NULL を無視します。**NULL が入りうるなら ifnull で埋めてから渡してください
regexp 正規表現の方言が違います(MySQL 8 は ICU、PostgreSQL は POSIX)。^ $ [] + は同じですが、\d は片方でしか効きません。[0-9] と書いてください
0 除算・負の長さ MySQL は NULL を返し、PostgreSQL は落ちますmod(x, 0)left(x, -1) など)
数値を文字列関数に渡す MySQL は暗黙に変換し、PostgreSQL は「関数が無い」で落ちます。cast を挟んでください
groupConcat MySQL は group_concat_max_len(既定 1024 バイト)を超えると黙って切ります
cast で数にできない文字列 MySQL は 0 を返し、PostgreSQL は落ちます

その製品に無いものは、SQL を組み立てたところで例外DialectException)になります。 DB を使う にまとめてあります。

DB 製品が違うとき#

同じコードのまま、db.xxx.product に応じた SQL が出ます。 識別子の囲み(`")、ON DUPLICATE KEY UPDATEON CONFLICTINSERT IGNOREON CONFLICT DO NOTHINGRAND()RANDOM() などは ビルダーが吸収します。

その製品で書けないものは、SQL を組み立てたところで例外DialectException)になります。 どれが書けないかは DB を使う にまとめてあります。

// PostgreSQL では DialectException(to_char は書式の言語が違う)
SQL.select(Dsl.dateFormat(Post.created_at, "%Y-%m-%d")).from(Post.instance());

まとめて流す#

List<Integer> counts = db.executeBatch(builderList);   // 件数のリスト
List<Long>    ids    = db.insertBatch(builderList);    // 採番値のリスト

2つで戻り値が違います。executeBatch は件数(List<Integer>)、 insertBatch は採番値(List<Long>)です。 件数のほうは DB.isBatchSuccess(list) で全部通ったか見られます。

落とし穴

積んだビルダーの SQL は、全部同じでなければなりません。 1本の文にパラメータだけを積み替えて流すためです。 value() を積む順が途中で変わると SQL も変わるので、 ループの中で順を揃えてください。 揃っていなければ DB_998 を立てて null が返ります (直すまでは、例外も警告も無しに値が横にずれて入っていました)。

大きい結果#

全部をリストに載せたくないときは、カーソルで1行ずつ受け取ります。

try (DB db = BlogExample.db();
	 ResultSetFetcher fetcher = new ResultSetFetcher()) {

	db.selectListWithFetcher(fetcher, SQL.select().from(Post.instance()));

	for (Data row : fetcher) {
		// 1行ずつ来る
	}

}

落とし穴

DB も一緒に畳んでください。 ふつうの SQL は1文ごとにコネクションをプールへ返すので、DB を閉じ忘れても 何も残りません(DBUtil.getMainDB() を使い捨てにする書き方は、これで成り立っています)。 **カーソルだけは違います。**読み終わるまで ResultSet を開けておく必要があるので、 close() までコネクションを握ったままです。 fetcher だけ畳んで DB を畳まないと、呼ばれるたびにプールから1本ずつ消えます—— SQL は成功し、例外も警告も出ないので、プールが枯れるまで誰も気づきません。 畳み忘れは実行の終わりに拾いますが(要件 F-D-16)、そのときはエラーログが出ます