検証とページング#
検証は3段ある#
| 段 | クラス | 何をするか |
|---|---|---|
| 1項目 | ValidationRule |
「空でない」「1〜120 の整数」を積む |
| 1リクエスト | ValidationRules |
列ごとにルールを結びつけ、まとめて回す。validate は失敗したら 422 の例外 |
| 1ルート | ValidationExecutor |
失敗したら後続の処理を止めて 422 を返す |
下だけ、上だけ、どちらでも使えます。
ルールを組み立てる#
ValidationRule rule = new ValidationRule()
.empty()
.textLengthMax(100);
積んだ順に走り、最初に落ちたところで止まります(1項目につきエラーは1件)。
| 分類 | メソッド |
|---|---|
| 必須 | empty() / required()(同じもの) |
| 文字数 | textLength(min, max) / textLengthMin(min) / textLengthMax(max) |
| バイト数 | textByteLength(min, max[, charset]) / textByteLengthMin / textByteLengthMax(既定 UTF-8) |
| 数値 | integer() / integer(min, max) / integerMin / integerMax / number() / number(min, max) / numberMin / numberMax |
| 形式 | bool() / email() / url() / domain() / date() / date(format) / regex(regex) / enumType(Class) |
| 文字種 | characterType(CharacterType[]) / characterType(Character[]) / 両方 |
| 自作 | custom(IValidator) |
| 分岐 | insertRequired() |
補足
empty() 以外は、空を通します。
textLengthMax(100) は「入っているなら 100 文字以内」という意味で、
空文字や null はエラーにしません。必須は必ず empty() で書いてください。
empty() はキーごと送られてこないときも落とします。
補足
形の検証は、値の全体と突き合わせます。
email() url() domain() regex(...) characterType(...) は、
中にそれらしい文字列が入っているだけでは通りません——
こんにちは a@example.com です は email() で落ちます。
regex("[0-9]{4}") は「全体が4桁の数字」の意味なので、
部分一致させたいときは .* で挟んでください。
補足
email() は、メールを送る側(simplejavamail の既定の検査)が断る形を通しません。
通したアドレスへ送れずに、MQ のやり直しが重なることを防ぐためです。
ローカル部はドットが続かず、最初と最後に来ません(a..b@・.a@・a.@ は落ちます)。
ドメインのラベルは英数字とハイフンで、最初と最後はハイフンではなく、63 文字までです。最後のラベルは数字だけではいけません。
通す文字は送る側より狭く、英数字と + _ % - . だけです(引用符・空白・Name <a@…>・日本語は落ちます)。全体は 254 文字までです。
補足
bool() が通すのは true / false / 1 / 0 だけです(大小は問いません)。
yes や はい は落ちます——読み出し側が同じ規則で読むので、
ここを広げると「はい」と答えた人が false として保存されます。
注意
date() と date(format) は「読めるかどうか」しか見ていません。
2026-13-01(13月)も 2026-02-31(2月31日)も、
2026-09-12x(後ろにゴミ)も 通ります。
date() は DB の読み出しやリクエスト変換と同じ共通変換を通っているためです。
暦として正しいことまで見たいときは、regex(...) を重ねるか custom(...) を書いてください。
列にまとめる#
ValidationRules rules = new ValidationRules()
.put(Item.name, new ValidationRule().empty())
.put(Item.age, new ValidationRule().integer(1, 120));
Data request = new Data();
request.putData(Item.name, "");
request.putData(Item.age, "999");
// エラーは最初の1件で止めず、全部集める(要件 F-V-03)
Data errors = rules.errors(null, request);
Data messages = ValidationMessages.toMessages(errors);
- 項目をまたいだエラーは全部集めます(1件ずつ言われるのが、入力し直す人にはいちばん困るため)
- **送られてこなかった項目は、
empty()(required())を積んだルールだけ落とします。**ほかのルールは見ません insertRequired()を付けたルールは「登録のときだけ必須、更新は送られたときだけ見る」です(下の「登録のときだけ必須」)- 1つの列に配列が来たら、要素ごとに回します
put(rule)(列なし)で、項目に紐づかない相関チェックも書けます
生のエラーの形#
errors(...) が返すのは文言ではありません。「どの種別で落ちたか」と「そのときの設定」です。
{ "validation_type": Empty, "validation_setting": {}, "input": "" }
これを人が読む形に変えるのが ValidationMessages.toMessages(errors) で、
項目名 → メッセージの一覧になります。
{ "title": ["入力してください"], "age": ["1 以上 120 以下の整数で入力してください"] }
こつ
文言を持たずに種別で返しているので、同じ検証結果を日本語にも英語にも API のコードにも変えられます。
文言を差し替える#
ValidationMessages.put(ValidationErrorType.Empty, (type, settings) -> "required");
ValidationRules rules = new ValidationRules()
.put(Item.name, new ValidationRule().empty());
Data request = new Data();
request.putData(Item.name, "");
assertEquals(List.of("required"),
ValidationMessages.toMessages(rules.errors(null, request)).get("name"));
落とし穴
ValidationMessages は static でグローバルです。テストで差し替えたら
finally で ValidationMessages.reset() してください。
忘れると、あとから走ったテストだけが落ちます。
止めるか、一覧を受け取るか#
| メソッド | 通らなかったとき |
|---|---|
rules.validate(db, data) |
ValidationException(422)を投げて止まる。戻り値は無い |
rules.errors(db, data) |
エラーの一覧(Data)を返す。止めない。通れば空 |
ふつうは validate を文として書きます。
rules.validate(db, context.request().bodyAll()); // 通らなければここで 422
db.insert(...);
何もしなければ枠組みが 422 で返し、本文は {"validation": {"項目名": ["メッセージ"]}} + 送られてきた入力値です
(ValidationExecutor と同じ形)。例外なので error(...) フックは通ります(エラー処理)。
ValidationException は HttpException の子で、errors() で一覧を取り出せます。
一覧を見て自分で分岐したいときは errors(...) です。複数の束をまとめるなら Validator.validate(db, data, rules...) / Validator.errors(db, data, rules...) です。
登録のときだけ必須#
ValidationRules rules = new ValidationRules()
.put(Item.name, new ValidationRule().insertRequired().empty())
.insertRequestChecker(req -> req.getBoolean("is_insert"));
Data update = new Data();
update.put("is_insert", false);
assertTrue(rules.errors(null, update).isEmpty(), "更新なのに必須になっている");
Data insert = new Data();
insert.put("is_insert", true);
assertFalse(rules.errors(null, insert).isEmpty(), "登録なのに必須になっていない");
「登録リクエストかどうか」の判定は insertRequestChecker に渡します。
同じ判定が各バリデータの isInsertRequest にも届きます。
複数行を検証する#
ValidationRules rules = new ValidationRules()
.put(Item.name, new ValidationRule().empty());
Data ok = new Data();
ok.putData(Item.name, "あ");
Data ng = new Data();
ng.putData(Item.name, "");
List<Data> errors = rules.errors(null, List.of(ok, ng, ok));
assertEquals(1, errors.size());
assertEquals(2, errors.getFirst().getInt("index"), "行番号が違う");
エラーのある行だけ返り、各行に index が入ります(1 始まり)。
validate(db, list) は、通らない行が1つでもあれば ValidationException です(例外の errors() は {"rows": [...]})。
ルートにかける#
ValidationExecutor を継承して validate だけ書きます。
private class Failing extends ValidationExecutor {
@Override
protected void validate (WebContext context) {
log.add("validate");
addError("title", "入力してください");
}
}
ルートではいちばん先に積みます。
JimbleApp app = new JimbleApp() {
{
post("/items", context -> {
context.addExecutor(new Failing());
context.addExecutor(new UseCase());
});
}
};
失敗すると、こうなります。
| ステータス | 422 |
| 本文 | {"validation": {"項目名": ["メッセージ"]}} + 送られてきた入力値 |
| 後続の Executor | 実行されない(捨てられる) |
error(...) フック |
通らない(例外を投げていないため。エラー処理) |
ValidationRules の結果をそのまま積むこともできます。
addErrors(rules.errors(db, context.request().bodyAll()));
補足
1.x の validate(...) は一覧を返すだけで、戻り値を捨てるとエラーがあっても素通りしていました。
2.0 で void にし、通らなければ例外にしました。一覧が要るところは errors(...) に書き換えます(2.0 への移行)。
補足
ValidationExecutor は WebContext をフィールドに持ちません。
validate(WebContext) の引数で受け取ります。
インスタンスを使い回したときに、前のリクエストのコンテキストに書き込む事故を避けるためです。
注意
列定義からルールは自動生成されません。
生成されたテーブルクラス(Post.title など)が持っているのは
型・NULL 可否・主キーだけで、varchar の桁数を持っていません。
textLengthMax を自動で導く材料が無いので、ルールは手で書きます。
ページング#
リクエストから読む#
Paging paging = context.request().paging();
| 読むキー | page / per |
| 既定 | page = 1、per = 10 |
| 上限 | paging.max_per(既定 200)。超える指定は上限に丸めます |
| 全件 | per=all。上限が効くので、既定では 200 件までです(paging.max_per = 0 で本当に全件) |
| キー名の変更 | paging.name_page / paging.name_per(設定) |
| 数値でない値 | 無視して既定を使います |
context.request().paging(20) と書けば、per が来ていないときの既定を変えられます。
Paging は1リクエストに1つで、最初に作ったものを使い回します。先に paging() を呼んだあとで paging(50) と違う件数を渡すと IllegalStateException です。件数は最初の1回で渡してください。
SELECT にかける#
Paging paging = new Paging();
paging.load(request("2", "10"), 0);
SelectListResponse response = DBUtil.getMainDB().selectListWithRowCount(select().paging(paging));
assertEquals(10, response.list().size(), "1ページ分だけ取れていない");
assertEquals(TOTAL, response.rowCount(), "総件数が LIMIT に影響されている");
assertEquals(TOTAL, paging.totalCount());
assertEquals(3, paging.maxPage(), "25 件を 10 件ずつなら 3 ページ");
assertEquals(11, paging.start());
selectListWithRowCount が総件数の COUNT も投げます。
COUNT 文は FROM / WHERE / GROUP BY / HAVING だけを写すので、
SELECT 句や ORDER BY、LIMIT には影響されません。
落とし穴
総件数を数えるのは selectListWithRowCount だけです。
ふつうの selectList(builder) では paging.totalCount() が 0 のままで、
ページャが「1 / 1 ページ」になります。
注意
生 SQL 版(selectListWithRowCount(sql, params...))は Paging を触りません。
自分で paging.set(response.list.size(), response.rowCount) を呼んでください。
取れるもの#
| メソッド | 中身 |
|---|---|
page() |
現在ページ |
per() |
1ページの件数 |
perAll() |
全件指定だったか |
totalCount() |
総件数 |
maxPage() |
総ページ数(最低 1) |
start() |
このページの先頭が何件目か(1 始まり) |
count() |
このページで実際に取れた件数 |
補足
「次がある / 前がある」のメソッドはありません。
page() > 1 と page() < maxPage() で判定してください。
レスポンスに載る#
context.request().paging() を呼んだ時点でレスポンスに載ります。自分で詰め直す必要はありません。
{
"rows": [ ... ],
"paging": { "page": 2, "per": 10, "perAll": false,
"maxPage": 3, "totalCount": 25, "start": 11, "count": 10 }
}
テンプレートからも同じものが見えます(${paging.page()})。
気をつけること#
落とし穴
per に上限がありません。?per=100000 を送られると
LIMIT 100000 がそのまま出ます。外に公開する一覧では、
paging(...) の前後で自分で上限を決めてください。
範囲外のページ(?page=999)はエラーにならず、0 件が返ります。