Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.67% covered (warning)
58.67%
44 / 75
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
UrlPath
58.67% covered (warning)
58.67%
44 / 75
33.33% covered (danger)
33.33%
3 / 9
210.70
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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 normalize
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 reverse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 condition
33.33% covered (danger)
33.33%
7 / 21
0.00% covered (danger)
0.00%
0 / 1
114.00
 isComparison
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 scalarComparison
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 literal
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
5.27
 localeClause
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
11.64
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 UrlPath extends Expression implements Output
13{
14    public function __construct(
15        public Token $left,
16        public Token $operator,
17        public Token $right,
18        private Context $context,
19    ) {}
20
21    public function get(): string
22    {
23        [$pathToken, $valueToken, $operator] = $this->normalize();
24        [$localeClause, $isNegated, $condition] = $this->condition($valueToken, $operator);
25
26        // Safe SQL fragment: table() validates the hardcoded identifier and configured prefix.
27        $table = $this->context->config->db->table('url_paths', $this->context->db->getPdoDriver());
28
29        return sprintf(
30            '%sEXISTS (SELECT 1 FROM %s p WHERE p.node = n.node AND p.inactive IS NULL%s AND %s)',
31            $isNegated ? 'NOT ' : '',
32            $table,
33            $localeClause,
34            $condition,
35        );
36    }
37
38    /**
39     * @return array{0: Token, 1: Token, 2: TokenType}
40     */
41    private function normalize(): array
42    {
43        if ($this->left->type === TokenType::Path) {
44            return [$this->left, $this->right, $this->operator->type];
45        }
46
47        if ($this->right->type !== TokenType::Path) {
48            throw new ParserOutputException($this->left, 'A path expression requires a path operand.');
49        }
50
51        return [$this->right, $this->left, $this->reverse($this->operator->type)];
52    }
53
54    private function reverse(TokenType $type): TokenType
55    {
56        return match ($type) {
57            TokenType::Greater => TokenType::Less,
58            TokenType::GreaterEqual => TokenType::LessEqual,
59            TokenType::Less => TokenType::Greater,
60            TokenType::LessEqual => TokenType::GreaterEqual,
61            default => $type,
62        };
63    }
64
65    /**
66     * @return array{0: string, 1: bool, 2: string}
67     */
68    private function condition(#[\SensitiveParameter] Token $valueToken, TokenType $operator): array
69    {
70        $localeClause = $this->localeClause();
71
72        return match ($operator) {
73            TokenType::Equal => [$localeClause, false, $this->isComparison($valueToken, true)],
74            TokenType::Unequal => [$localeClause, true, $this->isComparison($valueToken, true)],
75            TokenType::Like => [$localeClause, false, $this->scalarComparison($valueToken, 'LIKE')],
76            TokenType::Unlike => [$localeClause, true, $this->scalarComparison($valueToken, 'LIKE')],
77            TokenType::ILike => [$localeClause, false, $this->scalarComparison($valueToken, 'ILIKE')],
78            TokenType::IUnlike => [$localeClause, true, $this->scalarComparison($valueToken, 'ILIKE')],
79            TokenType::Regex => [$localeClause, false, $this->scalarComparison($valueToken, '~')],
80            TokenType::NotRegex => [$localeClause, true, $this->scalarComparison($valueToken, '~')],
81            TokenType::IRegex => [$localeClause, false, $this->scalarComparison($valueToken, '~*')],
82            TokenType::INotRegex => [$localeClause, true, $this->scalarComparison($valueToken, '~*')],
83            TokenType::In => [$localeClause, false, $this->scalarComparison($valueToken, 'IN')],
84            TokenType::NotIn => [$localeClause, true, $this->scalarComparison($valueToken, 'IN')],
85            TokenType::Greater => [$localeClause, false, $this->scalarComparison($valueToken, '>')],
86            TokenType::GreaterEqual => [$localeClause, false, $this->scalarComparison($valueToken, '>=')],
87            TokenType::Less => [$localeClause, false, $this->scalarComparison($valueToken, '<')],
88            TokenType::LessEqual => [$localeClause, false, $this->scalarComparison($valueToken, '<=')],
89            default => throw new ParserOutputException(
90                $this->operator,
91                'Operator not supported for path expressions.',
92            ),
93        };
94    }
95
96    private function isComparison(#[\SensitiveParameter] Token $valueToken, bool $allowNull): string
97    {
98        if ($valueToken->type === TokenType::Null) {
99            if (!$allowNull) {
100                throw new ParserOutputException($valueToken, 'NULL is not supported for this path comparison.');
101            }
102
103            return 'p.path IS NULL';
104        }
105
106        return 'p.path = ' . $this->literal($valueToken);
107    }
108
109    private function scalarComparison(
110        #[\SensitiveParameter]
111        Token $valueToken,
112        string $operator,
113    ): string {
114        if ($valueToken->type === TokenType::Null) {
115            throw new ParserOutputException(
116                $valueToken,
117                'NULL is only supported with = or != for path expressions.',
118            );
119        }
120
121        return 'p.path ' . $operator . ' ' . $this->literal($valueToken);
122    }
123
124    private function literal(#[\SensitiveParameter] Token $token): string
125    {
126        return match ($token->type) {
127            TokenType::String => $this->context->db->quote($token->lexeme),
128            TokenType::Number, TokenType::Boolean => $this->context->db->quote($token->lexeme),
129            TokenType::List => $token->lexeme,
130            default => throw new ParserOutputException(
131                $token,
132                'Path comparisons only support literal values.',
133            ),
134        };
135    }
136
137    private function localeClause(): string
138    {
139        $path = $this->left->type === TokenType::Path ? $this->left->lexeme : $this->right->lexeme;
140        $parts = explode('.', $path);
141
142        if (count($parts) === 1 || count($parts) === 2 && $parts[1] === '*') {
143            return '';
144        }
145
146        if (count($parts) !== 2) {
147            throw new ParserOutputException(
148                $this->left,
149                'Invalid path selector. Use path, path.?, path.*, or path.<locale>.',
150            );
151        }
152
153        $selector = $parts[1] === '?' ? $this->context->localeId() : $parts[1];
154
155        if (preg_match('/^[A-Za-z0-9_-]{1,64}$/', $selector) !== 1) {
156            throw new ParserOutputException($this->left, 'Invalid locale in path selector.');
157        }
158
159        return ' AND p.locale = ' . $this->context->db->quote($selector);
160    }
161}