Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.07% covered (success)
94.07%
111 / 118
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
NodeQuery
94.07% covered (success)
94.07%
111 / 118
64.29% covered (warning)
64.29%
9 / 14
68.97
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
 fromQuery
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 fromBody
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 source
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 fields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 state
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 from
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
7.01
 order
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
12
 list
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
8.19
 names
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 sync
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 instant
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 int
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 invalid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Cosray\DateTime\Codec;
9use Cosray\Finder\Order;
10use Cosray\Finder\SortField;
11
12/**
13 * A node listing's parameters, read from a query string or from the JSON
14 * body of a query request. Both take the same names; lists are
15 * comma-separated in a query string and arrays in a body.
16 */
17final readonly class NodeQuery
18{
19    public const int DEFAULT_LIMIT = 50;
20    public const int MAX_LIMIT = 1000;
21
22    /** Builtins a client may filter and order on; the others name users or internals. */
23    public const array BUILTINS = [
24        'uid',
25        'handle',
26        'type',
27        'parent',
28        'published',
29        'hidden',
30        'created',
31        'changed',
32        'routable',
33        'renderable',
34    ];
35
36    /** Finder builtins that name users or internals, which clients neither filter nor order on. */
37    private const array PRIVATE = ['creator', 'editor', 'changes', 'id', 'locked', 'deleted'];
38
39    private const string NAME = '/^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$/';
40    private const array SORT_TYPES = ['text', 'number', 'date', 'datetime'];
41
42    /**
43     * @param list<string> $types
44     * @param list<string> $uids
45     * @param list<string> $handles
46     * @param ?array<array-key, mixed> $filter
47     * @param non-empty-list<Order> $order
48     * @param ?list<string> $fields
49     */
50    public function __construct(
51        public array $types = [],
52        public array $uids = [],
53        public array $handles = [],
54        public ?string $parent = null,
55        public ?bool $published = true,
56        public ?bool $hidden = false,
57        public ?bool $deleted = false,
58        public ?array $filter = null,
59        public array $order = [new Order('title'), new Order('uid')],
60        public int $limit = self::DEFAULT_LIMIT,
61        public int $offset = 0,
62        public ?array $fields = null,
63        public bool $source = false,
64        public ?SyncCursor $sync = null,
65        public bool $ordered = false,
66    ) {}
67
68    /** @param array<array-key, mixed> $params */
69    public static function fromQuery(array $params): self
70    {
71        $filter = $params['filter'] ?? null;
72
73        if ($filter !== null) {
74            if (!is_string($filter)) {
75                throw self::invalid('`filter` is a JSON object.');
76            }
77
78            $filter = json_decode($filter, true);
79
80            if (!is_array($filter)) {
81                throw self::invalid('`filter` is a JSON object.');
82            }
83        }
84
85        return self::from($params, $filter);
86    }
87
88    /** @param array<array-key, mixed> $body */
89    public static function fromBody(array $body): self
90    {
91        $filter = $body['filter'] ?? null;
92
93        if ($filter !== null && !is_array($filter)) {
94            throw self::invalid('`filter` is an object.');
95        }
96
97        return self::from($body, $filter);
98    }
99
100    /** Whether the request asks for the source view. */
101    public static function source(mixed $view): bool
102    {
103        return match ($view) {
104            null, '', 'resolved' => false,
105            'source' => true,
106            default => throw self::invalid('`view` is `resolved` or `source`.'),
107        };
108    }
109
110    /** @return ?list<string> */
111    public static function fields(mixed $fields): ?array
112    {
113        return $fields === null ? null : self::names($fields, 'fields');
114    }
115
116    /** A tristate filter: true, false, or `any` for both. */
117    public static function state(mixed $value, string $name, ?bool $default): ?bool
118    {
119        return match ($value) {
120            null => $default,
121            true, 'true', '1' => true,
122            false, 'false', '0' => false,
123            'any' => null,
124            default => throw self::invalid("`{$name}` is true, false or any."),
125        };
126    }
127
128    /**
129     * @param array<array-key, mixed> $params
130     * @param ?array<array-key, mixed> $filter
131     */
132    private static function from(array $params, ?array $filter): self
133    {
134        $uids = self::list($params['uid'] ?? null, 'uid');
135
136        if (count($uids) > self::MAX_LIMIT) {
137            throw self::invalid('A query takes at most ' . self::MAX_LIMIT . ' uids.');
138        }
139
140        $parent = $params['parent'] ?? null;
141
142        if ($parent !== null && (!is_string($parent) || trim($parent) === '')) {
143            throw self::invalid('`parent` is a node uid.');
144        }
145
146        $sync = self::sync($params);
147
148        return new self(
149            types: self::list($params['type'] ?? null, 'type'),
150            uids: $uids,
151            handles: self::list($params['handle'] ?? null, 'handle'),
152            parent: $parent === null ? null : trim($parent),
153            published: self::state($params['published'] ?? null, 'published', true),
154            hidden: self::state($params['hidden'] ?? null, 'hidden', false),
155            deleted: self::state($params['deleted'] ?? null, 'deleted', false),
156            filter: $filter,
157            order: self::order($params['order'] ?? null),
158            limit: self::int(
159                $params['limit'] ?? null,
160                'limit',
161                $uids === [] ? self::DEFAULT_LIMIT : count($uids),
162                1,
163                self::MAX_LIMIT,
164            ),
165            offset: self::int($params['offset'] ?? null, 'offset', 0, 0, PHP_INT_MAX),
166            fields: self::fields($params['fields'] ?? null),
167            source: self::source($params['view'] ?? null),
168            sync: $sync,
169            ordered: isset($params['order']),
170        );
171    }
172
173    /**
174     * Terms like `-changed`, `title` or `-start:date`; a leading minus sorts
175     * descending. The node uid always breaks ties.
176     *
177     * @return non-empty-list<Order>
178     */
179    private static function order(mixed $value): array
180    {
181        if ($value === null) {
182            return [new Order('title'), new Order('uid')];
183        }
184
185        $orders = [];
186
187        foreach (self::list($value, 'order') as $term) {
188            $direction = str_starts_with($term, '-') ? 'desc' : 'asc';
189            [$name, $type] = array_pad(explode(':', ltrim($term, '-'), 2), 2, 'text');
190
191            if (
192                preg_match(self::NAME, $name) !== 1
193                || !in_array($type, self::SORT_TYPES, true)
194                || in_array($name, self::PRIVATE, true)
195            ) {
196                throw self::invalid("`{$term}` is not an order term.");
197            }
198
199            $orders[] = new Order(match (true) {
200                $name === 'position' => SortField::position(),
201                $type === 'number' => SortField::number($name),
202                $type === 'date' => SortField::date($name),
203                $type === 'datetime' => SortField::dateTime($name),
204                default => SortField::text($name),
205            }, $direction);
206        }
207
208        $orders[] = new Order('uid');
209
210        return $orders;
211    }
212
213    /** @return list<string> */
214    private static function list(mixed $value, string $name): array
215    {
216        if ($value === null) {
217            return [];
218        }
219
220        if (is_string($value)) {
221            $value = explode(',', $value);
222        }
223
224        if (!is_array($value) || !array_is_list($value)) {
225            throw self::invalid("`{$name}` is a list.");
226        }
227
228        $items = [];
229
230        foreach ($value as $item) {
231            if (!is_string($item)) {
232                throw self::invalid("`{$name}` holds strings.");
233            }
234
235            $item = trim($item);
236
237            if ($item !== '') {
238                $items[] = $item;
239            }
240        }
241
242        return array_values(array_unique($items));
243    }
244
245    /** @return list<string> */
246    private static function names(mixed $value, string $name): array
247    {
248        $names = self::list($value, $name);
249
250        foreach ($names as $item) {
251            if (preg_match(self::NAME, $item) !== 1) {
252                throw self::invalid("`{$item}` is not a field name.");
253            }
254        }
255
256        return $names;
257    }
258
259    /**
260     * `changedSince` starts a sync, `cursor` goes on with one.
261     *
262     * @param array<array-key, mixed> $params
263     */
264    private static function sync(array $params): ?SyncCursor
265    {
266        $since = self::instant($params['changedSince'] ?? null);
267        $cursor = $params['cursor'] ?? null;
268
269        if ($since !== null && $cursor !== null) {
270            throw self::invalid('Send `changedSince` to start a sync or `cursor` to go on with one, not both.');
271        }
272
273        if ($since === null && $cursor === null) {
274            return null;
275        }
276
277        if (isset($params['order']) || isset($params['offset'])) {
278            throw self::invalid(
279                'A sync runs in change order and goes on with `cursor`, so it takes no `order` or `offset`.',
280            );
281        }
282
283        return $cursor === null ? SyncCursor::since((string) $since) : SyncCursor::fromToken($cursor);
284    }
285
286    private static function instant(mixed $value): ?string
287    {
288        if ($value === null) {
289            return null;
290        }
291
292        return (
293            (is_string($value) ? Codec::normalize($value) : null)
294                ?? throw self::invalid('`changedSince` is an RFC 3339 time like 2026-10-24T08:00:00Z.')
295        );
296    }
297
298    private static function int(mixed $value, string $name, int $default, int $min, int $max): int
299    {
300        if ($value === null) {
301            return $default;
302        }
303
304        $int = is_int($value) ? $value : filter_var($value, FILTER_VALIDATE_INT);
305
306        if (!is_int($int) || $int < $min || $int > $max) {
307            throw self::invalid("`{$name}` is a whole number from {$min} to {$max}.");
308        }
309
310        return $int;
311    }
312
313    private static function invalid(string $message): HttpBadRequest
314    {
315        return new HttpBadRequest(payload: ['message' => $message]);
316    }
317}