Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.62% covered (success)
95.62%
131 / 137
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Reference
95.62% covered (success)
95.62%
131 / 137
40.00% covered (danger)
40.00%
4 / 10
43
0.00% covered (danger)
0.00%
0 / 1
 search
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
5
 nodes
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
4
 labels
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 item
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 constraints
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 registeredTypes
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 nodeClass
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 result
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 stringParam
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 intParam
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Core\Factory\Factory;
8use Celema\Core\Response;
9use Cosray\Bootstrap;
10use Cosray\Cms;
11use Cosray\Field\Reference as ReferenceField;
12use Cosray\Node\Wrapper;
13use Cosray\Schema\Pick;
14use ReflectionNamedType;
15use ReflectionProperty;
16
17/**
18 * JSON node search backing the reference picker. The pickable set is
19 * derived server-side from the reference field's own schema (never from
20 * the client): non-deleted, optionally type/filter constrained, any
21 * publication state, hidden included, current node excluded. Rows whose
22 * node type is no longer registered are omitted because they cannot be hydrated.
23 */
24final class Reference extends Panel
25{
26    private const int LIMIT_DEFAULT = 30;
27    private const int LIMIT_MAX = 100;
28
29    public function search(Cms $cms, Factory $factory): Response
30    {
31        $constraints = $this->constraints($this->stringParam('type'), $this->stringParam('field'));
32
33        if ($constraints === null) {
34            return $this->result($factory, [], false);
35        }
36
37        $q = $this->stringParam('q');
38        $exclude = $this->stringParam('node');
39        $offset = $this->intParam('offset', 0, min: 0);
40        $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX);
41
42        $types = $this->registeredTypes($constraints['types']);
43
44        if ($types === []) {
45            return $this->result($factory, [], false);
46        }
47
48        $finder = $cms
49            ->nodes($constraints['where'])
50            ->deleted(false)
51            ->published($constraints['published'])
52            ->hidden($constraints['hidden'])
53            ->types(...$types)
54            ->order('changed DESC', 'uid ASC');
55
56        if ($exclude !== '') {
57            $finder->exclude($exclude);
58        }
59
60        if ($q !== '') {
61            $finder->searchTitle($q);
62        }
63
64        $finder->offset($offset)->limit($limit + 1);
65
66        $rows = iterator_to_array($finder, false);
67        $more = count($rows) > $limit;
68
69        return $this->result(
70            $factory,
71            array_map($this->item(...), array_slice($rows, 0, $limit)),
72            $more,
73        );
74    }
75
76    /**
77     * Unconstrained node search backing the richtext link picker. A prose
78     * link is not bound to a schema property, so the pickable set is simply
79     * every non-deleted node (any registered type, any publication), the current node
80     * excluded. Kept separate from search() so that method's reflected
81     * #[Pick] contract (constraints from the field, never the client) stays
82     * intact.
83     */
84    public function nodes(Cms $cms, Factory $factory): Response
85    {
86        $q = $this->stringParam('q');
87        $exclude = $this->stringParam('node');
88        $offset = $this->intParam('offset', 0, min: 0);
89        $limit = $this->intParam('limit', self::LIMIT_DEFAULT, min: 1, max: self::LIMIT_MAX);
90
91        $types = $this->registeredTypes();
92
93        if ($types === []) {
94            return $this->result($factory, [], false);
95        }
96
97        $finder = $cms
98            ->nodes()
99            ->deleted(false)
100            ->published(null)
101            ->hidden(null)
102            ->types(...$types)
103            ->order('changed DESC');
104
105        if ($exclude !== '') {
106            $finder->exclude($exclude);
107        }
108
109        if ($q !== '') {
110            $finder->searchTitle($q);
111        }
112
113        $finder->offset($offset)->limit($limit + 1);
114
115        $rows = iterator_to_array($finder, false);
116        $more = count($rows) > $limit;
117
118        return $this->result(
119            $factory,
120            array_map($this->item(...), array_slice($rows, 0, $limit)),
121            $more,
122        );
123    }
124
125    /**
126     * Resolve titles for already-chosen uids so the control can render its
127     * selected rows. Chosen values render regardless of the pickable set;
128     * soft-deleted targets and unregistered node types drop out, and the
129     * caller's order is kept.
130     */
131    public function labels(Cms $cms, Factory $factory): Response
132    {
133        $uids = array_values(array_filter(
134            array_map('trim', explode(',', $this->stringParam('uids'))),
135            static fn(string $uid): bool => $uid !== '',
136        ));
137
138        if ($uids === []) {
139            return $this->result($factory, [], false);
140        }
141
142        $types = $this->registeredTypes();
143
144        if ($types === []) {
145            return $this->result($factory, [], false);
146        }
147
148        $byUid = [];
149
150        foreach ($cms
151            ->nodes()
152            ->deleted(false)
153            ->published(null)
154            ->hidden(null)
155            ->types(...$types)
156            ->only(...$uids) as $node) {
157            $byUid[$node->meta->uid] = $this->item($node);
158        }
159
160        $ordered = [];
161
162        foreach ($uids as $uid) {
163            if (!isset($byUid[$uid])) {
164                continue;
165            }
166
167            $ordered[] = $byUid[$uid];
168        }
169
170        return $this->result($factory, $ordered, false);
171    }
172
173    /** @return array{uid: string, title: string, type: string, typeLabel: string} */
174    private function item(Wrapper $node): array
175    {
176        return [
177            'uid' => $node->meta->uid,
178            'title' => $node->label(),
179            'type' => (string) $node->meta->type->get('handle', ''),
180            'typeLabel' => (string) $node->meta->type->get('label', ''),
181        ];
182    }
183
184    /**
185     * Read the reference field's declared pickable-set constraints from its
186     * #[Pick] attribute. Null when the type/field is unknown or the field is
187     * not a Reference â€” the caller returns an empty result. A Reference field
188     * without #[Pick] yields the open defaults (any type, any publication).
189     *
190     * @return array{types: list<string>, where: string, published: ?bool, hidden: ?bool}|null
191     */
192    private function constraints(string $typeHandle, string $field): ?array
193    {
194        $class = $this->nodeClass($typeHandle);
195
196        if ($class === null || $field === '' || !property_exists($class, $field)) {
197            return null;
198        }
199
200        $property = new ReflectionProperty($class, $field);
201        $propType = $property->getType();
202
203        if (
204            !$propType instanceof ReflectionNamedType
205            || !is_a($propType->getName(), ReferenceField::class, true)
206        ) {
207            return null;
208        }
209
210        $attributes = $property->getAttributes(Pick::class);
211
212        if ($attributes === []) {
213            return ['types' => [], 'where' => '', 'published' => null, 'hidden' => null];
214        }
215
216        $pick = $attributes[0]->newInstance();
217
218        return [
219            'types' => $pick->types,
220            'where' => $pick->where,
221            'published' => $pick->published,
222            'hidden' => $pick->hidden,
223        ];
224    }
225
226    /**
227     * Limit picker queries to registered node types. Database rows can outlive
228     * a removed type, but the finder cannot hydrate them without its class.
229     *
230     * @param list<string> $requested Class names or handles from #[Pick].
231     * @return list<string>
232     */
233    private function registeredTypes(array $requested = []): array
234    {
235        $tag = $this->container->tag(Bootstrap::NODE_TAG);
236        $handles = [];
237
238        foreach ($tag->entries() as $handle) {
239            $class = $tag->entry($handle)->definition();
240
241            if (!is_string($class) || !class_exists($class)) {
242                continue;
243            }
244
245            if (
246                $requested !== []
247                && !in_array($class, $requested, true)
248                && !in_array($handle, $requested, true)
249            ) {
250                continue;
251            }
252
253            $handles[] = $handle;
254        }
255
256        return $handles;
257    }
258
259    /** @return class-string|null */
260    private function nodeClass(string $handle): ?string
261    {
262        if ($handle === '') {
263            return null;
264        }
265
266        $tag = $this->container->tag(Bootstrap::NODE_TAG);
267
268        if (!in_array($handle, $tag->entries(), true)) {
269            return null;
270        }
271
272        $class = $tag->entry($handle)->definition();
273
274        return is_string($class) && class_exists($class) ? $class : null;
275    }
276
277    /** @param list<array{uid: string, title: string, type: string, typeLabel: string}> $nodes */
278    private function result(Factory $factory, array $nodes, bool $more): Response
279    {
280        return Response::create($factory)->json([
281            'ok' => true,
282            'nodes' => $nodes,
283            'more' => $more,
284        ]);
285    }
286
287    private function stringParam(string $key): string
288    {
289        $value = $this->request->param($key, '');
290
291        return is_string($value) ? trim($value) : '';
292    }
293
294    private function intParam(string $key, int $default, int $min, ?int $max = null): int
295    {
296        $value = $this->request->param($key, (string) $default);
297
298        if (is_int($value)) {
299            $int = $value;
300        } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) {
301            $int = (int) $value;
302        } else {
303            $int = $default;
304        }
305
306        $int = max($min, $int);
307
308        return $max === null ? $int : min($max, $int);
309    }
310}