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

[11.x] Enhance URI query string manipulation #53805

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 11 additions & 36 deletions src/Illuminate/Support/Uri.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class Uri implements Htmlable, Responsable
*/
public function __construct(UriInterface|Stringable|string $uri = '')
{
$this->uri = $uri instanceof UriInterface ? $uri : LeagueUri::new((string) $uri);
$this->uri = $uri instanceof UriInterface ? $uri : LeagueUri::new($uri);
}

/**
Expand Down Expand Up @@ -116,7 +116,7 @@ public function port(): ?int
*/
public function path(): ?string
{
$path = trim((string) $this->uri->getPath(), '/');
$path = trim($this->uri->getPath(), '/');

return $path === '' ? '/' : $path;
}
Expand All @@ -126,7 +126,7 @@ public function path(): ?string
*/
public function query(): UriQueryString
{
return new UriQueryString($this);
return new UriQueryString($this->uri->getQuery());
}

/**
Expand Down Expand Up @@ -188,64 +188,39 @@ public function withQuery(array $query, bool $merge = true): static
}
}

if ($merge) {
$mergedQuery = $this->query()->all();

foreach ($query as $key => $value) {
data_set($mergedQuery, $key, $value);
}
$currentQuery = $this->query();

$newQuery = $mergedQuery;
if ($merge) {
$currentQuery->merge($query);
} else {
$newQuery = [];

foreach ($query as $key => $value) {
data_set($newQuery, $key, $value);
}
$currentQuery->set($query);
}

return new static($this->uri->withQuery(Arr::query($newQuery)));
return new static($this->uri->withQuery($currentQuery->value()));
}

/**
* Merge new query parameters into the URI if they are not already in the query string.
*/
public function withQueryIfMissing(array $query): static
{
$currentQuery = $this->query();

foreach ($query as $key => $value) {
if (! $currentQuery->missing($key)) {
Arr::forget($query, $key);
}
}

return $this->withQuery($query);
return $this->withQuery($this->query()->mergeIfMissing($query));
}

/**
* Push a value onto the end of a query string parameter that is a list.
*/
public function pushOntoQuery(string $key, mixed $value): static
{
$currentValue = data_get($this->query()->all(), $key);

$values = Arr::wrap($value);

return $this->withQuery([$key => match (true) {
is_array($currentValue) && array_is_list($currentValue) => array_values(array_unique([...$currentValue, ...$values])),
is_array($currentValue) => [...$currentValue, ...$values],
! is_null($currentValue) => [$currentValue, ...$values],
default => $values,
}]);
return $this->withQuery($this->query()->push($key, $value));
}

/**
* Remove the given query parameters from the URI.
*/
public function withoutQuery(array $keys): static
{
return $this->replaceQuery(Arr::except($this->query()->all(), $keys));
return $this->replaceQuery($this->query()->except($keys));
}

/**
Expand Down
111 changes: 104 additions & 7 deletions src/Illuminate/Support/UriQueryString.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,33 @@ class UriQueryString implements Arrayable
{
use InteractsWithData;

/**
* Query string parameters.
*/
protected array $data;

/**
* Create a new URI query string instance.
*/
public function __construct(protected Uri $uri)
public function __construct(Stringable|string|array|null $data = [])
{
$this->data = is_array($data) ? $data : static::parse($data);
}

/**
* Create a new URI Query String instance.
*/
public static function of(Stringable|string|array $query = []): static
{
return new static($query);
}

/**
* Parse the given query string.
*/
public static function parse(Stringable|string|null $query): array
{
//
return QueryString::extract($query);
}

/**
Expand All @@ -35,7 +56,7 @@ public function all($keys = null)
$results = [];

foreach (is_array($keys) ? $keys : func_get_args() as $key) {
Arr::set($results, $key, Arr::get($query, $key));
$results[$key] = $query[$key] ?? null;
}

return $results;
Expand All @@ -58,15 +79,91 @@ protected function data($key = null, $default = null)
*/
public function get(?string $key = null, mixed $default = null): mixed
{
return data_get($this->toArray(), $key, $default);
return array_key_exists($key, $this->data) ? $this->data[$key] : value($default);
}

/**
* Determine if the data contains a given key.
*
* @param string|array $key
* @return bool
*/
public function has($key): bool
{
$keys = is_array($key) ? $key : func_get_args();

$data = $this->toArray();

foreach ($keys as $value) {
if (! array_key_exists($value, $data)) {
return false;
}
}

return true;
}

/**
* Set the query string.
*/
public function set(array $value): void
{
$this->data = $value;
}

/**
* Get the URL decoded version of the query string.
*/
public function decode(): string
{
return rawurldecode((string) $this);
return rawurldecode($this->value());
}

/**
* Merge new query parameters into the query string.
*/
public function merge(array $query): array
{
$results = $this->all();

foreach ($query as $key => $value) {
$results[$key] = $value;
}

$this->set($results);

return $results;
}

/**
* Merge new query parameters if they are not already in the query string.
*/
public function mergeIfMissing(array $query): array
{
foreach ($query as $key => $value) {
if ($this->has($key)) {
unset($query[$key]);
}
}

return $this->merge($query);
}

/**
* Push a value onto the query string.
*/
public function push(string $key, mixed $value): array
{
$currentValue = $this->get($key);

$values = Arr::wrap($value);

return $this->merge([$key => match (true) {
is_array($currentValue) && array_is_list($currentValue) => array_values(array_unique([...$currentValue, ...$values])),
is_array($currentValue) => [...$currentValue, ...$values],
! is_null($currentValue) => [$currentValue, ...$values],
default => $values,
}]);
}

/**
Expand All @@ -82,14 +179,14 @@ public function value(): string
*/
public function toArray()
{
return QueryString::extract($this->value());
return $this->data;
}

/**
* Get the string representation of the query string.
*/
public function __toString(): string
{
return (string) $this->uri->getUri()->getQuery();
return Arr::query($this->toArray());
}
}
68 changes: 60 additions & 8 deletions tests/Support/SupportUriTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public function test_complicated_query_string_parsing()
'flag_value' => '',
], $uri->query()->all());

$this->assertEquals('key_1=value&key_2[sub_field]=value&key_3[]=value&key_4[9]=value&key_5[][][foo][9]=bar&key.6=value&flag_value', $uri->query()->decode());
$this->assertEquals('key_1=value&key_2[sub_field]=value&key_3[0]=value&key_4[9]=value&key_5[0][0][foo][9]=bar&key.6=value&flag_value=', $uri->query()->decode());
}

public function test_uri_building()
Expand Down Expand Up @@ -112,18 +112,70 @@ public function test_complicated_query_string_manipulation()
$this->assertEquals(['tag' => ['foo', 'bar']], $uri->pushOntoQuery('tag', 'bar')->query()->all());
}

public function test_query_strings_with_dots_can_be_replaced_or_merged_consistently()
public function test_decoding_the_entire_uri()
{
$uri = Uri::of('https://dot.test/?foo.bar=baz');
$uri = Uri::of('https://laravel.com/docs/11.x/installation')->withQuery(['tags' => ['first', 'second']]);

$this->assertEquals('foo.bar=baz&foo[bar]=zab', $uri->withQuery(['foo.bar' => 'zab'])->query()->decode());
$this->assertEquals('foo[bar]=zab', $uri->replaceQuery(['foo.bar' => 'zab'])->query()->decode());
$this->assertEquals('https://laravel.com/docs/11.x/installation?tags[0]=first&tags[1]=second', $uri->decode());
}

public function test_decoding_the_entire_uri()
public function test_query_string_manipulation()
{
$uri = Uri::of('https://laravel.com/docs/11.x/installation')->withQuery(['tags' => ['first', 'second']]);
$uri = Uri::of('https://laravel.com?foo.bar=value&foo[bar]=4&bar[foo]=7');

// Test `withQuery`
$this->assertEquals('foo.bar=value&foo=9&bar[foo]=7', $uri->withQuery(['foo' => 9])->query()->decode());
$this->assertEquals('foo.bar=value&foo[bar]=9&bar[foo]=7', $uri->withQuery(['foo' => ['bar' => 9]])->query()->decode());
$this->assertEquals('foo.bar=9&foo[bar]=4&bar[foo]=7', $uri->withQuery(['foo.bar' => 9])->query()->decode());
$this->assertEquals('foo=9', $uri->withQuery(['foo' => 9], false)->query()->decode());
$this->assertEquals('foo[bar]=9', $uri->withQuery(['foo' => ['bar' => 9]], false)->query()->decode());
$this->assertEquals('foo.bar=9', $uri->withQuery(['foo.bar' => 9], false)->query()->decode());

// Test `replaceQuery`
$this->assertEquals('foo=9', $uri->replaceQuery(['foo' => 9])->query()->decode());
$this->assertEquals('foo[bar]=9', $uri->replaceQuery(['foo' => ['bar' => 9]])->query()->decode());
$this->assertEquals('foo.bar=9', $uri->replaceQuery(['foo.bar' => 9])->query()->decode());

// Test `withQueryIfMissing`
$this->assertEquals('foo.bar=value&foo[bar]=4&bar[foo]=7', $uri->withQueryIfMissing(['foo' => 9])->query()->decode());
$this->assertEquals('foo.bar=value&foo[bar]=4&bar[foo]=7', $uri->withQueryIfMissing(['foo' => ['bar' => 9]])->query()->decode());
$this->assertEquals('foo.bar=value&foo[bar]=4&bar[foo]=7', $uri->withQueryIfMissing(['foo.bar' => 9])->query()->decode());
$this->assertEquals('foo.bar=value&foo[bar]=4&bar[foo]=7&bar.foo=9', $uri->withQueryIfMissing(['bar.foo' => 9])->query()->decode());

// Test `pushOntoQuery`
$this->assertEquals('foo.bar=value&foo[bar]=4&foo[0]=9&bar[foo]=7', $uri->pushOntoQuery('foo', 9)->query()->decode());
$this->assertEquals('foo.bar=value&foo[bar]=9&bar[foo]=7', $uri->pushOntoQuery('foo', ['bar' => 9])->query()->decode());
$this->assertEquals('foo.bar[0]=value&foo.bar[1]=9&foo[bar]=4&bar[foo]=7', $uri->pushOntoQuery('foo.bar', 9)->query()->decode());

// Test `withoutQuery`
$this->assertEquals('foo.bar=value&bar[foo]=7', $uri->withoutQuery(['foo'])->query()->decode());
$this->assertEquals('foo[bar]=4&bar[foo]=7', $uri->withoutQuery(['foo.bar'])->query()->decode());
}

$this->assertEquals('https://laravel.com/docs/11.x/installation?tags[0]=first&tags[1]=second', $uri->decode());
public function test_query_string()
{
$uri = Uri::of('https://laravel.com?foo.bar=value&foo[bar]=4&bar[foo]=7');

// Test `toArray`
$this->assertEquals([
'foo.bar' => 'value',
'foo' => ['bar' => '4'],
'bar' => ['foo' => '7'],
], $uri->query()->toArray());

// Test `all`
$this->assertEquals($uri->query()->toArray(), $uri->query()->all());
$this->assertEquals(['foo' => ['bar' => '4']], $uri->query()->all('foo'));
$this->assertEquals(['foo.bar' => 'value'], $uri->query()->all('foo.bar'));

// Test `get`
$this->assertEquals(['bar' => '4'], $uri->query()->get('foo'));
$this->assertEquals('value', $uri->query()->get('foo.bar'));

// Test `has`
$this->assertTrue($uri->query()->has('foo'));
$this->assertTrue($uri->query()->has('bar'));
$this->assertTrue($uri->query()->has('foo.bar'));
$this->assertFalse($uri->query()->has('bar.foo'));
}
}