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