Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
Writer
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
4 / 4
8
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 assertUsablePaths
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Cosray\Actor;
8use Cosray\Cms;
9use Cosray\Context;
10use Cosray\Exception\RuntimeException;
11use DateTimeInterface;
12
13/**
14 * Creates CMS nodes without exposing blueprint, serialization, and storage wiring.
15 *
16 * @api
17 */
18final class Writer
19{
20    private readonly Factory $factory;
21    private readonly Serializer $serializer;
22    private readonly Store $store;
23
24    public function __construct(
25        private readonly Context $context,
26        private readonly Cms $cms,
27        Types $types,
28    ) {
29        $this->factory = $cms->nodeFactory();
30        $this->serializer = new Serializer($types, $this->factory->uid());
31        $this->store = new Store(
32            $context->db,
33            new PathManager(ReservedPaths::fromConfig($context->config)),
34            $types,
35            $this->factory->uid(),
36            factory: $this->factory,
37            cms: $cms,
38            context: $context,
39        );
40    }
41
42    /**
43     * @param class-string $class
44     * @param array<string, mixed> $values
45     */
46    public function prepare(string $class, array $values = []): Prepared
47    {
48        $node = $this->factory->blueprint($class, $this->context, $this->cms);
49        $data = $this->serializer->blueprint(
50            $node,
51            Factory::fieldNamesFor($node),
52            $this->context->locales(),
53            $values,
54        );
55
56        return new Prepared($node, $data);
57    }
58
59    /**
60     * The actor is the last editor and, unless overridden, the creator.
61     * Historical dates apply only to the node, not its paths or handle.
62     *
63     * @return array{success: true, uid: string}
64     */
65    public function create(
66        Prepared $prepared,
67        ?Actor $actor = null,
68        ?Actor $creator = null,
69        ?DateTimeInterface $created = null,
70        ?DateTimeInterface $changed = null,
71    ): array {
72        $this->assertUsablePaths($prepared->data());
73
74        return $this->store->create(
75            $prepared->node,
76            $prepared->data(),
77            $this->context->locales(),
78            $actor ?? Actor::system(),
79            $creator,
80            $created,
81            $changed,
82        );
83    }
84
85    /**
86     * Explicit paths fail loudly here: `PathManager` would silently
87     * suffix a colliding path, which defeats preserving a legacy URL.
88     *
89     * @param array<string, mixed> $data
90     */
91    private function assertUsablePaths(array $data): void
92    {
93        $locales = $this->context->locales();
94
95        foreach ($data['paths'] ?? [] as $locale => $path) {
96            if (!$path) {
97                continue;
98            }
99
100            if (!$locales->exists($locale)) {
101                throw new RuntimeException(
102                    "Unknown locale '{$locale}' for the node path '{$path}'",
103                );
104            }
105
106            if ($this->context->db->nodes->activePathExists(['path' => $path])->first()) {
107                throw new RuntimeException("The URL path '{$path}' is already in use");
108            }
109        }
110    }
111}