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

[10.x] HotFix: throw captured UniqueConstraintViolationException if there are no matching records on SELECT retry #48234

Merged
merged 2 commits into from
Aug 30, 2023

Conversation

mpyw
Copy link
Contributor

@mpyw mpyw commented Aug 30, 2023

There is an issue with createOrFirst up to version v10.21.0. This causes the following problems when createOrFirst() returns NULL.

  • firstOrCreate() also inadvertently returns NULL.
  • updateOrCreate() causes an error because it tries to access the ->wasRecentlyCreated property on NULL.

The latter is particularly critical, so if possible, we would like you to release it as a hot fix in version v10.21.1. Please consider it.

@fuwasegu
Copy link
Contributor

fuwasegu commented Aug 30, 2023

Looks like a good solution
thanks!

@mpyw mpyw marked this pull request as ready for review August 30, 2023 06:14
@mpyw mpyw changed the title HotFix: throw captured UniqueConstraintViolationException HotFix: throw captured UniqueConstraintViolationException if there are no matching records on SELECT retry Aug 30, 2023
@mpyw
Copy link
Contributor Author

mpyw commented Aug 30, 2023

@taylorotwell @driesvints @tonysm Please check it out 😃

@mpyw mpyw changed the title HotFix: throw captured UniqueConstraintViolationException if there are no matching records on SELECT retry [10.x] HotFix: throw captured UniqueConstraintViolationException if there are no matching records on SELECT retry Aug 30, 2023
@mpyw
Copy link
Contributor Author

mpyw commented Aug 30, 2023

Failing tests look to be irrelevant. Ready to merge.

@tonysm
Copy link
Contributor

tonysm commented Aug 30, 2023

@mpyw if it got it right, this isn't "fixing" the problem, right? It's more like letting the error throw so the developer knows about the edge case, which I guess it makes sense. I didn't think about this scenario as I was working on this feature. 🤔

@tonysm
Copy link
Contributor

tonysm commented Aug 30, 2023

And the previous implementation of updateOrCreate would also fail in this scenario, right? A unique constraint violation would happen and a QueryException would be thrown when it tried to save the record.

@mpyw
Copy link
Contributor Author

mpyw commented Aug 30, 2023

@tonysm Thank you for your comment!

Firstly, it's important to clarify that a QueryException was correctly thrown for unique constraint violations on the previous updateOrCreate implementation. However, with the recent implementation, silently returning NULL led to an unintended Attempt to read property "wasRecentlyCreated" on null error in updateOrCreate, which is a significant issue.

While you mentioned that this change seems to be just aiding the developer's debug process, it is crucial in the context of a framework providing functionality to web applications. The correct behavior here involves the code either succeeding or throwing a UniqueConstraintViolationException, rather than leading to an unintended null error.

Furthermore, it is worth noting that the UniqueConstraintViolationException is treated like a LogicException in some contexts, while there may be cases where we want to treat it like a RuntimeException. For instance, my application was initially designed to potentially encounter unique constraint errors with the non-unique $values in updateOrCreate, and this was part of the regular flow. Encountering a different, all-encompassing error instead of the designed UniqueConstraintViolationException in such a case poses a significant problem.

In conclusion, while it may appear that this change merely aids in debugging, it is a critical adjustment that prevents unintended behavior and enables developers to handle unique constraint violations more appropriately in various scenarios.

@taylorotwell taylorotwell merged commit 60c1330 into laravel:10.x Aug 30, 2023
17 of 19 checks passed
@mpyw mpyw deleted the fix-create-or-first-exception branch August 30, 2023 13:57
@mpyw
Copy link
Contributor Author

mpyw commented Aug 30, 2023

@taylorotwell Thanks for merging!

@mpyw
Copy link
Contributor Author

mpyw commented Aug 30, 2023

@tonysm I think I might understand a bit of what you're saying. Are you suggesting that, in the case of two or more independent unique keys (not composite unique keys), if one of them causes a unique constraint error, we should use the attribute that caused the error as a key to retrieve the existing record?

Indeed, there might be useful cases for this, but attempting to address this would make the operation extremely complex and would likely require changing the method signature to accept groups of $attributes. I am a colleague of @fuwasegu, the person who raised the issue, and we did not consider such cases. I believe it is acceptable to consider this issue out of scope for the current response.

@tonysm
Copy link
Contributor

tonysm commented Aug 30, 2023

@mpyw I get it that the unique violation can be used programatically.

I'm trying to think if we better switch the createOrFirst to do a firstOrFail instead of first and let it throw a ModelNotFoundException instead of UniqueViolationException` (as you did in your recent closed PR).

I think this would signal better that the record couldn't be found using the given attributes.

But this wouldn't solve the issue in updateOrCreate, at least not in a BC way, since the ModelNotFoundException is not a subclass of the QueryException.

@mpyw
Copy link
Contributor Author

mpyw commented Aug 30, 2023

@tonysm Using ModelNotFoundException as it indeed seems suitable for certain data inconsistency situations, such as the one addressed in PR #48161 with the ->useWritePdo() method. However, as I've rightly noted, that specific issue has already been resolved and should no longer occur.

Given this, I believe it's more appropriate in this context to rethrow the UniqueConstraintViolationException caused by the attributes listed in $values.

@fuwasegu
Copy link
Contributor

@tonysm
Thank you for engaging in a discussion to improve the issue I reported!

I don't have any objections to the changes that @mpyw has made in this PR.

The flow here is:

  1. Try to create a record using $attributes and $values -> if successful, return it.
  2. If a Unique Key error prevents creation, we assume a record already exists and try to fetch and return it.

You argue that since the final operation is a 'SELECT', the appropriate exception to throw would be ModelNotFoundException. I understand that perspective.

However, this behavior contradicts what a developer might expect. They would assume that the retrieval is based on $attributes failing the unique constraint, while in reality, it's $values that's causing the conflict.
It is counterintuitive to search using $attributes and get no result when it appears that $attributes violates the unique key constraint. The real reason for this is that $values violates the unique constraint.

Thus, the root cause of the SELECT resulting in NULL is a Unique Key violation. So, shouldn't developers be dealing with UniqueViolationException instead?

@tonysm
Copy link
Contributor

tonysm commented Aug 30, 2023

I'm not convinced that rethrowing the UniqueConstraintViolationException is the best way to go here. The dev would expect the method to handle unique violations, not throw it back at them. Passing unique values instead of attributes isn't the expected behavior. This is an edge case, and from the perspective of createOrFirst, I think it would make more sense to throw a ModelNotFoundException (since that's really what happened; it didn't find a record using the unique attributes even though a unique violation occurred). Maybe the unique constraint could be the "previous" exception to that.

But, as I said, the issue with the updateOrCreate is different; it expects a model to be returned, but it gets null instead. Again, this was caused by the edge case of passing a unique field as values instead of in the attributes list. In that case, I don't know what we could throw. ModelNotFoundException doesn't make sense because you're telling it to update or create, but UniqueConstraintViolationException also doesn't. In this case, either exception would work.

However, it was merged, so it's okay.

@trevorgehman
Copy link
Contributor

They would assume that the retrieval is based on $attributes failing the unique constraint, while in reality, it's $values that's causing the conflict.
It is counterintuitive to search using $attributes and get no result when it appears that $attributes violates the unique key constraint. The real reason for this is that $values violates the unique constraint.

This is exactly the situation we encountered when our test suite failed updating to 10.21.0. We were getting type errors because firstOrCreate suddenly started returning null. The reason was we had a unique constraint on a column in the values list and were listening for a QueryException to check if it was caused by the constraint. But then that exception stopped being thrown.

I think throwing the UniqueConstraintViolationException exception is the right choice. Just my $0.02.

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.

Unexpected NULL in createOrFirst() with Multiple Unique Constraints
5 participants