From 618b2a366196c0af878b3a15ad832f529bcd863a Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Fri, 20 Feb 2026 19:26:55 -0300 Subject: [PATCH] Introduce Trimmed validator This commit introduces the `Trimmed` validator that ensures a string cannot start or end with a list of specific values. The default values used are a selected list of Unicode invisible characters. To support this change, the StartsWith and EndsWith validators were modified so they can also support multiple values to check for. While StartsWith and EndsWith are more generic, and also perform start-of-array and end-of-array kinds of checks, Trimmed is more focused on string inputs, which tailors to a more specific use case. --- docs/validators.md | 6 +- docs/validators/EndsWith.md | 31 +++++++- docs/validators/StartsWith.md | 32 ++++++-- docs/validators/Trimmed.md | 78 ++++++++++++++++++ src/Mixins/AllBuilder.php | 6 +- src/Mixins/AllChain.php | 6 +- src/Mixins/Builder.php | 6 +- src/Mixins/Chain.php | 6 +- src/Mixins/KeyBuilder.php | 6 +- src/Mixins/KeyChain.php | 6 +- src/Mixins/NotBuilder.php | 6 +- src/Mixins/NotChain.php | 6 +- src/Mixins/NullOrBuilder.php | 6 +- src/Mixins/NullOrChain.php | 6 +- src/Mixins/PropertyBuilder.php | 6 +- src/Mixins/PropertyChain.php | 6 +- src/Mixins/UndefOrBuilder.php | 6 +- src/Mixins/UndefOrChain.php | 6 +- src/Validators/Core/Envelope.php | 9 ++- src/Validators/EndsWith.php | 44 +++++++++-- src/Validators/StartsWith.php | 39 +++++++-- src/Validators/Trimmed.php | 87 +++++++++++++++++++++ tests/feature/Validators/EndsWithTest.php | 20 +++++ tests/feature/Validators/StartsWithTest.php | 10 +++ tests/feature/Validators/TrimmedTest.php | 35 +++++++++ tests/src/SmokeTestProvider.php | 1 + tests/unit/Validators/EndsWithTest.php | 7 ++ tests/unit/Validators/StartsWithTest.php | 4 + tests/unit/Validators/TrimmedTest.php | 48 ++++++++++++ 29 files changed, 480 insertions(+), 55 deletions(-) create mode 100644 docs/validators/Trimmed.md create mode 100644 src/Validators/Trimmed.php create mode 100644 tests/feature/Validators/TrimmedTest.php create mode 100644 tests/unit/Validators/TrimmedTest.php diff --git a/docs/validators.md b/docs/validators.md index 958731a6..6413105f 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -49,7 +49,7 @@ In this page you will find a list of validators by their category. **Objects**: [Attributes][] - [Instance][] - [ObjectType][] - [Property][] - [PropertyExists][] - [PropertyOptional][] -**Strings**: [Alnum][] - [Alpha][] - [Base64][] - [Charset][] - [Consonant][] - [Contains][] - [ContainsAny][] - [ContainsCount][] - [Control][] - [Digit][] - [Emoji][] - [EndsWith][] - [Format][] - [Graph][] - [HexRgbColor][] - [In][] - [Json][] - [Lowercase][] - [Phone][] - [PostalCode][] - [Printable][] - [Punct][] - [Regex][] - [Slug][] - [Sorted][] - [Space][] - [Spaced][] - [StartsWith][] - [StringType][] - [StringVal][] - [Uppercase][] - [Uuid][] - [Version][] - [Vowel][] - [Xdigit][] +**Strings**: [Alnum][] - [Alpha][] - [Base64][] - [Charset][] - [Consonant][] - [Contains][] - [ContainsAny][] - [ContainsCount][] - [Control][] - [Digit][] - [Emoji][] - [EndsWith][] - [Format][] - [Graph][] - [HexRgbColor][] - [In][] - [Json][] - [Lowercase][] - [Phone][] - [PostalCode][] - [Printable][] - [Punct][] - [Regex][] - [Slug][] - [Sorted][] - [Space][] - [Spaced][] - [StartsWith][] - [StringType][] - [StringVal][] - [Trimmed][] - [Uppercase][] - [Uuid][] - [Version][] - [Vowel][] - [Xdigit][] **Structures**: [Attributes][] - [Key][] - [KeyExists][] - [KeyOptional][] - [KeySet][] - [Property][] - [PropertyExists][] - [PropertyOptional][] @@ -203,6 +203,7 @@ In this page you will find a list of validators by their category. - [Templated][] - `v::templated('You must provide a valid email', v::email())->assert('foo@bar.com');` - [Time][] - `v::time()->assert('00:00:00');` - [Tld][] - `v::tld()->assert('com');` +- [Trimmed][] - `v::trimmed()->assert('lorem ipsum');` - [TrueVal][] - `v::trueVal()->assert(true);` - [Undef][] - `v::undef()->assert('');` - [UndefOr][] - `v::undefOr(v::alpha())->assert('');` @@ -351,7 +352,7 @@ In this page you will find a list of validators by their category. [Sorted]: validators/Sorted.md "Validates whether the input is sorted in a certain order or not." [Space]: validators/Space.md "Validates whether the input contains only whitespaces characters." [Spaced]: validators/Spaced.md "Validates if a string contains at least one whitespace (spaces, tabs, or line breaks);" -[StartsWith]: validators/StartsWith.md "Validates whether the input starts with a given value." +[StartsWith]: validators/StartsWith.md "Validates whether the input starts with one of the given values." [StringType]: validators/StringType.md "Validates whether the type of an input is string or not." [StringVal]: validators/StringVal.md "Validates whether the input can be used as a string." [SubdivisionCode]: validators/SubdivisionCode.md "Validates subdivision country codes according to ISO 3166-2." @@ -360,6 +361,7 @@ In this page you will find a list of validators by their category. [Templated]: validators/Templated.md "Defines a validator with a custom message template." [Time]: validators/Time.md "Validates whether an input is a time or not. The `$format` argument should be in" [Tld]: validators/Tld.md "Validates whether the input is a top-level domain." +[Trimmed]: validators/Trimmed.md "Validates whether the input string does not start or end with the given values." [TrueVal]: validators/TrueVal.md "Validates if a value is considered as `true`." [Undef]: validators/Undef.md "Validates if the given input is undefined. By _undefined_ we consider `null` or an empty string (`''`)." [UndefOr]: validators/UndefOr.md "Validates the input using a defined validator when the input is not `null` or an empty string (`''`)." diff --git a/docs/validators/EndsWith.md b/docs/validators/EndsWith.md index a6010780..ac4d889d 100644 --- a/docs/validators/EndsWith.md +++ b/docs/validators/EndsWith.md @@ -8,15 +8,22 @@ SPDX-FileContributor: Henrique Moody # EndsWith - `EndsWith(mixed $endValue)` +- `EndsWith(mixed $endValue, mixed ...$endValues)` This validator is similar to `Contains()`, but validates -only if the value is at the end of the input. +only if one of the values is at the end of the input. Only +string inputs and string end values are checked; non‑string +values are considered invalid but will not produce PHP errors +thanks to internal type guards. -For strings: +For strings (non-string inputs are always rejected): ```php v::endsWith('ipsum')->assert('lorem ipsum'); // Validation passes successfully + +v::endsWith(', PhD', ', doctor')->assert('Jane Doe, PhD'); +// Validation passes successfully ``` For arrays: @@ -24,9 +31,15 @@ For arrays: ```php v::endsWith('ipsum')->assert(['lorem', 'ipsum']); // Validation passes successfully + +v::endsWith('.', ';')->assert(['this', 'is', 'a', 'tokenized', 'phrase', '.']); +// Validation passes successfully + +v::endsWith('.', ';')->assert(['this', 'is', 'a', 'tokenized', 'phrase']); +// → `["this", "is", "a", "tokenized", "phrase"]` must end with "." or ";" ``` -Message template for this validator includes `{{endValue}}`. +Message template for this validator includes `{{endValue}}` and `{{endValues}}`. ## Templates @@ -37,12 +50,20 @@ Message template for this validator includes `{{endValue}}`. | `default` | {{subject}} must end with {{endValue}} | | `inverted` | {{subject}} must not end with {{endValue}} | +### `EndsWith::TEMPLATE_MULTIPLE_VALUES` + +| Mode | Template | +| ---------: | :------------------------------------------------------- | +| `default` | {{subject}} must end with {{endValues|list:or}} | +| `inverted` | {{subject}} must not end with {{endValues|list:or}} | + ## Template placeholders | Placeholder | Description | | ----------- | ---------------------------------------------------------------- | -| `endValue` | | | `subject` | The validated input or the custom validator name (if specified). | +| `endValue` | The value that will be checked to be at the end of the input. | +| `endValues` | Additional values to check. | ## Categorization @@ -53,6 +74,7 @@ Message template for this validator includes `{{endValue}}`. | Version | Description | | ------: | :---------------------------------- | +| 3.1.0 | Added support for multiple values | | 3.0.0 | Case-insensitive comparison removed | | 0.3.9 | Created | @@ -62,3 +84,4 @@ Message template for this validator includes `{{endValue}}`. - [In](In.md) - [Regex](Regex.md) - [StartsWith](StartsWith.md) +- [Trimmed](Trimmed.md) diff --git a/docs/validators/StartsWith.md b/docs/validators/StartsWith.md index 51fff00e..9ce2aa00 100644 --- a/docs/validators/StartsWith.md +++ b/docs/validators/StartsWith.md @@ -9,8 +9,9 @@ SPDX-FileContributor: Henrique Moody # StartsWith - `StartsWith(mixed $startValue)` +- `StartsWith(mixed $startValue, mixed ...$startValues)` -Validates whether the input starts with a given value. +Validates whether the input starts with one of the given values. This validator is similar to [Contains](Contains.md), but validates only if the value is at the beginning of the input. @@ -20,6 +21,9 @@ For strings: ```php v::startsWith('lorem')->assert('lorem ipsum'); // Validation passes successfully + +v::startsWith('Dr.', 'Mr.')->assert('Dr. Jane Doe'); +// Validation passes successfully ``` For arrays: @@ -27,9 +31,15 @@ For arrays: ```php v::startsWith('lorem')->assert(['lorem', 'ipsum']); // Validation passes successfully + +v::startsWith(0, 1)->assert([0, 1, 2, 3]); +// Validation passes successfully + +v::startsWith(0, 1)->assert([1, 2, 3]); +// Validation passes successfully ``` -Message template for this validator includes `{{startValue}}`. +Message template for this validator includes `{{startValue}}` and `{{startValues}}`. ## Templates @@ -40,12 +50,20 @@ Message template for this validator includes `{{startValue}}`. | `default` | {{subject}} must start with {{startValue}} | | `inverted` | {{subject}} must not start with {{startValue}} | +### `StartsWith::TEMPLATE_MULTIPLE_VALUES` + +| Mode | Template | +| ---------: | :----------------------------------------------------------- | +| `default` | {{subject}} must start with {{startValues|list:or}} | +| `inverted` | {{subject}} must not start with {{startValues|list:or}} | + ## Template placeholders -| Placeholder | Description | -| ------------ | ---------------------------------------------------------------- | -| `subject` | The validated input or the custom validator name (if specified). | -| `startValue` | | +| Placeholder | Description | +| ------------- | ---------------------------------------------------------------- | +| `subject` | The validated input or the custom validator name (if specified). | +| `startValue` | The value that will be checked to be at the start of the input. | +| `startValues` | Additional values to check. | ## Categorization @@ -56,6 +74,7 @@ Message template for this validator includes `{{startValue}}`. | Version | Description | | ------: | :---------------------------------- | +| 3.1.0 | Added support for multiple values | | 3.0.0 | Case-insensitive comparison removed | | 0.3.9 | Created | @@ -65,3 +84,4 @@ Message template for this validator includes `{{startValue}}`. - [EndsWith](EndsWith.md) - [In](In.md) - [Regex](Regex.md) +- [Trimmed](Trimmed.md) diff --git a/docs/validators/Trimmed.md b/docs/validators/Trimmed.md new file mode 100644 index 00000000..9a41c33c --- /dev/null +++ b/docs/validators/Trimmed.md @@ -0,0 +1,78 @@ + + +# Trimmed + +- `Trimmed()` +- `Trimmed(string ...$trimValues)` + +Validates whether the input string does not start or end with the given values. + +When no values are provided, this validator uses a default list of Unicode invisible characters (including regular whitespace, non-breaking spaces, and zero-width characters). + +With the default values: + +```php +v::trimmed()->assert('lorem ipsum'); +// Validation passes successfully + +v::trimmed()->assert("\u{200B}lorem"); +// → "​lorem" must not contain leading or trailing whitespace +``` + +With custom values: + +```php +v::trimmed('Dr.', 'Mr.', 'PhD.')->assert('John'); +// Validation passes successfully + +v::trimmed('Dr.', 'Mr.', 'PhD.')->assert('Dr. John'); +// → "Dr. John" must not contain leading or trailing "Dr.", "Mr.", or "PhD." + +v::trimmed('Dr.', 'Mr.', ', PhD')->assert('John Doe, PhD'); +// → "John Doe, PhD" must not contain leading or trailing "Dr.", "Mr.", or ", PhD" +``` + +This validator composes [StartsWith](StartsWith.md) and [EndsWith](EndsWith.md). + +## Templates + +### `Trimmed::TEMPLATE_STANDARD` + +| Mode | Template | +| ---------: | :---------------------------------------------------------- | +| `default` | {{subject}} must not contain leading or trailing whitespace | +| `inverted` | {{subject}} must contain leading or trailing whitespace | + +### `Trimmed::TEMPLATE_CUSTOM` + +| Mode | Template | +| ---------: | :--------------------------------------------------------------------------- | +| `default` | {{subject}} must not contain leading or trailing {{trimValues|list:or}} | +| `inverted` | {{subject}} must contain leading or trailing {{trimValues|list:or}} | + +## Template placeholders + +| Placeholder | Description | +| ------------ | ---------------------------------------------------------------- | +| `subject` | The validated input or the custom validator name (if specified). | +| `trimValues` | The values that will be checked at start end end of input. | + +## Categorization + +- Strings + +## Changelog + +| Version | Description | +| ------: | :---------- | +| 3.1.0 | Created | + +## See Also + +- [EndsWith](EndsWith.md) +- [Space](Space.md) +- [Spaced](Spaced.md) +- [StartsWith](StartsWith.md) diff --git a/src/Mixins/AllBuilder.php b/src/Mixins/AllBuilder.php index a455969a..1a288c4f 100644 --- a/src/Mixins/AllBuilder.php +++ b/src/Mixins/AllBuilder.php @@ -103,7 +103,7 @@ interface AllBuilder public static function allEmoji(): Chain; - public static function allEndsWith(mixed $endValue): Chain; + public static function allEndsWith(mixed $endValue, mixed ...$endValues): Chain; public static function allEquals(mixed $compareTo): Chain; @@ -269,7 +269,7 @@ interface AllBuilder public static function allSpaced(): Chain; - public static function allStartsWith(mixed $startValue): Chain; + public static function allStartsWith(mixed $startValue, mixed ...$startValues): Chain; public static function allStringType(): Chain; @@ -286,6 +286,8 @@ interface AllBuilder public static function allTld(): Chain; + public static function allTrimmed(string ...$trimValues): Chain; + public static function allTrueVal(): Chain; public static function allUndef(): Chain; diff --git a/src/Mixins/AllChain.php b/src/Mixins/AllChain.php index aef20164..e8b90c01 100644 --- a/src/Mixins/AllChain.php +++ b/src/Mixins/AllChain.php @@ -103,7 +103,7 @@ interface AllChain public function allEmoji(): Chain; - public function allEndsWith(mixed $endValue): Chain; + public function allEndsWith(mixed $endValue, mixed ...$endValues): Chain; public function allEquals(mixed $compareTo): Chain; @@ -269,7 +269,7 @@ interface AllChain public function allSpaced(): Chain; - public function allStartsWith(mixed $startValue): Chain; + public function allStartsWith(mixed $startValue, mixed ...$startValues): Chain; public function allStringType(): Chain; @@ -286,6 +286,8 @@ interface AllChain public function allTld(): Chain; + public function allTrimmed(string ...$trimValues): Chain; + public function allTrueVal(): Chain; public function allUndef(): Chain; diff --git a/src/Mixins/Builder.php b/src/Mixins/Builder.php index 354c339f..d4042eda 100644 --- a/src/Mixins/Builder.php +++ b/src/Mixins/Builder.php @@ -108,7 +108,7 @@ interface Builder extends AllBuilder, KeyBuilder, LengthBuilder, MaxBuilder, Min public static function emoji(): Chain; - public static function endsWith(mixed $endValue): Chain; + public static function endsWith(mixed $endValue, mixed ...$endValues): Chain; public static function equals(mixed $compareTo): Chain; @@ -296,7 +296,7 @@ interface Builder extends AllBuilder, KeyBuilder, LengthBuilder, MaxBuilder, Min public static function spaced(): Chain; - public static function startsWith(mixed $startValue): Chain; + public static function startsWith(mixed $startValue, mixed ...$startValues): Chain; public static function stringType(): Chain; @@ -316,6 +316,8 @@ interface Builder extends AllBuilder, KeyBuilder, LengthBuilder, MaxBuilder, Min public static function tld(): Chain; + public static function trimmed(string ...$trimValues): Chain; + public static function trueVal(): Chain; public static function undef(): Chain; diff --git a/src/Mixins/Chain.php b/src/Mixins/Chain.php index 0a486889..acc75aef 100644 --- a/src/Mixins/Chain.php +++ b/src/Mixins/Chain.php @@ -110,7 +110,7 @@ interface Chain extends Validator, AllChain, KeyChain, LengthChain, MaxChain, Mi public function emoji(): Chain; - public function endsWith(mixed $endValue): Chain; + public function endsWith(mixed $endValue, mixed ...$endValues): Chain; public function equals(mixed $compareTo): Chain; @@ -298,7 +298,7 @@ interface Chain extends Validator, AllChain, KeyChain, LengthChain, MaxChain, Mi public function spaced(): Chain; - public function startsWith(mixed $startValue): Chain; + public function startsWith(mixed $startValue, mixed ...$startValues): Chain; public function stringType(): Chain; @@ -318,6 +318,8 @@ interface Chain extends Validator, AllChain, KeyChain, LengthChain, MaxChain, Mi public function tld(): Chain; + public function trimmed(string ...$trimValues): Chain; + public function trueVal(): Chain; public function undef(): Chain; diff --git a/src/Mixins/KeyBuilder.php b/src/Mixins/KeyBuilder.php index bb6f7187..a8c85ec4 100644 --- a/src/Mixins/KeyBuilder.php +++ b/src/Mixins/KeyBuilder.php @@ -105,7 +105,7 @@ interface KeyBuilder public static function keyEmoji(int|string $key): Chain; - public static function keyEndsWith(int|string $key, mixed $endValue): Chain; + public static function keyEndsWith(int|string $key, mixed $endValue, mixed ...$endValues): Chain; public static function keyEquals(int|string $key, mixed $compareTo): Chain; @@ -271,7 +271,7 @@ interface KeyBuilder public static function keySpaced(int|string $key): Chain; - public static function keyStartsWith(int|string $key, mixed $startValue): Chain; + public static function keyStartsWith(int|string $key, mixed $startValue, mixed ...$startValues): Chain; public static function keyStringType(int|string $key): Chain; @@ -288,6 +288,8 @@ interface KeyBuilder public static function keyTld(int|string $key): Chain; + public static function keyTrimmed(int|string $key, string ...$trimValues): Chain; + public static function keyTrueVal(int|string $key): Chain; public static function keyUndef(int|string $key): Chain; diff --git a/src/Mixins/KeyChain.php b/src/Mixins/KeyChain.php index f29ea57f..e0c62514 100644 --- a/src/Mixins/KeyChain.php +++ b/src/Mixins/KeyChain.php @@ -105,7 +105,7 @@ interface KeyChain public function keyEmoji(int|string $key): Chain; - public function keyEndsWith(int|string $key, mixed $endValue): Chain; + public function keyEndsWith(int|string $key, mixed $endValue, mixed ...$endValues): Chain; public function keyEquals(int|string $key, mixed $compareTo): Chain; @@ -271,7 +271,7 @@ interface KeyChain public function keySpaced(int|string $key): Chain; - public function keyStartsWith(int|string $key, mixed $startValue): Chain; + public function keyStartsWith(int|string $key, mixed $startValue, mixed ...$startValues): Chain; public function keyStringType(int|string $key): Chain; @@ -288,6 +288,8 @@ interface KeyChain public function keyTld(int|string $key): Chain; + public function keyTrimmed(int|string $key, string ...$trimValues): Chain; + public function keyTrueVal(int|string $key): Chain; public function keyUndef(int|string $key): Chain; diff --git a/src/Mixins/NotBuilder.php b/src/Mixins/NotBuilder.php index bcf83744..41c6c44a 100644 --- a/src/Mixins/NotBuilder.php +++ b/src/Mixins/NotBuilder.php @@ -105,7 +105,7 @@ interface NotBuilder public static function notEmoji(): Chain; - public static function notEndsWith(mixed $endValue): Chain; + public static function notEndsWith(mixed $endValue, mixed ...$endValues): Chain; public static function notEquals(mixed $compareTo): Chain; @@ -287,7 +287,7 @@ interface NotBuilder public static function notSpaced(): Chain; - public static function notStartsWith(mixed $startValue): Chain; + public static function notStartsWith(mixed $startValue, mixed ...$startValues): Chain; public static function notStringType(): Chain; @@ -304,6 +304,8 @@ interface NotBuilder public static function notTld(): Chain; + public static function notTrimmed(string ...$trimValues): Chain; + public static function notTrueVal(): Chain; public static function notUndef(): Chain; diff --git a/src/Mixins/NotChain.php b/src/Mixins/NotChain.php index 9a550413..113737fa 100644 --- a/src/Mixins/NotChain.php +++ b/src/Mixins/NotChain.php @@ -105,7 +105,7 @@ interface NotChain public function notEmoji(): Chain; - public function notEndsWith(mixed $endValue): Chain; + public function notEndsWith(mixed $endValue, mixed ...$endValues): Chain; public function notEquals(mixed $compareTo): Chain; @@ -287,7 +287,7 @@ interface NotChain public function notSpaced(): Chain; - public function notStartsWith(mixed $startValue): Chain; + public function notStartsWith(mixed $startValue, mixed ...$startValues): Chain; public function notStringType(): Chain; @@ -304,6 +304,8 @@ interface NotChain public function notTld(): Chain; + public function notTrimmed(string ...$trimValues): Chain; + public function notTrueVal(): Chain; public function notUndef(): Chain; diff --git a/src/Mixins/NullOrBuilder.php b/src/Mixins/NullOrBuilder.php index 3ac1b0b7..12f54b0b 100644 --- a/src/Mixins/NullOrBuilder.php +++ b/src/Mixins/NullOrBuilder.php @@ -105,7 +105,7 @@ interface NullOrBuilder public static function nullOrEmoji(): Chain; - public static function nullOrEndsWith(mixed $endValue): Chain; + public static function nullOrEndsWith(mixed $endValue, mixed ...$endValues): Chain; public static function nullOrEquals(mixed $compareTo): Chain; @@ -289,7 +289,7 @@ interface NullOrBuilder public static function nullOrSpaced(): Chain; - public static function nullOrStartsWith(mixed $startValue): Chain; + public static function nullOrStartsWith(mixed $startValue, mixed ...$startValues): Chain; public static function nullOrStringType(): Chain; @@ -306,6 +306,8 @@ interface NullOrBuilder public static function nullOrTld(): Chain; + public static function nullOrTrimmed(string ...$trimValues): Chain; + public static function nullOrTrueVal(): Chain; public static function nullOrUnique(): Chain; diff --git a/src/Mixins/NullOrChain.php b/src/Mixins/NullOrChain.php index 1c5e4ac4..32965991 100644 --- a/src/Mixins/NullOrChain.php +++ b/src/Mixins/NullOrChain.php @@ -105,7 +105,7 @@ interface NullOrChain public function nullOrEmoji(): Chain; - public function nullOrEndsWith(mixed $endValue): Chain; + public function nullOrEndsWith(mixed $endValue, mixed ...$endValues): Chain; public function nullOrEquals(mixed $compareTo): Chain; @@ -289,7 +289,7 @@ interface NullOrChain public function nullOrSpaced(): Chain; - public function nullOrStartsWith(mixed $startValue): Chain; + public function nullOrStartsWith(mixed $startValue, mixed ...$startValues): Chain; public function nullOrStringType(): Chain; @@ -306,6 +306,8 @@ interface NullOrChain public function nullOrTld(): Chain; + public function nullOrTrimmed(string ...$trimValues): Chain; + public function nullOrTrueVal(): Chain; public function nullOrUnique(): Chain; diff --git a/src/Mixins/PropertyBuilder.php b/src/Mixins/PropertyBuilder.php index ff523c57..1ba349a0 100644 --- a/src/Mixins/PropertyBuilder.php +++ b/src/Mixins/PropertyBuilder.php @@ -105,7 +105,7 @@ interface PropertyBuilder public static function propertyEmoji(string $propertyName): Chain; - public static function propertyEndsWith(string $propertyName, mixed $endValue): Chain; + public static function propertyEndsWith(string $propertyName, mixed $endValue, mixed ...$endValues): Chain; public static function propertyEquals(string $propertyName, mixed $compareTo): Chain; @@ -271,7 +271,7 @@ interface PropertyBuilder public static function propertySpaced(string $propertyName): Chain; - public static function propertyStartsWith(string $propertyName, mixed $startValue): Chain; + public static function propertyStartsWith(string $propertyName, mixed $startValue, mixed ...$startValues): Chain; public static function propertyStringType(string $propertyName): Chain; @@ -288,6 +288,8 @@ interface PropertyBuilder public static function propertyTld(string $propertyName): Chain; + public static function propertyTrimmed(string $propertyName, string ...$trimValues): Chain; + public static function propertyTrueVal(string $propertyName): Chain; public static function propertyUndef(string $propertyName): Chain; diff --git a/src/Mixins/PropertyChain.php b/src/Mixins/PropertyChain.php index e06549f8..6b8f198e 100644 --- a/src/Mixins/PropertyChain.php +++ b/src/Mixins/PropertyChain.php @@ -105,7 +105,7 @@ interface PropertyChain public function propertyEmoji(string $propertyName): Chain; - public function propertyEndsWith(string $propertyName, mixed $endValue): Chain; + public function propertyEndsWith(string $propertyName, mixed $endValue, mixed ...$endValues): Chain; public function propertyEquals(string $propertyName, mixed $compareTo): Chain; @@ -271,7 +271,7 @@ interface PropertyChain public function propertySpaced(string $propertyName): Chain; - public function propertyStartsWith(string $propertyName, mixed $startValue): Chain; + public function propertyStartsWith(string $propertyName, mixed $startValue, mixed ...$startValues): Chain; public function propertyStringType(string $propertyName): Chain; @@ -288,6 +288,8 @@ interface PropertyChain public function propertyTld(string $propertyName): Chain; + public function propertyTrimmed(string $propertyName, string ...$trimValues): Chain; + public function propertyTrueVal(string $propertyName): Chain; public function propertyUndef(string $propertyName): Chain; diff --git a/src/Mixins/UndefOrBuilder.php b/src/Mixins/UndefOrBuilder.php index 769ae53f..c8c6273e 100644 --- a/src/Mixins/UndefOrBuilder.php +++ b/src/Mixins/UndefOrBuilder.php @@ -103,7 +103,7 @@ interface UndefOrBuilder public static function undefOrEmoji(): Chain; - public static function undefOrEndsWith(mixed $endValue): Chain; + public static function undefOrEndsWith(mixed $endValue, mixed ...$endValues): Chain; public static function undefOrEquals(mixed $compareTo): Chain; @@ -287,7 +287,7 @@ interface UndefOrBuilder public static function undefOrSpaced(): Chain; - public static function undefOrStartsWith(mixed $startValue): Chain; + public static function undefOrStartsWith(mixed $startValue, mixed ...$startValues): Chain; public static function undefOrStringType(): Chain; @@ -304,6 +304,8 @@ interface UndefOrBuilder public static function undefOrTld(): Chain; + public static function undefOrTrimmed(string ...$trimValues): Chain; + public static function undefOrTrueVal(): Chain; public static function undefOrUnique(): Chain; diff --git a/src/Mixins/UndefOrChain.php b/src/Mixins/UndefOrChain.php index fd9ceb96..aee52fca 100644 --- a/src/Mixins/UndefOrChain.php +++ b/src/Mixins/UndefOrChain.php @@ -103,7 +103,7 @@ interface UndefOrChain public function undefOrEmoji(): Chain; - public function undefOrEndsWith(mixed $endValue): Chain; + public function undefOrEndsWith(mixed $endValue, mixed ...$endValues): Chain; public function undefOrEquals(mixed $compareTo): Chain; @@ -287,7 +287,7 @@ interface UndefOrChain public function undefOrSpaced(): Chain; - public function undefOrStartsWith(mixed $startValue): Chain; + public function undefOrStartsWith(mixed $startValue, mixed ...$startValues): Chain; public function undefOrStringType(): Chain; @@ -304,6 +304,8 @@ interface UndefOrChain public function undefOrTld(): Chain; + public function undefOrTrimmed(string ...$trimValues): Chain; + public function undefOrTrueVal(): Chain; public function undefOrUnique(): Chain; diff --git a/src/Validators/Core/Envelope.php b/src/Validators/Core/Envelope.php index d034cf69..b1f401aa 100644 --- a/src/Validators/Core/Envelope.php +++ b/src/Validators/Core/Envelope.php @@ -21,11 +21,18 @@ abstract class Envelope implements Validator public function __construct( private readonly Validator $validator, private readonly array $parameters = [], + private readonly string $template = Validator::TEMPLATE_STANDARD, ) { } public function evaluate(mixed $input): Result { - return Result::of($this->validator->evaluate($input)->hasPassed, $input, $this, $this->parameters); + return Result::of( + $this->validator->evaluate($input)->hasPassed, + $input, + $this, + $this->parameters, + $this->template, + ); } } diff --git a/src/Validators/EndsWith.php b/src/Validators/EndsWith.php index 5a5279e6..5056a446 100644 --- a/src/Validators/EndsWith.php +++ b/src/Validators/EndsWith.php @@ -20,8 +20,10 @@ use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; +use function count; use function end; use function is_array; +use function is_string; use function mb_strlen; use function mb_strrpos; @@ -30,26 +32,56 @@ use function mb_strrpos; '{{subject}} must end with {{endValue}}', '{{subject}} must not end with {{endValue}}', )] +#[Template( + '{{subject}} must end with {{endValues|list:or}}', + '{{subject}} must not end with {{endValues|list:or}}', + self::TEMPLATE_MULTIPLE_VALUES, +)] final readonly class EndsWith implements Validator { + public const string TEMPLATE_MULTIPLE_VALUES = '__multiple_values__'; + + /** @var non-empty-array */ + private array $endValues; + public function __construct( - private mixed $endValue, + mixed $endValue, + mixed ...$endValues, ) { + $this->endValues = [$endValue, ...$endValues]; } public function evaluate(mixed $input): Result { - $parameters = ['endValue' => $this->endValue]; + $template = self::TEMPLATE_STANDARD; + $parameters = [ + 'endValue' => $this->endValues[0], + 'endValues' => $this->endValues, + ]; - return Result::of($this->validateIdentical($input), $input, $this, $parameters); + if (count($this->endValues) > 1) { + $template = self::TEMPLATE_MULTIPLE_VALUES; + } + + return Result::of($this->validateIdentical($input), $input, $this, $parameters, $template); } private function validateIdentical(mixed $input): bool { - if (is_array($input)) { - return end($input) === $this->endValue; + foreach ($this->endValues as $endValue) { + if (is_array($input) && end($input) === $endValue) { + return true; + } + + // ensure both operands are strings before using mb_ functions + if ( + is_string($input) && is_string($endValue) + && mb_strrpos($input, $endValue) === mb_strlen($input) - mb_strlen($endValue) + ) { + return true; + } } - return mb_strrpos($input, $this->endValue) === mb_strlen($input) - mb_strlen($this->endValue); + return false; } } diff --git a/src/Validators/StartsWith.php b/src/Validators/StartsWith.php index a9548148..6b9d48cd 100644 --- a/src/Validators/StartsWith.php +++ b/src/Validators/StartsWith.php @@ -20,6 +20,7 @@ use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; +use function count; use function is_array; use function is_string; use function mb_strpos; @@ -30,28 +31,50 @@ use function reset; '{{subject}} must start with {{startValue}}', '{{subject}} must not start with {{startValue}}', )] +#[Template( + '{{subject}} must start with {{startValues|list:or}}', + '{{subject}} must not start with {{startValues|list:or}}', + self::TEMPLATE_MULTIPLE_VALUES, +)] final readonly class StartsWith implements Validator { + public const string TEMPLATE_MULTIPLE_VALUES = '__multiple_values__'; + + /** @var non-empty-array */ + private array $startValues; + public function __construct( - private mixed $startValue, + mixed $startValue, + mixed ...$startValues, ) { + $this->startValues = [$startValue, ...$startValues]; } public function evaluate(mixed $input): Result { - $parameters = ['startValue' => $this->startValue]; + $template = self::TEMPLATE_STANDARD; + $parameters = [ + 'startValue' => $this->startValues[0], + 'startValues' => $this->startValues, + ]; - return Result::of($this->validateIdentical($input), $input, $this, $parameters); + if (count($this->startValues) > 1) { + $template = self::TEMPLATE_MULTIPLE_VALUES; + } + + return Result::of($this->validateIdentical($input), $input, $this, $parameters, $template); } protected function validateIdentical(mixed $input): bool { - if (is_array($input)) { - return reset($input) === $this->startValue; - } + foreach ($this->startValues as $startValue) { + if (is_array($input) && reset($input) === $startValue) { + return true; + } - if (is_string($input) && is_string($this->startValue)) { - return mb_strpos($input, $this->startValue) === 0; + if (is_string($input) && is_string($startValue) && mb_strpos($input, $startValue) === 0) { + return true; + } } return false; diff --git a/src/Validators/Trimmed.php b/src/Validators/Trimmed.php new file mode 100644 index 00000000..88588aa1 --- /dev/null +++ b/src/Validators/Trimmed.php @@ -0,0 +1,87 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators; + +use Attribute; +use Respect\Validation\Message\Template; +use Respect\Validation\Validator; +use Respect\Validation\Validators\Core\Envelope; + +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +#[Template( + '{{subject}} must not contain leading or trailing whitespace', + '{{subject}} must contain leading or trailing whitespace', + Validator::TEMPLATE_STANDARD, +)] +#[Template( + '{{subject}} must not contain leading or trailing {{trimValues|list:or}}', + '{{subject}} must contain leading or trailing {{trimValues|list:or}}', + self::TEMPLATE_CUSTOM, +)] +final class Trimmed extends Envelope +{ + public const string TEMPLATE_CUSTOM = '__custom__'; + + /** Unicode whitespace and zero-width characters. */ + private const array DEFAULT_TRIM_VALUES = [ + "\u{0009}", // CHARACTER TABULATION + "\u{000A}", // LINE FEED + "\u{000B}", // LINE TABULATION + "\u{000C}", // FORM FEED + "\u{000D}", // CARRIAGE RETURN + "\u{0020}", // SPACE + "\u{0085}", // NEXT LINE + "\u{00A0}", // NO-BREAK SPACE + "\u{1680}", // OGHAM SPACE MARK + "\u{180E}", // MONGOLIAN VOWEL SEPARATOR + "\u{2000}", // EN QUAD + "\u{2001}", // EM QUAD + "\u{2002}", // EN SPACE + "\u{2003}", // EM SPACE + "\u{2004}", // THREE-PER-EM SPACE + "\u{2005}", // FOUR-PER-EM SPACE + "\u{2006}", // SIX-PER-EM SPACE + "\u{2007}", // FIGURE SPACE + "\u{2008}", // PUNCTUATION SPACE + "\u{2009}", // THIN SPACE + "\u{200A}", // HAIR SPACE + "\u{200B}", // ZERO WIDTH SPACE + "\u{200C}", // ZERO WIDTH NON-JOINER + "\u{200D}", // ZERO WIDTH JOINER + "\u{2028}", // LINE SEPARATOR + "\u{2029}", // PARAGRAPH SEPARATOR + "\u{202F}", // NARROW NO-BREAK SPACE + "\u{205F}", // MEDIUM MATHEMATICAL SPACE + "\u{2060}", // WORD JOINER + "\u{3000}", // IDEOGRAPHIC SPACE + "\u{FEFF}", // ZERO WIDTH NO-BREAK SPACE + ]; + + public function __construct(string ...$trimValues) + { + $hasCustomTrimValues = $trimValues !== []; + $trimValues = $hasCustomTrimValues ? $trimValues : self::DEFAULT_TRIM_VALUES; + + parent::__construct( + new ShortCircuit( + new StringType(), + new Not( + new AnyOf( + new StartsWith(...$trimValues), + new EndsWith(...$trimValues), + ), + ), + ), + $hasCustomTrimValues ? ['trimValues' => $trimValues] : [], + $hasCustomTrimValues ? self::TEMPLATE_CUSTOM : Validator::TEMPLATE_STANDARD, + ); + } +} diff --git a/tests/feature/Validators/EndsWithTest.php b/tests/feature/Validators/EndsWithTest.php index d93af327..6bc9ee3a 100644 --- a/tests/feature/Validators/EndsWithTest.php +++ b/tests/feature/Validators/EndsWithTest.php @@ -9,6 +9,8 @@ declare(strict_types=1); +use Respect\Validation\Exceptions\ValidationException; + test('Scenario #1', catchMessage( fn() => v::endsWith('foo')->assert('bar'), fn(string $message) => expect($message)->toBe('"bar" must end with "foo"'), @@ -28,3 +30,21 @@ test('Scenario #4', catchFullMessage( fn() => v::not(v::endsWith('foo'))->assert(['bar', 'foo']), fn(string $fullMessage) => expect($fullMessage)->toBe('- `["bar", "foo"]` must not end with "foo"'), )); + +test('Scenario #5', catchMessage( + fn() => v::endsWith('Mr.', 'Dr.')->assert('John Doe'), + fn(string $message) => expect($message)->toBe('"John Doe" must end with "Mr." or "Dr."'), +)); + +test('Scenario #6', catchFullMessage( + fn() => v::not(v::endsWith('divorced.', 'PhD.'))->assert('John Doe, PhD.'), + fn(string $fullMessage) => expect($fullMessage)->toBe('- "John Doe, PhD." must not end with "divorced." or "PhD."'), +)); + +// ensure non-string values do not throw errors and are considered invalid +test('non-string input or end value are invalid', function (): void { + expect(fn() => v::endsWith('foo')->assert(123)) + ->toThrow(ValidationException::class); + expect(fn() => v::endsWith(123)->assert('foo')) + ->toThrow(ValidationException::class); +}); diff --git a/tests/feature/Validators/StartsWithTest.php b/tests/feature/Validators/StartsWithTest.php index aa4cef08..59e45145 100644 --- a/tests/feature/Validators/StartsWithTest.php +++ b/tests/feature/Validators/StartsWithTest.php @@ -28,3 +28,13 @@ test('Scenario #4', catchFullMessage( fn() => v::not(v::startsWith('c'))->assert(['c', 'd']), fn(string $fullMessage) => expect($fullMessage)->toBe('- `["c", "d"]` must not start with "c"'), )); + +test('Scenario #5', catchMessage( + fn() => v::startsWith('Mr.', 'Dr.')->assert('John Doe'), + fn(string $message) => expect($message)->toBe('"John Doe" must start with "Mr." or "Dr."'), +)); + +test('Scenario #6', catchFullMessage( + fn() => v::not(v::startsWith('Mr.', 'Dr.'))->assert('Dr. John Doe'), + fn(string $fullMessage) => expect($fullMessage)->toBe('- "Dr. John Doe" must not start with "Mr." or "Dr."'), +)); diff --git a/tests/feature/Validators/TrimmedTest.php b/tests/feature/Validators/TrimmedTest.php new file mode 100644 index 00000000..f209c0c5 --- /dev/null +++ b/tests/feature/Validators/TrimmedTest.php @@ -0,0 +1,35 @@ + + */ + +declare(strict_types=1); + +test('default template', catchAll( + fn() => v::trimmed()->assert(' word'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('" word" must not contain leading or trailing whitespace') + ->and($fullMessage)->toBe('- " word" must not contain leading or trailing whitespace') + ->and($messages)->toBe(['trimmed' => '" word" must not contain leading or trailing whitespace']), +)); + +test('inverted template', catchAll( + fn() => v::not(v::trimmed())->assert('word'), + fn(string $message, string $fullMessage, array $messages) => expect() + ->and($message)->toBe('"word" must contain leading or trailing whitespace') + ->and($fullMessage)->toBe('- "word" must contain leading or trailing whitespace') + ->and($messages)->toBe(['notTrimmed' => '"word" must contain leading or trailing whitespace']), +)); + +test('custom alternatives', catchMessage( + fn() => v::trimmed('foo', 'bar')->assert('foobaz'), + fn(string $message) => expect($message)->toBe('"foobaz" must not contain leading or trailing "foo" or "bar"'), +)); + +test('custom alternatives inverted template', catchMessage( + fn() => v::not(v::trimmed('foo', 'bar'))->assert('bazqux'), + fn(string $message) => expect($message)->toBe('"bazqux" must contain leading or trailing "foo" or "bar"'), +)); diff --git a/tests/src/SmokeTestProvider.php b/tests/src/SmokeTestProvider.php index dbf438f0..c3438160 100644 --- a/tests/src/SmokeTestProvider.php +++ b/tests/src/SmokeTestProvider.php @@ -169,6 +169,7 @@ trait SmokeTestProvider yield 'Templated' => [new vs\Templated('Foo', new vs\StringVal()), 'foo']; yield 'Time' => [new vs\Time(), '12:34:56']; yield 'Tld' => [new vs\Tld(), 'com']; + yield 'Trimmed' => [new vs\Trimmed(), 'example']; yield 'TrueVal' => [new vs\TrueVal(), true]; yield 'Undef' => [new vs\Undef(), null]; yield 'UndefOr' => [new vs\UndefOr(new vs\IntVal()), null]; diff --git a/tests/unit/Validators/EndsWithTest.php b/tests/unit/Validators/EndsWithTest.php index 04394d7d..d2d79821 100644 --- a/tests/unit/Validators/EndsWithTest.php +++ b/tests/unit/Validators/EndsWithTest.php @@ -28,6 +28,8 @@ final class EndsWithTest extends RuleTestCase [new EndsWith('foo'), ['bar', 'foo']], [new EndsWith('foo'), 'barbazfoo'], [new EndsWith('foo'), 'foobazfoo'], + [new EndsWith('foo', 'bar'), 'bazbar'], + [new EndsWith('foo', 'bar'), ['baz', 'bar']], [new EndsWith(1), [2, 3, 1]], [new EndsWith('1'), [2, 3, '1']], ]; @@ -44,8 +46,13 @@ final class EndsWithTest extends RuleTestCase [new EndsWith('foo'), 'faabarbaz'], [new EndsWith('foo'), 'baabazfaa'], [new EndsWith('foo'), 'baafoofaa'], + [new EndsWith('foo', 'bar'), 'foobaz'], + [new EndsWith('foo', 'bar'), ['foo', 'baz']], [new EndsWith('1'), [1, '1', 3]], [new EndsWith('1'), [2, 3, 1]], + // non-string inputs/values should not trigger warnings + [new EndsWith('foo'), 123], + [new EndsWith(123), 'foo'], ]; } } diff --git a/tests/unit/Validators/StartsWithTest.php b/tests/unit/Validators/StartsWithTest.php index 0c1c0f5d..eabcfaa8 100644 --- a/tests/unit/Validators/StartsWithTest.php +++ b/tests/unit/Validators/StartsWithTest.php @@ -28,6 +28,8 @@ final class StartsWithTest extends RuleTestCase [new StartsWith('foo'), ['foo', 'bar']], [new StartsWith('foo'), 'foobarbaz'], [new StartsWith('foo'), 'foobazfoo'], + [new StartsWith('foo', 'bar'), 'barbaz'], + [new StartsWith('foo', 'bar'), ['bar', 'baz']], [new StartsWith('1'), ['1', 2, 3]], ]; } @@ -44,6 +46,8 @@ final class StartsWithTest extends RuleTestCase [new StartsWith('foo'), 'faabarbaz'], [new StartsWith('foo'), 'baabazfaa'], [new StartsWith('foo'), 'baafoofaa'], + [new StartsWith('foo', 'bar'), 'bazfoo'], + [new StartsWith('foo', 'bar'), ['baz', 'foo']], [new StartsWith('1'), [1, '1', 3]], [new StartsWith('1'), [1, 2, 3]], ]; diff --git a/tests/unit/Validators/TrimmedTest.php b/tests/unit/Validators/TrimmedTest.php new file mode 100644 index 00000000..466fad3f --- /dev/null +++ b/tests/unit/Validators/TrimmedTest.php @@ -0,0 +1,48 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use Respect\Validation\Test\RuleTestCase; + +#[Group('validator')] +#[CoversClass(Trimmed::class)] +final class TrimmedTest extends RuleTestCase +{ + /** @return iterable */ + public static function providerForValidInput(): iterable + { + return [ + [new Trimmed(), 'foo'], + [new Trimmed(), 'foo bar'], + [new Trimmed(), "foo\tbar"], + [new Trimmed(), ''], + [new Trimmed('foo', 'bar'), 'bazqux'], + [new Trimmed('foo', 'bar'), 'oofbarf'], + ]; + } + + /** @return iterable */ + public static function providerForInvalidInput(): iterable + { + return [ + [new Trimmed(), ' foo'], + [new Trimmed(), "foo\t"], + [new Trimmed(), "\u{200B}foo"], + [new Trimmed(), "foo\u{FEFF}"], + [new Trimmed(), 123], + [new Trimmed('foo', 'bar'), 'foobaz'], + [new Trimmed('foo', 'bar'), 'bazbar'], + [new Trimmed('foo', 'bar'), 'barbazfoo'], + ]; + } +}