Nette Schema

Eine praktische Bibliothek zum Validieren und Normalisieren von Datenstrukturen gegen ein vorgegebenes Schema, mit einer klugen und leicht verständlichen API.

Installation:

composer require nette/schema

Grundlegende Verwendung

In der Variablen $schema haben wir ein Validierungsschema (was das bedeutet und wie man eines erstellt, erklären wir gleich), und in der Variablen $data die Datenstruktur, die wir validieren und normalisieren wollen. Das können zum Beispiel Daten sein, die ein Benutzer über eine API gesendet hat, eine Konfigurationsdatei usw.

Um die Aufgabe kümmert sich die Klasse Nette\Schema\Processor, die die Eingabe verarbeitet und entweder normalisierte Daten zurückgibt oder bei einem Fehler eine Nette\Schema\ValidationException wirft.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Die Daten sind ungültig: ' . $e->getMessage();
}

Die Methode $e->getMessages() gibt ein Array aller Meldungen als Strings zurück, und $e->getMessageObjects() gibt alle Meldungen als Objekte vom Typ Nette\Schema\Message zurück.

Das Schema definieren

Und nun erstellen wir das Schema. Zu seiner Definition dient die Klasse Nette\Schema\Expect; wir definieren im Grunde Erwartungen daran, wie die Daten aussehen sollen. Nehmen wir an, die Eingabedaten müssen eine Struktur sein (etwa ein Array), die die Elemente processRefund vom Typ bool und refundAmount vom Typ int enthält.

use Nette\Schema\Expect;

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

Wir glauben, dass die Definition des Schemas verständlich aussieht, auch wenn Sie sie zum ersten Mal sehen.

Schicken wir die folgenden Daten zur Validierung:

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

$normalized = $processor->process($schema, $data); // OK, besteht die Validierung

Die Ausgabe, also der Wert $normalized, ist ein Objekt vom Typ stdClass. Wollten wir, dass die Ausgabe ein Array ist, würden wir dem Schema das Casting ->castTo('array') hinzufügen.

Alle Elemente der Struktur sind optional und haben den Standardwert null. Beispiel:

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

$normalized = $processor->process($schema, $data); // OK, besteht die Validierung
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

Dass der Standardwert null ist, bedeutet nicht, dass in den Eingabedaten 'processRefund' => null akzeptiert würde. Nein, die Eingabe muss ein Boolean sein, also nur true oder false. Wir müssten null mit Expect::bool()->nullable() ausdrücklich erlauben.

Ein Element lässt sich mit Expect::bool()->required() zum Pflichtfeld machen. Den Standardwert können wir zum Beispiel mit Expect::bool()->default(false) oder kurz Expect::bool(false) auf false ändern.

Und was, wenn wir außer Booleans auch 1 und 0 akzeptieren wollten? Dann zählen wir die Werte auf, die wir ebenfalls zu einem Boolean normalisieren wollen:

$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

Nun kennen Sie die Grundlagen der Schemadefinition und wissen, wie sich die Elemente der Struktur verhalten. Jetzt zeigen wir, welche weiteren Elemente Sie beim Definieren eines Schemas verwenden können.

Datentypen: type()

Im Schema lassen sich alle Standarddatentypen von PHP angeben:

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

Und außerdem alle Typen, die die Klasse Validators unterstützt, zum Beispiel Expect::type('scalar') oder kurz Expect::scalar(). Auch Klassen- oder Interface-Namen, etwa Expect::type('AddressEntity').

Auch die Union-Syntax lässt sich verwenden:

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

Der Standardwert ist immer null, mit Ausnahme von array und list, wo es ein leeres Array ist. (Eine Liste ist ein Array, das mit einer Folge numerischer Schlüssel ab null indiziert ist, also ein nicht assoziatives Array.)

Array von Werten: arrayOf() listOf()

Ein Array stellt eine zu allgemeine Struktur dar; nützlicher ist es, genau anzugeben, welche Elemente es enthalten darf. Zum Beispiel ein Array, dessen Elemente nur Strings sein dürfen:

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // FEHLER: 123 ist kein String

Der zweite Parameter kann die Schlüssel angeben (seit Version 1.2):

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // FEHLER: 'a' ist kein int

Eine Liste ist ein indiziertes Array:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // FEHLER: 123 ist kein String
$processor->process($schema, ['key' => 'a']); // FEHLER: keine Liste
$processor->process($schema, [1 => 'a', 0 => 'b']); // FEHLER: ebenfalls keine Liste

Der Parameter kann auch ein Schema sein, wir können also schreiben:

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

Der Standardwert ist ein leeres Array. Geben Sie einen Standardwert an, wird er mit den übergebenen Daten zusammengeführt. Das lässt sich mit mergeDefaults(false) abschalten (seit Version 1.1).

Aufzählung: anyOf()

anyOf() stellt eine Menge von Werten oder Schemata dar, die ein Wert annehmen darf. So schreiben Sie ein Array von Elementen, die entweder 'a', true oder null sein können:

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

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // FEHLER: false gehört nicht dazu

Die Elemente der Aufzählung können auch Schemata sein:

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

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

Die Methode anyOf() nimmt die Varianten als einzelne Parameter entgegen, nicht als Array. Um ihr ein Array von Werten zu übergeben, verwenden Sie den Unpack-Operator anyOf(...$variants).

Der Standardwert ist null. Verwenden Sie die Methode firstIsDefault(), um das erste Element zum Standardwert zu machen:

// der Standardwert ist 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Strukturen

Strukturen sind Objekte mit definierten Schlüsseln. Jedes Schlüssel-Wert-Paar wird als “Property” bezeichnet.

Strukturen nehmen Arrays und Objekte entgegen und geben Objekte vom Typ stdClass zurück.

Standardmäßig sind alle Properties optional und haben den Standardwert null. Pflicht-Properties definieren Sie mit required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // der Standardwert ist null
]);

$processor->process($schema, ['optional' => '']);
// FEHLER: die Option 'required' fehlt

$processor->process($schema, ['required' => 'foo']);
// OK, gibt {'required' => 'foo', 'optional' => null} zurück

Eine Struktur selbst ist Pflicht. Ist sie also in einer anderen Struktur verschachtelt und die Eingabe enthält sie nicht, wird sie trotzdem erzeugt – und meldet einen Fehler, wenn sie eine Pflicht-Property enthält. Verwenden Sie required(false), um die gesamte verschachtelte Struktur optional zu machen. Fehlt sie in der Eingabe, erscheint in der Ausgabe null, ist sie aber vorhanden, werden ihre Pflicht-Properties durchgesetzt:

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

$processor->process($schema, []);
// OK, gibt {'db' => null} zurück

$processor->process($schema, ['db' => []]);
// FEHLER: 'db › dsn' fehlt

Wenn Sie Properties mit Standardwert nicht in der Ausgabe haben wollen, verwenden Sie skipDefaults():

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

$processor->process($schema, ['required' => 'foo']);
// OK, gibt {'required' => 'foo'} zurück

Obwohl null der Standardwert der Property optional ist, ist er in den Eingabedaten nicht erlaubt (der Wert muss ein String sein). Properties, die null akzeptieren, definieren Sie mit nullable():

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

$processor->process($schema, ['optional' => null]);
// FEHLER: 'optional' erwartet einen String, null übergeben.

$processor->process($schema, ['nullable' => null]);
// OK, gibt {'optional' => null, 'nullable' => null} zurück

Das Array aller Properties der Struktur gibt die Methode getShape() zurück.

Standardmäßig dürfen in den Eingabedaten keine weiteren Elemente vorkommen:

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

$processor->process($schema, ['additional' => 1]);
// FEHLER: Unerwartetes Element 'additional'

Das lässt sich mit otherItems() ändern. Übergeben Sie als Parameter das Schema zur Validierung jedes zusätzlichen Elements:

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

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

Eine neue Struktur können Sie erstellen, indem Sie eine andere mit extend() erweitern:

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

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

Array

Ein Array mit definierten Schlüsseln. Für es gilt alles, was für Strukturen gilt.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // der Standardwert ist null
]);

Sie können auch ein indiziertes Array definieren, ein sogenanntes Tupel:

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

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

Veraltete Properties

Eine Property können Sie mit der Methode deprecated([string $message]) als veraltet kennzeichnen. Informationen über die Veraltung gibt $processor->getWarnings() zurück:

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('Das Element %path% ist veraltet'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["Das Element 'old' ist veraltet"]

Bereiche: min() max()

Verwenden Sie min() und max(), um bei Arrays die Anzahl zu begrenzen:

// Array, mindestens 10 Elemente, höchstens 20 Elemente
Expect::array()->min(10)->max(20);

Bei Strings begrenzen Sie ihre Länge:

// String, mindestens 10 Zeichen lang, höchstens 20 Zeichen
Expect::string()->min(10)->max(20);

Bei Zahlen begrenzen Sie ihren Wert:

// Ganzzahl, zwischen 10 und 20 einschließlich
Expect::int()->min(10)->max(20);

Natürlich lässt sich auch nur min() oder nur max() angeben:

// String, höchstens 20 Zeichen
Expect::string()->max(20);

Reguläre Ausdrücke: pattern()

Mit pattern() können Sie einen regulären Ausdruck angeben, zu dem der gesamte eingegebene String passen muss (also so, als wäre er in die Zeichen ^ und $ eingeschlossen):

// genau 9 Ziffern
Expect::string()->pattern('\d{9}');

Eigene Assertions: assert()

Beliebige weitere Einschränkungen können Sie mit assert(callable $fn) ergänzen.

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

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // die Anzahl muss gerade sein

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // FEHLER: 3 ist keine gerade Anzahl

Oder

Expect::string()->assert('is_file'); // die Datei muss existieren

Zu jeder Assertion können Sie eine eigene Beschreibung hinzufügen. Sie wird Teil der Fehlermeldung.

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Gerade Anzahl von Elementen im Array');

$processor->process($schema, ['a', 'b', 'c']);
// Failed assertion "Gerade Anzahl von Elementen im Array" for item with value array.

Die Methode lässt sich wiederholt aufrufen, um mehrere Einschränkungen zu ergänzen. Sie kann mit Aufrufen von transform() und castTo() verschränkt werden.

Transformation: transform()

Erfolgreich validierte Daten lassen sich mit einer eigenen Funktion verändern:

// in Großbuchstaben umwandeln:
Expect::string()->transform(fn(string $s) => strtoupper($s));

Die Methode lässt sich wiederholt aufrufen, um mehrere Transformationen zu ergänzen. Sie kann mit Aufrufen von assert() und castTo() verschränkt werden. Die Operationen werden in der Reihenfolge ausgeführt, in der sie deklariert sind:

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'All characters must be lowercased')
	->transform(fn(string $s) => strtoupper($s)); // in Großbuchstaben umwandeln

Die Methode transform() kann den Wert gleichzeitig transformieren und validieren. Das ist oft einfacher und führt zu weniger Codeduplizierung als das Verketten von transform() und assert(). Dazu erhält die Funktion ein Objekt Context mit der Methode addError(), mit der sich Informationen über Validierungsprobleme ergänzen lassen:

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);
	});

Casting: castTo()

Erfolgreich validierte Daten lassen sich casten:

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

Außer in native PHP-Typen können Sie auch in Klassen casten. Dabei wird zwischen einer einfachen Klasse ohne Konstruktor und einer Klasse mit Konstruktor unterschieden. Hat die Klasse keinen Konstruktor, wird eine Instanz erzeugt und alle Elemente der Struktur werden in die Properties geschrieben:

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

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

// erzeugt '$obj = new Info' und schreibt in $obj->processRefund und $obj->refundAmount

Hat die Klasse einen Konstruktor, werden die Elemente der Struktur als benannte Argumente an den Konstruktor übergeben:

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

// erzeugt $obj = new Info(processRefund: ..., refundAmount: ...)

Casting in Verbindung mit einem skalaren Parameter erzeugt ein Objekt und übergibt den Wert als einziges Argument an den Konstruktor:

Expect::string()->castTo(DateTime::class);
// erzeugt new DateTime(...)

Normalisierung: before()

Vor der eigentlichen Validierung lassen sich die Daten mit der Methode before() normalisieren. Nehmen wir als Beispiel ein Element, das ein Array von Strings sein muss (etwa ['a', 'b', 'c']), aber eine Eingabe in Form des Strings a b c akzeptiert:

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

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

$normalized = $processor->process($schema, 'a b c');
// OK und gibt ['a', 'b', 'c'] zurück

Mapping auf Objekte: from()

Sie können das Schema der Struktur aus einer Klasse erzeugen lassen. Beispiel:

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}

Auch anonyme Klassen werden unterstützt:

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

Weil die aus der Klassendefinition gewonnenen Informationen möglicherweise nicht ausreichen, können Sie die Elemente über den zweiten Parameter durch ein eigenes Schema ergänzen:

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

Mehrere Konfigurationen zusammenführen

Anwendungen setzen ihre Konfiguration oft in Schichten zusammen: Es gibt eingebaute Standardwerte, und darüber legt der Benutzer seine eigenen Einstellungen, die nur die Elemente überschreiben sollen, die er tatsächlich angibt. Genau das tut processMultiple() – es nimmt mehrere Datensätze entgegen, führt sie der Reihe nach so zusammen, dass die späteren Vorrang haben, und validiert das Endergebnis als Ganzes:

$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}

Das Element host behält seinen Standardwert, weil der Benutzer es nicht gesetzt hat, während port und logging vom späteren Datensatz überschrieben werden. Werte unter String-Schlüsseln werden auf diese Weise zusammengeführt; numerisch indizierte Elemente (Listen) werden hintereinander angehängt, statt überschrieben zu werden.

Unter der Haube: normalize, merge, complete

Jedes Element eines Schemas – ob eingebaut oder selbst geschrieben – implementiert vier Methoden, die zusammen definieren, wie es mit Daten umgeht. Drei von ihnen bilden die Verarbeitungskette:

  1. normalize() – bereitet die rohe Eingabe auf. Hier laufen die before()-Hooks, und hier wird zum Beispiel ein Objekt in ein Array umgewandelt. Sie läuft zuerst, getrennt für jeden Datensatz.
  2. merge() – führt zwei bereits normalisierte Datensätze zusammen, wobei der spätere Vorrang hat. Diesen Schritt nutzt nur processMultiple(); process() überspringt ihn, weil es nur einen einzigen Datensatz hat.
  3. complete() – führt die eigentliche Validierung durch, füllt Standardwerte für fehlende Elemente ein und wendet assert(), transform() und castTo() an. Sie läuft zuletzt, auf dem zusammengeführten Ergebnis.

Die vierte Methode, completeDefault(), ruft das übergeordnete Element für ein in der Eingabe völlig fehlendes Element auf – sie liefert entweder den Standardwert oder meldet, dass ein required()-Element fehlt.

process() führt also normalize → complete aus, während processMultiple() normalize (jeder Datensatz) → merge → complete ausführt. Wegen dieser Reihenfolge sieht before() die rohe Eingabe, transform() dagegen den bereits validierten Wert.

Eigene Schema-Elemente

Mit assert(), transform() und before() kommen Sie weit, sodass Sie selten etwas von Grund auf bauen müssen. Wenn Sie aber ein wiederverwendbares, in sich geschlossenes Element mit eigener Validierungs- und Merge-Logik wollen, können Sie eines erstellen, indem Sie das Interface Nette\Schema\Schema implementieren. Es hat genau die vier oben beschriebenen Methoden:

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

Fehler werden nicht geworfen; stattdessen melden Sie sie über das Objekt Context mit $context->addError() und geben null zurück. Der Processor sammelt alle Fehler und wirft sie am Ende gemeinsam.

Bauen wir als Beispiel ein wiederverwendbares Element, das den Backing-Wert eines Enums entgegennimmt (etwa den String 'hearts') und die Enum-Instanz zurückgibt:

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; // keine Vorverarbeitung nötig
	}

	public function merge(mixed $value, mixed $base): mixed
	{
		return $value ?? $base; // der spätere Wert gewinnt
	}

	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; // Wert, der verwendet wird, wenn das Element in der Eingabe fehlt
	}
}

Sie können es überall dort verwenden, wo ein eingebautes Element erwartet wird – für sich allein oder als Teil einer größeren Struktur:

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

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

$processor->process($schema, ['suit' => 'hearts']);
// OK, gibt {'suit' => Suit::Hearts} zurück

Weil das Element das gesamte Interface implementiert, funktioniert es automatisch auch innerhalb von processMultiple() – der Processor ruft seine Methode merge() genauso auf wie bei jedem anderen Element.

Version: 2.x