Skip to content
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

PHPORM-139 Implement Model::createOrFirst() using findOneAndUpdate operation #2742

Merged
merged 4 commits into from
Mar 11, 2024

Conversation

GromNaN
Copy link
Member

@GromNaN GromNaN commented Mar 4, 2024

Fix PHPORM-139
Fix #2720

This may be considered as a new feature and merged into 4.2 instead of 4.1

Laravel v10.20 came out with a brand-new method called createOrFirst().
Learn more about this method laravel/framework#48567: https://laravel-news.com/firstorcreate-vs-createorfirst

The createOfFirst algorithm is:

try {
    // insert the new document
} catch (UniqueException) {
    // find the document from criteria
}

This is precisely what we can do in a single command with findOneAndUpdate.

Unlike Eloquent's implementation, we don't rely on the "unique" index constraint, and just use the $attribute array as criteria.

Checklist

  • Add tests and ensure they pass
  • Add an entry to the CHANGELOG.md file
  • Update documentation for new features

@GromNaN GromNaN changed the title PHPORM-139 Implement Model::createOrFirst() using findOneAndUpdate operation PHPORM-139 Implement Model::createOrFirst() using findOneAndUpdate operation Mar 5, 2024
@GromNaN GromNaN changed the base branch from 4.1 to 4.2 March 6, 2024 10:16
@GromNaN GromNaN force-pushed the PHPORM-139 branch 3 times, most recently from 1ca7d46 to 0eec67b Compare March 6, 2024 14:41
@GromNaN GromNaN marked this pull request as ready for review March 7, 2024 14:27
@GromNaN GromNaN requested a review from a team as a code owner March 7, 2024 14:27
@GromNaN GromNaN requested review from alcaeus and jmikola March 7, 2024 14:27
@GromNaN GromNaN added this to the 4.2 milestone Mar 8, 2024
@GromNaN GromNaN merged commit 19fc801 into mongodb:4.2 Mar 11, 2024
23 checks passed
@GromNaN GromNaN deleted the PHPORM-139 branch March 11, 2024 09:08

public function testCreateOrFirst()
{
$user1 = User::createOrFirst(['email' => 'taylorotwell@gmail.com']);
Copy link
Member

Choose a reason for hiding this comment

The 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 @example.com in email data fixtures.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public function createOrFirst(array $attributes = [], array $values = []): Model
{
// Apply casting and default values to the attributes
$instance = $this->newModelInstance($values + $attributes);
Copy link
Member

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?

Copy link
Member Author

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 an array_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.

Copy link
Member

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.

try {
$document = $collection->findOneAndUpdate(
$attributes,
['$setOnInsert' => $values],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since $values is over-written above, its value here includes the union of fields from the $attributes and $values parameters. This is a bit redundant, since upserts already apply the criteria to a new document, but I suppose it makes no functional difference.

Just to confirm, what prevents a user from using query syntax in the $attributes parameter? It'd logically work for executing findOneAndUpdate(), but I imagine it wouldn't play nice with Builder::newModelInstance().

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I didn't know the $filter argument was used to create the document, good to know.

Just to confirm, what prevents a user from using query syntax in the $attributes parameter? It'd logically work for executing findOneAndUpdate(), but I imagine it wouldn't play nice with Builder::newModelInstance().

$parameters and $values are supposed to be used to create a Model instance. Using a query operator would be incorrect.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If $values is empty, I get this server error:

MongoDB\Driver\Exception\CommandException: Modifiers operate on fields but we found type array instead. For example: {$mod: {: ...}} not {$setOnInsert: []}

If I try to use an empty update, the error is:

MongoDB\Exception\InvalidArgumentException: Expected update operator(s) or non-empty pipeline for $update

I could add a ternary condition to use $attribute as fallback if $values is empty, but I do prefer to be explicit and merge all values all the time.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifiers operate on fields but we found type array instead.

That could be fixed with an explicit (object) cast on the $setOnInsert value. Note that server versions prior to 5.0 would still error if that object is empty (this is noted in the $setOnInsert docs).

Expected update operator(s) or non-empty pipeline for $update

Also addressable with an explicit object cast on the value; however, it's secondary to the issue above so not worth looking into.

$document = $collection->findOneAndUpdate(
$attributes,
['$setOnInsert' => $values],
['upsert' => true, 'new' => true, 'typeMap' => ['root' => 'array', 'document' => 'array']],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new is an option on the internal FindAndModify operation. Since this is using FindOneAndUpdate, the correct option to use would be returnDocument with a value of FindOneAndUpdate::RETURN_DOCUMENT_AFTER.

$collection->getManager()->removeSubscriber($listener);
}

$model = $this->model->newFromBuilder($document);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this method differ from Builder::newModelInstance()? It looks like both are used to construct new model instances from "raw" document data.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Eloquent\Builder::newModelInstance() applies cast on the attributes. Values comes from the developer.
  • Eloquent\Model::newFromBuilder() uses data from the database and doesn't apply changes.

alcaeus added a commit that referenced this pull request Mar 13, 2024
* 4.2:
  PHPORM-139 Implement `Model::createOrFirst()` using `findOneAndUpdate` operation (#2742)
  Test Laravel 10 and 11 (#2746)
  PHPORM-150 Run CI on Laravel 11 (#2735)
  PHPORM-152 Fix tests for Carbon 3 (#2733)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Transaction already in progress
3 participants