Skip to content

Commit 6328f11

Browse files
authored
feat(uri): add immutable query replacement helpers (#10268)
- add immutable URI helpers for replacing and filtering query vars - document the full immutable query helper family - cover clone behavior, raw query strings, fragments, and invalid query strings Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 2af3cbd commit 6328f11

6 files changed

Lines changed: 166 additions & 5 deletions

File tree

system/HTTP/URI.php

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -817,8 +817,6 @@ protected function refreshPath(): self
817817
* to clean the various parts of the query keys and values.
818818
*
819819
* @return $this
820-
*
821-
* @TODO PSR-7: Should be `withQuery($query)`.
822820
*/
823821
public function setQuery(string $query)
824822
{
@@ -844,13 +842,23 @@ public function setQuery(string $query)
844842
return $this;
845843
}
846844

845+
/**
846+
* Returns an instance with the specified query string.
847+
*/
848+
public function withQuery(string $query): static
849+
{
850+
$uri = clone $this;
851+
852+
$uri->setQuery($query);
853+
854+
return $uri;
855+
}
856+
847857
/**
848858
* A convenience method to pass an array of items in as the Query
849859
* portion of the URI.
850860
*
851-
* @return URI
852-
*
853-
* @TODO: PSR-7: Should be `withQueryParams(array $query)`
861+
* @return $this
854862
*/
855863
public function setQueryArray(array $query)
856864
{
@@ -859,6 +867,22 @@ public function setQueryArray(array $query)
859867
return $this->setQuery($query);
860868
}
861869

870+
/**
871+
* Returns an instance with the specified query vars.
872+
*
873+
* Note: Method not in PSR-7
874+
*
875+
* @param array<string, mixed> $query
876+
*/
877+
public function withQueryArray(array $query): static
878+
{
879+
$uri = clone $this;
880+
881+
$uri->setQueryArray($query);
882+
883+
return $uri;
884+
}
885+
862886
/**
863887
* Adds a single new element to the query vars.
864888
*
@@ -925,6 +949,20 @@ public function stripQuery(...$params)
925949
return $this;
926950
}
927951

952+
/**
953+
* Returns an instance without the specified query vars.
954+
*
955+
* Note: Method not in PSR-7
956+
*/
957+
public function withoutQueryVars(string ...$params): static
958+
{
959+
$uri = clone $this;
960+
961+
$uri->stripQuery(...$params);
962+
963+
return $uri;
964+
}
965+
928966
/**
929967
* Filters the query variables so that only the keys passed in
930968
* are kept. The rest are removed from the object.
@@ -952,6 +990,20 @@ public function keepQuery(...$params)
952990
return $this;
953991
}
954992

993+
/**
994+
* Returns an instance with only the specified query vars.
995+
*
996+
* Note: Method not in PSR-7
997+
*/
998+
public function withOnlyQueryVars(string ...$params): static
999+
{
1000+
$uri = clone $this;
1001+
1002+
$uri->keepQuery(...$params);
1003+
1004+
return $uri;
1005+
}
1006+
9551007
/**
9561008
* Sets the fragment portion of the URI.
9571009
*

tests/system/HTTP/URITest.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,19 @@ public function testSetQuerySetsValue(): void
477477
$this->assertSame($expected, (string) $uri);
478478
}
479479

480+
public function testWithQuerySetsQueryWithoutMutatingOriginal(): void
481+
{
482+
$url = 'http://example.com/path?foo=bar#fragment';
483+
$uri = new URI($url);
484+
485+
$new = $uri->withQuery('?key=value&second.key=value.2');
486+
487+
$this->assertNotSame($uri, $new);
488+
$this->assertSame('key=value&second_key=value.2', $new->getQuery());
489+
$this->assertSame('http://example.com/path?key=value&second_key=value.2#fragment', (string) $new);
490+
$this->assertSame($url, (string) $uri);
491+
}
492+
480493
public function testUseRawQueryStringAtConstructor(): void
481494
{
482495
$url = 'http://example.com/path?key=value&second.key=value.2';
@@ -509,6 +522,32 @@ public function testSetQueryArraySetsValue(): void
509522
$this->assertSame($expected, (string) $uri);
510523
}
511524

525+
public function testWithQueryArraySetsQueryWithoutMutatingOriginal(): void
526+
{
527+
$url = 'http://example.com/path?foo=bar#fragment';
528+
$uri = new URI($url);
529+
530+
$new = $uri->withQueryArray(['key' => 'value', 'second.key' => 'value.2']);
531+
532+
$this->assertNotSame($uri, $new);
533+
$this->assertSame('key=value&second_key=value.2', $new->getQuery());
534+
$this->assertSame('http://example.com/path?key=value&second_key=value.2#fragment', (string) $new);
535+
$this->assertSame($url, (string) $uri);
536+
}
537+
538+
public function testWithQueryArraySetsQueryWithUseRawQueryStringWithoutMutatingOriginal(): void
539+
{
540+
$url = 'http://example.com/path?foo=bar#fragment';
541+
$uri = new URI($url, true);
542+
543+
$new = $uri->withQueryArray(['key' => 'value', 'second.key' => 'value.2']);
544+
545+
$this->assertNotSame($uri, $new);
546+
$this->assertSame('key=value&second.key=value.2', $new->getQuery());
547+
$this->assertSame('http://example.com/path?key=value&second.key=value.2#fragment', (string) $new);
548+
$this->assertSame($url, (string) $uri);
549+
}
550+
512551
public function testSetQueryArraySetsValueWithUseRawQueryString(): void
513552
{
514553
$url = 'http://example.com/path';
@@ -531,6 +570,20 @@ public function testSetQueryThrowsErrorWhenFragmentPresent(): void
531570
$uri->setQuery('?key=value#fragment');
532571
}
533572

573+
public function testWithQueryThrowsErrorWhenFragmentPresentWithoutMutatingOriginal(): void
574+
{
575+
$url = 'http://example.com/path?foo=bar';
576+
$uri = new URI($url);
577+
578+
$this->expectException(HTTPException::class);
579+
580+
try {
581+
$uri->withQuery('?key=value#fragment');
582+
} finally {
583+
$this->assertSame($url, (string) $uri);
584+
}
585+
}
586+
534587
/**
535588
* @param string $url
536589
* @param string $expected
@@ -896,6 +949,18 @@ public function testStripQueryVars(): void
896949
$this->assertSame('http://example.com/foo?foo=bar', (string) $uri);
897950
}
898951

952+
public function testWithoutQueryVarsRemovesQueryVarsWithoutMutatingOriginal(): void
953+
{
954+
$base = 'http://example.com/foo?foo=bar&bar=baz&baz=foz#section';
955+
$uri = new URI($base);
956+
957+
$new = $uri->withoutQueryVars('bar', 'baz');
958+
959+
$this->assertNotSame($uri, $new);
960+
$this->assertSame('http://example.com/foo?foo=bar#section', (string) $new);
961+
$this->assertSame($base, (string) $uri);
962+
}
963+
899964
public function testKeepQueryVars(): void
900965
{
901966
$base = 'http://example.com/foo?foo=bar&bar=baz&baz=foz';
@@ -906,6 +971,18 @@ public function testKeepQueryVars(): void
906971
$this->assertSame('http://example.com/foo?bar=baz&baz=foz', (string) $uri);
907972
}
908973

974+
public function testWithOnlyQueryVarsKeepsQueryVarsWithoutMutatingOriginal(): void
975+
{
976+
$base = 'http://example.com/foo?foo=bar&bar=baz&baz=foz#section';
977+
$uri = new URI($base);
978+
979+
$new = $uri->withOnlyQueryVars('bar', 'baz');
980+
981+
$this->assertNotSame($uri, $new);
982+
$this->assertSame('http://example.com/foo?bar=baz&baz=foz#section', (string) $new);
983+
$this->assertSame($base, (string) $uri);
984+
}
985+
909986
public function testEmptyQueryVars(): void
910987
{
911988
$base = 'http://example.com/foo';

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ HTTP
316316
- ``CLIRequest`` now supports options with values specified using an equals sign (e.g., ``--option=value``) in addition to the existing space-separated syntax (e.g., ``--option value``).
317317
This provides more flexibility in how you can pass options to CLI requests.
318318
- Added ``$enableStyleNonce`` and ``$enableScriptNonce`` options to ``Config\App`` to automatically add nonces to control whether to add nonces to style-* and script-* directives in the Content Security Policy (CSP) header when CSP is enabled. See :ref:`csp-control-nonce-generation` for details.
319+
- Added ``URI::withQuery()``, ``URI::withQueryArray()``, ``URI::withoutQueryVars()``, and ``URI::withOnlyQueryVars()`` to return cloned URIs with replaced or filtered query variables.
319320
- Added ``URI::withQueryVar()`` and ``URI::withQueryVars()`` to return a cloned URI with query variables added or replaced.
320321
- ``URI`` now accepts an optional boolean second parameter in the constructor, defaulting to ``false``, to control how the query string is parsed in instantiation.
321322
This is the behavior of ``->useRawQueryString()`` brought into the constructor for convenience. Previously, you need to call ``$uri->useRawQueryString(true)->setURI($uri)`` to get this behavior.

user_guide_src/source/libraries/uri.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,22 @@ Changing Query Values Without Mutation
193193

194194
.. versionadded:: 4.8.0
195195

196+
You can return a new URI instance with its query variables replaced by using the
197+
``withQuery()`` and ``withQueryArray()`` methods:
198+
199+
.. literalinclude:: uri/029.php
200+
196201
You can return a new URI instance with one or more query variables added or replaced by using the
197202
``withQueryVar()`` and ``withQueryVars()`` methods. Existing query variables are preserved unless they
198203
are replaced:
199204

200205
.. literalinclude:: uri/028.php
201206

207+
You can also return a new URI instance with query variables removed or filtered by using the
208+
``withoutQueryVars()`` and ``withOnlyQueryVars()`` methods:
209+
210+
.. literalinclude:: uri/030.php
211+
202212
The original URI instance is not modified.
203213

204214
Filtering Query Values
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
$uri = new \CodeIgniter\HTTP\URI('https://example.com/users?q=bob&page=1');
4+
5+
$page = $uri->withQuery('page=2');
6+
// https://example.com/users?page=2
7+
8+
$filtered = $uri->withQueryArray([
9+
'q' => 'alice',
10+
'role' => 'admin',
11+
]);
12+
// https://example.com/users?q=alice&role=admin
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
$uri = new \CodeIgniter\HTTP\URI('https://example.com/users?q=bob&page=1&role=admin');
4+
5+
$withoutPage = $uri->withoutQueryVars('page');
6+
// https://example.com/users?q=bob&role=admin
7+
8+
$onlyFilters = $uri->withOnlyQueryVars('q', 'role');
9+
// https://example.com/users?q=bob&role=admin

0 commit comments

Comments
 (0)