Passed
Branch feature-validator (0d7506)
by Thomas
02:52
created

EntityManager::getInstanceByParent()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 16
ccs 8
cts 8
cp 1
rs 9.2
c 0
b 0
f 0
cc 4
eloc 8
nc 4
nop 1
crap 4
1
<?php
2
3
namespace ORM;
4
5
use ORM\Dbal\Dbal;
6
use ORM\Dbal\Column;
7
use ORM\Dbal\Other;
8
use ORM\Exceptions\IncompletePrimaryKey;
9
use ORM\Exceptions\InvalidConfiguration;
10
use ORM\Exceptions\NoConnection;
11
use ORM\Exceptions\NoEntity;
12
use ORM\Exceptions\NotScalar;
13
use ORM\Exceptions\UnsupportedDriver;
14
15
/**
16
 * The EntityManager that manages the instances of Entities.
17
 *
18
 * @package ORM
19
 * @author Thomas Flori <[email protected]>
20
 */
21
class EntityManager
22
{
23
    const OPT_CONNECTION             = 'connection';
24
    const OPT_TABLE_NAME_TEMPLATE    = 'tableNameTemplate';
25
    const OPT_NAMING_SCHEME_TABLE    = 'namingSchemeTable';
26
    const OPT_NAMING_SCHEME_COLUMN   = 'namingSchemeColumn';
27
    const OPT_NAMING_SCHEME_METHODS  = 'namingSchemeMethods';
28
29
    /** @deprecated */
30
    const OPT_MYSQL_BOOLEAN_TRUE     = 'mysqlTrue';
31
    /** @deprecated */
32
    const OPT_MYSQL_BOOLEAN_FALSE    = 'mysqlFalse';
33
    /** @deprecated */
34
    const OPT_SQLITE_BOOLEAN_TRUE    = 'sqliteTrue';
35
    /** @deprecated */
36
    const OPT_SQLITE_BOOLEAN_FASLE   = 'sqliteFalse';
37
    /** @deprecated */
38
    const OPT_PGSQL_BOOLEAN_TRUE     = 'pgsqlTrue';
39
    /** @deprecated */
40
    const OPT_PGSQL_BOOLEAN_FALSE    = 'pgsqlFalse';
41
    /** @deprecated */
42
    const OPT_QUOTING_CHARACTER      = 'quotingChar';
43
    /** @deprecated */
44
    const OPT_IDENTIFIER_DIVIDER     = 'identifierDivider';
45
46
    /** Connection to database
47
     * @var \PDO|callable|DbConfig */
48
    protected $connection;
49
50
    /** The Database Abstraction Layer
51
     * @var Dbal */
52
    protected $dbal;
53
54
    /** The Namer instance
55
     * @var Namer */
56
    protected $namer;
57
58
    /** The Entity map
59
     * @var Entity[][] */
60
    protected $map = [];
61
62
    /** The options set for this instance
63
     * @var array */
64
    protected $options = [];
65
66
    /** Already fetched column descriptions
67
     * @var Column[][] */
68
    protected $descriptions = [];
69
70
    /** Mapping for EntityManager instances
71
     * @var EntityManager[][] */
72
    protected static $emMapping = [
73
        'byClass' => [],
74
        'byNameSpace' => [],
75
        'byParent' => [],
76
        'last' => null,
77
    ];
78
79
    /**
80
     * Constructor
81
     *
82
     * @param array $options Options for the new EntityManager
83
     * @throws InvalidConfiguration
84
     */
85 723
    public function __construct($options = [])
86
    {
87 723
        foreach ($options as $option => $value) {
88 9
            $this->setOption($option, $value);
89
        }
90
91 723
        self::$emMapping['last'] = $this;
92 723
    }
93
94
    /**
95
     * Get an instance of the EntityManager.
96
     *
97
     * If no class is given it gets $class from backtrace.
98
     *
99
     * It first gets tries the EntityManager for the Namespace of $class, then for the parents of $class. If no
100
     * EntityManager is found it returns the last created EntityManager (null if no EntityManager got created).
101
     *
102
     * @param string $class
103
     * @return EntityManager
104
     */
105 223
    public static function getInstance($class = null)
106
    {
107 223
        if (!$class) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $class of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
108 2
            $trace = debug_backtrace();
109 2
            if (empty($trace[1]['class'])) {
110 1
                return self::$emMapping['last'];
0 ignored issues
show
Bug Best Practice introduced by
The return type of return self::$emMapping['last']; (ORM\EntityManager[]) is incompatible with the return type documented by ORM\EntityManager::getInstance of type ORM\EntityManager.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
111
            }
112 1
            $class = $trace[1]['class'];
113
        }
114
115 222
        if (!isset(self::$emMapping['byClass'][$class])) {
116 17
            if (!($em = self::getInstanceByNameSpace($class)) && !($em = self::getInstanceByParent($class))) {
117 15
                $em = self::$emMapping['last'];
118
            }
119
120 17
            self::$emMapping['byClass'][$class] = $em;
121
        }
122
123 222
        return self::$emMapping['byClass'][$class];
124
    }
125
126
    /**
127
     * Get the instance by NameSpace mapping
128
     *
129
     * @param $class
130
     * @return EntityManager
131
     */
132 17
    private static function getInstanceByNameSpace($class)
133
    {
134 17
        foreach (self::$emMapping['byNameSpace'] as $nameSpace => $em) {
135 3
            if (substr($class, 0, strlen($nameSpace)) === $nameSpace) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison === seems to always evaluate to false as the types of substr($class, 0, strlen($nameSpace)) (string) and $nameSpace (integer) can never be identical. Maybe you want to use a loose comparison == instead?
Loading history...
136 3
                return $em;
137
            }
138
        }
139
140 16
        return null;
141
    }
142
143
    /**
144
     * Get the instance by Parent class mapping
145
     *
146
     * @param $class
147
     * @return EntityManager
148
     */
149 16
    private static function getInstanceByParent($class)
150
    {
151
        // we don't need a reflection when we don't have mapping byParent
152 16
        if (empty(self::$emMapping['byParent'])) {
153 14
            return null;
154
        }
155
156 2
        $reflection = new \ReflectionClass($class);
157 2
        foreach (self::$emMapping['byParent'] as $parentClass => $em) {
158 2
            if ($reflection->isSubclassOf($parentClass)) {
159 2
                return $em;
160
            }
161
        }
162
163 1
        return null;
164
    }
165
166
    /**
167
     * Define $this EntityManager as the default EntityManager for $nameSpace
168
     *
169
     * @param $nameSpace
170
     * @return self
171
     */
172 2
    public function defineForNamespace($nameSpace)
173
    {
174 2
        self::$emMapping['byNameSpace'][$nameSpace] = $this;
175 2
        return $this;
176
    }
177
178
    /**
179
     * Define $this EntityManager as the default EntityManager for subClasses of $class
180
     *
181
     * @param $class
182
     * @return self
183
     */
184 2
    public function defineForParent($class)
185
    {
186 2
        self::$emMapping['byParent'][$class] = $this;
187 2
        return $this;
188
    }
189
190
    /**
191
     * Set $option to $value
192
     *
193
     * @param string $option One of OPT_* constants
194
     * @param mixed  $value
195
     * @return self
196
     */
197 13
    public function setOption($option, $value)
198
    {
199
        switch ($option) {
200 13
            case self::OPT_CONNECTION:
201 1
                $this->setConnection($value);
202 1
                break;
203
204 12
            case self::OPT_TABLE_NAME_TEMPLATE:
205 1
                Entity::setTableNameTemplate($value);
0 ignored issues
show
Deprecated Code introduced by
The method ORM\Entity::setTableNameTemplate() has been deprecated with message: use setOption from EntityManager

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
206 1
                break;
207
208 11
            case self::OPT_NAMING_SCHEME_TABLE:
209 1
                Entity::setNamingSchemeTable($value);
0 ignored issues
show
Deprecated Code introduced by
The method ORM\Entity::setNamingSchemeTable() has been deprecated with message: use setOption from EntityManager

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
210 1
                break;
211
212 10
            case self::OPT_NAMING_SCHEME_COLUMN:
213 1
                Entity::setNamingSchemeColumn($value);
0 ignored issues
show
Deprecated Code introduced by
The method ORM\Entity::setNamingSchemeColumn() has been deprecated with message: use setOption from EntityManager

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
214 1
                break;
215
216 9
            case self::OPT_NAMING_SCHEME_METHODS:
217 1
                Entity::setNamingSchemeMethods($value);
0 ignored issues
show
Deprecated Code introduced by
The method ORM\Entity::setNamingSchemeMethods() has been deprecated with message: use setOption from EntityManager

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
218 1
                break;
219
        }
220
221 13
        $this->options[$option] = $value;
222 13
        return $this;
223
    }
224
225
    /**
226
     * Get $option
227
     *
228
     * @param $option
229
     * @return mixed
230
     */
231 4
    public function getOption($option)
232
    {
233 4
        return isset($this->options[$option]) ? $this->options[$option] : null;
234
    }
235
236
    /**
237
     * Add connection after instantiation
238
     *
239
     * The connection can be an array of parameters for DbConfig::__construct(), a callable function that returns a PDO
240
     * instance, an instance of DbConfig or a PDO instance itself.
241
     *
242
     * When it is not a PDO instance the connection get established on first use.
243
     *
244
     * @param mixed $connection A configuration for (or a) PDO instance
245
     * @throws InvalidConfiguration
246
     */
247 10
    public function setConnection($connection)
248
    {
249 10
        if (is_callable($connection) || $connection instanceof DbConfig) {
250 6
            $this->connection = $connection;
251
        } else {
252 4
            if ($connection instanceof \PDO) {
253 1
                $connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
254 1
                $this->connection = $connection;
255 3
            } elseif (is_array($connection)) {
256 2
                $dbConfigReflection = new \ReflectionClass(DbConfig::class);
257 2
                $this->connection   = $dbConfigReflection->newInstanceArgs($connection);
258
            } else {
259 1
                throw new InvalidConfiguration(
260 1
                    'Connection must be callable, DbConfig, PDO or an array of parameters for DbConfig::__constructor'
261
                );
262
            }
263
        }
264 9
    }
265
266
    /**
267
     * Get the pdo connection.
268
     *
269
     * @return \PDO
270
     * @throws NoConnection
271
     */
272 9
    public function getConnection()
273
    {
274 9
        if (!$this->connection) {
275 1
            throw new NoConnection('No database connection');
276
        }
277
278 8
        if (!$this->connection instanceof \PDO) {
279 7
            if ($this->connection instanceof DbConfig) {
280
                /** @var DbConfig $dbConfig */
281 4
                $dbConfig = $this->connection;
282 4
                $this->connection = new \PDO(
283 4
                    $dbConfig->getDsn(),
284 4
                    $dbConfig->user,
285 4
                    $dbConfig->pass,
286 4
                    $dbConfig->attributes
287
                );
288
            } else {
289 3
                $pdo = call_user_func($this->connection);
290 3
                if (!$pdo instanceof \PDO) {
291 1
                    throw new NoConnection('Getter does not return PDO instance');
292
                }
293 2
                $this->connection = $pdo;
294
            }
295 6
            $this->connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
296
        }
297
298 7
        return $this->connection;
299
    }
300
301
    /**
302
     * Get the Datbase Abstraction Layer
303
     *
304
     * @return Dbal
305
     */
306 11
    public function getDbal()
307
    {
308 11
        if (!$this->dbal) {
309 11
            $connectionType = $this->getConnection()->getAttribute(\PDO::ATTR_DRIVER_NAME);
310 11
            $dbalClass = __NAMESPACE__ . '\\Dbal\\' . ucfirst($connectionType);
311 11
            if (!class_exists($dbalClass)) {
312 2
                $this->dbal = new Other($this);
313
            } else {
314 9
                $this->dbal = new $dbalClass($this);
315
            }
316
317
            // backward compatibility - deprecated
318 11
            if (isset($this->options[$connectionType . 'True'])) {
319 1
                $this->dbal->setBooleanTrue($this->options[$connectionType . 'True']);
320
            }
321 11
            if (isset($this->options[$connectionType . 'False'])) {
322 1
                $this->dbal->setBooleanFalse($this->options[$connectionType . 'False']);
323
            }
324 11
            if (isset($this->options[self::OPT_QUOTING_CHARACTER])) {
325 1
                $this->dbal->setQuotingCharacter($this->options[self::OPT_QUOTING_CHARACTER]);
326
            }
327 11
            if (isset($this->options[self::OPT_IDENTIFIER_DIVIDER])) {
328 1
                $this->dbal->setIdentifierDivider($this->options[self::OPT_IDENTIFIER_DIVIDER]);
329
            }
330
        }
331
332 11
        return $this->dbal;
333
    }
334
335
    /**
336
     * Get the Namer instance
337
     *
338
     * @return Namer
339
     * @codeCoverageIgnore trivial code...
340
     */
341
    public function getNamer()
342
    {
343
        if (!$this->namer) {
344
            $this->namer = new Namer($this->options);
345
        }
346
347
        return $this->namer;
348
    }
349
350
    /**
351
     * Synchronizing $entity with database
352
     *
353
     * If $reset is true it also calls reset() on $entity.
354
     *
355
     * @param Entity $entity
356
     * @param bool   $reset Reset entities current data
357
     * @return bool
358
     * @throws IncompletePrimaryKey
359
     * @throws InvalidConfiguration
360
     * @throws NoConnection
361
     * @throws NoEntity
362
     */
363 13
    public function sync(Entity $entity, $reset = false)
364
    {
365 13
        $this->map($entity, true);
366
367
        /** @var EntityFetcher $fetcher */
368 10
        $fetcher = $this->fetch(get_class($entity));
369 10
        foreach ($entity->getPrimaryKey() as $var => $value) {
370 10
            $fetcher->where($var, $value);
371
        }
372
373 10
        $result = $this->getConnection()->query($fetcher->getQuery());
374 7
        if ($originalData = $result->fetch(\PDO::FETCH_ASSOC)) {
375 5
            $entity->setOriginalData($originalData);
376 5
            if ($reset) {
377 2
                $entity->reset();
378
            }
379 5
            return true;
380
        }
381 2
        return false;
382
    }
383
384
    /**
385
     * Insert $entity in database
386
     *
387
     * Returns boolean if it is not auto incremented or the value of auto incremented column otherwise.
388
     *
389
     * @param Entity $entity
390
     * @param bool   $useAutoIncrement
391
     * @return mixed
392
     * @internal
393
     */
394 11
    public function insert(Entity $entity, $useAutoIncrement = true)
395
    {
396 11
        return $this->getDbal()->insert($entity, $useAutoIncrement);
397
    }
398
399
    /**
400
     * Update $entity in database
401
     *
402
     * @param Entity $entity
403
     * @return bool
404
     * @internal
405
     */
406 6
    public function update(Entity $entity)
407
    {
408 6
        $this->getDbal()->update($entity);
409 3
        $this->sync($entity, true);
410 3
        return true;
411
    }
412
413
    /**
414
     * Delete $entity from database
415
     *
416
     * This method does not delete from the map - you can still receive the entity via fetch.
417
     *
418
     * @param Entity $entity
419
     * @return bool
420
     */
421 6
    public function delete(Entity $entity)
422
    {
423 6
        $this->getDbal()->delete($entity);
424 4
        $entity->setOriginalData([]);
425 4
        return true;
426
    }
427
428
    /**
429
     * Map $entity in the entity map
430
     *
431
     * Returns the given entity or an entity that previously got mapped. This is useful to work in every function with
432
     * the same object.
433
     *
434
     * ```php?start_inline=true
435
     * $user = $enitityManager->map(new User(['id' => 42]));
436
     * ```
437
     *
438
     * @param Entity $entity
439
     * @param bool   $update Update the entity map
440
     * @return Entity
441
     * @throws IncompletePrimaryKey
442
     */
443 26
    public function map(Entity $entity, $update = false)
444
    {
445 26
        $class = get_class($entity);
446 26
        $key = md5(serialize($entity->getPrimaryKey()));
447
448 22
        if ($update || !isset($this->map[$class][$key])) {
449 22
            $this->map[$class][$key] = $entity;
450
        }
451
452 22
        return $this->map[$class][$key];
453
    }
454
455
    /**
456
     * Fetch one or more entities
457
     *
458
     * With $primaryKey it tries to find this primary key in the entity map (carefully: mostly the database returns a
459
     * string and we do not convert them). If there is no entity in the entity map it tries to fetch the entity from
460
     * the database. The return value is then null (not found) or the entity.
461
     *
462
     * Without $primaryKey it creates an entityFetcher and returns this.
463
     *
464
     * @param string|Entity $class      The entity class you want to fetch
465
     * @param mixed         $primaryKey The primary key of the entity you want to fetch
466
     * @return Entity|EntityFetcher
467
     * @throws IncompletePrimaryKey
468
     * @throws InvalidConfiguration
469
     * @throws NoConnection
470
     * @throws NoEntity
471
     */
472 60
    public function fetch($class, $primaryKey = null)
473
    {
474 60
        $reflection = new \ReflectionClass($class);
475 60
        if (!$reflection->isSubclassOf(Entity::class)) {
476 1
            throw new NoEntity($class . ' is not a subclass of Entity');
477
        }
478
479 59
        if ($primaryKey === null) {
480 51
            return new EntityFetcher($this, $class);
481
        }
482
483 9
        if (!is_array($primaryKey)) {
484 7
            $primaryKey = [$primaryKey];
485
        }
486
487 9
        $primaryKeyVars = $class::getPrimaryKeyVars();
488 9
        if (count($primaryKeyVars) !== count($primaryKey)) {
489 1
            throw new IncompletePrimaryKey(
490 1
                'Primary key consist of [' . implode(',', $primaryKeyVars) . '] only ' . count($primaryKey) . ' given'
491
            );
492
        }
493
494 8
        $primaryKey = array_combine($primaryKeyVars, $primaryKey);
495
496 8
        if (isset($this->map[$class][md5(serialize($primaryKey))])) {
497 7
            return $this->map[$class][md5(serialize($primaryKey))];
498
        }
499
500 1
        $fetcher = new EntityFetcher($this, $class);
501 1
        foreach ($primaryKey as $var => $value) {
502 1
            $fetcher->where($var, $value);
503
        }
504
505 1
        return $fetcher->one();
506
    }
507
508
    /**
509
     * Returns $value formatted to use in a sql statement.
510
     *
511
     * @param  mixed  $value      The variable that should be returned in SQL syntax
512
     * @return string
513
     * @codeCoverageIgnore This is just a proxy
514
     */
515
    public function escapeValue($value)
516
    {
517
        return $this->getDbal()->escapeValue($value);
518
    }
519
520
    /**
521
     * Returns $identifier quoted for use in a sql statement
522
     *
523
     * @param string $identifier Identifier to quote
524
     * @return string
525
     * @codeCoverageIgnore This is just a proxy
526
     */
527
    public function escapeIdentifier($identifier)
528
    {
529
        return $this->getDbal()->escapeIdentifier($identifier);
530
    }
531
532
    /**
533
     * Returns an array of columns from $table.
534
     *
535
     * @param string $table
536
     * @return Column[]
537
     */
538 2
    public function describe($table)
539
    {
540 2
        if (!isset($this->descriptions[$table])) {
541 2
            $this->descriptions[$table] = $this->getDbal()->describe($table);
542
        }
543 2
        return $this->descriptions[$table];
544
    }
545
}
546