[9.x] Fix bug in BelongsToMany where non-related rows are returned #42087
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
This PR is preceded by #41881 and #42049 .
Since Laravel 9.0, the methods
firstOrNew()
,firstOrCreate()
andupdateOrCreate()
received a slight update in behaviour (3e66eb7) where related instance existence is now determined using a clean$this->related->
query instead of the previous$this->
relation query implementation. This introduced an inconsistency in these methods in comparison to the otherBelongsToMany
methods, where a possibility is introduced that these 3 methods return (or update) a row that is not (was not, is not, will not be) related to the base model. This PR addresses this issue.First, this PR adds 3 tests that (as far as I understand Taylor's comment) describes why the changes in 3e66eb7 were made. This concerns the entirety of the three new tests, except the last assertion of each test. These assertions succeed in the current Laravel 9.* .
Note that this is based on my understanding of the issue, I have not found a reference to the actually issue back then. (Are any references available?)
Second, this PR contains 3 new assertions (the last one of each test mentioned here-above) that fail on the current Laravel 9.*, but which should probably succeed in order for these methods to be consistent with other similar methods. I think this at least is the case for
firstOrCreate()
andupdateOrCreate()
. I am not entirely sure onfirstOrNew()
, more on that below.Thirdly, this PR contains changes to the implementations of the 3 methods that make sure the 3 new assertions succeed.
Updated Motivation
Consider that any method on the
BelongsToMany
relationship returning a model, returns a model that is related to the base model. This was the case in Laravel 8.*, the three methods either:firstOrNew
, which cannotattach()
due to no primary key being available)Note that for the
*OrCreate
methods, we always end up in a state where the returned related model is actually related to the base model. Since Laravel 9.*, one new possibility is introduced; the three methods can:To my understanding, the calling site cannot distinguish this new situation from the first situation where an already existing related method is returned. Hence, to ensure in a mutating call site that the model from the related table really will be related to the base model, one should always manually call
attach()
, even if the row was in fact already related. In a retrieving call site, I am not sure what one could do when an existing non-related model is returned.firstOrCreate()
My understanding of
firstOrCreate
is that you will always receive a related model instance that already was related ('first') or is newly related ('create') to the base model. In case a new related record is created,attach()
is explicitly called. Hence I would assume that in case an existing unrelated model is returned, it should be explicitlyattach()
ed as well.firstOrNew()
Similar arguments hold for
firstOrNew
. Also consider for example the similar methodfindOrNew()
, which only returns existing models that are actually related (unlike the currentfirstOrNew()
implementation). However, compared tofirstOrCreate()
,firstOrNew()
is more nuanced:first
returns the first related model,OrNew
returns a new unsaved model that cannot be related since it is unsaved. In our new case (returning an existing unrelated model) one can follow two thoughts:first
OrNew
As not attaching leads to the call site ending up with no indication whether the model is actually attached or not, I would vote for consistency with
first
here.Do note however, that when
attach()
ing here, we have no access to$joining
and$touch
variables like we do in the other method.updateOrCreate
Consider that
$model->relation()->update()
will always only update rows that are related. Changing it into$model->relation()->updateOrCreate()
introduces the possibility that (without clear indication) a row is being updated that is not related to the model at all. One could argue that also in this case (similar tofirstOrCreate()
), the implementation should automaticallyattach()
a related model if it is not related already.Alternatives
While I am convinced that the three proposed tests indicate a problem in behaviour, I am not convinced that my proposed implementation is actually the best improvement.
One objection would be, it adds a potential extra query to be executed. This could be circumvented by performing a query on
$this->related->
and adding an extra select which determines whether the row is related or not. However, this might add a little too much complexity.Looking at all different cases here, I ultimately feel that perhaps the new behaviour of returning (and attaching) unrelated rows should probably deserve their own dedicated methods, likefirstOrCreateAndAttach()
andupdateOrCreateAndAttach()
or something like that, leaving the existingOr
methods like they were in Laravel 8.*. However, I do understand that might be a matter of taste.Conclusion
Long story short, I am concerned that these three methods currently violate the invariant that all similar BelongsToMany methods always act on related data, and these three now do not behave in this same way. I can see what the intention behind the change might have been, but maybe that should have required a different solution? Currently I feel the behaviour can be rather unexpected, especially in some edge cases that are not unlikely to occur in applications. It could lead to hard-to-find bugs and/or vulnerabilities. I certainly do not want to push through any opinion of mine, but I would appreciate if you would give this issue a good thought, to make sure these methods (as seen as part of the greater whole) are evolving in the right direction.