Skip to content

Commit c49da58

Browse files
committed
feat: add Model::firstOrInsert()
1 parent de063a8 commit c49da58

File tree

6 files changed

+371
-0
lines changed

6 files changed

+371
-0
lines changed

system/Model.php

Lines changed: 47 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,52 @@ 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+
public function firstOrInsert(array|object $attributes, array|object $values = []): array|object|null
728+
{
729+
if (is_object($attributes)) {
730+
$attributes = $this->transformDataToArray($attributes, 'insert');
731+
}
732+
733+
if ($attributes === []) {
734+
throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.');
735+
}
736+
737+
$row = $this->where($attributes)->first();
738+
if ($row !== null) {
739+
return $row;
740+
}
741+
742+
if (is_object($values)) {
743+
$values = $this->transformDataToArray($values, 'insert');
744+
}
745+
746+
$data = array_merge($attributes, $values);
747+
748+
try {
749+
$id = $this->insert($data);
750+
} catch (UniqueConstraintViolationException) {
751+
return $this->where($attributes)->first();
752+
}
753+
754+
if ($id === false) {
755+
if ($this->db->getLastException() instanceof UniqueConstraintViolationException) {
756+
return $this->where($attributes)->first();
757+
}
758+
759+
return null;
760+
}
761+
762+
return $this->where($this->primaryKey, $id)->first();
763+
}
764+
718765
public function update($id = null, $row = null): bool
719766
{
720767
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->assertNotNull($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->assertNotNull($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->assertNotNull($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->assertNotNull($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->assertNotNull($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->assertNotNull($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->assertNotNull($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->assertNotNull($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->assertNotNull($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 testReturnsNullOnNonUniqueErrorWithDebugDisabled(): 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->assertNull($result);
249+
$this->dontSeeInDatabase('user', ['email' => 'error@example.com']);
250+
}
251+
252+
public function testReturnsNullOnValidationFailure(): 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->assertNull($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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ 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
172+
attributes or inserts a new one. Uses an optimistic insert strategy to avoid race conditions, relying on
173+
:php:class:`UniqueConstraintViolationException <CodeIgniter\\Database\\Exceptions\\UniqueConstraintViolationException>`
174+
to detect concurrent inserts.
171175

172176
Libraries
173177
=========

user_guide_src/source/models/model.rst

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,41 @@ model's ``save()`` method to inspect the class, grab any public and private prop
633633
.. note:: If you find yourself working with Entities a lot, CodeIgniter provides a built-in :doc:`Entity class </models/entities>`
634634
that provides several handy features that make developing Entities simpler.
635635

636+
.. _model-first-or-insert:
637+
638+
firstOrInsert()
639+
---------------
640+
641+
.. versionadded:: 4.8.0
642+
643+
Finds the first row matching the given ``$attributes``, or inserts a new row
644+
combining ``$attributes`` and ``$values`` when no match is found.
645+
646+
Both parameters accept an array, a ``stdClass`` object, or an
647+
:doc:`Entity </models/entities>`:
648+
649+
.. literalinclude:: model/065.php
650+
651+
``$attributes`` is used as the WHERE condition for the lookup. If no record is
652+
found, a new row is inserted using the merged result of ``$attributes`` and
653+
``$values``. The ``$values`` data is only applied during insertion and is
654+
ignored when a matching record already exists.
655+
656+
.. literalinclude:: model/066.php
657+
658+
The method returns the found or newly inserted row in the format defined by
659+
`$returnType`_, or ``null`` on failure (e.g., validation error or database
660+
error when ``DBDebug`` is ``false``).
661+
662+
.. note:: A database **unique constraint** on the lookup column(s) is required
663+
for the method to be race-safe. Without it, two concurrent requests could
664+
both pass the initial lookup and attempt to insert, resulting in duplicate
665+
rows.
666+
667+
When a unique constraint is present, a concurrent insert is detected via
668+
:php:class:`UniqueConstraintViolationException <CodeIgniter\\Database\\Exceptions\\UniqueConstraintViolationException>`
669+
and resolved automatically by performing a second lookup.
670+
636671
.. _model-saving-dates:
637672

638673
Saving Dates
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
// Find by email, or insert with additional data.
4+
$user = $userModel->firstOrInsert(
5+
['email' => 'john@example.com'],
6+
['name' => 'John Doe', 'country' => 'US'],
7+
);

0 commit comments

Comments
 (0)