From 3dd6b45e891557dfe209a45332da9fd66843de4e Mon Sep 17 00:00:00 2001 From: MohammadAlhallaq Date: Sun, 21 Dec 2025 15:20:08 +0300 Subject: [PATCH 1/7] wip --- packages/validation/src/Rules/Custom.php | 47 +++++++++++++++++++ .../validation/tests/Rules/CustomTest.php | 47 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/validation/src/Rules/Custom.php create mode 100644 packages/validation/tests/Rules/CustomTest.php diff --git a/packages/validation/src/Rules/Custom.php b/packages/validation/src/Rules/Custom.php new file mode 100644 index 000000000..7669dc557 --- /dev/null +++ b/packages/validation/src/Rules/Custom.php @@ -0,0 +1,47 @@ +callback = $callback; + + $reflection = new ReflectionFunction($callback); + + // Must be static + if (!$reflection->isStatic()) { + throw new InvalidArgumentException('Validation closures must be static'); + } + + // Must not capture variables + if ($reflection->getStaticVariables() !== []) { + throw new InvalidArgumentException('Validation closures may not capture variables.'); + } + } + + public function isValid(mixed $value): bool + { + return ($this->callback)($value); + } +} diff --git a/packages/validation/tests/Rules/CustomTest.php b/packages/validation/tests/Rules/CustomTest.php new file mode 100644 index 000000000..49352ba25 --- /dev/null +++ b/packages/validation/tests/Rules/CustomTest.php @@ -0,0 +1,47 @@ + str_contains((string) $value, '@')); + + $this->assertTrue($rule->isValid('user@example.com')); + $this->assertTrue($rule->isValid('test@domain.org')); + } + + public function test_closure_validation_fails(): void + { + $rule = new Custom(static fn(mixed $value): bool => str_contains((string) $value, '@')); + + $this->assertFalse($rule->isValid('username')); + $this->assertFalse($rule->isValid('example.com')); + } + + public function test_non_string_value_fails(): void + { + $rule = new Custom(static fn(mixed $value): bool => str_contains((string) $value, '@')); + + $this->assertFalse($rule->isValid(12345)); + $this->assertFalse($rule->isValid(null)); + $this->assertFalse($rule->isValid(false)); + } + + public function test_static_closure_required(): void + { + $this->expectException(\InvalidArgumentException::class); + + // Non-static closure should throw exception + new Custom(fn(mixed $value): bool => str_contains((string) $value, '@')); + } +} From 03b89a6efcfbcc1f690420476062badbcfc09832 Mon Sep 17 00:00:00 2001 From: MohammadAlhallaq Date: Sun, 21 Dec 2025 15:24:52 +0300 Subject: [PATCH 2/7] feat(validation): Validation rule based on closure --- packages/validation/src/Rules/Custom.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/validation/src/Rules/Custom.php b/packages/validation/src/Rules/Custom.php index 7669dc557..20a8a6549 100644 --- a/packages/validation/src/Rules/Custom.php +++ b/packages/validation/src/Rules/Custom.php @@ -10,7 +10,6 @@ use ReflectionFunction; use Tempest\Validation\Rule; - /** * Custom validation rule defined by a closure. * From a8ef450e9cf85d86a063dc63ad3e80c6abda431a Mon Sep 17 00:00:00 2001 From: MohammadAlhallaq Date: Sun, 21 Dec 2025 15:30:27 +0300 Subject: [PATCH 3/7] feat(validation): validation rules based on closures --- packages/validation/src/Rules/Custom.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/validation/src/Rules/Custom.php b/packages/validation/src/Rules/Custom.php index 20a8a6549..7669dc557 100644 --- a/packages/validation/src/Rules/Custom.php +++ b/packages/validation/src/Rules/Custom.php @@ -10,6 +10,7 @@ use ReflectionFunction; use Tempest\Validation\Rule; + /** * Custom validation rule defined by a closure. * From 694e74a5475e826cd019de80de77e0fd68709fb3 Mon Sep 17 00:00:00 2001 From: MohammadAlhallaq Date: Sun, 21 Dec 2025 15:43:40 +0300 Subject: [PATCH 4/7] fix: files formatting --- packages/validation/src/Rules/Custom.php | 6 ++---- packages/validation/tests/Rules/CustomTest.php | 10 ++++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/validation/src/Rules/Custom.php b/packages/validation/src/Rules/Custom.php index 7669dc557..759ca45a2 100644 --- a/packages/validation/src/Rules/Custom.php +++ b/packages/validation/src/Rules/Custom.php @@ -4,13 +4,12 @@ namespace Tempest\Validation\Rules; -use Closure; use Attribute; +use Closure; use InvalidArgumentException; use ReflectionFunction; use Tempest\Validation\Rule; - /** * Custom validation rule defined by a closure. * @@ -19,7 +18,6 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] final readonly class Custom implements Rule { - private Closure $callback; public function __construct( @@ -30,7 +28,7 @@ public function __construct( $reflection = new ReflectionFunction($callback); // Must be static - if (!$reflection->isStatic()) { + if (! $reflection->isStatic()) { throw new InvalidArgumentException('Validation closures must be static'); } diff --git a/packages/validation/tests/Rules/CustomTest.php b/packages/validation/tests/Rules/CustomTest.php index 49352ba25..0ca6ce480 100644 --- a/packages/validation/tests/Rules/CustomTest.php +++ b/packages/validation/tests/Rules/CustomTest.php @@ -14,15 +14,14 @@ final class CustomTest extends TestCase { public function test_closure_validation_passes(): void { - $rule = new Custom(static fn(mixed $value): bool => str_contains((string) $value, '@')); - + $rule = new Custom(static fn (mixed $value): bool => str_contains((string) $value, '@')); $this->assertTrue($rule->isValid('user@example.com')); $this->assertTrue($rule->isValid('test@domain.org')); } public function test_closure_validation_fails(): void { - $rule = new Custom(static fn(mixed $value): bool => str_contains((string) $value, '@')); + $rule = new Custom(static fn (mixed $value): bool => str_contains((string) $value, '@')); $this->assertFalse($rule->isValid('username')); $this->assertFalse($rule->isValid('example.com')); @@ -30,7 +29,7 @@ public function test_closure_validation_fails(): void public function test_non_string_value_fails(): void { - $rule = new Custom(static fn(mixed $value): bool => str_contains((string) $value, '@')); + $rule = new Custom(static fn (mixed $value): bool => str_contains((string) $value, '@')); $this->assertFalse($rule->isValid(12345)); $this->assertFalse($rule->isValid(null)); @@ -41,7 +40,6 @@ public function test_static_closure_required(): void { $this->expectException(\InvalidArgumentException::class); - // Non-static closure should throw exception - new Custom(fn(mixed $value): bool => str_contains((string) $value, '@')); + new Custom(fn (mixed $value): bool => str_contains((string) $value, '@')); } } From 30d35bd0b9470b85cab0b094a9d4c7b2b19d58c6 Mon Sep 17 00:00:00 2001 From: MohammadAlhallaq Date: Sun, 21 Dec 2025 16:48:09 +0300 Subject: [PATCH 5/7] fix: more descriptive class name --- .../validation/src/Rules/{Custom.php => Closure.php} | 7 +++---- .../tests/Rules/{CustomTest.php => ClosureTest.php} | 12 ++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) rename packages/validation/src/Rules/{Custom.php => Closure.php} (89%) rename packages/validation/tests/Rules/{CustomTest.php => ClosureTest.php} (65%) diff --git a/packages/validation/src/Rules/Custom.php b/packages/validation/src/Rules/Closure.php similarity index 89% rename from packages/validation/src/Rules/Custom.php rename to packages/validation/src/Rules/Closure.php index 759ca45a2..cd6d60fd0 100644 --- a/packages/validation/src/Rules/Custom.php +++ b/packages/validation/src/Rules/Closure.php @@ -5,7 +5,6 @@ namespace Tempest\Validation\Rules; use Attribute; -use Closure; use InvalidArgumentException; use ReflectionFunction; use Tempest\Validation\Rule; @@ -16,12 +15,12 @@ * The closure receives the value and must return true if it is valid, false otherwise. */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] -final readonly class Custom implements Rule +final readonly class Closure implements Rule { - private Closure $callback; + private \Closure $callback; public function __construct( - Closure $callback, + \Closure $callback, ) { $this->callback = $callback; diff --git a/packages/validation/tests/Rules/CustomTest.php b/packages/validation/tests/Rules/ClosureTest.php similarity index 65% rename from packages/validation/tests/Rules/CustomTest.php rename to packages/validation/tests/Rules/ClosureTest.php index 0ca6ce480..ea81e9c0e 100644 --- a/packages/validation/tests/Rules/CustomTest.php +++ b/packages/validation/tests/Rules/ClosureTest.php @@ -5,23 +5,23 @@ namespace Tempest\Validation\Tests\Rules; use PHPUnit\Framework\TestCase; -use Tempest\Validation\Rules\Custom; +use Tempest\Validation\Rules\Closure; /** * @internal */ -final class CustomTest extends TestCase +final class ClosureTest extends TestCase { public function test_closure_validation_passes(): void { - $rule = new Custom(static fn (mixed $value): bool => str_contains((string) $value, '@')); + $rule = new Closure(static fn (mixed $value): bool => str_contains((string) $value, '@')); $this->assertTrue($rule->isValid('user@example.com')); $this->assertTrue($rule->isValid('test@domain.org')); } public function test_closure_validation_fails(): void { - $rule = new Custom(static fn (mixed $value): bool => str_contains((string) $value, '@')); + $rule = new Closure(static fn (mixed $value): bool => str_contains((string) $value, '@')); $this->assertFalse($rule->isValid('username')); $this->assertFalse($rule->isValid('example.com')); @@ -29,7 +29,7 @@ public function test_closure_validation_fails(): void public function test_non_string_value_fails(): void { - $rule = new Custom(static fn (mixed $value): bool => str_contains((string) $value, '@')); + $rule = new Closure(static fn (mixed $value): bool => str_contains((string) $value, '@')); $this->assertFalse($rule->isValid(12345)); $this->assertFalse($rule->isValid(null)); @@ -40,6 +40,6 @@ public function test_static_closure_required(): void { $this->expectException(\InvalidArgumentException::class); - new Custom(fn (mixed $value): bool => str_contains((string) $value, '@')); + new Closure(fn (mixed $value): bool => str_contains((string) $value, '@')); } } From cd1aeb4e2db51ee1b302c156a5a2c49b9fe6a7ad Mon Sep 17 00:00:00 2001 From: MohammadAlhallaq Date: Wed, 31 Dec 2025 01:39:03 +0300 Subject: [PATCH 6/7] refactor: additional test case --- .../src/Rules/{Closure.php => Predicate.php} | 7 ++- .../ObjectWithPredicateValidation.php | 15 +++++ .../validation/tests/Rules/ClosureTest.php | 45 -------------- .../validation/tests/Rules/PredicateTest.php | 59 +++++++++++++++++++ 4 files changed, 78 insertions(+), 48 deletions(-) rename packages/validation/src/Rules/{Closure.php => Predicate.php} (89%) create mode 100644 packages/validation/tests/Fixtures/ObjectWithPredicateValidation.php delete mode 100644 packages/validation/tests/Rules/ClosureTest.php create mode 100644 packages/validation/tests/Rules/PredicateTest.php diff --git a/packages/validation/src/Rules/Closure.php b/packages/validation/src/Rules/Predicate.php similarity index 89% rename from packages/validation/src/Rules/Closure.php rename to packages/validation/src/Rules/Predicate.php index cd6d60fd0..3173ca696 100644 --- a/packages/validation/src/Rules/Closure.php +++ b/packages/validation/src/Rules/Predicate.php @@ -5,6 +5,7 @@ namespace Tempest\Validation\Rules; use Attribute; +use Closure; use InvalidArgumentException; use ReflectionFunction; use Tempest\Validation\Rule; @@ -15,12 +16,12 @@ * The closure receives the value and must return true if it is valid, false otherwise. */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] -final readonly class Closure implements Rule +final readonly class Predicate implements Rule { - private \Closure $callback; + private Closure $callback; public function __construct( - \Closure $callback, + Closure $callback, ) { $this->callback = $callback; diff --git a/packages/validation/tests/Fixtures/ObjectWithPredicateValidation.php b/packages/validation/tests/Fixtures/ObjectWithPredicateValidation.php new file mode 100644 index 000000000..d40f8f98d --- /dev/null +++ b/packages/validation/tests/Fixtures/ObjectWithPredicateValidation.php @@ -0,0 +1,15 @@ + str_contains((string) $value, '@')); - $this->assertTrue($rule->isValid('user@example.com')); - $this->assertTrue($rule->isValid('test@domain.org')); - } - - public function test_closure_validation_fails(): void - { - $rule = new Closure(static fn (mixed $value): bool => str_contains((string) $value, '@')); - - $this->assertFalse($rule->isValid('username')); - $this->assertFalse($rule->isValid('example.com')); - } - - public function test_non_string_value_fails(): void - { - $rule = new Closure(static fn (mixed $value): bool => str_contains((string) $value, '@')); - - $this->assertFalse($rule->isValid(12345)); - $this->assertFalse($rule->isValid(null)); - $this->assertFalse($rule->isValid(false)); - } - - public function test_static_closure_required(): void - { - $this->expectException(\InvalidArgumentException::class); - - new Closure(fn (mixed $value): bool => str_contains((string) $value, '@')); - } -} diff --git a/packages/validation/tests/Rules/PredicateTest.php b/packages/validation/tests/Rules/PredicateTest.php new file mode 100644 index 000000000..aa531ca5b --- /dev/null +++ b/packages/validation/tests/Rules/PredicateTest.php @@ -0,0 +1,59 @@ +getAttributes(Predicate::class); + + $this->assertCount(1, $attributes); + + $rule = $attributes[0]->newInstance(); + $this->assertTrue($rule->isValid('user@example')); + $this->assertFalse($rule->isValid('invalid-prop')); + } + + public function test_closure_validation_passes(): void + { + $rule = new Predicate(static fn (mixed $value): bool => str_contains((string) $value, '@')); + $this->assertTrue($rule->isValid('user@example.com')); + $this->assertTrue($rule->isValid('test@domain.org')); + } + + public function test_closure_validation_fails(): void + { + $rule = new Predicate(static fn (mixed $value): bool => str_contains((string) $value, '@')); + + $this->assertFalse($rule->isValid('username')); + $this->assertFalse($rule->isValid('example.com')); + } + + public function test_non_string_value_fails(): void + { + $rule = new Predicate(static fn (mixed $value): bool => str_contains((string) $value, '@')); + + $this->assertFalse($rule->isValid(12345)); + $this->assertFalse($rule->isValid(null)); + $this->assertFalse($rule->isValid(false)); + } + + public function test_static_closure_required(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Predicate(fn (mixed $value): bool => str_contains((string) $value, '@')); + } +} From 15dddce64deab771c76ae4b2c9e5fbba5b6f5c73 Mon Sep 17 00:00:00 2001 From: MohammadAlhallaq Date: Wed, 31 Dec 2025 01:43:21 +0300 Subject: [PATCH 7/7] refactor: adding return type --- packages/validation/tests/Rules/PredicateTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validation/tests/Rules/PredicateTest.php b/packages/validation/tests/Rules/PredicateTest.php index aa531ca5b..ea9a0360d 100644 --- a/packages/validation/tests/Rules/PredicateTest.php +++ b/packages/validation/tests/Rules/PredicateTest.php @@ -14,7 +14,7 @@ */ final class PredicateTest extends TestCase { - public function test_predicate_attribute_on_property_is_applied() + public function test_predicate_attribute_on_property_is_applied(): void { $reflection = new ReflectionProperty(ObjectWithPredicateValidation::class, 'prop'); $attributes = $reflection->getAttributes(Predicate::class);