From cb36681141f17dab1350bf75998ea35067712859 Mon Sep 17 00:00:00 2001 From: George Steel Date: Tue, 20 Sep 2022 17:37:50 +0100 Subject: [PATCH 1/4] Initial revision of concrete validation result By using a value object to represent the validation result, error message iteration and translation can be extracted from all the validators. Signed-off-by: George Steel --- composer.json | 4 +- composer.lock | 207 +++++++++++-------------------------- src/ValidationResult.php | 217 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 148 deletions(-) create mode 100644 src/ValidationResult.php diff --git a/composer.json b/composer.json index 715d2b73..682c4ea3 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "config": { "sort-packages": true, "platform": { - "php": "7.4.99" + "php": "8.0.99" }, "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true @@ -31,7 +31,7 @@ } }, "require": { - "php": "^7.4 || ~8.0.0 || ~8.1.0", + "php": "~8.0.0 || ~8.1.0", "laminas/laminas-servicemanager": "^3.12.0", "laminas/laminas-stdlib": "^3.13" }, diff --git a/composer.lock b/composer.lock index 7034189a..be455b87 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "85c2b13ee36a9a4429eca67b43f03d0a", + "content-hash": "fd8808a94b22406148ab416a21490de0", "packages": [ { "name": "laminas/laminas-servicemanager", @@ -1348,23 +1348,23 @@ }, { "name": "laminas/laminas-i18n", - "version": "2.17.0", + "version": "2.18.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-i18n.git", - "reference": "7e8e63353b38792f2f360dc57cfa7187be20f182" + "reference": "ba4c7a12e0ff63a033aa16d4aa061ea09bda7f98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-i18n/zipball/7e8e63353b38792f2f360dc57cfa7187be20f182", - "reference": "7e8e63353b38792f2f360dc57cfa7187be20f182", + "url": "https://api.github.com/repos/laminas/laminas-i18n/zipball/ba4c7a12e0ff63a033aa16d4aa061ea09bda7f98", + "reference": "ba4c7a12e0ff63a033aa16d4aa061ea09bda7f98", "shasum": "" }, "require": { "ext-intl": "*", "laminas/laminas-servicemanager": "^3.14.0", "laminas/laminas-stdlib": "^2.7 || ^3.0", - "php": "^7.4 || ~8.0.0 || ~8.1.0" + "php": "~8.0.0 || ~8.1.0" }, "conflict": { "laminas/laminas-view": "<2.20.0", @@ -1375,13 +1375,12 @@ "laminas/laminas-cache": "^3.1.2", "laminas/laminas-cache-storage-adapter-memory": "^2.0.0", "laminas/laminas-cache-storage-deprecated-factory": "^1.0.0", - "laminas/laminas-coding-standard": "~2.3.0", + "laminas/laminas-coding-standard": "~2.4.0", "laminas/laminas-config": "^3.4.0", "laminas/laminas-eventmanager": "^3.5.0", "laminas/laminas-filter": "^2.16.0", "laminas/laminas-validator": "^2.17.0", "laminas/laminas-view": "^2.21.0", - "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5.21", "psalm/plugin-phpunit": "^0.17.0", "vimeo/psalm": "^4.24.0" @@ -1431,7 +1430,7 @@ "type": "community_bridge" } ], - "time": "2022-07-27T11:23:29+00:00" + "time": "2022-09-13T16:07:21+00:00" }, { "name": "laminas/laminas-loader", @@ -2921,30 +2920,30 @@ }, { "name": "psr/log", - "version": "1.1.4", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2965,9 +2964,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "source": "https://github.com/php-fig/log/tree/3.0.0" }, - "time": "2021-05-03T11:20:27+00:00" + "time": "2021-07-14T16:46:02+00:00" }, { "name": "sebastian/cli-parser", @@ -4052,46 +4051,42 @@ }, { "name": "symfony/console", - "version": "v5.4.12", + "version": "v6.0.12", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c072aa8f724c3af64e2c7a96b796a4863d24dba1" + "reference": "c5c2e313aa682530167c25077d6bdff36346251e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c072aa8f724c3af64e2c7a96b796a4863d24dba1", - "reference": "c072aa8f724c3af64e2c7a96b796a4863d24dba1", + "url": "https://api.github.com/repos/symfony/console/zipball/c5c2e313aa682530167c25077d6bdff36346251e", + "reference": "c5c2e313aa682530167c25077d6bdff36346251e", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", + "php": ">=8.0.2", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php73": "^1.9", - "symfony/polyfill-php80": "^1.16", "symfony/service-contracts": "^1.1|^2|^3", - "symfony/string": "^5.1|^6.0" + "symfony/string": "^5.4|^6.0" }, "conflict": { - "psr/log": ">=3", - "symfony/dependency-injection": "<4.4", - "symfony/dotenv": "<5.1", - "symfony/event-dispatcher": "<4.4", - "symfony/lock": "<4.4", - "symfony/process": "<4.4" + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" }, "provide": { - "psr/log-implementation": "1.0|2.0" + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "psr/log": "^1|^2", - "symfony/config": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/event-dispatcher": "^4.4|^5.0|^6.0", - "symfony/lock": "^4.4|^5.0|^6.0", - "symfony/process": "^4.4|^5.0|^6.0", - "symfony/var-dumper": "^4.4|^5.0|^6.0" + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/lock": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" }, "suggest": { "psr/log": "For using the console logger", @@ -4131,7 +4126,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.12" + "source": "https://github.com/symfony/console/tree/v6.0.12" }, "funding": [ { @@ -4147,29 +4142,29 @@ "type": "tidelift" } ], - "time": "2022-08-17T13:18:05+00:00" + "time": "2022-08-23T20:52:30+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v2.5.2", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" + "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", + "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.0.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.0-dev" }, "thanks": { "name": "symfony/contracts", @@ -4198,7 +4193,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.2" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.2" }, "funding": [ { @@ -4214,7 +4209,7 @@ "type": "tidelift" } ], - "time": "2022-01-02T09:53:40+00:00" + "time": "2022-01-02T09:55:41+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4546,85 +4541,6 @@ ], "time": "2022-05-24T11:49:31+00:00" }, - { - "name": "symfony/polyfill-php73", - "version": "v1.26.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/e440d35fa0286f77fb45b79a03fedbeda9307e85", - "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.26-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.26.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-05-24T11:49:31+00:00" - }, { "name": "symfony/polyfill-php80", "version": "v1.26.0", @@ -4793,34 +4709,33 @@ }, { "name": "symfony/string", - "version": "v5.4.12", + "version": "v6.0.12", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "2fc515e512d721bf31ea76bd02fe23ada4640058" + "reference": "3a975ba1a1508ad97df45f4590f55b7cc4c1a0a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/2fc515e512d721bf31ea76bd02fe23ada4640058", - "reference": "2fc515e512d721bf31ea76bd02fe23ada4640058", + "url": "https://api.github.com/repos/symfony/string/zipball/3a975ba1a1508ad97df45f4590f55b7cc4c1a0a0", + "reference": "3a975ba1a1508ad97df45f4590f55b7cc4c1a0a0", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.0.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "~1.15" + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/translation-contracts": ">=3.0" + "symfony/translation-contracts": "<2.0" }, "require-dev": { - "symfony/error-handler": "^4.4|^5.0|^6.0", - "symfony/http-client": "^4.4|^5.0|^6.0", - "symfony/translation-contracts": "^1.1|^2", - "symfony/var-exporter": "^4.4|^5.0|^6.0" + "symfony/error-handler": "^5.4|^6.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/translation-contracts": "^2.0|^3.0", + "symfony/var-exporter": "^5.4|^6.0" }, "type": "library", "autoload": { @@ -4859,7 +4774,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.4.12" + "source": "https://github.com/symfony/string/tree/v6.0.12" }, "funding": [ { @@ -4875,7 +4790,7 @@ "type": "tidelift" } ], - "time": "2022-08-12T17:03:11+00:00" + "time": "2022-08-12T18:05:20+00:00" }, { "name": "theseer/tokenizer", @@ -5205,11 +5120,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.4 || ~8.0.0 || ~8.1.0" + "php": "~8.0.0 || ~8.1.0" }, "platform-dev": [], "platform-overrides": { - "php": "7.4.99" + "php": "8.0.99" }, "plugin-api-version": "2.3.0" } diff --git a/src/ValidationResult.php b/src/ValidationResult.php new file mode 100644 index 00000000..70fa3e1e --- /dev/null +++ b/src/ValidationResult.php @@ -0,0 +1,217 @@ + */ +final class ValidationResult implements IteratorAggregate, Countable +{ + /** @var list */ + private array $errors = []; + + /** + * @param array $templates Message templates that will be interpolated with the given + * value and other message variables. + * @param array $variables Arbitrary variables to interpolate into error messages. + * @param bool $valueObscured Whether the value should be obscured in error messages. + * @param TranslatorInterface|null $translator An optional translator with which to translate error + * messages. + * @param non-empty-string $textDomain Translator text domain to use for translations. + * @param mixed $value The input value that was validated. + */ + private function __construct( + private array $templates, + private array $variables, + private mixed $value, + private bool $valueObscured = false, + private ?TranslatorInterface $translator = null, + private string $textDomain = 'default', + ) { + } + + /** + * @param array $templates Message templates that will be interpolated with the given + * value and other message variables. + * @param array $variables Arbitrary variables to interpolate into error messages. + * @param bool $valueObscured Whether the value should be obscured in error messages. + * @param TranslatorInterface|null $translator An optional translator with which to translate error + * messages. + * @param non-empty-string $textDomain Translator text domain to use for translations. + * @param mixed $value The input value that was validated. + */ + public static function new( + array $templates, + array $variables, + mixed $value, + bool $valueObscured = false, + ?TranslatorInterface $translator = null, + string $textDomain = 'default', + ): self { + return new self($templates, $variables, $value, $valueObscured, $translator, $textDomain); + } + + public function isValid(): bool + { + return $this->errors === []; + } + + public function withValue(mixed $value): self + { + $result = clone $this; + $result->value = self::stringify($value); + + return $result; + } + + /** @param non-empty-string $key */ + public function withError(string $key): self + { + if (! array_key_exists($key, $this->templates)) { + throw new InvalidArgumentException(sprintf( + 'An error message template named "%s" does not exist', + $key + )); + } + + $result = clone $this; + $result->errors[] = $key; + + return $result; + } + + public function withVariable(string $name, mixed $value): self + { + $result = clone $this; + $result->variables[$name] = $value; + + return $result; + } + + public function getMessages(): array + { + return array_map(function (string $key): string { + assert(array_key_exists($key, $this->templates)); + $message = $this->templates[$key]; + if ($this->translator) { + $message = $this->translator->translate($message, $this->textDomain); + } + + $message = $this->interpolateVariable('value', $this->getValue(), $message); + /** @psalm-suppress MixedAssignment */ + foreach ($this->variables as $name => $variable) { + $message = $this->interpolateVariable($name, $variable, $message); + } + + return $message; + }, $this->errors); + } + + public function merge(self $other): self + { + $result = clone $this; + $result->errors = array_merge($result->errors, $other->errors); + $result->templates = array_replace($result->templates, $other->templates); + $result->variables = array_replace($result->variables, $other->variables); + + return $result; + } + + public function getValue(): string + { + $value = self::stringify($this->value); + if ($this->valueObscured) { + $value = str_repeat('*', strlen($value)); + } + + return $value; + } + + private function interpolateVariable(string $name, mixed $value, string $intoMessage): string + { + return str_replace( + '%' . $name . '%', + self::stringify($value), + $intoMessage + ); + } + + /** @psalm-pure */ + private static function stringify(mixed $value): string + { + if (is_float($value) || is_int($value) || is_string($value)) { + return (string) $value; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value === null) { + return 'null'; + } + + if (is_array($value)) { + return 'array'; + } + + if (is_resource($value)) { + return 'resource'; + } + + assert(is_object($value)); + + return self::stringifyObject($value); + } + + /** @psalm-pure */ + private static function stringifyObject(object $value): string + { + if (method_exists($value, 'toString')) { + return (string) $value->toString(); + } + + if (method_exists($value, '__toString')) { + return (string) $value; + } + + return $value::class; + } + + /** @return Traversable */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->getMessages()); + } + + public function count(): int + { + return count($this->errors); + } +} From 62f7efc1694f29f460716190aa9b5b6936ac074b Mon Sep 17 00:00:00 2001 From: George Steel Date: Tue, 20 Sep 2022 17:39:38 +0100 Subject: [PATCH 2/4] Example refactor of StringLength to return a validation result Signed-off-by: George Steel --- src/StringLength.php | 67 ++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src/StringLength.php b/src/StringLength.php index 64edff34..20df1aae 100644 --- a/src/StringLength.php +++ b/src/StringLength.php @@ -13,32 +13,30 @@ use function is_string; use function max; -class StringLength extends AbstractValidator +/** + * @psalm-type OptionsArray array{ + * min: int, + * max: int|null, + * encoding: string, + * } + */ +class StringLength { public const INVALID = 'stringLengthInvalid'; public const TOO_SHORT = 'stringLengthTooShort'; public const TOO_LONG = 'stringLengthTooLong'; - /** @var array */ - protected $messageTemplates = [ + private const TEMPLATES = [ self::INVALID => 'Invalid type given. String expected', self::TOO_SHORT => 'The input is less than %min% characters long', self::TOO_LONG => 'The input is more than %max% characters long', ]; - /** @var array> */ - protected $messageVariables = [ - 'min' => ['options' => 'min'], - 'max' => ['options' => 'max'], - 'length' => ['options' => 'length'], - ]; - - /** @var array */ - protected $options = [ + /** @var OptionsArray */ + private array $options = [ 'min' => 0, // Minimum length 'max' => null, // Maximum length, null if there is no length limitation 'encoding' => 'UTF-8', // Encoding to use - 'length' => 0, // Actual length ]; /** @var null|StringWrapperInterface */ @@ -64,8 +62,6 @@ public function __construct($options = []) $options = $temp; } - - parent::__construct($options); } /** @@ -134,7 +130,7 @@ public function setMax($max) * * @return StringWrapper */ - public function getStringWrapper() + public function getStringWrapper(): StringWrapper { if (! $this->stringWrapper) { $this->stringWrapper = StringUtils::getWrapper($this->getEncoding()); @@ -202,32 +198,37 @@ private function setLength($length) /** * Returns true if and only if the string length of $value is at least the min option and * no greater than the max option (when the max option is not null). - * - * @param string $value - * @return bool */ - public function isValid($value) + public function isValid(mixed $value): ValidationResult { + $result = ValidationResult::new( + self::TEMPLATES, + [ + 'min' => $this->getMin(), + 'max' => $this->getMax(), + 'length' => $this->getLength(), + ], + $value, + false, + null, // $this->translator + 'default' // $this->textDomain + ); + if (! is_string($value)) { - $this->error(self::INVALID); - return false; + return $result->withError(self::INVALID); } - $this->setValue($value); - - $this->setLength($this->getStringWrapper()->strlen($value)); - if ($this->getLength() < $this->getMin()) { - $this->error(self::TOO_SHORT); - } + $length = $this->getStringWrapper()->strlen($value); + $result = $result->withVariable('length', $length); - if (null !== $this->getMax() && $this->getMax() < $this->getLength()) { - $this->error(self::TOO_LONG); + if ($length < $this->getMin()) { + $result = $result->withError(self::TOO_SHORT); } - if ($this->getMessages()) { - return false; + if (null !== $this->getMax() && $this->getMax() < $length) { + $result = $result->withError(self::TOO_LONG); } - return true; + return $result; } } From f20aeb081c9d478ee1ad3b13dde7a4ab10008394 Mon Sep 17 00:00:00 2001 From: George Steel Date: Wed, 21 Sep 2022 14:32:29 +0100 Subject: [PATCH 3/4] Modify ValidatorInterface removing message retrieval Signed-off-by: George Steel --- src/StringLength.php | 11 +++++++++-- src/ValidatorInterface.php | 24 ++++++++---------------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/StringLength.php b/src/StringLength.php index 20df1aae..fffbd5d9 100644 --- a/src/StringLength.php +++ b/src/StringLength.php @@ -1,5 +1,7 @@ validate($value, $context)->isValid(); + } + /** * Returns true if and only if the string length of $value is at least the min option and * no greater than the max option (when the max option is not null). */ - public function isValid(mixed $value): ValidationResult + public function validate(mixed $value, ?array $context = null): ValidationResult { $result = ValidationResult::new( self::TEMPLATES, diff --git a/src/ValidatorInterface.php b/src/ValidatorInterface.php index af83fbd0..3676c524 100644 --- a/src/ValidatorInterface.php +++ b/src/ValidatorInterface.php @@ -1,5 +1,7 @@ + * @param array $context + * @throws Exception\RuntimeException If validation of $value is impossible. */ - public function getMessages(); + public function isValid(mixed $value, ?array $context = null): bool; } From 36b691cdfab02f11e4fbe9d0388608e7caaeed5b Mon Sep 17 00:00:00 2001 From: George Steel Date: Wed, 21 Sep 2022 16:29:59 +0100 Subject: [PATCH 4/4] Split success and failure cases into separate values Signed-off-by: George Steel --- src/StringLength.php | 11 +- src/ValidationFailure.php | 248 ++++++++++++++++++++++++++++++++++++++ src/ValidationResult.php | 216 ++------------------------------- src/ValidationSuccess.php | 38 ++++++ 4 files changed, 301 insertions(+), 212 deletions(-) create mode 100644 src/ValidationFailure.php create mode 100644 src/ValidationSuccess.php diff --git a/src/StringLength.php b/src/StringLength.php index fffbd5d9..efa8eae3 100644 --- a/src/StringLength.php +++ b/src/StringLength.php @@ -205,10 +205,12 @@ public function isValid(mixed $value, ?array $context = null): bool /** * Returns true if and only if the string length of $value is at least the min option and * no greater than the max option (when the max option is not null). + * + * @return ValidationFailure|ValidationSuccess */ public function validate(mixed $value, ?array $context = null): ValidationResult { - $result = ValidationResult::new( + $result = ValidationFailure::new( self::TEMPLATES, [ 'min' => $this->getMin(), @@ -216,6 +218,7 @@ public function validate(mixed $value, ?array $context = null): ValidationResult 'length' => $this->getLength(), ], $value, + [], false, null, // $this->translator 'default' // $this->textDomain @@ -229,13 +232,13 @@ public function validate(mixed $value, ?array $context = null): ValidationResult $result = $result->withVariable('length', $length); if ($length < $this->getMin()) { - $result = $result->withError(self::TOO_SHORT); + return $result->withError(self::TOO_SHORT); } if (null !== $this->getMax() && $this->getMax() < $length) { - $result = $result->withError(self::TOO_LONG); + return $result->withError(self::TOO_LONG); } - return $result; + return ValidationSuccess::new($value); } } diff --git a/src/ValidationFailure.php b/src/ValidationFailure.php new file mode 100644 index 00000000..09e4f534 --- /dev/null +++ b/src/ValidationFailure.php @@ -0,0 +1,248 @@ + + * @implements IteratorAggregate + * @psalm-immutable + */ +final class ValidationFailure implements ValidationResult, IteratorAggregate, Countable +{ + /** + * @param array $templates Message templates that will be interpolated with the given + * value and other message variables. + * @param array $variables Arbitrary variables to interpolate into error messages. + * @param T $value The input value that was validated. + * @param list $errors The message template keys that describe the failure. + * @param bool $valueObscured Whether the value should be obscured in error messages. + * @param TranslatorInterface|null $translator An optional translator with which to translate error + * messages. + * @param non-empty-string $textDomain Translator text domain to use for translations. + */ + private function __construct( + private array $templates, + private array $variables, + private mixed $value, + private array $errors, + private bool $valueObscured = false, + private ?TranslatorInterface $translator = null, + private string $textDomain = 'default', + ) { + } + + /** + * @param array $templates Message templates that will be interpolated with the given + * value and other message variables. + * @param array $variables Arbitrary variables to interpolate into error messages. + * @param TValue $value The input value that was validated. + * @param list $errors The message template keys that describe the failure. + * @param bool $valueObscured Whether the value should be obscured in error messages. + * @param TranslatorInterface|null $translator An optional translator with which to translate error + * messages. + * @param non-empty-string $textDomain Translator text domain to use for translations. + * @return self + * @template TValue of mixed + */ + public static function new( + array $templates, + array $variables, + mixed $value, + array $errors, + bool $valueObscured = false, + ?TranslatorInterface $translator = null, + string $textDomain = 'default', + ): self { + return new self($templates, $variables, $value, $errors, $valueObscured, $translator, $textDomain); + } + + public function isValid(): bool + { + return false; + } + + /** @return T */ + public function value(): mixed + { + return $this->value; + } + + /** + * Return a clone with an updated value - i.e. the value that was validated. + * + * @param T $value + * @return self + */ + public function withValue(mixed $value): self + { + $result = clone $this; + $result->value = $value; + + return $result; + } + + /** + * Return a clone with the additional error listed as a reason for validation failure + * + * @param non-empty-string $key + * @throws InvalidArgumentException If $key is not present in the list of message templates. + */ + public function withError(string $key): self + { + if (! array_key_exists($key, $this->templates)) { + throw new InvalidArgumentException(sprintf( + 'An error message template named "%s" does not exist', + $key + )); + } + + $result = clone $this; + $result->errors[] = $key; + + return $result; + } + + public function withVariable(string $name, mixed $value): self + { + $result = clone $this; + $result->variables[$name] = $value; + + return $result; + } + + /** @return non-empty-array */ + public function getMessages(): array + { + $messages = []; + foreach ($this->errors as $key) { + assert(array_key_exists($key, $this->templates)); + $message = $this->templates[$key]; + if ($this->translator) { + /** @psalm-suppress ImpureMethodCall $message */ + $message = $this->translator->translate($message, $this->textDomain); + } + + $message = $this->interpolateVariable('value', $this->getValue(), $message); + /** @psalm-suppress MixedAssignment */ + foreach ($this->variables as $name => $variable) { + $message = $this->interpolateVariable($name, $variable, $message); + } + + assert($message !== ''); + + $messages[$key] = $message; + } + + return $messages; + } + + public function merge(self $other): self + { + $result = clone $this; + $result->errors = array_merge($result->errors, $other->errors); + $result->templates = array_replace($result->templates, $other->templates); + $result->variables = array_replace($result->variables, $other->variables); + + return $result; + } + + public function getValue(): string + { + $value = self::stringify($this->value); + if ($this->valueObscured) { + $value = str_repeat('*', strlen($value)); + } + + return $value; + } + + private function interpolateVariable(string $name, mixed $value, string $intoMessage): string + { + return str_replace( + '%' . $name . '%', + self::stringify($value), + $intoMessage + ); + } + + /** @psalm-pure */ + private static function stringify(mixed $value): string + { + if (is_float($value) || is_int($value) || is_string($value)) { + return (string) $value; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value === null) { + return 'null'; + } + + if (is_array($value)) { + return 'array'; + } + + if (is_resource($value)) { + return 'resource'; + } + + assert(is_object($value)); + + return self::stringifyObject($value); + } + + /** @psalm-pure */ + private static function stringifyObject(object $value): string + { + if (method_exists($value, 'toString')) { + return (string) $value->toString(); + } + + if (method_exists($value, '__toString')) { + return (string) $value; + } + + return $value::class; + } + + /** @return Traversable */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->getMessages()); + } + + public function count(): int + { + return count($this->errors); + } +} diff --git a/src/ValidationResult.php b/src/ValidationResult.php index 70fa3e1e..a5232fa9 100644 --- a/src/ValidationResult.php +++ b/src/ValidationResult.php @@ -4,214 +4,14 @@ namespace Laminas\Validator; -use ArrayIterator; -use Countable; -use IteratorAggregate; -use Laminas\Validator\Exception\InvalidArgumentException; -use Laminas\Validator\Translator\TranslatorInterface; -use Traversable; - -use function array_key_exists; -use function array_map; -use function array_merge; -use function array_replace; -use function assert; -use function count; -use function is_array; -use function is_bool; -use function is_float; -use function is_int; -use function is_object; -use function is_resource; -use function is_string; -use function method_exists; -use function sprintf; -use function str_repeat; -use function str_replace; -use function strlen; - -/** @implements IteratorAggregate */ -final class ValidationResult implements IteratorAggregate, Countable +/** + * @template T of mixed + * @psalm-immutable + */ +interface ValidationResult { - /** @var list */ - private array $errors = []; - - /** - * @param array $templates Message templates that will be interpolated with the given - * value and other message variables. - * @param array $variables Arbitrary variables to interpolate into error messages. - * @param bool $valueObscured Whether the value should be obscured in error messages. - * @param TranslatorInterface|null $translator An optional translator with which to translate error - * messages. - * @param non-empty-string $textDomain Translator text domain to use for translations. - * @param mixed $value The input value that was validated. - */ - private function __construct( - private array $templates, - private array $variables, - private mixed $value, - private bool $valueObscured = false, - private ?TranslatorInterface $translator = null, - private string $textDomain = 'default', - ) { - } - - /** - * @param array $templates Message templates that will be interpolated with the given - * value and other message variables. - * @param array $variables Arbitrary variables to interpolate into error messages. - * @param bool $valueObscured Whether the value should be obscured in error messages. - * @param TranslatorInterface|null $translator An optional translator with which to translate error - * messages. - * @param non-empty-string $textDomain Translator text domain to use for translations. - * @param mixed $value The input value that was validated. - */ - public static function new( - array $templates, - array $variables, - mixed $value, - bool $valueObscured = false, - ?TranslatorInterface $translator = null, - string $textDomain = 'default', - ): self { - return new self($templates, $variables, $value, $valueObscured, $translator, $textDomain); - } - - public function isValid(): bool - { - return $this->errors === []; - } - - public function withValue(mixed $value): self - { - $result = clone $this; - $result->value = self::stringify($value); - - return $result; - } - - /** @param non-empty-string $key */ - public function withError(string $key): self - { - if (! array_key_exists($key, $this->templates)) { - throw new InvalidArgumentException(sprintf( - 'An error message template named "%s" does not exist', - $key - )); - } - - $result = clone $this; - $result->errors[] = $key; - - return $result; - } - - public function withVariable(string $name, mixed $value): self - { - $result = clone $this; - $result->variables[$name] = $value; - - return $result; - } - - public function getMessages(): array - { - return array_map(function (string $key): string { - assert(array_key_exists($key, $this->templates)); - $message = $this->templates[$key]; - if ($this->translator) { - $message = $this->translator->translate($message, $this->textDomain); - } - - $message = $this->interpolateVariable('value', $this->getValue(), $message); - /** @psalm-suppress MixedAssignment */ - foreach ($this->variables as $name => $variable) { - $message = $this->interpolateVariable($name, $variable, $message); - } - - return $message; - }, $this->errors); - } - - public function merge(self $other): self - { - $result = clone $this; - $result->errors = array_merge($result->errors, $other->errors); - $result->templates = array_replace($result->templates, $other->templates); - $result->variables = array_replace($result->variables, $other->variables); - - return $result; - } - - public function getValue(): string - { - $value = self::stringify($this->value); - if ($this->valueObscured) { - $value = str_repeat('*', strlen($value)); - } - - return $value; - } - - private function interpolateVariable(string $name, mixed $value, string $intoMessage): string - { - return str_replace( - '%' . $name . '%', - self::stringify($value), - $intoMessage - ); - } - - /** @psalm-pure */ - private static function stringify(mixed $value): string - { - if (is_float($value) || is_int($value) || is_string($value)) { - return (string) $value; - } - - if (is_bool($value)) { - return $value ? 'true' : 'false'; - } - - if ($value === null) { - return 'null'; - } - - if (is_array($value)) { - return 'array'; - } - - if (is_resource($value)) { - return 'resource'; - } - - assert(is_object($value)); - - return self::stringifyObject($value); - } - - /** @psalm-pure */ - private static function stringifyObject(object $value): string - { - if (method_exists($value, 'toString')) { - return (string) $value->toString(); - } - - if (method_exists($value, '__toString')) { - return (string) $value; - } - - return $value::class; - } - - /** @return Traversable */ - public function getIterator(): Traversable - { - return new ArrayIterator($this->getMessages()); - } + public function isValid(): bool; - public function count(): int - { - return count($this->errors); - } + /** @return T */ + public function value(): mixed; } diff --git a/src/ValidationSuccess.php b/src/ValidationSuccess.php new file mode 100644 index 00000000..f0b6c438 --- /dev/null +++ b/src/ValidationSuccess.php @@ -0,0 +1,38 @@ + + * @template TValue of mixed + */ + public static function new(mixed $value): self + { + return new self($value); + } + + public function isValid(): bool + { + return true; + } + + /** @return T */ + public function value(): mixed + { + return $this->value; + } +}