Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.27% covered (success)
96.27%
129 / 134
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryParser
96.27% covered (success)
96.27%
129 / 134
80.00% covered (warning)
80.00%
8 / 10
49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
10
 materializeLists
88.57% covered (warning)
88.57%
31 / 35
0.00% covered (danger)
0.00%
0 / 1
9.12
 getExpression
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
10
 getComparisonCondition
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 getExistsCondition
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getBooleanOperator
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getLeftParen
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getRightParen
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 error
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Finder;
6
7use Cosray\Context;
8use Cosray\Exception\ParserException;
9use Cosray\Exception\ParserOutputException;
10use Cosray\Finder\Input\Token;
11use Cosray\Finder\Input\TokenGroup;
12use Cosray\Finder\Input\TokenType;
13use Cosray\Finder\Output\Comparison;
14use Cosray\Finder\Output\Exists;
15use Cosray\Finder\Output\Expression;
16use Cosray\Finder\Output\LeftParen;
17use Cosray\Finder\Output\NodeReference;
18use Cosray\Finder\Output\NullComparison;
19use Cosray\Finder\Output\Operator;
20use Cosray\Finder\Output\RightParen;
21use Cosray\Finder\Output\UrlPath;
22
23final class QueryParser
24{
25    /** @var list<Token> */
26    private array $tokens;
27
28    private int $pos;
29    private int $length;
30    private int $parensBalance;
31    private bool $readyForCondition = true;
32    private string $query;
33
34    /** @param list<string> $builtins */
35    public function __construct(
36        private readonly Context $context,
37        private readonly array $builtins = [],
38    ) {}
39
40    /**
41     * Returns an array of output tokens which can be translated to a
42     * valid SQL WHERE expression.
43     */
44    public function parse(string $query): array
45    {
46        $result = [];
47
48        $this->query = $query;
49        $this->tokens = $this->materializeLists(
50            new QueryLexer(array_keys($this->builtins))->tokens($query),
51        );
52        $this->length = count($this->tokens);
53
54        $this->parensBalance = 0;
55        $this->readyForCondition = true;
56        $this->pos = 0;
57
58        while ($this->pos < $this->length) {
59            try {
60                $token = $this->tokens[$this->pos];
61
62                $result[] = match ($token->group) {
63                    TokenGroup::Operand => $this->getExpression($token),
64                    TokenGroup::BooleanOperator => $this->getBooleanOperator($token),
65                    TokenGroup::LeftParen => $this->getLeftParen($token),
66                    TokenGroup::RightParen => $this->getRightParen($token),
67                    // Special case Operator:
68                    // As we consume operators together with operands, it would
69                    // be invalid if we would find operators anywhere else.
70                    TokenGroup::Operator => $this->error($token, 'Invalid position for an operator.'),
71                };
72
73                if ($this->parensBalance < 0) {
74                    $this->error($token, 'Parse error. Unbalanced parenthesis');
75                }
76            } catch (ParserOutputException $e) {
77                $this->error($e->token, $e->getMessage());
78            }
79        }
80
81        if ($this->parensBalance > 0) {
82            $this->error($token, 'Parse error. Unbalanced parenthesis');
83        }
84
85        return $result;
86    }
87
88    private function materializeLists(array $tokens): array
89    {
90        $insideList = false;
91        $transformedTokens = [];
92        $currentList = [];
93        $currentListPos = null;
94
95        foreach ($tokens as $token) {
96            if ($token->type === TokenType::LeftBracket) {
97                if ($insideList) {
98                    throw new ParserException('Invalid query: nested list');
99                }
100
101                $insideList = true;
102                $currentListPos = $token->position;
103
104                continue;
105            }
106
107            if ($token->type === TokenType::RightBracket) {
108                if (!$insideList) {
109                    throw new ParserException('Invalid query: not inside list');
110                }
111
112                $insideList = false;
113
114                $transformedTokens[] = Token::fromList(
115                    TokenGroup::Operand,
116                    TokenType::List,
117                    $currentListPos,
118                    $currentList,
119                    $token->position - $currentListPos,
120                    $this->context->db,
121                );
122                $currentList = [];
123                $currentListPos = null;
124
125                continue;
126            }
127
128            if ($insideList) {
129                if ($token->group === TokenGroup::Operand) {
130                    $currentList[] = $token;
131                } else {
132                    throw new ParserException('Invalid query: only operands are allowed as list members');
133                }
134
135                continue;
136            }
137
138            $transformedTokens[] = $token;
139        }
140
141        if ($insideList) {
142            throw new ParserException('Invalid query: unbalanced list');
143        }
144
145        return $transformedTokens;
146    }
147
148    /**
149     * @throws ParserException
150     */
151    private function getExpression(#[\SensitiveParameter] Token $token): Expression
152    {
153        if (!$this->readyForCondition) {
154            $this->error($token, 'Invalid position for a condition.');
155        }
156
157        // Consume the whole condition if valid
158        if (
159            ($this->pos + 2) <= $this->length
160            && $this->tokens[$this->pos + 1]->group === TokenGroup::Operator
161            && $this->tokens[$this->pos + 2]->group === TokenGroup::Operand
162        ) {
163            // A Regular key value comparision
164            return $this->getComparisonCondition($token);
165        }
166
167        if (
168            ($this->pos + 2) <= $this->length
169            && $this->tokens[$this->pos + 1]->group === TokenGroup::BooleanOperator
170            || count($this->tokens) === ($this->pos + 1)
171        ) {
172            // Key exists query
173            return $this->getExistsCondition($token);
174        }
175
176        if (
177            $this->tokens[$this->pos + 1]->group === TokenGroup::Operator
178            && $this->tokens[$this->pos + 2]->group === TokenGroup::Operator
179        ) {
180            $this->error($token, 'Multiple operators. Maybe you used == instead of =.');
181        }
182
183        $this->error($token, 'Invalid condition.');
184    }
185
186    private function getComparisonCondition(Token $left): Expression
187    {
188        $operator = $this->tokens[$this->pos + 1];
189        $right = $this->tokens[$this->pos + 2];
190
191        // Advance 3 steps: operand operator operand
192        $this->pos += 3;
193        // Wrong position to start a new condition after this one
194        $this->readyForCondition = false;
195
196        if ($left->type === TokenType::Null) {
197            $this->error($left, 'Invalid position for a null value.');
198        }
199
200        if ($right->type === TokenType::Null) {
201            return new NullComparison($left, $operator, $right, $this->context, $this->builtins);
202        }
203
204        if ($left->type === TokenType::Path || $right->type === TokenType::Path) {
205            return new UrlPath($left, $operator, $right, $this->context);
206        }
207
208        if ($left->type === TokenType::Reference || $right->type === TokenType::Reference) {
209            return new NodeReference($left, $operator, $right, $this->context);
210        }
211
212        return new Comparison($left, $operator, $right, $this->context, $this->builtins);
213    }
214
215    private function getExistsCondition(#[\SensitiveParameter] Token $token): Exists
216    {
217        if ($token->type !== TokenType::Field) {
218            $this->error(
219                $token,
220                'Conditions of type `field exists` must consist of a single operand of type Field.',
221            );
222        }
223
224        $this->readyForCondition = false;
225        $this->pos++;
226
227        return new Exists($token, $this->context);
228    }
229
230    /**
231     * @throws ParserException
232     */
233    private function getBooleanOperator(#[\SensitiveParameter] Token $token): Operator
234    {
235        if ($this->readyForCondition) {
236            $this->error(
237                $token,
238                'Invalid position for a boolean operator. '
239                    . 'Maybe you used && instead of & or || instead of |',
240            );
241        }
242
243        if ($this->pos >= ($this->length - 1)) {
244            $this->error($token, 'Boolean operator at the end of the expression.');
245        }
246
247        $this->readyForCondition = true;
248        $this->pos++;
249
250        return new Operator($token);
251    }
252
253    /**
254     * @throws ParserException
255     */
256    private function getLeftParen(#[\SensitiveParameter] Token $token): LeftParen
257    {
258        if (!$this->readyForCondition) {
259            $this->error($token, 'Invalid position for parenthesis.');
260        }
261
262        $this->parensBalance++;
263        $this->pos++;
264
265        return new LeftParen($token);
266    }
267
268    /**
269     * @throws ParserException
270     */
271    private function getRightParen(#[\SensitiveParameter] Token $token): RightParen
272    {
273        if ($this->pos > 0 && $this->tokens[$this->pos - 1]->type === TokenType::LeftParen) {
274            $this->error(
275                $token,
276                'Invalid parenthesis: empty group.',
277            );
278        }
279
280        $this->readyForCondition = false;
281        $this->parensBalance--;
282        $this->pos++;
283
284        return new RightParen($token);
285    }
286
287    /**
288     * @throws ParserException
289     */
290    private function error(#[\SensitiveParameter] Token $token, string $msg): never
291    {
292        $position = $token->position + 1;
293
294        if ($this->pos === count($this->tokens)) {
295            // This is a general error. We are after the last token.
296            $start = 8;
297            $len = strlen($this->query);
298        } else {
299            $start = $position + 7;
300            $len = $token->len();
301        }
302
303        throw new ParserException(
304            "Parse error at position {$position}{$msg}\n\n"
305                . "Query: `{$this->query}`\n"
306                . str_repeat(' ', $start)
307                . str_repeat('^', $len)
308                . "\n\n",
309        );
310    }
311}