Validation and paging#
Validation comes in three levels#
| Level | Class | What it does |
|---|---|---|
| One field | ValidationRule |
Stack up "not empty", "an integer from 1 to 120" |
| One request | ValidationRules |
Bind rules to columns and run them in one pass |
| One route | ValidationExecutor |
On failure, stop everything downstream and return 422 |
Use only the lower levels, or only the top one. Either works.
Building a rule#
ValidationRule rule = new ValidationRule()
.empty()
.textLengthMax(100);
They run in the order you stacked them and stop at the first failure (one error per field).
| Kind | Methods |
|---|---|
| Required | empty() / required() (the same thing) |
| Character count | textLength(min, max) / textLengthMin(min) / textLengthMax(max) |
| Byte length | textByteLength(min, max[, charset]) / textByteLengthMin / textByteLengthMax (UTF-8 by default) |
| Numbers | integer() / integer(min, max) / integerMin / integerMax / number() / number(min, max) / numberMin / numberMax |
| Format | bool() / email() / url() / domain() / date() / date(format) / regex(regex) / enumType(Class) |
| Character classes | characterType(CharacterType[]) / characterType(Character[]) / both |
| Your own | custom(IValidator) |
| Conditional | insertRequired() |
Note
Everything other than empty() lets empty through.
textLengthMax(100) means "at most 100 characters, if there is a value";
an empty string or null is not an error. Always write required as empty().
Careful
regex(...) uses find (a partial match), not matches.
When you want it to cover the whole value, write ^ and $ yourself.
Binding rules to columns#
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.validate(null, request);
Data messages = ValidationMessages.toMessages(errors);
- Errors across fields are all collected (being told about them one at a time is the worst possible experience for the person retyping the form)
- Fields that were not sent are not validated (except through
insertRequired()) - When an array arrives for one column, every element is run through
put(rule)(with no column) lets you write cross-field checks that belong to no single field
The raw shape of an error#
What validate returns is not wording. It is which kind of check failed, and the settings it failed against.
{ "validation_type": Empty, "validation_setting": {}, "input": "" }
ValidationMessages.toMessages(errors) turns that into something a person can read:
a map of field name to messages.
{ "title": ["入力してください"], "age": ["1 以上 120 以下の整数で入力してください"] }
Tip
Because the result carries a kind instead of wording, the same validation result can become Japanese, English, or an API code.
Swapping the wording#
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.validate(null, request)).get("name"));
Trap
ValidationMessages is static and global. If you swap it in a test,
call ValidationMessages.reset() in a finally.
Forget, and the tests that run afterwards are the ones that fail.
Required only on insert#
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.validate(null, update).isEmpty(), "更新なのに必須になっている");
Data insert = new Data();
insert.put("is_insert", true);
assertFalse(rules.validate(null, insert).isEmpty(), "登録なのに必須になっていない");
You hand the "is this an insert request?" decision to insertRequestChecker.
The same decision reaches each validator as isInsertRequest.
Validating multiple rows#
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.validate(null, List.of(ok, ng, ok));
assertEquals(1, errors.size());
assertEquals(2, errors.getFirst().getInt("index"), "行番号が違う");
Only the rows with errors come back, and each one carries an index (1-based).
Applying it to a route#
Extend ValidationExecutor and write nothing but validate.
private class Failing extends ValidationExecutor {
@Override
protected void validate (WebContext context) {
log.add("validate");
addError("title", "入力してください");
}
}
On the route, stack it first.
JimbleApp app = new JimbleApp() {
{
post("/items", context -> {
context.addExecutor(new Failing());
context.addExecutor(new UseCase());
});
}
};
When it fails, this is what you get.
| Status | 422 |
| Body | {"validation": {"field": ["message"]}} plus the input that was sent |
| Executors after it | Not run (discarded) |
The error(...) hook |
Not reached (nothing was thrown — see Error handling) |
You can also stack the result of ValidationRules directly.
addErrors(rules.validate(db, context.request().bodyAll()));
Note
ValidationExecutor does not hold WebContext in a field.
It receives it as the argument to validate(WebContext).
That avoids the accident where a reused instance writes into the previous request's context.
Careful
Rules are not generated from your column definitions.
The generated table classes (Post.title and the rest) carry
the type, nullability and primary key only — they do not carry the varchar length.
There is nothing to derive textLengthMax from, so you write the rules by hand.
Paging#
Reading it from the request#
Paging paging = context.request().paging();
| Keys it reads | page / per |
| Defaults | page = 1, per = 10 |
| Everything | per=all (no LIMIT is added) |
| Renaming the keys | paging.page / paging.per (configuration) |
| A non-numeric value | Ignored; the default is used |
Write context.request().paging(20) to change the default used when no per arrives.
Applying it to a 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 also issues a COUNT for the total.
The COUNT statement copies only FROM / WHERE / GROUP BY / HAVING,
so the SELECT list, ORDER BY and LIMIT have no effect on it.
Trap
selectListWithRowCount is the only thing that counts the total.
With an ordinary selectList(builder), paging.totalCount() stays 0
and your pager reads "page 1 of 1".
Careful
The raw-SQL version (selectListWithRowCount(sql, params...)) does not touch Paging.
Call paging.set(response.list.size(), response.rowCount) yourself.
What you can read#
| Method | What it holds |
|---|---|
page() |
The current page |
per() |
Rows per page |
perAll() |
Whether "everything" was requested |
totalCount() |
The total number of rows |
maxPage() |
The total number of pages (at least 1) |
start() |
The index of this page's first row (1-based) |
count() |
How many rows this page actually returned |
Note
There are no "has next" / "has previous" methods.
Decide with page() > 1 and page() < maxPage().
It rides on the response#
The moment you call context.request().paging(), it is on the response. You do not repack it yourself.
{
"rows": [ ... ],
"paging": { "page": 2, "per": 10, "perAll": false,
"maxPage": 3, "totalCount": 25, "start": 11, "count": 10 }
}
Templates see the same thing (${paging.page()}).
What to watch for#
Trap
There is no upper bound on per. Send ?per=100000 and
LIMIT 100000 goes out exactly as written. On a listing you expose publicly,
cap it yourself around the paging(...) call.
An out-of-range page (?page=999) is not an error; it returns zero rows.