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