Skip to content

Commit c82e68e

Browse files
authored
feat: add Model::firstOrInsert() with failure handling (#10012)
1 parent de063a8 commit c82e68e

File tree

7 files changed

+384
-0
lines changed

7 files changed

+384
-0
lines changed

system/BaseModel.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use CodeIgniter\Database\BaseResult;
1919
use CodeIgniter\Database\Exceptions\DatabaseException;
2020
use CodeIgniter\Database\Exceptions\DataException;
21+
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
2122
use CodeIgniter\Database\Query;
2223
use CodeIgniter\Database\RawSql;
2324
use CodeIgniter\DataCaster\Cast\CastInterface;
@@ -869,6 +870,7 @@ protected function validateID(mixed $id, bool $allowArray = true): void
869870
* @return ($returnID is true ? false|int|string : bool)
870871
*
871872
* @throws ReflectionException
873+
* @throws UniqueConstraintViolationException
872874
*/
873875
public function insert($row = null, bool $returnID = true)
874876
{

system/Model.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use CodeIgniter\Database\ConnectionInterface;
2020
use CodeIgniter\Database\Exceptions\DatabaseException;
2121
use CodeIgniter\Database\Exceptions\DataException;
22+
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
2223
use CodeIgniter\Entity\Entity;
2324
use CodeIgniter\Exceptions\BadMethodCallException;
2425
use CodeIgniter\Exceptions\InvalidArgumentException;
@@ -715,6 +716,54 @@ protected function doProtectFieldsForInsert(array $row): array
715716
return $row;
716717
}
717718

719+
/**
720+
* Finds the first row matching attributes or inserts a new row.
721+
*
722+
* Note: without a DB unique constraint, this is not race-safe.
723+
*
724+
* @param array<string, mixed>|object $attributes
725+
* @param array<string, mixed>|object $values
726+
*
727+
* @return array<string, mixed>|false|object
728+
*/
729+
public function firstOrInsert(array|object $attributes, array|object $values = []): array|false|object
730+
{
731+
if (is_object($attributes)) {
732+
$attributes = $this->transformDataToArray($attributes, 'insert');
733+
}
734+
735+
if ($attributes === []) {
736+
throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.');
737+
}
738+
739+
$row = $this->where($attributes)->first();
740+
if ($row !== null) {
741+
return $row;
742+
}
743+
744+
if (is_object($values)) {
745+
$values = $this->transformDataToArray($values, 'insert');
746+
}
747+
748+
$data = array_merge($attributes, $values);
749+
750+
try {
751+
$id = $this->insert($data);
752+
} catch (UniqueConstraintViolationException) {
753+
return $this->where($attributes)->first() ?? false;
754+
}
755+
756+
if ($id === false) {
757+
if ($this->db->getLastException() instanceof UniqueConstraintViolationException) {
758+
return $this->where($attributes)->first() ?? false;
759+
}
760+
761+
return false;
762+
}
763+
764+
return $this->where($this->primaryKey, $id)->first() ?? false;
765+
}
766+
718767
public function update($id = null, $row = null): bool
719768
{
720769
if (isset($this->tempData['data'])) {
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Models;
15+
16+
use CodeIgniter\Database\Exceptions\DatabaseException;
17+
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
18+
use CodeIgniter\Exceptions\InvalidArgumentException;
19+
use PHPUnit\Framework\Attributes\Group;
20+
use ReflectionProperty;
21+
use stdClass;
22+
use Tests\Support\Models\UserModel;
23+
24+
/**
25+
* @internal
26+
*/
27+
#[Group('DatabaseLive')]
28+
final class FirstOrInsertModelTest extends LiveModelTestCase
29+
{
30+
protected function tearDown(): void
31+
{
32+
$this->enableDBDebug();
33+
parent::tearDown();
34+
}
35+
36+
public function testReturnsExistingRecord(): void
37+
{
38+
$this->createModel(UserModel::class);
39+
40+
$row = $this->model->firstOrInsert(['email' => 'derek@world.com']);
41+
42+
$this->assertIsObject($row);
43+
$this->assertSame('Derek Jones', $row->name);
44+
$this->assertSame('derek@world.com', $row->email);
45+
$this->assertSame('US', $row->country);
46+
}
47+
48+
public function testDoesNotInsertWhenRecordExists(): void
49+
{
50+
$this->createModel(UserModel::class);
51+
52+
$this->model->firstOrInsert(['email' => 'derek@world.com']);
53+
54+
// Seeder inserts 4 users; calling firstOrInsert on an existing
55+
// record must not add a fifth one.
56+
$this->seeNumRecords(4, 'user', ['deleted_at' => null]);
57+
}
58+
59+
public function testValuesAreIgnoredWhenRecordExists(): void
60+
{
61+
$this->createModel(UserModel::class);
62+
63+
// The $values array must not be used to modify the found record.
64+
$row = $this->model->firstOrInsert(
65+
['email' => 'derek@world.com'],
66+
['name' => 'Should Not Change', 'country' => 'XX'],
67+
);
68+
69+
$this->assertIsObject($row);
70+
$this->assertSame('Derek Jones', $row->name);
71+
$this->assertSame('US', $row->country);
72+
}
73+
74+
public function testInsertsNewRecordWhenNotFound(): void
75+
{
76+
$this->createModel(UserModel::class);
77+
78+
$row = $this->model->firstOrInsert([
79+
'name' => 'New User',
80+
'email' => 'new@example.com',
81+
'country' => 'US',
82+
]);
83+
84+
$this->assertIsObject($row);
85+
$this->assertSame('new@example.com', $row->email);
86+
$this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]);
87+
}
88+
89+
public function testMergesValuesOnInsert(): void
90+
{
91+
$this->createModel(UserModel::class);
92+
93+
$row = $this->model->firstOrInsert(
94+
['email' => 'new@example.com'],
95+
['name' => 'New User', 'country' => 'CA'],
96+
);
97+
98+
$this->assertIsObject($row);
99+
$this->assertSame('New User', $row->name);
100+
$this->assertSame('CA', $row->country);
101+
$this->seeInDatabase('user', [
102+
'email' => 'new@example.com',
103+
'name' => 'New User',
104+
'country' => 'CA',
105+
'deleted_at' => null,
106+
]);
107+
}
108+
109+
public function testAcceptsObjectForValues(): void
110+
{
111+
$this->createModel(UserModel::class);
112+
113+
$values = new stdClass();
114+
$values->name = 'Object User';
115+
$values->country = 'DE';
116+
117+
$row = $this->model->firstOrInsert(
118+
['email' => 'object@example.com'],
119+
$values,
120+
);
121+
122+
$this->assertIsObject($row);
123+
$this->assertSame('Object User', $row->name);
124+
$this->assertSame('DE', $row->country);
125+
$this->seeInDatabase('user', ['email' => 'object@example.com', 'deleted_at' => null]);
126+
}
127+
128+
public function testAcceptsObjectForAttributes(): void
129+
{
130+
$this->createModel(UserModel::class);
131+
132+
$attributes = new stdClass();
133+
$attributes->email = 'derek@world.com';
134+
135+
$row = $this->model->firstOrInsert($attributes);
136+
137+
$this->assertIsObject($row);
138+
$this->assertSame('Derek Jones', $row->name);
139+
$this->seeNumRecords(4, 'user', ['deleted_at' => null]);
140+
}
141+
142+
public function testAcceptsObjectForAttributesAndInsertsWhenNotFound(): void
143+
{
144+
$this->createModel(UserModel::class);
145+
146+
$attributes = new stdClass();
147+
$attributes->email = 'new@example.com';
148+
$attributes->name = 'New User';
149+
$attributes->country = 'US';
150+
151+
$row = $this->model->firstOrInsert($attributes);
152+
153+
$this->assertIsObject($row);
154+
$this->assertSame('new@example.com', $row->email);
155+
$this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]);
156+
}
157+
158+
public function testThrowsOnEmptyAttributes(): void
159+
{
160+
$this->createModel(UserModel::class);
161+
162+
$this->expectException(InvalidArgumentException::class);
163+
$this->model->firstOrInsert([]);
164+
}
165+
166+
public function testHandlesRaceConditionWithDebugEnabled(): void
167+
{
168+
// Subclass that simulates a concurrent insert winning the race:
169+
// doInsert() first persists the row (the "other process"), then
170+
// throws UniqueConstraintViolationException as if our own attempt
171+
// also tried to insert the same row.
172+
$model = new class ($this->db) extends UserModel {
173+
protected function doInsert(array $row): bool
174+
{
175+
parent::doInsert($row);
176+
177+
throw new UniqueConstraintViolationException('Duplicate entry');
178+
}
179+
};
180+
181+
$row = $model->firstOrInsert(
182+
['email' => 'race@example.com'],
183+
['name' => 'Race User', 'country' => 'US'],
184+
);
185+
186+
$this->assertIsObject($row);
187+
$this->assertSame('race@example.com', $row->email);
188+
// The "other process" inserted exactly one record.
189+
$this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]);
190+
}
191+
192+
public function testHandlesRaceConditionWithDebugDisabled(): void
193+
{
194+
$this->disableDBDebug();
195+
196+
// Subclass that simulates a concurrent insert: the "other process"
197+
// inserts via a direct DB call, then our own attempt fails with a
198+
// unique violation which is stored in lastException (DBDebug=false).
199+
$model = new class ($this->db) extends UserModel {
200+
protected function doInsert(array $row): bool
201+
{
202+
// Direct insert – bypasses the model so it won't interfere
203+
// with the model's own builder state.
204+
$this->db->table($this->table)->insert([
205+
'name' => $row['name'],
206+
'email' => $row['email'],
207+
'country' => $row['country'],
208+
]);
209+
210+
// The real insert now fails; the driver stores
211+
// UniqueConstraintViolationException in lastException.
212+
return parent::doInsert($row);
213+
}
214+
};
215+
216+
$row = $model->firstOrInsert(
217+
['email' => 'race@example.com'],
218+
['name' => 'Race User', 'country' => 'US'],
219+
);
220+
221+
$this->assertIsObject($row);
222+
$this->assertSame('race@example.com', $row->email);
223+
$this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]);
224+
}
225+
226+
public function testReturnsFalseOnNonUniqueErrorWithDebugDisabled(): void
227+
{
228+
$this->disableDBDebug();
229+
230+
// Subclass that simulates a non-unique database error by placing
231+
// a plain DatabaseException (not UniqueConstraintViolationException)
232+
// into lastException and returning false.
233+
$model = new class ($this->db) extends UserModel {
234+
protected function doInsert(array $row): bool
235+
{
236+
$prop = new ReflectionProperty($this->db, 'lastException');
237+
$prop->setValue($this->db, new DatabaseException('Connection error'));
238+
239+
return false;
240+
}
241+
};
242+
243+
$result = $model->firstOrInsert(
244+
['email' => 'error@example.com'],
245+
['name' => 'Error User', 'country' => 'US'],
246+
);
247+
248+
$this->assertFalse($result);
249+
$this->dontSeeInDatabase('user', ['email' => 'error@example.com']);
250+
}
251+
252+
public function testReturnsFalseOnValidationFailure(): void
253+
{
254+
// Subclass with strict validation rules that the test data fails.
255+
$model = new class ($this->db) extends UserModel {
256+
protected $validationRules = [
257+
'email' => 'required|valid_email',
258+
'name' => 'required|min_length[50]',
259+
];
260+
};
261+
262+
$result = $model->firstOrInsert(
263+
['email' => 'not-a-valid-email'],
264+
['name' => 'Too Short'],
265+
);
266+
267+
$this->assertFalse($result);
268+
$this->dontSeeInDatabase('user', ['email' => 'not-a-valid-email']);
269+
$this->assertNotEmpty($model->errors());
270+
}
271+
}

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ Model
168168
=====
169169

170170
- Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks.
171+
- Added new ``firstOrInsert()`` method to ``CodeIgniter\Model`` that finds the first row matching the given attributes or inserts a new one. See :ref:`model-first-or-insert`.
171172

172173
Libraries
173174
=========

0 commit comments

Comments
 (0)