Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.96% covered (success)
97.96%
96 / 98
93.75% covered (success)
93.75%
15 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Factory
97.96% covered (success)
97.96%
96 / 98
93.75% covered (success)
93.75%
15 / 16
23
0.00% covered (danger)
0.00%
0 / 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%
58 / 58
100.00% covered (success)
100.00%
1 / 1
3
 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
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 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\Core\Factory\Factory as CoreFactory;
9use Celema\Core\Request;
10use Celema\Quma\Database;
11use Celema\Wire\Creator;
12use Cosray\Cms;
13use Cosray\Config;
14use Cosray\Context;
15use Cosray\Contract\Init;
16use Cosray\Exception\NoSuchField;
17use Cosray\Exception\RuntimeException;
18use Cosray\Field\Definitions;
19use Cosray\Field\EmbeddedDefinition;
20use Cosray\Field\Field;
21use Cosray\Field\FieldHydrator;
22use Cosray\Field\FieldsetDefinition;
23use Cosray\Field\Services;
24use Cosray\Uid;
25use ReflectionClass;
26use ReflectionNamedType;
27use WeakMap;
28
29class Factory
30{
31    /**
32     * @var WeakMap<object, array{
33     *     data: array,
34     *     fields: array<string, Field>,
35     *     embedded: array<string, object>,
36     *     fieldsets: list<FieldsetDefinition>
37     * }>
38     */
39    private static WeakMap $nodeState;
40
41    private readonly FieldHydrator $hydrator;
42    private readonly Types $types;
43
44    public function __construct(
45        private readonly Container $container,
46        private readonly Services $services,
47        private readonly Uid $uid,
48    ) {
49        $this->hydrator = new FieldHydrator($services);
50        $this->types = $services->types;
51        self::$nodeState ??= new WeakMap();
52    }
53
54    /**
55     * Create a node instance from a class name and raw DB data.
56     *
57     * Uses Wire Creator for autowired construction,
58     * then FieldHydrator for field initialization.
59     */
60    public function create(string $class, Context $context, Cms $cms, array $data): object
61    {
62        $serializer = new Serializer($this->types, $this->uid, $context->assets());
63        $store = new Store(
64            $context->db,
65            new PathManager(),
66            $this->types,
67            $this->uid,
68            factory: $this,
69            cms: $cms,
70            context: $context,
71        );
72        $creator = new Creator($this->container);
73        $type = $this->types->typeOf($class);
74        $types = $this->types;
75
76        // The node's own view has to know the node, which does not exist yet.
77        // A lazy proxy closes over the variable and materializes on first use,
78        // by which time the node is built.
79        $node = null;
80        $view = new ReflectionClass(View::class)->newLazyProxy(
81            // By reference: an arrow function would capture the null.
82            static function () use (&$node, $cms, $context, $types): View {
83                return new View($node, $cms, $context, $types);
84            },
85        );
86
87        $predefinedTypes = [
88            Context::class => $context,
89            Cms::class => $cms,
90            Config::class => $context->config,
91            Database::class => $context->db,
92            Container::class => $context->container,
93            CoreFactory::class => $context->factory,
94            self::class => $this,
95            Type::class => $type,
96            View::class => $view,
97            Serializer::class => $serializer,
98            Store::class => $store,
99            FieldHydrator::class => $this->hydrator,
100            Services::class => $this->services,
101        ];
102
103        if ($context->request !== null) {
104            $predefinedTypes[Request::class] = $context->request;
105        }
106
107        $node = $creator->create($class, predefinedTypes: $predefinedTypes);
108
109        $uid = $data['uid'] ?? $this->uid->generate();
110        $data['uid'] = $uid;
111        $owner = new FieldOwner($context, $uid);
112        $hydration = $this->hydrator->hydrateEmbedded(
113            $node,
114            $data['content'] ?? [],
115            $owner,
116            function (EmbeddedDefinition $definition) use ($class, $creator, $predefinedTypes): object {
117                $this->assertFreshEmbedded($definition, $class);
118
119                return $creator->create($definition->type, predefinedTypes: $predefinedTypes);
120            },
121        );
122
123        self::$nodeState[$node] = [
124            'data' => $data,
125            'fields' => $hydration->fields,
126            'embedded' => $hydration->embedded,
127            'fieldsets' => Definitions::for($class)->fieldsets(),
128        ];
129
130        if ($node instanceof Init) {
131            $node->init();
132        }
133
134        return $node;
135    }
136
137    /**
138     * Wrap a node for template-friendly access.
139     */
140    public function proxy(
141        object $node,
142        ?Context $context = null,
143        ?Cms $cms = null,
144    ): Wrapper {
145        return new Wrapper(
146            $node,
147            self::fieldNamesFor($node),
148            $this->types,
149            $context,
150            $cms,
151            $this,
152        );
153    }
154
155    /**
156     * Create a blueprint (empty) node for admin panel schema generation.
157     */
158    public function blueprint(string $class, Context $context, Cms $cms): object
159    {
160        return $this->create($class, $context, $cms, []);
161    }
162
163    /**
164     * Get the raw DB data associated with a node instance.
165     */
166    public static function dataFor(object $node): array
167    {
168        return self::getNodeState($node)['data'] ?? [];
169    }
170
171    /** @return array<string, Field> */
172    public static function fieldsFor(object $node): array
173    {
174        return self::getNodeState($node)['fields'] ?? [];
175    }
176
177    public static function fieldFor(object $node, string $name): Field
178    {
179        $field = self::fieldsFor($node)[$name] ?? null;
180
181        if ($field === null) {
182            $class = $node::class;
183            throw new NoSuchField("Node '{$class}' does not have a hydrated field '{$name}'.");
184        }
185
186        return $field;
187    }
188
189    /** @return list<string> */
190    public static function fieldNamesFor(object $node): array
191    {
192        return array_keys(self::fieldsFor($node));
193    }
194
195    /** @return array<string, object> */
196    public static function embedsFor(object $node): array
197    {
198        return self::getNodeState($node)['embedded'] ?? [];
199    }
200
201    public static function embeddedFor(object $node, string $name): ?object
202    {
203        return self::embedsFor($node)[$name] ?? null;
204    }
205
206    /** @return list<FieldsetDefinition> */
207    public static function fieldsetsFor(object $node): array
208    {
209        return self::getNodeState($node)['fieldsets'] ?? [];
210    }
211
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}