diff --git a/src/Illuminate/Validation/Rule.php b/src/Illuminate/Validation/Rule.php index f0483842d99c..cf29cd7c7c5e 100644 --- a/src/Illuminate/Validation/Rule.php +++ b/src/Illuminate/Validation/Rule.php @@ -2,7 +2,9 @@ namespace Illuminate\Validation; +use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Traits\Macroable; use Illuminate\Validation\Rules\ArrayRule; use Illuminate\Validation\Rules\Can; @@ -13,6 +15,7 @@ use Illuminate\Validation\Rules\File; use Illuminate\Validation\Rules\ImageFile; use Illuminate\Validation\Rules\In; +use Illuminate\Validation\Rules\ModelExists; use Illuminate\Validation\Rules\NotIn; use Illuminate\Validation\Rules\ProhibitedIf; use Illuminate\Validation\Rules\RequiredIf; @@ -106,6 +109,17 @@ public static function exists($table, $column = 'NULL') return new Exists($table, $column); } + /** + * Get an ModelExists constraint builder instance. + * + * @param Builder|TModel|class-string $model + * @return \Illuminate\Validation\Rules\ModelExists> + */ + public static function modelExists(Builder|Model|string $model, ?string $column = null): ModelExists + { + return ModelExists::make($model, $column); + } + /** * Get an in rule builder instance. * diff --git a/src/Illuminate/Validation/Rules/ModelExists.php b/src/Illuminate/Validation/Rules/ModelExists.php new file mode 100644 index 000000000000..77bd2828c9f7 --- /dev/null +++ b/src/Illuminate/Validation/Rules/ModelExists.php @@ -0,0 +1,88 @@ + + */ +class ModelExists implements ValidationRule +{ + use ForwardsCalls; + + /** + * Create a new rule instance. + */ + public function __construct( + protected Builder $query, + protected ?string $column + ) { + // + } + + /** + * Create a new rule instance with the given Builder, Model, or class name. + * + * @template TModel of Model + * + * @param Builder|TModel|class-string $model + * @return ModelExists + */ + public static function make(Builder|Model|string $model, ?string $column = null): self + { + $builder = match (true) { + $model instanceof Builder => $model, + $model instanceof Model => $model->query(), + default => $model::query(), + }; + + return new self($builder, $column); + } + + /** + * Run the validation rule. + * + * @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $this->column + ? $this->query->where($this->column, $value) + : $this->query->whereKey($value); + + if (! $this->query->exists()) { + $fail('validation.exists')->translate(['attribute' => $attribute]); + } + } + + /** + * Get the underlying query builder instance. + * + * @return Builder + */ + public function getQueryBuilder(): Builder + { + return $this->query; + } + + /** + * Dynamically handle calls into the query instance. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + $this->forwardCallTo($this->query, $method, $parameters); + + return $this; + } +} diff --git a/tests/Validation/ValidationModelExistsRuleTest.php b/tests/Validation/ValidationModelExistsRuleTest.php new file mode 100644 index 000000000000..50d50d00cfd9 --- /dev/null +++ b/tests/Validation/ValidationModelExistsRuleTest.php @@ -0,0 +1,176 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + public function testItCanPassAnEloquentBuilderInstance() + { + $rule = Rule::modelExists(UserModel::query()); + $this->assertInstanceOf(Builder::class, $rule->getQueryBuilder()); + $this->assertInstanceOf(UserModel::class, $rule->getQueryBuilder()->getModel()); + } + + public function testItCanPassAModelInstance() + { + $rule = Rule::modelExists(new UserModel); + $this->assertInstanceOf(Builder::class, $rule->getQueryBuilder()); + $this->assertInstanceOf(UserModel::class, $rule->getQueryBuilder()->getModel()); + } + + public function testItCanPassAModelClassName() + { + $rule = Rule::modelExists(UserModel::class); + $this->assertInstanceOf(Builder::class, $rule->getQueryBuilder()); + $this->assertInstanceOf(UserModel::class, $rule->getQueryBuilder()->getModel()); + } + + public function testItForwardsCallsToTheQueryBuilder() + { + $rule = Rule::modelExists(UserModel::class); + $rule->where('foo', 'bar'); + $this->assertSame('select * from "users" where "foo" = ?', $rule->getQueryBuilder()->toSql()); + } + + public function testPassesWhenRecordExists() + { + $rule = Rule::modelExists(UserModel::class); + + UserModel::create(['id' => 1, 'type' => 'foo']); + + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.exists' => 'The selected :attribute is invalid.'], 'en'); + $v = new Validator($trans, ['id' => 1], ['id' => $rule]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['id' => 2], ['id' => $rule]); + $this->assertFalse($v->passes()); + $this->assertSame('The selected id is invalid.', $v->errors()->first('id')); + } + + public function testPassesWhenRecordExistsWithScope() + { + $rule = Rule::modelExists(UserModel::class)->typeFoo(); + + UserModel::create(['id' => 1, 'type' => 'foo']); + UserModel::create(['id' => 2, 'type' => 'bar']); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['id' => 1], ['id' => $rule]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['id' => 2], ['id' => $rule]); + $this->assertFalse($v->passes()); + } + + public function testPassesWhenRecordExistsWithColumn() + { + $rule = Rule::modelExists(UserModel::class, 'type'); + + UserModel::create(['id' => 1, 'type' => 'foo']); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['id' => 'foo'], ['id' => $rule]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['id' => 'bar'], ['id' => $rule]); + $this->assertFalse($v->passes()); + } + + protected function createSchema() + { + $this->schema('default')->create('users', function ($table) { + $table->unsignedInteger('id'); + $table->string('type'); + }); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return $this->getConnectionResolver()->connection($connection); + } + + /** + * Get connection resolver. + * + * @return \Illuminate\Database\ConnectionResolverInterface + */ + protected function getConnectionResolver() + { + return Eloquent::getConnectionResolver(); + } + + /** + * Tear down the database schema. + */ + protected function tearDown(): void + { + $this->schema('default')->drop('users'); + } + + public function getIlluminateArrayTranslator() + { + return new Translator( + new ArrayLoader, 'en' + ); + } +} + +/** + * Eloquent Models. + */ +class UserModel extends Eloquent +{ + protected $table = 'users'; + + protected $guarded = []; + + public $timestamps = false; + + public function scopeTypeFoo($query) + { + $query->where('type', 'foo'); + } +}