Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[10.x] Adds the firstOrCreate and createOrFirst methods to the HasManyThrough relation #48541

Merged
merged 9 commits into from
Sep 26, 2023
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);
}
}