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. validate throws a 422 on failure |
| 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().
empty() also fails when the key was not sent at all.
Note
Format checks are matched against the whole value.
email(), url(), domain(), regex(...) and characterType(...)
do not pass just because something plausible appears somewhere inside —
hello a@example.com there fails email().
regex("[0-9]{4}") means "the whole value is four digits",
so wrap it in .* when you want a partial match.
Note
email() rejects every address the sending side (simplejavamail's default check) rejects,
so an address that passed cannot later fail to send and pile up MQ retries.
The local part has no consecutive, leading or trailing dots (a..b@, .a@ and a.@ fail).
Domain labels are letters, digits and hyphens, do not start or end with a hyphen, and are at most 63 characters; the last label is not all digits.
The accepted characters are narrower than the sender's: letters, digits and + _ % - . only (quotes, spaces, Name <a@…> and non-ASCII fail). The whole address is at most 254 characters.
Note
bool() accepts only true / false / 1 / 0 (case does not matter).
yes fails — the reading side uses the same spellings, so widening this
means someone who answered "yes" gets stored as false.
Careful
date() and date(format) only check that the value can be read.
2026-13-01 (month 13), 2026-02-31 (Feb 31st) and
2026-09-12x (trailing junk) all pass.
date() goes through the same shared conversion as database reads and request binding.
To check that the date is real, stack a regex(...) on it or write a custom(...).
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.errors(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)
- A field that was not sent fails only the rules with
empty()(required()) in them. Other rules do not look at it - A rule with
insertRequired()means "required on insert; on update, checked only when sent" (see "Required only on insert" below) - 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 errors(...) 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.errors(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.
Stop, or take the list#
| Method | When validation fails |
|---|---|
rules.validate(db, data) |
Throws ValidationException (422) and stops. Returns nothing |
rules.errors(db, data) |
Returns the list of errors (a Data). Does not stop. Empty when it passes |
Normally you write validate as a statement.
rules.validate(db, context.request().bodyAll()); // a 422 right here if it fails
db.insert(...);
If you do nothing else, the framework replies 422 with the body {"validation": {"field": ["message"]}} plus the input that was sent
(the same shape as ValidationExecutor). It is an exception, so the error(...) hook does run (Error handling).
ValidationException is a subclass of HttpException; errors() gives you the list.
When you want to look at the list and branch yourself, use errors(...). To run several sets together, use Validator.validate(db, data, rules...) / Validator.errors(db, data, rules...).
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.errors(null, update).isEmpty(), "更新なのに必須になっている");
Data insert = new Data();
insert.put("is_insert", true);
assertFalse(rules.errors(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.errors(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).
validate(db, list) throws ValidationException if even one row fails (the exception's errors() is {"rows": [...]}).
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.errors(db, context.request().bodyAll()));
Note
In 1.x, validate(...) only returned the list, so throwing the return value away let invalid input straight through.
2.0 made it void and throws when validation fails. Where you need the list, rewrite it as errors(...) (Moving to 2.0).
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 |
| Cap | paging.max_per (200 by default). A larger request is rounded down to it |
| Everything | per=all. The cap still applies, so 200 rows by default (paging.max_per = 0 for a real "everything") |
| Renaming the keys | paging.name_page / paging.name_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.
There is one Paging per request, and the first one made is reused. Call paging() first and then paging(50) with a different count, and you get IllegalStateException. Pass the count on the first call.
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.