diff --git a/docker-compose.yml b/docker-compose.yml index 5de7c4c8cf80..0a3b1697128b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,32 @@ services: ports: - "3306:3306" restart: always + # postgres: + # image: postgres:15 + # environment: + # POSTGRES_PASSWORD: "secret" + # POSTGRES_DB: "forge" + # ports: + # - "5432:5432" + # restart: always + # mariadb: + # image: mariadb:11 + # environment: + # MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" + # MARIADB_ROOT_PASSWORD: "" + # MARIADB_DATABASE: "forge" + # MARIADB_ROOT_HOST: "%" + # ports: + # - "3306:3306" + # restart: always + # mssql: + # image: mcr.microsoft.com/mssql/server:2019-latest + # environment: + # ACCEPT_EULA: "Y" + # SA_PASSWORD: "Forge123" + # ports: + # - "1433:1433" + # restart: always redis: image: redis:7.0-alpine ports: diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index 4f20472e005d..71faf0a47d14 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -792,12 +792,29 @@ protected function runQueryCallback($query, $bindings, Closure $callback) // message to include the bindings with SQL, which will make this exception a // lot more helpful to the developer instead of just the database's errors. catch (Exception $e) { + if ($this->isUniqueConstraintError($e)) { + throw new UniqueConstraintViolationException( + $this->getName(), $query, $this->prepareBindings($bindings), $e + ); + } + throw new QueryException( $this->getName(), $query, $this->prepareBindings($bindings), $e ); } } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return false; + } + /** * Log a query in the connection's query log. * diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 5bab008e13da..3e222ab6183c 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\RecordsNotFoundException; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -554,7 +555,7 @@ public function firstOrNew(array $attributes = [], array $values = []) } /** - * Get the first record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes * @param array $values @@ -566,9 +567,23 @@ public function firstOrCreate(array $attributes = [], array $values = []) return $instance; } - return tap($this->newModelInstance(array_merge($attributes, $values)), function ($instance) { - $instance->save(); - }); + return $this->createOrFirst($attributes, $values); + } + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array $attributes + * @param array $values + * @return \Illuminate\Database\Eloquent\Model|static + */ + public function createOrFirst(array $attributes = [], array $values = []) + { + try { + return $this->create(array_merge($attributes, $values)); + } catch (UniqueConstraintViolationException $exception) { + return $this->where($attributes)->first(); + } } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index a6422b6855e6..a4cc76fb0308 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Str; use InvalidArgumentException; @@ -609,7 +610,7 @@ public function firstOrNew(array $attributes = [], array $values = []) } /** - * Get the first related record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes * @param array $values @@ -630,6 +631,32 @@ public function firstOrCreate(array $attributes = [], array $values = [], array return $instance; } + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array $attributes + * @param array $values + * @param array $joining + * @param bool $touch + * @return \Illuminate\Database\Eloquent\Model + */ + public function createOrFirst(array $attributes = [], array $values = [], array $joining = [], $touch = true) + { + try { + return $this->create(array_merge($attributes, $values), $joining, $touch); + } catch (UniqueConstraintViolationException $exception) { + // ... + } + + try { + return tap($this->related->where($attributes)->first(), function ($instance) use ($joining, $touch) { + $this->attach($instance, $joining, $touch); + }); + } catch (UniqueConstraintViolationException $exception) { + return (clone $this)->where($attributes)->first(); + } + } + /** * Create or update a related record matching the attributes, and fill it with values. * diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 488d966ef112..482e5208a946 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Database\UniqueConstraintViolationException; abstract class HasOneOrMany extends Relation { @@ -226,7 +227,7 @@ public function firstOrNew(array $attributes = [], array $values = []) } /** - * Get the first related record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes * @param array $values @@ -241,6 +242,22 @@ public function firstOrCreate(array $attributes = [], array $values = []) return $instance; } + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array $attributes + * @param array $values + * @return \Illuminate\Database\Eloquent\Model + */ + public function createOrFirst(array $attributes = [], array $values = []) + { + try { + return $this->create(array_merge($attributes, $values)); + } catch (UniqueConstraintViolationException $exception) { + return $this->where($attributes)->first(); + } + } + /** * Create or update a related record matching the attributes, and fill it with values. * diff --git a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php index e6d91d91786b..f0b0bd417925 100644 --- a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php +++ b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php @@ -9,7 +9,7 @@ class SoftDeletingScope implements Scope * * @var string[] */ - protected $extensions = ['Restore', 'RestoreOrCreate', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; + protected $extensions = ['Restore', 'RestoreOrCreate', 'CreateOrRestore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; /** * Apply the scope to a given Eloquent query builder. @@ -91,6 +91,23 @@ protected function addRestoreOrCreate(Builder $builder) }); } + /** + * Add the create-or-restore extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function addCreateOrRestore(Builder $builder) + { + $builder->macro('createOrRestore', function (Builder $builder, array $attributes = [], array $values = []) { + $builder->withTrashed(); + + return tap($builder->createOrFirst($attributes, $values), function ($instance) { + $instance->restore(); + }); + }); + } + /** * Add the with-trashed extension to the builder. * diff --git a/src/Illuminate/Database/MySqlConnection.php b/src/Illuminate/Database/MySqlConnection.php index 2f87b16f5afe..460a4fd375c1 100755 --- a/src/Illuminate/Database/MySqlConnection.php +++ b/src/Illuminate/Database/MySqlConnection.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use Exception; use Illuminate\Database\PDO\MySqlDriver; use Illuminate\Database\Query\Grammars\MySqlGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\MySqlProcessor; @@ -26,6 +27,17 @@ protected function escapeBinary($value) return "x'{$hex}'"; } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return boolval(preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage())); + } + /** * Determine if the connected database is a MariaDB database. * diff --git a/src/Illuminate/Database/PostgresConnection.php b/src/Illuminate/Database/PostgresConnection.php index a03b29e3bec2..c3e22a928801 100755 --- a/src/Illuminate/Database/PostgresConnection.php +++ b/src/Illuminate/Database/PostgresConnection.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use Exception; use Illuminate\Database\PDO\PostgresDriver; use Illuminate\Database\Query\Grammars\PostgresGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\PostgresProcessor; @@ -36,6 +37,17 @@ protected function escapeBool($value) return $value ? 'true' : 'false'; } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return '23505' === $exception->getCode(); + } + /** * Get the default query grammar instance. * diff --git a/src/Illuminate/Database/SQLiteConnection.php b/src/Illuminate/Database/SQLiteConnection.php index 6e9df07e97ba..ad7c1486d2d2 100755 --- a/src/Illuminate/Database/SQLiteConnection.php +++ b/src/Illuminate/Database/SQLiteConnection.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use Exception; use Illuminate\Database\PDO\SQLiteDriver; use Illuminate\Database\Query\Grammars\SQLiteGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\SQLiteProcessor; @@ -49,6 +50,17 @@ protected function escapeBinary($value) return "x'{$hex}'"; } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return boolval(preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage())); + } + /** * Get the default query grammar instance. * diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 57d2b20402e0..e376e6fa6c38 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -3,6 +3,7 @@ namespace Illuminate\Database; use Closure; +use Exception; use Illuminate\Database\PDO\SqlServerDriver; use Illuminate\Database\Query\Grammars\SqlServerGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\SqlServerProcessor; @@ -67,6 +68,17 @@ protected function escapeBinary($value) return "0x{$hex}"; } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return boolval(preg_match('#Cannot insert duplicate key row in object#i', $exception->getMessage())); + } + /** * Get the default query grammar instance. * diff --git a/src/Illuminate/Database/UniqueConstraintViolationException.php b/src/Illuminate/Database/UniqueConstraintViolationException.php new file mode 100644 index 000000000000..13b705b77c3b --- /dev/null +++ b/src/Illuminate/Database/UniqueConstraintViolationException.php @@ -0,0 +1,7 @@ +assertEquals($model, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); } + public function testCreateOrFirstMethodWithValuesFindsFirstModel() + { + $relation = $this->getRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn(m::mock(Model::class, function ($model) { + $model->shouldReceive('setAttribute')->once()->with('foreign_key', 1); + $model->shouldReceive('save')->once()->andThrow( + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + })); + + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(stdClass::class)); + + $this->assertInstanceOf(stdClass::class, $found = $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + $this->assertSame($model, $found); + } + + public function testCreateOrFirstMethodCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + $model = $this->expectCreatedModel($relation, ['foo']); + + $this->assertEquals($model, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + $model = $this->expectCreatedModel($relation, ['foo' => 'bar', 'baz' => 'qux']); + + $this->assertEquals($model, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() { $relation = $this->getRelation(); diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index a7225b2bb1ed..aa34e8d3b172 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -88,6 +88,14 @@ protected function createSchema() $table->timestamps(); }); + $this->schema($connection)->create('unique_users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamp('birthday', 6)->nullable(); + $table->timestamps(); + }); + $this->schema($connection)->create('friends', function ($table) { $table->integer('user_id'); $table->integer('friend_id'); @@ -511,6 +519,39 @@ public function testFirstOrCreate() $this->assertSame('Nuno Maduro', $user4->name); } + public function testCreateOrFirst() + { + $user1 = EloquentTestUniqueUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = EloquentTestUniqueUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = EloquentTestUniqueUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = EloquentTestUniqueUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } + public function testUpdateOrCreate() { $user1 = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); @@ -2243,6 +2284,13 @@ public static function boot() } } +class EloquentTestUniqueUser extends Eloquent +{ + protected $table = 'unique_users'; + protected $casts = ['birthday' => 'datetime']; + protected $guarded = []; +} + class EloquentTestPost extends Eloquent { protected $table = 'posts'; diff --git a/tests/Database/DatabaseEloquentMorphTest.php b/tests/Database/DatabaseEloquentMorphTest.php index cf7c8352a4de..d30eee15d7cd 100755 --- a/tests/Database/DatabaseEloquentMorphTest.php +++ b/tests/Database/DatabaseEloquentMorphTest.php @@ -2,12 +2,14 @@ namespace Illuminate\Tests\Database; +use Exception; use Foo\Bar\EloquentModelNamespacedStub; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\UniqueConstraintViolationException; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -214,6 +216,70 @@ public function testFirstOrCreateMethodWithValuesCreatesNewMorphModel() $this->assertInstanceOf(Model::class, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); } + public function testCreateOrFirstMethodFindsFirstModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andThrow( + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesFindsFirstModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andThrow( + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testCreateOrFirstMethodCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andReturn(true); + + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); + $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); + $model->shouldReceive('save')->once()->andReturn(true); + + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() { $relation = $this->getOneRelation(); diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php index 47c67d78c2bf..8af1eeaf5aed 100644 --- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php +++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -320,6 +320,20 @@ public function testFirstOrCreate() $this->assertCount(3, SoftDeletesTestUser::withTrashed()->get()); } + public function testCreateOrFirst() + { + $this->createUsers(); + + $result = SoftDeletesTestUser::withTrashed()->createOrFirst(['email' => 'taylorotwell@gmail.com']); + $this->assertSame('taylorotwell@gmail.com', $result->email); + $this->assertCount(1, SoftDeletesTestUser::all()); + + $result = SoftDeletesTestUser::createOrFirst(['email' => 'foo@bar.com']); + $this->assertSame('foo@bar.com', $result->email); + $this->assertCount(2, SoftDeletesTestUser::all()); + $this->assertCount(3, SoftDeletesTestUser::withTrashed()->get()); + } + /** * @throws \Exception */ diff --git a/tests/Database/DatabaseEloquentWithCastsTest.php b/tests/Database/DatabaseEloquentWithCastsTest.php index ecc6ccb419a2..53fe7d449d70 100644 --- a/tests/Database/DatabaseEloquentWithCastsTest.php +++ b/tests/Database/DatabaseEloquentWithCastsTest.php @@ -32,6 +32,12 @@ protected function createSchema() $table->time('time'); $table->timestamps(); }); + + $this->schema()->create('unique_times', function ($table) { + $table->increments('id'); + $table->time('time')->unique(); + $table->timestamps(); + }); } public function testWithFirstOrNew() @@ -59,6 +65,17 @@ public function testWithFirstOrCreate() $this->assertSame($time1->id, $time2->id); } + public function testWithCreateOrFirst() + { + $time1 = UniqueTime::query()->withCasts(['time' => 'string']) + ->createOrFirst(['time' => '07:30']); + + $time2 = UniqueTime::query()->withCasts(['time' => 'string']) + ->createOrFirst(['time' => '07:30']); + + $this->assertSame($time1->id, $time2->id); + } + /** * Get a database connection instance. * @@ -88,3 +105,12 @@ class Time extends Eloquent 'time' => 'datetime', ]; } + +class UniqueTime extends Eloquent +{ + protected $guarded = []; + + protected $casts = [ + 'time' => 'datetime', + ]; +} diff --git a/tests/Database/DatabaseSoftDeletingScopeTest.php b/tests/Database/DatabaseSoftDeletingScopeTest.php index d7563c402b0b..adf03ac6dc3c 100644 --- a/tests/Database/DatabaseSoftDeletingScopeTest.php +++ b/tests/Database/DatabaseSoftDeletingScopeTest.php @@ -72,6 +72,28 @@ public function testRestoreOrCreateExtension() $this->assertEquals($model, $result); } + public function testCreateOrRestoreExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + + $scope = new SoftDeletingScope; + $scope->extend($builder); + $callback = $builder->getMacro('createOrRestore'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('withTrashed')->once(); + $attributes = ['name' => 'foo']; + $values = ['email' => 'bar']; + $givenBuilder->shouldReceive('createOrFirst')->once()->with($attributes, $values)->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('restore')->once()->andReturn(true); + $result = $callback($givenBuilder, $attributes, $values); + + $this->assertEquals($model, $result); + } + public function testWithTrashedExtension() { $builder = new EloquentBuilder(new BaseBuilder( diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 156ce67bab64..ca4b15894613 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -45,6 +45,13 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->timestamps(); }); + Schema::create('unique_tags', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->unique(); + $table->string('type')->nullable(); + $table->timestamps(); + }); + Schema::create('users_posts', function (Blueprint $table) { $table->increments('id'); $table->string('user_uuid'); @@ -60,6 +67,14 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->string('flag')->default('')->nullable(); $table->timestamps(); }); + + Schema::create('posts_unique_tags', function (Blueprint $table) { + $table->integer('post_id'); + $table->integer('tag_id')->default(0); + $table->string('tag_name')->default('')->nullable(); + $table->string('flag')->default('')->nullable(); + $table->timestamps(); + }); } public function testBasicCreateAndRetrieve() @@ -586,6 +601,34 @@ public function testFirstOrCreateUnrelatedExisting() $this->assertTrue($tag->is($post->tags()->first())); } + public function testCreateOrFirst() + { + $post = Post::create(['title' => Str::random()]); + + $tag = UniqueTag::create(['name' => Str::random()]); + + $post->tagsUnique()->attach(UniqueTag::all()); + + $this->assertEquals($tag->id, $post->tagsUnique()->createOrFirst(['name' => $tag->name])->id); + + $new = $post->tagsUnique()->createOrFirst(['name' => 'wavez']); + $this->assertSame('wavez', $new->name); + $this->assertNotNull($new->id); + } + + public function testCreateOrFirstUnrelatedExisting() + { + $post = Post::create(['title' => Str::random()]); + + $name = Str::random(); + $tag = UniqueTag::create(['name' => $name]); + + $postTag = $post->tagsUnique()->createOrFirst(['name' => $name]); + $this->assertTrue($postTag->exists); + $this->assertTrue($postTag->is($tag)); + $this->assertTrue($tag->is($post->tagsUnique()->first())); + } + public function testFirstOrNewMethodWithValues() { $post = Post::create(['title' => Str::random()]); @@ -1330,6 +1373,14 @@ public function tags() ->wherePivot('flag', '<>', 'exclude'); } + public function tagsUnique() + { + return $this->belongsToMany(UniqueTag::class, 'posts_unique_tags', 'post_id', 'tag_id') + ->withPivot('flag') + ->withTimestamps() + ->wherePivot('flag', '<>', 'exclude'); + } + public function tagsWithExtraPivot() { return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id') @@ -1393,6 +1444,18 @@ public function posts() } } +class UniqueTag extends Model +{ + public $table = 'unique_tags'; + public $timestamps = true; + protected $fillable = ['name', 'type']; + + public function posts() + { + return $this->belongsToMany(Post::class, 'posts_unique_tags', 'tag_id', 'post_id'); + } +} + class TouchingTag extends Model { public $table = 'tags'; diff --git a/tests/Integration/Database/EloquentHasManyTest.php b/tests/Integration/Database/EloquentHasManyTest.php index e0592e02d0d6..ac8e9b21446e 100644 --- a/tests/Integration/Database/EloquentHasManyTest.php +++ b/tests/Integration/Database/EloquentHasManyTest.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; class EloquentHasManyTest extends DatabaseTestCase { @@ -15,6 +16,13 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->id(); }); + Schema::create('eloquent_has_many_test_posts', function ($table) { + $table->id(); + $table->foreignId('eloquent_has_many_test_user_id'); + $table->string('title')->unique(); + $table->timestamps(); + }); + Schema::create('eloquent_has_many_test_logins', function ($table) { $table->id(); $table->foreignId('eloquent_has_many_test_user_id'); @@ -51,6 +59,17 @@ public function testHasOneRelationshipFromHasMany() $this->assertEquals($oldestLogin->id, $user->oldestLogin->id); $this->assertEquals($latestLogin->id, $user->latestLogin->id); } + + public function testCreateOrFirst() + { + $user = EloquentHasManyTestUser::create(); + + $post1 = $user->posts()->createOrFirst(['title' => Str::random()]); + $post2 = $user->posts()->createOrFirst(['title' => $post1->title]); + + $this->assertTrue($post1->is($post2)); + $this->assertCount(1, $user->posts()->get()); + } } class EloquentHasManyTestUser extends Model @@ -72,6 +91,11 @@ public function oldestLogin(): HasOne { return $this->logins()->one()->oldestOfMany('login_time'); } + + public function posts(): HasMany + { + return $this->hasMany(EloquentHasManyTestPost::class); + } } class EloquentHasManyTestLogin extends Model @@ -79,3 +103,8 @@ class EloquentHasManyTestLogin extends Model protected $guarded = []; public $timestamps = false; } + +class EloquentHasManyTestPost extends Model +{ + protected $guarded = []; +} diff --git a/tests/Integration/Database/EloquentModelEnumCastingTest.php b/tests/Integration/Database/EloquentModelEnumCastingTest.php index c0a47fbf8ce8..b835aeb05d56 100644 --- a/tests/Integration/Database/EloquentModelEnumCastingTest.php +++ b/tests/Integration/Database/EloquentModelEnumCastingTest.php @@ -25,6 +25,11 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->json('integer_status_array')->nullable(); $table->string('arrayable_status')->nullable(); }); + + Schema::create('unique_enum_casts', function (Blueprint $table) { + $table->increments('id'); + $table->string('string_status', 100)->unique(); + }); } public function testEnumsAreCastable() @@ -264,6 +269,26 @@ public function testFirstOrCreate() $this->assertEquals(StringStatus::pending, $model->string_status); $this->assertEquals(StringStatus::done, $model2->string_status); } + + public function testCreateOrFirst() + { + $model1 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::pending, + ]); + + $model2 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::pending, + ]); + + $model3 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::done, + ]); + + $this->assertEquals(StringStatus::pending, $model1->string_status); + $this->assertEquals(StringStatus::pending, $model2->string_status); + $this->assertTrue($model1->is($model2)); + $this->assertEquals(StringStatus::done, $model3->string_status); + } } class EloquentModelEnumCastingTestModel extends Model @@ -282,3 +307,14 @@ class EloquentModelEnumCastingTestModel extends Model 'arrayable_status' => ArrayableStatus::class, ]; } + +class EloquentModelEnumCastingUniqueTestModel extends Model +{ + public $timestamps = false; + protected $guarded = []; + protected $table = 'unique_enum_casts'; + + public $casts = [ + 'string_status' => StringStatus::class, + ]; +} diff --git a/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php b/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php new file mode 100644 index 000000000000..bad64969d83f --- /dev/null +++ b/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php @@ -0,0 +1,67 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('database_eloquent_mysql_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentMySqlIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } +} + +class DatabaseEloquentMySqlIntegrationUser extends Model +{ + protected $table = 'database_eloquent_mysql_integration_users'; + + protected $guarded = []; +} diff --git a/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php b/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php new file mode 100644 index 000000000000..7bc71f3fdc57 --- /dev/null +++ b/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php @@ -0,0 +1,67 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('database_eloquent_postgres_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentPostgresIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } +} + +class DatabaseEloquentPostgresIntegrationUser extends Model +{ + protected $table = 'database_eloquent_postgres_integration_users'; + + protected $guarded = []; +} diff --git a/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php b/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php new file mode 100644 index 000000000000..d7502edcfe44 --- /dev/null +++ b/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php @@ -0,0 +1,67 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('database_eloquent_sql_server_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } +} + +class DatabaseEloquentSqlServerIntegrationUser extends Model +{ + protected $table = 'database_eloquent_sql_server_integration_users'; + + protected $guarded = []; +}