From c49da587ac1b6b40a22f8caa7988ec640bbe7377 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 25 Feb 2026 19:55:09 +0100 Subject: [PATCH 1/5] feat: add Model::firstOrInsert() --- system/Model.php | 47 +++ .../system/Models/FirstOrInsertModelTest.php | 271 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 4 + user_guide_src/source/models/model.rst | 35 +++ user_guide_src/source/models/model/065.php | 7 + user_guide_src/source/models/model/066.php | 7 + 6 files changed, 371 insertions(+) create mode 100644 tests/system/Models/FirstOrInsertModelTest.php create mode 100644 user_guide_src/source/models/model/065.php create mode 100644 user_guide_src/source/models/model/066.php diff --git a/system/Model.php b/system/Model.php index 790da4fe56c7..df5f98f75897 100644 --- a/system/Model.php +++ b/system/Model.php @@ -19,6 +19,7 @@ use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\InvalidArgumentException; @@ -715,6 +716,52 @@ protected function doProtectFieldsForInsert(array $row): array return $row; } + /** + * Finds the first row matching attributes or inserts a new row. + * + * Note: without a DB unique constraint, this is not race-safe. + * + * @param array|object $attributes + * @param array|object $values + */ + public function firstOrInsert(array|object $attributes, array|object $values = []): array|object|null + { + if (is_object($attributes)) { + $attributes = $this->transformDataToArray($attributes, 'insert'); + } + + if ($attributes === []) { + throw new InvalidArgumentException('firstOrInsert() requires non-empty $attributes.'); + } + + $row = $this->where($attributes)->first(); + if ($row !== null) { + return $row; + } + + if (is_object($values)) { + $values = $this->transformDataToArray($values, 'insert'); + } + + $data = array_merge($attributes, $values); + + try { + $id = $this->insert($data); + } catch (UniqueConstraintViolationException) { + return $this->where($attributes)->first(); + } + + if ($id === false) { + if ($this->db->getLastException() instanceof UniqueConstraintViolationException) { + return $this->where($attributes)->first(); + } + + return null; + } + + return $this->where($this->primaryKey, $id)->first(); + } + public function update($id = null, $row = null): bool { if (isset($this->tempData['data'])) { diff --git a/tests/system/Models/FirstOrInsertModelTest.php b/tests/system/Models/FirstOrInsertModelTest.php new file mode 100644 index 000000000000..e8bcea7205ec --- /dev/null +++ b/tests/system/Models/FirstOrInsertModelTest.php @@ -0,0 +1,271 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; +use CodeIgniter\Exceptions\InvalidArgumentException; +use PHPUnit\Framework\Attributes\Group; +use ReflectionProperty; +use stdClass; +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class FirstOrInsertModelTest extends LiveModelTestCase +{ + protected function tearDown(): void + { + $this->enableDBDebug(); + parent::tearDown(); + } + + public function testReturnsExistingRecord(): void + { + $this->createModel(UserModel::class); + + $row = $this->model->firstOrInsert(['email' => 'derek@world.com']); + + $this->assertNotNull($row); + $this->assertSame('Derek Jones', $row->name); + $this->assertSame('derek@world.com', $row->email); + $this->assertSame('US', $row->country); + } + + public function testDoesNotInsertWhenRecordExists(): void + { + $this->createModel(UserModel::class); + + $this->model->firstOrInsert(['email' => 'derek@world.com']); + + // Seeder inserts 4 users; calling firstOrInsert on an existing + // record must not add a fifth one. + $this->seeNumRecords(4, 'user', ['deleted_at' => null]); + } + + public function testValuesAreIgnoredWhenRecordExists(): void + { + $this->createModel(UserModel::class); + + // The $values array must not be used to modify the found record. + $row = $this->model->firstOrInsert( + ['email' => 'derek@world.com'], + ['name' => 'Should Not Change', 'country' => 'XX'], + ); + + $this->assertNotNull($row); + $this->assertSame('Derek Jones', $row->name); + $this->assertSame('US', $row->country); + } + + public function testInsertsNewRecordWhenNotFound(): void + { + $this->createModel(UserModel::class); + + $row = $this->model->firstOrInsert([ + 'name' => 'New User', + 'email' => 'new@example.com', + 'country' => 'US', + ]); + + $this->assertNotNull($row); + $this->assertSame('new@example.com', $row->email); + $this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]); + } + + public function testMergesValuesOnInsert(): void + { + $this->createModel(UserModel::class); + + $row = $this->model->firstOrInsert( + ['email' => 'new@example.com'], + ['name' => 'New User', 'country' => 'CA'], + ); + + $this->assertNotNull($row); + $this->assertSame('New User', $row->name); + $this->assertSame('CA', $row->country); + $this->seeInDatabase('user', [ + 'email' => 'new@example.com', + 'name' => 'New User', + 'country' => 'CA', + 'deleted_at' => null, + ]); + } + + public function testAcceptsObjectForValues(): void + { + $this->createModel(UserModel::class); + + $values = new stdClass(); + $values->name = 'Object User'; + $values->country = 'DE'; + + $row = $this->model->firstOrInsert( + ['email' => 'object@example.com'], + $values, + ); + + $this->assertNotNull($row); + $this->assertSame('Object User', $row->name); + $this->assertSame('DE', $row->country); + $this->seeInDatabase('user', ['email' => 'object@example.com', 'deleted_at' => null]); + } + + public function testAcceptsObjectForAttributes(): void + { + $this->createModel(UserModel::class); + + $attributes = new stdClass(); + $attributes->email = 'derek@world.com'; + + $row = $this->model->firstOrInsert($attributes); + + $this->assertNotNull($row); + $this->assertSame('Derek Jones', $row->name); + $this->seeNumRecords(4, 'user', ['deleted_at' => null]); + } + + public function testAcceptsObjectForAttributesAndInsertsWhenNotFound(): void + { + $this->createModel(UserModel::class); + + $attributes = new stdClass(); + $attributes->email = 'new@example.com'; + $attributes->name = 'New User'; + $attributes->country = 'US'; + + $row = $this->model->firstOrInsert($attributes); + + $this->assertNotNull($row); + $this->assertSame('new@example.com', $row->email); + $this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]); + } + + public function testThrowsOnEmptyAttributes(): void + { + $this->createModel(UserModel::class); + + $this->expectException(InvalidArgumentException::class); + $this->model->firstOrInsert([]); + } + + public function testHandlesRaceConditionWithDebugEnabled(): void + { + // Subclass that simulates a concurrent insert winning the race: + // doInsert() first persists the row (the "other process"), then + // throws UniqueConstraintViolationException as if our own attempt + // also tried to insert the same row. + $model = new class ($this->db) extends UserModel { + protected function doInsert(array $row): bool + { + parent::doInsert($row); + + throw new UniqueConstraintViolationException('Duplicate entry'); + } + }; + + $row = $model->firstOrInsert( + ['email' => 'race@example.com'], + ['name' => 'Race User', 'country' => 'US'], + ); + + $this->assertNotNull($row); + $this->assertSame('race@example.com', $row->email); + // The "other process" inserted exactly one record. + $this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]); + } + + public function testHandlesRaceConditionWithDebugDisabled(): void + { + $this->disableDBDebug(); + + // Subclass that simulates a concurrent insert: the "other process" + // inserts via a direct DB call, then our own attempt fails with a + // unique violation which is stored in lastException (DBDebug=false). + $model = new class ($this->db) extends UserModel { + protected function doInsert(array $row): bool + { + // Direct insert – bypasses the model so it won't interfere + // with the model's own builder state. + $this->db->table($this->table)->insert([ + 'name' => $row['name'], + 'email' => $row['email'], + 'country' => $row['country'], + ]); + + // The real insert now fails; the driver stores + // UniqueConstraintViolationException in lastException. + return parent::doInsert($row); + } + }; + + $row = $model->firstOrInsert( + ['email' => 'race@example.com'], + ['name' => 'Race User', 'country' => 'US'], + ); + + $this->assertNotNull($row); + $this->assertSame('race@example.com', $row->email); + $this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]); + } + + public function testReturnsNullOnNonUniqueErrorWithDebugDisabled(): void + { + $this->disableDBDebug(); + + // Subclass that simulates a non-unique database error by placing + // a plain DatabaseException (not UniqueConstraintViolationException) + // into lastException and returning false. + $model = new class ($this->db) extends UserModel { + protected function doInsert(array $row): bool + { + $prop = new ReflectionProperty($this->db, 'lastException'); + $prop->setValue($this->db, new DatabaseException('Connection error')); + + return false; + } + }; + + $result = $model->firstOrInsert( + ['email' => 'error@example.com'], + ['name' => 'Error User', 'country' => 'US'], + ); + + $this->assertNull($result); + $this->dontSeeInDatabase('user', ['email' => 'error@example.com']); + } + + public function testReturnsNullOnValidationFailure(): void + { + // Subclass with strict validation rules that the test data fails. + $model = new class ($this->db) extends UserModel { + protected $validationRules = [ + 'email' => 'required|valid_email', + 'name' => 'required|min_length[50]', + ]; + }; + + $result = $model->firstOrInsert( + ['email' => 'not-a-valid-email'], + ['name' => 'Too Short'], + ); + + $this->assertNull($result); + $this->dontSeeInDatabase('user', ['email' => 'not-a-valid-email']); + $this->assertNotEmpty($model->errors()); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 958546e464fe..e6c3afe36db8 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -168,6 +168,10 @@ Model ===== - Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks. +- Added new ``firstOrInsert()`` method to ``CodeIgniter\Model`` that finds the first row matching the given + attributes or inserts a new one. Uses an optimistic insert strategy to avoid race conditions, relying on + :php:class:`UniqueConstraintViolationException ` + to detect concurrent inserts. Libraries ========= diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 01418cc396d0..29fbea115388 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -633,6 +633,41 @@ model's ``save()`` method to inspect the class, grab any public and private prop .. note:: If you find yourself working with Entities a lot, CodeIgniter provides a built-in :doc:`Entity class ` that provides several handy features that make developing Entities simpler. +.. _model-first-or-insert: + +firstOrInsert() +--------------- + +.. versionadded:: 4.8.0 + +Finds the first row matching the given ``$attributes``, or inserts a new row +combining ``$attributes`` and ``$values`` when no match is found. + +Both parameters accept an array, a ``stdClass`` object, or an +:doc:`Entity `: + +.. literalinclude:: model/065.php + +``$attributes`` is used as the WHERE condition for the lookup. If no record is +found, a new row is inserted using the merged result of ``$attributes`` and +``$values``. The ``$values`` data is only applied during insertion and is +ignored when a matching record already exists. + +.. literalinclude:: model/066.php + +The method returns the found or newly inserted row in the format defined by +`$returnType`_, or ``null`` on failure (e.g., validation error or database +error when ``DBDebug`` is ``false``). + +.. note:: A database **unique constraint** on the lookup column(s) is required + for the method to be race-safe. Without it, two concurrent requests could + both pass the initial lookup and attempt to insert, resulting in duplicate + rows. + + When a unique constraint is present, a concurrent insert is detected via + :php:class:`UniqueConstraintViolationException ` + and resolved automatically by performing a second lookup. + .. _model-saving-dates: Saving Dates diff --git a/user_guide_src/source/models/model/065.php b/user_guide_src/source/models/model/065.php new file mode 100644 index 000000000000..a2a80b81d844 --- /dev/null +++ b/user_guide_src/source/models/model/065.php @@ -0,0 +1,7 @@ +firstOrInsert( + ['email' => 'john@example.com'], + ['name' => 'John Doe', 'country' => 'US'], +); diff --git a/user_guide_src/source/models/model/066.php b/user_guide_src/source/models/model/066.php new file mode 100644 index 000000000000..e7baee4ad993 --- /dev/null +++ b/user_guide_src/source/models/model/066.php @@ -0,0 +1,7 @@ +email = 'john@example.com'; + +$user = $userModel->firstOrInsert($attrs, ['name' => 'John Doe', 'country' => 'US']); From 2ffd53578bfb79f96d8cfe82f498cb7fe0454683 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 26 Feb 2026 08:43:04 +0100 Subject: [PATCH 2/5] refactor and return false on failure --- system/Model.php | 10 ++++----- .../system/Models/FirstOrInsertModelTest.php | 22 +++++++++---------- user_guide_src/source/changelogs/v4.8.0.rst | 5 +---- user_guide_src/source/models/model.rst | 2 +- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/system/Model.php b/system/Model.php index df5f98f75897..559aff118996 100644 --- a/system/Model.php +++ b/system/Model.php @@ -724,7 +724,7 @@ protected function doProtectFieldsForInsert(array $row): array * @param array|object $attributes * @param array|object $values */ - public function firstOrInsert(array|object $attributes, array|object $values = []): array|object|null + public function firstOrInsert(array|object $attributes, array|object $values = []): array|object|false { if (is_object($attributes)) { $attributes = $this->transformDataToArray($attributes, 'insert'); @@ -748,18 +748,18 @@ public function firstOrInsert(array|object $attributes, array|object $values = [ try { $id = $this->insert($data); } catch (UniqueConstraintViolationException) { - return $this->where($attributes)->first(); + return $this->where($attributes)->first() ?? false; } if ($id === false) { if ($this->db->getLastException() instanceof UniqueConstraintViolationException) { - return $this->where($attributes)->first(); + return $this->where($attributes)->first() ?? false; } - return null; + return false; } - return $this->where($this->primaryKey, $id)->first(); + return $this->where($this->primaryKey, $id)->first() ?? false; } public function update($id = null, $row = null): bool diff --git a/tests/system/Models/FirstOrInsertModelTest.php b/tests/system/Models/FirstOrInsertModelTest.php index e8bcea7205ec..e29bcb4fd676 100644 --- a/tests/system/Models/FirstOrInsertModelTest.php +++ b/tests/system/Models/FirstOrInsertModelTest.php @@ -66,7 +66,7 @@ public function testValuesAreIgnoredWhenRecordExists(): void ['name' => 'Should Not Change', 'country' => 'XX'], ); - $this->assertNotNull($row); + $this->assertNotFalse($row); $this->assertSame('Derek Jones', $row->name); $this->assertSame('US', $row->country); } @@ -81,7 +81,7 @@ public function testInsertsNewRecordWhenNotFound(): void 'country' => 'US', ]); - $this->assertNotNull($row); + $this->assertNotFalse($row); $this->assertSame('new@example.com', $row->email); $this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]); } @@ -95,7 +95,7 @@ public function testMergesValuesOnInsert(): void ['name' => 'New User', 'country' => 'CA'], ); - $this->assertNotNull($row); + $this->assertNotFalse($row); $this->assertSame('New User', $row->name); $this->assertSame('CA', $row->country); $this->seeInDatabase('user', [ @@ -119,7 +119,7 @@ public function testAcceptsObjectForValues(): void $values, ); - $this->assertNotNull($row); + $this->assertNotFalse($row); $this->assertSame('Object User', $row->name); $this->assertSame('DE', $row->country); $this->seeInDatabase('user', ['email' => 'object@example.com', 'deleted_at' => null]); @@ -134,7 +134,7 @@ public function testAcceptsObjectForAttributes(): void $row = $this->model->firstOrInsert($attributes); - $this->assertNotNull($row); + $this->assertNotFalse($row); $this->assertSame('Derek Jones', $row->name); $this->seeNumRecords(4, 'user', ['deleted_at' => null]); } @@ -183,7 +183,7 @@ protected function doInsert(array $row): bool ['name' => 'Race User', 'country' => 'US'], ); - $this->assertNotNull($row); + $this->assertNotFalse($row); $this->assertSame('race@example.com', $row->email); // The "other process" inserted exactly one record. $this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]); @@ -218,12 +218,12 @@ protected function doInsert(array $row): bool ['name' => 'Race User', 'country' => 'US'], ); - $this->assertNotNull($row); + $this->assertNotFalse($row); $this->assertSame('race@example.com', $row->email); $this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]); } - public function testReturnsNullOnNonUniqueErrorWithDebugDisabled(): void + public function testReturnsFalseOnNonUniqueErrorWithDebugDisabled(): void { $this->disableDBDebug(); @@ -245,11 +245,11 @@ protected function doInsert(array $row): bool ['name' => 'Error User', 'country' => 'US'], ); - $this->assertNull($result); + $this->assertFalse($result); $this->dontSeeInDatabase('user', ['email' => 'error@example.com']); } - public function testReturnsNullOnValidationFailure(): void + public function testReturnsFalseOnValidationFailure(): void { // Subclass with strict validation rules that the test data fails. $model = new class ($this->db) extends UserModel { @@ -264,7 +264,7 @@ public function testReturnsNullOnValidationFailure(): void ['name' => 'Too Short'], ); - $this->assertNull($result); + $this->assertFalse($result); $this->dontSeeInDatabase('user', ['email' => 'not-a-valid-email']); $this->assertNotEmpty($model->errors()); } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index e6c3afe36db8..abc8265e0e63 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -168,10 +168,7 @@ Model ===== - Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks. -- Added new ``firstOrInsert()`` method to ``CodeIgniter\Model`` that finds the first row matching the given - attributes or inserts a new one. Uses an optimistic insert strategy to avoid race conditions, relying on - :php:class:`UniqueConstraintViolationException ` - to detect concurrent inserts. +- 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`. Libraries ========= diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 29fbea115388..f84a36dab9f0 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -656,7 +656,7 @@ ignored when a matching record already exists. .. literalinclude:: model/066.php The method returns the found or newly inserted row in the format defined by -`$returnType`_, or ``null`` on failure (e.g., validation error or database +`$returnType`_, or ``false`` on failure (e.g., validation error or database error when ``DBDebug`` is ``false``). .. note:: A database **unique constraint** on the lookup column(s) is required From 011ed365af4321c04ddb3fa0cc54696fb45b4fad Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 27 Feb 2026 20:57:49 +0100 Subject: [PATCH 3/5] cs fix --- system/Model.php | 2 +- user_guide_src/source/models/model/066.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/system/Model.php b/system/Model.php index 559aff118996..190e06abbd2e 100644 --- a/system/Model.php +++ b/system/Model.php @@ -724,7 +724,7 @@ protected function doProtectFieldsForInsert(array $row): array * @param array|object $attributes * @param array|object $values */ - public function firstOrInsert(array|object $attributes, array|object $values = []): array|object|false + public function firstOrInsert(array|object $attributes, array|object $values = []): array|false|object { if (is_object($attributes)) { $attributes = $this->transformDataToArray($attributes, 'insert'); diff --git a/user_guide_src/source/models/model/066.php b/user_guide_src/source/models/model/066.php index e7baee4ad993..f24b54f6ac81 100644 --- a/user_guide_src/source/models/model/066.php +++ b/user_guide_src/source/models/model/066.php @@ -1,7 +1,7 @@ email = 'john@example.com'; $user = $userModel->firstOrInsert($attrs, ['name' => 'John Doe', 'country' => 'US']); From 15c2b5aa4f0c490fafed190bc124d1c1802c01a8 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 27 Feb 2026 21:19:16 +0100 Subject: [PATCH 4/5] fix phpstan --- system/BaseModel.php | 2 ++ system/Model.php | 2 ++ tests/system/Models/FirstOrInsertModelTest.php | 18 +++++++++--------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/system/BaseModel.php b/system/BaseModel.php index d6d89d6b36cc..34f62ac60357 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -18,6 +18,7 @@ use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; use CodeIgniter\Database\Query; use CodeIgniter\Database\RawSql; use CodeIgniter\DataCaster\Cast\CastInterface; @@ -869,6 +870,7 @@ protected function validateID(mixed $id, bool $allowArray = true): void * @return ($returnID is true ? false|int|string : bool) * * @throws ReflectionException + * @throws UniqueConstraintViolationException */ public function insert($row = null, bool $returnID = true) { diff --git a/system/Model.php b/system/Model.php index 190e06abbd2e..259885a14987 100644 --- a/system/Model.php +++ b/system/Model.php @@ -723,6 +723,8 @@ protected function doProtectFieldsForInsert(array $row): array * * @param array|object $attributes * @param array|object $values + * + * @return array|false|object */ public function firstOrInsert(array|object $attributes, array|object $values = []): array|false|object { diff --git a/tests/system/Models/FirstOrInsertModelTest.php b/tests/system/Models/FirstOrInsertModelTest.php index e29bcb4fd676..61a68f2aad22 100644 --- a/tests/system/Models/FirstOrInsertModelTest.php +++ b/tests/system/Models/FirstOrInsertModelTest.php @@ -39,7 +39,7 @@ public function testReturnsExistingRecord(): void $row = $this->model->firstOrInsert(['email' => 'derek@world.com']); - $this->assertNotNull($row); + $this->assertIsObject($row); $this->assertSame('Derek Jones', $row->name); $this->assertSame('derek@world.com', $row->email); $this->assertSame('US', $row->country); @@ -66,7 +66,7 @@ public function testValuesAreIgnoredWhenRecordExists(): void ['name' => 'Should Not Change', 'country' => 'XX'], ); - $this->assertNotFalse($row); + $this->assertIsObject($row); $this->assertSame('Derek Jones', $row->name); $this->assertSame('US', $row->country); } @@ -81,7 +81,7 @@ public function testInsertsNewRecordWhenNotFound(): void 'country' => 'US', ]); - $this->assertNotFalse($row); + $this->assertIsObject($row); $this->assertSame('new@example.com', $row->email); $this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]); } @@ -95,7 +95,7 @@ public function testMergesValuesOnInsert(): void ['name' => 'New User', 'country' => 'CA'], ); - $this->assertNotFalse($row); + $this->assertIsObject($row); $this->assertSame('New User', $row->name); $this->assertSame('CA', $row->country); $this->seeInDatabase('user', [ @@ -119,7 +119,7 @@ public function testAcceptsObjectForValues(): void $values, ); - $this->assertNotFalse($row); + $this->assertIsObject($row); $this->assertSame('Object User', $row->name); $this->assertSame('DE', $row->country); $this->seeInDatabase('user', ['email' => 'object@example.com', 'deleted_at' => null]); @@ -134,7 +134,7 @@ public function testAcceptsObjectForAttributes(): void $row = $this->model->firstOrInsert($attributes); - $this->assertNotFalse($row); + $this->assertIsObject($row); $this->assertSame('Derek Jones', $row->name); $this->seeNumRecords(4, 'user', ['deleted_at' => null]); } @@ -150,7 +150,7 @@ public function testAcceptsObjectForAttributesAndInsertsWhenNotFound(): void $row = $this->model->firstOrInsert($attributes); - $this->assertNotNull($row); + $this->assertIsObject($row); $this->assertSame('new@example.com', $row->email); $this->seeInDatabase('user', ['email' => 'new@example.com', 'deleted_at' => null]); } @@ -183,7 +183,7 @@ protected function doInsert(array $row): bool ['name' => 'Race User', 'country' => 'US'], ); - $this->assertNotFalse($row); + $this->assertIsObject($row); $this->assertSame('race@example.com', $row->email); // The "other process" inserted exactly one record. $this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]); @@ -218,7 +218,7 @@ protected function doInsert(array $row): bool ['name' => 'Race User', 'country' => 'US'], ); - $this->assertNotFalse($row); + $this->assertIsObject($row); $this->assertSame('race@example.com', $row->email); $this->seeNumRecords(1, 'user', ['email' => 'race@example.com', 'deleted_at' => null]); } From 88b9756f88a549a1b1b59902bdcc075b2e83cfb8 Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 28 Feb 2026 10:43:30 +0100 Subject: [PATCH 5/5] add a comment to the code example --- user_guide_src/source/models/model/065.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/user_guide_src/source/models/model/065.php b/user_guide_src/source/models/model/065.php index a2a80b81d844..a310c239daba 100644 --- a/user_guide_src/source/models/model/065.php +++ b/user_guide_src/source/models/model/065.php @@ -5,3 +5,15 @@ ['email' => 'john@example.com'], ['name' => 'John Doe', 'country' => 'US'], ); + +// The above will trigger: +// +// 1) First it tries to find the record: +// SELECT * FROM `users` WHERE `email` = 'john@example.com' LIMIT 1; +// +// 2) If no result is found, it inserts a new record: +// INSERT INTO `users` (`email`, `name`, `country`) +// VALUES ('john@example.com', 'John Doe', 'US'); +// +// 3) Then it returns the found or newly created entity/row, +// or false if something went wrong