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