Passed
Push — master ( fda4e5...db1fd3 )
by Maarten de
03:08 queued 01:40
created

FilterParser::updateValuePathAttributePath()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 25
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 12
nc 5
nop 2
dl 0
loc 25
rs 9.5555
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Cloudstek\SCIM\FilterParser;
6
7
use Cloudstek\SCIM\FilterParser\Exception\InvalidValuePathFilterException;
8
use Nette\Tokenizer\Exception as TokenizerException;
9
10
/**
11
 * SCIM Filter Parser.
12
 */
13
class FilterParser implements FilterParserInterface
14
{
15
    private const T_NUMBER = 10;
16
    private const T_STRING = 11;
17
    private const T_BOOL = 12;
18
    private const T_NULL = 13;
19
20
    private const T_PAREN_OPEN = 21;
21
    private const T_PAREN_CLOSE = 22;
22
    private const T_BRACKET_OPEN = 23;
23
    private const T_BRACKET_CLOSE = 24;
24
25
    private const T_NAME = 30;
26
27
    private const T_NEGATION = 40;
28
    private const T_LOG_OP = 41;
29
    private const T_COMP_OP = 42;
30
31
    private Tokenizer\Tokenizer $tokenizer;
32
33
    /**
34
     * SCIM Filter Parser.
35
     */
36
    public function __construct()
37
    {
38
        $this->tokenizer = new Tokenizer\Tokenizer(
39
            [
40
                self::T_NUMBER => '\d+(?:\.\d+)?',
41
                self::T_STRING => '\"[^\"]*\"',
42
                self::T_BOOL => 'true|false',
43
                self::T_NULL => 'null',
44
                self::T_PAREN_OPEN => '\(',
45
                self::T_PAREN_CLOSE => '\)',
46
                self::T_BRACKET_OPEN => '\[',
47
                self::T_BRACKET_CLOSE => '\]',
48
                self::T_NEGATION => 'not\s+',
49
                self::T_LOG_OP => '\s+(?:and|or)\s+',
50
                self::T_COMP_OP => '\s(?:(?:eq|ne|co|sw|ew|gt|lt|ge|le)\s+|pr)',
51
                self::T_NAME => '(?:(?:[^\"]+\:)+)?[\-\_a-z0-9]+(?:\.[\-\_a-z0-9]+)?',
52
            ],
53
            'i'
54
        );
55
    }
56
57
    /**
58
     * Parse filter string.
59
     *
60
     * @param string $input SCIM filter.
61
     *
62
     * @throws TokenizerException
63
     *
64
     * @return AST\Node|null
65
     */
66
    public function parse(string $input): ?AST\Node
67
    {
68
        $stream = $this->tokenizer->tokenize($input);
69
70
        return $this->parseFilter($stream);
71
    }
72
73
    /**
74
     * Parse filter.
75
     *
76
     * @param Tokenizer\Stream $stream
77
     * @param bool             $inValuePath
78
     *
79
     * @throws TokenizerException
80
     *
81
     * @return AST\Node|null
82
     */
83
    private function parseFilter(Tokenizer\Stream $stream, bool $inValuePath = false): ?AST\Node
84
    {
85
        $node = null;
86
87
        // Parentheses
88
        if ($stream->isNext(self::T_PAREN_OPEN)) {
89
            $node = $this->parseParentheses($stream);
90
91
            if ($node === null) {
92
                return null;
93
            }
94
        }
95
96
        // Negation
97
        if ($stream->isNext(self::T_NEGATION)) {
98
            $node = $this->parseNegation($stream, $inValuePath);
99
100
            if ($node === null) {
101
                return null;
102
            }
103
        }
104
105
        // Comparison or value path
106
        if ($stream->isNext(self::T_NAME)) {
107
            $node = $this->parseAttributePath($stream, $inValuePath);
108
        }
109
110
        if ($node !== null) {
111
            // Logical connective
112
            if ($stream->isNext(self::T_LOG_OP)) {
113
                return $this->parseConnective($stream, $node, $inValuePath);
114
            }
115
116
            return $node;
117
        }
118
119
        throw new TokenizerException(
120
            sprintf(
121
                'Expected an attribute/value path, opening parenthesis or a negation, got "%s".',
122
                $stream->nextValue()
123
            )
124
        );
125
    }
126
127
    /**
128
     * Parse filter in parentheses.
129
     *
130
     * @param Tokenizer\Stream $stream
131
     *
132
     * @throws TokenizerException
133
     *
134
     * @return AST\Node|null
135
     */
136
    private function parseParentheses(Tokenizer\Stream $stream): ?AST\Node
137
    {
138
        $stream->matchNext(self::T_PAREN_OPEN);
139
        $filter = $stream->joinUntil(self::T_PAREN_CLOSE);
140
        $stream->matchNext(self::T_PAREN_CLOSE);
141
142
        if (empty($filter)) {
143
            return null;
144
        }
145
146
        return $this->parse($filter);
147
    }
148
149
    /**
150
     * Parse negation.
151
     *
152
     * @param Tokenizer\Stream $stream
153
     * @param bool             $inValuePath
154
     *
155
     * @throws TokenizerException
156
     *
157
     * @return AST\Node|null
158
     */
159
    private function parseNegation(Tokenizer\Stream $stream, bool $inValuePath = false): ?AST\Node
160
    {
161
        $stream->matchNext(self::T_NEGATION);
162
        $stream->matchNext(self::T_PAREN_OPEN);
163
        $filter = $stream->joinUntil(self::T_PAREN_CLOSE);
164
        $stream->matchNext(self::T_PAREN_CLOSE);
165
166
        if (empty($filter)) {
167
            return null;
168
        }
169
170
        $node = $this->parseFilter($this->tokenizer->tokenize($filter), $inValuePath);
171
172
        return new AST\Negation($node);
173
    }
174
175
    /**
176
     * Parse attribute path.
177
     *
178
     * @param Tokenizer\Stream $stream
179
     * @param bool             $inValuePath
180
     *
181
     * @throws TokenizerException
182
     *
183
     * @return AST\Node
184
     */
185
    private function parseAttributePath(Tokenizer\Stream $stream, bool $inValuePath = false): AST\Node
186
    {
187
        $name = $stream->matchNext(self::T_NAME)->value;
188
189
        // Attribute scheme
190
        $scheme = null;
191
192
        if (strpos($name, ':') !== false) {
193
            $lastColonPos = strrpos($name, ':');
194
            $scheme = substr($name, 0, $lastColonPos);
195
            $name = substr($name, $lastColonPos + 1);
196
        }
197
198
        // Attribute path
199
        $attributePath = new AST\AttributePath($scheme, explode('.', $name));
200
201
        // Value path (only if not in value path already
202
        if ($stream->isNext(self::T_BRACKET_OPEN)) {
203
            if ($inValuePath === true) {
204
                throw new InvalidValuePathFilterException();
205
            }
206
207
            return $this->parseValuePath($stream, $attributePath);
208
        }
209
210
        // Comparison
211
        return $this->parseComparison($stream, $attributePath);
212
    }
213
214
    /**
215
     * Parse value path.
216
     *
217
     * @param Tokenizer\Stream  $stream
218
     * @param AST\AttributePath $attributePath
219
     *
220
     * @throws TokenizerException
221
     *
222
     * @return AST\Node
223
     */
224
    private function parseValuePath(Tokenizer\Stream $stream, AST\AttributePath $attributePath): AST\Node
225
    {
226
        // Parse node between brackets.
227
        $stream->matchNext(self::T_BRACKET_OPEN);
228
229
        $node = $this->parseFilter($stream, true);
230
231
        if (
232
            $node instanceof AST\Connective === false
233
            && $node instanceof AST\Comparison === false
234
            && $node instanceof AST\Negation === false
235
        ) {
236
            throw new InvalidValuePathFilterException();
237
        }
238
239
        $stream->matchNext(self::T_BRACKET_CLOSE);
240
241
        // Correct attribute path for node.
242
        $this->updateValuePathAttributePath($attributePath, $node);
243
244
        return new AST\ValuePath($attributePath, $node);
245
    }
246
247
    /**
248
     * Parse comparison.
249
     *
250
     * @param Tokenizer\Stream  $stream
251
     * @param AST\AttributePath $attributePath
252
     *
253
     * @throws TokenizerException
254
     *
255
     * @return AST\Connective|AST\Comparison
256
     */
257
    private function parseComparison(
258
        Tokenizer\Stream $stream,
259
        AST\AttributePath $attributePath
260
    ) {
261
        $operator = trim($stream->matchNext(self::T_COMP_OP)->value);
262
        $value = null;
263
264
        if (strcasecmp($operator, 'pr') <> 0) {
265
            $value = $stream->matchNext(self::T_STRING, self::T_NUMBER, self::T_BOOL, self::T_NULL);
266
267
            switch ($value->type) {
268
                case self::T_STRING:
269
                    $value = trim($value->value, '"');
270
                    break;
271
                case self::T_NUMBER:
272
                    if (strpos($value->value, '.') !== false) {
273
                        $value = floatval($value->value);
274
                        break;
275
                    }
276
277
                    $value = intval($value->value);
278
                    break;
279
                case self::T_BOOL:
280
                    $value = strcasecmp($value->value, 'true') === 0;
281
                    break;
282
                default:
283
                    $value = null;
284
                    break;
285
            }
286
        }
287
288
        return new AST\Comparison($attributePath, $operator, $value);
289
    }
290
291
    /**
292
     * Parse logical connective (and/or).
293
     *
294
     * @param Tokenizer\Stream $stream
295
     * @param AST\Node         $leftNode
296
     * @param bool             $inValuePath
297
     *
298
     * @throws TokenizerException
299
     *
300
     * @return AST\Connective
301
     */
302
    private function parseConnective(
303
        Tokenizer\Stream $stream,
304
        AST\Node $leftNode,
305
        bool $inValuePath = false
306
    ): AST\Connective {
307
        $logOp = trim($stream->matchNext(self::T_LOG_OP)->value);
308
309
        $isConjunction = strcasecmp($logOp, 'and') === 0;
310
311
        // Parse right hand node
312
        $rightNode = $this->parseFilter($stream, $inValuePath);
313
314
        // Connective nodes
315
        $nodes = [$leftNode, $rightNode];
316
317
        // Merge consecutive connectives of the same type into one connective.
318
        if (
319
            ($rightNode instanceof AST\Conjunction && $isConjunction === true)
320
            || ($rightNode instanceof AST\Disjunction && $isConjunction === false)
321
        ) {
322
            $rightNodes = $rightNode->getNodes();
323
324
            $nodes = array_merge([$leftNode], $rightNodes);
325
        }
326
327
        return $isConjunction === true
328
            ? new AST\Conjunction($nodes)
329
            : new AST\Disjunction($nodes);
330
    }
331
332
    /**
333
     * Prepend attribute path to node.
334
     *
335
     * @param AST\AttributePath $attributePath
336
     * @param AST\Node          $node
337
     *
338
     * @throws TokenizerException
339
     *
340
     * @return AST\Node
341
     */
342
    private function updateValuePathAttributePath(AST\AttributePath $attributePath, AST\Node $node): AST\Node
343
    {
344
        if ($node instanceof AST\Comparison) {
345
            $names = array_merge($attributePath->getNames(), $node->getAttributePath()->getNames());
346
347
            $node->setAttributePath(new AST\AttributePath($attributePath->getSchema(), $names));
348
349
            return $node;
350
        }
351
352
        if ($node instanceof AST\Negation) {
353
            $node->setNode($this->updateValuePathAttributePath($attributePath, $node->getNode()));
354
355
            return $node;
356
        }
357
358
        if ($node instanceof AST\Connective) {
359
            foreach ($node->getNodes() as $subNode) {
360
                $this->updateValuePathAttributePath($attributePath, $subNode);
361
            }
362
363
            return $node;
364
        }
365
366
        throw new InvalidValuePathFilterException();
367
    }
368
}
369