Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
Factory
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
16 / 16
22
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
2
 proxy
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 blueprint
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dataFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fieldsFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fieldFor
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 fieldNamesFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 embedsFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 embeddedFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fieldsetsFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNodeState
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 meta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertFreshEmbedded
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 hydrator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 uid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Celema\Container\Container;
8use Celema\Wire\Creator;
9use Cosray\Cms;
10use Cosray\Context;
11use Cosray\Contract\Init;
12use Cosray\Exception\NoSuchField;
13use Cosray\Exception\RuntimeException;
14use Cosray\Field\Definitions;
15use Cosray\Field\EmbeddedDefinition;
16use Cosray\Field\Field;
17use Cosray\Field\FieldHydrator;
18use Cosray\Field\FieldsetDefinition;
19use Cosray\Field\Services;
20use Cosray\Uid;
21use ReflectionClass;
22use ReflectionNamedType;
23use WeakMap;
24
25class Factory
26{
27    /**
28     * @var WeakMap<object, array{
29     *     data: array<mixed>,
30     *     fields: array<string, Field>,
31     *     embedded: array<string, object>,
32     *     fieldsets: list<FieldsetDefinition>
33     * }>
34     */
35    private static WeakMap $nodeState;
36
37    private readonly FieldHydrator $hydrator;
38    private readonly Types $types;
39
40    public function __construct(
41        private readonly Container $container,
42        private readonly Services $services,
43        private readonly Uid $uid,
44    ) {
45        $this->hydrator = new FieldHydrator($services);
46        $this->types = $services->types;
47        self::$nodeState ??= new WeakMap();
48    }
49
50    /**
51     * Create a node instance from a class name and raw DB data.
52     *
53     * Uses Wire Creator for autowired construction,
54     * then FieldHydrator for field initialization.
55     *
56     * @param class-string $class
57     * @param array<mixed> $data
58     */
59    public function create(string $class, Context $context, Cms $cms, array $data): object
60    {
61        $serializer = new Serializer($this->types, $this->uid, $context->assets());
62        $store = new Store(
63            $context->db,
64            new PathManager(ReservedPaths::fromConfig($context->config)),
65            $this->types,
66            $this->uid,
67            factory: $this,
68            cms: $cms,
69            context: $context,
70        );
71        $type = $this->types->typeOf($class);
72        $types = $this->types;
73
74        // The node's own view has to know the node, which does not exist yet.
75        // A lazy proxy closes over the variable and materializes on first use,
76        // by which time the node is built.
77        /** @var ?object $node */
78        $node = null;
79        $view = new ReflectionClass(View::class)->newLazyProxy(
80            // By reference: an arrow function would capture the null.
81            static function () use (&$node, $cms, $context, $types): View {
82                return new View(
83                    $node ?? throw new RuntimeException('A node view materialized before its node existed'),
84                    $cms,
85                    $context,
86                    $types,
87                );
88            },
89        );
90
91        $nodeTypes = [
92            Cms::class => $cms,
93            self::class => $this,
94            Type::class => $type,
95            View::class => $view,
96            Serializer::class => $serializer,
97            Store::class => $store,
98            FieldHydrator::class => $this->hydrator,
99            Services::class => $this->services,
100        ];
101
102        $node = $context->create($class, $nodeTypes);
103
104        $uid = $data['uid'] ?? $this->uid->generate();
105        $data['uid'] = $uid;
106        $owner = new FieldOwner($context, $uid);
107        $hydration = $this->hydrator->hydrateEmbedded(
108            $node,
109            $data['content'] ?? [],
110            $owner,
111            function (EmbeddedDefinition $definition) use ($class, $context, $nodeTypes): object {
112                $this->assertFreshEmbedded($definition, $class);
113
114                return $context->create($definition->type, $nodeTypes);
115            },
116        );
117
118        self::$nodeState[$node] = [
119            'data' => $data,
120            'fields' => $hydration->fields,
121            'embedded' => $hydration->embedded,
122            'fieldsets' => Definitions::for($class)->fieldsets(),
123        ];
124
125        if ($node instanceof Init) {
126            $node->init();
127        }
128
129        return $node;
130    }
131
132    /**
133     * Wrap a node for template-friendly access.
134     */
135    public function proxy(
136        object $node,
137        ?Context $context = null,
138        ?Cms $cms = null,
139    ): Wrapper {
140        return new Wrapper(
141            $node,
142            self::fieldNamesFor($node),
143            $this->types,
144            $context,
145            $cms,
146            $this,
147        );
148    }
149
150    /**
151     * Create a blueprint (empty) node for admin panel schema generation.
152     *
153     * @param class-string $class
154     */
155    public function blueprint(string $class, Context $context, Cms $cms): object
156    {
157        return $this->create($class, $context, $cms, []);
158    }
159
160    /**
161     * Get the raw DB data associated with a node instance.
162     *
163     * @return array<mixed>
164     */
165    public static function dataFor(object $node): array
166    {
167        return self::getNodeState($node)['data'] ?? [];
168    }
169
170    /** @return array<string, Field> */
171    public static function fieldsFor(object $node): array
172    {
173        return self::getNodeState($node)['fields'] ?? [];
174    }
175
176    public static function fieldFor(object $node, string $name): Field
177    {
178        $field = self::fieldsFor($node)[$name] ?? null;
179
180        if ($field === null) {
181            $class = $node::class;
182            throw new NoSuchField("Node '{$class}' does not have a hydrated field '{$name}'.");
183        }
184
185        return $field;
186    }
187
188    /** @return list<string> */
189    public static function fieldNamesFor(object $node): array
190    {
191        return array_keys(self::fieldsFor($node));
192    }
193
194    /** @return array<string, object> */
195    public static function embedsFor(object $node): array
196    {
197        return self::getNodeState($node)['embedded'] ?? [];
198    }
199
200    public static function embeddedFor(object $node, string $name): ?object
201    {
202        return self::embedsFor($node)[$name] ?? null;
203    }
204
205    /** @return list<FieldsetDefinition> */
206    public static function fieldsetsFor(object $node): array
207    {
208        return self::getNodeState($node)['fieldsets'] ?? [];
209    }
210
211    /** @return array{data?: array<mixed>, fields?: array<string, Field>, embedded?: array<string, object>, fieldsets?: list<FieldsetDefinition>} */
212    private static function getNodeState(object $node): array
213    {
214        self::$nodeState ??= new WeakMap();
215        $node = Wrapper::unwrap($node);
216
217        return self::$nodeState[$node] ?? [];
218    }
219
220    /**
221     * Get a metadata value from the raw DB data for a node instance.
222     */
223    public static function meta(object $node, string $key): mixed
224    {
225        return self::dataFor($node)[$key] ?? null;
226    }
227
228    /**
229     * Embedded classes are node-local mutable schema objects, never services.
230     *
231     * @param class-string $nodeClass
232     */
233    private function assertFreshEmbedded(EmbeddedDefinition $definition, string $nodeClass): void
234    {
235        if ($this->container->has($definition->type)) {
236            throw new RuntimeException(
237                "Embedded type '{$definition->type}' must not be registered as a container service.",
238            );
239        }
240
241        $constructor = new ReflectionClass($definition->type)->getConstructor();
242
243        foreach ($constructor?->getParameters() ?? [] as $parameter) {
244            $type = $parameter->getType();
245
246            if ($type instanceof ReflectionNamedType && $type->getName() === $nodeClass) {
247                throw new RuntimeException(
248                    "Embedded type '{$definition->type}' must not inject its containing node '{$nodeClass}'.",
249                );
250            }
251        }
252    }
253
254    public function hydrator(): FieldHydrator
255    {
256        return $this->hydrator;
257    }
258
259    public function uid(): Uid
260    {
261        return $this->uid;
262    }
263}