Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.60% covered (warning)
75.60%
158 / 209
50.00% covered (danger)
50.00%
10 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Store
75.60% covered (warning)
75.60%
158 / 209
50.00% covered (danger)
50.00%
10 / 20
147.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
71.43% covered (warning)
71.43%
20 / 28
0.00% covered (danger)
0.00%
0 / 1
8.14
 create
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
9.37
 delete
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 validate
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
2.35
 persist
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 persistNode
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
5
 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
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 assertUidUnchanged
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 normalizeSubmittedHandle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 completeHandle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 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
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 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
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Celemas\Core\Exception\HttpBadRequest;
8use Celemas\Core\Exception\HttpConflict;
9use Celemas\Core\Exception\HttpError;
10use Celemas\Core\Request;
11use Celemas\Quma\Database;
12use Cosray\Exception\RoutePathError;
13use Cosray\Exception\RuntimeException;
14use Cosray\Locales;
15use Cosray\Uid;
16use Cosray\Validation\ValidatorFactory;
17use Throwable;
18
19class Store
20{
21    private const int CREATE_UID_ATTEMPTS = 5;
22
23    private readonly RoutePathGenerator $routePathGenerator;
24
25    public function __construct(
26        private readonly Database $db,
27        private readonly PathManager $pathManager,
28        private readonly Types $types,
29        private readonly Uid $uid,
30        ?RoutePathGenerator $routePathGenerator = null,
31    ) {
32        $this->routePathGenerator = $routePathGenerator ?? new RoutePathGenerator($db, $types);
33    }
34
35    public function save(
36        object $node,
37        array $data,
38        Request $request,
39        Locales $locales,
40        bool $create = false,
41    ): array {
42        $data = $this->normalizeSubmittedHandle($data);
43        $data = $this->validate($node, $data, $locales, $request);
44
45        if (!$create) {
46            $this->assertUidUnchanged($node, $data, $request);
47        }
48
49        $data = $this->completeHandle($node, $data);
50
51        if ($data['locked']) {
52            throw new HttpBadRequest($request, payload: ['message' => _('This document is locked')]);
53        }
54
55        try {
56            $editor = $request->get('session')->authenticatedUserId();
57
58            if (!$editor) {
59                $editor = 1;
60            }
61        } catch (Throwable) {
62            $editor = 1;
63        }
64
65        try {
66            $this->db->begin();
67
68            $this->persist($node, $data, $editor, $locales, $request, $create);
69
70            $this->db->commit();
71        } catch (Throwable $e) {
72            $this->db->rollback();
73
74            if ($e instanceof HttpError) {
75                throw $e;
76            }
77
78            throw new RuntimeException(
79                _('Fehler beim Speichern: ') . $e->getMessage(),
80                (int) $e->getCode(),
81                previous: $e,
82            );
83        }
84
85        return [
86            'success' => true,
87            'uid' => $data['uid'],
88        ];
89    }
90
91    public function create(object $node, array $data, Request $request, Locales $locales): array
92    {
93        $generatedUid = !array_key_exists('uid', $data);
94        if ($generatedUid) {
95            $data['uid'] = Factory::meta($node, 'uid') ?? $this->uid->generate();
96        }
97        $attempts = $generatedUid ? self::CREATE_UID_ATTEMPTS : 1;
98
99        for ($attempt = 1; $attempt <= $attempts; $attempt++) {
100            if ($generatedUid && $attempt > 1) {
101                $data['uid'] = $this->uid->generate();
102            }
103
104            try {
105                return $this->save($node, $data, $request, $locales, create: true);
106            } catch (HttpConflict $e) {
107                if (!$generatedUid || $attempt === $attempts) {
108                    throw $e;
109                }
110            }
111        }
112
113        throw new RuntimeException(_('Could not generate a unique node uid'));
114    }
115
116    public function delete(object $node, Request $request): array
117    {
118        if ($request->header('Accept') !== 'application/json') {
119            throw new HttpBadRequest($request);
120        }
121
122        $uid = Factory::meta($node, 'uid');
123
124        $this->db->nodes->delete([
125            'uid' => $uid,
126            'editor' => $request->get('session')->authenticatedUserId(),
127        ])->run();
128
129        return [
130            'success' => true,
131            'error' => false,
132        ];
133    }
134
135    public function validate(object $node, array $data, Locales $locales, Request $request): array
136    {
137        $factory = new ValidatorFactory($node, $locales);
138        $shape = $factory->create();
139        $result = $shape->validate($data);
140
141        if (!$result->valid()) {
142            throw new HttpBadRequest($request, payload: [
143                'message' => _('Incomplete or invalid data'),
144                'errors' => $result->issues(),
145            ]);
146        }
147
148        return $result->values();
149    }
150
151    private function persist(
152        object $node,
153        array $data,
154        int $editor,
155        Locales $locales,
156        Request $request,
157        bool $create = false,
158    ): void {
159        $parentUid = $this->resolveParentUid($node, $data, $request);
160        $parentId = $this->resolveParentId($parentUid, $request);
161        $handle = $this->resolveHandle($data);
162
163        $nodeId = $this->persistNode($node, $data, $editor, $parentId, $create, $request);
164        $this->persistHandle($nodeId, $handle, $editor, $request);
165
166        if ((bool) $this->types->get($node::class, 'routable', false)) {
167            $this->ensureRouteHandle($node, $handle, $request);
168            $data = $this->completeGeneratedPaths($node, $data, $locales, $parentId, $request);
169            $this->pathManager->persist($this->db, $data, $editor, $nodeId, $locales);
170        }
171    }
172
173    private function persistNode(
174        object $node,
175        array $data,
176        int $editor,
177        ?int $parent,
178        bool $create,
179        Request $request,
180    ): int {
181        $class = $node::class;
182        $handle = (string) $this->types->get($class, 'handle');
183        $this->ensureTypeExists($handle);
184        $params = [
185            'uid' => $data['uid'],
186            'parent' => $parent,
187            'hidden' => $data['hidden'],
188            'published' => $data['published'],
189            'locked' => $data['locked'],
190            'type' => $handle,
191            'content' => json_encode($data['content']),
192            'editor' => $editor,
193        ];
194
195        if (!$create) {
196            $nodeId = Factory::meta($node, 'node');
197
198            if (!is_int($nodeId) && !is_string($nodeId)) {
199                throw new RuntimeException(_('Missing node id for update'));
200            }
201
202            return (int) $this->db->nodes->save([
203                'node' => (int) $nodeId,
204                'parent' => $params['parent'],
205                'hidden' => $params['hidden'],
206                'published' => $params['published'],
207                'locked' => $params['locked'],
208                'content' => $params['content'],
209                'editor' => $params['editor'],
210            ])->one()['node'];
211        }
212
213        $result = $this->db->nodes->create($params)->first();
214
215        if (!$result) {
216            throw new HttpConflict($request, payload: [
217                'message' => _('A node with the same uid already exists: ') . $data['uid'],
218            ]);
219        }
220
221        return (int) $result['node'];
222    }
223
224    private function persistHandle(int $nodeId, ?string $handle, int $editor, Request $request): void
225    {
226        if ($handle === null) {
227            $this->db->nodes->deleteHandle(['node' => $nodeId])->run();
228
229            return;
230        }
231
232        $collision = $this->db
233            ->nodes
234            ->handleUidCollision(['handle' => $handle, 'node' => $nodeId])
235            ->first();
236
237        if ($collision) {
238            throw new HttpConflict($request, payload: [
239                'message' => _('A node uid with the same handle already exists: ') . $handle,
240            ]);
241        }
242
243        try {
244            $this->db->nodes->saveHandle([
245                'node' => $nodeId,
246                'handle' => $handle,
247                'editor' => $editor,
248            ])->run();
249        } catch (Throwable $e) {
250            if ((string) $e->getCode() === '23505') {
251                throw new HttpConflict($request, payload: [
252                    'message' => _('A node with the same handle already exists: ') . $handle,
253                ]);
254            }
255
256            throw $e;
257        }
258    }
259
260    private function resolveParentUid(object $node, array $data, Request $request): ?string
261    {
262        $parentUid = array_key_exists('parent', $data)
263            ? $data['parent']
264            : Factory::meta($node, 'parent');
265
266        if ($parentUid === null) {
267            return null;
268        }
269
270        if (!is_string($parentUid)) {
271            throw new HttpBadRequest($request, payload: [
272                'message' => _('Parent must be a uid string'),
273            ]);
274        }
275
276        $parentUid = trim($parentUid);
277
278        if ($parentUid === '') {
279            return null;
280        }
281
282        return $parentUid;
283    }
284
285    private function resolveParentId(?string $parentUid, Request $request): ?int
286    {
287        if ($parentUid === null) {
288            return null;
289        }
290
291        $parent = $this->db
292            ->nodes
293            ->parentIdByUid(['uid' => $parentUid])
294            ->first();
295
296        if (!$parent) {
297            throw new HttpBadRequest($request, payload: [
298                'message' => _('Invalid parent uid: ') . $parentUid,
299            ]);
300        }
301
302        return (int) $parent['node'];
303    }
304
305    private function assertUidUnchanged(object $node, array $data, Request $request): void
306    {
307        $uid = Factory::meta($node, 'uid');
308
309        if (!is_string($uid) || $data['uid'] === $uid) {
310            return;
311        }
312
313        throw new HttpBadRequest($request, payload: [
314            'message' => _('Node uid cannot be changed'),
315        ]);
316    }
317
318    private function normalizeSubmittedHandle(array $data): array
319    {
320        if (!array_key_exists('handle', $data) || !is_string($data['handle'])) {
321            return $data;
322        }
323
324        $handle = trim($data['handle']);
325        $data['handle'] = $handle === '' ? null : $handle;
326
327        return $data;
328    }
329
330    private function completeHandle(object $node, array $data): array
331    {
332        if (!array_key_exists('handle', $data)) {
333            $data['handle'] = Factory::meta($node, 'handle');
334        }
335
336        $data['handle'] = $this->resolveHandle($data);
337
338        return $data;
339    }
340
341    private function resolveHandle(array $data): ?string
342    {
343        $handle = $data['handle'] ?? null;
344
345        if (!is_string($handle)) {
346            return null;
347        }
348
349        $handle = trim($handle);
350
351        return $handle === '' ? null : $handle;
352    }
353
354    private function ensureRouteHandle(object $node, ?string $handle, Request $request): void
355    {
356        $route = $this->types->get($node::class, 'route');
357
358        if (!$this->routeNeedsHandle($route) || $handle !== null) {
359            return;
360        }
361
362        throw new HttpBadRequest($request, payload: [
363            'message' => _('A handle is required for this node route'),
364        ]);
365    }
366
367    private function completeGeneratedPaths(
368        object $node,
369        array $data,
370        Locales $locales,
371        ?int $parentId,
372        Request $request,
373    ): array {
374        if (!$this->needsGeneratedPaths($data)) {
375            return $data;
376        }
377
378        try {
379            $data['generatedPaths'] = $this->routePathGenerator->generate(
380                $node::class,
381                $data,
382                $locales,
383                $parentId,
384            );
385        } catch (RoutePathError $e) {
386            throw new HttpBadRequest(
387                $request,
388                payload: [
389                    'message' => $e->getMessage(),
390                ],
391                previous: $e,
392            );
393        }
394
395        return $data;
396    }
397
398    private function needsGeneratedPaths(array $data): bool
399    {
400        foreach ($data['paths'] ?? [] as $path) {
401            if (is_string($path) ? trim($path) !== '' : (bool) $path) {
402                return false;
403            }
404        }
405
406        return true;
407    }
408
409    private function routeNeedsHandle(mixed $route): bool
410    {
411        if (is_string($route)) {
412            return $this->routeContainsHandle($route);
413        }
414
415        if (!is_array($route)) {
416            return false;
417        }
418
419        foreach ($route as $localizedRoute) {
420            if (is_string($localizedRoute) && $this->routeContainsHandle($localizedRoute)) {
421                return true;
422            }
423        }
424
425        return false;
426    }
427
428    private function routeContainsHandle(string $route): bool
429    {
430        return preg_match('/\{\s*handle\s*(?:\|\s*[^{}|]+\s*)*\}/', $route) === 1;
431    }
432
433    private function ensureTypeExists(string $handle): void
434    {
435        $type = $this->db->nodes->type(['handle' => $handle])->first();
436
437        if (!$type) {
438            $this->db->nodes->addType([
439                'handle' => $handle,
440            ])->run();
441        }
442    }
443}