Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.82% covered (success)
90.82%
89 / 98
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Comparison
90.82% covered (success)
90.82%
89 / 98
77.78% covered (warning)
77.78%
7 / 9
43.37
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
 get
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
11
 getJsonPathExpression
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
11
 getRegex
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 getJsonFieldExpression
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
8.66
 localeIds
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getRight
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getSqlExpression
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 quote
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Finder\Output;
6
7use Cosray\Context;
8use Cosray\Exception\ParserOutputException;
9use Cosray\Finder\Input\Token;
10use Cosray\Finder\Input\TokenType;
11use Override;
12
13final readonly class Comparison extends Expression implements Output
14{
15    /** @param array<string, string> $builtins */
16    public function __construct(
17        private Token $left,
18        private Token $operator,
19        private Token $right,
20        private Context $context,
21        private array $builtins,
22    ) {}
23
24    #[Override]
25    public function get(): string
26    {
27        switch ($this->operator->type) {
28            case TokenType::Like:
29            case TokenType::Unlike:
30            case TokenType::ILike:
31            case TokenType::IUnlike:
32            case TokenType::In:
33            case TokenType::NotIn:
34                return $this->getSqlExpression();
35        }
36
37        if ($this->left->type === TokenType::Field) {
38            if ($this->right->type === TokenType::Builtin || $this->right->type === TokenType::Field) {
39                return $this->getSqlExpression();
40            }
41
42            return $this->getJsonPathExpression();
43        }
44
45        if ($this->left->type === TokenType::Builtin) {
46            return $this->getSqlExpression();
47        }
48
49        throw new ParserOutputException(
50            $this->left,
51            'Only fields or `path` are allowed on the left side of an expression.',
52        );
53    }
54
55    private function getJsonPathExpression(): string
56    {
57        [$operator, $jsonOperator, $right, $negate] = match ($this->operator->type) {
58            TokenType::Equal => ['@@', '==', $this->getRight(), false],
59            // PDO reads a bare `?` as a positional parameter; `??` is its escape.
60            TokenType::Regex => ['@??', '?', $this->getRegex(false), false],
61            TokenType::IRegex => ['@??', '?', $this->getRegex(true), false],
62            TokenType::NotRegex => ['@??', '?', $this->getRegex(false), true],
63            TokenType::INotRegex => ['@??', '?', $this->getRegex(true), true],
64            TokenType::In => ['@@', 'in', $this->getRight(), false],
65            TokenType::NotIn => ['@@', 'nin', $this->getRight(), false],
66            default => ['@@', $this->operator->lexeme, $this->getRight(), false],
67        };
68
69        $left = $this->getJsonFieldExpression();
70        $root = str_ends_with($this->left->lexeme, '.*') ? '$[*]' : '$';
71        $path = $root . ' ' . $jsonOperator . ' ' . $right;
72        $expression = sprintf(
73            '%s %s %s',
74            $left,
75            $operator,
76            $this->context->db->quote($path),
77        );
78
79        return $negate ? "NOT ({$expression})" : $expression;
80    }
81
82    private function getRegex(bool $ignoreCase): string
83    {
84        if (!($this->right->type === TokenType::String)) {
85            throw new ParserOutputException(
86                $this->right,
87                'Only strings are allowed on the right side of a regex expressions.',
88            );
89        }
90
91        $case = $ignoreCase ? ' flag "i"' : '';
92
93        // TODO: quote double quotes, check also in tests
94        $pattern = '"' . trim($this->context->db->quote($this->right->lexeme), "'") . '"';
95
96        return sprintf('(@ like_regex %s%s)', $pattern, $case);
97    }
98
99    private function getJsonFieldExpression(): string
100    {
101        $parts = explode('.', $this->left->lexeme);
102
103        if (count($parts) === 1) {
104            return $this->compileField(
105                $this->left->lexeme,
106                'n.content',
107                asIs: true,
108                localeIds: $this->localeIds(),
109            );
110        }
111
112        if (count($parts) === 2 && $parts[1] === '?') {
113            return "n.content->'{$parts[0]}'->'value'->'{$this->context->localeId()}'";
114        }
115
116        if (count($parts) === 2 && $parts[1] === '*') {
117            return "jsonb_path_query_array(n.content->'{$parts[0]}'->'value', '$.*')";
118        }
119
120        if (count($parts) > 2 && in_array('?', $parts, true)) {
121            throw new ParserOutputException(
122                $this->left,
123                'The questionmark is allowed after the first dot only.',
124            );
125        }
126
127        return $this->compileField(
128            $this->left->lexeme,
129            'n.content',
130            asIs: true,
131            localeIds: $this->localeIds(),
132        );
133    }
134
135    /** @return list<string> */
136    private function localeIds(): array
137    {
138        $ids = [];
139        $locale = $this->context->locale();
140
141        while ($locale) {
142            $ids[] = $locale->id;
143            $locale = $locale->fallback();
144        }
145
146        return $ids;
147    }
148
149    private function getRight(): string
150    {
151        return match ($this->right->type) {
152            TokenType::String => $this->quote($this->right->lexeme),
153            TokenType::Number, TokenType::Boolean, TokenType::List, TokenType::Null => $this->right->lexeme,
154            default => throw new ParserOutputException(
155                $this->right,
156                'The right hand side in a field expression must be a literal',
157            ),
158        };
159    }
160
161    private function getSqlExpression(): string
162    {
163        return sprintf(
164            '%s %s %s',
165            $this->getOperand($this->left, $this->context->db, $this->builtins, $this->context),
166            $this->getOperator($this->operator->type),
167            $this->getOperand($this->right, $this->context->db, $this->builtins, $this->context),
168        );
169    }
170
171    private function quote(string $string): string
172    {
173        return sprintf(
174            '"%s"',
175            // Escape all unescaped double quotes
176            // TODO: can prepended backslashes be exploited
177            preg_replace(
178                '/(?<!\\\\)(")/',
179                '\\"',
180                trim($this->context->db->quote($string), "'"),
181            ),
182        );
183    }
184}