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
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.