├── .github └── workflows │ └── main.yaml ├── .gitignore ├── LICENSE ├── composer.json ├── phpunit.xml ├── readme.md ├── src ├── Mask │ ├── IMask.php │ ├── Mask.php │ ├── MaskAny.php │ ├── MaskArray.php │ ├── MaskConfigurationException.php │ └── MaskOne.php └── Parser │ ├── Input.php │ ├── Parser.php │ └── ParserException.php └── tests ├── IdentifierTest.php ├── InvalidFiltersTest.php └── ParserTest.php /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | php: [7.1, 7.2, 7.3, 7.4, 8.0] 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup PHP 18 | uses: shivammathur/setup-php@v2 19 | with: 20 | php-version: ${{ matrix.php }} 21 | coverage: none 22 | 23 | - name: Validate composer.json 24 | run: composer validate 25 | 26 | - name: Install dependencies 27 | run: composer update --prefer-dist --no-progress --no-interaction 28 | 29 | - name: Check code style 30 | run: composer run-script phpcs 31 | 32 | - name: Run tests 33 | run: composer run-script tests 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /composer.lock 3 | /vendor/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Dariusz Sieradzki 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messere/php-value-mask", 3 | "description": "extract subset of array / object values", 4 | "version": "0.1.2", 5 | "type": "library", 6 | "keywords": [ 7 | "filter", 8 | "filtering", 9 | "mask", 10 | "partial response", 11 | "array filtering", 12 | "json filtering" 13 | ], 14 | "homepage": "https://github.com/Messere/php-value-mask", 15 | "readme": "readme.md", 16 | "minimum-stability": "stable", 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "Dariusz Sieradzki", 21 | "email": "opensource@aerolit.pl" 22 | } 23 | ], 24 | "autoload": { 25 | "psr-4": { 26 | "messere\\phpValueMask\\": "src/" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "messere\\phpValueMask\\": "tests/" 32 | } 33 | }, 34 | "require": { 35 | "php": "^7.1 || ^8.0", 36 | "ext-json": "*" 37 | }, 38 | "require-dev": { 39 | "phpunit/phpunit": "^7.0 || ^8.0", 40 | "squizlabs/php_codesniffer": "^3.3", 41 | "phpmd/phpmd": "^2.6" 42 | }, 43 | "scripts": { 44 | "tests": [ 45 | "phpunit" 46 | ], 47 | "phpcs": [ 48 | "phpcs --standard=PSR2 src/" 49 | ], 50 | "phpmd": [ 51 | "phpmd src text cleancode,codesize,controversial,design,naming,unusedcode" 52 | ], 53 | "fix": [ 54 | "phpcbf --standard=PSR2 src/" 55 | ], 56 | "build": [ 57 | "@tests", 58 | "@phpcs", 59 | "@phpmd" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tests 5 | 6 | 7 | 8 | 9 | src 10 | 11 | 12 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # php-value-mask 2 | 3 | [![Packagist](https://img.shields.io/packagist/dt/messere/php-value-mask.svg)](https://packagist.org/packages/messere/php-value-mask) 4 | 5 | ## Purpose 6 | 7 | Google in their [Performance Tips](https://cloud.google.com/storage/docs/json_api/v1/how-tos/performance#partial-response) for 8 | APIs, suggest to limit required bandwidth by filtering out unused fields in response. Their APIs 9 | support additional URL parameter `fields` which asks API to include only specific fields in response. 10 | 11 | `fields` parameter follows a simple syntax, which allows to query for nested keys, multiple keys 12 | or use wildcard to include all fields (see *Syntax* and *Grammar* sections below). 13 | 14 | This library implements parsing of `fields` parameter and filtering of arrays / objects. 15 | 16 | See also: PSR-15 compatible middleware based on this library: [Partial Response PSR-15 Middleware](https://github.com/Messere/partial-response-middleware) 17 | 18 | ## Usage example 19 | 20 | ```php 21 | 1, 31 | 'resource' => 'book', 32 | 'title' => 'Good Omens', 33 | 'identifiers' => (object)[ 34 | 'isbn' => 'ISBN 83-85100-63-6​', 35 | 'amazon' => '0060853980', 36 | ], 37 | 'authors' => [ 38 | [ 39 | 'firstName' => 'Terry', 40 | 'lastName' => 'Pratchett' 41 | ], 42 | [ 43 | 'firstName' => 'Neil', 44 | 'lastName' => 'Gaiman' 45 | ], 46 | ], 47 | 'year' => [ 48 | 'us' => 1990, 49 | 'uk' => 1990, 50 | 'pl' => 1992, 51 | ], 52 | 'publisher' => [ 53 | 'us' => 'Workman', 54 | 'uk' => 'Gollancz', 55 | 'pl' => 'CIA-Books-SVARO', 56 | ], 57 | ]; 58 | 59 | $filter = 'title,identifiers/isbn,authors/firstName,*(us,uk),keywords'; 60 | 61 | try { 62 | $filteredInput = $parser->parse($filter)->filter($input); 63 | print_r($filteredInput); 64 | } catch (ParserException $e) { 65 | echo 'Parser error: ' . $e->getMessage(); 66 | } 67 | ``` 68 | 69 | Let's analyze elements of used filter: 70 | 71 | - `title` matches top level element with key title ('Good Omens') 72 | - `identifiers/isbn` matches top level element `identifiers` and 73 | then includes `isbn` element from matched object ('ISBN 83-85100-63-6') 74 | - `authors/firstName` finds an array of elements (list) under the key `authors` 75 | and examines all elements, extracting `firstName` from each. ('Terry' and 'Neil') 76 | - `*(us,uk)` examines all properties and extracts fields `us` and `uk`. ('1990' and '1990' 77 | from `year` element, 'Workman', 'Gollancz' from `publisher`) 78 | - `keywords` does not match anything and is silently ignored. 79 | 80 | As a result we expect the following output: 81 | 82 | ``` 83 | Array 84 | ( 85 | [title] => Good Omens 86 | [identifiers] => Array 87 | ( 88 | [isbn] => ISBN 83-85100-63-6​ 89 | ) 90 | 91 | [authors] => Array 92 | ( 93 | [0] => Array 94 | ( 95 | [firstName] => Terry 96 | ) 97 | 98 | [1] => Array 99 | ( 100 | [firstName] => Neil 101 | ) 102 | 103 | ) 104 | 105 | [year] => Array 106 | ( 107 | [us] => 1990 108 | [uk] => 1999 109 | ) 110 | 111 | [publisher] => Array 112 | ( 113 | [us] => Workman 114 | [uk] => Gollancz 115 | ) 116 | 117 | ) 118 | ``` 119 | 120 | ready to serialize to `JSON`, etc. 121 | 122 | Note that library does preserve the structure/nesting of values, but not 123 | necessarily types of values - all objects are converted to associative arrays 124 | with object's public properties as keys. 125 | 126 | ## Syntax 127 | 128 | - `a` selects key `a` from input 129 | - `a,b,c` comma separated list of elements: selects keys `a` and `b` and `c` 130 | - `a/b/c` nested elements: selects key `c` from parent element `b` 131 | which in turn has parent element `a` 132 | - `a(b,c)` multiple elements: selects elements `b` and `c` from parent element `a` 133 | - `a(b,c/d)` multiple elements: from parent `a` select element `b` and element `d` 134 | nested in `c` 135 | - `a/*/c` wildcard: selects element `c` from all children of element `a` 136 | - `*(b,c)` wildcard: selects elements `b` and `c` from any parent 137 | 138 | Etc. See tests for more examples as well as examples of invalid filters. 139 | 140 | ## Grammar 141 | 142 | Since Google does not provide detailed grammar for their 143 | "fields" language, this package uses the following arbitrarily 144 | selected rules, that in author's opinion closely resamble intent 145 | of original authors. 146 | 147 | In EBNF notation: 148 | 149 | ```text 150 | Mask = MaskElement | MaskElement , "," , Mask ; 151 | MaskElement = ArrayOfMasks | NestedKeys ; 152 | ArrayOfMasks = Key , "(" , Mask , ")" ; 153 | NestedKeys = Key , [ "/" , NestedKeys ] ; 154 | Key = Wildcard | Identifier ; 155 | Identifier = Letter , { Letter | Digit } 156 | Wildcard = "*" ; 157 | Letter = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | 158 | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z" | 159 | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | 160 | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | 161 | "_"; 162 | Digit = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "0" ; 163 | ``` 164 | 165 | ## Acknowledgements 166 | 167 | Library is inspired by: 168 | 169 | * Google's [API performance tips](https://cloud.google.com/storage/docs/json_api/v1/how-tos/performance#partial-response). 170 | * Similar JavaScript library: [nemtsov/json-mask](https://github.com/nemtsov/json-mask). 171 | 172 | ## License 173 | 174 | [MIT](LICENSE) 175 | -------------------------------------------------------------------------------- /src/Mask/IMask.php: -------------------------------------------------------------------------------- 1 | maxMatchesNumber = $maxMatchesNumber; 19 | $this->childrenLimit = $childrenLimit; 20 | } 21 | 22 | /** 23 | * apply current mask to value, return filtered out value 24 | * note that it won't retain original object types, all values will be converted to array 25 | * @param $value 26 | * @return array 27 | */ 28 | public function filter($value): array 29 | { 30 | // normalize associative arrays to objects so we don't have to deal 31 | // with detecting if array value is in fact "list" or "object" 32 | $valueNormalized = json_decode(json_encode($value), false); 33 | return $this->applyToNormalized($valueNormalized); 34 | } 35 | 36 | public function addChild(IMask $child): void 37 | { 38 | if (null !== $this->childrenLimit && \count($this->children) >= $this->childrenLimit) { 39 | throw new MaskConfigurationException('Mask children limit exceeded'); 40 | } 41 | $this->children[] = $child; 42 | } 43 | 44 | /** 45 | * check if key matches current mask 46 | * @param string $key 47 | * @return bool 48 | */ 49 | abstract public function match(string $key): bool; 50 | 51 | /** 52 | * check if class has any children 53 | * @return bool 54 | */ 55 | private function hasChildren(): bool 56 | { 57 | return [] !== $this->children; 58 | } 59 | 60 | /** 61 | * append values to result if not empty 62 | * @param array $result 63 | * @param array $values 64 | */ 65 | private function maybeAppend(array &$result, array $values): void 66 | { 67 | if ([] !== $values) { 68 | $result[] = $values; 69 | } 70 | } 71 | 72 | /** 73 | * append values to result if not empty 74 | * @param string $key 75 | * @param array $result 76 | * @param array $values 77 | */ 78 | private function maybeAppendWithKey(string $key, array &$result, array $values): void 79 | { 80 | if ([] !== $values) { 81 | $result[$key] = $values; 82 | } 83 | } 84 | 85 | private function applyToObject($value): array 86 | { 87 | $result = []; 88 | $numberOfMatches = 0; 89 | foreach ((array)$value as $key => $val) { 90 | if ($this->matchedEnough($numberOfMatches)) { 91 | break; 92 | } 93 | 94 | if (!$this->match($key)) { 95 | continue; 96 | } 97 | 98 | $numberOfMatches++; 99 | 100 | if ($this->hasChildren()) { 101 | $this->maybeAppendWithKey($key, $result, $this->children[0]->filter($val)); 102 | continue; 103 | } 104 | 105 | $result[$key] = $val; 106 | } 107 | return $result; 108 | } 109 | 110 | protected function applyToNormalized($valueNormalized): array 111 | { 112 | $result = []; 113 | 114 | if (\is_object($valueNormalized)) { 115 | $result = array_merge($result, $this->applyToObject($valueNormalized)); 116 | } 117 | 118 | if (\is_array($valueNormalized)) { 119 | $subResult = []; 120 | foreach ($valueNormalized as $item) { 121 | $this->maybeAppend($subResult, $this->applyToNormalized($item)); 122 | } 123 | $result = array_merge($result, $subResult); 124 | } 125 | 126 | return $result; 127 | } 128 | 129 | private function matchedEnough(int $numberOfMatches): bool 130 | { 131 | return $this->maxMatchesNumber !== null && $numberOfMatches >= $this->maxMatchesNumber; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Mask/MaskAny.php: -------------------------------------------------------------------------------- 1 | applyToArray($value) 21 | : $this->applyToObject($value); 22 | } 23 | 24 | private function applyToArray(array $value): array 25 | { 26 | $items = []; 27 | foreach ($value as $item) { 28 | $subResult = $this->applyToNormalized($item); 29 | if ([] !== $subResult) { 30 | $items[] = $subResult; 31 | } 32 | } 33 | return $items; 34 | } 35 | 36 | private function applyToObject($value): array 37 | { 38 | $results = []; 39 | foreach ($this->children as $subMaskElement) { 40 | $results[] = $subMaskElement->filter($value); 41 | } 42 | return array_merge([], ...$results); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Mask/MaskConfigurationException.php: -------------------------------------------------------------------------------- 1 | key = $key; 13 | } 14 | 15 | public function match(string $key): bool 16 | { 17 | return $this->key === $key; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Parser/Input.php: -------------------------------------------------------------------------------- 1 | value = $value; 16 | } 17 | 18 | public function getChar(): ?string 19 | { 20 | $char = $this->value[$this->currentPosition] ?? null; 21 | $this->currentPosition++; 22 | return $char; 23 | } 24 | 25 | public function mark(): int 26 | { 27 | return $this->currentPosition; 28 | } 29 | 30 | public function rewind(int $mark): void 31 | { 32 | $this->currentPosition = $mark; 33 | } 34 | 35 | public function isConsumed(): bool 36 | { 37 | return $this->currentPosition === \strlen($this->value); 38 | } 39 | 40 | public function maybeConsume(callable $callback) 41 | { 42 | $mark = $this->mark(); 43 | $result = $callback(); 44 | if (null === $result) { 45 | $this->rewind($mark); 46 | } 47 | return $result; 48 | } 49 | 50 | public function maybeConsumeTerminal(string $terminal): ?string 51 | { 52 | $mark = $this->mark(); 53 | $char = $this->getChar(); 54 | 55 | if ($char !== $terminal) { 56 | $this->rewind($mark); 57 | return null; 58 | } 59 | return $char; 60 | } 61 | 62 | private function isLetter(?string $char): bool 63 | { 64 | if ($char === '_') { 65 | return true; 66 | } 67 | $charOrd = \ord(strtolower($char)); 68 | return $charOrd >= 97 && $charOrd <= 122; 69 | } 70 | 71 | public function maybeConsumeLetter(): ?string 72 | { 73 | $mark = $this->mark(); 74 | $char = $this->getChar(); 75 | if ($this->isLetter($char)) { 76 | return $char; 77 | } 78 | $this->rewind($mark); 79 | return null; 80 | } 81 | 82 | public function maybeConsumeLetterOrDigit(): ?string 83 | { 84 | $mark = $this->mark(); 85 | $char = $this->getChar(); 86 | if (\is_numeric($char) || $this->isLetter($char)) { 87 | return $char; 88 | } 89 | $this->rewind($mark); 90 | return null; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Parser/Parser.php: -------------------------------------------------------------------------------- 1 | input = new Input($valueMaskDefinition); 26 | $root = $this->parseMask(); 27 | if (!$this->input->isConsumed()) { 28 | throw new ParserException( 29 | 'Invalid input, parser stopped in the middle of input' 30 | ); 31 | } 32 | if (null === $root) { 33 | throw new ParserException('Invalid input, unrecognized input'); 34 | } 35 | return $root; 36 | } 37 | 38 | private function parseMask(?MaskArray $root = null): ?IMask 39 | { 40 | $root = $root ?? new MaskArray(); 41 | 42 | $maskElement = $this->input->maybeConsume(function () { 43 | return $this->parseMaskElement(); 44 | }); 45 | 46 | if (null === $maskElement) { 47 | return null; 48 | } 49 | $root->addChild($maskElement); 50 | 51 | if (null === $this->input->maybeConsumeTerminal(',')) { 52 | return $root; 53 | } 54 | return $this->parseMask($root); 55 | } 56 | 57 | private function parseMaskElement(): ?IMask 58 | { 59 | $node = $this->input->maybeConsume(function () { 60 | return $this->parseArrayOfMasks(); 61 | }); 62 | if (null !== $node) { 63 | return $node; 64 | } 65 | 66 | return $this->input->maybeConsume(function () { 67 | return $this->parseNestedKeys(); 68 | }); 69 | } 70 | 71 | private function parseArrayOfMasks(): ?IMask 72 | { 73 | /** 74 | * @var $keyNode IMask 75 | */ 76 | $keyNode = $this->input->maybeConsume(function (): ?IMask { 77 | return $this->parseKey(); 78 | }); 79 | if (null === $keyNode || null === $this->input->maybeConsumeTerminal('(')) { 80 | return null; 81 | } 82 | 83 | $maskNode = $this->input->maybeConsume(function (): ?IMask { 84 | return $this->parseMask(); 85 | }); 86 | if (null === $maskNode || null === $this->input->maybeConsumeTerminal(')')) { 87 | return null; 88 | } 89 | 90 | $keyNode->addChild($maskNode); 91 | 92 | return $keyNode; 93 | } 94 | 95 | private function parseNestedKeys(): ?IMask 96 | { 97 | /** 98 | * @var $keyNode IMask 99 | */ 100 | $keyNode = $this->input->maybeConsume(function (): ?IMask { 101 | return $this->parseKey(); 102 | }); 103 | 104 | if (null === $keyNode || null === $this->input->maybeConsumeTerminal('/')) { 105 | return $keyNode; 106 | } 107 | 108 | $moreNestedKeys = $this->input->maybeConsume(function () { 109 | return $this->parseNestedKeys(); 110 | }); 111 | 112 | if (null === $moreNestedKeys) { 113 | return null; 114 | } 115 | 116 | $keyNode->addChild($moreNestedKeys); 117 | return $keyNode; 118 | } 119 | 120 | private function parseKey(): ?Mask 121 | { 122 | 123 | $wildcard = $this->input->maybeConsume(function () { 124 | return $this->parseWildcard(); 125 | }); 126 | 127 | if (null !== $wildcard) { 128 | return new MaskAny(); 129 | } 130 | 131 | $identifier = $this->input->maybeConsume(function () { 132 | return $this->parseIdentifier(); 133 | }); 134 | if (null !== $identifier) { 135 | return new MaskOne($identifier); 136 | } 137 | 138 | return null; 139 | } 140 | 141 | private function parseIdentifier(): ?string 142 | { 143 | $identifier = $this->input->maybeConsume(function () { 144 | $identifier = ''; 145 | $firstChar = $this->input->maybeConsumeLetter(); 146 | if (null === $firstChar) { 147 | return null; 148 | } 149 | $identifier .= $firstChar; 150 | 151 | do { 152 | $char = $this->input->maybeConsumeLetterOrDigit(); 153 | if ($char === null) { 154 | break; 155 | } 156 | $identifier .= $char; 157 | } while (true); 158 | return $identifier; 159 | }); 160 | 161 | if ($identifier === null) { 162 | return null; 163 | } 164 | 165 | return $identifier; 166 | } 167 | 168 | private function parseWildcard(): ?string 169 | { 170 | return $this->input->maybeConsumeTerminal('*'); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Parser/ParserException.php: -------------------------------------------------------------------------------- 1 | 1, 16 | 'b2' => 2, 17 | ]; 18 | 19 | $parser = new Parser(); 20 | $this->assertEquals([], $parser->parse($mask)->filter($input)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/InvalidFiltersTest.php: -------------------------------------------------------------------------------- 1 | expectException(ParserException::class); 18 | $parser->parse($filter); 19 | } 20 | 21 | public function invalidFilterProvider(): array 22 | { 23 | return [ 24 | [''], 25 | ['123'], 26 | ['1z'], 27 | ['%a'], 28 | ['^a'], 29 | ['a,**'], 30 | ['a*'], 31 | ['a('], 32 | ['a()'], 33 | ['a(b'], 34 | ['a((b)'], 35 | ['a((b))'], 36 | ['a(b))'], 37 | ['a//b'], 38 | ['a,/a'], 39 | ['a(/)'], 40 | ['01(a)'], 41 | ['(a)'], 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/ParserTest.php: -------------------------------------------------------------------------------- 1 | parser = new Parser(); 17 | } 18 | 19 | public function testSingleKey(): void 20 | { 21 | $mask = 'a1'; 22 | 23 | $input = [ 24 | 'a1' => 1, 25 | 'b2' => 2, 26 | ]; 27 | 28 | $expected = [ 29 | 'a1' => 1, 30 | ]; 31 | 32 | $this->assertFilteringResult($expected, $input, $mask); 33 | } 34 | 35 | public function testTwoKeys(): void 36 | { 37 | $mask = 'a1,b2'; 38 | 39 | $input = [ 40 | 'a1' => 1, 41 | 'b2' => 2, 42 | 'c3' => 3, 43 | ]; 44 | 45 | $expected = [ 46 | 'a1' => 1, 47 | 'b2' => 2, 48 | ]; 49 | 50 | $this->assertFilteringResult($expected, $input, $mask); 51 | } 52 | 53 | public function testTwoKeysOneDoesNotExist(): void 54 | { 55 | $mask = 'a1,d4'; 56 | 57 | $input = [ 58 | 'a1' => 1, 59 | 'b2' => 2, 60 | 'c3' => 3, 61 | ]; 62 | 63 | $expected = [ 64 | 'a1' => 1 65 | ]; 66 | 67 | $this->assertFilteringResult($expected, $input, $mask); 68 | } 69 | 70 | public function testTwoKeysRepeated(): void 71 | { 72 | $mask = 'a1,a1'; 73 | 74 | $input = [ 75 | 'a1' => 1, 76 | 'b2' => 2, 77 | 'c3' => 3, 78 | ]; 79 | 80 | $expected = [ 81 | 'a1' => 1 82 | ]; 83 | 84 | $this->assertFilteringResult($expected, $input, $mask); 85 | } 86 | 87 | public function testWildcard(): void 88 | { 89 | $mask = '*'; 90 | 91 | $input = [ 92 | 'a1' => 1, 93 | 'b2' => 2, 94 | 'c3' => 3, 95 | ]; 96 | 97 | $expected = $input; 98 | 99 | $this->assertFilteringResult($expected, $input, $mask); 100 | } 101 | 102 | public function testWildcardAndKey(): void 103 | { 104 | $mask = 'a1,*'; 105 | 106 | $input = [ 107 | 'a1' => 1, 108 | 'b2' => 2, 109 | 'c3' => 3, 110 | ]; 111 | 112 | $expected = $input; 113 | 114 | $this->assertFilteringResult($expected, $input, $mask); 115 | } 116 | 117 | public function testKeyAndWildcard(): void 118 | { 119 | $input = [ 120 | 'a1' => 1, 121 | 'b2' => 2, 122 | 'c3' => 3, 123 | ]; 124 | 125 | $mask = '*,a1'; 126 | 127 | $expected = $input; 128 | 129 | $this->assertFilteringResult($expected, $input, $mask); 130 | } 131 | 132 | public function testNested(): void 133 | { 134 | $input = [ 135 | 'a1' => [ 136 | 'a2' => 2, 137 | ], 138 | 'a2' => 3 139 | ]; 140 | 141 | $mask = 'a1/a2'; 142 | 143 | $expected = [ 144 | 'a1' => [ 145 | 'a2' => 2 146 | ] 147 | ]; 148 | 149 | $this->assertFilteringResult($expected, $input, $mask); 150 | } 151 | 152 | public function testNestedWithWildcard(): void 153 | { 154 | $input = [ 155 | 'a1' => [ 156 | 'a2' => 2, 157 | ], 158 | 'a2' => 3, 159 | 'a3' => [ 160 | 'a2' => 4, 161 | ], 162 | ]; 163 | 164 | $mask = '*/a2'; 165 | 166 | $expected = [ 167 | 'a1' => [ 168 | 'a2' => 2 169 | ], 170 | 'a3' => [ 171 | 'a2' => 4 172 | ] 173 | ]; 174 | 175 | $this->assertFilteringResult($expected, $input, $mask); 176 | } 177 | 178 | public function testDeepNested(): void 179 | { 180 | $input = [ 181 | 'a1' => [ 182 | 'a2' => [ 183 | 'a3' => 1, 184 | 'b1' => 4 185 | ] 186 | ], 187 | 'a2' => 3, 188 | 'a3' => [ 189 | 'a2' => 4, 190 | ], 191 | ]; 192 | 193 | $mask = 'a1/a2/a3'; 194 | 195 | $expected = [ 196 | 'a1' => [ 197 | 'a2' => [ 198 | 'a3' => 1 199 | ] 200 | ], 201 | ]; 202 | 203 | $this->assertFilteringResult($expected, $input, $mask); 204 | } 205 | 206 | 207 | public function testArrayOfMasks(): void 208 | { 209 | $input = [ 210 | 'a1' => [ 211 | 'a1' => 1, 212 | 'a2' => 2, 213 | 'a3' => 3, 214 | 'a4' => 4 215 | ], 216 | 'a2' => [ 217 | 'a1' => 5, 218 | 'a2' => 6, 219 | 'a3' => 7, 220 | 'a4' => 8 221 | ], 222 | ]; 223 | 224 | $mask = 'a1(a2,a4)'; 225 | 226 | $expected = [ 227 | 'a1' => [ 228 | 'a2' => 2, 229 | 'a4' => 4 230 | ] 231 | ]; 232 | 233 | $this->assertFilteringResult($expected, $input, $mask); 234 | } 235 | 236 | public function testArrayOfMasksWithWildcard(): void 237 | { 238 | $input = [ 239 | 'a1' => [ 240 | 'a1' => 1, 241 | 'a2' => 2, 242 | 'a3' => 3, 243 | 'a4' => 4 244 | ], 245 | 'a2' => [ 246 | 'a1' => 5, 247 | 'a2' => 6, 248 | 'a3' => 7, 249 | 'a4' => 8 250 | ], 251 | ]; 252 | 253 | $mask = 'a1(*)'; 254 | 255 | $expected = [ 256 | 'a1' => [ 257 | 'a1' => 1, 258 | 'a2' => 2, 259 | 'a3' => 3, 260 | 'a4' => 4 261 | ] 262 | ]; 263 | 264 | $this->assertFilteringResult($expected, $input, $mask); 265 | } 266 | 267 | public function testWildcardArrayOfMasks(): void 268 | { 269 | $input = [ 270 | 'a1' => [ 271 | 'a1' => 1, 272 | 'a2' => 2, 273 | 'a3' => 3, 274 | 'a4' => 4 275 | ], 276 | 'a2' => [ 277 | 'a1' => 5, 278 | 'a2' => 6, 279 | 'a3' => 7, 280 | 'a4' => 8 281 | ], 282 | ]; 283 | 284 | $mask = '*(a1,a4)'; 285 | 286 | $expected = [ 287 | 'a1' => [ 288 | 'a1' => 1, 289 | 'a4' => 4 290 | ], 291 | 'a2' => [ 292 | 'a1' => 5, 293 | 'a4' => 8 294 | ], 295 | ]; 296 | 297 | $this->assertFilteringResult($expected, $input, $mask); 298 | } 299 | 300 | public function testArrayOfMasksWithNested(): void 301 | { 302 | $input = [ 303 | 'a1' => [ 304 | 'b1' => 1, 305 | 'b2' => [ 306 | 'c1' => 9, 307 | 'a1' => 10, 308 | ], 309 | 'b3' => 3, 310 | 'b4' => 4 311 | ], 312 | 'a2' => [ 313 | 'b1' => 5, 314 | 'b2' => 6, 315 | 'b3' => 7, 316 | 'b4' => 8 317 | ], 318 | ]; 319 | 320 | $mask = 'a1(b1,b2/c1)'; 321 | 322 | $expected = [ 323 | 'a1' => [ 324 | 'b1' => 1, 325 | 'b2' => [ 326 | 'c1' => 9 327 | ] 328 | ], 329 | ]; 330 | 331 | $this->assertFilteringResult($expected, $input, $mask); 332 | } 333 | 334 | public function testSingleKeyOnArray(): void 335 | { 336 | $input = [ 337 | [ 338 | 'a1' => 1, 339 | 'b2' => 2, 340 | ], 341 | [ 342 | 'a1' => 3, 343 | 'b2' => 4, 344 | ], 345 | [ 346 | 'a1' => 5, 347 | 'b2' => 6, 348 | ], 349 | [ 350 | 'b2' => 7, 351 | ], 352 | ]; 353 | 354 | $mask = 'a1'; 355 | 356 | $expected = [ 357 | [ 'a1' => 1 ], 358 | [ 'a1' => 3 ], 359 | [ 'a1' => 5 ], 360 | ]; 361 | 362 | $this->assertFilteringResult($expected, $input, $mask); 363 | } 364 | 365 | public function testTwoKeysOnArray(): void 366 | { 367 | $input = [ 368 | [ 369 | 'a1' => 1, 370 | 'b2' => 2, 371 | ], 372 | [ 373 | 'a1' => 3, 374 | 'b2' => 4, 375 | ], 376 | [ 377 | 'a1' => 5, 378 | 'b2' => 6, 379 | ], 380 | [ 381 | 'b2' => 7, 382 | ], 383 | ]; 384 | 385 | $mask = 'a1,b2'; 386 | 387 | $expected = $input; 388 | 389 | $this->assertFilteringResult($expected, $input, $mask); 390 | } 391 | 392 | public function testTwoKeysRepeatedOnArray(): void 393 | { 394 | $input = [ 395 | [ 396 | 'a1' => 1, 397 | 'b2' => 2, 398 | ], 399 | [ 400 | 'a1' => 3, 401 | 'b2' => 4, 402 | ], 403 | [ 404 | 'a1' => 5, 405 | 'b2' => 6, 406 | ], 407 | [ 408 | 'b2' => 7, 409 | ], 410 | ]; 411 | 412 | $mask = 'a1,a1'; 413 | 414 | $expected = [ 415 | [ 'a1' => 1 ], 416 | [ 'a1' => 3 ], 417 | [ 'a1' => 5 ], 418 | ]; 419 | 420 | $this->assertFilteringResult($expected, $input, $mask); 421 | } 422 | 423 | public function testOverlappingArrayAndNestedOnArray(): void 424 | { 425 | $input = [ 426 | [ 427 | 'a1' => [ 428 | 'a2' => 8, 429 | ], 430 | 'b2' => 2, 431 | ], 432 | [ 433 | 'a1' => 3, 434 | 'b2' => 4, 435 | ], 436 | [ 437 | 'a1' => [ 438 | 'a2' => 9 439 | ], 440 | 'b2' => 6, 441 | ], 442 | [ 443 | 'b2' => 7, 444 | ], 445 | ]; 446 | 447 | $mask = 'a1(a2),a1/a2'; 448 | 449 | $expected = [ 450 | [ 451 | 'a1' => [ 452 | 'a2' => 8, 453 | ], 454 | ], 455 | [ 456 | 'a1' => [ 457 | 'a2' => 9 458 | ], 459 | ], 460 | ]; 461 | 462 | $this->assertFilteringResult($expected, $input, $mask); 463 | } 464 | 465 | private function assertFilteringResult(array $expected, $input, string $filter): void 466 | { 467 | try { 468 | $this->assertEquals( 469 | $expected, 470 | $this->parser->parse($filter)->filter($input) 471 | ); 472 | } catch (ParserException $e) { 473 | $this->fail('Parsing exception: ' . $e->getMessage()); 474 | } 475 | } 476 | } 477 | --------------------------------------------------------------------------------