Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.98% covered (success)
97.98%
97 / 99
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Filter
97.98% covered (success)
97.98%
97 / 99
91.67% covered (success)
91.67%
11 / 12
64
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
 compile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filter
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 combine
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 member
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 condition
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 comparison
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 exists
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 operand
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
9.49
 literal
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 list
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 object
100.00% covered (success)
100.00%
3 / 3
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\Finder\Input\Token;
10use Cosray\Finder\Input\TokenGroup;
11use Cosray\Finder\Input\TokenType;
12use Cosray\Finder\Output\Comparison;
13use Cosray\Finder\Output\Exists;
14use Cosray\Finder\Output\NodeReference;
15use Cosray\Finder\Output\NullComparison;
16use Cosray\Finder\Output\UrlPath;
17
18/**
19 * Compiles a structured filter into the same conditions the query language
20 * produces, without ever building a query string, so values need no quoting
21 * by the caller:
22 *
23 *     ['or' => [
24 *         ['type' => 'global-topic', 'tenant' => 'T1'],
25 *         ['type' => 'local-topic', 'tenant' => 'T1', 'tutorial' => ['in' => ['a', 'b']]],
26 *     ]]
27 *
28 * An object ANDs its members; `and`, `or` and `not` combine filters. A member
29 * maps a field to a value, which means `eq`, or to operators: `eq`, `ne`, `lt`,
30 * `lte`, `gt`, `gte`, `in`, `nin`, `like`, `ilike`, `exists` and `null`.
31 * Content fields compare their effective value in the active locale; `title.de`
32 * reads one locale. `path`, `path.<locale>`, `references` and
33 * `references.<field>` work as in the query language.
34 */
35final class Filter
36{
37    public const int MAX_DEPTH = 8;
38
39    private const string FIELD = '/^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$/';
40
41    private const array COMPARISONS = [
42        'eq' => [TokenType::Equal, '='],
43        'ne' => [TokenType::Unequal, '!='],
44        'lt' => [TokenType::Less, '<'],
45        'lte' => [TokenType::LessEqual, '<='],
46        'gt' => [TokenType::Greater, '>'],
47        'gte' => [TokenType::GreaterEqual, '>='],
48        'in' => [TokenType::In, '@'],
49        'nin' => [TokenType::NotIn, '!@'],
50        'like' => [TokenType::Like, '~~'],
51        'ilike' => [TokenType::ILike, '~~*'],
52    ];
53
54    /**
55     * @param array<string, string> $builtins the builtin names and their SQL
56     * @param ?list<string> $allowed builtins a caller may use; null allows all
57     */
58    public function __construct(
59        private readonly Context $context,
60        private readonly array $builtins,
61        private readonly ?array $allowed = null,
62    ) {}
63
64    /** @param array<array-key, mixed> $filter */
65    public function compile(array $filter): string
66    {
67        return $this->filter($filter, 1);
68    }
69
70    /** @param array<array-key, mixed> $filter */
71    private function filter(array $filter, int $depth): string
72    {
73        if ($depth > self::MAX_DEPTH) {
74            throw new ParserException('The filter nests too deeply.');
75        }
76
77        if ($filter === [] || array_is_list($filter)) {
78            throw new ParserException('A filter is an object of conditions.');
79        }
80
81        $conditions = [];
82
83        foreach ($filter as $key => $value) {
84            $key = (string) $key;
85            $conditions[] = match ($key) {
86                'and', 'or' => $this->combine($key, $value, $depth),
87                'not' => 'NOT (' . $this->filter($this->object($value, 'not'), $depth + 1) . ')',
88                default => $this->member($key, $value),
89            };
90        }
91
92        return implode(' AND ', $conditions);
93    }
94
95    private function combine(string $operator, mixed $filters, int $depth): string
96    {
97        if (!is_array($filters) || $filters === [] || !array_is_list($filters)) {
98            throw new ParserException("`{$operator}` takes a list of filters.");
99        }
100
101        $parts = array_map(
102            fn(mixed $filter): string => (
103                '('
104                . $this->filter(
105                    $this->object($filter, $operator),
106                    $depth + 1,
107                )
108                . ')'
109            ),
110            $filters,
111        );
112
113        return '(' . implode(' ' . strtoupper($operator) . ' ', $parts) . ')';
114    }
115
116    private function member(string $field, mixed $value): string
117    {
118        $left = $this->operand($field);
119
120        if (!is_array($value) || array_is_list($value)) {
121            return $this->condition($left, 'eq', $value);
122        }
123
124        $conditions = [];
125
126        foreach ($value as $operator => $operand) {
127            $conditions[] = $this->condition($left, (string) $operator, $operand);
128        }
129
130        return implode(' AND ', $conditions);
131    }
132
133    private function condition(Token $left, string $operator, mixed $value): string
134    {
135        if ($operator === 'exists') {
136            return $this->exists($left, $value);
137        }
138
139        if ($operator === 'null') {
140            if (!is_bool($value)) {
141                throw new ParserException('`null` takes true or false.');
142            }
143
144            return $this->comparison($left, $value ? 'eq' : 'ne', null);
145        }
146
147        if (!array_key_exists($operator, self::COMPARISONS)) {
148            throw new ParserException("Unknown filter operator `{$operator}`.");
149        }
150
151        if ($value === null && !in_array($operator, ['eq', 'ne'], true)) {
152            throw new ParserException("`{$operator}` cannot compare with null.");
153        }
154
155        return $this->comparison($left, $operator, $value);
156    }
157
158    private function comparison(Token $left, string $operator, mixed $value): string
159    {
160        [$type, $lexeme] = self::COMPARISONS[$operator];
161        $operatorToken = new Token(TokenGroup::Operator, $type, 0, $lexeme);
162        $list = in_array($operator, ['in', 'nin'], true);
163        $right = $list ? $this->list($value, $operator) : $this->literal($value);
164
165        if ($right->type === TokenType::Null) {
166            return new NullComparison($left, $operatorToken, $right, $this->context, $this->builtins)->get();
167        }
168
169        return match ($left->type) {
170            TokenType::Path => new UrlPath($left, $operatorToken, $right, $this->context)->get(),
171            TokenType::Reference => new NodeReference($left, $operatorToken, $right, $this->context)->get(),
172            default => new Comparison($left, $operatorToken, $right, $this->context, $this->builtins)->get(),
173        };
174    }
175
176    private function exists(Token $left, mixed $value): string
177    {
178        if (!is_bool($value)) {
179            throw new ParserException('`exists` takes true or false.');
180        }
181
182        if ($left->type !== TokenType::Field) {
183            throw new ParserException('`exists` applies to content fields only.');
184        }
185
186        $condition = new Exists($left, $this->context)->get();
187
188        return $value ? $condition : "NOT ({$condition})";
189    }
190
191    private function operand(string $field): Token
192    {
193        if (preg_match(self::FIELD, $field) !== 1) {
194            throw new ParserException("`{$field}` is not a field name.");
195        }
196
197        if ($field === 'path' || str_starts_with($field, 'path.')) {
198            return new Token(TokenGroup::Operand, TokenType::Path, 0, $field);
199        }
200
201        if ($field === 'references' || str_starts_with($field, 'references.')) {
202            return new Token(TokenGroup::Operand, TokenType::Reference, 0, $field);
203        }
204
205        if (array_key_exists($field, $this->builtins)) {
206            if ($this->allowed !== null && !in_array($field, $this->allowed, true)) {
207                throw new ParserException("`{$field}` cannot be filtered on.");
208            }
209
210            return new Token(TokenGroup::Operand, TokenType::Builtin, 0, $field);
211        }
212
213        return new Token(TokenGroup::Operand, TokenType::Field, 0, $field);
214    }
215
216    private function literal(mixed $value): Token
217    {
218        return match (true) {
219            $value === null => new Token(TokenGroup::Operand, TokenType::Null, 0, 'null'),
220            is_bool($value) => new Token(TokenGroup::Operand, TokenType::Boolean, 0, $value ? 'true' : 'false'),
221            is_int($value) => new Token(TokenGroup::Operand, TokenType::Number, 0, (string) $value),
222            is_float($value) && is_finite($value) => new Token(
223                TokenGroup::Operand,
224                TokenType::Number,
225                0,
226                json_encode($value, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION),
227            ),
228            is_string($value) => new Token(TokenGroup::Operand, TokenType::String, 0, $value),
229            default => throw new ParserException('A filter value is a string, number, boolean or null.'),
230        };
231    }
232
233    private function list(mixed $values, string $operator): Token
234    {
235        if (!is_array($values) || $values === [] || !array_is_list($values)) {
236            throw new ParserException("`{$operator}` takes a non-empty list.");
237        }
238
239        $items = array_map(function (mixed $value) use ($operator): Token {
240            if (!is_string($value) && !is_int($value) && !(is_float($value) && is_finite($value))) {
241                throw new ParserException("`{$operator}` lists hold strings or numbers.");
242            }
243
244            return $this->literal($value);
245        }, $values);
246
247        return Token::fromList(TokenGroup::Operand, TokenType::List, 0, $items, 0, $this->context->db);
248    }
249
250    /** @return array<array-key, mixed> */
251    private function object(mixed $value, string $operator): array
252    {
253        if (!is_array($value)) {
254            throw new ParserException("`{$operator}` takes filter objects.");
255        }
256
257        return $value;
258    }
259}