Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
99 / 99
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
Schema
100.00% covered (success)
100.00%
99 / 99
100.00% covered (success)
100.00%
11 / 11
42
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
 __get
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 __isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 declares
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 properties
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveAttributes
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
13
 automaticTitle
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 validateFieldOrder
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 validatePropertyTitles
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Cosray\Contract\Title as TitleContract;
8use Cosray\Exception\NoSuchProperty;
9use Cosray\Exception\RuntimeException;
10use Cosray\Field\Definitions;
11use Cosray\Node\Schema\Registry;
12use Cosray\Schema\Title;
13use ReflectionClass;
14
15class Schema
16{
17    /** @var array<string, mixed> */
18    private array $properties;
19
20    /** @var list<string> */
21    private readonly array $declared;
22
23    /**
24     * @param class-string $nodeClass
25     */
26    public function __construct(
27        private readonly string $nodeClass,
28        private readonly Registry $registry,
29    ) {
30        $resolved = $this->resolveAttributes();
31        $this->declared = array_keys($resolved);
32        $this->properties = $this->registry->resolveDefaults($this->nodeClass, $resolved);
33    }
34
35    public function __get(string $key): mixed
36    {
37        if (!$this->has($key)) {
38            throw new NoSuchProperty(
39                "The node schema '{$this->nodeClass}' doesn't have the property '{$key}'",
40            );
41        }
42
43        return $this->get($key);
44    }
45
46    public function __isset(string $key): bool
47    {
48        return $this->has($key) && $this->properties[$key] !== null;
49    }
50
51    /**
52     * Get a schema property by key.
53     */
54    public function get(string $key, mixed $default = null): mixed
55    {
56        if (array_key_exists($key, $this->properties)) {
57            return $this->properties[$key];
58        }
59
60        return $default;
61    }
62
63    public function has(string $key): bool
64    {
65        return array_key_exists($key, $this->properties);
66    }
67
68    /** Whether the node class sets the property itself rather than leaving it to a default. */
69    public function declares(string $key): bool
70    {
71        return in_array($key, $this->declared, true);
72    }
73
74    /**
75     * Return all schema properties as an array.
76     *
77     * @return array<string, mixed>
78     */
79    public function properties(): array
80    {
81        return $this->properties;
82    }
83
84    /**
85     * @return array<string, mixed>
86     */
87    private function resolveAttributes(): array
88    {
89        $reflection = new ReflectionClass($this->nodeClass);
90        $resolved = [];
91
92        foreach ($reflection->getAttributes() as $attribute) {
93            $instance = $attribute->newInstance();
94            $handler = $this->registry->getHandler($instance);
95
96            if ($handler !== null) {
97                $resolved = array_merge($resolved, $handler->resolve($instance, $this->nodeClass));
98            }
99        }
100
101        $definitions = Definitions::for($this->nodeClass);
102        $this->validateFieldOrder($resolved, $definitions);
103        $selections = [];
104
105        if (array_key_exists('titleField', $resolved)) {
106            $selections[] = ['kind' => 'field', 'name' => $resolved['titleField']];
107        }
108
109        foreach ($definitions->fields() as $field) {
110            if ($field->property->getAttributes(Title::class) === []) {
111                continue;
112            }
113
114            $selections[] = ['kind' => 'field', 'name' => $field->name];
115        }
116
117        foreach ($definitions->embedded() as $embedded) {
118            if ($embedded->property->getAttributes(Title::class) === []) {
119                continue;
120            }
121
122            if (!is_a($embedded->type, TitleContract::class, true)) {
123                throw new RuntimeException(
124                    "The #[Title] attribute on embedded property '{$this->nodeClass}::{$embedded->name}' "
125                        . 'requires its type to implement Cosray\\Contract\\Title.',
126                );
127            }
128
129            $selections[] = ['kind' => 'embedded', 'name' => $embedded->name];
130        }
131
132        $this->validatePropertyTitles($reflection, $definitions);
133
134        if (count($selections) > 1) {
135            throw new RuntimeException(
136                "Node '{$this->nodeClass}' declares more than one explicit title source.",
137            );
138        }
139
140        $selection = $selections[0] ?? null;
141
142        if ($selection === null) {
143            return $this->automaticTitle($resolved, $definitions);
144        }
145
146        if ($selection['kind'] === 'embedded') {
147            $resolved['titleEmbedded'] = $selection['name'];
148
149            return $resolved;
150        }
151
152        $handler = $this->registry->getHandler(new Title());
153
154        if ($handler !== null) {
155            $resolved = array_merge($resolved, $handler->resolve(
156                new Title((string) $selection['name']),
157                $this->nodeClass,
158            ));
159        }
160
161        return $resolved;
162    }
163
164    /**
165     * Select the automatic embedded title provider when nothing explicit
166     * is declared. An outer Title implementation wins at resolution time,
167     * so embedded providers are irrelevant and not validated for it.
168     *
169     * @param array<string, mixed> $resolved
170     *
171     * @return array<string, mixed>
172     */
173    private function automaticTitle(array $resolved, Definitions $definitions): array
174    {
175        if (is_a($this->nodeClass, TitleContract::class, true)) {
176            return $resolved;
177        }
178
179        $providers = [];
180
181        foreach ($definitions->embedded() as $embedded) {
182            if (!is_a($embedded->type, TitleContract::class, true)) {
183                continue;
184            }
185
186            $providers[] = $embedded->name;
187        }
188
189        if (count($providers) > 1) {
190            throw new RuntimeException(
191                "Node '{$this->nodeClass}' has multiple embedded title providers: "
192                    . implode(', ', $providers)
193                    . '.',
194            );
195        }
196
197        if ($providers !== []) {
198            $resolved['titleEmbedded'] = $providers[0];
199        }
200
201        return $resolved;
202    }
203
204    /** @param array<string, mixed> $resolved */
205    private function validateFieldOrder(array $resolved, Definitions $definitions): void
206    {
207        $order = $resolved['fieldOrder'] ?? null;
208
209        if (!is_array($order)) {
210            return;
211        }
212
213        $seen = [];
214
215        foreach ($order as $name) {
216            if (!is_string($name) || $definitions->field($name) === null) {
217                $given = is_string($name) ? $name : get_debug_type($name);
218                throw new RuntimeException(
219                    "The #[FieldOrder] attribute on '{$this->nodeClass}' references unknown field '{$given}'.",
220                );
221            }
222
223            if (in_array($name, $seen, true)) {
224                throw new RuntimeException(
225                    "The #[FieldOrder] attribute on '{$this->nodeClass}' repeats field '{$name}'.",
226                );
227            }
228
229            $seen[] = $name;
230        }
231    }
232
233    /** @param ReflectionClass<object> $reflection */
234    private function validatePropertyTitles(
235        ReflectionClass $reflection,
236        Definitions $definitions,
237    ): void {
238        foreach ($reflection->getProperties() as $property) {
239            if ($property->getAttributes(Title::class) === []) {
240                continue;
241            }
242
243            $field = $definitions->field($property->getName());
244
245            if ($field !== null && $field->embedded === null || $definitions->embed($property->getName())) {
246                continue;
247            }
248
249            throw new RuntimeException(
250                "The #[Title] attribute on property '{$this->nodeClass}::{$property->getName()}' "
251                    . 'requires a field-typed or Embedded-typed property.',
252            );
253        }
254    }
255}