1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace Cycle\ORM; |
||
6 | |||
7 | use Cycle\ORM\Exception\SchemaException; |
||
8 | |||
9 | /** |
||
10 | * Static schema with automatic class name => role aliasing. |
||
11 | */ |
||
12 | final class Schema implements SchemaInterface |
||
13 | { |
||
14 | private array $aliases; |
||
15 | |||
16 | /** |
||
17 | * @var string[] |
||
18 | * |
||
19 | * @psalm-var class-string[] |
||
20 | */ |
||
21 | private array $classes = []; |
||
22 | |||
23 | /** @var array<string, array> */ |
||
24 | private array $subclasses = []; |
||
25 | |||
26 | 7428 | private array $schema; |
|
27 | |||
28 | public function __construct(array $schema) |
||
29 | 7428 | { |
|
30 | // split into two? |
||
31 | [$this->schema, $this->aliases] = $this->normalize($schema); |
||
32 | } |
||
33 | |||
34 | public function getRoles(): array |
||
35 | { |
||
36 | return \array_keys($this->schema); |
||
37 | } |
||
38 | |||
39 | public function getRelations(string $role): array |
||
40 | { |
||
41 | 42 | return \array_keys($this->define($role, self::RELATIONS)); |
|
42 | } |
||
43 | 42 | ||
44 | /** |
||
45 | * Return all defined roles with the schema. |
||
46 | 16 | */ |
|
47 | public function toArray(): array |
||
48 | 16 | { |
|
49 | return $this->schema; |
||
50 | } |
||
51 | |||
52 | /** |
||
53 | * @return array [role => [relation name => relation schema]] |
||
54 | 2 | */ |
|
55 | public function getOuterRelations(string $role): array |
||
56 | 2 | { |
|
57 | // return null; |
||
58 | $result = []; |
||
59 | foreach ($this->schema as $roleName => $entitySchema) { |
||
60 | foreach ($entitySchema[SchemaInterface::RELATIONS] ?? [] as $relName => $item) { |
||
61 | if ($item[Relation::TARGET] === $role) { |
||
62 | 6878 | $result[$roleName][$relName] = $item; |
|
63 | } elseif ($item[Relation::TYPE] === Relation::MANY_TO_MANY) { |
||
64 | $through = $this->resolveAlias($item[Relation::SCHEMA][Relation::THROUGH_ENTITY]); |
||
65 | 6878 | if ($through !== $role) { |
|
66 | 6878 | continue; |
|
67 | 6878 | } |
|
68 | 5412 | $handshake = $item[Relation::SCHEMA][Relation::INVERSION] ?? null; |
|
69 | 4534 | $target = $item[Relation::TARGET]; |
|
70 | 5236 | $result[$roleName][$relName] = [ |
|
71 | 936 | Relation::TYPE => Relation::HAS_MANY, |
|
72 | 936 | Relation::TARGET => $role, |
|
73 | 902 | Relation::SCHEMA => [ |
|
74 | Relation::CASCADE => $item[Relation::SCHEMA][Relation::CASCADE] ?? null, |
||
75 | 866 | Relation::INNER_KEY => $item[Relation::SCHEMA][Relation::INNER_KEY], |
|
76 | 866 | Relation::OUTER_KEY => $item[Relation::SCHEMA][Relation::THROUGH_INNER_KEY], |
|
77 | 866 | ], |
|
78 | ]; |
||
79 | $result[$target][$handshake ?? ($roleName . '.' . $relName . ':' . $target)] = [ |
||
80 | Relation::TYPE => Relation::HAS_MANY, |
||
81 | Relation::TARGET => $role, |
||
82 | Relation::SCHEMA => [ |
||
83 | Relation::CASCADE => $item[Relation::SCHEMA][Relation::CASCADE] ?? null, |
||
84 | Relation::INNER_KEY => $item[Relation::SCHEMA][Relation::OUTER_KEY], |
||
85 | Relation::OUTER_KEY => $item[Relation::SCHEMA][Relation::THROUGH_OUTER_KEY], |
||
86 | 866 | ], |
|
87 | ]; |
||
88 | } |
||
89 | } |
||
90 | } |
||
91 | return $result; |
||
92 | } |
||
93 | |||
94 | /** |
||
95 | * @return array [relation name => relation schema] |
||
96 | */ |
||
97 | public function getInnerRelations(string $role): array |
||
98 | 6878 | { |
|
99 | return $this->schema[$role][SchemaInterface::RELATIONS] ?? []; |
||
100 | } |
||
101 | |||
102 | public function defines(string $role): bool |
||
103 | { |
||
104 | 6878 | return isset($this->schema[$role]) || isset($this->aliases[$role]); |
|
105 | } |
||
106 | 6878 | ||
107 | public function define(string $role, int $property): mixed |
||
108 | { |
||
109 | 1582 | if ($property === SchemaInterface::ENTITY) { |
|
110 | return $this->defineEntityClass($role); |
||
111 | 1582 | } |
|
112 | $role = $this->resolveAlias($role) ?? $role; |
||
113 | |||
114 | 7232 | if (!isset($this->schema[$role])) { |
|
115 | throw new SchemaException("Undefined schema `{$role}`, not found."); |
||
116 | 7232 | } |
|
117 | 6088 | ||
118 | return $this->schema[$role][$property] ?? null; |
||
119 | 7226 | } |
|
120 | |||
121 | 7226 | public function defineRelation(string $role, string $relation): array |
|
122 | { |
||
123 | $relations = $this->define($role, self::RELATIONS); |
||
124 | |||
125 | 7226 | if (!isset($relations[$relation])) { |
|
126 | throw new SchemaException("Undefined relation `{$role}`.`{$relation}`."); |
||
127 | } |
||
128 | 5524 | ||
129 | return $relations[$relation]; |
||
130 | 5524 | } |
|
131 | |||
132 | 5524 | public function resolveAlias(string $role): ?string |
|
133 | 64 | { |
|
134 | // walk through all children until parent entity found |
||
135 | $found = $this->aliases[$role] ?? null; |
||
136 | 5524 | while ($found !== null && $found !== $role) { |
|
137 | $role = $found; |
||
138 | $found = $this->aliases[$found] ?? null; |
||
139 | 7232 | } |
|
140 | |||
141 | return $role; |
||
142 | 7232 | } |
|
143 | 7232 | ||
144 | 5786 | public function getInheritedRoles(string $parent): array |
|
145 | 5786 | { |
|
146 | return $this->subclasses[$parent] ?? []; |
||
147 | } |
||
148 | 7232 | ||
149 | public static function __set_state(array $an_array): self |
||
150 | { |
||
151 | 6834 | $schema = new self([]); |
|
152 | $schema->schema = $an_array['schema']; |
||
153 | 6834 | $schema->aliases = $an_array['aliases']; |
|
154 | |||
155 | return $schema; |
||
156 | } |
||
157 | |||
158 | /** |
||
159 | * Automatically replace class names with their aliases. |
||
160 | * |
||
161 | 7428 | * @return array Pair of [schema, aliases] |
|
162 | */ |
||
163 | 7428 | private function normalize(array $schema): array |
|
164 | { |
||
165 | 7428 | $result = $aliases = []; |
|
166 | 7258 | ||
167 | 7258 | foreach ($schema as $key => $item) { |
|
168 | $role = $key; |
||
169 | 6802 | if (!isset($item[self::ENTITY])) { |
|
170 | // legacy type of declaration (class => schema) |
||
171 | $item[self::ENTITY] = $key; |
||
172 | 7258 | } |
|
173 | 6026 | ||
174 | 6026 | if (\class_exists($key)) { |
|
175 | 6010 | $role = $item[self::ROLE] ?? $key; |
|
176 | if ($role !== $key) { |
||
177 | $aliases[$key] = $role; |
||
178 | } |
||
179 | 7258 | } |
|
180 | 6478 | ||
181 | 6478 | if ($item[self::ENTITY] !== $role && \class_exists($item[self::ENTITY])) { |
|
182 | $aliases[$item[self::ENTITY]] = $role; |
||
183 | $this->classes[$role] = $item[self::ENTITY]; |
||
184 | 7258 | } |
|
185 | 7258 | ||
186 | unset($item[self::ROLE]); |
||
187 | $result[$role] = $item; |
||
188 | } |
||
189 | 7428 | ||
190 | 7258 | // Normalize PARENT option |
|
191 | 1076 | foreach ($result as $role => &$item) { |
|
192 | 312 | if (isset($item[self::PARENT])) { |
|
193 | 312 | if (\class_exists($item[self::PARENT])) { |
|
194 | 312 | $parent = $item[self::PARENT]; |
|
195 | while (isset($aliases[$parent])) { |
||
196 | 312 | $parent = $aliases[$parent]; |
|
197 | } |
||
198 | 1076 | $item[self::PARENT] = $parent; |
|
199 | 1076 | } |
|
200 | $this->subclasses[$role] ??= []; |
||
201 | $this->subclasses[$item[self::PARENT]][$role] = &$this->subclasses[$role]; |
||
202 | 7428 | } |
|
203 | } |
||
204 | unset($item); |
||
205 | 7428 | ||
206 | 7258 | // Extract aliases from CHILDREN options |
|
207 | 416 | foreach ($result as $role => $item) { |
|
208 | 416 | if (isset($item[self::CHILDREN])) { |
|
209 | 416 | foreach ($item[self::CHILDREN] as $child) { |
|
210 | if (isset($aliases[$child]) && \class_exists($child)) { |
||
211 | 416 | $aliases[$aliases[$child]] = $role; |
|
212 | } |
||
213 | $aliases[$child] = $role; |
||
214 | } |
||
215 | } |
||
216 | } |
||
217 | 7428 | ||
218 | 7258 | // Normalize relation associations |
|
219 | 7248 | foreach ($result as &$item) { |
|
220 | 7248 | if (isset($item[self::RELATIONS])) { |
|
221 | $item[self::RELATIONS] = \iterator_to_array($this->normalizeRelations( |
||
222 | $item[self::RELATIONS], |
||
223 | $aliases, |
||
224 | )); |
||
225 | 7428 | } |
|
226 | } |
||
227 | 7428 | unset($item); |
|
228 | |||
229 | 7428 | $result = $this->linkRelations($result, $aliases); |
|
230 | |||
231 | return [$result, $aliases]; |
||
232 | 7248 | } |
|
233 | |||
234 | 7248 | private function normalizeRelations(array $relations, array $aliases): \Generator |
|
235 | 5628 | { |
|
236 | 5628 | foreach ($relations as $name => &$rel) { |
|
237 | 4306 | $target = $rel[Relation::TARGET]; |
|
238 | while (isset($aliases[$target])) { |
||
239 | $target = $aliases[$target]; |
||
240 | 5628 | } |
|
241 | |||
242 | 5628 | $rel[Relation::TARGET] = $target; |
|
243 | |||
244 | 5628 | $nullable = $rel[Relation::SCHEMA][Relation::NULLABLE] ?? null; |
|
245 | 16 | // Transform not nullable RefersTo to BelongsTo |
|
246 | if ($rel[Relation::TYPE] === Relation::REFERS_TO && $nullable === false) { |
||
247 | $rel[Relation::TYPE] = Relation::BELONGS_TO; |
||
248 | } |
||
249 | 5628 | ||
250 | 1008 | // Normalize THROUGH_ENTITY value |
|
251 | 1008 | if ($rel[Relation::TYPE] === Relation::MANY_TO_MANY) { |
|
252 | 784 | $through = $rel[Relation::SCHEMA][Relation::THROUGH_ENTITY]; |
|
253 | while (isset($aliases[$through])) { |
||
254 | 1008 | $through = $aliases[$through]; |
|
255 | } |
||
256 | $rel[Relation::SCHEMA][Relation::THROUGH_ENTITY] = $through; |
||
257 | 5628 | } |
|
258 | |||
259 | yield $name => $rel; |
||
260 | } |
||
261 | 7428 | } |
|
262 | |||
263 | 7428 | private function linkRelations(array $schemaArray, array $aliases): array |
|
264 | 7428 | { |
|
265 | 7258 | $result = $schemaArray; |
|
266 | 5628 | foreach ($result as $role => $item) { |
|
267 | 5628 | foreach ($item[self::RELATIONS] ?? [] as $container => $relation) { |
|
268 | 98 | $target = $relation[Relation::TARGET]; |
|
269 | if (!\array_key_exists($target, $result)) { |
||
270 | 5626 | continue; |
|
271 | 5626 | } |
|
272 | 5626 | $targetSchema = $result[$target]; |
|
273 | 5626 | $targetRelations = $targetSchema[self::RELATIONS] ?? []; |
|
274 | 40 | $inversion = $relation[Relation::SCHEMA][Relation::INVERSION] ?? null; |
|
275 | if ($inversion !== null) { |
||
276 | if (!\array_key_exists($inversion, $targetRelations)) { |
||
277 | throw new SchemaException( |
||
278 | \sprintf( |
||
279 | 'Relation `%s` as inversion of `%s.%s` not found in the `%s` role.', |
||
280 | $inversion, |
||
281 | $role, |
||
282 | $container, |
||
283 | $target, |
||
284 | ), |
||
285 | 40 | ); |
|
286 | 40 | } |
|
287 | $targetHandshake = $targetRelations[$inversion][Relation::SCHEMA][Relation::INVERSION] ?? null; |
||
288 | if ($targetHandshake !== null && $container !== $targetHandshake) { |
||
289 | throw new SchemaException( |
||
290 | \sprintf( |
||
291 | 'Relation `%s.%s` can\'t be inversion of `%s.%s` because they have different relation values.', |
||
292 | $role, |
||
293 | $container, |
||
294 | $target, |
||
295 | $inversion, |
||
296 | ), |
||
297 | 40 | ); |
|
298 | 40 | } |
|
299 | $result[$target][self::RELATIONS][$inversion][Relation::SCHEMA][Relation::INVERSION] = $container; |
||
300 | continue; |
||
301 | 5586 | } |
|
302 | 5586 | // find inverted relation |
|
303 | 5586 | $inversion = $this->findInvertedRelation($role, $container, $relation, $targetRelations); |
|
304 | if ($inversion === null) { |
||
305 | 520 | continue; |
|
306 | 520 | } |
|
307 | $result[$role][self::RELATIONS][$container][Relation::SCHEMA][Relation::INVERSION] = $inversion; |
||
308 | $result[$target][self::RELATIONS][$inversion][Relation::SCHEMA][Relation::INVERSION] = $container; |
||
309 | } |
||
310 | 7428 | } |
|
311 | |||
312 | return $result; |
||
313 | 5586 | } |
|
314 | |||
315 | private function findInvertedRelation( |
||
316 | string $role, |
||
317 | string $container, |
||
318 | array $relation, |
||
319 | 5586 | array $targetRelations, |
|
320 | ): ?string { |
||
321 | 5586 | $nullable = $relation[Relation::SCHEMA][Relation::NULLABLE] ?? null; |
|
0 ignored issues
–
show
Unused Code
introduced
by
![]() |
|||
322 | 968 | /** @var callable $compareCallback */ |
|
323 | 496 | $compareCallback = match ($relation[Relation::TYPE]) { |
|
324 | Relation::MANY_TO_MANY => [$this, 'compareManyToMany'], |
||
325 | 2205 | Relation::BELONGS_TO => [$this, 'checkBelongsToInversion'], |
|
326 | // Relation::HAS_ONE, Relation::HAS_MANY => $nullable === true ? Relation::REFERS_TO : Relation::BELONGS_TO, |
||
327 | 5586 | default => null, |
|
328 | 4410 | }; |
|
329 | if ($compareCallback === null) { |
||
0 ignored issues
–
show
|
|||
330 | 1760 | return null; |
|
331 | 904 | } |
|
332 | 904 | foreach ($targetRelations as $targetContainer => $targetRelation) { |
|
333 | 704 | $targetSchema = $targetRelation[Relation::SCHEMA]; |
|
334 | if ($role !== $targetRelation[Relation::TARGET]) { |
||
335 | 520 | continue; |
|
336 | 216 | } |
|
337 | if (isset($targetSchema[Relation::INVERSION])) { |
||
338 | 216 | if ($targetSchema[Relation::INVERSION] === $container) { |
|
339 | // This target relation will be checked in the linkRelations() method |
||
340 | 48 | return null; |
|
341 | } |
||
342 | 520 | continue; |
|
343 | 520 | } |
|
344 | if ($compareCallback($relation, $targetRelation)) { |
||
345 | return $targetContainer; |
||
346 | 1328 | } |
|
347 | } |
||
348 | return null; |
||
349 | 216 | } |
|
350 | |||
351 | 216 | private function compareManyToMany(array $relation, array $targetRelation): bool |
|
352 | 216 | { |
|
353 | $schema = $relation[Relation::SCHEMA]; |
||
354 | 216 | $targetSchema = $targetRelation[Relation::SCHEMA]; |
|
355 | 48 | // MTM connects with MTM only |
|
356 | if ($targetRelation[Relation::TYPE] !== Relation::MANY_TO_MANY) { |
||
357 | return false; |
||
358 | 216 | } |
|
359 | // Pivot entity should be same |
||
360 | if ($schema[Relation::THROUGH_ENTITY] !== $targetSchema[Relation::THROUGH_ENTITY]) { |
||
361 | return false; |
||
362 | 216 | } |
|
363 | 216 | // Same keys |
|
364 | if ((array) $schema[Relation::INNER_KEY] !== (array) $targetSchema[Relation::OUTER_KEY] |
||
365 | || (array) $schema[Relation::OUTER_KEY] !== (array) $targetSchema[Relation::INNER_KEY]) { |
||
366 | return false; |
||
367 | 216 | } |
|
368 | 216 | // Optional fields |
|
369 | return !(($schema[Relation::WHERE] ?? []) !== ($targetSchema[Relation::WHERE] ?? []) |
||
370 | || ($schema[Relation::THROUGH_WHERE] ?? []) !== ($targetSchema[Relation::THROUGH_WHERE] ?? [])); |
||
371 | 456 | } |
|
372 | |||
373 | 456 | private function checkBelongsToInversion(array $relation, array $targetRelation): bool |
|
374 | 456 | { |
|
375 | $schema = $relation[Relation::SCHEMA]; |
||
376 | 456 | $targetSchema = $targetRelation[Relation::SCHEMA]; |
|
377 | 56 | // MTM connects with MTM only |
|
378 | if (!\in_array($targetRelation[Relation::TYPE], [Relation::HAS_MANY, Relation::HAS_ONE], true)) { |
||
379 | return false; |
||
380 | 456 | } |
|
381 | 456 | // Same keys |
|
382 | if ((array) $schema[Relation::INNER_KEY] !== (array) $targetSchema[Relation::OUTER_KEY] |
||
383 | || (array) $schema[Relation::OUTER_KEY] !== (array) $targetSchema[Relation::INNER_KEY]) { |
||
384 | return false; |
||
385 | 456 | } |
|
386 | // Optional fields |
||
387 | return empty($schema[Relation::WHERE]) && empty($targetSchema[Relation::WHERE]); |
||
388 | } |
||
389 | |||
390 | /** |
||
391 | 6088 | * @psalm-return null|class-string |
|
392 | */ |
||
393 | 6088 | private function defineEntityClass(string $role): ?string |
|
394 | 6070 | { |
|
395 | if (\array_key_exists($role, $this->classes)) { |
||
396 | 28 | return $this->classes[$role]; |
|
397 | 28 | } |
|
398 | 26 | $rr = $this->resolveAlias($role) ?? $role; |
|
399 | 28 | return $this->classes[$rr] |
|
400 | ?? $this->schema[$rr][self::ENTITY] |
||
401 | ?? throw new SchemaException("Undefined schema `{$role}`, not found."); |
||
402 | } |
||
403 | } |
||
404 |