diff --git a/composer.json b/composer.json index 56b8452..41ba39e 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.3-dev" + "dev-master": "2.0-dev" } } } diff --git a/readme.md b/readme.md index 5ee1382..a6dbb85 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ Introduction A practical library for validation and normalization of data structures against a given schema with a smart & easy-to-understand API. -Documentation can be found on the [website](https://doc.nette.org/schema). +Documentation can be found on the [website](https://doc.nette.org/en/schema). Installation: @@ -127,7 +127,7 @@ Expect::null() Expect::array($default = []) ``` -And then all types [supported by the Validators](https://doc.nette.org/validators#toc-validation-rules) via `Expect::type('scalar')` or abbreviated `Expect::scalar()`. Also class or interface names are accepted, e.g. `Expect::type('AddressEntity')`. +And then all types [supported by the Validators](https://doc.nette.org/validators#toc-expected-types) via `Expect::type('scalar')` or abbreviated `Expect::scalar()`. Also class or interface names are accepted, e.g. `Expect::type('AddressEntity')`. You can also use union notation: @@ -177,7 +177,7 @@ The parameter can also be a schema, so we can write: Expect::arrayOf(Expect::bool()) ``` -The default value is an empty array. If you specify a default value, it will be merged with the passed data. This can be disabled using `mergeDefaults(false)`. +The default value is an empty array. If you specify a default value and call `mergeDefaults()`, it will be merged with the passed data. Enumeration: anyOf() @@ -486,12 +486,9 @@ You can generate structure schema from the class. Example: ```php class Config { - /** @var string */ - public $name; - /** @var string|null */ - public $password; - /** @var bool */ - public $admin = false; + public string $name; + public ?string $password; + public bool $admin = false; } $schema = Expect::from(new Config); @@ -505,19 +502,6 @@ $normalized = $processor->process($schema, $data); // $normalized = {'name' => 'jeff', 'password' => null, 'admin' => false} ``` -If you are using PHP 7.4 or higher, you can use native types: - -```php -class Config -{ - public string $name; - public ?string $password; - public bool $admin = false; -} - -$schema = Expect::from(new Config); -``` - Anonymous classes are also supported: ```php diff --git a/src/Schema/Elements/AnyOf.php b/src/Schema/Elements/AnyOf.php index 6c9d0ce..71a68f3 100644 --- a/src/Schema/Elements/AnyOf.php +++ b/src/Schema/Elements/AnyOf.php @@ -64,11 +64,6 @@ public function normalize(mixed $value, Context $context): mixed public function merge(mixed $value, mixed $base): mixed { - if (is_array($value) && isset($value[Helpers::PreventMerging])) { - unset($value[Helpers::PreventMerging]); - return $value; - } - return Helpers::merge($value, $base); } diff --git a/src/Schema/Elements/Structure.php b/src/Schema/Elements/Structure.php index 66e501a..8b83b71 100644 --- a/src/Schema/Elements/Structure.php +++ b/src/Schema/Elements/Structure.php @@ -94,10 +94,6 @@ public function getShape(): array public function normalize(mixed $value, Context $context): mixed { - if ($prevent = (is_array($value) && isset($value[Helpers::PreventMerging]))) { - unset($value[Helpers::PreventMerging]); - } - $value = $this->doNormalize($value, $context); if (is_object($value)) { $value = (array) $value; @@ -112,10 +108,6 @@ public function normalize(mixed $value, Context $context): mixed array_pop($context->path); } } - - if ($prevent) { - $value[Helpers::PreventMerging] = true; - } } return $value; diff --git a/src/Schema/Elements/Type.php b/src/Schema/Elements/Type.php index 69d5299..aa657d0 100644 --- a/src/Schema/Elements/Type.php +++ b/src/Schema/Elements/Type.php @@ -12,6 +12,7 @@ use Nette\Schema\Context; use Nette\Schema\DynamicParameter; use Nette\Schema\Helpers; +use Nette\Schema\MergeMode; use Nette\Schema\Schema; @@ -26,7 +27,8 @@ final class Type implements Schema /** @var array{?float, ?float} */ private array $range = [null, null]; private ?string $pattern = null; - private bool $merge = true; + private bool $merge = false; + private MergeMode $mergeMode = MergeMode::AppendKeys; public function __construct(string $type) @@ -44,13 +46,24 @@ public function nullable(): self } + /** @deprecated mergeDefaults is disabled by default */ public function mergeDefaults(bool $state = true): self { + if ($state === true) { + trigger_error(__METHOD__ . '() is deprecated and will be removed in the next major version.', E_USER_DEPRECATED); + } $this->merge = $state; return $this; } + public function mergeMode(MergeMode $mode): self + { + $this->mergeMode = $mode; + return $this; + } + + public function dynamic(): self { $this->type = DynamicParameter::class . '|' . $this->type; @@ -99,10 +112,6 @@ public function pattern(?string $pattern): self public function normalize(mixed $value, Context $context): mixed { - if ($prevent = (is_array($value) && isset($value[Helpers::PreventMerging]))) { - unset($value[Helpers::PreventMerging]); - } - $value = $this->doNormalize($value, $context); if (is_array($value) && $this->itemsValue) { $res = []; @@ -120,29 +129,24 @@ public function normalize(mixed $value, Context $context): mixed $value = $res; } - if ($prevent && is_array($value)) { - $value[Helpers::PreventMerging] = true; - } - return $value; } public function merge(mixed $value, mixed $base): mixed { - if (is_array($value) && isset($value[Helpers::PreventMerging])) { - unset($value[Helpers::PreventMerging]); + if ($this->mergeMode === MergeMode::Replace) { return $value; } - if (is_array($value) && is_array($base) && $this->itemsValue) { - $index = 0; + if (is_array($value) && is_array($base)) { + $index = $this->mergeMode === MergeMode::OverwriteKeys ? null : 0; foreach ($value as $key => $val) { if ($key === $index) { $base[] = $val; $index++; } else { - $base[$key] = array_key_exists($key, $base) + $base[$key] = array_key_exists($key, $base) && $this->itemsValue ? $this->itemsValue->merge($val, $base[$key]) : $val; } @@ -151,18 +155,12 @@ public function merge(mixed $value, mixed $base): mixed return $base; } - return Helpers::merge($value, $base); + return $value === null && is_array($base) ? $base : $value; } public function complete(mixed $value, Context $context): mixed { - $merge = $this->merge; - if (is_array($value) && isset($value[Helpers::PreventMerging])) { - unset($value[Helpers::PreventMerging]); - $merge = false; - } - if ($value === null && is_array($this->default)) { $value = []; // is unable to distinguish null from array in NEON } @@ -174,7 +172,7 @@ public function complete(mixed $value, Context $context): mixed $isOk() && Helpers::validateRange($value, $this->range, $context, $this->type); $isOk() && $value !== null && $this->pattern !== null && Helpers::validatePattern($value, $this->pattern, $context); $isOk() && is_array($value) && $this->validateItems($value, $context); - $isOk() && $merge && $value = Helpers::merge($value, $this->default); + $isOk() && $this->merge && $value = Helpers::merge($value, $this->default); $isOk() && $value = $this->doTransform($value, $context); if (!$isOk()) { return null; diff --git a/src/Schema/Expect.php b/src/Schema/Expect.php index eab3c84..4590610 100644 --- a/src/Schema/Expect.php +++ b/src/Schema/Expect.php @@ -63,30 +63,42 @@ public static function structure(array $shape): Structure } - public static function from(object $object, array $items = []): Structure + public static function from(object|string $object, array $items = []): Structure { - $ro = new \ReflectionObject($object); + $ro = new \ReflectionClass($object); $props = $ro->hasMethod('__construct') ? $ro->getMethod('__construct')->getParameters() : $ro->getProperties(); foreach ($props as $prop) { - $item = &$items[$prop->getName()]; - if (!$item) { - $type = Helpers::getPropertyType($prop) ?? 'mixed'; - $item = new Type($type); - if ($prop instanceof \ReflectionProperty ? $prop->isInitialized($object) : $prop->isOptional()) { - $def = ($prop instanceof \ReflectionProperty ? $prop->getValue($object) : $prop->getDefaultValue()); - if (is_object($def)) { - $item = static::from($def); - } elseif ($def === null && !Nette\Utils\Validators::is(null, $type)) { - $item->required(); - } else { - $item->default($def); - } + \assert($prop instanceof \ReflectionProperty || $prop instanceof \ReflectionParameter); + if ($item = &$items[$prop->getName()]) { + continue; + } + + $item = new Type($propType = (string) (Nette\Utils\Type::fromReflection($prop) ?? 'mixed')); + if (class_exists($propType)) { + $item = static::from($propType); + } + + $hasDefault = match (true) { + $prop instanceof \ReflectionParameter => $prop->isOptional(), + is_object($object) => $prop->isInitialized($object), + default => $prop->hasDefaultValue(), + }; + if ($hasDefault) { + $default = match (true) { + $prop instanceof \ReflectionParameter => $prop->getDefaultValue(), + is_object($object) => $prop->getValue($object), + default => $prop->getDefaultValue(), + }; + if (is_object($default)) { + $item = static::from($default); } else { - $item->required(); + $item->default($default); } + } else { + $item->required(); } } diff --git a/src/Schema/Helpers.php b/src/Schema/Helpers.php index 70bf183..8f4da1e 100644 --- a/src/Schema/Helpers.php +++ b/src/Schema/Helpers.php @@ -10,7 +10,6 @@ namespace Nette\Schema; use Nette; -use Nette\Utils\Reflection; /** @@ -55,41 +54,6 @@ public static function merge(mixed $value, mixed $base): mixed } - public static function getPropertyType(\ReflectionProperty|\ReflectionParameter $prop): ?string - { - if ($type = Nette\Utils\Type::fromReflection($prop)) { - return (string) $type; - } elseif ( - ($prop instanceof \ReflectionProperty) - && ($type = preg_replace('#\s.*#', '', (string) self::parseAnnotation($prop, 'var'))) - ) { - $class = Reflection::getPropertyDeclaringClass($prop); - return preg_replace_callback('#[\w\\\\]+#', fn($m) => Reflection::expandClassName($m[0], $class), $type); - } - - return null; - } - - - /** - * Returns an annotation value. - * @param \ReflectionProperty $ref - */ - public static function parseAnnotation(\Reflector $ref, string $name): ?string - { - if (!Reflection::areCommentsAvailable()) { - throw new Nette\InvalidStateException('You have to enable phpDoc comments in opcode cache.'); - } - - $re = '#[\s*]@' . preg_quote($name, '#') . '(?=\s|$)(?:[ \t]+([^@\s]\S*))?#'; - if ($ref->getDocComment() && preg_match($re, trim($ref->getDocComment(), '/*'), $m)) { - return $m[1] ?? ''; - } - - return null; - } - - public static function formatValue(mixed $value): string { if ($value instanceof DynamicParameter) { diff --git a/src/Schema/MergeMode.php b/src/Schema/MergeMode.php new file mode 100644 index 0000000..633fb6f --- /dev/null +++ b/src/Schema/MergeMode.php @@ -0,0 +1,23 @@ + 'val1', 'key2' => 'val2', 'val3', 'arr' => ['item'], - ])->mergeDefaults(false); + ]); Assert::same([], (new Processor)->process($schema, [])); @@ -53,13 +53,13 @@ test('not merging', function () { }); -test('merging', function () { - $schema = Expect::array([ +test('merging default value', function () { + $schema = @Expect::array([ // mergeDefaults() is deprecated 'key1' => 'val1', 'key2' => 'val2', 'val3', 'arr' => ['item'], - ]); + ])->mergeDefaults(true); Assert::same([ 'key1' => 'val1', @@ -95,48 +95,15 @@ test('merging', function () { 'arr' => ['newitem'], ]), ); - - Assert::same( - [ - 'key1' => 'newval', - 'key3' => 'newval', - 'newval3', - 'arr' => ['newitem'], - ], - (new Processor)->process($schema, [ - Helpers::PreventMerging => true, - 'key1' => 'newval', - 'key3' => 'newval', - 'newval3', - 'arr' => ['newitem'], - ]), - ); - - Assert::same( - [ - 'key1' => 'newval', - 'key2' => 'val2', - 'val3', - 'arr' => ['newitem'], - 'key3' => 'newval', - 'newval3', - ], - (new Processor)->process($schema, [ - 'key1' => 'newval', - 'key3' => 'newval', - 'newval3', - 'arr' => [Helpers::PreventMerging => true, 'newitem'], - ]), - ); }); -test('merging & other items validation', function () { - $schema = Expect::array([ +test('merging default value & other items validation', function () { + $schema = @Expect::array([ // mergeDefaults() is deprecated 'key1' => 'val1', 'key2' => 'val2', 'val3', - ])->items('string'); + ])->mergeDefaults(true)->items('string'); Assert::same([ 'key1' => 'val1', @@ -169,7 +136,7 @@ test('merging & other items validation', function () { }); -test('merging & other items validation', function () { +test('merging default value & other items validation', function () { $schema = Expect::array()->items('string'); Assert::same([ @@ -204,11 +171,9 @@ test('merging & other items validation', function () { test('items() & scalar', function () { - $schema = Expect::array([ - 'a' => 'defval', - ])->items('string'); + $schema = Expect::array()->items('string'); - Assert::same(['a' => 'defval'], (new Processor)->process($schema, [])); + Assert::same([], (new Processor)->process($schema, [])); checkValidationErrors(function () use ($schema) { (new Processor)->process($schema, [1, 2, 3]); @@ -232,16 +197,14 @@ test('items() & scalar', function () { (new Processor)->process($schema, ['b' => null]); }, ["The item 'b' expects to be string, null given."]); - Assert::same(['a' => 'defval', 'b' => 'val'], (new Processor)->process($schema, ['b' => 'val'])); + Assert::same(['b' => 'val'], (new Processor)->process($schema, ['b' => 'val'])); }); test('items() & structure', function () { - $schema = Expect::array([ - 'a' => 'defval', - ])->items(Expect::structure(['k' => Expect::string()])); + $schema = Expect::array([])->items(Expect::structure(['k' => Expect::string()])); - Assert::same(['a' => 'defval'], (new Processor)->process($schema, [])); + Assert::same([], (new Processor)->process($schema, [])); checkValidationErrors(function () use ($schema) { (new Processor)->process($schema, ['a' => 'val']); @@ -264,7 +227,7 @@ test('items() & structure', function () { }, ["Unexpected item 'b\u{a0}›\u{a0}a', did you mean 'k'?"]); Assert::equal( - ['a' => 'defval', 'b' => (object) ['k' => 'val']], + ['b' => (object) ['k' => 'val']], (new Processor)->process($schema, ['b' => ['k' => 'val']]), ); }); @@ -347,3 +310,34 @@ test('array shape', function () { (new Processor)->process($schema, []), ); }); + + +test('merge modes', function () { + $schema = Expect::structure([ + 'foo1' => Expect::array()->mergeMode(MergeMode::Replace), + 'foo2' => Expect::array()->mergeMode(MergeMode::OverwriteKeys), + 'foo3' => Expect::array()->mergeMode(MergeMode::AppendKeys), + ]); + + $processor = new Processor; + + Assert::equal( + (object) [ + 'foo1' => ['key' => 'new'], + 'foo2' => ['new', 'key' => 'new'], + 'foo3' => ['old', 'new', 'key' => 'new'], + ], + $processor->processMultiple($schema, [ + [ + 'foo1' => ['old', 'key' => '1'], + 'foo2' => ['old', 'key' => '1'], + 'foo3' => ['old', 'key' => '1'], + ], + [ + 'foo1' => ['key' => 'new'], + 'foo2' => ['new', 'key' => 'new'], + 'foo3' => ['new', 'key' => 'new'], + ], + ]), + ); +}); diff --git a/tests/Schema/Expect.from.php80.phpt b/tests/Schema/Expect.from.dynamic.phpt similarity index 62% rename from tests/Schema/Expect.from.php80.phpt rename to tests/Schema/Expect.from.dynamic.phpt index 1cf34c6..cb65ea9 100644 --- a/tests/Schema/Expect.from.php80.phpt +++ b/tests/Schema/Expect.from.dynamic.phpt @@ -11,6 +11,15 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; +Assert::with(Structure::class, function () { + $schema = Expect::from(new stdClass); + + Assert::type(Structure::class, $schema); + Assert::same([], $schema->items); + Assert::type(stdClass::class, (new Processor)->process($schema, [])); +}); + + Assert::with(Structure::class, function () { $schema = Expect::from($obj = new class { public string $dsn = 'mysql'; @@ -55,3 +64,35 @@ Assert::with(Structure::class, function () { // constructor injection (new Processor)->process($schema, ['user' => 'foo', 'password' => 'bar']), ); }); + + +Assert::with(Structure::class, function () { // overwritten item + $schema = Expect::from(new class { + public string $dsn = 'mysql'; + + public ?string $user; + }, ['dsn' => Expect::int(123)]); + + Assert::equal([ + 'dsn' => Expect::int(123), + 'user' => Expect::type('?string')->required(), + ], $schema->items); +}); + + +Assert::with(Structure::class, function () { // nested object + $obj = new class { + public object $inner; + }; + $obj->inner = new class { + public string $name; + }; + + $schema = Expect::from($obj); + + Assert::equal([ + 'inner' => Expect::structure([ + 'name' => Expect::string()->required(), + ])->castTo(get_class($obj->inner)), + ], $schema->items); +}); diff --git a/tests/Schema/Expect.from.phpt b/tests/Schema/Expect.from.phpt deleted file mode 100644 index 1881202..0000000 --- a/tests/Schema/Expect.from.phpt +++ /dev/null @@ -1,104 +0,0 @@ -items); - Assert::type(stdClass::class, (new Processor)->process($schema, [])); -}); - - -Assert::with(Structure::class, function () { - $schema = Expect::from($obj = new class { - /** @var string */ - public $dsn = 'mysql'; - - /** @var string|null */ - public $user; - - /** @var ?string */ - public $password; - - /** @var string[] */ - public $options = [1]; - - /** @var bool */ - public $debugger = true; - public $mixed; - - /** @var array|null */ - public $arr; - - /** @var string */ - public $required; - }); - - Assert::type(Structure::class, $schema); - Assert::equal([ - 'dsn' => Expect::string('mysql'), - 'user' => Expect::type('string|null'), - 'password' => Expect::type('?string'), - 'options' => Expect::type('string[]')->default([1]), - 'debugger' => Expect::bool(true), - 'mixed' => Expect::mixed(), - 'arr' => Expect::type('array|null')->default(null), - 'required' => Expect::type('string')->required(), - ], $schema->items); - Assert::type($obj, (new Processor)->process($schema, ['required' => ''])); -}); - - -Assert::exception(function () { - Expect::from(new class { - /** @var Unknown */ - public $unknown; - }); -}, Nette\NotImplementedException::class, 'Anonymous classes are not supported.'); - - -Assert::with(Structure::class, function () { // overwritten item - $schema = Expect::from(new class { - /** @var string */ - public $dsn = 'mysql'; - - /** @var string|null */ - public $user; - }, ['dsn' => Expect::int(123)]); - - Assert::equal([ - 'dsn' => Expect::int(123), - 'user' => Expect::type('string|null'), - ], $schema->items); -}); - - -Assert::with(Structure::class, function () { // nested object - $obj = new class { - /** @var object */ - public $inner; - }; - $obj->inner = new class { - /** @var string */ - public $name; - }; - - $schema = Expect::from($obj); - - Assert::equal([ - 'inner' => Expect::structure([ - 'name' => Expect::string()->required(), - ])->castTo(get_class($obj->inner)), - ], $schema->items); -}); diff --git a/tests/Schema/Expect.from.static.phpt b/tests/Schema/Expect.from.static.phpt new file mode 100644 index 0000000..581a4f3 --- /dev/null +++ b/tests/Schema/Expect.from.static.phpt @@ -0,0 +1,109 @@ +items); + Assert::type(stdClass::class, (new Processor)->process($schema, [])); +}); + + +Assert::with(Structure::class, function () { + class Data1 + { + public string $dsn = 'mysql'; + public ?string $user; + public ?string $password = null; + public array|int $options = []; + public bool $debugger = true; + public mixed $mixed; + public array $arr = [1]; + } + + $schema = Expect::from(Data1::class); + + Assert::type(Structure::class, $schema); + Assert::equal([ + 'dsn' => Expect::string('mysql'), + 'user' => Expect::type('?string')->required(), + 'password' => Expect::type('?string'), + 'options' => Expect::type('array|int')->default([]), + 'debugger' => Expect::bool(true), + 'mixed' => Expect::mixed()->required(), + 'arr' => Expect::type('array')->default([1]), + ], $schema->items); + Assert::type(Data1::class, (new Processor)->process($schema, ['user' => '', 'mixed' => ''])); +}); + + +Assert::with(Structure::class, function () { // constructor injection + class Data2 + { + public function __construct( + public ?string $user, + public ?string $password = null, + ) { + } + } + + $schema = Expect::from(Data2::class); + + Assert::type(Structure::class, $schema); + Assert::equal([ + 'user' => Expect::type('?string')->required(), + 'password' => Expect::type('?string'), + ], $schema->items); + Assert::equal( + new Data2('foo', 'bar'), + (new Processor)->process($schema, ['user' => 'foo', 'password' => 'bar']), + ); +}); + + +Assert::with(Structure::class, function () { // overwritten item + class Data3 + { + public string $dsn = 'mysql'; + public ?string $user; + } + + $schema = Expect::from(Data3::class, ['dsn' => Expect::int(123)]); + + Assert::equal([ + 'dsn' => Expect::int(123), + 'user' => Expect::type('?string')->required(), + ], $schema->items); +}); + + +Assert::with(Structure::class, function () { // nested object + class Data4 + { + public Data5 $inner; + } + + class Data5 + { + public string $name; + } + + $schema = Expect::from(Data4::class); + + Assert::equal([ + 'inner' => Expect::structure([ + 'name' => Expect::string()->required(), + ])->castTo(Data5::class), + ], $schema->items); +}); diff --git a/tests/Schema/Expect.list.phpt b/tests/Schema/Expect.list.phpt index 52a3e53..f565b53 100644 --- a/tests/Schema/Expect.list.phpt +++ b/tests/Schema/Expect.list.phpt @@ -37,8 +37,8 @@ test('without default value', function () { }); -test('not merging', function () { - $schema = Expect::list([1, 2, 3])->mergeDefaults(false); +test('not merging default value', function () { + $schema = Expect::list([1, 2, 3]); Assert::same([], (new Processor)->process($schema, [])); @@ -48,8 +48,8 @@ test('not merging', function () { }); -test('merging', function () { - $schema = Expect::list([1, 2, 3]); +test('merging default value', function () { + $schema = @Expect::list([1, 2, 3])->mergeDefaults(true); // mergeDefaults() is deprecated Assert::same([1, 2, 3], (new Processor)->process($schema, [])); @@ -59,8 +59,8 @@ test('merging', function () { }); -test('merging & other items validation', function () { - $schema = Expect::list([1, 2, 3])->items('string'); +test('merging default value & other items validation', function () { + $schema = @Expect::list([1, 2, 3])->mergeDefaults(true)->items('string'); // mergeDefaults() is deprecated Assert::same([1, 2, 3], (new Processor)->process($schema, [])); diff --git a/tests/Schema/Helpers.getPropertyType.phpt b/tests/Schema/Helpers.getPropertyType.phpt deleted file mode 100644 index 998bec5..0000000 --- a/tests/Schema/Helpers.getPropertyType.phpt +++ /dev/null @@ -1,37 +0,0 @@ -