Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
64 changes: 64 additions & 0 deletions packages/validation/src/Rules/Exists.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Tempest\Validation\Rules;

use Attribute;
use InvalidArgumentException;
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, HasTranslationVariables
{
/**
* @param class-string|non-empty-string $table
*/
public function __construct(
private string $table,
private ?string $column = null,
) {
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_object($value) && ! is_numeric($value) && ! is_string($value)) {
return false;
}

if (is_float($value)) {
return false;
}

$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 query($this->table)
->count()
->whereField($column, $value)
->execute() > 0;
}

public function getTranslationVariables(): array
{
return [
'model' => $this->table,
];
}
}
3 changes: 3 additions & 0 deletions packages/validation/src/localization.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions packages/validation/tests/Rules/ExistsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Tempest\Validation\Tests\Rules;

use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Tempest\Database\PrimaryKey;
use Tempest\Validation\Rules\Exists;

/**
* @internal
*/
final class ExistsTest extends TestCase
{
#[Test]
public function throws_exception_for_table_without_column(): void
{
$this->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,
) {}
}
1 change: 1 addition & 0 deletions tests/Fixtures/Requests/BookRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions tests/Integration/Validator/ExistsRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\Validator;

use PHPUnit\Framework\Attributes\PreCondition;
use PHPUnit\Framework\Attributes\Test;
use Tempest\Database\Migrations\CreateMigrationsTable;
use Tempest\Validation\Rules\Exists;
use Tests\Tempest\Fixtures\Migrations\CreateAuthorTable;
use Tests\Tempest\Fixtures\Migrations\CreateBookTable;
use Tests\Tempest\Fixtures\Migrations\CreatePublishersTable;
use Tests\Tempest\Fixtures\Modules\Books\Models\Author;
use Tests\Tempest\Fixtures\Modules\Books\Models\Book;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;

/**
* @internal
*/
final class ExistsRuleTest extends FrameworkIntegrationTestCase
{
#[PreCondition]
protected function configure(): void
{
$this->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));
}
}
11 changes: 11 additions & 0 deletions tests/Integration/Validator/TranslationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading