Skip to content

Commit

Permalink
[10.x] Adds the firstOrCreate and createOrFirst methods to the `H…
Browse files Browse the repository at this point in the history
…asManyThrough` 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
  • Loading branch information
tonysm committed Sep 26, 2023
1 parent 3e1e127 commit 2a4d848
Show file tree
Hide file tree
Showing 2 changed files with 204 additions and 0 deletions.
33 changes: 33 additions & 0 deletions src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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.
*
Expand Down
171 changes: 171 additions & 0 deletions tests/Integration/Database/EloquentHasManyThroughTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}

0 comments on commit 2a4d848

Please sign in to comment.