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