Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.89% covered (success)
94.89%
130 / 137
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Reference
94.89% covered (success)
94.89%
130 / 137
40.00% covered (danger)
40.00%
4 / 10
43.25
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
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
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');
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    private function item(Wrapper $node): array
174    {
175        return [
176            'uid' => $node->meta->uid,
177            'title' => $node->label(),
178            'type' => (string) $node->meta->type->get('handle', ''),
179            'typeLabel' => (string) $node->meta->type->get('label', ''),
180        ];
181    }
182
183    /**
184     * Read the reference field's declared pickable-set constraints from its
185     * #[Pick] attribute. Null when the type/field is unknown or the field is
186     * not a Reference â€” the caller returns an empty result. A Reference field
187     * without #[Pick] yields the open defaults (any type, any publication).
188     *
189     * @return array{types: list<string>, where: string, published: ?bool, hidden: ?bool}|null
190     */
191    private function constraints(string $typeHandle, string $field): ?array
192    {
193        $class = $this->nodeClass($typeHandle);
194
195        if ($class === null || $field === '' || !property_exists($class, $field)) {
196            return null;
197        }
198
199        $property = new ReflectionProperty($class, $field);
200        $propType = $property->getType();
201
202        if (
203            !$propType instanceof ReflectionNamedType
204            || !is_a($propType->getName(), ReferenceField::class, true)
205        ) {
206            return null;
207        }
208
209        $attributes = $property->getAttributes(Pick::class);
210
211        if ($attributes === []) {
212            return ['types' => [], 'where' => '', 'published' => null, 'hidden' => null];
213        }
214
215        $pick = $attributes[0]->newInstance();
216
217        return [
218            'types' => $pick->types,
219            'where' => $pick->where,
220            'published' => $pick->published,
221            'hidden' => $pick->hidden,
222        ];
223    }
224
225    /**
226     * Limit picker queries to registered node types. Database rows can outlive
227     * a removed type, but the finder cannot hydrate them without its class.
228     *
229     * @param list<string> $requested Class names or handles from #[Pick].
230     * @return list<string>
231     */
232    private function registeredTypes(array $requested = []): array
233    {
234        $tag = $this->container->tag(Bootstrap::NODE_TAG);
235        $handles = [];
236
237        foreach ($tag->entries() as $handle) {
238            $class = $tag->entry($handle)->definition();
239
240            if (!is_string($class) || !class_exists($class)) {
241                continue;
242            }
243
244            if (
245                $requested !== []
246                && !in_array($class, $requested, true)
247                && !in_array($handle, $requested, true)
248            ) {
249                continue;
250            }
251
252            $handles[] = $handle;
253        }
254
255        return $handles;
256    }
257
258    /** @return class-string|null */
259    private function nodeClass(string $handle): ?string
260    {
261        if ($handle === '') {
262            return null;
263        }
264
265        $tag = $this->container->tag(Bootstrap::NODE_TAG);
266
267        if (!in_array($handle, $tag->entries(), true)) {
268            return null;
269        }
270
271        $class = $tag->entry($handle)->definition();
272
273        return is_string($class) && class_exists($class) ? $class : null;
274    }
275
276    private function result(Factory $factory, array $nodes, bool $more): Response
277    {
278        return Response::create($factory)->json([
279            'ok' => true,
280            'nodes' => $nodes,
281            'more' => $more,
282        ]);
283    }
284
285    private function stringParam(string $key): string
286    {
287        $value = $this->request->param($key, '');
288
289        return is_string($value) ? trim($value) : '';
290    }
291
292    private function intParam(string $key, int $default, int $min, ?int $max = null): int
293    {
294        $value = $this->request->param($key, (string) $default);
295
296        if (is_int($value)) {
297            $int = $value;
298        } elseif (is_string($value) && preg_match('/^-?[0-9]+$/', $value)) {
299            $int = (int) $value;
300        } else {
301            $int = $default;
302        }
303
304        $int = max($min, $int);
305
306        return $max === null ? $int : min($max, $int);
307    }
308}