Nette Schema

決められたスキーマに照らしてデータ構造を検証し、整えるための実用的なライブラリです。賢く分かりやすい API を備えています。

インストール:

composer require nette/schema

基本の使い方

$schema の変数には検証のスキーマ(それが何を意味し、どう作るかはすぐ説明します)が、$data の変数には検証して整えたいデータ構造が入っています。これはたとえば、API を通して利用者が送ったデータや設定ファイルなどです。

この仕事は Nette\Schema\Processorクラスが受け持ちます。入力を処理して、整えられたデータを返すか、エラーが起きたら Nette\Schema\ValidationExceptionの例外を投げます。

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Data is invalid: ' . $e->getMessage();
}

$e->getMessages() メソッドはすべてのメッセージを文字列の配列として返し、$e->getMessageObjects() はすべてのメッセージを Nette\Schema\Message のオブジェクトとして返します。

スキーマを定める

ではスキーマを作りましょう。定めるのに使うのは Nette\Schema\Expectクラスで、要するにデータがどうあってほしいかという期待を書きます。入力のデータが、bool 型の processRefund と int 型の refundAmount の要素を含む構造(たとえば配列)でなければならないとしましょう。

use Nette\Schema\Expect;

$schema = Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
]);

はじめて見る方でも、このスキーマの定義は分かりやすいのではないでしょうか。

次のデータを検証に送ってみましょう。

$data = [
	'processRefund' => true,
	'refundAmount' => 17,
];

$normalized = $processor->process($schema, $data); // OK、検証を通ります

出力、つまり $normalized の値は stdClass のオブジェクトです。出力を配列にしたいなら、スキーマに ->castTo('array') のキャストを足します。

構造のすべての要素は省略でき、既定値は null です。例です。

$data = [
	'refundAmount' => 17,
];

$normalized = $processor->process($schema, $data); // OK、検証を通ります
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

既定値が null だからといって、入力のデータで 'processRefund' => null が受け入れられるわけではありません。入力は真偽値、つまり truefalse だけでなければなりません。null を許すには Expect::bool()->nullable() ではっきりそう書く必要があります。

項目は Expect::bool()->required() で必須にできます。既定値はたとえば Expect::bool()->default(false) や短い形 Expect::bool(false)false に変えられます。

では真偽値に加えて 10 も受け入れたいならどうするでしょうか。そのときは、真偽値へ整えたい値も並べます。

$schema = Expect::structure([
	'processRefund' => Expect::anyOf(true, false, 1, 0)->castTo('bool'),
	'refundAmount' => Expect::int(),
]);

$normalized = $processor->process($schema, $data);
is_bool($normalized->processRefund); // true

これでスキーマを定める基本と、構造の要素の振る舞いが分かりました。ここからは、スキーマを定めるときに使えるほかの要素をお見せします。

データの型: type()

スキーマには PHP の標準のデータ型をすべて指定できます。

Expect::string($default = null)
Expect::int($default = null)
Expect::float($default = null)
Expect::bool($default = null)
Expect::null()
Expect::array($default = [])
Expect::list($default = [])

そして Validators クラスが対応するすべての型も使えます。たとえば Expect::type('scalar') や短い形 Expect::scalar() です。クラス名やインターフェース名も使えます。たとえば Expect::type('AddressEntity') です。

合併の書き方も使えます。

Expect::type('bool|string|array')

既定値はいつも null です。ただし arraylist は例外で、空の配列になります。(list はゼロから始まる数のキーの並びで添字が付けられた配列、つまり連想でない配列です。)

値の配列: arrayOf() listOf()

配列は漠然としすぎた構造なので、どんな要素を含めてよいかをきっちり指定するほうが役に立ちます。たとえば、要素が文字列だけの配列です。

$schema = Expect::arrayOf('string');

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERROR: 123 は文字列ではありません

第 2 パラメータでキーを指定できます(バージョン 1.2 以降)。

$schema = Expect::arrayOf('string', 'int');

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERROR: 'a' は int ではありません

list は添字の配列です。

$schema = Expect::listOf('string');

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 は文字列ではありません
$processor->process($schema, ['key' => 'a']); // ERROR: list ではありません
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: これも list ではありません

パラメータにはスキーマも渡せるので、次のようにも書けます。

Expect::arrayOf(Expect::bool())

既定値は空の配列です。既定値を指定すると、それは渡されたデータと併合されます。これは mergeDefaults(false) で切れます(バージョン 1.1 以降)。

列挙: anyOf()

anyOf() は、値が取りうる値やスキーマの一そろいを表します。要素が 'a'truenull のいずれかになりうる配列は次のように書きます。

$schema = Expect::listOf(
	Expect::anyOf('a', true, null),
);

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERROR: false はそこに入りません

列挙の要素はスキーマにもできます。

$schema = Expect::listOf(
	Expect::anyOf(Expect::string(), true, null),
);

$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ERROR

anyOf() メソッドは、選択肢を配列ではなく別々のパラメータとして受け取ります。値の配列を渡すには、展開の演算子を使って anyOf(...$variants) とします。

既定値は null です。最初の要素を既定値にするには firstIsDefault() メソッドを使います。

// 既定は 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

構造

構造は、定められたキーを持つオブジェクトです。キーと値の組それぞれを「プロパティ」と呼びます。

構造は配列とオブジェクトを受け取り、stdClass のオブジェクトを返します。

既定では、すべてのプロパティは省略でき、既定値は null です。必須のプロパティは required() で定められます。

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // 既定値は null
]);

$processor->process($schema, ['optional' => '']);
// ERROR: option 'required' is missing

$processor->process($schema, ['required' => 'foo']);
// OK、{'required' => 'foo', 'optional' => null} を返します

構造そのものは必須です。ですから別の構造の中に入れ子になっていて、入力にそれが含まれていなくても、やはり作られます。そして必須のプロパティを含んでいればエラーを知らせます。入れ子の構造全体を省略できるようにするには required(false) を使います。入力にそれがなければ出力に null が現れますが、あればその必須のプロパティが求められます。

$schema = Expect::structure([
	'db' => Expect::structure([
		'dsn' => Expect::string()->required(),
	])->required(false),
]);

$processor->process($schema, []);
// OK、{'db' => null} を返します

$processor->process($schema, ['db' => []]);
// ERROR: 'db › dsn' is missing

既定値のプロパティを出力に入れたくないなら、skipDefaults() を使います。

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(),
])->skipDefaults();

$processor->process($schema, ['required' => 'foo']);
// OK、{'required' => 'foo'} を返します

optional のプロパティの既定値は null ですが、入力のデータでは許されません(値は文字列でなければなりません)。null を受け入れるプロパティは nullable() で定めます。

$schema = Expect::structure([
	'optional' => Expect::string(),
	'nullable' => Expect::string()->nullable(),
]);

$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' expects to be string, null given.

$processor->process($schema, ['nullable' => null]);
// OK、{'optional' => null, 'nullable' => null} を返します

構造のすべてのプロパティの配列は getShape() メソッドが返します。

既定では、入力のデータに余分な項目があってはいけません。

$schema = Expect::structure([
	'key' => Expect::string(),
]);

$processor->process($schema, ['additional' => 1]);
// ERROR: Unexpected item 'additional'

これは otherItems() で変えられます。パラメータには、余分な項目それぞれを検証するスキーマを渡します。

$schema = Expect::structure([
	'key' => Expect::string(),
])->otherItems(Expect::int());

$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // ERROR

extend() で別の構造を広げて、新しい構造を作れます。

$dog = Expect::structure([
	'name' => Expect::string(),
	'age' => Expect::int(),
]);

$dogWithBreed = $dog->extend([
	'breed' => Expect::string(),
]);

配列

キーの定められた配列です。構造に当てはまることはすべてこれにも当てはまります。

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // 既定値は null
]);

タプルと呼ばれる添字の配列も定められます。

$schema = Expect::array([
	Expect::int(),
	Expect::string(),
	Expect::bool(),
]);

$processor->process($schema, [1, 'hello', true]); // OK

非推奨のプロパティ

プロパティは deprecated([string $message]) メソッドで非推奨と印を付けられます。非推奨についての情報は $processor->getWarnings() で返されます。

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('The item %path% is deprecated'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["The item 'old' is deprecated"]

範囲: min() max()

配列では min()max() で個数を制限します。

// 配列。要素は 10 個以上、20 個以下
Expect::array()->min(10)->max(20);

文字列では長さを制限します。

// 文字列。長さは 10 文字以上、20 文字以下
Expect::string()->min(10)->max(20);

数では値を制限します。

// 整数。10 以上 20 以下
Expect::int()->min(10)->max(20);

もちろん min() だけ、max() だけを指定することもできます。

// 文字列。20 文字以下
Expect::string()->max(20);

正規表現: pattern()

pattern() では、入力の文字列全体が合わなければならない正規表現を指定できます(つまり ^$ の文字で囲まれているかのように扱われます)。

// ちょうど 9 桁の数字
Expect::string()->pattern('\d{9}');

独自のアサーション: assert()

assert(callable $fn) でそのほかの制約を好きなだけ足せます。

$countIsEven = fn($v) => count($v) % 2 === 0;

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // 個数は偶数でなければなりません

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 は偶数の個数ではありません

あるいは

Expect::string()->assert('is_file'); // ファイルが存在しなければなりません

アサーションごとに独自の説明を足せます。それはエラーのメッセージの一部になります。

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Even items in array');

$processor->process($schema, ['a', 'b', 'c']);
// Failed assertion "Even items in array" for item with value array.

このメソッドは繰り返し呼んで、複数の制約を足せます。transform()castTo() の呼び出しと混ぜられます。

変換: transform()

検証を通ったデータは、独自の関数で変えられます。

// 大文字に変えます:
Expect::string()->transform(fn(string $s) => strtoupper($s));

このメソッドは繰り返し呼んで、複数の変換を足せます。assert()castTo() の呼び出しと混ぜられます。処理は書かれた順に行われます。

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'All characters must be lowercased')
	->transform(fn(string $s) => strtoupper($s)); // 大文字に変えます

transform() メソッドは、値の変換と検証を同時に行えます。transform()assert() をつなげるより単純で、書くことも重複しません。そのためにこの関数は、addError() メソッドを持つ Contextオブジェクトを受け取り、検証の問題の情報を足せます。

Expect::string()
	->transform(function (string $s, Nette\Schema\Context $context) {
		if (!ctype_lower($s)) {
			$context->addError('All characters must be lowercased', 'my.case.error');
			return null;
		}

		return strtoupper($s);
	});

キャスト: castTo()

検証を通ったデータはキャストできます。

Expect::scalar()->castTo('string');

PHP のネイティブの型のほかに、クラスへもキャストできます。コンストラクタのない素朴なクラスと、コンストラクタのあるクラスは区別されます。クラスにコンストラクタがなければインスタンスが作られ、構造のすべての要素がプロパティへ書き込まれます。

class Info
{
	public bool $processRefund;
	public int $refundAmount;
}

Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
])->castTo(Info::class);

// '$obj = new Info' を作り、$obj->processRefund と $obj->refundAmount へ書き込みます

クラスにコンストラクタがあれば、構造の要素は名前付きの引数としてコンストラクタへ渡されます。

class Info
{
	public function __construct(
		public bool $processRefund,
		public int $refundAmount,
	) {
	}
}

// $obj = new Info(processRefund: ..., refundAmount: ...) を作ります

スカラーのパラメータと組み合わせたキャストは、オブジェクトを作ってその値をただひとつの引数としてコンストラクタへ渡します。

Expect::string()->castTo(DateTime::class);
// new DateTime(...) を作ります

正規化: before()

検証そのものの前に、before() メソッドでデータを整えられます。例として、文字列の配列(たとえば ['a', 'b', 'c'])でなければならないけれど、a b c という文字列の形の入力も受け取る要素を挙げます。

$explode = fn($v) => explode(' ', $v);

$schema = Expect::arrayOf('string')
	->before($explode);

$normalized = $processor->process($schema, 'a b c');
// OK、['a', 'b', 'c'] を返します

オブジェクトへの対応づけ: from()

構造のスキーマをクラスから作らせられます。例です。

class Config
{
	public string $name;
	public string|null $password = null;
	public bool $admin = false;
}

$schema = Expect::from(new Config);

$data = [
	'name' => 'Frank',
];

$normalized = $processor->process($schema, $data);
// $normalized instanceof Config
// $normalized = {'name' => 'Frank', 'password' => null, 'admin' => false}

無名クラスにも対応しています。

$schema = Expect::from(new class {
	public string $name;
	public ?string $password = null;
	public bool $admin = false;
});

クラスの定義から得られる情報では足りないこともあるので、第 2 パラメータで独自のスキーマを要素に補えます。

$schema = Expect::from(new Config, [
	'name' => Expect::string()->pattern('\w:.*'),
]);

複数の設定を併合する

アプリケーションはしばしば設定を層として組み立てます。組み込みの既定値があり、その上に利用者が自分の設定を与え、それは実際に指定した項目だけを上書きすべきです。まさにそれを processMultiple() が行います。いくつかのデータの組を受け取り、あとのものが優先されるように順に併合し、その結果全体を検証します。

$schema = Expect::structure([
	'host' => Expect::string(),
	'port' => Expect::int(),
	'logging' => Expect::bool(),
]);

$defaults = ['host' => 'localhost', 'port' => 3306, 'logging' => false];
$userConfig = ['port' => 5432, 'logging' => true];

$config = $processor->processMultiple($schema, [$defaults, $userConfig]);
// $config = {'host' => 'localhost', 'port' => 5432, 'logging' => true}

host の項目は利用者が決めていないので既定値を保ち、portlogging はあとのデータの組で上書きされます。文字列のキーに置かれた値はこうして併合されます。数の添字の項目(list)は上書きされずに、後ろへつなげられます。

内側の話: normalize、merge、complete

スキーマのすべての要素は、組み込みのものでも自分で書いたものでも、データの扱い方を定める 4 つのメソッドを実装しています。そのうち 3 つが処理の流れを作ります。

  1. normalize() – 生の入力を整えます。ここで before() の仕掛けが走り、たとえばオブジェクトが配列に変えられます。これがいちばん先に、データの組ごとに別々に走ります。
  2. merge() – すでに整えられた 2 つのデータの組を、あとのものを優先して合わせます。この段階は processMultiple() だけが使います。process() はデータの組がひとつしかないので飛ばします。
  3. complete() – 実際の検証を行い、足りない項目に既定値を入れ、assert()transform()castTo() を当てます。これが最後に、併合された結果に対して走ります。

4 つめのメソッド completeDefault() は、入力にまったくない項目について親の要素が呼びます。既定値を与えるか、required() の項目が足りないと知らせるかします。

ですから process()normalize → complete を、processMultiple()normalize(データの組ごと)→ merge → complete を走らせます。この順序があるからこそ、before() は生の入力を見て、transform() は検証を通った値を見るのです。

独自のスキーマの要素

assert()transform()before() でかなりのことができるので、一から何かを作る必要はめったにありません。とはいえ、自分の検証と併合の論理を持つ、使い回せる独立した要素が欲しいなら、Nette\Schema\Schemaインターフェースを実装して作れます。これにはちょうど、上で説明した 4 つのメソッドがあります。

interface Schema
{
	function normalize(mixed $value, Context $context);
	function merge(mixed $value, mixed $base);
	function complete(mixed $value, Context $context);
	function completeDefault(Context $context);
}

エラーは投げず、代わりに Contextオブジェクトの $context->addError() で知らせて null を返します。Processor はすべてのエラーを集め、最後にまとめて投げます。

例として、enum の裏の値(たとえば文字列 'hearts')を受け取り、その enum のインスタンスを返す、使い回せる要素を作ってみましょう。

use Nette\Schema\Context;
use Nette\Schema\Schema;

class EnumSchema implements Schema
{
	public function __construct(
		private string $enumClass,
	) {
	}

	public function normalize(mixed $value, Context $context): mixed
	{
		return $value; // 前処理は要りません
	}

	public function merge(mixed $value, mixed $base): mixed
	{
		return $value ?? $base; // あとの値が勝ちます
	}

	public function complete(mixed $value, Context $context): mixed
	{
		$enum = is_string($value) ? ($this->enumClass)::tryFrom($value) : null;
		if ($enum === null) {
			$context->addError('The item %path% is not a valid value.', 'enum.value');
			return null;
		}

		return $enum;
	}

	public function completeDefault(Context $context): mixed
	{
		return null; // 入力にその項目がないときに使われる値
	}
}

組み込みの要素が期待されるところなら、どこでも使えます。単独でも、大きな構造の一部としてもです。

enum Suit: string
{
	case Hearts = 'hearts';
	case Spades = 'spades';
}

$schema = Expect::structure([
	'suit' => new EnumSchema(Suit::class),
]);

$processor->process($schema, ['suit' => 'hearts']);
// OK、{'suit' => Suit::Hearts} を返します

この要素はインターフェース全体を実装しているので、processMultiple() の中でも自動的に働きます。Processor は、ほかの要素と同じようにその merge() メソッドを呼ぶからです。

バージョン: 2.x