From 1b45354d12e0bd0a0d3ffc7e186d56973c065eb1 Mon Sep 17 00:00:00 2001 From: Tonko Mulder Date: Thu, 7 Aug 2025 01:02:57 +0200 Subject: [PATCH 1/3] add `Exists` validation rule with comprehensive tests Signed-off-by: Tonko Mulder --- packages/validation/src/Rules/Exists.php | 48 +++++ .../tests/Fixtures/ValidateExistsModel.php | 16 ++ .../validation/tests/Rules/ExistsTest.php | 56 ++++++ tests/Fixtures/Rules/ValidateExistsModel.php | 16 ++ .../Integration/Http/Rules/ExistsRuleTest.php | 175 ++++++++++++++++++ 5 files changed, 311 insertions(+) create mode 100644 packages/validation/src/Rules/Exists.php create mode 100644 packages/validation/tests/Fixtures/ValidateExistsModel.php create mode 100644 packages/validation/tests/Rules/ExistsTest.php create mode 100644 tests/Fixtures/Rules/ValidateExistsModel.php create mode 100644 tests/Integration/Http/Rules/ExistsRuleTest.php diff --git a/packages/validation/src/Rules/Exists.php b/packages/validation/src/Rules/Exists.php new file mode 100644 index 000000000..58fd08b91 --- /dev/null +++ b/packages/validation/src/Rules/Exists.php @@ -0,0 +1,48 @@ +model)) { + throw new InvalidArgumentException("Model {$this->model} does not exist"); + } + } + + public function isValid(mixed $value): bool + { + if ((! is_numeric($value) || is_float($value)) && ! is_object($value)) { + return false; + } + + $id = is_object($value) ? $value : new Id($value); + + if ($id->id >= PHP_INT_MAX) { + return false; + } + + $model = query($this->model) + ->select() + ->get(id: $id); + + return $model !== null; + } + + public function message(): string + { + return sprintf('Record for model %1$s does not exist', $this->model); + } +} diff --git a/packages/validation/tests/Fixtures/ValidateExistsModel.php b/packages/validation/tests/Fixtures/ValidateExistsModel.php new file mode 100644 index 000000000..1f7c87929 --- /dev/null +++ b/packages/validation/tests/Fixtures/ValidateExistsModel.php @@ -0,0 +1,16 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model NonExistentModel does not exist'); + + new Exists('NonExistentModel'); + } + + #[Test] + public function returns_false_for_null_or_non_integer_values(): void + { + $rule = new Exists(ValidateExistsModel::class); + + $this->assertFalse($rule->isValid('string')); + $this->assertFalse($rule->isValid(1.5)); + $this->assertFalse($rule->isValid([])); + $this->assertFalse($rule->isValid(true)); + $this->assertFalse($rule->isValid(null)); + } + + #[Test] + public function returns_correct_error_message(): void + { + $rule = new Exists(ValidateExistsModel::class); + + $expectedMessage = sprintf('Record for model %s does not exist', ValidateExistsModel::class); + $this->assertSame($expectedMessage, $rule->message()); + } + + #[Test] + public function can_be_constructed_with_valid_model_class(): void + { + $rule = new Exists(ValidateExistsModel::class); + + $this->assertInstanceOf(Exists::class, $rule); + $this->assertStringContainsString(ValidateExistsModel::class, $rule->message()); + } +} diff --git a/tests/Fixtures/Rules/ValidateExistsModel.php b/tests/Fixtures/Rules/ValidateExistsModel.php new file mode 100644 index 000000000..022f16cdf --- /dev/null +++ b/tests/Fixtures/Rules/ValidateExistsModel.php @@ -0,0 +1,16 @@ +migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $book = Book::create(title: 'Timeline Taxi'); + + $rule = new Exists(Book::class); + + $this->assertTrue($rule->isValid($book->id)); + } + + #[Test] + public function validates_non_existent_record_returns_false(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $rule = new Exists(Book::class); + + $this->assertFalse($rule->isValid(99999)); + $this->assertFalse($rule->isValid(12345)); + } + + #[Test] + public function validates_multiple_existing_records(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $book1 = Book::create(title: 'The Lord of the Rings'); + $book2 = Book::create(title: 'The Silmarillion'); + $book3 = Book::create(title: 'Unfinished Tales'); + + $rule = new Exists(Book::class); + + $this->assertTrue($rule->isValid($book1->id->id)); + $this->assertTrue($rule->isValid($book2->id->id)); + $this->assertTrue($rule->isValid($book3->id->id)); + + $this->assertFalse($rule->isValid(99999)); + } + + #[Test] + public function validates_different_model_types(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $author = Author::create(name: 'B. Roose'); + $book = Book::create(title: 'Timeline Taxi'); + + $authorRule = new Exists(Author::class); + $bookRule = new Exists(Book::class); + + $this->assertTrue($authorRule->isValid($author->id->id)); + $this->assertTrue($bookRule->isValid($book->id->id)); + + $this->assertFalse($authorRule->isValid(99999)); + $this->assertFalse($bookRule->isValid(99999)); + + $author2 = Author::create(name: 'B. Roose'); + $book2 = Book::create(title: 'Timeline Taxi 2'); + + $this->assertTrue($authorRule->isValid($author2->id->id)); + $this->assertTrue($bookRule->isValid($book2->id->id)); + } + + #[Test] + public function validates_edge_cases_with_large_id_numbers(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $rule = new Exists(Book::class); + + $this->assertFalse($rule->isValid(PHP_INT_MAX)); + $this->assertFalse($rule->isValid(999999999)); + $this->assertFalse($rule->isValid(2147483647)); // Max 32-bit integer + } + + #[Test] + public function validates_after_record_deletion(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $book = Book::create(title: 'Timeline Taxi Draft'); + $bookId = $book->id->id; + + $rule = new Exists(Book::class); + + $this->assertTrue($rule->isValid($bookId)); + + $book->delete(); + + $this->assertFalse($rule->isValid($bookId)); + } + + #[Test] + public function validates_with_sequential_id_creation(): void + { + $this->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + + $rule = new Exists(Book::class); + $createdIds = []; + + for ($i = 1; $i <= 5; $i++) { + $book = Book::create(title: "Book {$i}"); + $createdIds[] = $book->id->id; + + $this->assertTrue($rule->isValid($book->id->id)); + } + + foreach ($createdIds as $id) { + $this->assertTrue($rule->isValid($id)); + } + + $maxId = max($createdIds); + $this->assertFalse($rule->isValid($maxId + 1)); + $this->assertFalse($rule->isValid($maxId + 100)); + } +} From 3349ded323ec8ccdef5110235e9d13be53a9d4f0 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Fri, 15 Aug 2025 20:34:41 +0200 Subject: [PATCH 2/3] refactor: adapt to orm changes and support normal tables --- .../Builder/QueryBuilders/QueryBuilder.php | 2 +- packages/validation/src/Rules/Exists.php | 46 +++-- packages/validation/src/localization.en.yml | 3 + .../tests/Fixtures/ValidateExistsModel.php | 16 -- .../validation/tests/Rules/ExistsTest.php | 33 ++-- tests/Fixtures/Requests/BookRequest.php | 1 + tests/Fixtures/Rules/ValidateExistsModel.php | 16 -- .../Integration/Http/Rules/ExistsRuleTest.php | 175 ------------------ .../Integration/Validator/ExistsRuleTest.php | 66 +++++++ .../Validator/TranslationsTest.php | 16 ++ 10 files changed, 133 insertions(+), 241 deletions(-) delete mode 100644 packages/validation/tests/Fixtures/ValidateExistsModel.php delete mode 100644 tests/Fixtures/Rules/ValidateExistsModel.php delete mode 100644 tests/Integration/Http/Rules/ExistsRuleTest.php create mode 100644 tests/Integration/Validator/ExistsRuleTest.php diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index a770e9f2d..25f0494f0 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -152,7 +152,7 @@ public function new(mixed ...$params): object * * @return TModel */ - public function findById(string|int|PrimaryKey $id): object + public function findById(string|int|PrimaryKey $id): ?object { if (! inspect($this->model)->hasPrimaryKey()) { throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'findById'); diff --git a/packages/validation/src/Rules/Exists.php b/packages/validation/src/Rules/Exists.php index 58fd08b91..620c8871b 100644 --- a/packages/validation/src/Rules/Exists.php +++ b/packages/validation/src/Rules/Exists.php @@ -6,43 +6,59 @@ use Attribute; use InvalidArgumentException; -use Tempest\Database\Id; +use Tempest\Database\PrimaryKey; +use Tempest\Validation\HasTranslationVariables; use Tempest\Validation\Rule; +use function Tempest\Database\inspect; use function Tempest\Database\query; +/** + * Ensures that for the given model, the primary key value associated to this rule exists in the database. + */ #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class Exists implements Rule +final readonly class Exists implements Rule, HasTranslationVariables { + /** + * @param class-string|non-empty-string $table + */ public function __construct( - private string $model, + private string $table, + private ?string $column = null, ) { - if (! class_exists($this->model)) { - throw new InvalidArgumentException("Model {$this->model} does not exist"); + if (! class_exists($table) && $column === null) { + throw new InvalidArgumentException('A column must be specified when the table is not a model class.'); } } public function isValid(mixed $value): bool { - if ((! is_numeric($value) || is_float($value)) && ! is_object($value)) { + if (! is_object($value) && ! is_numeric($value) && ! is_string($value)) { return false; } - $id = is_object($value) ? $value : new Id($value); - - if ($id->id >= PHP_INT_MAX) { + if (is_float($value)) { return false; } - $model = query($this->model) - ->select() - ->get(id: $id); + $column = match (is_null($this->column)) { + false => $this->column, + true => match ($key = inspect($this->table)->getPrimaryKey()) { + null => throw new InvalidArgumentException("Model `{$this->table}` does not have a primary key, and a column was not specified."), + default => $key, + }, + }; - return $model !== null; + return query($this->table) + ->count() + ->whereField($column, $value) + ->execute() > 0; } - public function message(): string + public function getTranslationVariables(): array { - return sprintf('Record for model %1$s does not exist', $this->model); + return [ + 'model' => $this->table, + ]; } } diff --git a/packages/validation/src/localization.en.yml b/packages/validation/src/localization.en.yml index 5618bb97c..4ce3310eb 100644 --- a/packages/validation/src/localization.en.yml +++ b/packages/validation/src/localization.en.yml @@ -79,6 +79,9 @@ validation_error: .input {$field :string} .input {$needle :string} {$field} must end with "{$needle}" + exists: | + .input {$field :string} + {$field} could not be found is_even_number: | .input {$field :string} {$field} must be even diff --git a/packages/validation/tests/Fixtures/ValidateExistsModel.php b/packages/validation/tests/Fixtures/ValidateExistsModel.php deleted file mode 100644 index 1f7c87929..000000000 --- a/packages/validation/tests/Fixtures/ValidateExistsModel.php +++ /dev/null @@ -1,16 +0,0 @@ -expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model NonExistentModel does not exist'); + $this->expectExceptionMessage('A column must be specified when the table is not a model class.'); - new Exists('NonExistentModel'); + new Exists('random-table'); } #[Test] @@ -29,28 +29,25 @@ public function returns_false_for_null_or_non_integer_values(): void { $rule = new Exists(ValidateExistsModel::class); - $this->assertFalse($rule->isValid('string')); $this->assertFalse($rule->isValid(1.5)); $this->assertFalse($rule->isValid([])); $this->assertFalse($rule->isValid(true)); + $this->assertFalse($rule->isValid(false)); $this->assertFalse($rule->isValid(null)); } - #[Test] - public function returns_correct_error_message(): void - { - $rule = new Exists(ValidateExistsModel::class); - - $expectedMessage = sprintf('Record for model %s does not exist', ValidateExistsModel::class); - $this->assertSame($expectedMessage, $rule->message()); - } - #[Test] public function can_be_constructed_with_valid_model_class(): void { - $rule = new Exists(ValidateExistsModel::class); - - $this->assertInstanceOf(Exists::class, $rule); - $this->assertStringContainsString(ValidateExistsModel::class, $rule->message()); + $this->assertInstanceOf(Exists::class, new Exists(ValidateExistsModel::class)); } } + +/** @internal */ +final class ValidateExistsModel +{ + public function __construct( + public PrimaryKey $id, + public string $name, + ) {} +} diff --git a/tests/Fixtures/Requests/BookRequest.php b/tests/Fixtures/Requests/BookRequest.php index 04ca40b0f..fb1a5372c 100644 --- a/tests/Fixtures/Requests/BookRequest.php +++ b/tests/Fixtures/Requests/BookRequest.php @@ -7,6 +7,7 @@ use Tempest\Database\HasOne; use Tempest\Http\IsRequest; use Tempest\Http\Request; +use Tempest\Validation\Rules\Exists; use Tempest\Validation\Rules\HasLength; use Tests\Tempest\Fixtures\Modules\Books\Models\Author; use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn; diff --git a/tests/Fixtures/Rules/ValidateExistsModel.php b/tests/Fixtures/Rules/ValidateExistsModel.php deleted file mode 100644 index 022f16cdf..000000000 --- a/tests/Fixtures/Rules/ValidateExistsModel.php +++ /dev/null @@ -1,16 +0,0 @@ -migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $book = Book::create(title: 'Timeline Taxi'); - - $rule = new Exists(Book::class); - - $this->assertTrue($rule->isValid($book->id)); - } - - #[Test] - public function validates_non_existent_record_returns_false(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $rule = new Exists(Book::class); - - $this->assertFalse($rule->isValid(99999)); - $this->assertFalse($rule->isValid(12345)); - } - - #[Test] - public function validates_multiple_existing_records(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $book1 = Book::create(title: 'The Lord of the Rings'); - $book2 = Book::create(title: 'The Silmarillion'); - $book3 = Book::create(title: 'Unfinished Tales'); - - $rule = new Exists(Book::class); - - $this->assertTrue($rule->isValid($book1->id->id)); - $this->assertTrue($rule->isValid($book2->id->id)); - $this->assertTrue($rule->isValid($book3->id->id)); - - $this->assertFalse($rule->isValid(99999)); - } - - #[Test] - public function validates_different_model_types(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $author = Author::create(name: 'B. Roose'); - $book = Book::create(title: 'Timeline Taxi'); - - $authorRule = new Exists(Author::class); - $bookRule = new Exists(Book::class); - - $this->assertTrue($authorRule->isValid($author->id->id)); - $this->assertTrue($bookRule->isValid($book->id->id)); - - $this->assertFalse($authorRule->isValid(99999)); - $this->assertFalse($bookRule->isValid(99999)); - - $author2 = Author::create(name: 'B. Roose'); - $book2 = Book::create(title: 'Timeline Taxi 2'); - - $this->assertTrue($authorRule->isValid($author2->id->id)); - $this->assertTrue($bookRule->isValid($book2->id->id)); - } - - #[Test] - public function validates_edge_cases_with_large_id_numbers(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $rule = new Exists(Book::class); - - $this->assertFalse($rule->isValid(PHP_INT_MAX)); - $this->assertFalse($rule->isValid(999999999)); - $this->assertFalse($rule->isValid(2147483647)); // Max 32-bit integer - } - - #[Test] - public function validates_after_record_deletion(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $book = Book::create(title: 'Timeline Taxi Draft'); - $bookId = $book->id->id; - - $rule = new Exists(Book::class); - - $this->assertTrue($rule->isValid($bookId)); - - $book->delete(); - - $this->assertFalse($rule->isValid($bookId)); - } - - #[Test] - public function validates_with_sequential_id_creation(): void - { - $this->migrate( - CreateMigrationsTable::class, - CreatePublishersTable::class, - CreateAuthorTable::class, - CreateBookTable::class, - ); - - $rule = new Exists(Book::class); - $createdIds = []; - - for ($i = 1; $i <= 5; $i++) { - $book = Book::create(title: "Book {$i}"); - $createdIds[] = $book->id->id; - - $this->assertTrue($rule->isValid($book->id->id)); - } - - foreach ($createdIds as $id) { - $this->assertTrue($rule->isValid($id)); - } - - $maxId = max($createdIds); - $this->assertFalse($rule->isValid($maxId + 1)); - $this->assertFalse($rule->isValid($maxId + 100)); - } -} diff --git a/tests/Integration/Validator/ExistsRuleTest.php b/tests/Integration/Validator/ExistsRuleTest.php new file mode 100644 index 000000000..e18fcd23a --- /dev/null +++ b/tests/Integration/Validator/ExistsRuleTest.php @@ -0,0 +1,66 @@ +migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + CreateBookTable::class, + ); + } + + #[Test] + public function existing_record_return_true(): void + { + $book = Book::create(title: 'Timeline Taxi'); + + $this->assertTrue(new Exists(Book::class)->isValid($book->id)); + $this->assertTrue(new Exists('books', column: 'id')->isValid($book->id)); + $this->assertTrue(new Exists('books', column: 'title')->isValid('Timeline Taxi')); + } + + #[Test] + public function non_existent_record_returns_false(): void + { + Book::create(title: 'Timeline Taxi'); + + $this->assertFalse(new Exists(Book::class)->isValid(99999)); + $this->assertFalse(new Exists(Book::class)->isValid(12345)); + $this->assertFalse(new Exists(Book::class, column: 'title')->isValid('Timeline Taxi 2')); + } + + #[Test] + public function validates_multiple_existing_records(): void + { + $book1 = Book::create(title: 'The Lord of the Rings'); + $book2 = Book::create(title: 'The Silmarillion'); + $book3 = Book::create(title: 'Unfinished Tales'); + + $this->assertTrue(new Exists(Book::class)->isValid($book1->id)); + $this->assertTrue(new Exists(Book::class)->isValid($book2->id)); + $this->assertTrue(new Exists(Book::class)->isValid($book3->id)); + $this->assertFalse(new Exists(Book::class)->isValid(99999)); + } +} diff --git a/tests/Integration/Validator/TranslationsTest.php b/tests/Integration/Validator/TranslationsTest.php index d4d6b06d1..29e7be9df 100644 --- a/tests/Integration/Validator/TranslationsTest.php +++ b/tests/Integration/Validator/TranslationsTest.php @@ -3,6 +3,7 @@ namespace Tests\Tempest\Integration\Validator; use PHPUnit\Framework\Attributes\TestWith; +use Tempest\Database\PrimaryKey; use Tempest\DateTime\FormatPattern; use Tempest\Validation\Rule; use Tempest\Validation\Rules; @@ -208,6 +209,16 @@ public function test_ends_with(?string $field = null): void ); } + #[TestWith([null])] + #[TestWith(['Input'])] + public function test_exists(?string $field = null): void + { + $this->assertSame( + expected: $this->formatWithField('%s could not be found', $field), + actual: $this->translate(new Rules\Exists(table: ModelForExistsRule::class), field: $field), + ); + } + #[TestWith([null])] #[TestWith(['Input'])] public function test_even(?string $field = null): void @@ -707,3 +718,8 @@ public function test_uuid(?string $field = null): void ); } } + +final class ModelForExistsRule +{ + public PrimaryKey $id; +} From 30e2a7da08d509faf32484e502a248f4240d62dd Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Fri, 15 Aug 2025 20:40:37 +0200 Subject: [PATCH 3/3] test: refactor to not use a model --- tests/Integration/Validator/TranslationsTest.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/Integration/Validator/TranslationsTest.php b/tests/Integration/Validator/TranslationsTest.php index 29e7be9df..c309fd72e 100644 --- a/tests/Integration/Validator/TranslationsTest.php +++ b/tests/Integration/Validator/TranslationsTest.php @@ -215,7 +215,7 @@ public function test_exists(?string $field = null): void { $this->assertSame( expected: $this->formatWithField('%s could not be found', $field), - actual: $this->translate(new Rules\Exists(table: ModelForExistsRule::class), field: $field), + actual: $this->translate(new Rules\Exists(table: 'non-existing-table', column: 'non-existing-column'), field: $field), ); } @@ -718,8 +718,3 @@ public function test_uuid(?string $field = null): void ); } } - -final class ModelForExistsRule -{ - public PrimaryKey $id; -}