├── .github └── workflows │ └── ci-workflow.yml ├── .gitignore ├── .scrutinizer.yml ├── LICENSE ├── README.md ├── composer.json ├── doc ├── Instantiation.md ├── ManipulateParameters.md └── RenderAsString.md ├── phpunit.xml.dist ├── src ├── Pairs.php ├── Parser │ ├── FlatParser.php │ ├── NativeParser.php │ └── QueryStringParserInterface.php ├── QueryString.php ├── Renderer │ ├── ArrayValuesNormalizerRenderer.php │ ├── FlatRenderer.php │ ├── NativeRenderer.php │ ├── QueryStringRendererInterface.php │ └── QueryStringRendererTrait.php └── functions.php └── tests ├── ArrayValuesNormalizerRendererTest.php ├── FlatParserTest.php ├── FlatRendererTest.php ├── NativeParserTest.php ├── NativeRendererTest.php ├── PairsTest.php └── QueryStringTest.php /.github/workflows/ci-workflow.yml: -------------------------------------------------------------------------------- 1 | name: CI Workflow 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | code-style: 11 | name: Code Style & Static Analysis 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: 8.4 21 | extensions: mbstring, pcntl 22 | 23 | - name: Validate composer.json and composer.lock 24 | run: composer validate 25 | 26 | - name: Install dependencies 27 | run: composer install --prefer-dist --no-progress 28 | 29 | - name: Check code style 30 | run: vendor/bin/phpcs --standard=psr2 -n src/ 31 | 32 | 33 | tests: 34 | name: Test Suite 35 | runs-on: ubuntu-latest 36 | strategy: 37 | max-parallel: 10 38 | matrix: 39 | php: 40 | - 7.1 41 | - 7.2 42 | - 7.3 43 | - 7.4 44 | - 8.0 45 | - 8.1 46 | - 8.2 47 | - 8.3 48 | - 8.4 49 | 50 | steps: 51 | - name: Checkout 52 | uses: actions/checkout@v4 53 | 54 | - name: Setup PHP 55 | uses: shivammathur/setup-php@v2 56 | with: 57 | php-version: ${{ matrix.php }} 58 | extensions: mbstring, curl, zip 59 | coverage: xdebug 60 | 61 | - name: Install dependencies 62 | run: composer install --prefer-dist --no-progress 63 | 64 | - name: Run tests 65 | run: vendor/bin/phpunit --coverage-clover=coverage.xml 66 | 67 | - name: Upload coverage to Codecov 68 | uses: codecov/codecov-action@v1 69 | with: 70 | token: ${{ secrets.CODECOV_TOKEN }} 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: [tests/*] 3 | checks: 4 | php: 5 | code_rating: true 6 | remove_extra_empty_lines: true 7 | remove_php_closing_tag: true 8 | remove_trailing_whitespace: true 9 | fix_use_statements: 10 | remove_unused: true 11 | preserve_multiple: false 12 | preserve_blanklines: true 13 | order_alphabetically: true 14 | fix_php_opening_tag: true 15 | fix_linefeed: true 16 | fix_line_ending: true 17 | fix_identation_4spaces: true 18 | fix_doc_comments: true 19 | tools: 20 | php_analyzer: true 21 | php_code_coverage: false 22 | php_code_sniffer: 23 | config: 24 | standard: PSR2 25 | filter: 26 | paths: ['src'] 27 | php_loc: 28 | enabled: true 29 | excluded_dirs: [vendor, tests] 30 | php_cpd: 31 | enabled: true 32 | excluded_dirs: [vendor, tests] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Benoit POLASZEK 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Stable Version](https://poser.pugx.org/bentools/querystring/v/stable)](https://packagist.org/packages/bentools/querystring) 2 | [![License](https://poser.pugx.org/bentools/querystring/license)](https://packagist.org/packages/bentools/querystring) 3 | [![CI Workflow](https://github.com/bpolaszek/querystring/actions/workflows/ci-workflow.yml/badge.svg)](https://github.com/bpolaszek/querystring/actions/workflows/ci-workflow.yml) 4 | [![Coverage](https://codecov.io/gh/bpolaszek/querystring/branch/master/graph/badge.svg?token=9AXQUHY1R7)](https://codecov.io/gh/bpolaszek/querystring) 5 | [![Quality Score](https://img.shields.io/scrutinizer/g/bpolaszek/querystring.svg?style=flat-square)](https://scrutinizer-ci.com/g/bpolaszek/querystring) 6 | [![Total Downloads](https://poser.pugx.org/bentools/querystring/downloads)](https://packagist.org/packages/bentools/querystring) 7 | 8 | # QueryString 9 | 10 | A lightweight, object-oriented, Query String manipulation library. 11 | 12 | ## Why? 13 | 14 | Because I needed an intuitive way to add or remove parameters from a query string, in any project. 15 | 16 | Oh, and, I also wanted that `['foos' => ['foo', 'bar']]` resolved to `foos[]=foo&foos[]=bar` instead of `foos[0]=foo&foos[1]=bar`, unlike many libraries do. 17 | 18 | Thanks to object-oriented design, you can define the way query strings are [parsed](doc/Instantiation.md) and [rendered](doc/RenderAsString.md#change-renderer). 19 | 20 | ## Usage 21 | 22 | Simple as that: 23 | ```php 24 | require_once __DIR__ . '/vendor/autoload.php'; 25 | 26 | use function BenTools\QueryString\query_string; 27 | 28 | $qs = query_string( 29 | 'foo=bar&baz=bat' 30 | ); 31 | $qs = $qs->withParam('foo', 'foofoo') 32 | ->withoutParam('baz') 33 | ->withParam('ho', 'hi'); 34 | 35 | print_r($qs->getParams()); 36 | /* Array 37 | ( 38 | [foo] => foofoo 39 | [ho] => hi 40 | ) */ 41 | 42 | print $qs; // foo=foofoo&ho=hi 43 | ``` 44 | 45 | ## Documentation 46 | 47 | [Instantiation / Parsing](doc/Instantiation.md) 48 | 49 | [Manipulate parameters](doc/ManipulateParameters.md) 50 | 51 | [Render as string](doc/RenderAsString.md) 52 | 53 | ## Installation 54 | PHP 7.1+ is required. 55 | > composer require bentools/querystring:^1.0 56 | 57 | ## Tests 58 | > ./vendor/bin/phpunit 59 | 60 | ## License 61 | MIT 62 | 63 | ## See also 64 | 65 | [bentools/uri-factory](https://github.com/bpolaszek/uri-factory) - A PSR-7 `UriInterface` factory based on your own dependencies. 66 | 67 | [bentools/pager](https://github.com/bpolaszek/bentools-pager) - A simple, object oriented Pager. 68 | 69 | [bentools/where](https://github.com/bpolaszek/where) - A framework-agnostic fluent, immutable, SQL query builder. 70 | 71 | [bentools/picker](https://github.com/bpolaszek/picker) - Pick a random item from an array, with weight management. 72 | 73 | [bentools/psr7-request-matcher](https://github.com/bpolaszek/psr7-request-matcher) - A PSR-7 request matcher interface. 74 | 75 | [bentools/cartesian-product](https://github.com/bpolaszek/cartesian-product) - Generate all possible combinations from a multidimensionnal array. 76 | 77 | [bentools/string-combinations](https://github.com/bpolaszek/string-combinations) - A string combinations generator. 78 | 79 | [bentools/flatten-iterator](https://github.com/bpolaszek/flatten-iterator) - An iterator that flattens multiple iterators or arrays. 80 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bentools/querystring", 3 | "description": "Query String manipulation library. PHP 7.1+. No dependency, immutable, PSR-7 compliant.", 4 | "keywords": ["url", "uri", "querystring", "query string", "psr-7", "psr7"], 5 | "authors": [ 6 | { 7 | "name": "Beno!t POLASZEK", 8 | "email": "bpolaszek@gmail.com", 9 | "homepage": "http://www.polaszek.fr", 10 | "role": "Developer" 11 | } 12 | ], 13 | "type": "library", 14 | "license": "MIT", 15 | "require": { 16 | "php": ">=7.1" 17 | }, 18 | "require-dev": { 19 | "league/uri": "^5.0", 20 | "phpunit/phpunit": "^7.5|^8.0|^9.0", 21 | "squizlabs/php_codesniffer": "^3.0", 22 | "symfony/var-dumper": "@stable" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "BenTools\\QueryString\\": "src" 27 | }, 28 | "files": [ 29 | "src/functions.php" 30 | ] 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "BenTools\\QueryString\\Tests\\": "tests" 35 | }, 36 | "files": [ 37 | "vendor/symfony/var-dumper/Resources/functions/dump.php" 38 | ] 39 | }, 40 | "config": { 41 | "sort-packages": true 42 | }, 43 | "extra": { 44 | "branch-alias": { 45 | "dev-master": "1.0.x-dev" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /doc/Instantiation.md: -------------------------------------------------------------------------------- 1 | # Instantiation 2 | 3 | You can create a `QueryString` object from a PSR-7 `UriInterface` object, a string or an array of values. 4 | 5 | ```php 6 | require_once __DIR__ . '/vendor/autoload.php'; 7 | 8 | use function BenTools\QueryString\query_string; 9 | 10 | // Instanciate from an existing Psr\Http\Message\UriInterface object 11 | $qs = query_string($uri); 12 | 13 | // Instanciate from a string 14 | $qs = query_string('foo=bar&baz=bat'); 15 | $qs = query_string('?foo=bar&baz=bat'); // This works too 16 | 17 | // Instanciate from an array 18 | $qs = query_string(['foo' => 'bar', 'baz' => 'bat']); 19 | 20 | // Or create an empty object to get started 21 | $qs = query_string(); 22 | ``` 23 | 24 | If you don't like shortcut functions, use the class' factory: 25 | ```php 26 | use BenTools\QueryString\QueryString; 27 | $qs = QueryString::factory('foo=bar&baz=bat'); // Same argument requirements 28 | ``` 29 | 30 | ## Instantiate from current location 31 | 32 | It will read `$_SERVER['QUERY_STRING']`. 33 | 34 | ```php 35 | use function BenTools\QueryString\query_string; 36 | $qs = query_string()->withCurrentLocation(); 37 | ``` 38 | or: 39 | ```php 40 | use BenTools\QueryString\QueryString; 41 | $qs = QueryString::createFromCurrentLocation(); 42 | ``` 43 | 44 | Of course this will throw a `RuntimeException` when trying to run this from `cli` :smile: 45 | 46 | ## Flat query parser 47 | 48 | By default, PHP considers that `?foo=bar&foo=baz` has a single parameter `foo` with value `baz`. 49 | 50 | If you need to parse such query strings, you can use the `FlatParser`: multiple keys will be detected and converted to arrays. 51 | 52 | ```php 53 | use BenTools\QueryString\Parser\FlatParser; 54 | use function BenTools\QueryString\query_string; 55 | 56 | $qs = query_string('foo=bar&foo=baz&bar=foo', new FlatParser()); 57 | var_dump($qs->getParam('foo')); // array('bar', 'baz') 58 | var_dump($qs->getParam('bar')); // string 'foo' 59 | ``` 60 | 61 | ## Create your own parser 62 | 63 | [bentools/querystring](https://github.com/bpolaszek/querystring) relies on PHP's `parse_str()` function to create an array of parameters from your query string. 64 | 65 | This may not fit your needs, because sometimes you have to decode some query strings like this: 66 | 67 | > status=foo&status=bar 68 | 69 | In this case, `parse_str()` will not consider `status` as an array. You can implement your own `QueryStringParserInterface` to get over this: 70 | 71 | ```php 72 | use BenTools\QueryString\Parser\QueryStringParserInterface; 73 | use function BenTools\QueryString\query_string; 74 | use function BenTools\QueryString\pairs; 75 | 76 | $myParser = new class implements QueryStringParserInterface 77 | { 78 | 79 | public function parse(string $queryString): array 80 | { 81 | $params = []; 82 | 83 | foreach (pairs($queryString) as $key => $value) { 84 | if (isset($params[$key])) { 85 | $params[$key] = (array) $params[$key]; 86 | $params[$key][] = $value; 87 | } else { 88 | $params[$key] = $value; 89 | } 90 | } 91 | 92 | return $params; 93 | } 94 | 95 | }; 96 | 97 | $qs = query_string('foo=bar&foo=baz&baz=bat', $myParser); 98 | print_r($qs->getParams()); 99 | /* Array 100 | ( 101 | [foo] => Array 102 | ( 103 | [0] => bar 104 | [1] => baz 105 | ) 106 | 107 | [baz] => bat 108 | ) */ 109 | 110 | // You can also set it as the default parser: 111 | QueryString::setDefaultParser($myParser); 112 | 113 | // Or restore the native one 114 | QueryString::restoreDefaultParser(); 115 | ``` 116 | 117 | 118 | [Next](ManipulateParameters.md) - Manipulate parameters 119 | -------------------------------------------------------------------------------- /doc/ManipulateParameters.md: -------------------------------------------------------------------------------- 1 | # Manipulate parameters 2 | 3 | **Retrieve all parameters** 4 | ```php 5 | use function BenTools\QueryString\query_string; 6 | 7 | $qs = query_string( 8 | 'foo=bar&baz=bat' 9 | ); 10 | 11 | print_r($qs->getParams()); 12 | /* Array 13 | ( 14 | [foo] => bar 15 | [baz] => bat 16 | ) */ 17 | ``` 18 | 19 | **Retrieve specific parameter** 20 | ```php 21 | print_r($qs->getParam('foo')); // bar 22 | ``` 23 | 24 | **Add / replace parameter** 25 | ```php 26 | $qs = $qs->withParam('foo', 'foofoo'); 27 | print_r($qs->getParams()); 28 | /* Array 29 | ( 30 | [foo] => foofoo 31 | [baz] => bat 32 | ) */ 33 | print($qs); // foo=foofoo&baz=bat 34 | ``` 35 | 36 | **Remove parameter** 37 | ```php 38 | $qs = $qs->withoutParam('baz'); 39 | print($qs); // foo=foofoo 40 | ``` 41 | 42 | **Create from a complex, nested array** 43 | ```php 44 | $qs = query_string([ 45 | 'yummy' => [ 46 | 'fruits' => [ 47 | 'strawberries', 48 | 'apples', 49 | 'raspberries', 50 | ], 51 | ] 52 | ]); 53 | ``` 54 | **Retrieve a parameter at a specific path** 55 | ```php 56 | print($qs->getParam('yummy', 'fruits', 2)); // raspberries 57 | ``` 58 | 59 | 60 | **Remove a parameter at a specific path** 61 | 62 | _Example: remove "apples", resolved at `$params['yummy']['fruits'][1]`_ 63 | 64 | ```php 65 | $qs = $qs->withoutParam('yummy', 'fruits', 1); 66 | print_r($qs->getParams()); 67 | /* Array 68 | ( 69 | [yummy] => Array 70 | ( 71 | [fruits] => Array 72 | ( 73 | [0] => strawberries 74 | [1] => raspberries // Yep, this indexed array has been reordered. 75 | ) 76 | 77 | ) 78 | 79 | )*/ 80 | ``` 81 | 82 | ## Retrieve key / value pairs 83 | 84 | This can be useful if you want to generate hidden input fields based on current query string. 85 | 86 | ```php 87 | use function BenTools\QueryString\query_string; 88 | use function BenTools\QueryString\withoutNumericIndices; 89 | 90 | $qs = query_string( 91 | 'f[status][]=pending&f[status][]=reopened&f[status][]=waiting for changes', 92 | withoutNumericIndices() 93 | ); 94 | 95 | foreach ($qs->getPairs() as $key => $value) { 96 | printf( 97 | '' . PHP_EOL, 98 | urldecode($key), 99 | htmlentities($value) 100 | ); 101 | } 102 | ``` 103 | 104 | Output: 105 | ``` 106 | 107 | 108 | 109 | ``` 110 | 111 | More conveniently, you can `urldecode` keys and values if needed: 112 | ```php 113 | foreach ($qs->getPairs($decodeKeys = true, $decodeValues = true) as $key => $value) { 114 | printf( 115 | '' . PHP_EOL, 116 | $key, 117 | $value 118 | ); 119 | } 120 | ``` 121 | 122 | Output: 123 | ``` 124 | 125 | 126 | 127 | ``` 128 | 129 | 130 | [Previous](Instantiation.md) - Instantiation 131 | 132 | [Next](RenderAsString.md) - Render as string 133 | -------------------------------------------------------------------------------- /doc/RenderAsString.md: -------------------------------------------------------------------------------- 1 | # Render as string 2 | 3 | ```php 4 | use function BenTools\QueryString\query_string; 5 | 6 | $qs = query_string([ 7 | 'yummy' => [ 8 | 'fruits' => [ 9 | 'strawberries', 10 | 'raspberries', 11 | ], 12 | ] 13 | ]); 14 | 15 | print(urldecode((string) $qs)); // yummy[fruits][0]=strawberries&yummy[fruits][1]=raspberries 16 | ``` 17 | 18 | Note that the leading question mark will never be included. 19 | 20 | ## Change renderer 21 | 22 | ### Remove numeric indices: 23 | This renderer will render indexed arrays as `foo[]=bar&foo[]=baz` instead of `foo[0]=bar&foo[1]=baz`. 24 | 25 | ```php 26 | use function BenTools\QueryString\withoutNumericIndices; 27 | 28 | $qs = $qs->withRenderer(withoutNumericIndices()); 29 | print(urldecode((string) $qs)); 30 | ``` 31 | 32 | ### Flat renderer 33 | This renderer will render indexed arrays as `foo=bar&foo=baz` instead of `foo[0]=bar&foo[1]=baz`. 34 | 35 | ```php 36 | use function BenTools\QueryString\flat; 37 | 38 | $qs = $qs->withRenderer(flat()); 39 | print(urldecode((string) $qs)); 40 | ``` 41 | 42 | ### Global setting 43 | 44 | You can define a default renderer on a global scope for future QueryString objects: 45 | 46 | ```php 47 | use BenTools\QueryString\QueryString; 48 | use function BenTools\QueryString\withoutNumericIndices; 49 | 50 | QueryString::setDefaultRenderer(withoutNumericIndices()); 51 | ``` 52 | 53 | You can also create your own rendering logic by implementing `BenTools\QueryString\Renderer\QueryStringRendererInterface`. 54 | 55 | 56 | ## Change encoding 57 | 58 | This library renders query strings with [RFC 3986](http://www.rfc-base.org/txt/rfc-3986.txt) by default, but you can change it if needed. 59 | ```php 60 | $qs = query_string('param=foo bar'); 61 | print((string) $qs); // param=foo%20bar 62 | 63 | $qs = $qs->withRenderer( 64 | $qs->getRenderer()->withEncoding(PHP_QUERY_RFC1738) 65 | ); 66 | print((string) $qs); // param=foo+bar 67 | ``` 68 | 69 | ## Change separator 70 | 71 | ```php 72 | $qs = query_string('foo=bar&baz=bat'); 73 | $qs = $qs->withRenderer( 74 | $qs->getRenderer()->withSeparator(';') 75 | ); 76 | print((string) $qs); // foo=bar;baz=bat 77 | ``` 78 | 79 | 80 | ## PSR-7 manipulation 81 | Example: 82 | 83 | ```php 84 | use function BenTools\QueryString\query_string; 85 | 86 | /** 87 | * @var \Psr\Http\Message\MessageInterface $uri 88 | */ 89 | print((string) $uri); // http://www.example.net/ 90 | 91 | $uri = $uri->withQuery( 92 | (string) query_string($uri)->withParam('foo', 'bar') 93 | ); 94 | 95 | print((string) $uri); // http://www.example.net/?foo=bar 96 | ``` 97 | 98 | [Previous](ManipulateParameters.md) - Manipulate parameters 99 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | tests 20 | 21 | 22 | 23 | 24 | src 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Pairs.php: -------------------------------------------------------------------------------- 1 | queryString = $queryString; 41 | $this->decodeKeys = $decodeKeys; 42 | $this->decodeValues = $decodeValues; 43 | $this->separator = $separator; 44 | } 45 | 46 | /** 47 | * @param string $queryString 48 | * @return Pairs 49 | */ 50 | public function withQueryString(string $queryString): self 51 | { 52 | $clone = clone $this; 53 | $clone->queryString = $queryString; 54 | return $clone; 55 | } 56 | 57 | /** 58 | * @param bool $decodeKeys 59 | * @return Pairs 60 | */ 61 | public function withDecodeKeys(bool $decodeKeys): self 62 | { 63 | $clone = clone $this; 64 | $clone->decodeKeys = $decodeKeys; 65 | return $clone; 66 | } 67 | 68 | /** 69 | * @param bool $decodeValues 70 | * @return Pairs 71 | */ 72 | public function withDecodeValues(bool $decodeValues): self 73 | { 74 | $clone = clone $this; 75 | $clone->decodeValues = $decodeValues; 76 | return $clone; 77 | } 78 | 79 | /** 80 | * @param null|string $separator 81 | * @return Pairs 82 | */ 83 | public function withSeparator(?string $separator): self 84 | { 85 | $clone = clone $this; 86 | $clone->separator = $separator; 87 | return $clone; 88 | } 89 | 90 | /** 91 | * @return Traversable 92 | */ 93 | public function getIterator(): Traversable 94 | { 95 | if ('' === trim($this->queryString)) { 96 | return; 97 | } 98 | 99 | $separator = $this->separator ?? ini_get('arg_separator.input'); 100 | 101 | if ('' === $separator) { 102 | throw new \RuntimeException("A separator cannot be blank."); 103 | } 104 | 105 | $pairs = explode($separator, $this->queryString); 106 | 107 | foreach ($pairs as $pair) { 108 | $keyValue = explode('=', $pair); 109 | $key = $keyValue[0]; 110 | $value = $keyValue[1] ?? null; 111 | 112 | if (true === $this->decodeKeys) { 113 | $key = urldecode($key); 114 | } 115 | 116 | if (true === $this->decodeValues) { 117 | $value = urldecode($value); 118 | } 119 | 120 | yield $key => $value; 121 | } 122 | } 123 | 124 | /** 125 | * @return string 126 | */ 127 | public function __toString(): string 128 | { 129 | return (string) $this->queryString; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Parser/FlatParser.php: -------------------------------------------------------------------------------- 1 | separator = $separator; 19 | } 20 | 21 | /** 22 | * @return array 23 | */ 24 | public function parse(string $queryString): array 25 | { 26 | $params = []; 27 | $pairs = explode($this->separator, $queryString); 28 | foreach ($pairs as $pair) { 29 | if (!isset($params[$pair]) && false === strpos($pair, '=')) { 30 | $key = urldecode($pair); 31 | $params[$key] = null; 32 | continue; 33 | } 34 | [$key, $value] = explode('=', $pair); 35 | $key = urldecode($key); 36 | $value = urldecode($value); 37 | if (!isset($params[$key])) { 38 | $params[$key] = $value; 39 | } elseif (!is_array($params[$key])) { 40 | $params[$key] = [$params[$key], $value]; 41 | } else { 42 | $params[$key][] = $value; 43 | } 44 | } 45 | 46 | return $params; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Parser/NativeParser.php: -------------------------------------------------------------------------------- 1 | $value) { 42 | $this->params[(string) $key] = $value; 43 | } 44 | $this->renderer = self::getDefaultRenderer(); 45 | } 46 | 47 | /** 48 | * @param array $params 49 | * @return QueryString 50 | */ 51 | private static function createFromParams(array $params): self 52 | { 53 | return new self($params); 54 | } 55 | 56 | /** 57 | * @param \Psr\Http\Message\UriInterface $uri 58 | * @param QueryStringParserInterface $queryStringParser 59 | * @return QueryString 60 | */ 61 | private static function createFromUri($uri, QueryStringParserInterface $queryStringParser): self 62 | { 63 | return self::createFromString($uri->getQuery(), $queryStringParser); 64 | } 65 | 66 | /** 67 | * @param string $string 68 | * @param QueryStringParserInterface $queryStringParser 69 | * @return QueryString 70 | */ 71 | private static function createFromString(string $string, QueryStringParserInterface $queryStringParser): self 72 | { 73 | return new self($queryStringParser->parse($string)); 74 | } 75 | 76 | /** 77 | * @param QueryStringParserInterface|null $queryStringParser 78 | * @return QueryString 79 | * @throws \RuntimeException 80 | */ 81 | public static function createFromCurrentLocation(?QueryStringParserInterface $queryStringParser = null): self 82 | { 83 | if (!isset($_SERVER['REQUEST_URI'])) { 84 | throw new \RuntimeException('$_SERVER[\'REQUEST_URI\'] has not been set.'); 85 | } 86 | return self::createFromString($_SERVER['REQUEST_URI'], $queryStringParser ?? self::getDefaultParser()); 87 | } 88 | 89 | /** 90 | * @return QueryString 91 | * @throws \RuntimeException 92 | */ 93 | public function withCurrentLocation(): self 94 | { 95 | return self::createFromCurrentLocation(); 96 | } 97 | 98 | /** 99 | * @param $input 100 | * @param QueryStringParserInterface|null $queryStringParser 101 | * @return QueryString 102 | * @throws \InvalidArgumentException 103 | * @throws \TypeError 104 | */ 105 | public static function factory($input = null, ?QueryStringParserInterface $queryStringParser = null): self 106 | { 107 | if (is_array($input)) { 108 | return self::createFromParams($input); 109 | } elseif (null === $input) { 110 | return self::createFromParams([]); 111 | } elseif (is_a($input, 'Psr\Http\Message\UriInterface')) { 112 | return self::createFromUri($input, $queryStringParser ?? self::getDefaultParser()); 113 | } elseif (is_string($input)) { 114 | return self::createFromString($input, $queryStringParser ?? self::getDefaultParser()); 115 | } 116 | throw new \InvalidArgumentException(sprintf('Expected array, string or Psr\Http\Message\UriInterface, got %s', is_object($input) ? get_class($input) : gettype($input))); 117 | } 118 | 119 | /** 120 | * @return array 121 | */ 122 | public function getParams(): ?array 123 | { 124 | return $this->params; 125 | } 126 | 127 | /** 128 | * @param string $key 129 | * @param array ...$deepKeys 130 | * @return mixed|null 131 | */ 132 | public function getParam(string $key, ...$deepKeys) 133 | { 134 | $param = $this->params[$key] ?? null; 135 | foreach ($deepKeys as $key) { 136 | if (!isset($param[$key])) { 137 | return null; 138 | } 139 | $param = $param[$key]; 140 | } 141 | return $param; 142 | } 143 | 144 | /** 145 | * @param string $key 146 | * @return bool 147 | */ 148 | public function hasParam(string $key, ...$deepKeys): bool 149 | { 150 | return [] === $deepKeys ? array_key_exists($key, $this->params) : null !== $this->getParam($key, ...$deepKeys); 151 | } 152 | 153 | /** 154 | * Yield key => value pairs. 155 | * 156 | * @param bool $decodeKeys 157 | * @param bool $decodeValues 158 | * @return Traversable 159 | */ 160 | public function getPairs(bool $decodeKeys = false, bool $decodeValues = false): Traversable 161 | { 162 | return new Pairs((string) $this, $decodeKeys, $decodeValues, $this->getRenderer()->getSeparator()); 163 | } 164 | 165 | /** 166 | * @param string $key 167 | * @param $value 168 | * @return QueryString 169 | */ 170 | public function withParam(string $key, $value): self 171 | { 172 | $clone = clone $this; 173 | $clone->params[$key] = $value; 174 | return $clone; 175 | } 176 | 177 | /** 178 | * @param array $params 179 | * @return QueryString 180 | */ 181 | public function withParams(array $params, bool $append = false): self 182 | { 183 | $clone = clone $this; 184 | if (!$append) { 185 | $clone->params = []; 186 | } 187 | foreach ($params as $key => $value) { 188 | $clone->params[(string) $key] = $value; 189 | } 190 | return $clone; 191 | } 192 | 193 | /** 194 | * @param string $key 195 | * @param array ...$deepKeys 196 | * @return QueryString 197 | */ 198 | public function withoutParam(string $key, ...$deepKeys): self 199 | { 200 | $clone = clone $this; 201 | 202 | // $key does not exist 203 | if (!isset($clone->params[$key])) { 204 | return $clone; 205 | } 206 | 207 | // $key exists and there are no $deepKeys 208 | if ([] === $deepKeys) { 209 | unset($clone->params[$key]); 210 | return $clone; 211 | } 212 | 213 | // Deepkeys 214 | $clone->params[$key] = $this->removeFromPath($clone->params[$key], ...$deepKeys); 215 | return $clone; 216 | } 217 | 218 | /** 219 | * @return QueryStringRendererInterface 220 | */ 221 | public function getRenderer(): QueryStringRendererInterface 222 | { 223 | return $this->renderer; 224 | } 225 | 226 | /** 227 | * @param QueryStringRendererInterface $renderer 228 | * @return QueryString 229 | */ 230 | public function withRenderer(QueryStringRendererInterface $renderer): self 231 | { 232 | $clone = clone $this; 233 | $clone->renderer = $renderer; 234 | return $clone; 235 | } 236 | 237 | /** 238 | * @return string 239 | */ 240 | public function __toString(): string 241 | { 242 | return $this->renderer->render($this); 243 | } 244 | 245 | /** 246 | * @param array $array 247 | * @return bool 248 | */ 249 | private function isAnIndexedArray(array $array): bool 250 | { 251 | $keys = array_keys($array); 252 | return $keys === array_filter($keys, 'is_int'); 253 | } 254 | 255 | /** 256 | * @param array $params 257 | * @param array ...$keys 258 | * @return array 259 | */ 260 | private function removeFromPath(array $params, ...$keys): array 261 | { 262 | $nbKeys = count($keys); 263 | $lastIndex = $nbKeys - 1; 264 | $cursor = &$params; 265 | 266 | foreach ($keys as $k => $key) { 267 | if (!isset($cursor[$key])) { 268 | return $params; // End here if not found 269 | } 270 | 271 | if ($k === $lastIndex) { 272 | unset($cursor[$key]); 273 | if (is_array($cursor) && $this->isAnIndexedArray($cursor)) { 274 | $cursor = array_values($cursor); 275 | } 276 | break; 277 | } 278 | 279 | $cursor = &$cursor[$key]; 280 | } 281 | 282 | return $params; 283 | } 284 | 285 | /** 286 | * Returns the default renderer. 287 | * 288 | * @return QueryStringRendererInterface 289 | */ 290 | public static function getDefaultRenderer(): QueryStringRendererInterface 291 | { 292 | if (!isset(self::$defaultRenderer)) { 293 | self::restoreDefaultRenderer(); 294 | } 295 | return self::$defaultRenderer; 296 | } 297 | 298 | /** 299 | * Changes default renderer. 300 | * 301 | * @param QueryStringRendererInterface $defaultRenderer 302 | */ 303 | public static function setDefaultRenderer(QueryStringRendererInterface $defaultRenderer): void 304 | { 305 | self::$defaultRenderer = $defaultRenderer; 306 | } 307 | 308 | /** 309 | * Restores the default renderer. 310 | */ 311 | public static function restoreDefaultRenderer(): void 312 | { 313 | self::$defaultRenderer = NativeRenderer::factory(); 314 | } 315 | 316 | /** 317 | * Returns the default parser. 318 | * 319 | * @return QueryStringParserInterface 320 | */ 321 | public static function getDefaultParser(): QueryStringParserInterface 322 | { 323 | if (!isset(self::$defaultParser)) { 324 | self::restoreDefaultParser(); 325 | } 326 | return self::$defaultParser; 327 | } 328 | 329 | /** 330 | * Changes default parser. 331 | * 332 | * @param QueryStringParserInterface $defaultParser 333 | */ 334 | public static function setDefaultParser(QueryStringParserInterface $defaultParser): void 335 | { 336 | self::$defaultParser = $defaultParser; 337 | } 338 | 339 | /** 340 | * Restores the default parser. 341 | */ 342 | public static function restoreDefaultParser(): void 343 | { 344 | self::$defaultParser = new NativeParser(); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/Renderer/ArrayValuesNormalizerRenderer.php: -------------------------------------------------------------------------------- 1 | renderer = $renderer; 22 | } 23 | 24 | public static function factory(?QueryStringRendererInterface $renderer = null) 25 | { 26 | return new self($renderer ?? NativeRenderer::factory()); 27 | } 28 | 29 | /** 30 | * @inheritDoc 31 | */ 32 | public function render(QueryString $queryString): string 33 | { 34 | $separator = $this->getSeparator() ?? ini_get('arg_separator.output'); 35 | $input = $this->renderer->render($queryString); 36 | $output = ''; 37 | 38 | $iterator = new IteratorIterator(new Pairs($input, false, false, $separator)); 39 | $iterator->rewind(); 40 | while (true === $iterator->valid()) { 41 | $key = $iterator->key(); 42 | $value = $iterator->current(); 43 | $iterator->next(); 44 | $output .= sprintf( 45 | '%s=%s', 46 | preg_replace('/\%5B\d+\%5D/', '%5B%5D', $key), 47 | $value 48 | ); 49 | if (false !== $iterator->valid()) { 50 | $output .= $separator; 51 | } 52 | } 53 | 54 | return $output; 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public function getEncoding(): int 61 | { 62 | return $this->renderer->getEncoding(); 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | public function withEncoding(int $encoding): QueryStringRendererInterface 69 | { 70 | return new self($this->renderer->withEncoding($encoding)); 71 | } 72 | 73 | /** 74 | * @inheritDoc 75 | */ 76 | public function getSeparator(): ?string 77 | { 78 | return $this->renderer->getSeparator(); 79 | } 80 | 81 | /** 82 | * @inheritDoc 83 | */ 84 | public function withSeparator(?string $separator): QueryStringRendererInterface 85 | { 86 | return new self($this->renderer->withSeparator($separator)); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Renderer/FlatRenderer.php: -------------------------------------------------------------------------------- 1 | renderer = $renderer; 17 | } 18 | 19 | public static function factory(?QueryStringRendererInterface $renderer = null) 20 | { 21 | return new self($renderer ?? NativeRenderer::factory()); 22 | } 23 | 24 | /** 25 | * @inheritDoc 26 | */ 27 | public function render(QueryString $queryString): string 28 | { 29 | $separator = $this->getSeparator() ?? ini_get('arg_separator.output'); 30 | $parts = [[]]; 31 | 32 | foreach ($queryString->getParams() as $key => $value) { 33 | $parts[] = $this->getParts($key, $value); 34 | } 35 | 36 | return \implode($separator, \array_merge([], ...$parts)); 37 | } 38 | 39 | /** 40 | * @inheritDoc 41 | */ 42 | public function getEncoding(): int 43 | { 44 | return $this->renderer->getEncoding(); 45 | } 46 | 47 | /** 48 | * @inheritDoc 49 | */ 50 | public function withEncoding(int $encoding): QueryStringRendererInterface 51 | { 52 | return new self($this->renderer->withEncoding($encoding)); 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function getSeparator(): ?string 59 | { 60 | return $this->renderer->getSeparator(); 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function withSeparator(?string $separator): QueryStringRendererInterface 67 | { 68 | return new self($this->renderer->withSeparator($separator)); 69 | } 70 | 71 | private function getParts($key, $value): array 72 | { 73 | if (\is_iterable($value)) { 74 | $parts = [[]]; 75 | foreach ($value as $sub) { 76 | $parts[] = $this->getParts($key, $sub); 77 | } 78 | 79 | return \array_merge([], ...$parts); 80 | } 81 | 82 | $encode = \PHP_QUERY_RFC1738 === $this->getEncoding() ? '\\urlencode' : '\\rawurlencode'; 83 | 84 | return [$key . '=' . \call_user_func($encode, $value)]; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Renderer/NativeRenderer.php: -------------------------------------------------------------------------------- 1 | encoding = $encoding; 18 | } 19 | 20 | /** 21 | * @param int $encoding 22 | * @return NativeRenderer 23 | * @throws \InvalidArgumentException 24 | */ 25 | public static function factory(int $encoding = self::DEFAULT_ENCODING): self 26 | { 27 | self::validateEncoding($encoding); 28 | 29 | return new self($encoding); 30 | } 31 | 32 | 33 | /** 34 | * @inheritDoc 35 | */ 36 | public function render(QueryString $queryString): string 37 | { 38 | return http_build_query( 39 | $queryString->getParams(), 40 | '', 41 | $this->separator ?? ini_get('arg_separator.output'), 42 | $this->encoding 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Renderer/QueryStringRendererInterface.php: -------------------------------------------------------------------------------- 1 | encoding; 23 | } 24 | 25 | /** 26 | * @param int $encoding 27 | * @return QueryStringRendererInterface 28 | * @throws \InvalidArgumentException 29 | */ 30 | public function withEncoding(int $encoding): QueryStringRendererInterface 31 | { 32 | self::validateEncoding($encoding); 33 | 34 | $clone = clone $this; 35 | $clone->encoding = $encoding; 36 | return $clone; 37 | } 38 | 39 | /** 40 | * @return null|string 41 | */ 42 | public function getSeparator(): ?string 43 | { 44 | return $this->separator; 45 | } 46 | 47 | /** 48 | * @param null|string $separator 49 | * @return QueryStringRendererInterface 50 | */ 51 | public function withSeparator(?string $separator): QueryStringRendererInterface 52 | { 53 | $clone = clone $this; 54 | $clone->separator = $separator; 55 | 56 | return $clone; 57 | } 58 | 59 | /** 60 | * @param int $encoding 61 | * @throws \InvalidArgumentException 62 | */ 63 | protected static function validateEncoding(int $encoding): void 64 | { 65 | if (!in_array($encoding, [PHP_QUERY_RFC1738, PHP_QUERY_RFC3986])) { 66 | throw new \InvalidArgumentException("Invalid encoding."); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | 'bar', 20 | 'sort' => [ 21 | 'bar' => 'desc', 22 | 'foo' => 'asc', 23 | ], 24 | 'filters' => [ 25 | 'foo' => [ 26 | 'bar', 27 | 'baz', 28 | ], 29 | 'bar' => [ 30 | 'foo bar', 31 | ], 32 | ], 33 | ]; 34 | 35 | $qs = query_string($data); 36 | $renderer = withoutNumericIndices(); 37 | $this->assertInstanceOf(ArrayValuesNormalizerRenderer::class, $renderer); 38 | $this->assertEquals('foo=bar&sort%5Bbar%5D=desc&sort%5Bfoo%5D=asc&filters%5Bfoo%5D%5B%5D=bar&filters%5Bfoo%5D%5B%5D=baz&filters%5Bbar%5D%5B%5D=foo%20bar', $renderer->render($qs)); 39 | $this->assertEquals('foo=bar&sort[bar]=desc&sort[foo]=asc&filters[foo][]=bar&filters[foo][]=baz&filters[bar][]=foo bar', urldecode($qs->withRenderer($renderer))); 40 | $this->assertEquals('foo=bar&sort%5Bbar%5D=desc&sort%5Bfoo%5D=asc&filters%5Bfoo%5D%5B%5D=bar&filters%5Bfoo%5D%5B%5D=baz&filters%5Bbar%5D%5B%5D=foo+bar', $renderer->withEncoding(PHP_QUERY_RFC1738)->render($qs)); 41 | $this->assertEquals('foo=bar&sort[bar]=desc&sort[foo]=asc&filters[foo][]=bar&filters[foo][]=baz&filters[bar][]=foo bar', urldecode($qs->withRenderer($renderer->withEncoding(PHP_QUERY_RFC1738)))); 42 | } 43 | 44 | public function testRendererOnlyAffectsKeys(): void 45 | { 46 | $data = [ 47 | 'foo' => [ 48 | 'bar baz[0]' 49 | ] 50 | ]; 51 | $qs = query_string($data); 52 | $this->assertEquals('foo%5B%5D=bar%20baz%5B0%5D', (string) $qs->withRenderer(withoutNumericIndices())); 53 | } 54 | 55 | public function testChangeEncoding(): void 56 | { 57 | $renderer = ArrayValuesNormalizerRenderer::factory(); 58 | $this->assertNotSame($renderer->withEncoding($renderer->getEncoding()), $renderer); 59 | 60 | $this->assertEquals(QueryStringRendererInterface::DEFAULT_ENCODING, $renderer->getEncoding()); 61 | $renderer = $renderer->withEncoding(PHP_QUERY_RFC1738); 62 | $this->assertEquals(PHP_QUERY_RFC1738, $renderer->getEncoding()); 63 | } 64 | 65 | public function testChangeSeparator(): void 66 | { 67 | 68 | ini_set('arg_separator.output', '~'); 69 | 70 | $qs = query_string(['foo' => 'bar', 'bar' => 'baz']); 71 | $renderer = ArrayValuesNormalizerRenderer::factory(); 72 | $this->assertNull($renderer->getSeparator()); 73 | $this->assertEquals('foo=bar~bar=baz', $renderer->render($qs)); 74 | $this->assertNotSame($renderer->withSeparator($renderer->getSeparator()), $renderer); 75 | 76 | $renderer = $renderer->withSeparator('|'); 77 | $this->assertEquals('|', $renderer->getSeparator()); 78 | $this->assertEquals('foo=bar|bar=baz', $renderer->render($qs)); 79 | 80 | $renderer = $renderer->withSeparator(null); // Reset to default 81 | $this->assertEquals('foo=bar~bar=baz', $renderer->render($qs)); 82 | 83 | ini_set('arg_separator.output', $this->defaultSeparator); 84 | } 85 | 86 | public function testBlankSeparator(): void 87 | { 88 | $this->expectException(\RuntimeException::class); 89 | $qs = query_string(['foo' => 'bar', 'bar' => 'baz']); 90 | $renderer = ArrayValuesNormalizerRenderer::factory(); 91 | $renderer = $renderer->withSeparator(''); // Blank separator 92 | $renderer->render($qs); 93 | } 94 | 95 | public function setUp(): void 96 | { 97 | $this->defaultSeparator = ini_get('arg_separator.output'); 98 | } 99 | 100 | public function tearDown(): void 101 | { 102 | ini_set('arg_separator.output', $this->defaultSeparator); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tests/FlatParserTest.php: -------------------------------------------------------------------------------- 1 | ['foo', 'bar', 'foo bar'], 19 | 'foo' => 'bar', 20 | 'hi' => null, 21 | ]; 22 | $this->assertEquals($expected, $qs->getParams()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/FlatRendererTest.php: -------------------------------------------------------------------------------- 1 | 'bar', 15 | 'foos' => [ 16 | 'bar', 17 | 'foo bar', 18 | ], 19 | 'fruits' => [ 20 | 'banana' => 'yellow', 21 | 'strawberry' => 'red', 22 | ], 23 | ]; 24 | 25 | $qs = query_string($data); 26 | $renderer = flat(); 27 | 28 | $this->assertEquals('foo=bar&foos=bar&foos=foo%20bar&fruits=yellow&fruits=red', (string) $qs->withRenderer( 29 | $renderer 30 | )); 31 | 32 | $this->assertEquals('foo=bar&foos=bar&foos=foo+bar&fruits=yellow&fruits=red', (string) $qs->withRenderer( 33 | $renderer->withEncoding(PHP_QUERY_RFC1738) 34 | )); 35 | 36 | $this->assertEquals('foo=bar;foos=bar;foos=foo+bar;fruits=yellow;fruits=red', (string) $qs->withRenderer( 37 | $renderer->withEncoding(PHP_QUERY_RFC1738)->withSeparator(';') 38 | )); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /tests/NativeParserTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(['foo' => 'bar', 'baz' => 'bat'], $parser->parse('foo=bar&baz=bat')); 15 | $this->assertEquals(['foo' => 'bar', 'baz' => 'bat'], $parser->parse('?foo=bar&baz=bat')); 16 | $this->assertEquals(['foo' => 'baz'], $parser->parse('foo=bar&foo=baz')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/NativeRendererTest.php: -------------------------------------------------------------------------------- 1 | 'bar', 18 | 'sort' => [ 19 | 'bar' => 'desc', 20 | 'foo' => 'asc', 21 | ], 22 | 'filters' => [ 23 | 'foo' => [ 24 | 'bar', 25 | 'baz', 26 | ], 27 | 'bar' => [ 28 | 'foo bar', 29 | ], 30 | ], 31 | ]; 32 | 33 | $qs = query_string($data); 34 | $renderer = NativeRenderer::factory(); 35 | $this->assertInstanceOf(NativeRenderer::class, $renderer); 36 | $this->assertEquals('foo=bar&sort%5Bbar%5D=desc&sort%5Bfoo%5D=asc&filters%5Bfoo%5D%5B0%5D=bar&filters%5Bfoo%5D%5B1%5D=baz&filters%5Bbar%5D%5B0%5D=foo%20bar', $renderer->render($qs)); 37 | $this->assertEquals('foo=bar&sort[bar]=desc&sort[foo]=asc&filters[foo][0]=bar&filters[foo][1]=baz&filters[bar][0]=foo bar', urldecode($renderer->render($qs))); 38 | $qs = $qs->withRenderer($qs->getRenderer()->withEncoding(PHP_QUERY_RFC1738)); 39 | $this->assertEquals('foo=bar&sort%5Bbar%5D=desc&sort%5Bfoo%5D=asc&filters%5Bfoo%5D%5B0%5D=bar&filters%5Bfoo%5D%5B1%5D=baz&filters%5Bbar%5D%5B0%5D=foo+bar', $renderer->withEncoding(PHP_QUERY_RFC1738)->render($qs)); 40 | $this->assertEquals('foo=bar&sort[bar]=desc&sort[foo]=asc&filters[foo][0]=bar&filters[foo][1]=baz&filters[bar][0]=foo bar', urldecode($qs->withRenderer($renderer->withEncoding(PHP_QUERY_RFC1738)))); 41 | } 42 | 43 | public function testFactoryFails(): void 44 | { 45 | $this->expectException(\InvalidArgumentException::class); 46 | NativeRenderer::factory(1000); 47 | } 48 | 49 | public function testChangeEncodingFails(): void 50 | { 51 | $this->expectException(\InvalidArgumentException::class); 52 | $renderer = NativeRenderer::factory(); 53 | $renderer->withEncoding(1000); 54 | } 55 | 56 | public function testChangeEncoding(): void 57 | { 58 | $renderer = NativeRenderer::factory(); 59 | $this->assertNotSame($renderer->withEncoding($renderer->getEncoding()), $renderer); 60 | 61 | $this->assertEquals(QueryStringRendererInterface::DEFAULT_ENCODING, $renderer->getEncoding()); 62 | $renderer = $renderer->withEncoding(PHP_QUERY_RFC1738); 63 | $this->assertEquals(PHP_QUERY_RFC1738, $renderer->getEncoding()); 64 | } 65 | 66 | public function testChangeSeparator(): void 67 | { 68 | 69 | ini_set('arg_separator.output', '~'); 70 | 71 | $qs = query_string(['foo' => 'bar', 'bar' => 'baz']); 72 | $renderer = NativeRenderer::factory(); 73 | $this->assertNull($renderer->getSeparator()); 74 | $this->assertEquals('foo=bar~bar=baz', $renderer->render($qs)); 75 | $this->assertNotSame($renderer->withSeparator($renderer->getSeparator()), $renderer); 76 | 77 | $renderer = $renderer->withSeparator('|'); 78 | $this->assertEquals('|', $renderer->getSeparator()); 79 | $this->assertEquals('foo=bar|bar=baz', $renderer->render($qs)); 80 | 81 | $renderer = $renderer->withSeparator(null); // Reset to default 82 | $this->assertEquals('foo=bar~bar=baz', $renderer->render($qs)); 83 | 84 | $renderer = $renderer->withSeparator(''); // Blank separator 85 | $this->assertEquals('foo=barbar=baz', $renderer->render($qs)); 86 | 87 | ini_set('arg_separator.output', $this->defaultSeparator); 88 | } 89 | 90 | public function setUp(): void 91 | { 92 | $this->defaultSeparator = ini_get('arg_separator.output'); 93 | } 94 | 95 | public function tearDown(): void 96 | { 97 | ini_set('arg_separator.output', $this->defaultSeparator); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/PairsTest.php: -------------------------------------------------------------------------------- 1 | rewind(); 18 | $this->assertEquals('foo%5Bbar%5D', $pairs->key()); 19 | $this->assertEquals('baz%20bat', $pairs->current()); 20 | } 21 | 22 | public function testPairsWithKeyDecoding(): void 23 | { 24 | $qs = (string) query_string('foo[bar]=baz bat'); 25 | $pairs = new IteratorIterator(new Pairs($qs, true)); 26 | $pairs->rewind(); 27 | $this->assertEquals('foo[bar]', $pairs->key()); 28 | $this->assertEquals('baz%20bat', $pairs->current()); 29 | 30 | $qs = (string) query_string('foo[bar]=baz bat'); 31 | $pairs = new IteratorIterator((new Pairs($qs))->withDecodeKeys(true)); 32 | $pairs->rewind(); 33 | $this->assertEquals('foo[bar]', $pairs->key()); 34 | $this->assertEquals('baz%20bat', $pairs->current()); 35 | } 36 | 37 | public function testPairsWithValueDecoding(): void 38 | { 39 | $qs = (string) query_string('foo[bar]=baz bat'); 40 | $pairs = new IteratorIterator(new Pairs($qs, false, true)); 41 | $pairs->rewind(); 42 | $this->assertEquals('foo%5Bbar%5D', $pairs->key()); 43 | $this->assertEquals('baz bat', $pairs->current()); 44 | 45 | $qs = (string) query_string('foo[bar]=baz bat'); 46 | $pairs = new IteratorIterator((new Pairs($qs))->withDecodeValues(true)); 47 | $pairs->rewind(); 48 | $this->assertEquals('foo%5Bbar%5D', $pairs->key()); 49 | $this->assertEquals('baz bat', $pairs->current()); 50 | } 51 | 52 | public function testPairsWithDifferentSeparator(): void 53 | { 54 | $qs = 'foo=bar;baz=bat'; 55 | $this->assertEquals(['foo' => 'bar', 'baz' => 'bat'], iterator_to_array(new Pairs($qs, false, false, ';'))); 56 | $qs = 'foo=bar;baz=bat'; 57 | $this->assertEquals(['foo' => 'bar', 'baz' => 'bat'], iterator_to_array((new Pairs($qs))->withSeparator(';'))); 58 | } 59 | 60 | public function testPairsWithMissingValues(): void 61 | { 62 | $qs = 'foo=&baz'; 63 | $this->assertEquals(['foo' => '', 'baz' => null], iterator_to_array(new Pairs($qs, false, false))); 64 | } 65 | 66 | public function testPairsOnEmptyQueryString(): void 67 | { 68 | $qs = ' '; 69 | $this->assertEquals([], iterator_to_array(new Pairs($qs))); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/QueryStringTest.php: -------------------------------------------------------------------------------- 1 | 'bar', 'baz' => 'bat']; 23 | $qs = query_string($array); 24 | $this->assertInstanceOf(QueryString::class, $qs); 25 | $this->assertEquals('foo=bar&baz=bat', (string) $qs); 26 | $this->assertEquals($array, $qs->getParams()); 27 | 28 | 29 | $uri = Http::createFromString('http://www.example.net?foo=bar&baz=bat'); 30 | $qs = query_string($uri); 31 | $this->assertInstanceOf(QueryString::class, $qs); 32 | $this->assertEquals('foo=bar&baz=bat', (string) $qs); 33 | 34 | 35 | $qs = query_string('foo=bar&baz=bat'); 36 | $this->assertInstanceOf(QueryString::class, $qs); 37 | $this->assertEquals('foo=bar&baz=bat', (string) $qs); 38 | 39 | $qs = query_string(); 40 | $this->assertInstanceOf(QueryString::class, $qs); 41 | $this->assertEquals('', (string) $qs); 42 | $this->assertEquals([], $qs->getParams()); 43 | } 44 | 45 | public function testFactoryFailsWithInvalidArgument(): void 46 | { 47 | $this->expectException(\InvalidArgumentException::class); 48 | query_string(new \stdClass()); 49 | } 50 | 51 | 52 | public function testCurrentLocationFactory(): void 53 | { 54 | $_SERVER['REQUEST_URI'] = 'foo=bar&baz=bat'; 55 | 56 | $qs = QueryString::createFromCurrentLocation(); 57 | $this->assertEquals('foo=bar&baz=bat', (string) $qs); 58 | 59 | $qs = query_string('bat=bab')->withCurrentLocation(); 60 | $this->assertEquals('foo=bar&baz=bat', (string) $qs); 61 | 62 | unset($_SERVER['REQUEST_URI']); 63 | } 64 | 65 | /** 66 | * In CLI mode, $_SERVER['REQUEST_URI'] should not be set. 67 | */ 68 | public function testCurrentLocationFactoryFailsWhenNotSet(): void 69 | { 70 | $this->expectException(\RuntimeException::class); 71 | QueryString::createFromCurrentLocation(); 72 | } 73 | 74 | public function testRenderer(): void 75 | { 76 | $data = [ 77 | 'foo' => 'bar', 78 | 'sort' => [ 79 | 'bar' => 'desc', 80 | 'foo' => 'asc', 81 | ], 82 | 'filters' => [ 83 | 'foo' => [ 84 | 'bar', 85 | 'baz', 86 | ], 87 | 'bar' => [ 88 | 'foo bar', 89 | ], 90 | ], 91 | ]; 92 | 93 | $qs = query_string($data); 94 | $renderer = NativeRenderer::factory(); 95 | $this->assertEquals((string) $qs, $renderer->render($qs)); 96 | $renderer = NativeRenderer::factory(PHP_QUERY_RFC3986); 97 | $this->assertEquals((string) $qs, $renderer->render($qs)); 98 | } 99 | 100 | public function testChangeRenderer(): void 101 | { 102 | $data = [ 103 | 'foo' => 'bar', 104 | 'sort' => [ 105 | 'bar' => 'desc', 106 | 'foo' => 'asc', 107 | ], 108 | 'filters' => [ 109 | 'foo' => [ 110 | 'bar', 111 | 'baz', 112 | ], 113 | 'bar' => [ 114 | 'foo bar', 115 | ], 116 | ], 117 | ]; 118 | $qs = query_string($data); 119 | $renderer = ArrayValuesNormalizerRenderer::factory(); 120 | $qs = $qs->withRenderer($renderer); 121 | $this->assertEquals((string) $qs, $renderer->render($qs)); 122 | } 123 | 124 | public function testDefaultRenderer(): void 125 | { 126 | $renderer = QueryString::getDefaultRenderer(); 127 | $this->assertEquals(NativeRenderer::class, get_class(QueryString::getDefaultRenderer())); 128 | $this->assertSame($renderer, query_string()->getRenderer()); 129 | 130 | $renderer = ArrayValuesNormalizerRenderer::factory(); 131 | QueryString::setDefaultRenderer($renderer); 132 | $this->assertEquals(ArrayValuesNormalizerRenderer::class, get_class(QueryString::getDefaultRenderer())); 133 | $this->assertSame($renderer, query_string()->getRenderer()); 134 | 135 | QueryString::restoreDefaultRenderer(); 136 | $this->assertEquals(NativeRenderer::class, get_class(QueryString::getDefaultRenderer())); 137 | $this->assertSame(QueryString::getDefaultRenderer(), query_string()->getRenderer()); 138 | } 139 | 140 | public function testAddParam(): void 141 | { 142 | $qs = query_string(['foo' => 'bar']); 143 | $qs = $qs->withParam('bar', 'baz'); 144 | $this->assertEquals(['foo' => 'bar', 'bar' => 'baz'], $qs->getParams()); 145 | } 146 | 147 | public function testReplaceParams(): void 148 | { 149 | $qs = query_string(['foo' => 'bar']); 150 | $qs = $qs->withParams(['bar' => 'baz']); 151 | $this->assertEquals(['bar' => 'baz'], $qs->getParams()); 152 | } 153 | 154 | public function testAppendParams(): void 155 | { 156 | $qs = query_string(['foo' => 'bar']); 157 | $qs = $qs->withParams(['bar' => 'baz'], true); 158 | $this->assertEquals(['foo' => 'bar', 'bar' => 'baz'], $qs->getParams()); 159 | } 160 | 161 | public function testSimpleGetParam(): void 162 | { 163 | $qs = query_string(['foo' => 'bar']); 164 | $this->assertTrue($qs->hasParam('foo')); 165 | $this->assertEquals('bar', $qs->getParam('foo')); 166 | $this->assertFalse($qs->hasParam('bar')); 167 | $this->assertNull($qs->getParam('bar')); 168 | } 169 | 170 | public function testGetComplexGetParam(): void 171 | { 172 | $data = [ 173 | 'filters' => [ 174 | 'foo' => [ 175 | 'bar', 176 | 'baz', 177 | ], 178 | ], 179 | ]; 180 | $qs = query_string($data); 181 | 182 | // 1st level 183 | $this->assertEquals($data['filters'], $qs->getParam('filters')); 184 | $this->assertNull($qs->getParam('filters', 'bar')); 185 | 186 | // 2nd level 187 | $this->assertTrue($qs->hasParam('filters', 'foo')); 188 | $this->assertEquals($data['filters']['foo'], $qs->getParam('filters', 'foo')); 189 | $this->assertFalse($qs->hasParam('filters', 'bar')); 190 | $this->assertNull($qs->getParam('filters', 'bar')); 191 | 192 | // 3rd level 193 | $this->assertTrue($qs->hasParam('filters', 'foo', 0)); 194 | $this->assertEquals($data['filters']['foo'][0], $qs->getParam('filters', 'foo', 0)); 195 | $this->assertTrue($qs->hasParam('filters', 'foo', 1)); 196 | $this->assertEquals($data['filters']['foo'][1], $qs->getParam('filters', 'foo', 1)); 197 | $this->assertFalse($qs->hasParam('filters', 'foo', 2)); 198 | $this->assertNull($qs->getParam('filters', 'foo', 2)); 199 | $this->assertFalse($qs->hasParam('filters', 'bar', 0)); 200 | $this->assertNull($qs->getParam('filters', 'bar', 0)); 201 | } 202 | 203 | public function testSimpleWithoutParam(): void 204 | { 205 | $qs = query_string(['foo' => 'bar', 'bar' => 'baz']); 206 | $qs = $qs->withoutParam('bar'); 207 | $this->assertEquals(['foo' => 'bar'], $qs->getParams()); 208 | } 209 | 210 | public function testComplexWithoutParam(): void 211 | { 212 | $data = [ 213 | 'filters' => [ 214 | 'foo' => [ 215 | 'bar', 216 | 'baz', 217 | ], 218 | 'bar' => [ 219 | 'bar' => 'foo', 220 | 'foo' => 'bar', 221 | ], 222 | ], 223 | ]; 224 | 225 | // Try to remove unexisting params 226 | $qs = query_string($data); 227 | $qs = $qs->withoutParam('filters', 'dummy'); 228 | $this->assertEquals($data, $qs->getParams()); 229 | $qs = $qs->withoutParam('filters', 'dummy', 'dummy'); 230 | $this->assertEquals($data, $qs->getParams()); 231 | $qs = $qs->withoutParam('filters', 'foo', 'dummy'); 232 | $this->assertEquals($data, $qs->getParams()); 233 | 234 | // Try to remove 2nd level key 235 | $qs2 = $qs->withoutParam('filters', 'bar'); 236 | $this->assertEquals([ 237 | 'filters' => [ 238 | 'foo' => [ 239 | 'bar', 240 | 'baz', 241 | ], 242 | ] 243 | ], $qs2->getParams()); 244 | 245 | // Try to remove 3rd level key 246 | $qs2 = $qs->withoutParam('filters', 'foo', 0); 247 | $this->assertEquals([ 248 | 'filters' => [ 249 | 'foo' => [ 250 | 'baz', 251 | ], 252 | 'bar' => [ 253 | 'bar' => 'foo', 254 | 'foo' => 'bar', 255 | ], 256 | ] 257 | ], $qs2->getParams()); 258 | 259 | $qs2 = $qs->withoutParam('filters', 'foo', 1); 260 | $this->assertEquals([ 261 | 'filters' => [ 262 | 'foo' => [ 263 | 'bar', 264 | ], 265 | 'bar' => [ 266 | 'bar' => 'foo', 267 | 'foo' => 'bar', 268 | ], 269 | ] 270 | ], $qs2->getParams()); 271 | 272 | $qs2 = $qs->withoutParam('filters', 'bar', 'bar'); 273 | $this->assertEquals([ 274 | 'filters' => [ 275 | 'foo' => [ 276 | 'bar', 277 | 'baz', 278 | ], 279 | 'bar' => [ 280 | 'foo' => 'bar', 281 | ], 282 | ] 283 | ], $qs2->getParams()); 284 | 285 | $qs2 = $qs->withoutParam('filters', 'bar', 'foo'); 286 | $this->assertEquals([ 287 | 'filters' => [ 288 | 'foo' => [ 289 | 'bar', 290 | 'baz', 291 | ], 292 | 'bar' => [ 293 | 'bar' => 'foo', 294 | ], 295 | ] 296 | ], $qs2->getParams()); 297 | } 298 | 299 | public function testGetPairs(): void 300 | { 301 | $qs = query_string('a=b&c=d&e[]=f&e[]=g&h[foo]=bar&h[bar][]=baz&h[bar][]=bat&boo')->withRenderer(withoutNumericIndices()); 302 | $pairs = new IteratorIterator($qs->getPairs()); 303 | $pairs->rewind(); 304 | 305 | $this->assertEquals('a', $pairs->key()); 306 | $this->assertEquals('b', $pairs->current()); 307 | 308 | $pairs->next(); 309 | 310 | $this->assertEquals('c', $pairs->key()); 311 | $this->assertEquals('d', $pairs->current()); 312 | 313 | $pairs->next(); 314 | 315 | $this->assertEquals('e%5B%5D', $pairs->key()); 316 | $this->assertEquals('f', $pairs->current()); 317 | 318 | $pairs->next(); 319 | 320 | $this->assertEquals('e%5B%5D', $pairs->key()); 321 | $this->assertEquals('g', $pairs->current()); 322 | 323 | $pairs->next(); 324 | 325 | $this->assertEquals('h%5Bfoo%5D', $pairs->key()); 326 | $this->assertEquals('bar', $pairs->current()); 327 | 328 | $pairs->next(); 329 | 330 | $this->assertEquals('h%5Bbar%5D%5B%5D', $pairs->key()); 331 | $this->assertEquals('baz', $pairs->current()); 332 | 333 | $pairs->next(); 334 | 335 | $this->assertEquals('h%5Bbar%5D%5B%5D', $pairs->key()); 336 | $this->assertEquals('bat', $pairs->current()); 337 | 338 | $pairs->next(); 339 | 340 | $this->assertEquals('boo', $pairs->key()); 341 | $this->assertEquals('', $pairs->current()); 342 | 343 | 344 | $pairs->next(); 345 | $this->assertNull($pairs->current()); 346 | } 347 | 348 | public function testPairsWithKeyDecoding(): void 349 | { 350 | $qs = query_string('foo[bar]=baz bat'); 351 | $pairs = new IteratorIterator($qs->getPairs(true)); 352 | $pairs->rewind(); 353 | $this->assertEquals('foo[bar]', $pairs->key()); 354 | $this->assertEquals('baz%20bat', $pairs->current()); 355 | } 356 | 357 | public function testPairsWithValueDecoding(): void 358 | { 359 | $qs = query_string('foo[bar]=baz bat'); 360 | $pairs = new IteratorIterator($qs->getPairs(false, true)); 361 | $pairs->rewind(); 362 | $this->assertEquals('foo%5Bbar%5D', $pairs->key()); 363 | $this->assertEquals('baz bat', $pairs->current()); 364 | } 365 | 366 | public function testChangeEncoding(): void 367 | { 368 | $qs = query_string(['foo' => 'foo bar']); 369 | $this->assertEquals('foo=foo%20bar', (string) $qs); 370 | $qs = $qs->withRenderer( 371 | $qs->getRenderer()->withEncoding(PHP_QUERY_RFC1738) 372 | ); 373 | $this->assertEquals('foo=foo+bar', (string) $qs); 374 | $qs = $qs->withRenderer( 375 | $qs->getRenderer()->withEncoding(PHP_QUERY_RFC3986) 376 | ); 377 | $this->assertEquals('foo=foo%20bar', (string) $qs); 378 | } 379 | 380 | public function testImmutability(): void 381 | { 382 | $qs = query_string([]); 383 | $qs2 = $qs->withParam('bar', 'baz'); 384 | $this->assertNotSame($qs, $qs2); 385 | 386 | $qs = query_string(['foo' => 'bar']); 387 | $qs2 = $qs->withoutParam('dummy'); 388 | $this->assertNotSame($qs, $qs2); 389 | 390 | $qs = query_string(['foo' => 'bar']); 391 | $qs2 = $qs->withParams(['bar' => 'baz']); 392 | $this->assertNotSame($qs, $qs2); 393 | 394 | $qs = query_string([]); 395 | $qs2 = $qs->withRenderer(new class implements QueryStringRendererInterface { 396 | 397 | use QueryStringRendererTrait; 398 | 399 | public function render(QueryString $queryString): string 400 | { 401 | return ''; 402 | } 403 | 404 | }); 405 | $this->assertNotSame($qs, $qs2); 406 | } 407 | 408 | public function testAnotherParser(): void 409 | { 410 | $dummyParser = new class implements QueryStringParserInterface 411 | { 412 | public function parse(string $queryString): array 413 | { 414 | return ['ho' => 'hi']; 415 | } 416 | 417 | }; 418 | $qs = query_string('foo=bar', $dummyParser); 419 | $this->assertEquals(['ho' => 'hi'], $qs->getParams()); 420 | } 421 | 422 | public function testChangeDefaultParser(): void 423 | { 424 | $dummyParser = new class implements QueryStringParserInterface 425 | { 426 | public function parse(string $queryString): array 427 | { 428 | return ['ho' => 'hi']; 429 | } 430 | 431 | }; 432 | QueryString::setDefaultParser($dummyParser); 433 | $qs = query_string('foo=bar'); 434 | $this->assertEquals(['ho' => 'hi'], $qs->getParams()); 435 | 436 | QueryString::restoreDefaultParser(); 437 | $qs = query_string('foo=bar'); 438 | $this->assertEquals(['foo' => 'bar'], $qs->getParams()); 439 | } 440 | } 441 | --------------------------------------------------------------------------------