Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.53% covered (success)
93.53%
159 / 170
73.08% covered (warning)
73.08%
19 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Nodes
93.53% covered (success)
93.53%
159 / 170
73.08% covered (warning)
73.08%
19 / 26
57.88
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
 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
 order
100.00% covered (success)
100.00%
3 / 3
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
92.86% covered (success)
92.86%
13 / 14
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\Cms;
8use Cosray\Context;
9use Cosray\Exception\RuntimeException;
10use Cosray\Node\Factory;
11use Cosray\Node\Node;
12use Cosray\Node\Types;
13use Cosray\Plugin;
14use Generator;
15use Iterator;
16
17final class Nodes implements Iterator
18{
19    use CompilesField;
20
21    private string $whereFields = '';
22    private string $whereTypes = '';
23    private string $order = '';
24    private ?int $limit = null;
25    private ?int $offset = null;
26    private ?bool $deleted = false; // defaults to false, if all nodes are needed set $deleted to null
27    private ?bool $published = true; // ditto
28    private ?bool $hidden = false; // ditto
29    private array $whereParams = [];
30    private readonly array $builtins;
31    private Generator $result;
32
33    public function __construct(
34        private readonly Context $context,
35        private readonly Cms $cms,
36        private readonly Factory $nodeFactory,
37        private readonly Types $types,
38    ) {
39        $this->builtins = [
40            'changed' => 'n.changed',
41            'created' => 'n.created',
42            'creator' => 'uc.uid',
43            'editor' => 'ue.uid',
44            'deleted' => 'n.deleted',
45            'id' => 'n.uid',
46            'locked' => 'n.locked',
47            'published' => 'n.published',
48            'hidden' => 'n.hidden',
49            'parent' => 'np.uid',
50            'routable' => $this->typeFlagExpression(
51                fn(string $class): bool => (bool) $this->types->get($class, 'routable', false),
52            ),
53            'renderable' => $this->typeFlagExpression(
54                fn(string $class): bool => (bool) $this->types->get($class, 'renderable', false),
55            ),
56            'type' => 't.handle',
57            'handle' => 'h.handle',
58            'uid' => 'n.uid',
59        ];
60    }
61
62    public function filter(string $query): self
63    {
64        $compiler = new QueryCompiler($this->context, $this->builtins);
65        $this->addWhere($compiler->compile($query));
66
67        return $this;
68    }
69
70    public function search(string $query, array $fields): self
71    {
72        $query = trim($query);
73
74        if ($query === '') {
75            return $this;
76        }
77
78        $fields = array_values(array_filter(array_map('trim', $fields)));
79
80        if ($fields === []) {
81            return $this;
82        }
83
84        $terms = preg_split('/\s+/u', $query, -1, PREG_SPLIT_NO_EMPTY);
85
86        if (!is_array($terms) || $terms === []) {
87            return $this;
88        }
89
90        $expressions = array_map(
91            $this->fieldExpression(...),
92            $fields,
93        );
94        $termClauses = [];
95
96        foreach ($terms as $term) {
97            $needle = $this->context->db->quote('%' . $term . '%');
98            $fieldClauses = array_map(
99                static fn(string $expression): string => "COALESCE(({$expression})::text, '') ILIKE {$needle}",
100                $expressions,
101            );
102
103            $termClauses[] = '(' . implode(' OR ', $fieldClauses) . ')';
104        }
105
106        $this->addWhere(implode(' AND ', $termClauses));
107
108        return $this;
109    }
110
111    public function types(string ...$types): self
112    {
113        $this->whereTypes = $this->typesCondition($types);
114
115        return $this;
116    }
117
118    public function type(string $type): self
119    {
120        $this->whereTypes = $this->typesCondition([$type]);
121
122        return $this;
123    }
124
125    public function roots(): self
126    {
127        $this->addWhere('n.parent IS NULL');
128
129        return $this;
130    }
131
132    public function childrenOf(string $uid): self
133    {
134        $uid = trim($uid);
135
136        if ($uid === '') {
137            throw new RuntimeException('Parent uid is required');
138        }
139
140        $param = 'parent_uid_' . count($this->whereParams);
141
142        $this->addWhere('np.uid = :' . $param);
143        $this->whereParams[$param] = $uid;
144
145        return $this;
146    }
147
148    public function order(string ...$order): self
149    {
150        $compiler = new OrderCompiler($this->builtins, $this->context);
151        $this->order = $compiler->compile(implode(',', $order));
152
153        return $this;
154    }
155
156    public function limit(int $limit): self
157    {
158        $this->limit = $limit;
159
160        return $this;
161    }
162
163    public function offset(int $offset): self
164    {
165        $this->offset = $offset;
166
167        return $this;
168    }
169
170    public function count(): int
171    {
172        $record = $this->context
173            ->db
174            ->nodes
175            ->count($this->baseParams())
176            ->one();
177
178        return (int) ($record['count'] ?? 0);
179    }
180
181    public function published(?bool $published): self
182    {
183        $this->published = $published;
184
185        return $this;
186    }
187
188    public function hidden(?bool $hidden): self
189    {
190        $this->hidden = $hidden;
191
192        return $this;
193    }
194
195    public function deleted(?bool $deleted): self
196    {
197        $this->deleted = $deleted;
198
199        return $this;
200    }
201
202    public function rewind(): void
203    {
204        if (!isset($this->result)) {
205            $this->fetchResult();
206        }
207        $this->result->rewind();
208    }
209
210    public function current(): Node
211    {
212        if (!isset($this->result)) {
213            $this->fetchResult();
214        }
215
216        $page = $this->result->current();
217
218        $page['content'] = json_decode($page['content'], true);
219        $page['editor_data'] = json_decode($page['editor_data'], true);
220        $page['creator_data'] = json_decode($page['creator_data'], true);
221        $page['paths'] = json_decode($page['paths'], true);
222        $class = $this->context
223            ->container
224            ->tag(Plugin::NODE_TAG)
225            ->entry($page['type_handle'])
226            ->definition();
227
228        $node = $this->nodeFactory->create($class, $this->context, $this->cms, $page);
229
230        return $this->nodeFactory->proxy($node, $this->context->request, $this->context, $this->cms);
231    }
232
233    public function key(): int
234    {
235        return $this->result->key();
236    }
237
238    public function next(): void
239    {
240        $this->result->next();
241    }
242
243    public function valid(): bool
244    {
245        return $this->result->valid();
246    }
247
248    private function fetchResult(): void
249    {
250        $params = $this->baseParams();
251
252        if ($this->order) {
253            $params['order'] = $this->order;
254        }
255
256        if ($this->limit !== null) {
257            $params['limit'] = $this->limit;
258        }
259
260        if ($this->offset !== null) {
261            $params['offset'] = $this->offset;
262        }
263
264        $this->result = $this->context
265            ->db
266            ->nodes
267            ->find($params)
268            ->lazy();
269    }
270
271    private function baseParams(): array
272    {
273        $conditions = implode(' AND ', array_filter(
274            [
275                trim($this->whereFields),
276                trim($this->whereTypes),
277            ],
278            static fn(string $clause): bool => $clause !== '',
279        ));
280
281        $params = [
282            'condition' => $conditions,
283        ];
284
285        if ($this->whereParams !== []) {
286            $params = array_merge($params, $this->whereParams);
287        }
288
289        if (is_bool($this->deleted)) {
290            $params['deleted'] = $this->deleted;
291        }
292
293        if (is_bool($this->published)) {
294            $params['published'] = $this->published;
295        }
296
297        if (is_bool($this->hidden)) {
298            $params['hidden'] = $this->hidden;
299        }
300
301        return $params;
302    }
303
304    private function addWhere(string $clause): void
305    {
306        $clause = trim($clause);
307
308        if ($clause === '') {
309            return;
310        }
311
312        if ($this->whereFields === '') {
313            $this->whereFields = $clause;
314
315            return;
316        }
317
318        $this->whereFields = "({$this->whereFields}) AND ({$clause})";
319    }
320
321    private function fieldExpression(string $field): string
322    {
323        $builtin = $this->builtins[$field] ?? null;
324
325        if (is_string($builtin) && $builtin !== '') {
326            return $builtin;
327        }
328
329        if (!preg_match('/^[A-Za-z][A-Za-z0-9._-]*$/', $field)) {
330            throw new RuntimeException('Invalid field name for search: ' . $field);
331        }
332
333        return $this->compileField($field, 'n.content', localeIds: $this->localeIds());
334    }
335
336    private function localeIds(): array
337    {
338        $ids = [];
339        $locale = $this->context->locale();
340
341        while ($locale) {
342            $ids[] = $locale->id;
343            $locale = $locale->fallback();
344        }
345
346        return $ids;
347    }
348
349    private function typesCondition(array $types): string
350    {
351        $result = [];
352
353        foreach ($types as $type) {
354            if (class_exists($type)) {
355                $type = (string) $this->types->get($type, 'handle');
356            }
357
358            $result[] = 't.handle = ' . $this->context->db->quote($type);
359        }
360
361        return match (count($result)) {
362            0 => '',
363            1 => '    ' . $result[0],
364            default => "    (\n        " . implode("\n        OR ", $result) . "\n    )",
365        };
366    }
367
368    private function typeFlagExpression(callable $flag): string
369    {
370        $handles = [];
371        $types = $this->context->container->tag(Plugin::NODE_TAG);
372
373        foreach ($types->entries() as $handle) {
374            $class = $types->entry($handle)->definition();
375
376            if (!is_string($class) || !class_exists($class) || !$flag($class)) {
377                continue;
378            }
379
380            $handles[] = $this->context->db->quote($handle);
381        }
382
383        sort($handles);
384
385        if ($handles === []) {
386            return 'FALSE';
387        }
388
389        return 't.handle IN (' . implode(', ', $handles) . ')';
390    }
391}