diff --git a/src/Illuminate/Routing/RouteUrlGenerator.php b/src/Illuminate/Routing/RouteUrlGenerator.php index 7586288ec246..c82324363418 100644 --- a/src/Illuminate/Routing/RouteUrlGenerator.php +++ b/src/Illuminate/Routing/RouteUrlGenerator.php @@ -2,8 +2,11 @@ namespace Illuminate\Routing; +use BackedEnum; +use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Routing\Exceptions\UrlGenerationException; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; class RouteUrlGenerator { @@ -74,6 +77,8 @@ public function __construct($url, $request) */ public function to($route, $parameters = [], $absolute = false) { + $parameters = $this->formatParameters($route, $parameters); + $domain = $this->getRouteDomain($route, $parameters); // First we will construct the entire URI including the root and query string. Once it @@ -167,6 +172,95 @@ protected function addPortToDomain($domain) : $domain.':'.$port; } + /** + * Format the array of route parameters. + * + * @param \Illuminate\Routing\Route $route + * @param mixed $parameters + * @return array + */ + protected function formatParameters(Route $route, $parameters) + { + $parameters = Arr::wrap($parameters); + + $passedParameterCount = count($parameters); + + $namedParameters = []; + $namedQueryParameters = []; + $routeParametersWithoutDefaultsOrNamedParameters = []; + + $routeParameters = $route->parameterNames(); + + foreach ($routeParameters as $name) { + if (isset($parameters[$name])) { + // Named parameters don't need any special handling... + $namedParameters[$name] = $parameters[$name]; + unset($parameters[$name]); + + continue; + } elseif (! isset($this->defaultParameters[$name])) { + // No named parameter or default value, try to match to positional parameter below... + array_push($routeParametersWithoutDefaultsOrNamedParameters, $name); + } + + $namedParameters[$name] = ''; + } + + // Named parameters that don't have route parameters will be used for query string... + foreach ($parameters as $key => $value) { + if (is_string($key)) { + $namedQueryParameters[$key] = $value; + + unset($parameters[$key]); + } + } + + // Match positional parameters to the route parameters that didn't have a value in order... + if (count($parameters) == count($routeParametersWithoutDefaultsOrNamedParameters)) { + foreach (array_reverse($routeParametersWithoutDefaultsOrNamedParameters) as $name) { + if (count($parameters) === 0) { + break; + } + + $namedParameters[$name] = array_pop($parameters); + } + } + + // If there are extra parameters, just fill left to right... if not, fill right to left and try to use defaults... + $extraParameters = $passedParameterCount > count($routeParameters); + + foreach ($extraParameters ? $namedParameters : array_reverse($namedParameters) as $key => $value) { + $bindingField = $route->bindingFieldFor($key); + + $defaultParameterKey = $bindingField ? "$key:$bindingField" : $key; + + if ($value !== '') { + continue; + } elseif (! empty($parameters)) { + $namedParameters[$key] = $extraParameters ? array_shift($parameters) : array_pop($parameters); + } elseif (isset($this->defaultParameters[$defaultParameterKey])) { + $namedParameters[$key] = $this->defaultParameters[$defaultParameterKey]; + } + } + + // Any remaining values in $parameters are unnamed query string parameters... + $parameters = array_merge($namedParameters, $namedQueryParameters, $parameters); + + $parameters = Collection::wrap($parameters)->map(function ($value, $key) use ($route) { + return $value instanceof UrlRoutable && $route->bindingFieldFor($key) + ? $value->{$route->bindingFieldFor($key)} + : $value; + })->all(); + + array_walk_recursive($parameters, function (&$item) { + if ($item instanceof BackedEnum) { + $item = $item->value; + } + }); + + return $this->url->formatParameters($parameters); + } + /** * Replace the parameters on the root path. * diff --git a/src/Illuminate/Routing/UrlGenerator.php b/src/Illuminate/Routing/UrlGenerator.php index 3234d01f64dc..4808c1c0a89e 100755 --- a/src/Illuminate/Routing/UrlGenerator.php +++ b/src/Illuminate/Routing/UrlGenerator.php @@ -538,20 +538,8 @@ public function route($name, $parameters = [], $absolute = true) */ public function toRoute($route, $parameters, $absolute) { - $parameters = Collection::wrap($parameters)->map(function ($value, $key) use ($route) { - return $value instanceof UrlRoutable && $route->bindingFieldFor($key) - ? $value->{$route->bindingFieldFor($key)} - : $value; - })->all(); - - array_walk_recursive($parameters, function (&$item) { - if ($item instanceof BackedEnum) { - $item = $item->value; - } - }); - return $this->routeUrl()->to( - $route, $this->formatParameters($parameters), $absolute + $route, $parameters, $absolute ); } diff --git a/tests/Routing/RoutingUrlGeneratorTest.php b/tests/Routing/RoutingUrlGeneratorTest.php index cfce4d87962f..28bb67a49423 100755 --- a/tests/Routing/RoutingUrlGeneratorTest.php +++ b/tests/Routing/RoutingUrlGeneratorTest.php @@ -398,15 +398,8 @@ public function testRoutableInterfaceRoutingAsQueryString() $this->assertSame('/foo?foo=routable', $url->route('query-string', ['foo' => $model], false)); } - /** - * @todo Fix bug related to route keys - * - * @link https://github.com/laravel/framework/pull/42425 - */ public function testRoutableInterfaceRoutingWithSeparateBindingFieldOnlyForSecondParameter() { - $this->markTestSkipped('See https://github.com/laravel/framework/pull/43255'); - $url = new UrlGenerator( $routes = new RouteCollection, Request::create('http://www.foo.com/') @@ -956,6 +949,878 @@ public function testMissingNamedRouteResolution() $this->assertSame('test-url', $url->route('foo')); } + + public function testPassedParametersHavePrecedenceOverDefaults() + { + $url = new UrlGenerator( + $routes = new RouteCollection, + Request::create('https://www.foo.com/') + ); + + $url->defaults([ + 'tenant' => 'defaultTenant', + ]); + + $route = new Route(['GET'], 'bar/{tenant}/{post}', ['as' => 'bar', fn () => '']); + $routes->add($route); + + // Named parameters + $this->assertSame( + 'https://www.foo.com/bar/concreteTenant/concretePost', + $url->route('bar', [ + 'tenant' => tap(new RoutableInterfaceStub, fn ($x) => $x->key = 'concreteTenant'), + 'post' => tap(new RoutableInterfaceStub, fn ($x) => $x->key = 'concretePost'), + ]), + ); + + // Positional parameters + $this->assertSame( + 'https://www.foo.com/bar/concreteTenant/concretePost', + $url->route('bar', [ + tap(new RoutableInterfaceStub, fn ($x) => $x->key = 'concreteTenant'), + tap(new RoutableInterfaceStub, fn ($x) => $x->key = 'concretePost'), + ]), + ); + } + + public function testComplexRouteGenerationWithDefaultsAndBindingFields() + { + $url = new UrlGenerator( + $routes = new RouteCollection, + Request::create('https://www.foo.com/') + ); + + $url->defaults([ + 'tenant' => 'defaultTenant', + 'tenant:slug' => 'defaultTenantSlug', + 'team' => 'defaultTeam', + 'team:slug' => 'defaultTeamSlug', + 'user' => 'defaultUser', + 'user:slug' => 'defaultUserSlug', + ]); + + $keyParam = fn ($value) => tap(new RoutableInterfaceStub, fn ($routable) => $routable->key = $value); + $slugParam = fn ($value) => tap(new RoutableInterfaceStub, fn ($routable) => $routable->slug = $value); + + /** + * One parameter with a default value, one without a default value. + * + * No binding fields. + */ + $route = new Route(['GET'], 'tenantPost/{tenant}/{post}', ['as' => 'tenantPost', fn () => '']); + $routes->add($route); + + // tenantPost: Both parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPost/concreteTenant/concretePost', + $url->route('tenantPost', [$keyParam('concreteTenant'), $keyParam('concretePost')]), + ); + + // tenantPost: Both parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantPost/concreteTenant/concretePost', + $url->route('tenantPost', ['tenant' => $keyParam('concreteTenant'), 'post' => $keyParam('concretePost')]), + ); + + // tenantPost: Tenant (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPost/defaultTenant/concretePost', + $url->route('tenantPost', [$keyParam('concretePost')]), + ); + + // tenantPost: Tenant (with default) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/tenantPost/defaultTenant/concretePost', + $url->route('tenantPost', ['post' => $keyParam('concretePost')]), + ); + + /** + * One parameter with a default value, one without a default value. + * + * Binding field for the first {tenant} parameter with a default value. + */ + $route = new Route(['GET'], 'tenantSlugPost/{tenant:slug}/{post}', ['as' => 'tenantSlugPost', fn () => '']); + $routes->add($route); + + // tenantSlugPost: Both parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPost/concreteTenantSlug/concretePost', + $url->route('tenantSlugPost', [$slugParam('concreteTenantSlug'), $keyParam('concretePost')]), + ); + + // tenantSlugPost: Both parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantSlugPost/concreteTenantSlug/concretePost', + $url->route('tenantSlugPost', ['tenant' => $slugParam('concreteTenantSlug'), 'post' => $keyParam('concretePost')]), + ); + + // tenantSlugPost: Tenant (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPost/defaultTenantSlug/concretePost', + $url->route('tenantSlugPost', [$keyParam('concretePost')]), + ); + + // tenantSlugPost: Tenant (with default) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPost/defaultTenantSlug/concretePost', + $url->route('tenantSlugPost', ['post' => $keyParam('concretePost')]), + ); + + /** + * One parameter with a default value, one without a default value. + * + * Binding field for the second parameter without a default value. + * + * This is the only route in this test where we use a binding field + * for a parameter that does not have a default value and is not + * the first parameter. This is the simplest scenario so it doesn't + * need to be tested as repetitively as the other scenarios which are + * all special in some way. + */ + $route = new Route(['GET'], 'tenantPostSlug/{tenant}/{post:slug}', ['as' => 'tenantPostSlug', fn () => '']); + $routes->add($route); + + // tenantPostSlug: Both parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPostSlug/concreteTenant/concretePostSlug', + $url->route('tenantPostSlug', [$keyParam('concreteTenant'), $slugParam('concretePostSlug')]), + ); + + // tenantPostSlug: Both parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantPostSlug/concreteTenant/concretePostSlug', + $url->route('tenantPostSlug', ['tenant' => $keyParam('concreteTenant'), 'post' => $slugParam('concretePostSlug')]), + ); + + // tenantPostSlug: Tenant (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPostSlug/defaultTenant/concretePostSlug', + $url->route('tenantPostSlug', [$slugParam('concretePostSlug')]), + ); + + // tenantPostSlug: Tenant (with default) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/tenantPostSlug/defaultTenant/concretePostSlug', + $url->route('tenantPostSlug', ['post' => $slugParam('concretePostSlug')]), + ); + + /** + * Two parameters with a default value, one without. + * + * Having established that passing parameters by key works fine above, + * we mainly test positional parameters in variations of this route. + */ + $route = new Route(['GET'], 'tenantTeamPost/{tenant}/{team}/{post}', ['as' => 'tenantTeamPost', fn () => '']); + $routes->add($route); + + // tenantTeamPost: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantTeamPost/concreteTenant/concreteTeam/concretePost', + $url->route('tenantTeamPost', [$keyParam('concreteTenant'), $keyParam('concreteTeam'), $keyParam('concretePost')]), + ); + + // tenantTeamPost: Tenant (with default) omitted, team and post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantTeamPost/defaultTenant/concreteTeam/concretePost', + $url->route('tenantTeamPost', [$keyParam('concreteTeam'), $keyParam('concretePost')]), + ); + + // tenantTeamPost: Tenant and team (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantTeamPost/defaultTenant/defaultTeam/concretePost', + $url->route('tenantTeamPost', [$keyParam('concretePost')]), + ); + + // tenantTeamPost: Tenant passed by key, team (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantTeamPost/concreteTenant/defaultTeam/concretePost', + $url->route('tenantTeamPost', ['tenant' => $keyParam('concreteTenant'), $keyParam('concretePost')]), + ); + + /** + * Two parameters with a default value, one without. + * + * The first {tenant} parameter also has a binding field. + */ + $route = new Route(['GET'], 'tenantSlugTeamPost/{tenant:slug}/{team}/{post}', ['as' => 'tenantSlugTeamPost', fn () => '']); + $routes->add($route); + + // tenantSlugTeamPost: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugTeamPost/concreteTenantSlug/concreteTeam/concretePost', + $url->route('tenantSlugTeamPost', [$slugParam('concreteTenantSlug'), $keyParam('concreteTeam'), $keyParam('concretePost')]), + ); + + // tenantSlugTeamPost: Tenant (with default) omitted, team and post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugTeamPost/defaultTenantSlug/concreteTeam/concretePost', + $url->route('tenantSlugTeamPost', [$keyParam('concreteTeam'), $keyParam('concretePost')]), + ); + + // tenantSlugTeamPost: Tenant and team (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugTeamPost/defaultTenantSlug/defaultTeam/concretePost', + $url->route('tenantSlugTeamPost', [$keyParam('concretePost')]), + ); + + // tenantSlugTeamPost: Tenant passed by key, team (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugTeamPost/concreteTenantSlug/defaultTeam/concretePost', + $url->route('tenantSlugTeamPost', ['tenant' => $slugParam('concreteTenantSlug'), $keyParam('concretePost')]), + ); + + /** + * Two parameters with a default value, one without. + * + * The second {team} parameter also has a binding field. + */ + $route = new Route(['GET'], 'tenantTeamSlugPost/{tenant}/{team:slug}/{post}', ['as' => 'tenantTeamSlugPost', fn () => '']); + $routes->add($route); + + // tenantTeamSlugPost: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantTeamSlugPost/concreteTenant/concreteTeamSlug/concretePost', + $url->route('tenantTeamSlugPost', [$keyParam('concreteTenant'), $slugParam('concreteTeamSlug'), $keyParam('concretePost')]), + ); + + // tenantTeamSlugPost: Tenant (with default) omitted, team and post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantTeamSlugPost/defaultTenant/concreteTeamSlug/concretePost', + $url->route('tenantTeamSlugPost', [$slugParam('concreteTeamSlug'), $keyParam('concretePost')]), + ); + + // tenantTeamSlugPost: Tenant and team (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantTeamSlugPost/defaultTenant/defaultTeamSlug/concretePost', + $url->route('tenantTeamSlugPost', [$keyParam('concretePost')]), + ); + + // tenantTeamSlugPost: Tenant passed by key, team (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantTeamSlugPost/concreteTenantSlug/defaultTeamSlug/concretePost', + $url->route('tenantTeamSlugPost', ['tenant' => $keyParam('concreteTenantSlug'), $keyParam('concretePost')]), + ); + + /** + * Two parameters with a default value, one without. + * + * Both parameters with default values, {tenant} and {team}, also have binding fields. + */ + $route = new Route(['GET'], 'tenantSlugTeamSlugPost/{tenant:slug}/{team:slug}/{post}', ['as' => 'tenantSlugTeamSlugPost', fn () => '']); + $routes->add($route); + + // tenantSlugTeamSlugPost: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugTeamSlugPost/concreteTenantSlug/concreteTeamSlug/concretePost', + $url->route('tenantSlugTeamSlugPost', [$slugParam('concreteTenantSlug'), $slugParam('concreteTeamSlug'), $keyParam('concretePost')]), + ); + + // tenantSlugTeamSlugPost: Tenant (with default) omitted, team and post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugTeamSlugPost/defaultTenantSlug/concreteTeamSlug/concretePost', + $url->route('tenantSlugTeamSlugPost', [$slugParam('concreteTeamSlug'), $keyParam('concretePost')]), + ); + + // tenantSlugTeamSlugPost: Tenant and team (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugTeamSlugPost/defaultTenantSlug/defaultTeamSlug/concretePost', + $url->route('tenantSlugTeamSlugPost', [$keyParam('concretePost')]), + ); + + // tenantSlugTeamSlugPost: Tenant passed by key, team (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugTeamSlugPost/concreteTenantSlug/defaultTeamSlug/concretePost', + $url->route('tenantSlugTeamSlugPost', ['tenant' => $slugParam('concreteTenantSlug'), $keyParam('concretePost')]), + ); + + /** + * One parameter without a default value, one with a default value. + * + * Importantly, the parameter with the default value comes second. + */ + $route = new Route(['GET'], 'postUser/{post}/{user}', ['as' => 'postUser', fn () => '']); + $routes->add($route); + + // postUser: Both parameters passed positionally + $this->assertSame( + 'https://www.foo.com/postUser/concretePost/concreteUser', + $url->route('postUser', [$keyParam('concretePost'), $keyParam('concreteUser')]), + ); + + // postUser: Both parameters passed with keys + $this->assertSame( + 'https://www.foo.com/postUser/concretePost/concreteUser', + // Reversed order just to check it doesn't matter with named parameters + $url->route('postUser', ['user' => $keyParam('concreteUser'), 'post' => $keyParam('concretePost')]), + ); + + // postUser: User (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/postUser/concretePost/defaultUser', + $url->route('postUser', [$keyParam('concretePost')]), + ); + + // postUser: User (with default) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/postUser/concretePost/defaultUser', + $url->route('postUser', ['post' => $keyParam('concretePost')]), + ); + + /** + * One parameter without a default value, one with a default value. + * + * Importantly, the parameter with the default value comes second. + * + * In this variation the first parameter, without a default value, + * also has a binding field. + */ + $route = new Route(['GET'], 'postSlugUser/{post:slug}/{user}', ['as' => 'postSlugUser', fn () => '']); + $routes->add($route); + + // postSlugUser: Both parameters passed positionally + $this->assertSame( + 'https://www.foo.com/postSlugUser/concretePostSlug/concreteUser', + $url->route('postSlugUser', [$slugParam('concretePostSlug'), $keyParam('concreteUser')]), + ); + + // postSlugUser: Both parameters passed with keys + $this->assertSame( + 'https://www.foo.com/postSlugUser/concretePostSlug/concreteUser', + $url->route('postSlugUser', ['post' => $slugParam('concretePostSlug'), 'user' => $keyParam('concreteUser')]), + ); + + // postSlugUser: User (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/postSlugUser/concretePostSlug/defaultUser', + $url->route('postSlugUser', [$slugParam('concretePostSlug')]), + ); + + // postSlugUser: User (with default) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/postSlugUser/concretePostSlug/defaultUser', + $url->route('postSlugUser', ['post' => $slugParam('concretePostSlug')]), + ); + + /** + * One parameter without a default value, one with a default value. + * + * Importantly, the parameter with the default value comes second. + * + * In this variation the second parameter, with a default value, + * also has a binding field. + */ + $route = new Route(['GET'], 'postUserSlug/{post}/{user:slug}', ['as' => 'postUserSlug', fn () => '']); + $routes->add($route); + + // postUserSlug: Both parameters passed positionally + $this->assertSame( + 'https://www.foo.com/postUserSlug/concretePost/concreteUserSlug', + $url->route('postUserSlug', [$keyParam('concretePost'), $slugParam('concreteUserSlug')]), + ); + + // postUserSlug: Both parameters passed with keys + $this->assertSame( + 'https://www.foo.com/postUserSlug/concretePost/concreteUserSlug', + $url->route('postUserSlug', ['post' => $keyParam('concretePost'), 'user' => $slugParam('concreteUserSlug')]), + ); + + // postUserSlug: User (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/postUserSlug/concretePost/defaultUserSlug', + $url->route('postUserSlug', [$keyParam('concretePost')]), + ); + + // postUserSlug: User (with default) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/postUserSlug/concretePost/defaultUserSlug', + $url->route('postUserSlug', ['post' => $keyParam('concretePost')]), + ); + + /** + * One parameter without a default value, one with a default value. + * + * Importantly, the parameter with the default value comes second. + * + * In this variation, both parameters have binding fields. + */ + $route = new Route(['GET'], 'postSlugUserSlug/{post:slug}/{user:slug}', ['as' => 'postSlugUserSlug', fn () => '']); + $routes->add($route); + + // postSlugUserSlug: Both parameters passed positionally + $this->assertSame( + 'https://www.foo.com/postSlugUserSlug/concretePostSlug/concreteUserSlug', + $url->route('postSlugUserSlug', [$slugParam('concretePostSlug'), $slugParam('concreteUserSlug')]), + ); + + // postSlugUserSlug: Both parameters passed with keys + $this->assertSame( + 'https://www.foo.com/postSlugUserSlug/concretePostSlug/concreteUserSlug', + $url->route('postSlugUserSlug', ['post' => $slugParam('concretePostSlug'), 'user' => $slugParam('concreteUserSlug')]), + ); + + // postSlugUserSlug: User (with default) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/postSlugUserSlug/concretePostSlug/defaultUserSlug', + $url->route('postSlugUserSlug', [$slugParam('concretePostSlug')]), + ); + + // postSlugUserSlug: User (with default) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/postSlugUserSlug/concretePostSlug/defaultUserSlug', + $url->route('postSlugUserSlug', ['post' => $slugParam('concretePostSlug')]), + ); + + /** + * Parameter without a default value in between two parameters with default values. + */ + $route = new Route(['GET'], 'tenantPostUser/{tenant}/{post}/{user}', ['as' => 'tenantPostUser', fn () => '']); + $routes->add($route); + + // tenantPostUser: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPostUser/concreteTenant/concretePost/concreteUser', + $url->route('tenantPostUser', [$keyParam('concreteTenant'), $keyParam('concretePost'), $keyParam('concreteUser')]), + ); + + // tenantPostUser: Tenant parameter omitted, post and user passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPostUser/defaultTenant/concretePost/concreteUser', + $url->route('tenantPostUser', [$keyParam('concretePost'), $keyParam('concreteUser')]), + ); + + // tenantPostUser: Both tenant and user (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPostUser/defaultTenant/concretePost/defaultUser', + $url->route('tenantPostUser', [$keyParam('concretePost')]), + ); + + // tenantPostUser: All parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantPostUser/concreteTenant/concretePost/concreteUser', + $url->route('tenantPostUser', ['tenant' => $keyParam('concreteTenant'), 'post' => $keyParam('concretePost'), 'user' => $keyParam('concreteUser')]), + ); + + // tenantPostUser: Both tenant and user (with defaults) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/tenantPostUser/defaultTenant/concretePost/defaultUser', + $url->route('tenantPostUser', ['post' => $keyParam('concretePost')]), + ); + + // tenantPostUser: Tenant parameter (with default) omitted, post and user passed using key + $this->assertSame( + 'https://www.foo.com/tenantPostUser/defaultTenant/concretePost/concreteUser', + $url->route('tenantPostUser', ['post' => $keyParam('concretePost'), 'user' => $keyParam('concreteUser')]), + ); + + // tenantPostUser: User parameter (with default) omitted, tenant and post passed using key + $this->assertSame( + 'https://www.foo.com/tenantPostUser/concreteTenant/concretePost/defaultUser', + $url->route('tenantPostUser', ['tenant' => $keyParam('concreteTenant'), 'post' => $keyParam('concretePost')]), + ); + + /** + * Parameter without a default value in between two parameters with a default value. + * + * In this variation of this route, the first {tenant} parameter, with a default value, + * also has a binding field. + */ + $route = new Route(['GET'], 'tenantSlugPostUser/{tenant:slug}/{post}/{user}', ['as' => 'tenantSlugPostUser', fn () => '']); + $routes->add($route); + + // tenantSlugPostUser: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/concreteTenantSlug/concretePost/concreteUser', + $url->route('tenantSlugPostUser', [$slugParam('concreteTenantSlug'), $keyParam('concretePost'), $keyParam('concreteUser')]), + ); + + // tenantSlugPostUser: Tenant parameter omitted, post and user passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/defaultTenantSlug/concretePost/concreteUser', + $url->route('tenantSlugPostUser', [$keyParam('concretePost'), $keyParam('concreteUser')]), + ); + + // tenantSlugPostUser: Both tenant and user (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/defaultTenantSlug/concretePost/defaultUser', + $url->route('tenantSlugPostUser', [$keyParam('concretePost')]), + ); + + // tenantSlugPostUser: All parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/concreteTenantSlug/concretePost/concreteUser', + $url->route('tenantSlugPostUser', ['tenant' => $slugParam('concreteTenantSlug'), 'post' => $keyParam('concretePost'), 'user' => $keyParam('concreteUser')]), + ); + + // tenantSlugPostUser: Both tenant and user (with defaults) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/defaultTenantSlug/concretePost/defaultUser', + $url->route('tenantSlugPostUser', ['post' => $keyParam('concretePost')]), + ); + + // tenantSlugPostUser: Tenant parameter (with default) omitted, post and user passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/defaultTenantSlug/concretePost/concreteUser', + $url->route('tenantSlugPostUser', ['post' => $keyParam('concretePost'), 'user' => $keyParam('concreteUser')]), + ); + + // tenantSlugPostUser: User parameter (with default) omitted, tenant and post passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/concreteTenantSlug/concretePost/defaultUser', + $url->route('tenantSlugPostUser', ['tenant' => $slugParam('concreteTenantSlug'), 'post' => $keyParam('concretePost')]), + ); + + /** + * Parameter without a default value in between two parameters with a default value. + * + * In this variation of this route, the last {user} parameter, with a default value, + * also has a binding field. + */ + $route = new Route(['GET'], 'tenantPostUserSlug/{tenant}/{post}/{user:slug}', ['as' => 'tenantPostUserSlug', fn () => '']); + $routes->add($route); + + // tenantPostUserSlug: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPostUserSlug/concreteTenant/concretePost/concreteUserSlug', + $url->route('tenantPostUserSlug', [$keyParam('concreteTenant'), $keyParam('concretePost'), $slugParam('concreteUserSlug')]), + ); + + // tenantPostUserSlug: Tenant parameter omitted, post and user passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPostUserSlug/defaultTenant/concretePost/concreteUserSlug', + $url->route('tenantPostUserSlug', [$keyParam('concretePost'), $slugParam('concreteUserSlug')]), + ); + + // tenantPostUserSlug: Both tenant and user (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantPostUserSlug/defaultTenant/concretePost/defaultUserSlug', + $url->route('tenantPostUserSlug', [$keyParam('concretePost')]), + ); + + // tenantPostUserSlug: All parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantPostUserSlug/concreteTenant/concretePost/concreteUserSlug', + $url->route('tenantPostUserSlug', ['tenant' => $keyParam('concreteTenant'), 'post' => $keyParam('concretePost'), 'user' => $slugParam('concreteUserSlug')]), + ); + + // tenantPostUserSlug: Both tenant and user (with defaults) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/tenantPostUserSlug/defaultTenant/concretePost/defaultUserSlug', + $url->route('tenantPostUserSlug', ['post' => $keyParam('concretePost')]), + ); + + // tenantPostUserSlug: Tenant parameter (with default) omitted, post and user passed using key + $this->assertSame( + 'https://www.foo.com/tenantPostUserSlug/defaultTenant/concretePost/concreteUserSlug', + $url->route('tenantPostUserSlug', ['post' => $keyParam('concretePost'), 'user' => $slugParam('concreteUserSlug')]), + ); + + // tenantPostUserSlug: User parameter (with default) omitted, tenant and post passed using key + $this->assertSame( + 'https://www.foo.com/tenantPostUserSlug/concreteTenant/concretePost/defaultUserSlug', + $url->route('tenantPostUserSlug', ['tenant' => $keyParam('concreteTenant'), 'post' => $keyParam('concretePost')]), + ); + + /** + * Parameter without a default value in between two parameters with a default value. + * + * In this variation of this route, the first {tenant} parameter, with a default value, + * also has a binding field. + */ + $route = new Route(['GET'], 'tenantSlugPostUser/{tenant:slug}/{post}/{user}', ['as' => 'tenantSlugPostUser', fn () => '']); + $routes->add($route); + + // tenantSlugPostUser: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/concreteTenantSlug/concretePost/concreteUser', + $url->route('tenantSlugPostUser', [$slugParam('concreteTenantSlug'), $keyParam('concretePost'), $keyParam('concreteUser')]), + ); + + // tenantSlugPostUser: Tenant parameter omitted, post and user passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/defaultTenantSlug/concretePost/concreteUser', + $url->route('tenantSlugPostUser', [$keyParam('concretePost'), $keyParam('concreteUser')]), + ); + + // tenantSlugPostUser: Both tenant and user (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/defaultTenantSlug/concretePost/defaultUser', + $url->route('tenantSlugPostUser', [$keyParam('concretePost')]), + ); + + // tenantSlugPostUser: All parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/concreteTenantSlug/concretePost/concreteUser', + $url->route('tenantSlugPostUser', ['tenant' => $slugParam('concreteTenantSlug'), 'post' => $keyParam('concretePost'), 'user' => $keyParam('concreteUser')]), + ); + + // tenantSlugPostUser: Both tenant and user (with defaults) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/defaultTenantSlug/concretePost/defaultUser', + $url->route('tenantSlugPostUser', ['post' => $keyParam('concretePost')]), + ); + + // tenantSlugPostUser: Tenant parameter (with default) omitted, post and user passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/defaultTenantSlug/concretePost/concreteUser', + $url->route('tenantSlugPostUser', ['post' => $keyParam('concretePost'), 'user' => $keyParam('concreteUser')]), + ); + + // tenantSlugPostUser: User parameter (with default) omitted, tenant and post passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUser/concreteTenantSlug/concretePost/defaultUser', + $url->route('tenantSlugPostUser', ['tenant' => $slugParam('concreteTenantSlug'), 'post' => $keyParam('concretePost')]), + ); + + /** + * Parameter without a default value in between two parameters with a default value. + * + * In this variation of this route, both fields with a default value, {tenant} and + * {user}, also have binding fields. + */ + $route = new Route(['GET'], 'tenantSlugPostUserSlug/{tenant:slug}/{post}/{user:slug}', ['as' => 'tenantSlugPostUserSlug', fn () => '']); + $routes->add($route); + + // tenantSlugPostUserSlug: All parameters passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUserSlug/concreteTenantSlug/concretePost/concreteUserSlug', + $url->route('tenantSlugPostUserSlug', [$slugParam('concreteTenantSlug'), $keyParam('concretePost'), $slugParam('concreteUserSlug')]), + ); + + // tenantSlugPostUserSlug: Tenant parameter omitted, post and user passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUserSlug/defaultTenantSlug/concretePost/concreteUserSlug', + $url->route('tenantSlugPostUserSlug', [$keyParam('concretePost'), $slugParam('concreteUserSlug')]), + ); + + // tenantSlugPostUserSlug: Both tenant and user (with defaults) omitted, post passed positionally + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUserSlug/defaultTenantSlug/concretePost/defaultUserSlug', + $url->route('tenantSlugPostUserSlug', [$keyParam('concretePost')]), + ); + + // tenantSlugPostUserSlug: All parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUserSlug/concreteTenantSlug/concretePost/concreteUserSlug', + $url->route('tenantSlugPostUserSlug', ['tenant' => $slugParam('concreteTenantSlug'), 'post' => $keyParam('concretePost'), 'user' => $slugParam('concreteUserSlug')]), + ); + + // tenantSlugPostUserSlug: Both tenant and user (with defaults) omitted, post passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUserSlug/defaultTenantSlug/concretePost/defaultUserSlug', + $url->route('tenantSlugPostUserSlug', ['post' => $keyParam('concretePost')]), + ); + + // tenantSlugPostUserSlug: Tenant parameter (with default) omitted, post and user passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUserSlug/defaultTenantSlug/concretePost/concreteUserSlug', + $url->route('tenantSlugPostUserSlug', ['post' => $keyParam('concretePost'), 'user' => $slugParam('concreteUserSlug')]), + ); + + // tenantSlugPostUserSlug: User parameter (with default) omitted, tenant and post passed using key + $this->assertSame( + 'https://www.foo.com/tenantSlugPostUserSlug/concreteTenantSlug/concretePost/defaultUserSlug', + $url->route('tenantSlugPostUserSlug', ['tenant' => $slugParam('concreteTenantSlug'), 'post' => $keyParam('concretePost')]), + ); + } + + public function testComplexRouteGenerationWithDefaultsAndMixedParameterSyntax() + { + $url = new UrlGenerator( + $routes = new RouteCollection, + Request::create('https://www.foo.com/') + ); + + $url->defaults([ + 'tenant' => 'defaultTenant', + 'user' => 'defaultUser', + ]); + + /** + * Parameter without a default value in between two parameters with default values. + */ + $route = new Route(['GET'], 'tenantPostUser/{tenant}/{post}/{user}', ['as' => 'tenantPostUser', fn () => '']); + $routes->add($route); + + // If the required post parameter is specified using a key, + // the positional parameter is used for the user parameter. + $this->assertSame( + 'https://www.foo.com/tenantPostUser/defaultTenant/concretePost/concreteUser', + $url->route('tenantPostUser', ['post' => 'concretePost', 'concreteUser']), + ); + + /** + * Two parameters without default values in between two parameters with default values. + */ + $route = new Route(['GET'], 'tenantPostCommentUser/{tenant}/{post}/{comment}/{user}', ['as' => 'tenantPostCommentUser', fn () => '']); + $routes->add($route); + + // Pass first required parameter using a key, second positionally + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/defaultTenant/concretePost/concreteComment/defaultUser', + $url->route('tenantPostCommentUser', ['post' => 'concretePost', 'concreteComment']), + ); + + // Pass first required parameter positionally, second using a key + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/defaultTenant/concretePost/concreteComment/defaultUser', + $url->route('tenantPostCommentUser', ['concretePost', 'comment' => 'concreteComment']), + ); + + // Verify that this is order-independent + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/defaultTenant/concretePost/concreteComment/defaultUser', + $url->route('tenantPostCommentUser', ['comment' => 'concreteComment', 'concretePost']), + ); + + // Both required params passed with keys, positional parameter goes to the user param + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/defaultTenant/concretePost/concreteComment/concreteUser', + $url->route('tenantPostCommentUser', ['post' => 'concretePost', 'comment' => 'concreteComment', 'concreteUser']), + ); + + // First required param passed with a key, remaining params go to the last two route params + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/defaultTenant/concretePost/concreteComment/concreteUser', + $url->route('tenantPostCommentUser', ['post' => 'concretePost', 'concreteComment', 'concreteUser']), + ); + + // Comment parameter passed with a key, remaining params filled (last to last) + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/defaultTenant/concretePost/concreteComment/concreteUser', + $url->route('tenantPostCommentUser', ['concretePost', 'comment' => 'concreteComment', 'concreteUser']), + ); + + // Verify that this is order-independent + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/defaultTenant/concretePost/concreteComment/concreteUser', + $url->route('tenantPostCommentUser', ['comment' => 'concreteComment', 'concretePost', 'concreteUser']), + ); + + // Both default parameters passed positionally, required parameters passed with keys + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/concreteTenant/concretePost/concreteComment/concreteUser', + $url->route('tenantPostCommentUser', ['concreteTenant', 'post' => 'concretePost', 'comment' => 'concreteComment', 'concreteUser']), + ); + + // Verify that the positional parameters may come anywhere in the array + $this->assertSame( + 'https://www.foo.com/tenantPostCommentUser/concreteTenant/concretePost/concreteComment/concreteUser', + $url->route('tenantPostCommentUser', ['post' => 'concretePost', 'comment' => 'concreteComment', 'concreteTenant', 'concreteUser']), + ); + } + + public function testDefaultsCanBeCombinedWithExtraQueryParameters() + { + $url = new UrlGenerator( + $routes = new RouteCollection, + Request::create('https://www.foo.com/') + ); + + $url->defaults([ + 'tenant' => 'defaultTenant', + 'tenant:slug' => 'defaultTenantSlug', + 'user' => 'defaultUser', + ]); + + $slugParam = fn ($value) => tap(new RoutableInterfaceStub, fn ($routable) => $routable->slug = $value); + + /** + * One parameter with a default value, one parameter without a default value. + */ + $route = new Route(['GET'], 'tenantPost/{tenant}/{post}', ['as' => 'tenantPost', fn () => '']); + $routes->add($route); + + // tenantPost: Extra positional parameters without values are interpreted as query strings + $this->assertSame( + 'https://www.foo.com/tenantPost/concreteTenant/concretePost?extraQuery', + $url->route('tenantPost', ['concreteTenant', 'concretePost', 'extraQuery']), + ); + + // tenantPost: Query parameters without values go at the end + $this->assertSame( + 'https://www.foo.com/tenantPost/concreteTenant/concretePost?extra=query&extraQuery', + $url->route('tenantPost', ['concreteTenant', 'concretePost', 'extraQuery', 'extra' => 'query']), + ); + + // tenantPost: Defaults can be used with *named* query parameters + $this->assertSame( + 'https://www.foo.com/tenantPost/defaultTenant/concretePost?extra=query', + $url->route('tenantPost', ['concretePost', 'extra' => 'query']), + ); + + // tenantPost: Named query parameters can be placed anywhere in the parameters array + $this->assertSame( + 'https://www.foo.com/tenantPost/concreteTenant/concretePost?extra=query', + $url->route('tenantPost', ['concreteTenant', 'extra' => 'query', 'concretePost']), + ); + + /** + * One parameter with a default value, one parameter without a default value. + * + * The first parameter with a default value, {tenant}, also has a binding field. + */ + $route = new Route(['GET'], 'tenantSlugPost/{tenant:slug}/{post}', ['as' => 'tenantSlugPost', fn () => '']); + $routes->add($route); + + // tenantSlugPost: Extra positional parameters without values are interpreted as query strings + $this->assertSame( + 'https://www.foo.com/tenantSlugPost/concreteTenantSlug/concretePost?extraQuery', + $url->route('tenantSlugPost', [$slugParam('concreteTenantSlug'), 'concretePost', 'extraQuery']), + ); + + // tenantSlugPost: Query parameters without values go at the end + $this->assertSame( + 'https://www.foo.com/tenantSlugPost/concreteTenantSlug/concretePost?extra=query&extraQuery', + $url->route('tenantSlugPost', [$slugParam('concreteTenantSlug'), 'concretePost', 'extraQuery', 'extra' => 'query']), + ); + + // tenantSlugPost: Defaults can be used with *named* query parameters + $this->assertSame( + 'https://www.foo.com/tenantSlugPost/defaultTenantSlug/concretePost?extra=query', + $url->route('tenantSlugPost', ['concretePost', 'extra' => 'query']), + ); + + // tenantSlugPost: Named query parameters can be placed anywhere in the parameters array + $this->assertSame( + 'https://www.foo.com/tenantSlugPost/concreteTenantSlug/concretePost?extra=query', + $url->route('tenantSlugPost', [$slugParam('concreteTenantSlug'), 'extra' => 'query', 'concretePost']), + ); + + /** + * Parameter without a default value in between two parameters with default values. + */ + $route = new Route(['GET'], 'tenantPostUser/{tenant}/{post}/{user}', ['as' => 'tenantPostUser', fn () => '']); + $routes->add($route); + + // tenantPostUser: Query string parameters may be passed positionally if + // all route parameters are passed as well, i.e. defaults are not used. + $this->assertSame( + 'https://www.foo.com/tenantPostUser/concreteTenant/concretePost/concreteUser?extraQuery', + $url->route('tenantPostUser', ['concreteTenant', 'concretePost', 'concreteUser', 'extraQuery']), + ); + + // tenantPostUser: Query string parameters can be passed as key-value + // pairs if all route params are passed as well, i.e. no defaults. + $this->assertSame( + 'https://www.foo.com/tenantPostUser/concreteTenant/concretePost/concreteUser?extraQuery', + $url->route('tenantPostUser', ['concreteTenant', 'concretePost', 'concreteUser', 'extraQuery']), + ); + + // tenantPostUser: With omitted default parameters, query string parameters + // can only be specified using key-value pairs. Positional query string + // parameters would be interpreted as route parameters instead. + $this->assertSame( + 'https://www.foo.com/tenantPostUser/defaultTenant/concretePost/concreteUser?extra=query', + $url->route('tenantPostUser', ['concretePost', 'concreteUser', 'extra' => 'query']), + ); + + // tenantPostUser: Use defaults for tenant and user, pass post positionally + // and add an extra query string parameter as a key-value pair. + $this->assertSame( + 'https://www.foo.com/tenantPostUser/defaultTenant/concretePost/defaultUser?extra=query', + $url->route('tenantPostUser', ['concretePost', 'extra' => 'query']), + ); + } } class RoutableInterfaceStub implements UrlRoutable