Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.82% covered (warning)
73.82%
203 / 275
40.00% covered (danger)
40.00%
10 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Store
73.82% covered (warning)
73.82%
203 / 275
40.00% covered (danger)
40.00%
10 / 25
274.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 save
58.62% covered (warning)
58.62%
17 / 29
0.00% covered (danger)
0.00%
0 / 1
14.74
 create
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
12.00
 delete
66.67% covered (warning)
66.67%
18 / 27
0.00% covered (danger)
0.00%
0 / 1
12.00
 subtreeUids
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 childUids
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 persist
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 materializeTitle
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 dynamicTitle
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
3.27
 titleEvalNode
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 persistNode
88.24% covered (warning)
88.24%
30 / 34
0.00% covered (danger)
0.00%
0 / 1
5.04
 persistHandle
59.09% covered (warning)
59.09%
13 / 22
0.00% covered (danger)
0.00%
0 / 1
6.71
 resolveParentUid
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 resolveParentId
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
3.18
 assertUidUnchanged
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 normalizeSubmittedHandle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 completeHandle
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 resolveHandle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 ensureRouteHandle
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 completeGeneratedPaths
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
3.75
 needsGeneratedPaths
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 routeNeedsHandle
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
21.19
 routeContainsHandle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureTypeExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpConflict;
9use Celema\Core\Exception\HttpError;
10use Celema\Quma\Database;
11use Cosray\Actor;
12use Cosray\Cms;
13use Cosray\Context;
14use Cosray\Exception\RoutePathError;
15use Cosray\Exception\RuntimeException;
16use Cosray\Locale;
17use Cosray\Locales;
18use Cosray\References;
19use Cosray\Richtext\Normalizer;
20use Cosray\Title\Resolver as TitleResolver;
21use Cosray\Uid;
22use Cosray\Validation\ValidatorFactory;
23use Throwable;
24
25class Store
26{
27    private const int CREATE_UID_ATTEMPTS = 5;
28
29    private readonly RoutePathGenerator $routePathGenerator;
30    private readonly References\Scanner $scanner;
31    private readonly References\Sync $sync;
32    private readonly TitleResolver $titleResolver;
33
34    public function __construct(
35        private readonly Database $db,
36        private readonly PathManager $pathManager,
37        private readonly Types $types,
38        private readonly Uid $uid,
39        ?RoutePathGenerator $routePathGenerator = null,
40        // Dynamic (Contract\Title) titles read the node's fields, so they must
41        // be re-hydrated from the content being saved. These stay optional so
42        // a bare Store still works; without them dynamic titles fall back to
43        // the passed node and `db:titles` is the authoritative refresh.
44        private readonly ?Factory $factory = null,
45        private readonly ?Cms $cms = null,
46        private readonly ?Context $context = null,
47    ) {
48        $this->routePathGenerator = $routePathGenerator ?? new RoutePathGenerator($db, $types);
49        $this->scanner = new References\Scanner();
50        $this->sync = new References\Sync($db);
51        $this->titleResolver = new TitleResolver($types);
52    }
53
54    public function save(
55        object $node,
56        array $data,
57        Locales $locales,
58        Actor $actor,
59        bool $create = false,
60    ): array {
61        $data = $this->normalizeSubmittedHandle($data);
62        $data = $this->validate($node, $data, $locales);
63
64        if (is_array($data['content'] ?? null)) {
65            // Richtext documents persist in canonical form (byte-stable
66            // storage for history diffs); empty documents become null.
67            $data['content'] = new Normalizer()->content($data['content']);
68        }
69
70        if (!$create) {
71            $this->assertUidUnchanged($node, $data);
72        }
73
74        $data = $this->completeHandle($node, $data);
75
76        if ($data['locked']) {
77            throw new HttpBadRequest(payload: ['message' => __('node:locked')]);
78        }
79
80        $ownsTransaction = !$this->db->getConn()->inTransaction();
81
82        try {
83            if ($ownsTransaction) {
84                $this->db->begin();
85            }
86
87            $this->persist($node, $data, $actor->id, $locales, $create);
88
89            if ($ownsTransaction) {
90                $this->db->commit();
91            }
92        } catch (Throwable $e) {
93            if ($ownsTransaction) {
94                $this->db->rollback();
95            }
96
97            if ($e instanceof HttpError) {
98                throw $e;
99            }
100
101            throw new RuntimeException(
102                'Error while saving: ' . $e->getMessage(),
103                (int) $e->getCode(),
104                previous: $e,
105            );
106        }
107
108        return [
109            'success' => true,
110            'uid' => $data['uid'],
111        ];
112    }
113
114    public function create(object $node, array $data, Locales $locales, Actor $actor): array
115    {
116        $generatedUid = !array_key_exists('uid', $data);
117        if ($generatedUid) {
118            $data['uid'] = Factory::meta($node, 'uid') ?? $this->uid->generate();
119        }
120        $attempts = $generatedUid ? self::CREATE_UID_ATTEMPTS : 1;
121
122        for ($attempt = 1; $attempt <= $attempts; $attempt++) {
123            if ($generatedUid && $attempt > 1) {
124                $data['uid'] = $this->uid->generate();
125            }
126
127            try {
128                return $this->save($node, $data, $locales, $actor, create: true);
129            } catch (HttpConflict $e) {
130                if (!$generatedUid || $attempt === $attempts) {
131                    throw $e;
132                }
133            }
134        }
135
136        throw new RuntimeException('Could not generate a unique node uid');
137    }
138
139    /** @return array{success: true, error: false, deleted: list<string>} */
140    public function delete(object $node, Actor $actor, bool $withChildren = false): array
141    {
142        $uid = (string) Factory::meta($node, 'uid');
143
144        if (!$withChildren && $this->childUids($uid) !== []) {
145            throw new HttpConflict(payload: ['message' => __('node:has-children')]);
146        }
147
148        $uids = $withChildren ? $this->subtreeUids($uid) : [$uid];
149        $ownsTransaction = !$this->db->getConn()->inTransaction();
150
151        try {
152            if ($ownsTransaction) {
153                $this->db->begin();
154            }
155
156            foreach ($uids as $deleteUid) {
157                $this->db->nodes->delete([
158                    'uid' => $deleteUid,
159                    'editor' => $actor->id,
160                ])->run();
161            }
162
163            if ($ownsTransaction) {
164                $this->db->commit();
165            }
166        } catch (Throwable $e) {
167            if ($ownsTransaction) {
168                $this->db->rollback();
169            }
170
171            throw new RuntimeException(
172                'Error while deleting: ' . $e->getMessage(),
173                (int) $e->getCode(),
174                previous: $e,
175            );
176        }
177
178        return [
179            'success' => true,
180            'error' => false,
181            'deleted' => $uids,
182        ];
183    }
184
185    /**
186     * The node and all its non-deleted descendants, children before
187     * parents so a partial failure never leaves a reachable orphan.
188     *
189     * @return list<string>
190     */
191    private function subtreeUids(string $uid): array
192    {
193        $uids = [$uid];
194        $queue = [$uid];
195
196        while ($queue !== []) {
197            $current = array_shift($queue);
198
199            foreach ($this->childUids($current) as $child) {
200                // A parent cycle would loop forever; unseen uids only.
201                if (in_array($child, $uids, true)) {
202                    continue;
203                }
204
205                $uids[] = $child;
206                $queue[] = $child;
207            }
208        }
209
210        return array_reverse($uids);
211    }
212
213    /** @return list<string> */
214    private function childUids(string $uid): array
215    {
216        return array_map(
217            static fn(array $row): string => (string) $row['uid'],
218            $this->db->nodes->childUids(['uid' => $uid])->all(),
219        );
220    }
221
222    public function validate(object $node, array $data, Locales $locales): array
223    {
224        $factory = new ValidatorFactory($node, $locales);
225        $shape = $factory->create();
226        $result = $shape->validate($data);
227
228        if (!$result->valid()) {
229            throw new HttpBadRequest(payload: [
230                'message' => __('node:invalid-data'),
231                'errors' => $result->issues(),
232            ]);
233        }
234
235        return $result->values();
236    }
237
238    private function persist(
239        object $node,
240        array $data,
241        int $editor,
242        Locales $locales,
243        bool $create = false,
244    ): void {
245        $parentUid = $this->resolveParentUid($node, $data);
246        $parentId = $this->resolveParentId($parentUid);
247        $handle = $this->resolveHandle($data);
248
249        // Materialize the title alongside the content so both ride one UPDATE
250        // (a single change/history record instead of two).
251        $data['title'] = $this->materializeTitle($node, $data, $locales);
252
253        $nodeId = $this->persistNode($node, $data, $editor, $parentId, $create);
254        $this->persistHandle($nodeId, $handle, $editor);
255
256        // The reference indexes ride in the save transaction: full
257        // replace per owner from the content just written.
258        $this->sync->replace('node', $data['uid'], $this->scanner->scan($data['content'] ?? []));
259
260        if ((bool) $this->types->get($node::class, 'routable', false)) {
261            $this->ensureRouteHandle($node, $handle);
262            $data = $this->completeGeneratedPaths($node, $data, $locales, $parentId);
263            $this->pathManager->persist($this->db, $data, $editor, $nodeId, $locales);
264        }
265    }
266
267    /**
268     * The materialized title map for the content being saved. Mirrors
269     * `Wrapper::title()`'s resolution but yields every locale.
270     *
271     * @return array<string, string>
272     */
273    private function materializeTitle(
274        object $node,
275        array $data,
276        Locales $locales,
277    ): array {
278        $content = is_array($data['content'] ?? null) ? $data['content'] : [];
279        $descriptor = $this->titleResolver->descriptor($node::class);
280
281        return match ($descriptor['kind']) {
282            TitleResolver::KIND_FIELD => $this->titleResolver->fieldMap($content, $descriptor['field']),
283            TitleResolver::KIND_DYNAMIC => $this->dynamicTitle($node, $data, $locales),
284            default => [],
285        };
286    }
287
288    /**
289     * Evaluate a dynamic title once per locale against the content being
290     * saved, restoring the request locale afterwards.
291     *
292     * @return array<string, string>
293     */
294    private function dynamicTitle(object $node, array $data, Locales $locales): array
295    {
296        $target = $this->titleEvalNode($node, $data);
297        $provider = $this->titleResolver->provider($target);
298
299        if ($provider === null) {
300            return [];
301        }
302
303        if ($this->context === null) {
304            return $this->titleResolver->dynamicMap(
305                static fn(Locale $locale): string => $provider->title(),
306                $locales,
307            );
308        }
309
310        return $this->titleResolver->dynamicMap(
311            fn(Locale $locale): string => $this->context->withLocale(
312                $locale,
313                $provider->title(...),
314            ),
315            $locales,
316        );
317    }
318
319    /**
320     * A node instance whose fields reflect the content being saved. When the
321     * factory seam is wired the node is re-hydrated from that content (the
322     * passed instance still carries the previous version); otherwise the
323     * passed node is used and `db:titles` remains the authoritative refresh.
324     */
325    private function titleEvalNode(object $node, array $data): object
326    {
327        if ($this->factory !== null && $this->cms !== null && $this->context !== null) {
328            return $this->factory->create($node::class, $this->context, $this->cms, [
329                'uid' => $data['uid'],
330                'content' => is_array($data['content'] ?? null) ? $data['content'] : [],
331            ]);
332        }
333
334        return $node;
335    }
336
337    private function persistNode(
338        object $node,
339        array $data,
340        int $editor,
341        ?int $parent,
342        bool $create,
343    ): int {
344        $class = $node::class;
345        $handle = (string) $this->types->get($class, 'handle');
346        $this->ensureTypeExists($handle);
347        $params = [
348            'uid' => $data['uid'],
349            'parent' => $parent,
350            'hidden' => $data['hidden'],
351            'published' => $data['published'],
352            'locked' => $data['locked'],
353            'type' => $handle,
354            'content' => json_encode($data['content']),
355            'title' => json_encode($data['title'] ?? []),
356            'editor' => $editor,
357        ];
358
359        if (!$create) {
360            $nodeId = Factory::meta($node, 'node');
361
362            if (!is_int($nodeId) && !is_string($nodeId)) {
363                throw new RuntimeException('Missing node id for update');
364            }
365
366            return (int) $this->db->nodes->save([
367                'node' => (int) $nodeId,
368                'parent' => $params['parent'],
369                'hidden' => $params['hidden'],
370                'published' => $params['published'],
371                'locked' => $params['locked'],
372                'content' => $params['content'],
373                'title' => $params['title'],
374                'editor' => $params['editor'],
375            ])->one()['node'];
376        }
377
378        $result = $this->db->nodes->create($params)->first();
379
380        if (!$result) {
381            throw new HttpConflict(payload: [
382                'message' => __('node:duplicate-uid', ['uid' => $data['uid']]),
383            ]);
384        }
385
386        return (int) $result['node'];
387    }
388
389    private function persistHandle(int $nodeId, ?string $handle, int $editor): void
390    {
391        if ($handle === null) {
392            $this->db->nodes->deleteHandle(['node' => $nodeId])->run();
393
394            return;
395        }
396
397        $collision = $this->db
398            ->nodes
399            ->handleUidCollision(['handle' => $handle, 'node' => $nodeId])
400            ->first();
401
402        if ($collision) {
403            throw new HttpConflict(payload: [
404                'message' => __('node:duplicate-handle-uid', ['handle' => $handle]),
405            ]);
406        }
407
408        try {
409            $this->db->nodes->saveHandle([
410                'node' => $nodeId,
411                'handle' => $handle,
412                'editor' => $editor,
413            ])->run();
414        } catch (Throwable $e) {
415            if ((string) $e->getCode() === '23505') {
416                throw new HttpConflict(payload: [
417                    'message' => __('node:duplicate-handle', ['handle' => $handle]),
418                ]);
419            }
420
421            throw $e;
422        }
423    }
424
425    private function resolveParentUid(object $node, array $data): ?string
426    {
427        $parentUid = array_key_exists('parent', $data)
428            ? $data['parent']
429            : Factory::meta($node, 'parent');
430
431        if ($parentUid === null) {
432            return null;
433        }
434
435        if (!is_string($parentUid)) {
436            throw new HttpBadRequest(payload: [
437                'message' => __('node:parent-not-string'),
438            ]);
439        }
440
441        $parentUid = trim($parentUid);
442
443        if ($parentUid === '') {
444            return null;
445        }
446
447        return $parentUid;
448    }
449
450    private function resolveParentId(?string $parentUid): ?int
451    {
452        if ($parentUid === null) {
453            return null;
454        }
455
456        $parent = $this->db
457            ->nodes
458            ->parentIdByUid(['uid' => $parentUid])
459            ->first();
460
461        if (!$parent) {
462            throw new HttpBadRequest(payload: [
463                'message' => __('node:invalid-parent', ['uid' => $parentUid]),
464            ]);
465        }
466
467        return (int) $parent['node'];
468    }
469
470    private function assertUidUnchanged(object $node, array $data): void
471    {
472        $uid = Factory::meta($node, 'uid');
473
474        if (!is_string($uid) || $data['uid'] === $uid) {
475            return;
476        }
477
478        throw new HttpBadRequest(payload: [
479            'message' => __('node:uid-immutable'),
480        ]);
481    }
482
483    private function normalizeSubmittedHandle(array $data): array
484    {
485        if (!array_key_exists('handle', $data) || !is_string($data['handle'])) {
486            return $data;
487        }
488
489        $handle = trim($data['handle']);
490        $data['handle'] = $handle === '' ? null : $handle;
491
492        return $data;
493    }
494
495    private function completeHandle(object $node, array $data): array
496    {
497        if (!array_key_exists('handle', $data)) {
498            $data['handle'] = Factory::meta($node, 'handle');
499        }
500
501        $data['handle'] = $this->resolveHandle($data);
502
503        return $data;
504    }
505
506    private function resolveHandle(array $data): ?string
507    {
508        $handle = $data['handle'] ?? null;
509
510        if (!is_string($handle)) {
511            return null;
512        }
513
514        $handle = trim($handle);
515
516        return $handle === '' ? null : $handle;
517    }
518
519    private function ensureRouteHandle(object $node, ?string $handle): void
520    {
521        $route = $this->types->get($node::class, 'route');
522
523        if (!$this->routeNeedsHandle($route) || $handle !== null) {
524            return;
525        }
526
527        throw new HttpBadRequest(payload: [
528            'message' => __('node:handle-required'),
529        ]);
530    }
531
532    private function completeGeneratedPaths(
533        object $node,
534        array $data,
535        Locales $locales,
536        ?int $parentId,
537    ): array {
538        if (!$this->needsGeneratedPaths($data)) {
539            return $data;
540        }
541
542        try {
543            $data['generatedPaths'] = $this->routePathGenerator->generate(
544                $node::class,
545                $data,
546                $locales,
547                $parentId,
548            );
549        } catch (RoutePathError $e) {
550            throw new HttpBadRequest(
551                payload: [
552                    'message' => $e->getMessage(),
553                ],
554                previous: $e,
555            );
556        }
557
558        return $data;
559    }
560
561    private function needsGeneratedPaths(array $data): bool
562    {
563        foreach ($data['paths'] ?? [] as $path) {
564            if (is_string($path) ? trim($path) !== '' : (bool) $path) {
565                return false;
566            }
567        }
568
569        return true;
570    }
571
572    private function routeNeedsHandle(mixed $route): bool
573    {
574        if (is_string($route)) {
575            return $this->routeContainsHandle($route);
576        }
577
578        if (!is_array($route)) {
579            return false;
580        }
581
582        foreach ($route as $localizedRoute) {
583            if (is_string($localizedRoute) && $this->routeContainsHandle($localizedRoute)) {
584                return true;
585            }
586        }
587
588        return false;
589    }
590
591    private function routeContainsHandle(string $route): bool
592    {
593        return preg_match('/\{\s*handle\s*(?:\|\s*[^{}|]+\s*)*\}/', $route) === 1;
594    }
595
596    private function ensureTypeExists(string $handle): void
597    {
598        $type = $this->db->nodes->type(['handle' => $handle])->first();
599
600        if (!$type) {
601            $this->db->nodes->addType([
602                'handle' => $handle,
603            ])->run();
604        }
605    }
606}