Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.25% covered (success)
96.25%
77 / 80
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
OrderCompiler
96.25% covered (success)
96.25%
77 / 80
66.67% covered (warning)
66.67%
6 / 9
32
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
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 params
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 structured
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
10
 position
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 scalar
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 builtin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 localeIds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parse
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Finder;
6
7use Closure;
8use Cosray\Context;
9use Cosray\Exception\ParserException;
10
11final class OrderCompiler
12{
13    use CompilesField;
14
15    /** @var array<string, string> */
16    private array $params = [];
17
18    /** @param array<string, string|Closure(): string> $builtins */
19    public function __construct(
20        private readonly array $builtins = [],
21        private readonly ?Context $context = null,
22    ) {}
23
24    public function compile(string|Order ...$statements): string
25    {
26        $expressions = [];
27
28        foreach ($statements as $statement) {
29            if ($statement instanceof Order) {
30                $expressions[] = $this->structured($statement);
31                continue;
32            }
33
34            if (trim($statement) === '') {
35                throw new ParserException('Empty order by clause');
36            }
37
38            foreach ($this->parse($statement) as $field) {
39                $fieldName = $field['field'];
40                $expression = $this->builtin($fieldName)
41                    ?? $this->compileField($fieldName, 'n.content', localeIds: $this->localeIds());
42                $expressions[] = $expression . ' ' . $field['direction'];
43            }
44        }
45
46        if ($expressions === []) {
47            throw new ParserException('Empty order by clause');
48        }
49
50        return "\n    " . implode(",\n    ", $expressions);
51    }
52
53    /**
54     * Bound values the compiled order refers to.
55     *
56     * @return array<string, string>
57     */
58    public function params(): array
59    {
60        return $this->params;
61    }
62
63    private function structured(Order $order): string
64    {
65        $field = $order->field;
66
67        if ($field->type === 'position') {
68            return $this->position($field->collection, strtoupper($order->direction));
69        }
70
71        $expression = $this->builtin($field->name);
72
73        if ($expression !== null && $field->type !== 'text') {
74            throw new ParserException("Built-in sort field '{$field->name}' already has a native type");
75        }
76
77        if ($expression === null) {
78            $expression = $this->scalar($field->name);
79
80            if ($field->type !== 'text') {
81                // PostgreSQL accepts relative dates and special numeric values. Stored
82                // CMS values must be absolute, finite scalars; corrupt data must fail.
83                $pattern = match ($field->type) {
84                    'numeric' => '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?$',
85                    'date' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
86                    'timestamptz'
87                        => '^[0-9]{4}-[0-9]{2}-[0-9]{2}T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$',
88                    default => throw new ParserException("Unknown sort type '{$field->type}'"),
89                };
90                $expression =
91                    "CAST(CASE WHEN {$expression} ~ '{$pattern}' THEN {$expression} "
92                    . "ELSE 'Invalid {$field->type} sort value for {$field->name}: ' || {$expression} END AS {$field->type})";
93            }
94        }
95
96        return $expression . ' ' . strtoupper($order->direction) . ' NULLS LAST';
97    }
98
99    /**
100     * A correlated lookup rather than a join, so the finder's query stays
101     * as it is. The title and uid follow in the same direction: unpositioned
102     * nodes keep the order they had before anyone arranged them.
103     */
104    private function position(?string $collection, string $direction): string
105    {
106        if ($collection === null) {
107            $scope = 'pos.parent = n.parent';
108        } else {
109            $param = 'order_collection_' . count($this->params);
110            $this->params[$param] = $collection;
111            $scope = 'pos.collection = :' . $param;
112        }
113
114        $lookup =
115            '(SELECT pos.position FROM /*:cms.prefix:*/node_positions pos WHERE '
116            . $scope
117            . ' AND pos.node = n.node)';
118        $title = $this->builtin('title');
119        $terms = [$lookup . ' ' . $direction . ' NULLS LAST'];
120
121        if ($title !== null) {
122            $terms[] = $title . ' ' . $direction;
123        }
124
125        $terms[] = 'n.uid ' . $direction;
126
127        return implode(', ', $terms);
128    }
129
130    private function scalar(string $field): string
131    {
132        $fields = str_contains($field, '.')
133            ? [$field]
134            : array_map(
135                static fn(string $locale): string => $field . '.' . $locale,
136                $this->normalizeLocaleIds($this->localeIds()),
137            );
138        $values = [];
139
140        foreach ($fields as $path) {
141            // JSON_VALUE rejects arrays/objects instead of silently sorting their
142            // serialized representation. SQL/JSON nulls allow locale fallback.
143            $json = $this->compileField($path, 'n.content', asIs: true);
144            $values[] = "NULLIF(BTRIM(JSON_VALUE({$json}, '$' RETURNING text ERROR ON ERROR), E' \\t\\n\\r\\013'), '')";
145        }
146
147        return 'COALESCE(' . implode(', ', $values) . ')';
148    }
149
150    private function builtin(string $field): ?string
151    {
152        $value = $this->builtins[$field] ?? null;
153
154        return $value instanceof Closure ? $value() : $value;
155    }
156
157    /** @return list<string> */
158    private function localeIds(): array
159    {
160        if (!$this->context) {
161            return ['zxx'];
162        }
163
164        $locale = $this->context->locale();
165
166        return [$locale->id, ...$locale->fallbacks()];
167    }
168
169    /** @return list<array{field: string, direction: string}> */
170    private function parse(string $statement): array
171    {
172        $fields = explode(',', $statement);
173        $pattern = '/^\s*([a-zA-Z][a-zA-Z0-9._]*)\s*(asc|desc)?\s*$/i';
174        $result = [];
175
176        foreach ($fields as $field) {
177            if (preg_match($pattern, trim($field), $matches)) {
178                $result[] = [
179                    'field' => $matches[1],
180                    'direction' => strtoupper($matches[2] ?? null ?: 'ASC'),
181                ];
182            } else {
183                throw new ParserException('Invalid order by clause');
184            }
185        }
186
187        return $result;
188    }
189}