Completed
Push — v2 ( ff18b4...317d67 )
by Berend
04:34
created

AbstractActiveRecord::injectInstanceOnRelation()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2.0625

Importance

Changes 0
Metric Value
eloc 3
c 0
b 0
f 0
dl 0
loc 6
ccs 3
cts 4
cp 0.75
rs 10
cc 2
nc 2
nop 2
crap 2.0625
1
<?php
2
3
/**
4
 * This file is part of the miBadger package.
5
 *
6
 * @author Michael Webbers <[email protected]>
7
 * @license http://opensource.org/licenses/Apache-2.0 Apache v2 License
8
 */
9
10
namespace miBadger\ActiveRecord;
11
12
use miBadger\Query\Query;
13
14
/**
15
 * The abstract active record class.
16
 *
17
 * @since 1.0.0
18
 */
19
abstract class AbstractActiveRecord implements ActiveRecordInterface
20
{
21
	const COLUMN_NAME_ID = 'id';
22
	const COLUMN_TYPE_ID = 'INT UNSIGNED';
23
24
	const CREATE = 'CREATE';
25
	const READ = 'READ';
26
	const UPDATE = 'UPDATE';
27
	const DELETE = 'DELETE';
28
	const SEARCH = 'SEARCH';
29
30
	/** @var \PDO The PDO object. */
31
	protected $pdo;
32
33
	/** @var null|int The ID. */
34
	private $id;
35
36
	/** @var array A map of column name to functions that hook the insert function */
37
	protected $createHooks;
38
39
	/** @var array A map of column name to functions that hook the read function */
40
	protected $readHooks;
41
42
	/** @var array A map of column name to functions that hook the update function */
43
	protected $updateHooks;
44
45
	/** @var array A map of column name to functions that hook the update function */
46
	protected $deleteHooks;	
47
48
	/** @var array A map of column name to functions that hook the search function */
49
	protected $searchHooks;
50
51
	/** @var array A list of table column definitions */
52
	protected $tableDefinition;
53
54
	/**
55
	 * Construct an abstract active record with the given PDO.
56
	 *
57
	 * @param \PDO $pdo
58
	 */
59 107
	public function __construct(\PDO $pdo)
60
	{
61 107
		$pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
62 107
		$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
63
64 107
		$this->setPdo($pdo);
65
66 107
		$this->createHooks = [];
67 107
		$this->readHooks = [];
68 107
		$this->updateHooks = [];
69 107
		$this->deleteHooks = [];
70 107
		$this->searchHooks = [];
71 107
		$this->tableDefinition = $this->getTableDefinition();
72
73
		// Extend table definition with default ID field, throw exception if field already exists
74 107
		if (array_key_exists('id', $this->tableDefinition)) {
75
			$message = "Table definition in record contains a field with name \"id\"";
76
			$message .= ", which is a reserved name by ActiveRecord";
77
			throw new ActiveRecordException($message, 0);
78
		}
79
80 107
		$this->tableDefinition[self::COLUMN_NAME_ID] =
81
		[
82 107
			'value' => &$this->id,
83
			'validate' => null,
84 107
			'type' => self::COLUMN_TYPE_ID,
85
			'properties' =>
86 107
				ColumnProperty::NOT_NULL
87 107
				| ColumnProperty::IMMUTABLE
88 107
				| ColumnProperty::AUTO_INCREMENT
89 107
				| ColumnProperty::PRIMARY_KEY
90
		];
91 107
	}
92
93 39
	private function checkHookConstraints($columnName, $hookMap)
94
	{
95
		// Check whether column exists
96 39
		if (!array_key_exists($columnName, $this->tableDefinition)) 
97
		{
98 5
			throw new ActiveRecordException("Hook is trying to register on non-existing column \"$columnName\"", 0);
99
		}
100
101
		// Enforcing 1 hook per table column
102 34
		if (array_key_exists($columnName, $hookMap)) {
103 5
			$message = "Hook is trying to register on an already registered column \"$columnName\", ";
104 5
			$message .= "do you have conflicting traits?";
105 5
			throw new ActiveRecordException($message, 0);
106
		}
107 34
	}
108
109 44
	public function registerHookOnAction($actionName, $columnName, $fn)
110
	{
111 44
		if (is_string($fn) && is_callable([$this, $fn])) {
112 24
			$fn = [$this, $fn];
113
		}
114
115 44
		if (!is_callable($fn)) { 
116 5
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
117
		}
118
119
		switch ($actionName) {
120 39
			case self::CREATE:
121 6
				$this->checkHookConstraints($columnName, $this->createHooks);
122 5
				$this->createHooks[$columnName] = $fn;
123 5
				break;
124 36
			case self::READ:
125 24
				$this->checkHookConstraints($columnName, $this->readHooks);
126 23
				$this->readHooks[$columnName] = $fn;
127 23
				break;
128 33
			case self::UPDATE:
129 6
				$this->checkHookConstraints($columnName, $this->updateHooks);
130 5
				$this->updateHooks[$columnName] = $fn;
131 5
				break;
132 27
			case self::DELETE:
133 3
				$this->checkHookConstraints($columnName, $this->deleteHooks);
134 2
				$this->deleteHooks[$columnName] = $fn;
135 2
				break;
136 24
			case self::SEARCH:
137 24
				$this->checkHookConstraints($columnName, $this->searchHooks);
138 23
				$this->searchHooks[$columnName] = $fn;
139 23
				break;
140
			default:
141
				throw new ActiveRecordException("Invalid action: Can not register hook on non-existing action");
142
		}
143 34
	}
144
145
	/**
146
	 * Register a new hook for a specific column that gets called before execution of the create() method
147
	 * Only one hook per column can be registered at a time
148
	 * @param string $columnName The name of the column that is registered.
149
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
150
	 */
151 7
	public function registerCreateHook($columnName, $fn)
152
	{
153 7
		$this->registerHookOnAction(self::CREATE, $columnName, $fn);
154 5
	}
155
156
	/**
157
	 * Register a new hook for a specific column that gets called before execution of the read() method
158
	 * Only one hook per column can be registered at a time
159
	 * @param string $columnName The name of the column that is registered.
160
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
161
	 */
162 25
	public function registerReadHook($columnName, $fn)
163
	{
164 25
		$this->registerHookOnAction(self::READ, $columnName, $fn);
165 23
	}
166
167
	/**
168
	 * Register a new hook for a specific column that gets called before execution of the update() method
169
	 * Only one hook per column can be registered at a time
170
	 * @param string $columnName The name of the column that is registered.
171
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
172
	 */
173 7
	public function registerUpdateHook($columnName, $fn)
174
	{
175 7
		$this->registerHookOnAction(self::UPDATE, $columnName, $fn);
176 5
	}
177
178
	/**
179
	 * Register a new hook for a specific column that gets called before execution of the delete() method
180
	 * Only one hook per column can be registered at a time
181
	 * @param string $columnName The name of the column that is registered.
182
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
183
	 */
184 4
	public function registerDeleteHook($columnName, $fn)
185
	{
186 4
		$this->registerHookOnAction(self::DELETE, $columnName, $fn);
187 2
	}
188
189
	/**
190
	 * Register a new hook for a specific column that gets called before execution of the search() method
191
	 * Only one hook per column can be registered at a time
192
	 * @param string $columnName The name of the column that is registered.
193
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object. The callable is required to take one argument: an instance of miBadger\Query\Query; 
194
	 */
195 25
	public function registerSearchHook($columnName, $fn)
196
	{
197 25
		$this->registerHookOnAction(self::SEARCH, $columnName, $fn);
198 23
	}
199
200
	/**
201
	 * Adds a new column definition to the table.
202
	 * @param string $columnName The name of the column that is registered.
203
	 * @param Array $definition The definition of that column.
204
	 */
205 60
	protected function extendTableDefinition($columnName, $definition)
206
	{
207 60
		if ($this->tableDefinition === null) {
208 1
			throw new ActiveRecordException("tableDefinition is null, has parent been initialized in constructor?");
209
		}
210
211
		// Enforcing table can only be extended with new columns
212 59
		if (array_key_exists($columnName, $this->tableDefinition)) {
213
			$message = "Table is being extended with a column that already exists, ";
214
			$message .= "\"$columnName\" conflicts with your table definition";
215
			throw new ActiveRecordException($message, 0);
216
		}
217
218 59
		$this->tableDefinition[$columnName] = $definition;
219 59
	}
220
221 11
	public function hasColumn(string $column) {
222 11
		return array_key_exists($column, $this->tableDefinition);
223
	}
224
225 4
	public function hasRelation(string $column, ActiveRecordInterface $record) {
226 4
		if (!$this->hasColumn($column)) {
227 2
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
228
		}
229
230 2
		if (!isset($this->tableDefinition[$column]['relation'])) {
231 1
			return false;
232
		}
233
234 2
		$relation = $this->tableDefinition[$column]['relation'];
235 2
		if ($relation instanceof AbstractActiveRecord) {
236
			// Injected object
237 1
			return get_class($record) === get_class($relation);
238
		} else {
239
			// :: class definition
240 1
			return get_class($record) === $relation;
241
		}
242
	}
243
244 2
	public function hasProperty(string $column, $property) {
245 2
		if (!$this->hasColumn($column)) {
246
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
247
		}
248
249
		try {
250 2
			$enumValue = ColumnProperty::valueOf($property);
251
		} catch (\UnexpectedValueException $e) {
252
			throw new ActiveRecordException("Provided property \"$property\" is not a valid property", 0, $e);
253
		}
254
255 2
		$properties = $this->tableDefinition[$column]['properties'] ?? null;
256
257 2
		return $properties !== null && (($properties & $enumValue->getValue()) > 0);
258
	}
259
260 2
	public function getColumnType(string $column) {
261 2
		if (!$this->hasColumn($column)) {
262 1
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
263
		}
264
265 1
		return $this->tableDefinition[$column]['type'] ?? null;
266
	}
267
268 1
	public function getColumnLength(string $column) {
269 1
		if (!$this->hasColumn($column)) {
270
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
271
		}
272
273 1
		return $this->tableDefinition[$column]['length'] ?? null;
274
	}
275
276 1
	public function getDefault(string $column) {
277 1
		if (!$this->hasColumn($column)) {
278
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
279
		}
280
281 1
		return $this->tableDefinition[$column]['default'] ?? null;
282
	}
283
284 1
	public function validateColumn(string $column, $input) {
285 1
		if (!$this->hasColumn($column)) {
286
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
287
		}
288
289 1
		$fn = $this->tableDefinition[$column]['validate'] ?? null;
290
291 1
		if ($fn === null) {
292 1
			return [true, ''];
293
		}
294
295 1
		if (!is_callable($fn)) {
296
			throw new ActiveRecordException("Provided validation function is not callable", 0);
297
		}
298
299 1
		return $fn($input);
300
	}
301
302
	/**
303
	 * Useful for writing unit tests of models against ActiveRecord: 
304
	 * overrides a relation column with a relation onto a mock object.
305
	 * @param string $column the name of the column onto which to place the mock relation
306
	 * @param object $mock the instance of a mock object to palce onto the model.
307
	 */
308 3
	public function injectInstanceOnRelation(string $column, $mock) {
309 3
		if (!$this->hasColumn($column)) {
310
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
311
		}
312
313 3
		$this->tableDefinition[$column]['relation'] = $mock;
314 3
	}
315
316
	/**
317
	 * Creates the entity as a table in the database
318
	 */
319 39
	public function createTable()
320
	{
321 39
		$this->pdo->query(SchemaBuilder::buildCreateTableSQL($this->getTableName(), $this->tableDefinition));
322 38
	}
323
324
	/**
325
	 * Iterates over the specified constraints in the table definition, 
326
	 * 		and applies these to the database.
327
	 */
328 20
	public function createTableConstraints()
329
	{
330
		// Iterate over columns, check whether "relation" field exists, if so create constraint
331 20
		foreach ($this->tableDefinition as $colName => $definition) {
332 20
			if (!isset($definition['relation'])) {
333 18
				continue;
334
			}
335
336 4
			$relation = $definition['relation'];
337 4
			$properties = $definition['properties'] ?? 0;
338
			
339 4
			if (is_string($relation) 
340 4
				&& class_exists($relation) 
341 4
				&& new $relation($this->pdo) instanceof AbstractActiveRecord) {
342
				// ::class relation in tableDefinition
343 2
				$target = new $definition['relation']($this->pdo);
344
			}
345 2
			else if ($relation instanceof AbstractActiveRecord) {
346 1
				throw new ActiveRecordException(sprintf(
347 1
					"Relation constraint on column \"%s\" of table \"%s\" can not be built from relation instance, use %s::class in table definition instead",
348 1
					$colName,
349 1
					$this->getTableName(),
350 1
					get_class($relation)
351
				));
352
			}
353
			else {
354
				// Invalid class
355 1
				throw new ActiveRecordException(sprintf(
356 1
					"Relation constraint on column \"%s\" of table \"%s\" does not contain a valid ActiveRecord instance", 
357 1
					$colName,
358 1
					$this->getTableName()));
359
			}
360
361
			// Add new relation constraint on database
362 2
			if ($properties & ColumnProperty::NOT_NULL) {
363 1
				$constraintSql = SchemaBuilder::buildConstraintOnDeleteCascade($target->getTableName(), 'id', $this->getTableName(), $colName);
364
			} else {
365 1
				$constraintSql = SchemaBuilder::buildConstraintOnDeleteSetNull($target->getTableName(), 'id', $this->getTableName(), $colName);
366
			}
367 2
			$this->pdo->query($constraintSql);
368
		}
369 18
	}
370
371
	/**
372
	 * Returns the name -> variable mapping for the table definition.
373
	 * @return Array The mapping
374
	 */
375 58
	protected function getActiveRecordColumns()
376
	{
377 58
		$bindings = [];
378 58
		foreach ($this->tableDefinition as $colName => $definition) {
379
380
			// Ignore the id column (key) when inserting or updating
381 58
			if ($colName == self::COLUMN_NAME_ID) {
382 58
				continue;
383
			}
384
385 58
			$bindings[$colName] = &$definition['value'];
386
		}
387 58
		return $bindings;
388
	}
389
390 36
	protected function insertDefaults()
391
	{
392
		// Insert default values for not-null fields
393 36
		foreach ($this->tableDefinition as $colName => $colDef) {
394 36
			if ($colDef['value'] === null
395 36
				&& ($colDef['properties'] ?? 0) & ColumnProperty::NOT_NULL
396 36
				&& isset($colDef['default'])) {
397 36
				$this->tableDefinition[$colName]['value'] = $colDef['default'];
398
			}
399
		}		
400 36
	}
401
402
	/**
403
	 * {@inheritdoc}
404
	 */
405 31
	public function create()
406
	{
407 31
		foreach ($this->createHooks as $colName => $fn) {
408 3
			$fn();
409
		}
410
411 31
		$this->insertDefaults();
412
413
		try {
414 31
			(new Query($this->getPdo(), $this->getTableName()))
415 31
				->insert($this->getActiveRecordColumns())
416 31
				->execute();
417
418 29
			$this->setId(intval($this->getPdo()->lastInsertId()));
419 2
		} catch (\PDOException $e) {
420 2
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
421
		}
422
423 29
		return $this;
424
	}
425
426
	/**
427
	 * {@inheritdoc}
428
	 */
429 25
	public function read($id)
430
	{
431
		$whereConditions = [
432 25
			Query::Equal('id', $id)
433
		];
434 25
		foreach ($this->readHooks as $colName => $fn) {
435 9
			$cond = $fn();
436 9
			if ($cond !== null) {
437 9
				$whereConditions[] = $cond;
438
			}
439
		}
440
441
		try {
442 25
			$row = (new Query($this->getPdo(), $this->getTableName()))
443 25
				->select()
444 25
				->where(Query::AndArray($whereConditions))
0 ignored issues
show
Bug introduced by
It seems like miBadger\Query\Query::AndArray($whereConditions) can also be of type null; however, parameter $exp of miBadger\Query\Query::where() does only seem to accept miBadger\Query\QueryExpression, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

444
				->where(/** @scrutinizer ignore-type */ Query::AndArray($whereConditions))
Loading history...
445 25
				->execute()
446 24
				->fetch();
447
			
448 24
			if ($row === false) {
449 6
				$msg = sprintf('Can not read the non-existent active record entry %d from the `%s` table.', $id, $this->getTableName());
450 6
				throw new ActiveRecordException($msg, ActiveRecordException::NOT_FOUND);
451
			}
452
453 19
			$this->fill($row)->setId($id);
454 7
		} catch (\PDOException $e) {
455 1
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
456
		}
457
458 19
		return $this;
459
	}
460
461
	/**
462
	 * {@inheritdoc}
463
	 */
464 10
	public function update()
465
	{
466 10
		foreach ($this->updateHooks as $colName => $fn) {
467 2
			$fn();
468
		}
469
470
		try {
471 10
			(new Query($this->getPdo(), $this->getTableName()))
472 10
				->update($this->getActiveRecordColumns())
473 10
				->where(Query::Equal('id', $this->getId()))
474 10
				->execute();
475 2
		} catch (\PDOException $e) {
476 2
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
477
		}
478
479 8
		return $this;
480
	}
481
482
	/**
483
	 * {@inheritdoc}
484
	 */
485 7
	public function delete()
486
	{
487 7
		foreach ($this->deleteHooks as $colName => $fn) {
488 1
			$fn();
489
		}
490
491
		try {
492 7
			(new Query($this->getPdo(), $this->getTableName()))
493 7
				->delete()
494 7
				->where(Query::Equal('id', $this->getId()))
495 7
				->execute();
496
497 6
			$this->setId(null);
498 1
		} catch (\PDOException $e) {
499 1
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
500
		}
501
502 6
		return $this;
503
	}
504
505
	/**
506
	 * {@inheritdoc}
507
	 */
508 2
	public function sync()
509
	{
510 2
		if (!$this->exists()) {
511 1
			return $this->create();
512
		}
513
514 1
		return $this->update();
515
	}
516
517
	/**
518
	 * {@inheritdoc}
519
	 */
520 3
	public function exists()
521
	{
522 3
		return $this->getId() !== null;
523
	}
524
525
	/**
526
	 * {@inheritdoc}
527
	 */
528 33
	public function fill(array $attributes)
529
	{
530 33
		$columns = $this->getActiveRecordColumns();
531 33
		$columns['id'] = &$this->id;
532
533 33
		foreach ($attributes as $key => $value) {
534 33
			if (array_key_exists($key, $columns)) {
535 33
				$columns[$key] = $value;
536
			}
537
		}
538
539 33
		return $this;
540
	}
541
542
	/**
543
	 * Returns the serialized form of the specified columns
544
	 * 
545
	 * @return Array
546
	 */
547 10
	public function toArray(Array $fieldWhitelist)
548
	{
549 10
		$output = [];
550 10
		foreach ($this->tableDefinition as $colName => $definition) {
551 10
			if (in_array($colName, $fieldWhitelist)) {
552 10
				$output[$colName] = $definition['value'];
553
			}
554
		}
555
556 10
		return $output;
557
	}
558
559
	/**
560
	 * {@inheritdoc}
561
	 */
562 19
	public function search(array $ignoredTraits = [])
563
	{
564 19
		$clauses = [];
565 19
		foreach ($this->searchHooks as $column => $fn) {
566 3
			if (!in_array($column, $ignoredTraits)) {
567 3
				$clauses[] = $fn();
568
			}
569
		}
570
571 19
		return new ActiveRecordQuery($this, $clauses);
572
	}
573
574
	/**
575
	 * Returns the PDO.
576
	 *
577
	 * @return \PDO the PDO.
578
	 */
579 67
	public function getPdo()
580
	{
581 67
		return $this->pdo;
582
	}
583
584
	/**
585
	 * Set the PDO.
586
	 *
587
	 * @param \PDO $pdo
588
	 * @return $this
589
	 */
590 107
	protected function setPdo($pdo)
591
	{
592 107
		$this->pdo = $pdo;
593
594 107
		return $this;
595
	}
596
597
	/**
598
	 * Returns the ID.
599
	 *
600
	 * @return null|int The ID.
601
	 */
602 32
	public function getId()
603
	{
604 32
		return $this->id;
605
	}
606
607
	/**
608
	 * Set the ID.
609
	 *
610
	 * @param int $id
611
	 * @return $this
612
	 */
613 42
	protected function setId($id)
614
	{
615 42
		$this->id = $id;
616
617 42
		return $this;
618
	}
619
620
	public function getFinalTableDefinition()
621
	{
622
		return $this->tableDefinition;
623
	}
624
625 28
	public function newInstance()
626
	{
627 28
		return new static($this->pdo);
628
	}
629
630
	/**
631
	 * Returns the active record table.
632
	 *
633
	 * @return string the active record table name.
634
	 */
635
	abstract public function getTableName(): string;
636
637
	/**
638
	 * Returns the active record columns.
639
	 *
640
	 * @return array the active record columns.
641
	 */
642
	abstract protected function getTableDefinition(): Array;
643
}
644