Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.49% covered (success)
94.49%
223 / 236
72.73% covered (warning)
72.73%
24 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
Nodes
94.49% covered (success)
94.49%
223 / 236
72.73% covered (warning)
72.73%
24 / 33
76.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
23 / 23
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
 match
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 readable
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 fulltext
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 activeUrl
100.00% covered (success)
100.00%
2 / 2
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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 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
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
3.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%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 baseParams
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
7
 addWhere
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 fieldExpression
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 localeIds
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 typesCondition
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 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\Fulltext\Configurations;
12use Cosray\Fulltext\Search;
13use Cosray\Fulltext\Snippet;
14use Cosray\Node\Factory;
15use Cosray\Node\Types;
16use Cosray\Node\Wrapper;
17use Generator;
18use Iterator;
19use Override;
20
21/** @implements Iterator<int, Wrapper> */
22final class Nodes implements Iterator
23{
24    use CompilesField;
25
26    private string $whereFields = '';
27    private string $whereTypes = '';
28    private string $order = '';
29    /** @var array<string, string> */
30    private array $orderParams = [];
31    private ?int $limit = null;
32    private ?int $offset = null;
33    private ?string $fulltext = null;
34    private ?bool $activeUrl = null;
35    private readonly Configurations $configurations;
36    private ?bool $deleted = false; // defaults to false, if all nodes are needed set $deleted to null
37    private ?bool $published = true; // ditto
38    private ?bool $hidden = false; // ditto
39    /** @var array<string, string> */
40    private array $whereParams = [];
41    /** @var array<string, string> */
42    private readonly array $builtins;
43    private Generator $result;
44
45    public function __construct(
46        private readonly Context $context,
47        private readonly Cms $cms,
48        private readonly Factory $nodeFactory,
49        private readonly Types $types,
50    ) {
51        $this->configurations = new Configurations($context->db);
52        $this->builtins = [
53            'changed' => 'n.changed',
54            'changes' => 'd.node IS NOT NULL',
55            'created' => 'n.created',
56            'creator' => 'uc.uid',
57            'editor' => 'ue.uid',
58            'deleted' => 'n.deleted',
59            'id' => 'n.uid',
60            'locked' => 'n.locked',
61            'published' => 'n.published',
62            'hidden' => 'n.hidden',
63            'parent' => 'np.uid',
64            'routable' => $this->typeFlagExpression(
65                /** @param class-string $class */
66                fn(string $class): bool => (bool) $this->types->get($class, 'routable', false),
67            ),
68            'renderable' => $this->typeFlagExpression(
69                /** @param class-string $class */
70                fn(string $class): bool => (bool) $this->types->get($class, 'renderable', false),
71            ),
72            'type' => 't.handle',
73            'handle' => 'h.handle',
74            'uid' => 'n.uid',
75        ];
76    }
77
78    public function filter(string $query): self
79    {
80        $compiler = new QueryCompiler($this->context, $this->builtins);
81        $this->addWhere($compiler->compile($query));
82
83        return $this;
84    }
85
86    /**
87     * A structured filter, see Filter; `$builtins` limits the builtins it
88     * may use, null allows all.
89     *
90     * @param array<array-key, mixed> $filter
91     * @param ?list<string> $builtins
92     */
93    public function match(array $filter, ?array $builtins = null): self
94    {
95        $this->addWhere(new Filter($this->context, $this->builtins, $builtins)->compile($filter));
96
97        return $this;
98    }
99
100    /**
101     * Only what a reader may see: nodes of the `$any` types in every state,
102     * nodes of the `$live` types when published and not deleted.
103     *
104     * @param list<string> $live type handles
105     * @param list<string> $any type handles
106     */
107    public function readable(array $live, array $any): self
108    {
109        $in = fn(array $handles): string => (
110            't.handle IN ('
111            . implode(', ', array_map(fn(string $handle): string => $this->context->db->quote($handle), $handles))
112            . ')'
113        );
114        $conditions = [];
115
116        if ($any !== []) {
117            $conditions[] = $in($any);
118        }
119
120        if ($live !== []) {
121            $conditions[] = '(' . $in($live) . ' AND n.published AND n.deleted IS NULL)';
122        }
123
124        $this->addWhere($conditions === [] ? 'FALSE' : '(' . implode(' OR ', $conditions) . ')');
125
126        return $this;
127    }
128
129    public function fulltext(string $query): self
130    {
131        $this->fulltext = $query;
132
133        return $this;
134    }
135
136    public function activeUrl(bool $required = true): self
137    {
138        $this->activeUrl = $required;
139
140        return $this;
141    }
142
143    /** @param array<string> $fields */
144    public function search(string $query, array $fields): self
145    {
146        $query = trim($query);
147
148        if ($query === '') {
149            return $this;
150        }
151
152        $fields = array_values(array_filter(array_map('trim', $fields)));
153
154        if ($fields === []) {
155            return $this;
156        }
157
158        $terms = preg_split('/\s+/u', $query, -1, PREG_SPLIT_NO_EMPTY);
159
160        if (!is_array($terms) || $terms === []) {
161            return $this;
162        }
163
164        $expressions = array_map(
165            $this->fieldExpression(...),
166            $fields,
167        );
168        $termClauses = [];
169
170        foreach ($terms as $term) {
171            $needle = $this->context->db->quote('%' . $term . '%');
172            $fieldClauses = array_map(
173                static fn(string $expression): string => "COALESCE(({$expression})::text, '') ILIKE {$needle}",
174                $expressions,
175            );
176
177            $termClauses[] = '(' . implode(' OR ', $fieldClauses) . ')';
178        }
179
180        $this->addWhere(implode(' AND ', $termClauses));
181
182        return $this;
183    }
184
185    public function searchTitle(string $query): self
186    {
187        return $this->search($query, ['title']);
188    }
189
190    public function types(string ...$types): self
191    {
192        $this->whereTypes = $this->typesCondition(array_values($types));
193
194        return $this;
195    }
196
197    public function type(string $type): self
198    {
199        $this->whereTypes = $this->typesCondition([$type]);
200
201        return $this;
202    }
203
204    public function roots(): self
205    {
206        $this->addWhere('n.parent IS NULL');
207
208        return $this;
209    }
210
211    public function childrenOf(string $uid): self
212    {
213        $uid = trim($uid);
214
215        if ($uid === '') {
216            throw new RuntimeException('Parent uid is required');
217        }
218
219        $param = 'parent_uid_' . count($this->whereParams);
220
221        $this->addWhere('np.uid = :' . $param);
222        $this->whereParams[$param] = $uid;
223
224        return $this;
225    }
226
227    /** Restrict the result to the given node uids (order-independent). */
228    public function only(string ...$uids): self
229    {
230        $uids = array_values(array_filter(
231            array_map('trim', $uids),
232            static fn(string $uid): bool => $uid !== '',
233        ));
234
235        if ($uids === []) {
236            $this->addWhere('FALSE');
237
238            return $this;
239        }
240
241        $placeholders = [];
242
243        foreach ($uids as $uid) {
244            $param = 'only_uid_' . count($this->whereParams);
245            $this->whereParams[$param] = $uid;
246            $placeholders[] = ':' . $param;
247        }
248
249        $this->addWhere('n.uid IN (' . implode(', ', $placeholders) . ')');
250
251        return $this;
252    }
253
254    /** Exclude specific node uids from the result (e.g. self-reference). */
255    public function exclude(string ...$uids): self
256    {
257        foreach ($uids as $uid) {
258            $uid = trim($uid);
259
260            if ($uid === '') {
261                continue;
262            }
263
264            $param = 'exclude_uid_' . count($this->whereParams);
265            $this->addWhere('n.uid <> :' . $param);
266            $this->whereParams[$param] = $uid;
267        }
268
269        return $this;
270    }
271
272    public function order(string|Order ...$order): self
273    {
274        $compiler = new OrderCompiler(
275            $this->builtins
276                + ['title' => fn(): string => $this->context->titleSort()->order($this->context->locale(), 'n.title')],
277            $this->context,
278        );
279        $this->order = $compiler->compile(...$order);
280        $this->orderParams = $compiler->params();
281
282        return $this;
283    }
284
285    public function limit(int $limit): self
286    {
287        $this->limit = $limit;
288
289        return $this;
290    }
291
292    public function offset(int $offset): self
293    {
294        $this->offset = $offset;
295
296        return $this;
297    }
298
299    public function count(): int
300    {
301        $record = $this->context
302            ->db
303            ->nodes
304            ->count($this->baseParams())
305            ->one();
306
307        return (int) ($record['count'] ?? 0);
308    }
309
310    public function published(?bool $published): self
311    {
312        $this->published = $published;
313
314        return $this;
315    }
316
317    public function hidden(?bool $hidden): self
318    {
319        $this->hidden = $hidden;
320
321        return $this;
322    }
323
324    public function deleted(?bool $deleted): self
325    {
326        $this->deleted = $deleted;
327
328        return $this;
329    }
330
331    #[Override]
332    public function rewind(): void
333    {
334        if (!isset($this->result)) {
335            $this->fetchResult();
336        }
337        $this->result->rewind();
338    }
339
340    #[Override]
341    public function current(): Wrapper
342    {
343        if (!isset($this->result)) {
344            $this->fetchResult();
345        }
346
347        $row = $this->result->current();
348
349        $row['content'] = json_decode($row['content'], true);
350        $row['title'] = json_decode($row['title'], true);
351        $row['editor_data'] = json_decode($row['editor_data'], true);
352        $row['creator_data'] = json_decode($row['creator_data'], true);
353        $row['paths'] = json_decode($row['paths'], true);
354        $row = Node::foldDraft($row);
355        if (isset($row['search_score'], $row['search_headline'])) {
356            $row['search'] = new Search((float) $row['search_score'], new Snippet($row['search_headline']));
357            unset($row['search_score'], $row['search_headline']);
358        }
359        $class = $this->context
360            ->container
361            ->tag(Bootstrap::NODE_TAG)
362            ->entry($row['type_handle'])
363            ->definition();
364
365        $node = $this->nodeFactory->create($class, $this->context, $this->cms, $row);
366
367        return $this->nodeFactory->proxy($node, $this->context, $this->cms);
368    }
369
370    #[Override]
371    public function key(): int
372    {
373        return $this->result->key();
374    }
375
376    #[Override]
377    public function next(): void
378    {
379        $this->result->next();
380    }
381
382    #[Override]
383    public function valid(): bool
384    {
385        return $this->result->valid();
386    }
387
388    private function fetchResult(): void
389    {
390        $params = $this->baseParams();
391
392        if ($this->order) {
393            $params['order'] = $this->order;
394            $params = [...$params, ...$this->orderParams];
395        } elseif ($this->fulltext !== null) {
396            $params['order'] = 'search_score DESC, n.uid ASC';
397        }
398
399        if ($this->limit !== null) {
400            $params['limit'] = $this->limit;
401        }
402
403        if ($this->offset !== null) {
404            $params['offset'] = $this->offset;
405        }
406
407        $this->result = $this->context
408            ->db
409            ->nodes
410            ->find($params)
411            ->lazy();
412    }
413
414    /** @return array<string, mixed> */
415    private function baseParams(): array
416    {
417        $conditions = implode(' AND ', array_filter(
418            [
419                trim($this->whereFields),
420                trim($this->whereTypes),
421            ],
422            static fn(string $clause): bool => $clause !== '',
423        ));
424
425        $params = [
426            'condition' => $conditions,
427        ];
428
429        if ($this->whereParams !== []) {
430            $params = array_merge($params, $this->whereParams);
431        }
432
433        if (is_bool($this->deleted)) {
434            $params['deleted'] = $this->deleted;
435        }
436
437        if (is_bool($this->published)) {
438            $params['published'] = $this->published;
439        }
440
441        if (is_bool($this->hidden)) {
442            $params['hidden'] = $this->hidden;
443        }
444
445        if ($this->fulltext !== null) {
446            $params['fulltext'] = $this->fulltext;
447            $params['search_locale'] = $this->context->localeId();
448            $params['search_config'] = $this->configurations->for($this->context->locale());
449        }
450
451        // An explicit activeUrl() wins; otherwise the gate is on while fulltext() is active.
452        if ($this->activeUrl ?? $this->fulltext !== null) {
453            $params['active_url'] = true;
454            $params['url_locales'] = json_encode($this->localeIds(), JSON_THROW_ON_ERROR);
455            $params['routable'] = $this->builtins['routable'];
456        }
457
458        return $params;
459    }
460
461    private function addWhere(string $clause): void
462    {
463        $clause = trim($clause);
464
465        if ($clause === '') {
466            return;
467        }
468
469        if ($this->whereFields === '') {
470            $this->whereFields = $clause;
471
472            return;
473        }
474
475        $this->whereFields = "({$this->whereFields}) AND ({$clause})";
476    }
477
478    private function fieldExpression(string $field): string
479    {
480        // The materialized title also covers computed titles that have no
481        // content field. Only its values are matched, never the locale keys.
482        if ($field === 'title') {
483            return "(SELECT string_agg(value, ' ') FROM jsonb_each_text(n.title))";
484        }
485
486        $builtin = $this->builtins[$field] ?? null;
487
488        if (is_string($builtin) && $builtin !== '') {
489            return $builtin;
490        }
491
492        if (!preg_match('/^[A-Za-z][A-Za-z0-9._-]*$/', $field)) {
493            throw new RuntimeException('Invalid field name for search: ' . $field);
494        }
495
496        return $this->compileField($field, 'n.content', localeIds: $this->localeIds());
497    }
498
499    /** @return list<string> */
500    private function localeIds(): array
501    {
502        $ids = [];
503        $locale = $this->context->locale();
504
505        while ($locale) {
506            $ids[] = $locale->id;
507            $locale = $locale->fallback();
508        }
509
510        return $ids;
511    }
512
513    /** @param list<string> $types */
514    private function typesCondition(array $types): string
515    {
516        $result = [];
517
518        foreach ($types as $type) {
519            if (class_exists($type)) {
520                $type = (string) $this->types->get($type, 'handle');
521            }
522
523            $result[] = 't.handle = ' . $this->context->db->quote($type);
524        }
525
526        return match (count($result)) {
527            0 => '',
528            1 => '    ' . $result[0],
529            default => "    (\n        " . implode("\n        OR ", $result) . "\n    )",
530        };
531    }
532
533    /** @param callable(class-string): bool $flag */
534    private function typeFlagExpression(callable $flag): string
535    {
536        $handles = [];
537        $types = $this->context->container->tag(Bootstrap::NODE_TAG);
538
539        foreach ($types->entries() as $handle) {
540            $class = $types->entry($handle)->definition();
541
542            if (!is_string($class) || !class_exists($class) || !$flag($class)) {
543                continue;
544            }
545
546            $handles[] = $this->context->db->quote($handle);
547        }
548
549        sort($handles);
550
551        if ($handles === []) {
552            return 'FALSE';
553        }
554
555        return 't.handle IN (' . implode(', ', $handles) . ')';
556    }
557}