-
Notifications
You must be signed in to change notification settings - Fork 1.4k
PHPORM-139 Implement Model::createOrFirst()
using findOneAndUpdate
operation
#2742
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,9 +7,12 @@ | |
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\Laravel\Internal\FindAndModifyCommandSubscriber; | ||
use MongoDB\Model\BSONDocument; | ||
|
||
use function array_intersect_key; | ||
use function array_key_exists; | ||
use function array_merge; | ||
use function collect; | ||
|
@@ -183,6 +186,41 @@ public function raw($value = null) | |
return $results; | ||
} | ||
|
||
/** | ||
* 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); | ||
$values = $instance->getAttributes(); | ||
$attributes = array_intersect_key($attributes, $values); | ||
|
||
return $this->raw(function (Collection $collection) use ($attributes, $values) { | ||
$listener = new FindAndModifyCommandSubscriber(); | ||
$collection->getManager()->addSubscriber($listener); | ||
|
||
try { | ||
$document = $collection->findOneAndUpdate( | ||
$attributes, | ||
['$setOnInsert' => $values], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since Just to confirm, what prevents a user from using query syntax in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, I didn't know the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
If I try to use an empty update, the error is:
I could add a ternary condition to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That could be fixed with an explicit
Also addressable with an explicit object cast on the value; however, it's secondary to the issue above so not worth looking into. |
||
['upsert' => true, 'new' => true, 'typeMap' => ['root' => 'array', 'document' => 'array']], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
); | ||
} finally { | ||
$collection->getManager()->removeSubscriber($listener); | ||
} | ||
|
||
$model = $this->model->newFromBuilder($document); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How does this method differ from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
$model->wasRecentlyCreated = $listener->created; | ||
|
||
return $model; | ||
}); | ||
} | ||
|
||
/** | ||
* Add the "updated at" column to an array of values. | ||
* TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace MongoDB\Laravel\Internal; | ||
|
||
use MongoDB\Driver\Monitoring\CommandFailedEvent; | ||
use MongoDB\Driver\Monitoring\CommandStartedEvent; | ||
use MongoDB\Driver\Monitoring\CommandSubscriber; | ||
use MongoDB\Driver\Monitoring\CommandSucceededEvent; | ||
|
||
/** | ||
* Track findAndModify command events to detect when a document is inserted or | ||
* updated. | ||
* | ||
* @internal | ||
*/ | ||
final class FindAndModifyCommandSubscriber implements CommandSubscriber | ||
{ | ||
public bool $created; | ||
|
||
public function commandFailed(CommandFailedEvent $event) | ||
{ | ||
} | ||
|
||
public function commandStarted(CommandStartedEvent $event) | ||
{ | ||
} | ||
|
||
public function commandSucceeded(CommandSucceededEvent $event) | ||
{ | ||
$this->created = ! $event->getReply()->lastErrorObject->updatedExisting; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1044,4 +1044,43 @@ public function testNumericFieldName(): void | |
$this->assertInstanceOf(User::class, $found); | ||
$this->assertEquals([3 => 'two.three'], $found[2]); | ||
} | ||
|
||
public function testCreateOrFirst() | ||
{ | ||
$user1 = User::createOrFirst(['email' => '[email protected]']); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure where these addresses came from but we'd do well to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comes from Laravel's test suite. https://github.com/laravel/framework/blob/a518ef66bebd04c97fa1f04cb3a450795cc3ca11/tests/Database/DatabaseEloquentIntegrationTest.php#L527 |
||
|
||
$this->assertSame('[email protected]', $user1->email); | ||
$this->assertNull($user1->name); | ||
$this->assertTrue($user1->wasRecentlyCreated); | ||
|
||
$user2 = User::createOrFirst( | ||
['email' => '[email protected]'], | ||
['name' => 'Taylor Otwell', 'birthday' => new DateTime('1987-05-28')], | ||
); | ||
|
||
$this->assertEquals($user1->id, $user2->id); | ||
$this->assertSame('[email protected]', $user2->email); | ||
$this->assertNull($user2->name); | ||
$this->assertNull($user2->birthday); | ||
$this->assertFalse($user2->wasRecentlyCreated); | ||
|
||
$user3 = User::createOrFirst( | ||
['email' => '[email protected]'], | ||
['name' => 'Abigail Otwell', 'birthday' => new DateTime('1987-05-28')], | ||
); | ||
|
||
$this->assertNotEquals($user3->id, $user1->id); | ||
$this->assertSame('[email protected]', $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'], | ||
['name' => 'Nuno Maduro', 'email' => '[email protected]'], | ||
); | ||
|
||
$this->assertSame('Nuno Maduro', $user4->name); | ||
$this->assertTrue($user4->wasRecentlyCreated); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When doing
$values + $attributes
, preference is given to an existing key in$values
. In that case, it seems possible that actual criteria in$attributes
might be lost.Is there a particular reason you didn't do
$attributes + $values
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd say the behavior is not well defined if there is a key duplicate between
$attributes
and$values
. If we look at the implementation in Laravel, it tries to create with anarray_merge($attributes, $values)
, fails if there is a uniqueness constraint in DB and tries to find using$attributes
.I used this merge order
$values + $attribute === array_merge($attributes, $values)
to please the unit test that I imported.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sounds like a possible Laravel bug if you want to pursue that.