diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 22dcfd081..96675d509 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -18,6 +18,7 @@ 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; @@ -216,41 +217,10 @@ 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); + $instance = $this->newModelInstance(array_merge($attributes, $values)); + 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->save(['find_or_insert' => $attributes]); } /** diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index 5974e49e1..b86dadf3f 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -746,6 +746,35 @@ protected function isBSON(mixed $value): bool */ public function save(array $options = []) { + // create_or_first contains the list of fields to check for duplicates + if (isset($options['create_or_first'])) { + $query = $this->newModelQuery(); + + // 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 false; + } + + // If the model already exists in the database we can just update our record + // that is already in this database using the current IDs in this "where" + // clause to only update this model. Otherwise, we'll just insert them. + if ($this->exists) { + $saved = $this->isDirty() ? + $this->performUpdate($query) : true; + $instance = $this; + } else { + ['inserted' => $inserted, 'document' => $document] = $this->performFindOrInsert($query, $options['create_or_first']); + if ($inserted) { + $keyName = $this->getKeyName(); + $this->setAttribute($keyName, $document[$keyName]); + + return $this; + } + } + } + $saved = parent::save($options); // Clear list of unset fields @@ -766,4 +795,53 @@ public function refresh() return $this; } + + private function performFindOrInsert(Builder $query, array $filter) + { + if ($this->usesUniqueIds()) { + $this->setUniqueIds(); + } + + if ($this->fireModelEvent('creating') === false) { + return false; + } + + // 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(); + + if ($this->getIncrementing()) { + $this->insertAndSetId($query, $attributes); + } + + // If the table isn't incrementing we'll simply insert these attributes as they + // are. These attribute arrays must contain an "id" column previously placed + // there by the developer as the manually determined key for these models. + else { + if (empty($attributes)) { + return true; + } + + $query->insert($attributes); + } + + // We will go ahead and set the exists property to true, so that it is set when + // the created event is fired, just in case the developer tries to update it + // during the event. This will allow them to do so and run an update here. + $this->exists = true; + + $this->wasRecentlyCreated = true; + + $this->fireModelEvent('created', false); + + return true; + } } diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 89faa4b17..a321334f6 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,41 @@ public function update(array $values, array $options = []) return $this->performUpdate($values, $options); } + /** + * @internal while not stabilized + * + * @return array{inserted:bool, document:array} + */ + public function firstOrInsert(array $values, array $options = []) + { + $options = $this->inheritConnectionOptions($options); + $wheres = $this->compileWheres(); + + $listener = new FindAndModifyCommandSubscriber(); + $this->collection->getManager()->addSubscriber($listener); + + try { + $document = $this->collection->findOneAndUpdate( + $wheres, + // 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'], + ] + $options, + ); + } finally { + $this->collection->getManager()->removeSubscriber($listener); + } + + return [ + 'inserted' => $listener->created, + 'document' => $document, + ]; + } + /** @inheritdoc */ public function increment($column, $amount = 1, array $extra = [], array $options = []) { diff --git a/tests/ModelTest.php b/tests/ModelTest.php index baa731799..d6af73676 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -1117,6 +1117,11 @@ public function testUpdateOrCreate(array $criteria) Carbon::setTestNow('2010-01-01'); $createdAt = Carbon::now()->getTimestamp(); + $eventUsers = []; + User::created(function (User $user) use ($eventUsers) { + $eventUsers[] = $user; + }); + // Create $user = User::updateOrCreate( $criteria, @@ -1127,6 +1132,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->assertCount(1, $eventUsers); + $this->assertSame($user, $eventUsers[0]); Carbon::setTestNow('2010-02-01'); $updatedAt = Carbon::now()->getTimestamp();