From 168f34b6a8e0a20aeb63e70ce10c751a0d964dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 4 Mar 2024 19:44:35 +0100 Subject: [PATCH 1/4] PHPORM-139 Implement Model::createOrFirst using findOneAndUpdate operation --- src/Eloquent/Builder.php | 18 ++++++++++++++++++ tests/ModelTest.php | 41 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index b9005c442..b881c5d83 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -7,9 +7,11 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use MongoDB\Driver\Cursor; +use MongoDB\Laravel\Collection; use MongoDB\Laravel\Helpers\QueriesRelationships; use MongoDB\Model\BSONDocument; +use function array_intersect_key; use function array_key_exists; use function array_merge; use function collect; @@ -183,6 +185,22 @@ public function raw($value = null) return $results; } + public function createOrFirst(array $attributes = [], array $values = []) + { + // Apply casting and default values to the attributes + $instance = $this->newModelInstance($values + $attributes); + $values = $instance->getAttributes(); + $attributes = array_intersect_key($attributes, $values); + + return $this->raw(function (Collection $collection) use ($attributes, $values) { + return $collection->findOneAndUpdate( + $attributes, + ['$setOnInsert' => $values], + ['upsert' => true, 'new' => true], + ); + }); + } + /** * Add the "updated at" column to an array of values. * TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e diff --git a/tests/ModelTest.php b/tests/ModelTest.php index ec1579869..bbbb8236e 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -45,7 +45,7 @@ class ModelTest extends TestCase { public function tearDown(): void { - User::truncate(); + User::raw(fn(Collection $collection) => $collection->drop()); Soft::truncate(); Book::truncate(); Item::truncate(); @@ -1044,4 +1044,43 @@ public function testNumericFieldName(): void $this->assertInstanceOf(User::class, $found); $this->assertEquals([3 => 'two.three'], $found[2]); } + + public function testCreateOrFirst() + { + // Create index unique on "email" and "name" + User::raw(fn (Collection $collection) => $collection->createIndex(['email' => 1], ['unique' => true])); + User::raw(fn (Collection $collection) => $collection->createIndex(['name' => 1], ['unique' => true])); + + $user1 = User::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = User::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell', 'birthday' => new DateTime('1987-05-28')], + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + $this->assertNull($user2->birthday); + + $user3 = User::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell', 'birthday' => new DateTime('1987-05-28')], + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + $this->assertEquals(new DateTime('1987-05-28'), $user3->birthday); + + $user4 = User::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'], + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } } From 5ffe58a5961716c4e7e8caa16ddfa050281644d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 6 Mar 2024 15:31:10 +0100 Subject: [PATCH 2/4] Use monitoring to track if model was created or found --- src/Eloquent/Builder.php | 32 +++++++++++++---- .../FindAndModifyCommandSubscriber.php | 34 +++++++++++++++++++ tests/ModelTest.php | 4 +++ 3 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 src/Internal/FindAndModifyCommandSubscriber.php diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index b881c5d83..6ef960456 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -9,6 +9,7 @@ use MongoDB\Driver\Cursor; use MongoDB\Laravel\Collection; use MongoDB\Laravel\Helpers\QueriesRelationships; +use MongoDB\Laravel\Internal\FindAndModifyCommandSubscriber; use MongoDB\Model\BSONDocument; use function array_intersect_key; @@ -185,7 +186,14 @@ public function raw($value = null) return $results; } - public function createOrFirst(array $attributes = [], array $values = []) + /** + * Attempt to create the record if it does not exist with the matching attributes. + * If the record exists, it will be returned. + * + * @param array $attributes The attributes to check for duplicate records + * @param array $values The attributes to insert if no matching record is found + */ + public function createOrFirst(array $attributes = [], array $values = []): Model { // Apply casting and default values to the attributes $instance = $this->newModelInstance($values + $attributes); @@ -193,11 +201,23 @@ public function createOrFirst(array $attributes = [], array $values = []) $attributes = array_intersect_key($attributes, $values); return $this->raw(function (Collection $collection) use ($attributes, $values) { - return $collection->findOneAndUpdate( - $attributes, - ['$setOnInsert' => $values], - ['upsert' => true, 'new' => true], - ); + $listener = new FindAndModifyCommandSubscriber(); + $collection->getManager()->addSubscriber($listener); + + try { + $document = $collection->findOneAndUpdate( + $attributes, + ['$setOnInsert' => $values], + ['upsert' => true, 'new' => true, 'typeMap' => ['root' => 'array', 'document' => 'array']], + ); + } finally { + $collection->getManager()->removeSubscriber($listener); + } + + $model = $this->model->newFromBuilder($document); + $model->wasRecentlyCreated = $listener->created; + + return $model; }); } diff --git a/src/Internal/FindAndModifyCommandSubscriber.php b/src/Internal/FindAndModifyCommandSubscriber.php new file mode 100644 index 000000000..51fcb5ba5 --- /dev/null +++ b/src/Internal/FindAndModifyCommandSubscriber.php @@ -0,0 +1,34 @@ +created = ! $event->getReply()->lastErrorObject->updatedExisting; + } +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php index bbbb8236e..2fb7623d5 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -1055,6 +1055,7 @@ public function testCreateOrFirst() $this->assertSame('taylorotwell@gmail.com', $user1->email); $this->assertNull($user1->name); + $this->assertTrue($user1->wasRecentlyCreated); $user2 = User::createOrFirst( ['email' => 'taylorotwell@gmail.com'], @@ -1065,6 +1066,7 @@ public function testCreateOrFirst() $this->assertSame('taylorotwell@gmail.com', $user2->email); $this->assertNull($user2->name); $this->assertNull($user2->birthday); + $this->assertFalse($user2->wasRecentlyCreated); $user3 = User::createOrFirst( ['email' => 'abigailotwell@gmail.com'], @@ -1075,6 +1077,7 @@ public function testCreateOrFirst() $this->assertSame('abigailotwell@gmail.com', $user3->email); $this->assertSame('Abigail Otwell', $user3->name); $this->assertEquals(new DateTime('1987-05-28'), $user3->birthday); + $this->assertTrue($user3->wasRecentlyCreated); $user4 = User::createOrFirst( ['name' => 'Dries Vints'], @@ -1082,5 +1085,6 @@ public function testCreateOrFirst() ); $this->assertSame('Nuno Maduro', $user4->name); + $this->assertTrue($user4->wasRecentlyCreated); } } From 1bdcc24f9444e239f71217b980012eac6ab8501e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 7 Mar 2024 15:28:49 +0100 Subject: [PATCH 3/4] Update changelog --- CHANGELOG.md | 1 + src/Internal/FindAndModifyCommandSubscriber.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2263ac29d..a8c2cefc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [unreleased] * Add support for Laravel 11 by @GromNaN in [#2735](https://github.com/mongodb/laravel-mongodb/pull/2735) +* Implement Model::createOrFirst() using findOneAndUpdate operation by @GromNaN in [#2742](https://github.com/mongodb/laravel-mongodb/pull/2742) ## [4.1.3] - 2024-03-05 diff --git a/src/Internal/FindAndModifyCommandSubscriber.php b/src/Internal/FindAndModifyCommandSubscriber.php index 51fcb5ba5..55b13436b 100644 --- a/src/Internal/FindAndModifyCommandSubscriber.php +++ b/src/Internal/FindAndModifyCommandSubscriber.php @@ -15,7 +15,7 @@ * * @internal */ -class FindAndModifyCommandSubscriber implements CommandSubscriber +final class FindAndModifyCommandSubscriber implements CommandSubscriber { public bool $created; From 571d80ba3573870fceb0b0da0dd075b266c640d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 7 Mar 2024 15:31:08 +0100 Subject: [PATCH 4/4] Unique index is not necessary --- tests/ModelTest.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 2fb7623d5..f4d459422 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -45,7 +45,7 @@ class ModelTest extends TestCase { public function tearDown(): void { - User::raw(fn(Collection $collection) => $collection->drop()); + User::truncate(); Soft::truncate(); Book::truncate(); Item::truncate(); @@ -1047,10 +1047,6 @@ public function testNumericFieldName(): void public function testCreateOrFirst() { - // Create index unique on "email" and "name" - User::raw(fn (Collection $collection) => $collection->createIndex(['email' => 1], ['unique' => true])); - User::raw(fn (Collection $collection) => $collection->createIndex(['name' => 1], ['unique' => true])); - $user1 = User::createOrFirst(['email' => 'taylorotwell@gmail.com']); $this->assertSame('taylorotwell@gmail.com', $user1->email);