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