From e4e1694d9c59c48f78e6d445b97775bdf179d9d0 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 17 Jul 2026 04:02:56 +0200 Subject: [PATCH] feat(validation): support skipping missing properties --- docs/2-features/03-validation.md | 16 ++++++++++ packages/validation/src/SkipIfMissing.php | 10 ++++++ packages/validation/src/Validator.php | 2 +- .../RequestWithOptionalProperties.php | 15 +++++++++ .../Route/RequestToObjectMapperTest.php | 32 +++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/validation/src/SkipIfMissing.php create mode 100644 tests/Integration/Route/Fixtures/RequestWithOptionalProperties.php diff --git a/docs/2-features/03-validation.md b/docs/2-features/03-validation.md index 5bbfaed165..ea6ada0e81 100644 --- a/docs/2-features/03-validation.md +++ b/docs/2-features/03-validation.md @@ -76,6 +76,22 @@ final class Book } ``` +Use {b`#[Tempest\Validation\SkipIfMissing]`} when a property should only be validated when its value is present. Missing properties remain uninitialized when mapping, which is useful for partial updates. + +```php +use Tempest\Validation\Rules\IsNotEmptyString; +use Tempest\Validation\SkipIfMissing; + +final class UpdateBook +{ + #[SkipIfMissing, IsNotEmptyString] + public string $title; + + #[SkipIfMissing] + public ?DateTime $publishedAt; +} +``` + ## Validating an existing object instance When you already have an instantiated object, you may use the `validateObject()` method. Unlike `validateValuesForClass()`, this method takes an object instance and reads the actual values of its public properties directly. diff --git a/packages/validation/src/SkipIfMissing.php b/packages/validation/src/SkipIfMissing.php new file mode 100644 index 0000000000..e1fe5a1d49 --- /dev/null +++ b/packages/validation/src/SkipIfMissing.php @@ -0,0 +1,10 @@ +getName(); - if (! $values->hasKey($key) && $property->hasDefaultValue()) { + if (! $values->hasKey($key) && ($property->hasDefaultValue() || $property->hasAttribute(SkipIfMissing::class))) { continue; } diff --git a/tests/Integration/Route/Fixtures/RequestWithOptionalProperties.php b/tests/Integration/Route/Fixtures/RequestWithOptionalProperties.php new file mode 100644 index 0000000000..2fc81c848d --- /dev/null +++ b/tests/Integration/Route/Fixtures/RequestWithOptionalProperties.php @@ -0,0 +1,15 @@ +assertInstanceOf(IsNotNull::class, $validationFailed->failingRules['enumParam'][0]->rule); } } + + public function test_missing_optional_properties_are_not_initialized(): void + { + $request = map(new GenericRequest( + method: Method::PATCH, + uri: '/', + body: ['expiryDate' => null], + ))->with( + RequestToObjectMapper::class, + )->to(RequestWithOptionalProperties::class); + + $this->assertFalse(new ReflectionProperty($request, 'title')->isInitialized($request)); + $this->assertTrue(new ReflectionProperty($request, 'expiryDate')->isInitialized($request)); + $this->assertNull($request->expiryDate); + } + + public function test_present_optional_properties_are_validated(): void + { + $this->assertException( + expectedExceptionClass: ValidationFailed::class, + handler: fn () => map(new GenericRequest( + method: Method::PATCH, + uri: '/', + body: ['title' => ''], + ))->with( + RequestToObjectMapper::class, + )->to(RequestWithOptionalProperties::class), + assertException: fn (ValidationFailed $exception) => $this->assertArrayHasKey('title', $exception->failingRules), + ); + } }