AbstractActiveRecord::setPdo()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 5
c 0
b 0
f 0
ccs 3
cts 3
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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
	/**
94
	 * Verifies whether a column already has an entry in the specified hook map. If so, throws.
95
	 * @param string $columnName The column name for which to verify the hook constraints
96
	 * @param Array $hookMap The associative map of hooks to be verifie
97
	 */
98 39
	private function checkHookConstraints(string $columnName, Array $hookMap)
99
	{
100
		// Check whether column exists
101 39
		if (!array_key_exists($columnName, $this->tableDefinition)) 
102
		{
103 5
			throw new ActiveRecordException("Hook is trying to register on non-existing column \"$columnName\"", 0);
104
		}
105
106
		// Enforcing 1 hook per table column
107 34
		if (array_key_exists($columnName, $hookMap)) {
108 5
			$message = "Hook is trying to register on an already registered column \"$columnName\", ";
109 5
			$message .= "do you have conflicting traits?";
110 5
			throw new ActiveRecordException($message, 0);
111
		}
112 34
	}
113
114
115
	/**
116
	 * Registers a hook to be called on one of the following actions
117
	 * 		[CREATE, READ, UPDATE, DELETE, SEARCH]
118
	 * @param string $actionName The name of the action to register for
119
	 * @param string $columnName The columnName for which to register this action
120
	 * @param callable|string $fn The function name to call, or a callable function
121
	 */
122 44
	public function registerHookOnAction(string $actionName, string $columnName, $fn)
123
	{
124 44
		if (is_string($fn) && is_callable([$this, $fn])) {
125 24
			$fn = [$this, $fn];
126
		}
127
128 44
		if (!is_callable($fn)) { 
129 5
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
130
		}
131
132
		switch ($actionName) {
133 39
			case self::CREATE:
134 6
				$this->checkHookConstraints($columnName, $this->createHooks);
135 5
				$this->createHooks[$columnName] = $fn;
136 5
				break;
137 36
			case self::READ:
138 24
				$this->checkHookConstraints($columnName, $this->readHooks);
139 23
				$this->readHooks[$columnName] = $fn;
140 23
				break;
141 33
			case self::UPDATE:
142 6
				$this->checkHookConstraints($columnName, $this->updateHooks);
143 5
				$this->updateHooks[$columnName] = $fn;
144 5
				break;
145 27
			case self::DELETE:
146 3
				$this->checkHookConstraints($columnName, $this->deleteHooks);
147 2
				$this->deleteHooks[$columnName] = $fn;
148 2
				break;
149 24
			case self::SEARCH:
150 24
				$this->checkHookConstraints($columnName, $this->searchHooks);
151 23
				$this->searchHooks[$columnName] = $fn;
152 23
				break;
153
			default:
154
				throw new ActiveRecordException("Invalid action: Can not register hook on non-existing action");
155
		}
156 34
	}
157
158
	/**
159
	 * Register a new hook for a specific column that gets called before execution of the create() method
160
	 * Only one hook per column can be registered at a time
161
	 * @param string $columnName The name of the column that is registered.
162
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
163
	 */
164 7
	public function registerCreateHook(string $columnName, $fn)
165
	{
166 7
		$this->registerHookOnAction(self::CREATE, $columnName, $fn);
167 5
	}
168
169
	/**
170
	 * Register a new hook for a specific column that gets called before execution of the read() method
171
	 * Only one hook per column can be registered at a time
172
	 * @param string $columnName The name of the column that is registered.
173
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
174
	 */
175 25
	public function registerReadHook(string $columnName, $fn)
176
	{
177 25
		$this->registerHookOnAction(self::READ, $columnName, $fn);
178 23
	}
179
180
	/**
181
	 * Register a new hook for a specific column that gets called before execution of the update() method
182
	 * Only one hook per column can be registered at a time
183
	 * @param string $columnName The name of the column that is registered.
184
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
185
	 */
186 7
	public function registerUpdateHook(string $columnName, $fn)
187
	{
188 7
		$this->registerHookOnAction(self::UPDATE, $columnName, $fn);
189 5
	}
190
191
	/**
192
	 * Register a new hook for a specific column that gets called before execution of the delete() method
193
	 * Only one hook per column can be registered at a time
194
	 * @param string $columnName The name of the column that is registered.
195
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
196
	 */
197 4
	public function registerDeleteHook(string $columnName, $fn)
198
	{
199 4
		$this->registerHookOnAction(self::DELETE, $columnName, $fn);
200 2
	}
201
202
	/**
203
	 * Register a new hook for a specific column that gets called before execution of the search() method
204
	 * Only one hook per column can be registered at a time
205
	 * @param string $columnName The name of the column that is registered.
206
	 * @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; 
207
	 */
208 25
	public function registerSearchHook(string $columnName, $fn)
209
	{
210 25
		$this->registerHookOnAction(self::SEARCH, $columnName, $fn);
211 23
	}
212
213
	/**
214
	 * Adds a new column definition to the table.
215
	 * @param string $columnName The name of the column that is registered.
216
	 * @param Array $definition The definition of that column.
217
	 */
218 60
	protected function extendTableDefinition(string $columnName, $definition)
219
	{
220 60
		if ($this->tableDefinition === null) {
221 1
			throw new ActiveRecordException("tableDefinition is null, has parent been initialized in constructor?");
222
		}
223
224
		// Enforcing table can only be extended with new columns
225 59
		if (array_key_exists($columnName, $this->tableDefinition)) {
226
			$message = "Table is being extended with a column that already exists, ";
227
			$message .= "\"$columnName\" conflicts with your table definition";
228
			throw new ActiveRecordException($message, 0);
229
		}
230
231 59
		$this->tableDefinition[$columnName] = $definition;
232 59
	}
233
234
	/**
235
	 * Checks whether the provided column name is registered in the table definition
236
	 * @param string $column The column name
237
	 */
238 11
	public function hasColumn(string $column): bool {
239 11
		return array_key_exists($column, $this->tableDefinition);
240
	}
241
242
	/**
243
	 * Checks whether the column has a relation onto the provided record table
244
	 * @param string $column The column name
245
	 * @param ActiveRecordInterface $record The record to check the relation on
246
	 */
247 4
	public function hasRelation(string $column, ActiveRecordInterface $record): bool {
248 4
		if (!$this->hasColumn($column)) {
249 2
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
250
		}
251
252 2
		if (!isset($this->tableDefinition[$column]['relation'])) {
253 1
			return false;
254
		}
255
256 2
		$relation = $this->tableDefinition[$column]['relation'];
257 2
		if ($relation instanceof AbstractActiveRecord) {
258
			// Injected object
259 1
			return get_class($record) === get_class($relation);
260
		} else {
261
			// :: class definition
262 1
			return get_class($record) === $relation;
263
		}
264
	}
265
266
267
	/**
268
	 * Checks whether the property Exists for an instance of t
269
	 * @param string $column The column name
270
	 * @param int $property The ColumnProperty enum value
271
	 */
272 2
	public function hasProperty(string $column, $property): bool {
273 2
		if (!$this->hasColumn($column)) {
274
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
275
		}
276
277
		try {
278 2
			$enumValue = ColumnProperty::valueOf($property);
279
		} catch (\UnexpectedValueException $e) {
280
			throw new ActiveRecordException("Provided property \"$property\" is not a valid property", 0, $e);
281
		}
282
283 2
		$properties = $this->tableDefinition[$column]['properties'] ?? null;
284
285 2
		return $properties !== null && (($properties & $enumValue->getValue()) > 0);
286
	}
287
	/**
288
	 * @param $column string The column name
289
	 */
290 2
	public function getColumnType(string $column): string {
291 2
		if (!$this->hasColumn($column)) {
292 1
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
293
		}
294
295 1
		return $this->tableDefinition[$column]['type'] ?? null;
296
	}
297
298
	/**
299
	 * Returns the default value on a column
300
	 * @param $column string The column name
301
	 */
302 1
	public function getColumnLength(string $column): ?int {
303 1
		if (!$this->hasColumn($column)) {
304
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
305
		}
306
307 1
		return $this->tableDefinition[$column]['length'] ?? null;
308
	}
309
310
	/**
311
	 * Returns the default value on a column
312
	 * @param $column string The column name
313
	 */
314 1
	public function getDefault(string $column) {
315 1
		if (!$this->hasColumn($column)) {
316
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
317
		}
318
319 1
		return $this->tableDefinition[$column]['default'] ?? null;
320
	}
321
322
	/**
323
	 * Validates that the column matches the input constraints & passes the validator function
324
	 * @param $column string The column name
325
	 */
326 1
	public function validateColumn(string $column, $input) {
327 1
		if (!$this->hasColumn($column)) {
328
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
329
		}
330
331 1
		$fn = $this->tableDefinition[$column]['validate'] ?? null;
332
333 1
		if ($fn === null) {
334 1
			return [true, ''];
335
		}
336
337 1
		if (!is_callable($fn)) {
338
			throw new ActiveRecordException("Provided validation function is not callable", 0);
339
		}
340
341 1
		return $fn($input);
342
	}
343
344
	/**
345
	 * Useful for writing unit tests of models against ActiveRecord: 
346
	 * overrides a relation column with a relation onto a mock object.
347
	 * @param string $column the name of the column onto which to place the mock relation
348
	 * @param object $mock the instance of a mock object to palce onto the model.
349
	 */
350 3
	public function injectInstanceOnRelation(string $column, $mock) {
351 3
		if (!$this->hasColumn($column)) {
352
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
353
		}
354
355 3
		$this->tableDefinition[$column]['relation'] = $mock;
356 3
	}
357
358
	/**
359
	 * Creates the entity as a table in the database
360
	 */
361 39
	public function createTable()
362
	{
363 39
		$this->pdo->query(SchemaBuilder::buildCreateTableSQL($this->getTableName(), $this->tableDefinition));
364 38
	}
365
366
	/**
367
	 * Iterates over the specified constraints in the table definition, 
368
	 * 		and applies these to the database.
369
	 */
370 20
	public function createTableConstraints()
371
	{
372
		// Iterate over columns, check whether "relation" field exists, if so create constraint
373 20
		foreach ($this->tableDefinition as $colName => $definition) {
374 20
			if (!isset($definition['relation'])) {
375 18
				continue;
376
			}
377
378 4
			$relation = $definition['relation'];
379 4
			$properties = $definition['properties'] ?? 0;
380
			
381 4
			if (is_string($relation) 
382 4
				&& class_exists($relation) 
383 4
				&& new $relation($this->pdo) instanceof AbstractActiveRecord) {
384
				// ::class relation in tableDefinition
385 2
				$target = new $definition['relation']($this->pdo);
386
			}
387 2
			else if ($relation instanceof AbstractActiveRecord) {
388 1
				throw new ActiveRecordException(sprintf(
389 1
					"Relation constraint on column \"%s\" of table \"%s\" can not be built from relation instance, use %s::class in table definition instead",
390
					$colName,
391 1
					$this->getTableName(),
392 1
					get_class($relation)
393
				));
394
			}
395
			else {
396
				// Invalid class
397 1
				throw new ActiveRecordException(sprintf(
398 1
					"Relation constraint on column \"%s\" of table \"%s\" does not contain a valid ActiveRecord instance", 
399
					$colName,
400 1
					$this->getTableName()));
401
			}
402
403
			// Add new relation constraint on database
404 2
			if ($properties & ColumnProperty::NOT_NULL) {
405 1
				$constraintSql = SchemaBuilder::buildConstraintOnDeleteCascade($target->getTableName(), 'id', $this->getTableName(), $colName);
406
			} else {
407 1
				$constraintSql = SchemaBuilder::buildConstraintOnDeleteSetNull($target->getTableName(), 'id', $this->getTableName(), $colName);
408
			}
409 2
			$this->pdo->query($constraintSql);
410
		}
411 18
	}
412
413
	/**
414
	 * Returns the name -> variable mapping for the table definition.
415
	 * @return Array The mapping
416
	 */
417 58
	protected function getActiveRecordColumns()
418
	{
419 58
		$bindings = [];
420 58
		foreach ($this->tableDefinition as $colName => $definition) {
421
422
			// Ignore the id column (key) when inserting or updating
423 58
			if ($colName == self::COLUMN_NAME_ID) {
424 58
				continue;
425
			}
426
427 58
			$bindings[$colName] = &$definition['value'];
428
		}
429 58
		return $bindings;
430
	}
431
432
	/**
433
	 * Inserts the default values for columns that have a non-null specification
434
	 * 	and a registered default value
435
	 */
436 36
	protected function insertDefaults()
437
	{
438
		// Insert default values for not-null fields
439 36
		foreach ($this->tableDefinition as $colName => $colDef) {
440 36
			if ($colDef['value'] === null
441 36
				&& ($colDef['properties'] ?? 0) & ColumnProperty::NOT_NULL
442 36
				&& isset($colDef['default'])) {
443
				$this->tableDefinition[$colName]['value'] = $colDef['default'];
444
			}
445
		}		
446 36
	}
447
448
	/**
449
	 * {@inheritdoc}
450
	 */
451 31
	public function create()
452
	{
453 31
		foreach ($this->createHooks as $colName => $fn) {
454 3
			$fn();
455
		}
456
457 31
		$this->insertDefaults();
458
459
		try {
460 31
			(new Query($this->getPdo(), $this->getTableName()))
461 31
				->insert($this->getActiveRecordColumns())
462 31
				->execute();
463
464 29
			$this->setId(intval($this->getPdo()->lastInsertId()));
465 2
		} catch (\PDOException $e) {
466 2
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
467
		}
468
469 29
		return $this;
470
	}
471
472
	/**
473
	 * {@inheritdoc}
474
	 */
475 25
	public function read($id)
476
	{
477
		$whereConditions = [
478 25
			Query::Equal('id', $id)
479
		];
480 25
		foreach ($this->readHooks as $colName => $fn) {
481 9
			$cond = $fn();
482 9
			if ($cond !== null) {
483 8
				$whereConditions[] = $cond;
484
			}
485
		}
486
487
		try {
488 25
			$row = (new Query($this->getPdo(), $this->getTableName()))
489 25
				->select()
490 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

490
				->where(/** @scrutinizer ignore-type */ Query::AndArray($whereConditions))
Loading history...
491 25
				->execute()
492 24
				->fetch();
493
			
494 24
			if ($row === false) {
495 6
				$msg = sprintf('Can not read the non-existent active record entry %d from the `%s` table.', $id, $this->getTableName());
496 6
				throw new ActiveRecordException($msg, ActiveRecordException::NOT_FOUND);
497
			}
498
499 19
			$this->fill($row)->setId($id);
500 7
		} catch (\PDOException $e) {
501 1
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
502
		}
503
504 19
		return $this;
505
	}
506
507
	/**
508
	 * {@inheritdoc}
509
	 */
510 10
	public function update()
511
	{
512 10
		foreach ($this->updateHooks as $colName => $fn) {
513 2
			$fn();
514
		}
515
516
		try {
517 10
			(new Query($this->getPdo(), $this->getTableName()))
518 10
				->update($this->getActiveRecordColumns())
519 10
				->where(Query::Equal('id', $this->getId()))
520 10
				->execute();
521 2
		} catch (\PDOException $e) {
522 2
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
523
		}
524
525 8
		return $this;
526
	}
527
528
	/**
529
	 * {@inheritdoc}
530
	 */
531 7
	public function delete()
532
	{
533 7
		foreach ($this->deleteHooks as $colName => $fn) {
534 1
			$fn();
535
		}
536
537
		try {
538 7
			(new Query($this->getPdo(), $this->getTableName()))
539 7
				->delete()
540 7
				->where(Query::Equal('id', $this->getId()))
541 7
				->execute();
542
543 6
			$this->setId(null);
544 1
		} catch (\PDOException $e) {
545 1
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
546
		}
547
548 6
		return $this;
549
	}
550
551
	/**
552
	 * {@inheritdoc}
553
	 */
554 2
	public function sync()
555
	{
556 2
		if (!$this->exists()) {
557 1
			return $this->create();
558
		}
559
560 1
		return $this->update();
561
	}
562
563
	/**
564
	 * {@inheritdoc}
565
	 */
566 3
	public function exists()
567
	{
568 3
		return $this->getId() !== null;
569
	}
570
571
	/**
572
	 * {@inheritdoc}
573
	 */
574 33
	public function fill(array $attributes)
575
	{
576 33
		$columns = $this->getActiveRecordColumns();
577 33
		$columns['id'] = &$this->id;
578
579 33
		foreach ($attributes as $key => $value) {
580 33
			if (array_key_exists($key, $columns)) {
581 33
				$columns[$key] = $value;
582
			}
583
		}
584
585 33
		return $this;
586
	}
587
588
	/**
589
	 * Returns the serialized form of the specified columns
590
	 * 
591
	 * @return Array
592
	 */
593 10
	public function toArray(Array $fieldWhitelist)
594
	{
595 10
		$output = [];
596 10
		foreach ($this->tableDefinition as $colName => $definition) {
597 10
			if (in_array($colName, $fieldWhitelist)) {
598 10
				$output[$colName] = $definition['value'];
599
			}
600
		}
601
602 10
		return $output;
603
	}
604
605
	/**
606
	 * {@inheritdoc}
607
	 */
608 19
	public function search(array $ignoredTraits = [])
609
	{
610 19
		$clauses = [];
611 19
		foreach ($this->searchHooks as $column => $fn) {
612 3
			if (!in_array($column, $ignoredTraits)) {
613 3
				$clauses[] = $fn();
614
			}
615
		}
616
617 19
		return new ActiveRecordQuery($this, $clauses);
618
	}
619
620
	/**
621
	 * Returns the PDO.
622
	 *
623
	 * @return \PDO the PDO.
624
	 */
625 67
	public function getPdo()
626
	{
627 67
		return $this->pdo;
628
	}
629
630
	/**
631
	 * Set the PDO.
632
	 *
633
	 * @param \PDO $pdo
634
	 * @return $this
635
	 */
636 107
	protected function setPdo($pdo)
637
	{
638 107
		$this->pdo = $pdo;
639
640 107
		return $this;
641
	}
642
643
	/**
644
	 * Returns the ID.
645
	 *
646
	 * @return null|int The ID.
647
	 */
648 32
	public function getId(): ?int
649
	{
650 32
		return $this->id;
651
	}
652
653
	/**
654
	 * Set the ID.
655
	 *
656
	 * @param int|null $id
657
	 * @return $this
658
	 */
659 42
	protected function setId(?int $id)
660
	{
661 42
		$this->id = $id;
662
663 42
		return $this;
664
	}
665
666
	public function getFinalTableDefinition()
667
	{
668
		return $this->tableDefinition;
669
	}
670
671 28
	public function newInstance()
672
	{
673 28
		return new static($this->pdo);
674
	}
675
676
	/**
677
	 * Returns the active record table.
678
	 *
679
	 * @return string the active record table name.
680
	 */
681
	abstract public function getTableName(): string;
682
683
	/**
684
	 * Returns the active record columns.
685
	 *
686
	 * @return array the active record columns.
687
	 */
688
	abstract protected function getTableDefinition(): Array;
689
}
690