diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index a0bbcdb967fe..2e17922c5353 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -580,7 +580,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) public function createOrFirst(array $attributes = [], array $values = []) { try { - return $this->create(array_merge($attributes, $values)); + return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); } catch (UniqueConstraintViolationException $exception) { return $this->where($attributes)->first(); } @@ -1709,6 +1709,21 @@ public function withCasts($casts) return $this; } + /** + * Execute the given Closure within a transaction savepoint if needed. + * + * @template TModelValue + * + * @param \Closure(): TModelValue $scope + * @return TModelValue + */ + public function withSavepointIfNeeded(Closure $scope): mixed + { + return $this->getQuery()->getConnection()->transactionLevel() > 0 + ? $this->getQuery()->getConnection()->transaction($scope) + : $scope(); + } + /** * Get the underlying query builder instance. * diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index a4cc76fb0308..4a443ddece10 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -643,14 +643,14 @@ public function firstOrCreate(array $attributes = [], array $values = [], array public function createOrFirst(array $attributes = [], array $values = [], array $joining = [], $touch = true) { try { - return $this->create(array_merge($attributes, $values), $joining, $touch); + return $this->getQuery()->withSavePointIfNeeded(fn () => $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); + $this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch)); }); } catch (UniqueConstraintViolationException $exception) { return (clone $this)->where($attributes)->first(); diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 482e5208a946..109cb017dc95 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -252,7 +252,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) public function createOrFirst(array $attributes = [], array $values = []) { try { - return $this->create(array_merge($attributes, $values)); + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); } catch (UniqueConstraintViolationException $exception) { return $this->where($attributes)->first(); } diff --git a/tests/Database/DatabaseEloquentHasManyTest.php b/tests/Database/DatabaseEloquentHasManyTest.php index 8fcfa74a6b17..a10e884cd6e9 100755 --- a/tests/Database/DatabaseEloquentHasManyTest.php +++ b/tests/Database/DatabaseEloquentHasManyTest.php @@ -181,6 +181,9 @@ public function testCreateOrFirstMethodWithValuesFindsFirstModel() ); })); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(stdClass::class)); @@ -192,6 +195,9 @@ public function testCreateOrFirstMethodCreatesNewModelWithForeignKeySet() { $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->never(); $relation->getQuery()->shouldReceive('first')->never(); $model = $this->expectCreatedModel($relation, ['foo']); @@ -202,6 +208,9 @@ public function testCreateOrFirstMethodCreatesNewModelWithForeignKeySet() public function testCreateOrFirstMethodWithValuesCreatesNewModelWithForeignKeySet() { $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->never(); $relation->getQuery()->shouldReceive('first')->never(); $model = $this->expectCreatedModel($relation, ['foo' => 'bar', 'baz' => 'qux']); diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index aa34e8d3b172..ff497a92a979 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -552,6 +552,22 @@ public function testCreateOrFirst() $this->assertSame('Nuno Maduro', $user4->name); } + public function testCreateOrFirstWithinTransaction() + { + $user1 = EloquentTestUniqueUser::create(['email' => 'taylorotwell@gmail.com']); + + DB::transaction(function () use ($user1) { + $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); + }); + } + public function testUpdateOrCreate() { $user1 = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); diff --git a/tests/Database/DatabaseEloquentMorphTest.php b/tests/Database/DatabaseEloquentMorphTest.php index d30eee15d7cd..ed17b3a092a5 100755 --- a/tests/Database/DatabaseEloquentMorphTest.php +++ b/tests/Database/DatabaseEloquentMorphTest.php @@ -227,6 +227,9 @@ public function testCreateOrFirstMethodFindsFirstModel() new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), ); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); @@ -244,6 +247,9 @@ public function testCreateOrFirstMethodWithValuesFindsFirstModel() new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), ); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); @@ -259,6 +265,9 @@ public function testCreateOrFirstMethodCreatesNewMorphModel() $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); $model->shouldReceive('save')->once()->andReturn(true); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->never(); $relation->getQuery()->shouldReceive('first')->never(); @@ -274,6 +283,9 @@ public function testCreateOrFirstMethodWithValuesCreatesNewMorphModel() $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); $model->shouldReceive('save')->once()->andReturn(true); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->never(); $relation->getQuery()->shouldReceive('first')->never(); diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index ca4b15894613..23e6eb518bb7 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -629,6 +629,19 @@ public function testCreateOrFirstUnrelatedExisting() $this->assertTrue($tag->is($post->tagsUnique()->first())); } + public function testCreateOrFirstWithinTransaction() + { + $post = Post::create(['title' => Str::random()]); + + $tag = UniqueTag::create(['name' => Str::random()]); + + $post->tagsUnique()->attach(UniqueTag::all()); + + DB::transaction(function () use ($tag, $post) { + $this->assertEquals($tag->id, $post->tagsUnique()->createOrFirst(['name' => $tag->name])->id); + }); + } + public function testFirstOrNewMethodWithValues() { $post = Post::create(['title' => Str::random()]); diff --git a/tests/Integration/Database/EloquentHasManyTest.php b/tests/Integration/Database/EloquentHasManyTest.php index ac8e9b21446e..49e1480ba9c7 100644 --- a/tests/Integration/Database/EloquentHasManyTest.php +++ b/tests/Integration/Database/EloquentHasManyTest.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -70,6 +71,21 @@ public function testCreateOrFirst() $this->assertTrue($post1->is($post2)); $this->assertCount(1, $user->posts()->get()); } + + public function testCreateOrFirstWithinTransaction() + { + $user = EloquentHasManyTestUser::create(); + + $post1 = $user->posts()->create(['title' => Str::random()]); + + DB::transaction(function () use ($user, $post1) { + $post2 = $user->posts()->createOrFirst(['title' => $post1->title]); + + $this->assertTrue($post1->is($post2)); + }); + + $this->assertCount(1, $user->posts()->get()); + } } class EloquentHasManyTestUser extends Model diff --git a/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php b/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php index bad64969d83f..f7eaccdb274b 100644 --- a/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php +++ b/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; class DatabaseEloquentMySqlIntegrationTest extends MySqlTestCase @@ -57,6 +58,22 @@ public function testCreateOrFirst() $this->assertSame('Nuno Maduro', $user4->name); } + + public function testCreateOrFirstWithinTransaction() + { + $user1 = DatabaseEloquentMySqlIntegrationUser::createOrFirst(['email' => 'taylor@laravel.com']); + + DB::transaction(function () use ($user1) { + $user2 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['email' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylor@laravel.com', $user2->email); + $this->assertNull($user2->name); + }); + } } class DatabaseEloquentMySqlIntegrationUser extends Model diff --git a/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php b/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php index 7bc71f3fdc57..d95dca61a0cb 100644 --- a/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php +++ b/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; class DatabaseEloquentPostgresIntegrationTest extends PostgresTestCase @@ -57,6 +58,22 @@ public function testCreateOrFirst() $this->assertSame('Nuno Maduro', $user4->name); } + + public function testCreateOrFirstWithinTransaction() + { + $user1 = DatabaseEloquentPostgresIntegrationUser::create(['email' => 'taylor@laravel.com']); + + DB::transaction(function () use ($user1) { + $user2 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['email' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylor@laravel.com', $user2->email); + $this->assertNull($user2->name); + }); + } } class DatabaseEloquentPostgresIntegrationUser extends Model diff --git a/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php b/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php index d7502edcfe44..9098e6608c6c 100644 --- a/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php +++ b/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; class DatabaseEloquentSqlServerIntegrationTest extends SqlServerTestCase @@ -57,6 +58,22 @@ public function testCreateOrFirst() $this->assertSame('Nuno Maduro', $user4->name); } + + public function testCreateOrFirstWithinTransaction() + { + $user1 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst(['email' => 'taylor@laravel.com']); + + DB::transaction(function () use ($user1) { + $user2 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst( + ['email' => 'taylor@laravel.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylor@laravel.com', $user2->email); + $this->assertNull($user2->name); + }); + } } class DatabaseEloquentSqlServerIntegrationUser extends Model