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 new file mode 100644 index 000000000..620c8871b --- /dev/null +++ b/packages/validation/src/Rules/Exists.php @@ -0,0 +1,64 @@ +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 query($this->table) + ->count() + ->whereField($column, $value) + ->execute() > 0; + } + + public function getTranslationVariables(): array + { + 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/Rules/ExistsTest.php b/packages/validation/tests/Rules/ExistsTest.php new file mode 100644 index 000000000..3b35275fb --- /dev/null +++ b/packages/validation/tests/Rules/ExistsTest.php @@ -0,0 +1,53 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A column must be specified when the table is not a model class.'); + + new Exists('random-table'); + } + + #[Test] + public function returns_false_for_null_or_non_integer_values(): void + { + $rule = new Exists(ValidateExistsModel::class); + + $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 can_be_constructed_with_valid_model_class(): void + { + $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/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..c309fd72e 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: 'non-existing-table', column: 'non-existing-column'), field: $field), + ); + } + #[TestWith([null])] #[TestWith(['Input'])] public function test_even(?string $field = null): void