diff --git a/CHANGELOG.md b/CHANGELOG.md index bb318f301..521b820ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. * Add `mongodb` driver for Batching by @GromNaN in [#2904](https://github.com/mongodb/laravel-mongodb/pull/2904) * Rename queue option `table` to `collection` * Replace queue option `expire` with `retry_after` +* Trigger events in `Model::createOrFirst()` @GromNaN in [#2980](https://github.com/mongodb/laravel-mongodb/pull/2980) ## [4.3.1] diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 678e7095b..8a18a99e1 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -8,16 +8,13 @@ use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use InvalidArgumentException; use MongoDB\Driver\Cursor; -use MongoDB\Laravel\Collection; use MongoDB\Laravel\Helpers\QueriesRelationships; -use MongoDB\Laravel\Internal\FindAndModifyCommandSubscriber; use MongoDB\Laravel\Query\AggregationBuilder; use MongoDB\Model\BSONDocument; -use MongoDB\Operation\FindOneAndUpdate; -use function array_intersect_key; use function array_key_exists; use function array_merge; +use function assert; use function collect; use function is_array; use function iterator_to_array; @@ -218,40 +215,9 @@ public function createOrFirst(array $attributes = [], array $values = []): Model // Apply casting and default values to the attributes // In case of duplicate key between the attributes and the values, the values have priority $instance = $this->newModelInstance($values + $attributes); + assert($instance instanceof Model); - /* @see \Illuminate\Database\Eloquent\Model::performInsert */ - if ($instance->usesTimestamps()) { - $instance->updateTimestamps(); - } - - $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, - // Before MongoDB 5.0, $setOnInsert requires a non-empty document. - // This should not be an issue as $values includes the query filter. - ['$setOnInsert' => (object) $values], - [ - 'upsert' => true, - 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, - 'typeMap' => ['root' => 'array', 'document' => 'array'], - ], - ); - } finally { - $collection->getManager()->removeSubscriber($listener); - } - - $model = $this->model->newFromBuilder($document); - $model->wasRecentlyCreated = $listener->created; - - return $model; - }); + return $instance->saveOrFirst($attributes); } /** diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index f7b4f1f36..b29fac3a1 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -5,6 +5,7 @@ namespace MongoDB\Laravel\Eloquent; use BackedEnum; +use BadMethodCallException; use Carbon\CarbonInterface; use DateTimeInterface; use DateTimeZone; @@ -31,6 +32,7 @@ use function array_merge; use function array_unique; use function array_values; +use function assert; use function class_basename; use function count; use function date_default_timezone_get; @@ -759,6 +761,71 @@ public function save(array $options = []) return $saved; } + /** @internal Not part of Laravel Eloquent API. Use raw findOneAndModify if necessary */ + public function saveOrFirst(array $criteria): ?static + { + $this->mergeAttributesFromCachedCasts(); + + $query = $this->newModelQuery(); + assert($query instanceof Builder); + + // If the "saving" event returns false we'll bail out of the save and return + // false, indicating that the save failed. This provides a chance for any + // listeners to cancel save operations if validations fail or whatever. + if ($this->fireModelEvent('saving') === false) { + return null; + } + + if ($this->exists) { + throw new BadMethodCallException(sprintf('%s can be used on new model instances only', __FUNCTION__)); + } + + if ($this->usesUniqueIds()) { + $this->setUniqueIds(); + } + + if ($this->fireModelEvent('creating') === false) { + return null; + } + + // First we'll need to create a fresh query instance and touch the creation and + // update timestamps on this model, which are maintained by us for developer + // convenience. After, we will just continue saving these model instances. + if ($this->usesTimestamps()) { + $this->updateTimestamps(); + } + + // If the model has an incrementing key, we can use the "insertGetId" method on + // the query builder, which will give us back the final inserted ID for this + // table from the database. Not all tables have to be incrementing though. + $attributes = $this->getAttributesForInsert(); + + $document = $query->getQuery() + ->where($criteria) + ->insertOrFirst($attributes); + + $connection = $query->getConnection(); + if (! $this->getConnectionName() && $connection) { + $this->setConnection($connection->getName()); + } + + // If a document matching the criteria was found, it is returned. Nothing was saved. + if (! $document['wasRecentlyCreated']) { + return $this->newInstance($document); + } + + // If the model is successfully saved, we need to do a few more things once + // that is done. We will call the "saved" method here to run any actions + // we need to happen after a model gets successfully saved right here. + $this->exists = true; + $this->wasRecentlyCreated = true; + $this->setAttribute($this->getKeyName(), $document[$this->getKeyName()]); + $this->fireModelEvent('created', false); + $this->finishSave([]); + + return $this; + } + /** * {@inheritDoc} */ diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 89faa4b17..3bf576f8b 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -23,6 +23,8 @@ use MongoDB\BSON\UTCDateTime; use MongoDB\Builder\Stage\FluentFactoryTrait; use MongoDB\Driver\Cursor; +use MongoDB\Laravel\Internal\FindAndModifyCommandSubscriber; +use MongoDB\Operation\FindOneAndUpdate; use Override; use RuntimeException; @@ -725,6 +727,32 @@ public function update(array $values, array $options = []) return $this->performUpdate($values, $options); } + public function insertOrFirst(array $document): array + { + $wheres = $this->compileWheres(); + $listener = new FindAndModifyCommandSubscriber(); + + try { + $this->collection->getManager()->addSubscriber($listener); + $document = $this->collection->findOneAndUpdate( + $wheres, + // Before MongoDB 5.0, $setOnInsert requires a non-empty document. + ['$setOnInsert' => (object) $document], + [ + 'upsert' => true, + 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, + 'typeMap' => ['root' => 'array', 'document' => 'array'], + ], + ); + } finally { + $this->collection->getManager()->removeSubscriber($listener); + } + + $document['wasRecentlyCreated'] = $listener->created; + + return $document; + } + /** @inheritdoc */ public function increment($column, $amount = 1, array $extra = [], array $options = []) { diff --git a/tests/ModelTest.php b/tests/ModelTest.php index ead5847ca..9ac399210 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -1052,6 +1052,9 @@ public function testCreateOrFirst() { Carbon::setTestNow('2010-06-22'); $createdAt = Carbon::now()->getTimestamp(); + $events = []; + self::registerModelEvents(User::class, $events); + $user1 = User::createOrFirst(['email' => 'john.doe@example.com']); $this->assertSame('john.doe@example.com', $user1->email); @@ -1059,8 +1062,10 @@ public function testCreateOrFirst() $this->assertTrue($user1->wasRecentlyCreated); $this->assertEquals($createdAt, $user1->created_at->getTimestamp()); $this->assertEquals($createdAt, $user1->updated_at->getTimestamp()); + $this->assertEquals(['saving', 'creating', 'created', 'saved'], $events); Carbon::setTestNow('2020-12-28'); + $events = []; $user2 = User::createOrFirst( ['email' => 'john.doe@example.com'], ['name' => 'John Doe', 'birthday' => new DateTime('1987-05-28')], @@ -1073,7 +1078,9 @@ public function testCreateOrFirst() $this->assertFalse($user2->wasRecentlyCreated); $this->assertEquals($createdAt, $user1->created_at->getTimestamp()); $this->assertEquals($createdAt, $user1->updated_at->getTimestamp()); + $this->assertEquals(['saving', 'creating'], $events); + $events = []; $user3 = User::createOrFirst( ['email' => 'jane.doe@example.com'], ['name' => 'Jane Doe', 'birthday' => new DateTime('1987-05-28')], @@ -1086,7 +1093,9 @@ public function testCreateOrFirst() $this->assertTrue($user3->wasRecentlyCreated); $this->assertEquals($createdAt, $user1->created_at->getTimestamp()); $this->assertEquals($createdAt, $user1->updated_at->getTimestamp()); + $this->assertEquals(['saving', 'creating', 'created', 'saved'], $events); + $events = []; $user4 = User::createOrFirst( ['name' => 'Robert Doe'], ['name' => 'Maria Doe', 'email' => 'maria.doe@example.com'], @@ -1094,6 +1103,7 @@ public function testCreateOrFirst() $this->assertSame('Maria Doe', $user4->name); $this->assertTrue($user4->wasRecentlyCreated); + $this->assertEquals(['saving', 'creating', 'created', 'saved'], $events); } public function testCreateOrFirstRequiresFilter() @@ -1114,6 +1124,9 @@ public function testUpdateOrCreate(array $criteria) ['email' => 'john.doe@example.com'], ]); + $events = []; + self::registerModelEvents(User::class, $events); + Carbon::setTestNow('2010-01-01'); $createdAt = Carbon::now()->getTimestamp(); @@ -1127,6 +1140,8 @@ public function testUpdateOrCreate(array $criteria) $this->assertEquals(new DateTime('1987-05-28'), $user->birthday); $this->assertEquals($createdAt, $user->created_at->getTimestamp()); $this->assertEquals($createdAt, $user->updated_at->getTimestamp()); + $this->assertEquals(['saving', 'creating', 'created', 'saved'], $events); + $events = []; Carbon::setTestNow('2010-02-01'); $updatedAt = Carbon::now()->getTimestamp(); @@ -1142,6 +1157,7 @@ public function testUpdateOrCreate(array $criteria) $this->assertEquals(new DateTime('1990-01-12'), $user->birthday); $this->assertEquals($createdAt, $user->created_at->getTimestamp()); $this->assertEquals($updatedAt, $user->updated_at->getTimestamp()); + $this->assertEquals(['saving', 'saved'], $events); // Stored data $checkUser = User::where($criteria)->first(); @@ -1168,4 +1184,21 @@ public function testUpdateOrCreateWithNullId() ['email' => 'jane.doe@example.com'], ); } + + /** @param class-string $modelClass */ + private static function registerModelEvents(string $modelClass, array &$events): void + { + $modelClass::creating(function () use (&$events) { + $events[] = 'creating'; + }); + $modelClass::created(function () use (&$events) { + $events[] = 'created'; + }); + $modelClass::saving(function () use (&$events) { + $events[] = 'saving'; + }); + $modelClass::saved(function () use (&$events) { + $events[] = 'saved'; + }); + } }