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