Skip to content

[12.x] Allowing merging model attributes before insert via Model::fillAndInsert() #55038

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

Merged
merged 15 commits into from
Apr 1, 2025
61 changes: 61 additions & 0 deletions src/Illuminate/Database/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,67 @@ public function hydrate(array $items)
}, $items));
}

/**
* Insert into the database after merging the model's default attributes, setting timestamps, and casting values.
*
* @param array<int, array<string, mixed>> $values
* @return bool
*/
public function fillAndInsert(array $values)
{
return $this->insert($this->fillForInsert($values));
}

/**
* Insert (ignoring errors) into the database after merging the model's default attributes, setting timestamps, and casting values.
*
* @param array<int, array<string, mixed>> $values
* @return int
*/
public function fillAndInsertOrIgnore(array $values)
{
return $this->insertOrIgnore($this->fillForInsert($values));
}

/**
* Insert a record into the database and get its ID after merging the model's default attributes, setting timestamps, and casting values.
*
* @param array<string, mixed> $values
* @return int
*/
public function fillAndInsertGetId(array $values)
{
return $this->insertGetId($this->fillForInsert([$values])[0]);
}

/**
* Enrich the given values by merging in the model's default attributes, adding timestamps, and casting values.
*
* @param array<int, array<string, mixed>> $values
* @return array<int, array<string, mixed>>
*/
public function fillForInsert(array $values)
{
if (empty($values)) {
return [];
}

if (! is_array(reset($values))) {
$values = [$values];
}

$this->model->unguarded(function () use (&$values) {
foreach ($values as $key => $rowValues) {
$values[$key] = tap(
$this->newModelInstance($rowValues),
fn ($model) => $model->setUniqueIds()
)->getAttributes();
}
});

return $this->addTimestampsToUpsertValues($values);
}

/**
* Create a collection of models from a raw query.
*
Expand Down
193 changes: 193 additions & 0 deletions tests/Database/DatabaseEloquentIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Database\Capsule\Manager as DB;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Model as Eloquent;
use Illuminate\Database\Eloquent\ModelNotFoundException;
Expand All @@ -16,13 +17,15 @@
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Pagination\AbstractPaginator as Paginator;
use Illuminate\Pagination\Cursor;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use Illuminate\Tests\Integration\Database\Fixtures\Post;
use Illuminate\Tests\Integration\Database\Fixtures\User;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -80,6 +83,14 @@ protected function createSchema()
$table->timestamps();
});

$this->schema()->create('users_having_uuids', function (Blueprint $table) {
$table->id();
$table->uuid();
$table->string('name');
$table->tinyInteger('role');
$table->string('role_string');
});

foreach (['default', 'second_connection'] as $connection) {
$this->schema($connection)->create('users', function ($table) {
$table->increments('id');
Expand Down Expand Up @@ -187,6 +198,8 @@ protected function tearDown(): void
Eloquent::unsetConnectionResolver();

Carbon::setTestNow(null);
Str::createUuidsNormally();
DB::flushQueryLog();
}

/**
Expand Down Expand Up @@ -2461,6 +2474,147 @@ public function testTouchingBiDirectionalChaperonedModelUpdatesAllRelatedTimesta
}
}

public function testCanFillAndInsert()
{
DB::enableQueryLog();
Carbon::setTestNow('2025-03-15T07:32:00Z');

$this->assertTrue(EloquentTestUser::fillAndInsert([
['email' => '[email protected]', 'birthday' => null],
['email' => '[email protected]', 'birthday' => new Carbon('1980-01-01')],
['email' => '[email protected]', 'birthday' => '1987-11-01', 'created_at' => '2025-01-02T02:00:55', 'updated_at' => Carbon::parse('2025-02-19T11:41:13')],
]));

$this->assertCount(1, DB::getQueryLog());

$this->assertCount(3, $users = EloquentTestUser::get());

$users->take(2)->each(function (EloquentTestUser $user) {
$this->assertEquals(Carbon::parse('2025-03-15T07:32:00Z'), $user->created_at);
$this->assertEquals(Carbon::parse('2025-03-15T07:32:00Z'), $user->updated_at);
});

$tim = $users->firstWhere('email', '[email protected]');
$this->assertEquals(Carbon::parse('2025-01-02T02:00:55'), $tim->created_at);
$this->assertEquals(Carbon::parse('2025-02-19T11:41:13'), $tim->updated_at);

$this->assertNull($users[0]->birthday);
$this->assertInstanceOf(\DateTime::class, $users[1]->birthday);
$this->assertInstanceOf(\DateTime::class, $users[2]->birthday);
$this->assertEquals('1987-11-01', $users[2]->birthday->format('Y-m-d'));

DB::flushQueryLog();

$this->assertTrue(EloquentTestWithJSON::fillAndInsert([
['id' => 1, 'json' => ['album' => 'Keep It Like a Secret', 'release_date' => '1999-02-02']],
['id' => 2, 'json' => (object) ['album' => 'You In Reverse', 'release_date' => '2006-04-11']],
]));

$this->assertCount(1, DB::getQueryLog());

$this->assertCount(2, $testsWithJson = EloquentTestWithJSON::get());

$testsWithJson->each(function (EloquentTestWithJSON $testWithJson) {
$this->assertIsArray($testWithJson->json);
$this->assertArrayHasKey('album', $testWithJson->json);
});
}

public function testCanFillAndInsertWithUniqueStringIds()
{
Str::createUuidsUsingSequence([
'00000000-0000-7000-0000-000000000000',
'11111111-0000-7000-0000-000000000000',
'22222222-0000-7000-0000-000000000000',
]);

$this->assertTrue(ModelWithUniqueStringIds::fillAndInsert([
[
'name' => 'Taylor', 'role' => IntBackedRole::Admin, 'role_string' => StringBackedRole::Admin,
],
[
'name' => 'Nuno', 'role' => 3, 'role_string' => 'admin',
],
[
'name' => 'Dries', 'uuid' => 'bbbb0000-0000-7000-0000-000000000000',
],
[
'name' => 'Chris',
],
]));

$models = ModelWithUniqueStringIds::get();

$taylor = $models->firstWhere('name', 'Taylor');
$nuno = $models->firstWhere('name', 'Nuno');
$dries = $models->firstWhere('name', 'Dries');
$chris = $models->firstWhere('name', 'Chris');

$this->assertEquals(IntBackedRole::Admin, $taylor->role);
$this->assertEquals(StringBackedRole::Admin, $taylor->role_string);
$this->assertSame('00000000-0000-7000-0000-000000000000', $taylor->uuid);

$this->assertEquals(IntBackedRole::Admin, $nuno->role);
$this->assertEquals(StringBackedRole::Admin, $nuno->role_string);
$this->assertSame('11111111-0000-7000-0000-000000000000', $nuno->uuid);

$this->assertEquals(IntBackedRole::User, $dries->role);
$this->assertEquals(StringBackedRole::User, $dries->role_string);
$this->assertSame('bbbb0000-0000-7000-0000-000000000000', $dries->uuid);

$this->assertEquals(IntBackedRole::User, $chris->role);
$this->assertEquals(StringBackedRole::User, $chris->role_string);
$this->assertSame('22222222-0000-7000-0000-000000000000', $chris->uuid);
}

public function testFillAndInsertOrIgnore()
{
Str::createUuidsUsingSequence([
'00000000-0000-7000-0000-000000000000',
'11111111-0000-7000-0000-000000000000',
'22222222-0000-7000-0000-000000000000',
]);

$this->assertEquals(1, ModelWithUniqueStringIds::fillAndInsertOrIgnore([
[
'id' => 1, 'name' => 'Taylor', 'role' => IntBackedRole::Admin, 'role_string' => StringBackedRole::Admin,
],
]));

$this->assertSame(1, ModelWithUniqueStringIds::fillAndInsertOrIgnore([
[
'id' => 1, 'name' => 'Taylor', 'role' => IntBackedRole::Admin, 'role_string' => StringBackedRole::Admin,
],
[
'id' => 2, 'name' => 'Nuno',
],
]));

$models = ModelWithUniqueStringIds::get();
$this->assertSame('00000000-0000-7000-0000-000000000000', $models->firstWhere('name', 'Taylor')->uuid);
$this->assertSame(
['uuid' => '22222222-0000-7000-0000-000000000000', 'role' => IntBackedRole::User],
$models->firstWhere('name', 'Nuno')->only('uuid', 'role')
);
}

public function testFillAndInsertGetId()
{
Str::createUuidsUsingSequence([
'00000000-0000-7000-0000-000000000000',
]);

DB::enableQueryLog();

$this->assertIsInt($newId = ModelWithUniqueStringIds::fillAndInsertGetId([
'name' => 'Taylor',
'role' => IntBackedRole::Admin,
'role_string' => StringBackedRole::Admin,
]));
$this->assertCount(1, DB::getRawQueryLog());
$this->assertSame($newId, ModelWithUniqueStringIds::sole()->id);
}

/**
* Helpers...
*/
Expand Down Expand Up @@ -2786,3 +2940,42 @@ public function children()
return $this->hasMany(EloquentTouchingCategory::class, 'parent_id')->chaperone();
}
}

class ModelWithUniqueStringIds extends Eloquent
{
use HasUuids;

public $timestamps = false;

protected $table = 'users_having_uuids';

protected function casts()
{
return [
'role' => IntBackedRole::class,
'role_string' => StringBackedRole::class,
];
}

protected $attributes = [
'role' => IntBackedRole::User,
'role_string' => StringBackedRole::User,
];

public function uniqueIds()
{
return ['uuid'];
}
}

enum IntBackedRole: int
{
case User = 1;
case Admin = 3;
}

enum StringBackedRole: string
{
case User = 'user';
case Admin = 'admin';
}
Loading