From e7c27b976eefd383de10a5195e8c986815ac43a4 Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Mon, 14 Oct 2024 18:56:41 +0200 Subject: [PATCH] Add make and uuid functions --- src/Rules/ExistsEloquent.php | 62 ++++++++++++++++++++---- src/Rules/UniqueEloquent.php | 72 +++++++++++++++++++++------- tests/Feature/ExistsEloquentTest.php | 62 ++++++++++++++++++++++++ tests/Feature/UniqueEloquentTest.php | 58 ++++++++++++++++++++++ tests/TestCase.php | 4 ++ 5 files changed, 231 insertions(+), 27 deletions(-) diff --git a/src/Rules/ExistsEloquent.php b/src/Rules/ExistsEloquent.php index 4759400..56efc55 100644 --- a/src/Rules/ExistsEloquent.php +++ b/src/Rules/ExistsEloquent.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class ExistsEloquent implements ValidationRule { @@ -53,6 +54,11 @@ class ExistsEloquent implements ValidationRule */ private bool $includeSoftDeleted = false; + /** + * @var bool Whether the key field is of type UUID + */ + private bool $isFieldUuid = false; + /** * Create a new rule instance. * @@ -67,6 +73,18 @@ public function __construct(string $model, ?string $key = null, ?Closure $builde $this->setBuilderClosure($builderClosure); } + /** + * Create a new rule instance. + * + * @param class-string $model Class name of model + * @param string|null $key Relevant key in the model + * @param Closure|null $builderClosure Closure that can extend the eloquent builder + */ + public static function make(string $model, ?string $key = null, ?Closure $builderClosure = null): self + { + return new self($model, $key, $builderClosure); + } + /** * Set a custom validation message. * @@ -93,6 +111,20 @@ public function withCustomTranslation(string $translationKey): self return $this; } + /** + * The field has the data type UUID. + * If the field is not a UUID, the validation will fail, before the query is executed. + * This is useful for example for Postgres databases where queries fail if a field with UUID data type is queried with a non-UUID value. + * + * @return $this + */ + public function uuid(): self + { + $this->isFieldUuid = true; + + return $this; + } + /** * Determine if the validation rule passes. * @@ -104,6 +136,12 @@ public function withCustomTranslation(string $translationKey): self */ public function validate(string $attribute, mixed $value, Closure $fail): void { + if ($this->isFieldUuid) { + if (!is_string($value) || !Str::isUuid($value)) { + $this->fail($attribute, $value, $fail); + return; + } + } /** @var Model|Builder $builder */ $builder = new $this->model(); $modelKeyName = $builder->getKeyName(); @@ -122,15 +160,21 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } if ($builder->doesntExist()) { - if ($this->customMessage !== null) { - $fail($this->customMessage); - } else { - $fail($this->customMessageTranslationKey ?? 'modelValidationRules::validation.exists_model')->translate([ - 'attribute' => $attribute, - 'model' => strtolower(class_basename($this->model)), - 'value' => $value, - ]); - } + $this->fail($attribute, $value, $fail); + return; + } + } + + private function fail(string $attribute, mixed $value, Closure $fail): void + { + if ($this->customMessage !== null) { + $fail($this->customMessage); + } else { + $fail($this->customMessageTranslationKey ?? 'modelValidationRules::validation.exists_model')->translate([ + 'attribute' => $attribute, + 'model' => strtolower(class_basename($this->model)), + 'value' => $value, + ]); } } diff --git a/src/Rules/UniqueEloquent.php b/src/Rules/UniqueEloquent.php index 8a74841..bad589e 100644 --- a/src/Rules/UniqueEloquent.php +++ b/src/Rules/UniqueEloquent.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; class UniqueEloquent implements ValidationRule { @@ -63,12 +64,17 @@ class UniqueEloquent implements ValidationRule */ private bool $includeSoftDeleted = false; + /** + * @var bool Whether the ID is a UUID + */ + private bool $isFieldUuid = false; + /** * UniqueEloquent constructor. * - * @param class-string $model Class name of model. - * @param string|null $key Relevant key in the model. - * @param Closure|null $builderClosure Closure that can extend the eloquent builder + * @param class-string $model Class name of model. + * @param string|null $key Relevant key in the model. + * @param Closure|null $builderClosure Closure that can extend the eloquent builder */ public function __construct(string $model, ?string $key = null, ?Closure $builderClosure = null) { @@ -77,17 +83,32 @@ public function __construct(string $model, ?string $key = null, ?Closure $builde $this->setBuilderClosure($builderClosure); } + /** + * @param class-string $model Class name of model. + * @param string|null $key Relevant key in the model. + * @param Closure|null $builderClosure Closure that can extend the eloquent builder + */ + public static function make(string $model, ?string $key = null, ?Closure $builderClosure = null): self + { + return new self($model, $key, $builderClosure); + } + /** * Determine if the validation rule passes. * - * @param string $attribute - * @param mixed $value - * @param Closure $fail + * @param string $attribute + * @param mixed $value + * @param Closure $fail * * @return void */ public function validate(string $attribute, mixed $value, Closure $fail): void { + if ($this->isFieldUuid) { + if (!is_string($value) || !Str::isUuid($value)) { + return; + } + } /** @var Model|Builder $builder */ $builder = new $this->model(); $modelKeyName = $builder->getKeyName(); @@ -112,11 +133,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void if ($this->customMessage !== null) { $fail($this->customMessage); } else { - $fail($this->customMessageTranslationKey ?? 'modelValidationRules::validation.unique_model')->translate([ - 'attribute' => $attribute, - 'model' => strtolower(class_basename($this->model)), - 'value' => $value, - ]); + $fail($this->customMessageTranslationKey ?? 'modelValidationRules::validation.unique_model') + ->translate([ + 'attribute' => $attribute, + 'model' => strtolower(class_basename($this->model)), + 'value' => $value, + ]); } } } @@ -124,7 +146,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void /** * Set a custom validation message. * - * @param string $message + * @param string $message * @return $this */ public function withMessage(string $message): self @@ -137,7 +159,7 @@ public function withMessage(string $message): self /** * Set a translated custom validation message. * - * @param string $translationKey + * @param string $translationKey * @return $this */ public function withCustomTranslation(string $translationKey): self @@ -150,7 +172,7 @@ public function withCustomTranslation(string $translationKey): self /** * Set a closure that can extend the eloquent builder. * - * @param Closure|null $builderClosure + * @param Closure|null $builderClosure */ public function setBuilderClosure(?Closure $builderClosure): void { @@ -158,7 +180,7 @@ public function setBuilderClosure(?Closure $builderClosure): void } /** - * @param Closure $builderClosure + * @param Closure $builderClosure * @return $this */ public function query(Closure $builderClosure): self @@ -169,8 +191,8 @@ public function query(Closure $builderClosure): self } /** - * @param mixed $id - * @param string|null $column + * @param mixed $id + * @param string|null $column */ public function setIgnore(mixed $id, ?string $column = null): void { @@ -180,7 +202,7 @@ public function setIgnore(mixed $id, ?string $column = null): void /** * @param mixed $id - * @param string|null $column + * @param string|null $column * @return UniqueEloquent */ public function ignore(mixed $id, ?string $column = null): self @@ -201,6 +223,20 @@ public function setIncludeSoftDeleted(bool $includeSoftDeleted): void $this->includeSoftDeleted = $includeSoftDeleted; } + /** + * The field has the data type UUID. + * If a value is not a UUID, the validation will be skipped. + * This is useful for example for Postgres databases where queries fail if a field with UUID data type is queried with a non-UUID value. + * + * @return $this + */ + public function uuid(): self + { + $this->isFieldUuid = true; + + return $this; + } + /** * Activate including soft deleted models in the query. * diff --git a/tests/Feature/ExistsEloquentTest.php b/tests/Feature/ExistsEloquentTest.php index cdb9bf3..685dcab 100644 --- a/tests/Feature/ExistsEloquentTest.php +++ b/tests/Feature/ExistsEloquentTest.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Lang; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; @@ -30,12 +31,15 @@ public function testValidationFailsIfEntryDoesNotExistInDatabase(): void ], [ 'id' => [new ExistsEloquent(User::class)] ]); + $this->db->enableQueryLog(); // Act $isValid = $validator->passes(); $messages = $validator->messages()->toArray(); // Assert + $queryLog = $this->db->getQueryLog(); + $this->assertCount(1, $queryLog); $this->assertFalse($isValid); $this->assertEquals('The resource does not exist.', $messages['id'][0]); } @@ -405,4 +409,62 @@ public function testValidationMessageIsLaravelTranslationIfCustomTranslationIsSe $this->assertFalse($isValid); $this->assertEquals('A user with the id "1" does not exist. / Test', $messages['id'][0]); } + + public function testFunctionMakeIsIdenticalToConstructor(): void + { + // Arrange + $message = 'Test'; + $closure = function (Builder $builder) { + return $builder->where('user_id', 6); + }; + + // Act + $rule1 = ExistsEloquent::make(User::class, 'other_id', $closure)->withMessage($message); + $rule2 = (new ExistsEloquent(User::class, 'other_id', $closure))->withMessage($message); + + // Assert + $this->assertEquals($rule1, $rule2); + } + + public function testUuidOptionMakesRuleFailIfValueIsNotUuidBeforeQueryingTheDatabase(): void + { + // Arrange + $validator = Validator::make([ + 'id' => 'not-a-uuid', + ], [ + 'id' => [(new ExistsEloquent(User::class))->uuid()] + ]); + $this->db->enableQueryLog(); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $queryLog = $this->db->getQueryLog(); + $this->assertCount(0, $queryLog); + $this->assertFalse($isValid); + $this->assertEquals('The resource does not exist.', $messages['id'][0]); + } + + public function testUuidOptionMakesRuleFailIfValueIsNotStringBeforeQueryingTheDatabase(): void + { + // Arrange + $validator = Validator::make([ + 'id' => 1, + ], [ + 'id' => [(new ExistsEloquent(User::class))->uuid()] + ]); + $this->db->enableQueryLog(); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $queryLog = $this->db->getQueryLog(); + $this->assertCount(0, $queryLog); + $this->assertFalse($isValid); + $this->assertEquals('The resource does not exist.', $messages['id'][0]); + } } diff --git a/tests/Feature/UniqueEloquentTest.php b/tests/Feature/UniqueEloquentTest.php index 1af2b9b..b640296 100644 --- a/tests/Feature/UniqueEloquentTest.php +++ b/tests/Feature/UniqueEloquentTest.php @@ -500,4 +500,62 @@ public function testIgnoringEntryWithGivenIdColumn(): void $this->assertTrue($isValid); $this->assertArrayNotHasKey('id', $messages); } + + public function testFunctionMakeIsIdenticalToConstructor(): void + { + // Arrange + $message = 'Test'; + $closure = function (Builder $builder) { + return $builder->where('user_id', 6); + }; + + // Act + $rule1 = UniqueEloquent::make(User::class, 'other_id', $closure)->withMessage($message); + $rule2 = (new UniqueEloquent(User::class, 'other_id', $closure))->withMessage($message); + + // Assert + $this->assertEquals($rule1, $rule2); + } + + public function testUuidOptionSkipsValidationIfValueIsNotUuid(): void + { + // Arrange + $validator = Validator::make([ + 'id' => 'not-a-uuid', + ], [ + 'id' => [(new UniqueEloquent(User::class))->uuid()] + ]); + $this->db->enableQueryLog(); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $queryLog = $this->db->getQueryLog(); + $this->assertCount(0, $queryLog); + $this->assertTrue($isValid); + $this->assertArrayNotHasKey('id', $messages); + } + + public function testUuidOptionSkipsValidationIfValueIsNotString(): void + { + // Arrange + $validator = Validator::make([ + 'id' => 1, + ], [ + 'id' => [(new UniqueEloquent(User::class))->uuid()] + ]); + $this->db->enableQueryLog(); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $queryLog = $this->db->getQueryLog(); + $this->assertCount(0, $queryLog); + $this->assertTrue($isValid); + $this->assertArrayNotHasKey('id', $messages); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index a89b984..6cda415 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,6 +6,7 @@ use Illuminate\Container\Container; use Illuminate\Database\Capsule\Manager; +use Illuminate\Database\Connection; use Illuminate\Database\Schema\Blueprint; use Illuminate\Events\Dispatcher; use Illuminate\Foundation\Application; @@ -14,6 +15,8 @@ abstract class TestCase extends Orchestra { + protected Connection $db; + public function setUp(): void { parent::setUp(); @@ -57,5 +60,6 @@ protected function setUpDatabase(): void $table->softDeletes(); $table->timestamps(); }); + $this->db = $manager->getConnection(); } }