From b6c292286fbede62723fad900b68daece6cd0e10 Mon Sep 17 00:00:00 2001 From: Klaas058 Date: Thu, 18 Sep 2025 21:49:08 +0200 Subject: [PATCH] [feat] Add database constraints for validation annotations --- .../validation/using-validation-attributes.md | 35 +++ src/Attributes/Validation/Exists.php | 25 +- src/Attributes/Validation/Unique.php | 25 +- .../Constraints/DatabaseConstraint.php | 9 + .../Constraints/WhereConstraint.php | 17 ++ .../Constraints/WhereInConstraint.php | 16 ++ .../Constraints/WhereNotConstraint.php | 16 ++ .../Constraints/WhereNotInConstraint.php | 16 ++ .../Constraints/WhereNotNullConstraint.php | 15 ++ .../Constraints/WhereNullConstraint.php | 15 ++ tests/Datasets/RulesDataset.php | 175 +++++++++++++ tests/ValidationTest.php | 241 ++++++++++++++++++ 12 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 src/Support/Validation/Constraints/DatabaseConstraint.php create mode 100644 src/Support/Validation/Constraints/WhereConstraint.php create mode 100644 src/Support/Validation/Constraints/WhereInConstraint.php create mode 100644 src/Support/Validation/Constraints/WhereNotConstraint.php create mode 100644 src/Support/Validation/Constraints/WhereNotInConstraint.php create mode 100644 src/Support/Validation/Constraints/WhereNotNullConstraint.php create mode 100644 src/Support/Validation/Constraints/WhereNullConstraint.php diff --git a/docs/validation/using-validation-attributes.md b/docs/validation/using-validation-attributes.md index 1cfc035d..9aebfb15 100644 --- a/docs/validation/using-validation-attributes.md +++ b/docs/validation/using-validation-attributes.md @@ -222,6 +222,41 @@ The rules will now look like this: ] ``` +## Using database constraints + +When using `Exists` and `Unique` validation attributes, you can add database constraints to validate against specific conditions: + +```php +class UserData extends Data +{ + public function __construct( + #[Exists('users', where: new WhereConstraint('active', true))] + public int $user_id, + + #[Unique('users', 'email', where: new WhereNullConstraint('deleted_at'))] + public string $email, + ) { + } +} +``` + +You can also combine multiple constraints: + +```php +class ProductData extends Data +{ + public function __construct( + #[Unique('products', 'sku', where: [ + new WhereConstraint('active', true), + new WhereInConstraint('type', ['physical', 'digital']), + new WhereNullConstraint('deleted_at'), + ])] + public string $sku, + ) { + } +} +``` + ## Rule attribute One special attribute is the `Rule` attribute. With it, you can write rules just like you would when creating a custom diff --git a/src/Attributes/Validation/Exists.php b/src/Attributes/Validation/Exists.php index 318657c2..70db6c17 100644 --- a/src/Attributes/Validation/Exists.php +++ b/src/Attributes/Validation/Exists.php @@ -6,8 +6,16 @@ use Closure; use Exception; use Illuminate\Validation\Rules\Exists as BaseExists; +use InvalidArgumentException; +use Spatie\LaravelData\Support\Validation\Constraints\WhereConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNullConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotNullConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereInConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotInConstraint; use Spatie\LaravelData\Support\Validation\References\ExternalReference; use Spatie\LaravelData\Support\Validation\ValidationPath; +use Spatie\LaravelData\Support\Validation\Constraints\DatabaseConstraint; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class Exists extends ObjectValidationAttribute @@ -18,7 +26,7 @@ public function __construct( protected null|string|ExternalReference $connection = null, protected bool|ExternalReference $withoutTrashed = false, protected string|ExternalReference $deletedAtColumn = 'deleted_at', - protected ?Closure $where = null, + protected null|Closure|DatabaseConstraint|array $where = null, protected ?BaseExists $rule = null, ) { if ($rule === null && $table === null) { @@ -48,7 +56,20 @@ public function getRule(ValidationPath $path): object|string } if ($this->where) { - $rule->where($this->where); + $constraints = is_array($this->where) ? $this->where : [$this->where]; + + foreach ($constraints as $constraint) { + match (true) { + $constraint instanceof Closure => $rule->where($constraint), + $constraint instanceof WhereConstraint => $rule->where(...$constraint->toArray()), + $constraint instanceof WhereNotConstraint => $rule->whereNot(...$constraint->toArray()), + $constraint instanceof WhereNullConstraint => $rule->whereNull(...$constraint->toArray()), + $constraint instanceof WhereNotNullConstraint => $rule->whereNotNull(...$constraint->toArray()), + $constraint instanceof WhereInConstraint => $rule->whereIn(...$constraint->toArray()), + $constraint instanceof WhereNotInConstraint => $rule->whereNotIn(...$constraint->toArray()), + default => throw new InvalidArgumentException('Each where item must be a DatabaseConstraint or Closure'), + }; + } } return $rule; diff --git a/src/Attributes/Validation/Unique.php b/src/Attributes/Validation/Unique.php index d2e72cd0..1f80a0b3 100644 --- a/src/Attributes/Validation/Unique.php +++ b/src/Attributes/Validation/Unique.php @@ -6,8 +6,16 @@ use Closure; use Exception; use Illuminate\Validation\Rules\Unique as BaseUnique; +use InvalidArgumentException; +use Spatie\LaravelData\Support\Validation\Constraints\WhereConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNullConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotNullConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereInConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotInConstraint; use Spatie\LaravelData\Support\Validation\References\ExternalReference; use Spatie\LaravelData\Support\Validation\ValidationPath; +use Spatie\LaravelData\Support\Validation\Constraints\DatabaseConstraint; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class Unique extends ObjectValidationAttribute @@ -20,7 +28,7 @@ public function __construct( protected null|string|ExternalReference $ignoreColumn = null, protected bool|ExternalReference $withoutTrashed = false, protected string|ExternalReference $deletedAtColumn = 'deleted_at', - protected ?Closure $where = null, + protected null|Closure|DatabaseConstraint|array $where = null, protected ?BaseUnique $rule = null ) { if ($table === null && $rule === null) { @@ -56,7 +64,20 @@ public function getRule(ValidationPath $path): object|string } if ($this->where) { - $rule->where($this->where); + $constraints = is_array($this->where) ? $this->where : [$this->where]; + + foreach ($constraints as $constraint) { + match (true) { + $constraint instanceof Closure => $rule->where($constraint), + $constraint instanceof WhereConstraint => $rule->where(...$constraint->toArray()), + $constraint instanceof WhereNotConstraint => $rule->whereNot(...$constraint->toArray()), + $constraint instanceof WhereNullConstraint => $rule->whereNull(...$constraint->toArray()), + $constraint instanceof WhereNotNullConstraint => $rule->whereNotNull(...$constraint->toArray()), + $constraint instanceof WhereInConstraint => $rule->whereIn(...$constraint->toArray()), + $constraint instanceof WhereNotInConstraint => $rule->whereNotIn(...$constraint->toArray()), + default => throw new InvalidArgumentException('Each where item must be a DatabaseConstraint or Closure'), + }; + } } return $rule; diff --git a/src/Support/Validation/Constraints/DatabaseConstraint.php b/src/Support/Validation/Constraints/DatabaseConstraint.php new file mode 100644 index 00000000..439ec858 --- /dev/null +++ b/src/Support/Validation/Constraints/DatabaseConstraint.php @@ -0,0 +1,9 @@ +column, $this->value]; + } +} diff --git a/src/Support/Validation/Constraints/WhereInConstraint.php b/src/Support/Validation/Constraints/WhereInConstraint.php new file mode 100644 index 00000000..cca42066 --- /dev/null +++ b/src/Support/Validation/Constraints/WhereInConstraint.php @@ -0,0 +1,16 @@ +column, $this->values]; + } +} diff --git a/src/Support/Validation/Constraints/WhereNotConstraint.php b/src/Support/Validation/Constraints/WhereNotConstraint.php new file mode 100644 index 00000000..7446bd42 --- /dev/null +++ b/src/Support/Validation/Constraints/WhereNotConstraint.php @@ -0,0 +1,16 @@ +column, $this->value]; + } +} diff --git a/src/Support/Validation/Constraints/WhereNotInConstraint.php b/src/Support/Validation/Constraints/WhereNotInConstraint.php new file mode 100644 index 00000000..ba65e7d9 --- /dev/null +++ b/src/Support/Validation/Constraints/WhereNotInConstraint.php @@ -0,0 +1,16 @@ +column, $this->values]; + } +} diff --git a/src/Support/Validation/Constraints/WhereNotNullConstraint.php b/src/Support/Validation/Constraints/WhereNotNullConstraint.php new file mode 100644 index 00000000..7fd2581c --- /dev/null +++ b/src/Support/Validation/Constraints/WhereNotNullConstraint.php @@ -0,0 +1,15 @@ +column]; + } +} diff --git a/src/Support/Validation/Constraints/WhereNullConstraint.php b/src/Support/Validation/Constraints/WhereNullConstraint.php new file mode 100644 index 00000000..137fab4d --- /dev/null +++ b/src/Support/Validation/Constraints/WhereNullConstraint.php @@ -0,0 +1,15 @@ +column]; + } +} diff --git a/tests/Datasets/RulesDataset.php b/tests/Datasets/RulesDataset.php index b44c2473..0df807fa 100644 --- a/tests/Datasets/RulesDataset.php +++ b/tests/Datasets/RulesDataset.php @@ -98,6 +98,12 @@ use Spatie\LaravelData\Attributes\Validation\Url; use Spatie\LaravelData\Attributes\Validation\Uuid; use Spatie\LaravelData\Exceptions\CannotBuildValidationRule; +use Spatie\LaravelData\Support\Validation\Constraints\WhereConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereInConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotInConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotNullConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNullConstraint; use Spatie\LaravelData\Support\Validation\ValidationRule; use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; @@ -845,6 +851,86 @@ function existsAttributes(): Generator expected: (new BaseExists('users', 'email'))->withoutTrashed('deleted_when'), expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'email'))->withoutTrashed('deleted_when')) ); + + yield fixature( + attribute: new Exists('users', 'id', where: new WhereConstraint('active', true)), + expected: (new BaseExists('users', 'id'))->where('active', true), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id'))->where('active', true)) + ); + + yield fixature( + attribute: new Exists('users', 'email', where: new WhereNotConstraint('name', 'Unlucky')), + expected: (new BaseExists('users', 'email'))->whereNot('name', 'Unlucky'), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'email'))->whereNot('name', 'Unlucky')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: new WhereInConstraint('role', ['admin', 'user'])), + expected: (new BaseExists('users', 'id'))->whereIn('role', ['admin', 'user']), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id'))->whereIn('role', ['admin', 'user'])) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: new WhereNotInConstraint('status', ['banned', 'suspended'])), + expected: (new BaseExists('users', 'id'))->whereNotIn('status', ['banned', 'suspended']), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id'))->whereNotIn('status', ['banned', 'suspended'])) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: new WhereNotNullConstraint('email_verified_at')), + expected: (new BaseExists('users', 'id'))->whereNotNull('email_verified_at'), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id'))->whereNotNull('email_verified_at')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: [ + new WhereConstraint('active', true), + new WhereNotConstraint('name', 'Unlucky'), + new WhereInConstraint('role', ['admin', 'user']), + new WhereNotInConstraint('status', ['banned', 'suspended']), + new WhereNullConstraint('deleted_at'), + new WhereNotNullConstraint('email_verified_at'), + ]), + expected: (new BaseExists('users', 'id')) + ->where('active', true) + ->whereNot('name', 'Unlucky') + ->whereIn('role', ['admin', 'user']) + ->whereNotIn('status', ['banned', 'suspended']) + ->whereNull('deleted_at') + ->whereNotNull('email_verified_at'), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id')) + ->where('active', true) + ->whereNot('name', 'Unlucky') + ->whereIn('role', ['admin', 'user']) + ->whereNotIn('status', ['banned', 'suspended']) + ->whereNull('deleted_at') + ->whereNotNull('email_verified_at')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: [ + new WhereConstraint('active', true), + $closure, + ]), + expected: (new BaseExists('users', 'id')) + ->where('active', true) + ->where($closure), + expectCreatedAttribute: new Exists(rule: (new BaseExists('users', 'id')) + ->where('active', true) + ->where($closure)) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: []), + expected: new BaseExists('users', 'id'), + expectCreatedAttribute: new Exists(rule: new BaseExists('users', 'id')) + ); + + yield fixature( + attribute: new Exists('users', 'id', where: ['fake']), + expected: '', + exception: InvalidArgumentException::class, + ); } function inAttributes(): Generator @@ -1228,4 +1314,93 @@ function uniqueAttributes(): Generator expected: (new BaseUnique('users', 'email'))->where($closure), expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->where($closure)) ); + + yield fixature( + attribute: new Unique('users', 'email', where: new WhereConstraint('active', true)), + expected: (new BaseUnique('users', 'email'))->where('active', true), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->where('active', true)) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: new WhereNotConstraint('name', 'Unlucky')), + expected: (new BaseUnique('users', 'email'))->whereNot('name', 'Unlucky'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->whereNot('name', 'Unlucky')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: [ + new WhereConstraint('active', true), + new WhereNullConstraint('deleted_at'), + ]), + expected: (new BaseUnique('users', 'email'))->where('active', true)->whereNull('deleted_at'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->where('active', true)->whereNull('deleted_at')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: new WhereInConstraint('role', ['admin', 'user'])), + expected: (new BaseUnique('users', 'email'))->whereIn('role', ['admin', 'user']), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->whereIn('role', ['admin', 'user'])) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: new WhereNotInConstraint('status', ['banned', 'suspended'])), + expected: (new BaseUnique('users', 'email'))->whereNotIn('status', ['banned', 'suspended']), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->whereNotIn('status', ['banned', 'suspended'])) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: new WhereNotNullConstraint('email_verified_at')), + expected: (new BaseUnique('users', 'email'))->whereNotNull('email_verified_at'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email'))->whereNotNull('email_verified_at')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: [ + new WhereConstraint('active', true), + new WhereNotConstraint('name', 'Unlucky'), + new WhereInConstraint('role', ['admin', 'user']), + new WhereNotInConstraint('status', ['banned', 'suspended']), + new WhereNullConstraint('deleted_at'), + new WhereNotNullConstraint('email_verified_at'), + ]), + expected: (new BaseUnique('users', 'email')) + ->where('active', true) + ->whereNot('name', 'Unlucky') + ->whereIn('role', ['admin', 'user']) + ->whereNotIn('status', ['banned', 'suspended']) + ->whereNull('deleted_at') + ->whereNotNull('email_verified_at'), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email')) + ->where('active', true) + ->whereNot('name', 'Unlucky') + ->whereIn('role', ['admin', 'user']) + ->whereNotIn('status', ['banned', 'suspended']) + ->whereNull('deleted_at') + ->whereNotNull('email_verified_at')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: [ + new WhereConstraint('active', true), + $closure, + ]), + expected: (new BaseUnique('users', 'email')) + ->where('active', true) + ->where($closure), + expectCreatedAttribute: new Unique(rule: (new BaseUnique('users', 'email')) + ->where('active', true) + ->where($closure)) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: []), + expected: new BaseUnique('users', 'email'), + expectCreatedAttribute: new Unique(rule: new BaseUnique('users', 'email')) + ); + + yield fixature( + attribute: new Unique('users', 'email', where: ['fake']), + expected: '', + exception: InvalidArgumentException::class, + ); } diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index e75eeb9a..d3b70e58 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -12,6 +12,7 @@ use Illuminate\Validation\Rules\Enum; use Illuminate\Validation\Rules\Exists as LaravelExists; use Illuminate\Validation\Rules\In as LaravelIn; +use Illuminate\Validation\Rules\Unique as LaravelUnique; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; @@ -48,6 +49,12 @@ use Spatie\LaravelData\Mappers\SnakeCaseMapper; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Creation\ValidationStrategy; +use Spatie\LaravelData\Support\Validation\Constraints\WhereConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereInConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotInConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNotNullConstraint; +use Spatie\LaravelData\Support\Validation\Constraints\WhereNullConstraint; use Spatie\LaravelData\Support\Validation\References\AuthenticatedUserReference; use Spatie\LaravelData\Support\Validation\References\ContainerReference; use Spatie\LaravelData\Support\Validation\References\FieldReference; @@ -1515,6 +1522,240 @@ public static function rules(): array ]); }); +it('can use database constraints with Exists validation', function () { + $dataClass = new class () extends Data { + public int $property; + + public static function rules(): array + { + return [ + 'property' => [ + new Exists('users', where: [ + new WhereConstraint('status', 'active'), + new WhereNotConstraint('deleted_at', null), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'property' => [ + (new LaravelExists('users')) + ->where('status', 'active') + ->whereNot('deleted_at', null), + ], + ]); +}); + + +it('can use multiple database constraints with Exists validation', function () { + $dataClass = new class () extends Data { + public int $userId; + + public static function rules(): array + { + return [ + 'userId' => [ + new Exists('users', where: [ + new WhereConstraint('active', true), + new WhereNotConstraint('name', 'Unlucky'), + new WhereInConstraint('role', ['admin', 'user']), + new WhereNotInConstraint('type', ['guest', 'temp']), + new WhereNullConstraint('deleted_at'), + new WhereNotNullConstraint('email_verified_at'), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'userId' => [ + (new LaravelExists('users')) + ->where('active', true) + ->whereNot('name', 'Unlucky') + ->whereIn('role', ['admin', 'user']) + ->whereNotIn('type', ['guest', 'temp']) + ->whereNull('deleted_at') + ->whereNotNull('email_verified_at'), + ], + ]); +}); + +it('can combine database constraints with closure constraints in Exists validation', function () { + $dataClass = new class () extends Data { + public int $userId; + + public static function rules(): array + { + return [ + 'userId' => [ + new Exists('users', where: [ + new WhereConstraint('active', true), + fn ($query) => $query->where('created_at', '>', now()->subYear()), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'userId' => [ + (new LaravelExists('users')) + ->where('active', true) + ->where(fn ($query) => $query->where('created_at', '>', now()->subYear())), + ], + ]); +}); + +it('throws exception for invalid database constraint in Exists validation', function () { + $dataClass = new class () extends Data { + public int $userId; + + public static function rules(): array + { + return [ + 'userId' => [ + new Exists('users', where: ['invalid_constraint']), + ], + ]; + } + }; + + expect(fn () => $dataClass::getValidationRules([]))->toThrow(\InvalidArgumentException::class, 'Each where item must be a DatabaseConstraint or Closure'); +}); + +it('can use database constraints with Unique validation', function () { + $dataClass = new class () extends Data { + public string $email; + + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', where: [ + new WhereConstraint('active', true), + new WhereNotConstraint('deleted_at', null), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + (new LaravelUnique('users')) + ->where('active', true) + ->whereNot('deleted_at', null), + ], + ]); +}); + +it('can use multiple database constraints with Unique validation', function () { + $dataClass = new class () extends Data { + public string $email; + + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', where: [ + new WhereConstraint('active', true), + new WhereNotConstraint('name', 'Unlucky'), + new WhereInConstraint('role', ['admin', 'user']), + new WhereNotInConstraint('type', ['guest', 'temp']), + new WhereNullConstraint('deleted_at'), + new WhereNotNullConstraint('email_verified_at'), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + (new LaravelUnique('users')) + ->where('active', true) + ->whereNot('name', 'Unlucky') + ->whereIn('role', ['admin', 'user']) + ->whereNotIn('type', ['guest', 'temp']) + ->whereNull('deleted_at') + ->whereNotNull('email_verified_at'), + ], + ]); +}); + +it('can combine database constraints with closure constraints in Unique validation', function () { + $dataClass = new class () extends Data { + public string $email; + + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', where: [ + new WhereConstraint('active', true), + fn (Builder $query) => $query->where('created_at', '>', now()->subYear()), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + (new LaravelUnique('users')) + ->where('active', true) + ->where(fn (Builder $query) => $query->where('created_at', '>', now()->subYear())), + ], + ]); +}); + +it('can use database constraints with Unique validation while maintaining ignore functionality', function () { + $dataClass = new class () extends Data { + public string $email; + + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', ignore: 5, where: [ + new WhereConstraint('active', true), + new WhereNullConstraint('deleted_at'), + ]), + ], + ]; + } + }; + + DataValidationAsserter::for($dataClass)->assertRules([ + 'email' => [ + (new LaravelUnique('users')) + ->ignore(5) + ->where('active', true) + ->whereNull('deleted_at'), + ], + ]); +}); + +it('throws exception for invalid database constraint in Unique validation', function () { + $dataClass = new class () extends Data { + public string $email; + + public static function rules(): array + { + return [ + 'email' => [ + new Unique('users', where: ['invalid_constraint']), + ], + ]; + } + }; + + expect(fn () => $dataClass::getValidationRules([]))->toThrow(\InvalidArgumentException::class, 'Each where item must be a DatabaseConstraint or Closure'); +}); + it('can reference route parameters as values within rules', function () { $dataClass = new class () extends Data { #[Unique('posts', ignore: new RouteParameterReference('post_id'))]