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:
- 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. - 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. - complete() – führt die eigentliche Validierung durch, füllt Standardwerte für fehlende Elemente ein und wendet
assert(),transform()undcastTo()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.