-
Notifications
You must be signed in to change notification settings - Fork 11.3k
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
[12.x] Various URL generation bugfixes #54811
base: 12.x
Are you sure you want to change the base?
Conversation
Thanks for submitting a PR! Note that draft PR's are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface. Pull requests that are abandoned in draft may be closed due to inactivity. |
This method turns a list of passed parameters into a list of *named* parameters (where possible) reducing ambiguity and making other code work more accurately with positional route parameters, especially when URL::defaults() is involved. This commit also resolves a known bug -- removing a 'mark skipped' mark for a test.
f9e2aa7
to
2ebfa38
Compare
// by parameters that have default values and are | ||
// therefore optional, or their values have been | ||
// provided as key-value pairs by the developer. | ||
if (count($parameters) == count($routeParametersWithoutDefaultsOrNamedParameters)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could use $passedParameterCount
here
if (count($parameters) == count($routeParametersWithoutDefaultsOrNamedParameters)) { | |
if ($passedParameterCount == count($routeParametersWithoutDefaultsOrNamedParameters)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case it's using the count of $parameters
after some values are removed. It isn't $passedParameterCount
and switching to that makes tests fail.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, okay—thanks for the explanation 👍🏻 Nevermind
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's why I back up the original count of the passed parameters. It might help to rename $parameters
to something like $remainingParameters
or $positionalParameters
later to make this more clear.
* | ||
* @param Route $route | ||
* @param mixed $parameters | ||
* @return array |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we define this a little more precise?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We cannot. URL generator:
/**
* Get the URL for a given route instance.
*
* @param \Illuminate\Routing\Route $route
* @param mixed $parameters
* @param bool $absolute
* @return string
*
* @throws \Illuminate\Routing\Exceptions\UrlGenerationException
*/
public function toRoute($route, $parameters, $absolute)
{
return $this->routeUrl()->to(
$route, $parameters, $absolute
);
}
$parameters
are mixed
. They're passed without any transformations to routeUrl()->to()
, which is:
/**
* Generate a URL for the given route.
*
* @param \Illuminate\Routing\Route $route
* @param array $parameters
* @param bool $absolute
* @return string
*
* @throws \Illuminate\Routing\Exceptions\UrlGenerationException
*/
public function to($route, $parameters = [], $absolute = false)
{
$parameters = $this->formatParameters($route, $parameters);
$domain = $this->getRouteDomain($route, $parameters);
Here the $parameters
docblock technically says array
is required but in practice mixed
is passed from the URL generator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't mean the $parameters
param but the formatParams()
return value. Sorry for the confusion
Fixes #54796.
This PR addresses several issues in the route URL generator by adding a mechanism for turning
[$positional, $parameters]
into['named' => $parameters]
. The logic is a little complex with some notable edge cases so I've included an extensive number of assertions (that's the bulk of the diff) testing all edge case scenarios I could think of.I realize the added method for mapping positional parameters to their respective names is quite complex and may seem error-prone, but while working on this I've become confident this is the right approach here. There are many assumptions made in the URL generation logic about parameters being associative arrays, despite the API not requiring it. Code along the lines of
route('profile', $user)
is very common, so complex cases should be handled correctly as well.While originally reporting #54796 I've noticed a bunch of other bugs, all of which are perfectly solved by turning positional parameters into named parameters. On top of that, there was a TODO for a past bug, marking a test as skipped (I've confirmed it fails on 12.x) which passes with my changes:
framework/tests/Routing/RoutingUrlGeneratorTest.php
Lines 431 to 456 in 5645879
Implementation details
The added
formatParameters()
method essentially works like this:$namedParameters
and track parameters that don't have a default value OR a named parameter (i.e. required parameters)$parameters
(modified above) and move any remaining parameters with string values into$namedQueryParameters
as these parameters do not exist on the route>=
would work but the current implementation reads better) than the total number of route parameters, we know the parameter list may include positional query strings (?foo
) so we can't do the reverse mapping. On the other hand, we for sure have enough parameters to cover all route parameters, so we just match them in normal order.This PR tries to centralize logic related to handling URL generation to routes in the
RouteUrlGenerator
, while not moving too much stuff around to minimize breaking changes. The logic may seem quite long and complex, but this way issues are prevented upfront rather than each method in this class having to worry about all possible edge cases.The code likely requires some cleanup, maybe some variables renamed, comments aren't perfectly aligned etc, but it should be good enough for a review now.
Defaults overriding passed parameters
If
URL::defaults()
is used, and parameters are passed "positionally" rather than with names, this issue occurs:Using the default value for the {user} parameter instead of the provided one and pushing the user parameter to the query string.
This is because methods such as:
Expect named parameters. Here the first check fails because the parameters are passed as
[0]
and[1]
, so it uses the second branch — the default — for the parameter, turning the passed positional parameters into query string parameters.Binding fields breaking defaults
The issue comes from UrlGenerator::toRoute():
The default parameter only gets used in
$this->routeUrl()
(RouteUrlGenerator
), but by that point theUrlGenerator
has broken the provided parameters, passing a[null]
array.This PR fixes this issue by converting positional arguments into named ones. That at first look fixes the bug described here but there's actually a second bug — we actually don't want to use the default value for
'foo'
because we are using a{foo:slug}
parameter. Those can have different values — that's the point of binding fields, you don't overridegetRouteKeyName()
, you can set this on a per-route basis.The next section addresses this.
URL::defaults() support for binding fields
This PR adds support for:
This is done in the same method that handles converting positional arguments to named parameters. In other words, in the RouteUrlGenerator. That means this won't work in e.g.
replaceRootParameters()
but honestly I'm not sure if that'd be worth adding even more complexity to this PR.Resolve existing bug (stop skipping a test)
The test mentioned above:
framework/tests/Routing/RoutingUrlGeneratorTest.php
Lines 431 to 456 in 5645879
Now passes since it's essentially a subset of what I'm testing in the added tests. Notable difference is that it doesn't deal with
defaults()
at all. I'll also note that I'm taking a very different approach to solving the bug in the test than the original approaches (to my understanding) and only noticed that the bug is fixed after trying to un-skip the test. This makes me think converting positional parameters to named ones really is the right solution to all these seemingly unrelated bugs in the URL generation logic.Related PRs: #42942 #43255. Any feedback from @driesvints or @ksassnowski would be greatly appreciated.
Finally I'll say that I do know this is a big PR, however it's:
I came up with the mechanism by going over all the edge cases I could think of and tweaking the code until it seemed to work right. Then I added tests for those edge cases and all were passing.
So while this is a PR adding complex logic to an area of the codebase that was bug-prone in previous PRs (see above), I'm fairly confident in this approach and don't think it should cause any issues. If it does cause some issues in edge cases I didn't think of, hopefully it can be addressed by just updating the logic in the method I added, rather than any large scale changes or reverts.