Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.20% covered (warning)
62.20%
102 / 164
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
GenericRepository
62.20% covered (warning)
62.20%
102 / 164
35.71% covered (danger)
35.71%
5 / 14
397.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
76.19% covered (warning)
76.19%
48 / 63
0.00% covered (danger)
0.00%
0 / 1
43.97
 getNew
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 find
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 findOneBy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 findAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findBy
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 convertArrayToEntity
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 delete
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 save
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 executeSave
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
240
 getPropertiesFromEntity
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getPropertyFromEntity
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 setPropertyToEntity
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 toSnakeCase
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Miniframe\ORM\Repository;
4
5use Miniframe\Annotation\Service\AnnotationReader;
6use Miniframe\ORM\Annotation\Column;
7use Miniframe\ORM\Annotation\OneToMany;
8use Miniframe\ORM\Annotation\Table;
9use Miniframe\ORM\Engine\EngineInterface;
10use Miniframe\ORM\Exception\OrmException;
11use Miniframe\ORM\Collection\OneToMany as OneToManyCollection;
12
13class 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}