From 2a4d8481f9b5f9e2b7c2caf39290ccfe64953506 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Tue, 26 Sep 2023 08:41:08 -0500 Subject: [PATCH] [10.x] Adds the `firstOrCreate` and `createOrFirst` methods to the `HasManyThrough` relation (#48541) * Test firstOrCreate on HasManyThrough relation * Add the firstOrCreate method in the HasManyThrough relation to avoid select * issue * Invert the if statement on the firstOrCreate method * Adds tests for the firstOrCreate when model doesn't exist * Test createOrFirst on HasManyThrough relations with existing models * Fix the createOrFirst also happening the regression test from the updateOrCreate * Wraps the create part of the createOrFirst in a savepoint if needed * Fix comment * Adds the not is_null in the guard if statement --- .../Eloquent/Relations/HasManyThrough.php | 33 ++++ .../Database/EloquentHasManyThroughTest.php | 171 ++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php index ac5037185793..079bdd8b3bde 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\UniqueConstraintViolationException; class HasManyThrough extends Relation { @@ -262,6 +263,38 @@ public function firstOrNew(array $attributes) return $instance; } + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array $attributes + * @param array $values + * @return \Illuminate\Database\Eloquent\Model + */ + public function firstOrCreate(array $attributes = [], array $values = []) + { + if (! is_null($instance = $this->where($attributes)->first())) { + return $instance; + } + + return $this->create(array_merge($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 + */ + public function createOrFirst(array $attributes = [], array $values = []) + { + try { + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values))); + } catch (UniqueConstraintViolationException $exception) { + return $this->where($attributes)->first() ?? throw $exception; + } + } + /** * Create or update a related record matching the attributes, and fill it with values. * diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index 97a2208f0a63..0f5c38508fb0 100644 --- a/tests/Integration/Database/EloquentHasManyThroughTest.php +++ b/tests/Integration/Database/EloquentHasManyThroughTest.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Illuminate\Tests\Integration\Database\DatabaseTestCase; @@ -36,6 +37,13 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->increments('id'); $table->integer('category_id'); }); + + Schema::create('articles', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id'); + $table->string('title')->unique(); + $table->timestamps(); + }); } public function testBasicCreateAndRetrieve() @@ -140,6 +148,144 @@ public function testHasSameParentAndThroughParentTable() $this->assertEquals([1], $categories->pluck('id')->all()); } + public function testFirstOrCreateWhenModelDoesntExist() + { + $owner = User::create(['name' => 'Taylor']); + Team::create(['owner_id' => $owner->id]); + + $mate = $owner->teamMates()->firstOrCreate(['slug' => 'adam'], ['name' => 'Adam']); + + $this->assertTrue($mate->wasRecentlyCreated); + $this->assertNull($mate->team_id); + $this->assertEquals('Adam', $mate->name); + $this->assertEquals('adam', $mate->slug); + } + + public function testFirstOrCreateWhenModelExists() + { + $owner = User::create(['name' => 'Taylor']); + $team = Team::create(['owner_id' => $owner->id]); + + $team->members()->create(['slug' => 'adam', 'name' => 'Adam Wathan']); + + $mate = $owner->teamMates()->firstOrCreate(['slug' => 'adam'], ['name' => 'Adam']); + + $this->assertFalse($mate->wasRecentlyCreated); + $this->assertNotNull($mate->team_id); + $this->assertTrue($team->is($mate->team)); + $this->assertEquals('Adam Wathan', $mate->name); + $this->assertEquals('adam', $mate->slug); + } + + public function testFirstOrCreateRegressionIssue() + { + $team1 = Team::create(); + $team2 = Team::create(); + + $jane = $team2->members()->create(['name' => 'Jane', 'slug' => 'jane']); + $john = $team1->members()->create(['name' => 'John', 'slug' => 'john']); + + $taylor = User::create(['name' => 'Taylor']); + $team1->update(['owner_id' => $taylor->id]); + + $newJohn = $taylor->teamMates()->firstOrCreate( + ['slug' => 'john'], + ['name' => 'John Doe'], + ); + + $this->assertFalse($newJohn->wasRecentlyCreated); + $this->assertTrue($john->is($newJohn)); + $this->assertEquals('john', $newJohn->refresh()->slug); + $this->assertEquals('John', $newJohn->name); + + $this->assertSame('john', $john->refresh()->slug); + $this->assertSame('John', $john->name); + $this->assertSame('jane', $jane->refresh()->slug); + $this->assertSame('Jane', $jane->name); + } + + public function testCreateOrFirstWhenRecordDoesntExist() + { + $team = Team::create(); + $tony = $team->members()->create(['name' => 'Tony']); + + $article = $team->articles()->createOrFirst( + ['title' => 'Laravel Forever'], + ['user_id' => $tony->id], + ); + + $this->assertTrue($article->wasRecentlyCreated); + $this->assertEquals('Laravel Forever', $article->title); + $this->assertTrue($tony->is($article->user)); + } + + public function testCreateOrFirstWhenRecordExists() + { + $team = Team::create(); + $taylor = $team->members()->create(['name' => 'Taylor']); + $tony = $team->members()->create(['name' => 'Tony']); + + $existingArticle = $taylor->articles()->create([ + 'title' => 'Laravel Forever', + ]); + + $newArticle = $team->articles()->createOrFirst( + ['title' => 'Laravel Forever'], + ['user_id' => $tony->id], + ); + + $this->assertFalse($newArticle->wasRecentlyCreated); + $this->assertEquals('Laravel Forever', $newArticle->title); + $this->assertTrue($taylor->is($newArticle->user)); + $this->assertTrue($existingArticle->is($newArticle)); + } + + public function testCreateOrFirstWhenRecordExistsInTransaction() + { + $team = Team::create(); + $taylor = $team->members()->create(['name' => 'Taylor']); + $tony = $team->members()->create(['name' => 'Tony']); + + $existingArticle = $taylor->articles()->create([ + 'title' => 'Laravel Forever', + ]); + + $newArticle = DB::transaction(fn () => $team->articles()->createOrFirst( + ['title' => 'Laravel Forever'], + ['user_id' => $tony->id], + )); + + $this->assertFalse($newArticle->wasRecentlyCreated); + $this->assertEquals('Laravel Forever', $newArticle->title); + $this->assertTrue($taylor->is($newArticle->user)); + $this->assertTrue($existingArticle->is($newArticle)); + } + + public function testCreateOrFirstRegressionIssue() + { + $team1 = Team::create(); + + $taylor = $team1->members()->create(['name' => 'Taylor']); + $tony = $team1->members()->create(['name' => 'Tony']); + + $existingTonyArticle = $tony->articles()->create(['title' => 'The New createOrFirst Method']); + $existingTaylorArticle = $taylor->articles()->create(['title' => 'Laravel Forever']); + + $newArticle = $team1->articles()->createOrFirst( + ['title' => 'Laravel Forever'], + ['user_id' => $tony->id], + ); + + $this->assertFalse($newArticle->wasRecentlyCreated); + $this->assertTrue($existingTaylorArticle->is($newArticle)); + $this->assertEquals('Laravel Forever', $newArticle->refresh()->title); + $this->assertTrue($taylor->is($newArticle->user)); + + $this->assertSame('Laravel Forever', $existingTaylorArticle->refresh()->title); + $this->assertSame('The New createOrFirst Method', $existingTonyArticle->refresh()->title); + $this->assertTrue($tony->is($existingTonyArticle->user)); + } + public function testUpdateOrCreateAffectingWrongModelsRegression() { // On Laravel 10.21.0, a bug was introduced that would update the wrong model when using `updateOrCreate()`, @@ -228,6 +374,16 @@ public function ownedTeams() { return $this->hasMany(Team::class, 'owner_id'); } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function articles() + { + return $this->hasMany(Article::class); + } } class UserWithGlobalScope extends Model @@ -261,6 +417,11 @@ public function membersWithGlobalScope() { return $this->hasMany(UserWithGlobalScope::class, 'team_id'); } + + public function articles() + { + return $this->hasManyThrough(Article::class, User::class); + } } class Category extends Model @@ -281,3 +442,13 @@ class Product extends Model public $timestamps = false; protected $guarded = []; } + +class Article extends Model +{ + protected $guarded = []; + + public function user() + { + return $this->belongsTo(User::class); + } +}