Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.52% covered (success)
92.52%
198 / 214
66.67% covered (warning)
66.67%
20 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
Nodes
92.52% covered (success)
92.52%
198 / 214
66.67% covered (warning)
66.67%
20 / 30
70.99
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 filter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 search
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
6.08
 searchTitle
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 types
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 type
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 roots
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 childrenOf
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 only
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
3.03
 exclude
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 order
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 titleSort
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 limit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 offset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 published
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hidden
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleted
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rewind
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 current
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 next
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 valid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchResult
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 baseParams
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 addWhere
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 fieldExpression
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 localeIds
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 typesCondition
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 typeFlagExpression
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Finder;
6
7use Cosray\Bootstrap;
8use Cosray\Cms;
9use Cosray\Context;
10use Cosray\Exception\RuntimeException;
11use Cosray\Field\Field;
12use Cosray\Node\Factory;
13use Cosray\Node\Types;
14use Cosray\Node\Wrapper;
15use Cosray\Title\Sort;
16use Generator;
17use Iterator;
18
19final class Nodes implements Iterator
20{
21    use CompilesField;
22
23    private string $whereFields = '';
24    private string $whereTypes = '';
25    private string $order = '';
26    private ?int $limit = null;
27    private ?int $offset = null;
28    private ?bool $deleted = false; // defaults to false, if all nodes are needed set $deleted to null
29    private ?bool $published = true; // ditto
30    private ?bool $hidden = false; // ditto
31    private array $whereParams = [];
32    private readonly array $builtins;
33    private Generator $result;
34
35    public function __construct(
36        private readonly Context $context,
37        private readonly Cms $cms,
38        private readonly Factory $nodeFactory,
39        private readonly Types $types,
40    ) {
41        $this->builtins = [
42            'changed' => 'n.changed',
43            'created' => 'n.created',
44            'creator' => 'uc.uid',
45            'editor' => 'ue.uid',
46            'deleted' => 'n.deleted',
47            'id' => 'n.uid',
48            'locked' => 'n.locked',
49            'published' => 'n.published',
50            'hidden' => 'n.hidden',
51            'parent' => 'np.uid',
52            'routable' => $this->typeFlagExpression(
53                fn(string $class): bool => (bool) $this->types->get($class, 'routable', false),
54            ),
55            'renderable' => $this->typeFlagExpression(
56                fn(string $class): bool => (bool) $this->types->get($class, 'renderable', false),
57            ),
58            'type' => 't.handle',
59            'handle' => 'h.handle',
60            'uid' => 'n.uid',
61        ];
62    }
63
64    public function filter(string $query): self
65    {
66        $compiler = new QueryCompiler($this->context, $this->builtins);
67        $this->addWhere($compiler->compile($query));
68
69        return $this;
70    }
71
72    public function search(string $query, array $fields): self
73    {
74        $query = trim($query);
75
76        if ($query === '') {
77            return $this;
78        }
79
80        $fields = array_values(array_filter(array_map('trim', $fields)));
81
82        if ($fields === []) {
83            return $this;
84        }
85
86        $terms = preg_split('/\s+/u', $query, -1, PREG_SPLIT_NO_EMPTY);
87
88        if (!is_array($terms) || $terms === []) {
89            return $this;
90        }
91
92        $expressions = array_map(
93            $this->fieldExpression(...),
94            $fields,
95        );
96        $termClauses = [];
97
98        foreach ($terms as $term) {
99            $needle = $this->context->db->quote('%' . $term . '%');
100            $fieldClauses = array_map(
101                static fn(string $expression): string => "COALESCE(({$expression})::text, '') ILIKE {$needle}",
102                $expressions,
103            );
104
105            $termClauses[] = '(' . implode(' OR ', $fieldClauses) . ')';
106        }
107
108        $this->addWhere(implode(' AND ', $termClauses));
109
110        return $this;
111    }
112
113    /**
114     * Match the materialized title column across every locale. Unlike
115     * search(..., ['title']) â€” which reads the title *field* in content â€”
116     * this also finds nodes whose title is dynamic (no title field), so
117     * the reference picker can surface them.
118     */
119    public function searchTitle(string $query): self
120    {
121        $query = trim($query);
122
123        if ($query === '') {
124            return $this;
125        }
126
127        $terms = preg_split('/\s+/u', $query, -1, PREG_SPLIT_NO_EMPTY);
128
129        if (!is_array($terms) || $terms === []) {
130            return $this;
131        }
132
133        $clauses = array_map(
134            fn(string $term): string => "COALESCE(n.title::text, '') ILIKE "
135            . $this->context->db->quote('%' . $term . '%'),
136            $terms,
137        );
138
139        $this->addWhere(implode(' AND ', $clauses));
140
141        return $this;
142    }
143
144    public function types(string ...$types): self
145    {
146        $this->whereTypes = $this->typesCondition($types);
147
148        return $this;
149    }
150
151    public function type(string $type): self
152    {
153        $this->whereTypes = $this->typesCondition([$type]);
154
155        return $this;
156    }
157
158    public function roots(): self
159    {
160        $this->addWhere('n.parent IS NULL');
161
162        return $this;
163    }
164
165    public function childrenOf(string $uid): self
166    {
167        $uid = trim($uid);
168
169        if ($uid === '') {
170            throw new RuntimeException('Parent uid is required');
171        }
172
173        $param = 'parent_uid_' . count($this->whereParams);
174
175        $this->addWhere('np.uid = :' . $param);
176        $this->whereParams[$param] = $uid;
177
178        return $this;
179    }
180
181    /** Restrict the result to the given node uids (order-independent). */
182    public function only(string ...$uids): self
183    {
184        $uids = array_values(array_filter(
185            array_map('trim', $uids),
186            static fn(string $uid): bool => $uid !== '',
187        ));
188
189        if ($uids === []) {
190            $this->addWhere('FALSE');
191
192            return $this;
193        }
194
195        $placeholders = [];
196
197        foreach ($uids as $uid) {
198            $param = 'only_uid_' . count($this->whereParams);
199            $this->whereParams[$param] = $uid;
200            $placeholders[] = ':' . $param;
201        }
202
203        $this->addWhere('n.uid IN (' . implode(', ', $placeholders) . ')');
204
205        return $this;
206    }
207
208    /** Exclude specific node uids from the result (e.g. self-reference). */
209    public function exclude(string ...$uids): self
210    {
211        foreach ($uids as $uid) {
212            $uid = trim($uid);
213
214            if ($uid === '') {
215                continue;
216            }
217
218            $param = 'exclude_uid_' . count($this->whereParams);
219            $this->addWhere('n.uid <> :' . $param);
220            $this->whereParams[$param] = $uid;
221        }
222
223        return $this;
224    }
225
226    public function order(string ...$order): self
227    {
228        // `title` orders by the materialized node title for the request
229        // locale (neutral key as fallback) â€” the expression Title\Sort
230        // keeps in step with the per-locale sort indexes â€” not by a
231        // content field of that name, which a type's schema may not have.
232        $compiler = new OrderCompiler(
233            $this->builtins + ['title' => $this->titleSort()],
234            $this->context,
235        );
236        $this->order = $compiler->compile(implode(',', $order));
237
238        return $this;
239    }
240
241    private function titleSort(): string
242    {
243        $locale = $this->context->locale()->id;
244
245        return Sort::expression(
246            Sort::valid($locale) ? $locale : Field::NEUTRAL_LOCALE,
247            'n.title',
248        );
249    }
250
251    public function limit(int $limit): self
252    {
253        $this->limit = $limit;
254
255        return $this;
256    }
257
258    public function offset(int $offset): self
259    {
260        $this->offset = $offset;
261
262        return $this;
263    }
264
265    public function count(): int
266    {
267        $record = $this->context
268            ->db
269            ->nodes
270            ->count($this->baseParams())
271            ->one();
272
273        return (int) ($record['count'] ?? 0);
274    }
275
276    public function published(?bool $published): self
277    {
278        $this->published = $published;
279
280        return $this;
281    }
282
283    public function hidden(?bool $hidden): self
284    {
285        $this->hidden = $hidden;
286
287        return $this;
288    }
289
290    public function deleted(?bool $deleted): self
291    {
292        $this->deleted = $deleted;
293
294        return $this;
295    }
296
297    public function rewind(): void
298    {
299        if (!isset($this->result)) {
300            $this->fetchResult();
301        }
302        $this->result->rewind();
303    }
304
305    public function current(): Wrapper
306    {
307        if (!isset($this->result)) {
308            $this->fetchResult();
309        }
310
311        $row = $this->result->current();
312
313        $row['content'] = json_decode($row['content'], true);
314        $row['title'] = json_decode($row['title'], true);
315        $row['editor_data'] = json_decode($row['editor_data'], true);
316        $row['creator_data'] = json_decode($row['creator_data'], true);
317        $row['paths'] = json_decode($row['paths'], true);
318        $class = $this->context
319            ->container
320            ->tag(Bootstrap::NODE_TAG)
321            ->entry($row['type_handle'])
322            ->definition();
323
324        $node = $this->nodeFactory->create($class, $this->context, $this->cms, $row);
325
326        return $this->nodeFactory->proxy($node, $this->context, $this->cms);
327    }
328
329    public function key(): int
330    {
331        return $this->result->key();
332    }
333
334    public function next(): void
335    {
336        $this->result->next();
337    }
338
339    public function valid(): bool
340    {
341        return $this->result->valid();
342    }
343
344    private function fetchResult(): void
345    {
346        $params = $this->baseParams();
347
348        if ($this->order) {
349            $params['order'] = $this->order;
350        }
351
352        if ($this->limit !== null) {
353            $params['limit'] = $this->limit;
354        }
355
356        if ($this->offset !== null) {
357            $params['offset'] = $this->offset;
358        }
359
360        $this->result = $this->context
361            ->db
362            ->nodes
363            ->find($params)
364            ->lazy();
365    }
366
367    private function baseParams(): array
368    {
369        $conditions = implode(' AND ', array_filter(
370            [
371                trim($this->whereFields),
372                trim($this->whereTypes),
373            ],
374            static fn(string $clause): bool => $clause !== '',
375        ));
376
377        $params = [
378            'condition' => $conditions,
379        ];
380
381        if ($this->whereParams !== []) {
382            $params = array_merge($params, $this->whereParams);
383        }
384
385        if (is_bool($this->deleted)) {
386            $params['deleted'] = $this->deleted;
387        }
388
389        if (is_bool($this->published)) {
390            $params['published'] = $this->published;
391        }
392
393        if (is_bool($this->hidden)) {
394            $params['hidden'] = $this->hidden;
395        }
396
397        return $params;
398    }
399
400    private function addWhere(string $clause): void
401    {
402        $clause = trim($clause);
403
404        if ($clause === '') {
405            return;
406        }
407
408        if ($this->whereFields === '') {
409            $this->whereFields = $clause;
410
411            return;
412        }
413
414        $this->whereFields = "({$this->whereFields}) AND ({$clause})";
415    }
416
417    private function fieldExpression(string $field): string
418    {
419        $builtin = $this->builtins[$field] ?? null;
420
421        if (is_string($builtin) && $builtin !== '') {
422            return $builtin;
423        }
424
425        if (!preg_match('/^[A-Za-z][A-Za-z0-9._-]*$/', $field)) {
426            throw new RuntimeException('Invalid field name for search: ' . $field);
427        }
428
429        return $this->compileField($field, 'n.content', localeIds: $this->localeIds());
430    }
431
432    private function localeIds(): array
433    {
434        $ids = [];
435        $locale = $this->context->locale();
436
437        while ($locale) {
438            $ids[] = $locale->id;
439            $locale = $locale->fallback();
440        }
441
442        return $ids;
443    }
444
445    private function typesCondition(array $types): string
446    {
447        $result = [];
448
449        foreach ($types as $type) {
450            if (class_exists($type)) {
451                $type = (string) $this->types->get($type, 'handle');
452            }
453
454            $result[] = 't.handle = ' . $this->context->db->quote($type);
455        }
456
457        return match (count($result)) {
458            0 => '',
459            1 => '    ' . $result[0],
460            default => "    (\n        " . implode("\n        OR ", $result) . "\n    )",
461        };
462    }
463
464    private function typeFlagExpression(callable $flag): string
465    {
466        $handles = [];
467        $types = $this->context->container->tag(Bootstrap::NODE_TAG);
468
469        foreach ($types->entries() as $handle) {
470            $class = $types->entry($handle)->definition();
471
472            if (!is_string($class) || !class_exists($class) || !$flag($class)) {
473                continue;
474            }
475
476            $handles[] = $this->context->db->quote($handle);
477        }
478
479        sort($handles);
480
481        if ($handles === []) {
482            return 'FALSE';
483        }
484
485        return 't.handle IN (' . implode(', ', $handles) . ')';
486    }
487}