jimble

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.