Skip to content

Commit a28c943

Browse files
Treggatsinnocenzi
andauthored
feat(validation): add Exists validation rule (#1462)
Co-authored-by: Enzo Innocenzi <[email protected]>
1 parent c6302f3 commit a28c943

File tree

7 files changed

+199
-1
lines changed

7 files changed

+199
-1
lines changed

packages/database/src/Builder/QueryBuilders/QueryBuilder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ public function new(mixed ...$params): object
152152
*
153153
* @return TModel
154154
*/
155-
public function findById(string|int|PrimaryKey $id): object
155+
public function findById(string|int|PrimaryKey $id): ?object
156156
{
157157
if (! inspect($this->model)->hasPrimaryKey()) {
158158
throw ModelDidNotHavePrimaryColumn::neededForMethod($this->model, 'findById');
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Validation\Rules;
6+
7+
use Attribute;
8+
use InvalidArgumentException;
9+
use Tempest\Database\PrimaryKey;
10+
use Tempest\Validation\HasTranslationVariables;
11+
use Tempest\Validation\Rule;
12+
13+
use function Tempest\Database\inspect;
14+
use function Tempest\Database\query;
15+
16+
/**
17+
* Ensures that for the given model, the primary key value associated to this rule exists in the database.
18+
*/
19+
#[Attribute(Attribute::TARGET_PROPERTY)]
20+
final readonly class Exists implements Rule, HasTranslationVariables
21+
{
22+
/**
23+
* @param class-string|non-empty-string $table
24+
*/
25+
public function __construct(
26+
private string $table,
27+
private ?string $column = null,
28+
) {
29+
if (! class_exists($table) && $column === null) {
30+
throw new InvalidArgumentException('A column must be specified when the table is not a model class.');
31+
}
32+
}
33+
34+
public function isValid(mixed $value): bool
35+
{
36+
if (! is_object($value) && ! is_numeric($value) && ! is_string($value)) {
37+
return false;
38+
}
39+
40+
if (is_float($value)) {
41+
return false;
42+
}
43+
44+
$column = match (is_null($this->column)) {
45+
false => $this->column,
46+
true => match ($key = inspect($this->table)->getPrimaryKey()) {
47+
null => throw new InvalidArgumentException("Model `{$this->table}` does not have a primary key, and a column was not specified."),
48+
default => $key,
49+
},
50+
};
51+
52+
return query($this->table)
53+
->count()
54+
->whereField($column, $value)
55+
->execute() > 0;
56+
}
57+
58+
public function getTranslationVariables(): array
59+
{
60+
return [
61+
'model' => $this->table,
62+
];
63+
}
64+
}

packages/validation/src/localization.en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ validation_error:
7979
.input {$field :string}
8080
.input {$needle :string}
8181
{$field} must end with "{$needle}"
82+
exists: |
83+
.input {$field :string}
84+
{$field} could not be found
8285
is_even_number: |
8386
.input {$field :string}
8487
{$field} must be even
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Validation\Tests\Rules;
6+
7+
use InvalidArgumentException;
8+
use PHPUnit\Framework\Attributes\Test;
9+
use PHPUnit\Framework\TestCase;
10+
use Tempest\Database\PrimaryKey;
11+
use Tempest\Validation\Rules\Exists;
12+
13+
/**
14+
* @internal
15+
*/
16+
final class ExistsTest extends TestCase
17+
{
18+
#[Test]
19+
public function throws_exception_for_table_without_column(): void
20+
{
21+
$this->expectException(InvalidArgumentException::class);
22+
$this->expectExceptionMessage('A column must be specified when the table is not a model class.');
23+
24+
new Exists('random-table');
25+
}
26+
27+
#[Test]
28+
public function returns_false_for_null_or_non_integer_values(): void
29+
{
30+
$rule = new Exists(ValidateExistsModel::class);
31+
32+
$this->assertFalse($rule->isValid(1.5));
33+
$this->assertFalse($rule->isValid([]));
34+
$this->assertFalse($rule->isValid(true));
35+
$this->assertFalse($rule->isValid(false));
36+
$this->assertFalse($rule->isValid(null));
37+
}
38+
39+
#[Test]
40+
public function can_be_constructed_with_valid_model_class(): void
41+
{
42+
$this->assertInstanceOf(Exists::class, new Exists(ValidateExistsModel::class));
43+
}
44+
}
45+
46+
/** @internal */
47+
final class ValidateExistsModel
48+
{
49+
public function __construct(
50+
public PrimaryKey $id,
51+
public string $name,
52+
) {}
53+
}

tests/Fixtures/Requests/BookRequest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Tempest\Database\HasOne;
88
use Tempest\Http\IsRequest;
99
use Tempest\Http\Request;
10+
use Tempest\Validation\Rules\Exists;
1011
use Tempest\Validation\Rules\HasLength;
1112
use Tests\Tempest\Fixtures\Modules\Books\Models\Author;
1213
use Tests\Tempest\Fixtures\Modules\Books\Models\Isbn;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\Validator;
6+
7+
use PHPUnit\Framework\Attributes\PreCondition;
8+
use PHPUnit\Framework\Attributes\Test;
9+
use Tempest\Database\Migrations\CreateMigrationsTable;
10+
use Tempest\Validation\Rules\Exists;
11+
use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable;
12+
use Tests\Tempest\Fixtures\Migrations\CreateBookTable;
13+
use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable;
14+
use Tests\Tempest\Fixtures\Modules\Books\Models\Author;
15+
use Tests\Tempest\Fixtures\Modules\Books\Models\Book;
16+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
17+
18+
/**
19+
* @internal
20+
*/
21+
final class ExistsRuleTest extends FrameworkIntegrationTestCase
22+
{
23+
#[PreCondition]
24+
protected function configure(): void
25+
{
26+
$this->migrate(
27+
CreateMigrationsTable::class,
28+
CreatePublishersTable::class,
29+
CreateAuthorTable::class,
30+
CreateBookTable::class,
31+
);
32+
}
33+
34+
#[Test]
35+
public function existing_record_return_true(): void
36+
{
37+
$book = Book::create(title: 'Timeline Taxi');
38+
39+
$this->assertTrue(new Exists(Book::class)->isValid($book->id));
40+
$this->assertTrue(new Exists('books', column: 'id')->isValid($book->id));
41+
$this->assertTrue(new Exists('books', column: 'title')->isValid('Timeline Taxi'));
42+
}
43+
44+
#[Test]
45+
public function non_existent_record_returns_false(): void
46+
{
47+
Book::create(title: 'Timeline Taxi');
48+
49+
$this->assertFalse(new Exists(Book::class)->isValid(99999));
50+
$this->assertFalse(new Exists(Book::class)->isValid(12345));
51+
$this->assertFalse(new Exists(Book::class, column: 'title')->isValid('Timeline Taxi 2'));
52+
}
53+
54+
#[Test]
55+
public function validates_multiple_existing_records(): void
56+
{
57+
$book1 = Book::create(title: 'The Lord of the Rings');
58+
$book2 = Book::create(title: 'The Silmarillion');
59+
$book3 = Book::create(title: 'Unfinished Tales');
60+
61+
$this->assertTrue(new Exists(Book::class)->isValid($book1->id));
62+
$this->assertTrue(new Exists(Book::class)->isValid($book2->id));
63+
$this->assertTrue(new Exists(Book::class)->isValid($book3->id));
64+
$this->assertFalse(new Exists(Book::class)->isValid(99999));
65+
}
66+
}

tests/Integration/Validator/TranslationsTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Tests\Tempest\Integration\Validator;
44

55
use PHPUnit\Framework\Attributes\TestWith;
6+
use Tempest\Database\PrimaryKey;
67
use Tempest\DateTime\FormatPattern;
78
use Tempest\Validation\Rule;
89
use Tempest\Validation\Rules;
@@ -208,6 +209,16 @@ public function test_ends_with(?string $field = null): void
208209
);
209210
}
210211

212+
#[TestWith([null])]
213+
#[TestWith(['Input'])]
214+
public function test_exists(?string $field = null): void
215+
{
216+
$this->assertSame(
217+
expected: $this->formatWithField('%s could not be found', $field),
218+
actual: $this->translate(new Rules\Exists(table: 'non-existing-table', column: 'non-existing-column'), field: $field),
219+
);
220+
}
221+
211222
#[TestWith([null])]
212223
#[TestWith(['Input'])]
213224
public function test_even(?string $field = null): void

0 commit comments

Comments
 (0)