Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.47% covered (warning)
76.47%
52 / 68
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
FieldHydrator
76.47% covered (warning)
76.47%
52 / 68
66.67% covered (warning)
66.67%
6 / 9
38.21
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
 hydrate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hydrateEmbedded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hydrateAll
75.76% covered (warning)
75.76%
25 / 33
0.00% covered (danger)
0.00%
0 / 1
11.42
 getField
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
7.64
 getFields
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 services
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 isActive
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7use Cosray\Assets\Repository;
8use Cosray\Contract\Init;
9use Cosray\Exception\NoSuchField;
10use Cosray\Exception\RuntimeException;
11use Cosray\Schema\When;
12use Cosray\Value\ValueContext;
13use ReflectionProperty;
14
15class FieldHydrator
16{
17    public function __construct(
18        private readonly Services $services,
19    ) {}
20
21    /**
22     * Hydrate direct fields for callers that do not provide embedded object creation.
23     *
24     * @return list<string>
25     */
26    public function hydrate(object $target, array $content, Owner $owner): array
27    {
28        return array_keys($this->hydrateAll($target, $content, $owner)->fields);
29    }
30
31    /**
32     * @param callable(EmbeddedDefinition): object $createEmbedded
33     */
34    public function hydrateEmbedded(
35        object $target,
36        array $content,
37        Owner $owner,
38        callable $createEmbedded,
39    ): Hydration {
40        return $this->hydrateAll($target, $content, $owner, $createEmbedded);
41    }
42
43    /**
44     * @param null|callable(EmbeddedDefinition): object $createEmbedded
45     */
46    private function hydrateAll(
47        object $target,
48        array $content,
49        Owner $owner,
50        ?callable $createEmbedded = null,
51    ): Hydration {
52        $uids = Repository::collectUids($content);
53
54        if ($uids !== []) {
55            $owner->assets()->preload($uids);
56        }
57
58        $targetClass = $target::class;
59        $definitions = Definitions::for($targetClass);
60        $embedded = [];
61
62        foreach ($definitions->embedded() as $definition) {
63            if ($createEmbedded === null) {
64                throw new RuntimeException(
65                    "Hydrating '{$targetClass}' requires an embedded object factory.",
66                );
67            }
68
69            $instance = $createEmbedded($definition);
70            $type = $definition->type;
71
72            if (!$instance instanceof $type) {
73                $instanceClass = $instance::class;
74                throw new RuntimeException(
75                    "Embedded factory returned '{$instanceClass}' for '{$definition->type}'.",
76                );
77            }
78
79            $definition->property->setValue($target, $instance);
80            $embedded[$definition->name] = $instance;
81        }
82
83        $fields = [];
84
85        foreach ($definitions->fields() as $definition) {
86            $fieldTarget = $definition->embedded === null ? $target : $embedded[$definition->embedded];
87
88            if ($definition->property->isInitialized($fieldTarget)) {
89                continue;
90            }
91
92            $field = $this->initField($definition->property, $definition->type, $content, $owner);
93            $definition->property->setValue($fieldTarget, $field);
94            $fields[$definition->name] = $field;
95        }
96
97        foreach ($embedded as $instance) {
98            if (!$instance instanceof Init) {
99                continue;
100            }
101
102            $instance->init();
103        }
104
105        return new Hydration($fields, $embedded);
106    }
107
108    public static function getField(object $target, string $name): Field
109    {
110        $targetClass = $target::class;
111        $definitions = Definitions::for($targetClass);
112        $definition = $definitions->field($name);
113
114        if ($definition === null) {
115            throw new NoSuchField("Field '{$name}' is not declared on '{$targetClass}'.");
116        }
117
118        $fieldTarget = $target;
119
120        if ($definition->embedded !== null) {
121            $embed = $definitions->embed($definition->embedded);
122
123            if ($embed === null || !$embed->property->isInitialized($target)) {
124                throw new NoSuchField("Embedded field '{$name}' has not been hydrated.");
125            }
126
127            $value = $embed->property->getValue($target);
128
129            if (!is_object($value)) {
130                throw new NoSuchField("Embedded field '{$name}' has not been hydrated.");
131            }
132
133            $fieldTarget = $value;
134        }
135
136        if (!$definition->property->isInitialized($fieldTarget)) {
137            throw new NoSuchField("Field '{$name}' has not been hydrated.");
138        }
139
140        return $definition->property->getValue($fieldTarget);
141    }
142
143    /**
144     * @param list<string> $fieldNames
145     * @return array<string, Field>
146     */
147    public static function getFields(object $target, array $fieldNames): array
148    {
149        $fields = [];
150
151        foreach ($fieldNames as $name) {
152            $fields[$name] = self::getField($target, $name);
153        }
154
155        return $fields;
156    }
157
158    public function services(): Services
159    {
160        return $this->services;
161    }
162
163    protected function initField(
164        ReflectionProperty $property,
165        string $fieldType,
166        array $content,
167        Owner $owner,
168    ): Field {
169        $fieldName = $property->getName();
170        $data = $content[$fieldName] ?? [];
171
172        // A field whose When condition is not met hydrates with empty
173        // data: it presents as empty to every consumer (read-time
174        // enforcement) while the stored value survives untouched and
175        // stays reachable through Field::raw().
176        $active = $this->isActive($property, $content);
177        $field = new $fieldType($fieldName, $owner, new ValueContext($fieldName, $active ? $data : []));
178
179        $field->init($this->services, $property, $data);
180
181        return $field;
182    }
183
184    private function isActive(ReflectionProperty $property, array $content): bool
185    {
186        foreach ($property->getAttributes(When::class) as $attr) {
187            if (!Condition::active($attr->newInstance()->condition(), $content)) {
188                return false;
189            }
190        }
191
192        return true;
193    }
194}