Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
62.20% |
102 / 164 |
|
35.71% |
5 / 14 |
CRAP | |
0.00% |
0 / 1 |
GenericRepository | |
62.20% |
102 / 164 |
|
35.71% |
5 / 14 |
397.35 | |
0.00% |
0 / 1 |
__construct | |
76.19% |
48 / 63 |
|
0.00% |
0 / 1 |
43.97 | |||
getNew | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
find | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
findOneBy | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
findAll | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
findBy | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
10 | |||
convertArrayToEntity | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
5.01 | |||
delete | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
save | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
executeSave | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
240 | |||
getPropertiesFromEntity | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getPropertyFromEntity | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
setPropertyToEntity | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
toSnakeCase | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace Miniframe\ORM\Repository; |
4 | |
5 | use Miniframe\Annotation\Service\AnnotationReader; |
6 | use Miniframe\ORM\Annotation\Column; |
7 | use Miniframe\ORM\Annotation\OneToMany; |
8 | use Miniframe\ORM\Annotation\Table; |
9 | use Miniframe\ORM\Engine\EngineInterface; |
10 | use Miniframe\ORM\Exception\OrmException; |
11 | use Miniframe\ORM\Collection\OneToMany as OneToManyCollection; |
12 | |
13 | class GenericRepository |
14 | { |
15 | /** |
16 | * Name of the database table |
17 | * |
18 | * @var Table |
19 | */ |
20 | protected $table; |
21 | |
22 | /** |
23 | * All table columns |
24 | * |
25 | * @var Column[] |
26 | */ |
27 | protected $tableColumns = array(); |
28 | |
29 | /** |
30 | * All primary key columns |
31 | * |
32 | * @var Column[] |
33 | */ |
34 | protected $primaryKey = array(); |
35 | |
36 | /** |
37 | * All one to many relationships |
38 | * |
39 | * @var OneToMany[] |
40 | */ |
41 | protected $oneToMany = array(); |
42 | |
43 | /** |
44 | * The auto increment column, when it exists |
45 | * |
46 | * @var Column|null |
47 | */ |
48 | protected $autoIncrement; |
49 | |
50 | /** |
51 | * Fully qualified classname for the entity objects |
52 | * |
53 | * @var string |
54 | */ |
55 | protected $entityClassName; |
56 | |
57 | /** |
58 | * Reference to the database engine |
59 | * |
60 | * @var EngineInterface |
61 | */ |
62 | protected $engine; |
63 | |
64 | /** |
65 | * Reference to the reflection class for the current entity |
66 | * |
67 | * @var \ReflectionClass |
68 | */ |
69 | protected $reflectionClass; |
70 | |
71 | /** |
72 | * Initializes a new data repository |
73 | * |
74 | * @param EngineInterface $engine Reference to the database engine. |
75 | * @param string $entityClassName Fully qualified classname for the related entity object. |
76 | */ |
77 | public function __construct(EngineInterface $engine, string $entityClassName) |
78 | { |
79 | $this->entityClassName = $entityClassName; |
80 | $this->engine = $engine; |
81 | $this->reflectionClass = new \ReflectionClass($entityClassName); |
82 | |
83 | // Validate entity class |
84 | $tableData = (new AnnotationReader())->getClass($this->reflectionClass); |
85 | if (!$tableData->getAnnotation(Table::class)) { |
86 | throw new \RuntimeException($entityClassName . ' doesn\'t have a @Table annotation'); |
87 | } |
88 | |
89 | // Determine table name |
90 | $this->table = $tableData->getAnnotation(Table::class); |
91 | if (!$this->table->name) { |
92 | $this->table->name = $this->toSnakeCase($tableData->getShortClassName()); |
93 | } |
94 | |
95 | $properties = $tableData->getProperties(); |
96 | foreach ($properties as $propertyName => $propertyData) { |
97 | // Determine one to many joins |
98 | $oneToMany = $propertyData->getAnnotation(OneToMany::class); |
99 | if ($oneToMany) { |
100 | if ($oneToMany->localJoinProperty === null && in_array('id', array_keys($properties))) { |
101 | $oneToMany->localJoinProperty = 'id'; |
102 | } elseif ($oneToMany->localJoinProperty === null) { |
103 | throw new \RuntimeException('Please configure a localJoinProperty for a oneToMany join'); |
104 | } |
105 | if ($oneToMany->remoteJoinProperty === null) { |
106 | $oneToMany->remoteJoinProperty = $entityClassName; |
107 | } |
108 | if (!class_exists($oneToMany->remoteEntityClass)) { |
109 | $ns = substr($entityClassName, 0, strrpos($entityClassName, '\\')); |
110 | $oneToMany->remoteEntityClass = $ns . '\\' . $oneToMany->remoteEntityClass; |
111 | } |
112 | if (!class_exists($oneToMany->remoteEntityClass)) { |
113 | throw new \RuntimeException('Cannot resolve remoteEntityClass: ' . $oneToMany->remoteEntityClass); |
114 | } |
115 | $this->oneToMany[$propertyName] = $oneToMany; |
116 | } |
117 | |
118 | // Determine columns |
119 | $columnData = $propertyData->getAnnotation(Column::class); |
120 | if (!$columnData) { |
121 | continue; |
122 | } |
123 | // Populate column data |
124 | if (!$columnData->name) { |
125 | $columnData->name = $this->toSnakeCase($propertyData->getName()); |
126 | } |
127 | if (!$columnData->type && $propertyData->getAnnotation('var')) { |
128 | switch (array_reverse(explode('|', $propertyData->getAnnotation('var')->value))[0]) { |
129 | case 'int': |
130 | $columnData->type = 'int'; |
131 | break; |
132 | case 'bool': |
133 | case 'boolean': |
134 | $columnData->type = 'int'; |
135 | $columnData->length = 1; |
136 | break; |
137 | case '\DateTime': |
138 | $columnData->type = 'datetime'; |
139 | break; |
140 | default: |
141 | $columnData->type = 'varchar'; |
142 | } |
143 | } elseif (!$columnData->type) { |
144 | $columnData->type = 'varchar'; |
145 | } |
146 | if (!$columnData->length && $columnData->type == 'varchar') { |
147 | $columnData->length = 255; |
148 | } |
149 | $this->tableColumns[$propertyName] = $columnData; |
150 | if ($columnData->primaryKey) { |
151 | $this->primaryKey[$propertyName] = $columnData; |
152 | } |
153 | if ($columnData->autoIncrement && $this->autoIncrement) { |
154 | throw new \RuntimeException($entityClassName . ' has more then 1 auto increment values'); |
155 | } elseif ($columnData->autoIncrement) { |
156 | $this->autoIncrement = $columnData; |
157 | } |
158 | } |
159 | |
160 | // Default primary key to 'id' when possible |
161 | if (count($this->primaryKey) == null && isset($this->tableColumns['id'])) { |
162 | $this->tableColumns['id']->primaryKey = true; |
163 | $this->primaryKey[] = $this->tableColumns['id']; |
164 | } |
165 | |
166 | // Table requirements |
167 | if (count($this->tableColumns) === 0) { |
168 | throw new \RuntimeException('No @Column annotations found in ' . $entityClassName); |
169 | } |
170 | if (count($this->primaryKey) === 0) { |
171 | throw new \RuntimeException('No primary key defined in ' . $entityClassName); |
172 | } |
173 | |
174 | // Prepare table |
175 | $engine->prepareTable($this->table, $this->tableColumns); |
176 | } |
177 | |
178 | /** |
179 | * Returns a new, empty, model |
180 | * |
181 | * @return object |
182 | */ |
183 | public function getNew() |
184 | { |
185 | return new $this->entityClassName(); |
186 | } |
187 | |
188 | /** |
189 | * Returns a single entity matching the primary key to a specific value. |
190 | * |
191 | * Only works when there's a single column defined as primary key. |
192 | * |
193 | * @param integer|string $identifier The value of the primary key. |
194 | * |
195 | * @return object|null |
196 | */ |
197 | public function find($identifier) |
198 | { |
199 | if (count($this->primaryKey) !== 1) { |
200 | throw new \RuntimeException('find() only works when there\'s one primary key column'); |
201 | } |
202 | return $this->findOneBy([array_key_first($this->primaryKey) => $identifier]); |
203 | } |
204 | |
205 | /** |
206 | * Returns a single entity matching a set of properties. |
207 | * |
208 | * @param array $properties The list of properties. |
209 | * |
210 | * @return object|null |
211 | */ |
212 | public function findOneBy(array $properties) |
213 | { |
214 | $result = $this->findBy($properties, 0, 1); |
215 | return $result[0] ?? null; |
216 | } |
217 | |
218 | /** |
219 | * Returns a set of all entities |
220 | * |
221 | * @param integer $start The offset. |
222 | * @param integer|null $limit The max. amount of entities to return. |
223 | * |
224 | * @return object[] |
225 | */ |
226 | public function findAll(int $start = 0, ?int $limit = null): array |
227 | { |
228 | return $this->findBy([], $start, $limit); |
229 | } |
230 | |
231 | /** |
232 | * Returns a set of entities matching specific properties |
233 | * |
234 | * @param array $properties The list of properties. |
235 | * @param integer $start The offset. |
236 | * @param integer|null $limit The max. amount of entities to return. |
237 | * |
238 | * @return object[] |
239 | */ |
240 | public function findBy(array $properties, int $start = 0, ?int $limit = null): array |
241 | { |
242 | $parameters = array(); |
243 | $query = 'SELECT '; |
244 | foreach ($this->tableColumns as $propertyName => $column) { |
245 | $query .= '`' . $column->name . '`, '; |
246 | } |
247 | $query = substr($query, 0, -2) . ' FROM `' . $this->table->name . '` WHERE'; |
248 | foreach ($properties as $property => $value) { |
249 | $column = $this->tableColumns[$property]; |
250 | $query .= ' `' . $column->name . '` = :' . $column->name . ' AND '; |
251 | $parameters[$column->name] = $value; |
252 | } |
253 | $query = substr($query, 0, -5); // Removes 'WHERE' or ' AND ', depending if there are properties |
254 | |
255 | if ($start > 0 && $limit === null) { |
256 | $query .= ' OFFSET ' . $start; |
257 | } elseif ($start === 0 && $limit !== null) { |
258 | $query .= ' LIMIT ' . $limit; |
259 | } elseif ($start > 0 && $limit !== null) { |
260 | $query .= ' LIMIT ' . $start . ', ' . $limit; |
261 | } |
262 | |
263 | $result = $this->engine->fetch($query, $parameters); |
264 | |
265 | // Create dummies for the oneToMany relations |
266 | $return = array(); |
267 | foreach ($result as $entityData) { |
268 | $return[] = $this->convertArrayToEntity($entityData); |
269 | } |
270 | |
271 | return $return; |
272 | } |
273 | |
274 | /** |
275 | * Converts an associative array to a model class |
276 | * |
277 | * @param array $entityData The associative array. |
278 | * |
279 | * @return mixed |
280 | */ |
281 | protected function convertArrayToEntity(array $entityData) |
282 | { |
283 | $entity = new $this->entityClassName(); |
284 | foreach ($this->tableColumns as $propertyName => $columnData) { |
285 | if ($columnData->type == 'datetime') { |
286 | $entityData[$columnData->name] = new \DateTime($entityData[$columnData->name]); |
287 | } elseif (in_array($columnData->type, ['bool', 'boolean'])) { |
288 | $entityData[$columnData->name] = $entityData[$columnData->name] ?? false; |
289 | } |
290 | $this->setPropertyToEntity($entity, $propertyName, $entityData[$columnData->name]); |
291 | } |
292 | foreach ($this->oneToMany as $propertyName => $oneToMany) { |
293 | $this->setPropertyToEntity($entity, $propertyName, new OneToManyCollection( |
294 | $oneToMany->remoteEntityClass, |
295 | $oneToMany->remoteJoinProperty, |
296 | $this->getPropertyFromEntity($entity, $oneToMany->localJoinProperty) |
297 | )); |
298 | } |
299 | return $entity; |
300 | } |
301 | |
302 | /** |
303 | * Removes an entity |
304 | * |
305 | * @param object $object The entity. |
306 | * |
307 | * @return void |
308 | */ |
309 | public function delete(object $object): void |
310 | { |
311 | $properties = $this->getPropertiesFromEntity($object); |
312 | |
313 | $parameters = array(); |
314 | $query = 'DELETE FROM `' . $this->table->name . '` WHERE '; |
315 | foreach ($this->primaryKey as $column) { |
316 | $query .= '`' . $column->name . '` = :' . $column->name . ' AND '; |
317 | $parameters[$column->name] = $properties[$column->name]; |
318 | } |
319 | $query = substr($query, 0, -4) . ' LIMIT 1;'; |
320 | |
321 | $this->engine->query($query, $parameters); |
322 | } |
323 | |
324 | /** |
325 | * Saves an entity |
326 | * |
327 | * @param object $object The entity. |
328 | * |
329 | * @return void |
330 | */ |
331 | public function save(object $object): void |
332 | { |
333 | if (!is_a($object, $this->entityClassName)) { |
334 | throw new \RuntimeException('Invalid repository for ' . get_class($object)); |
335 | } |
336 | |
337 | $this->executeSave($object); |
338 | } |
339 | |
340 | /** |
341 | * Executes a save call |
342 | * |
343 | * @param object $object The object. |
344 | * @param boolean $insert True for INSERT, false for UPDATE. |
345 | * |
346 | * @return void |
347 | */ |
348 | protected function executeSave(object $object, bool $insert = false): void |
349 | { |
350 | $properties = $this->getPropertiesFromEntity($object); |
351 | |
352 | if ($insert) { |
353 | $query = 'INSERT INTO `' . $this->table->name . '` SET '; |
354 | } else { |
355 | $query = "UPDATE `" . $this->table->name . '`SET '; |
356 | } |
357 | $parameters = array(); |
358 | foreach ($properties as $propertyName => $propertyValue) { |
359 | if ( |
360 | isset($this->autoIncrement->name) |
361 | && $propertyName === $this->autoIncrement->name |
362 | && $propertyValue === null |
363 | ) { |
364 | continue; |
365 | } |
366 | $query .= '`' . $propertyName . '` = :' . $propertyName . ', '; |
367 | $parameters[$propertyName] = $propertyValue; |
368 | } |
369 | $query = substr($query, 0, -2); |
370 | |
371 | if (!$insert) { |
372 | $query .= ' WHERE '; |
373 | foreach ($this->primaryKey as $primaryKey) { |
374 | $query .= '`' . $primaryKey->name . '` = :' . $primaryKey->name . ' AND '; |
375 | $parameters[$primaryKey->name] = $properties[$primaryKey->name]; |
376 | } |
377 | $query = substr($query, 0, -4); |
378 | } |
379 | |
380 | try { |
381 | $result = $this->engine->query($query, $parameters); |
382 | } catch (OrmException $exception) { |
383 | // When inserting, apparently the UPDATE gave 0 results, so no changes |
384 | if ($insert && $exception->getCode() == OrmException::DUPLICATE_KEY) { |
385 | return; |
386 | } |
387 | throw $exception; |
388 | } |
389 | |
390 | if ($insert && $this->autoIncrement !== null) { |
391 | $this->setPropertyToEntity($object, $this->autoIncrement->name, $result); |
392 | } |
393 | if (!$insert && $result === 0) { |
394 | $this->executeSave($object, true); |
395 | } |
396 | } |
397 | |
398 | /** |
399 | * Fetches all properties from an object and returns an array with columnName => value |
400 | * |
401 | * @param object $object The object. |
402 | * |
403 | * @return array |
404 | */ |
405 | protected function getPropertiesFromEntity(object $object): array |
406 | { |
407 | $return = array(); |
408 | foreach ($this->tableColumns as $propertyName => $columnData) { |
409 | $return[$columnData->name] = $this->getPropertyFromEntity($object, $propertyName); |
410 | } |
411 | return $return; |
412 | } |
413 | |
414 | /** |
415 | * Fetches all properties from an object and returns an array with columnName => value |
416 | * |
417 | * @param object $object The object. |
418 | * @param string $propertyName The property name. |
419 | * |
420 | * @return mixed |
421 | */ |
422 | protected function getPropertyFromEntity(object $object, string $propertyName) |
423 | { |
424 | $pc = $this->reflectionClass->getProperty($propertyName); |
425 | if (!$pc->isPublic()) { |
426 | $pc->setAccessible(true); |
427 | $return = $pc->getValue($object); |
428 | $pc->setAccessible(false); |
429 | } else { |
430 | $return = $object->$propertyName; |
431 | } |
432 | |
433 | return $return; |
434 | } |
435 | |
436 | /** |
437 | * Sets a specific value in an object |
438 | * |
439 | * @param object $object The object. |
440 | * @param string $propertyName The property name. |
441 | * @param mixed $value The new value. |
442 | * |
443 | * @return void |
444 | */ |
445 | protected function setPropertyToEntity(object $object, string $propertyName, $value): void |
446 | { |
447 | $pc = $this->reflectionClass->getProperty($propertyName); |
448 | if (!$pc->isPublic()) { |
449 | $pc->setAccessible(true); |
450 | $pc->setValue($object, $value); |
451 | $pc->setAccessible(false); |
452 | } else { |
453 | $object->{$propertyName} = $value; |
454 | } |
455 | } |
456 | |
457 | /** |
458 | * Converts PascalCase, camelCase and kebab-case to snake_case |
459 | * |
460 | * @param string $input The input string. |
461 | * |
462 | * @return string |
463 | */ |
464 | protected function toSnakeCase(string $input): string |
465 | { |
466 | // Convert PascalCase to camelCase |
467 | $input = lcfirst($input); |
468 | // Convert kebab-case to snake_case |
469 | $input = str_replace('-', '_', $input); |
470 | // Convert camelCase to snake_case |
471 | $input = strtolower(preg_replace('/(?<!^)[A-Z]/u', '_$0', $input)); |
472 | |
473 | return $input; |
474 | } |
475 | } |