Test Failed
Push — v2 ( a0d121...604284 )
by Berend
02:50
created

AbstractActiveRecord::newInstance()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
ccs 0
cts 0
cp 0
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
crap 2
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
	/** @var \PDO The PDO object. */
25
	protected $pdo;
26
27
	/** @var null|int The ID. */
28
	private $id;
29
30
	/** @var array A map of column name to functions that hook the insert function */
31
	protected $registeredCreateHooks;
32
33
	/** @var array A map of column name to functions that hook the read function */
34
	protected $registeredReadHooks;
35
36
	/** @var array A map of column name to functions that hook the update function */
37
	protected $registeredUpdateHooks;
38
39
	/** @var array A map of column name to functions that hook the update function */
40
	protected $registeredDeleteHooks;	
41
42
	/** @var array A map of column name to functions that hook the search function */
43
	protected $registeredSearchHooks;
44
45
	/** @var array A list of table column definitions */
46
	protected $tableDefinition;
47
48
	/**
49
	 * Construct an abstract active record with the given PDO.
50
	 *
51
	 * @param \PDO $pdo
52
	 */
53 82
	public function __construct(\PDO $pdo )
54
	{
55 82
		$pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
56 82
		$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
57
58 82
		$this->setPdo($pdo);
59 82
60 82
		$this->registeredCreateHooks = [];
61 82
		$this->registeredReadHooks = [];
62 82
		$this->registeredUpdateHooks = [];
63 82
		$this->registeredDeleteHooks = [];
64 82
		$this->registeredSearchHooks = [];
65
		$this->tableDefinition = $this->getTableDefinition();
66
67 82
		// Extend table definition with default ID field, throw exception if field already exists
68
		if (array_key_exists('id', $this->tableDefinition)) {
69
			$message = "Table definition in record contains a field with name \"id\"";
70
			$message .= ", which is a reserved name by ActiveRecord";
71
			throw new ActiveRecordException($message, 0);
72
		}
73 82
74
		$this->tableDefinition[self::COLUMN_NAME_ID] =
75 82
		[
76
			'value' => &$this->id,
77 82
			'validate' => null,
78 82
			'type' => self::COLUMN_TYPE_ID,
79
			'properties' => ColumnProperty::NOT_NULL | ColumnProperty::IMMUTABLE | ColumnProperty::AUTO_INCREMENT | ColumnProperty::PRIMARY_KEY
80 82
		];
81
	}
82 34
83
	private function checkHookConstraints($columnName, $hookMap)
84
	{
85 34
		// Check whether column exists
86
		if (!array_key_exists($columnName, $this->tableDefinition)) 
87 5
		{
88
			throw new ActiveRecordException("Hook is trying to register on non-existing column \"$columnName\"", 0);
89
		}
90
91 29
		// Enforcing 1 hook per table column
92 5
		if (array_key_exists($columnName, $hookMap)) {
93 5
			$message = "Hook is trying to register on an already registered column \"$columnName\", ";
94 5
			$message .= "do you have conflicting traits?";
95
			throw new ActiveRecordException($message, 0);
96 29
		}
97
	}
98
99
	/**
100
	 * Register a new hook for a specific column that gets called before execution of the create() method
101
	 * Only one hook per column can be registered at a time
102
	 * @param string $columnName The name of the column that is registered.
103
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
104 7
	 */
105
	public function registerCreateHook($columnName, $fn)
106 7
	{
107
		$this->checkHookConstraints($columnName, $this->registeredCreateHooks);
108 6
109 3
		if (is_string($fn) && is_callable([$this, $fn])) {
110 3
			$this->registeredCreateHooks[$columnName] = [$this, $fn];
111 2
		} else if (is_callable($fn)) {
112
			$this->registeredCreateHooks[$columnName] = $fn;
113 1
		} else {
114
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
115 5
		}
116
	}
117
118
	/**
119
	 * Register a new hook for a specific column that gets called before execution of the read() method
120
	 * Only one hook per column can be registered at a time
121
	 * @param string $columnName The name of the column that is registered.
122
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
123 4
	 */
124
	public function registerReadHook($columnName, $fn)
125 4
	{
126
		$this->checkHookConstraints($columnName, $this->registeredReadHooks);
127 3
128
		if (is_string($fn) && is_callable([$this, $fn])) {
129 3
			$this->registeredReadHooks[$columnName] = [$this, $fn];
130 2
		} else if (is_callable($fn)) {
131
			$this->registeredReadHooks[$columnName] = $fn;
132 1
		} else {
133
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
134 2
		}
135
	}
136
137
	/**
138
	 * Register a new hook for a specific column that gets called before execution of the update() method
139
	 * Only one hook per column can be registered at a time
140
	 * @param string $columnName The name of the column that is registered.
141
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
142 7
	 */
143
	public function registerUpdateHook($columnName, $fn)
144 7
	{
145
		$this->checkHookConstraints($columnName, $this->registeredUpdateHooks);
146 6
147 3
		if (is_string($fn) && is_callable([$this, $fn])) {
148 3
			$this->registeredUpdateHooks[$columnName] = [$this, $fn];
149 2
		} else if (is_callable($fn)) {
150
			$this->registeredUpdateHooks[$columnName] = $fn;
151 1
		} else {
152
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
153 5
		}
154
	}
155
156
	/**
157
	 * Register a new hook for a specific column that gets called before execution of the delete() 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 4
	 */
162
	public function registerDeleteHook($columnName, $fn)
163 4
	{
164
		$this->checkHookConstraints($columnName, $this->registeredDeleteHooks);
165 3
166
		if (is_string($fn) && is_callable([$this, $fn])) {
167 3
			$this->registeredDeleteHooks[$columnName] = [$this, $fn];
168 2
		} else if (is_callable($fn)) {
169
			$this->registeredDeleteHooks[$columnName] = $fn;
170 1
		} else {
171
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
172 2
		}
173
	}
174
175
	/**
176
	 * Register a new hook for a specific column that gets called before execution of the search() method
177
	 * Only one hook per column can be registered at a time
178
	 * @param string $columnName The name of the column that is registered.
179
	 * @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; 
180 15
	 */
181
	public function registerSearchHook($columnName, $fn)
182 15
	{
183
		$this->checkHookConstraints($columnName, $this->registeredSearchHooks);
184 14
185 11
		if (is_string($fn) && is_callable([$this, $fn])) {
186 3
			$this->registeredSearchHooks[$columnName] = [$this, $fn];
187 2
		} else if (is_callable($fn)) {
188
			$this->registeredSearchHooks[$columnName] = $fn;
189 1
		} else {
190
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
191 13
		}
192
	}
193
194
	/**
195
	 * Adds a new column definition to the table.
196
	 * @param string $columnName The name of the column that is registered.
197
	 * @param Array $definition The definition of that column.
198 49
	 */
199
	public function extendTableDefinition($columnName, $definition)
200 49
	{
201 1
		if ($this->tableDefinition === null) {
202
			throw new ActiveRecordException("tableDefinition is null, most likely due to parent class not having been initialized in constructor");
203
		}
204
205 48
		// Enforcing table can only be extended with new columns
206 1
		if (array_key_exists($columnName, $this->tableDefinition)) {
207 1
			$message = "Table is being extended with a column that already exists, ";
208 1
			$message .= "\"$columnName\" conflicts with your table definition";
209
			throw new ActiveRecordException($message, 0);
210
		}
211 48
212 48
		$this->tableDefinition[$columnName] = $definition;
213
	}
214
215
	/**
216
	 * Returns the type string as it should appear in the mysql create table statement for the given column
217
	 * @return string The type string
218 27
	 */
219
	private function getDatabaseTypeString($colName, $type, $length)
220 27
	{
221 27
		switch (strtoupper($type)) {
222 1
			case '':
223
				throw new ActiveRecordException(sprintf("Column %s has invalid type \"NULL\"", $colName));
224 26
				
225 26
			case 'DATETIME':
226 26
			case 'DATE':
227 26
			case 'TIME':
228 26
			case 'TEXT':
229 26
			case 'INT UNSIGNED':
230
				return $type;
231 25
232 25
			case 'VARCHAR':
233
				if ($length === null) {
234
					throw new ActiveRecordException(sprintf("field type %s requires specified column field \"LENGTH\"", $colName));
235 25
				} else {
236
					return sprintf('%s(%d)', $type, $length);	
237
				}
238 9
239
			case 'INT':
240
			case 'TINYINT':
241
			case 'BIGINT':
242
			default: 	
243 9
				// Implicitly assuming that non-specified cases are correct without a length parameter
244
				if ($length === null) {
245
					return $type;
246 9
				} else {
247
					return sprintf('%s(%d)', $type, $length);	
248
				}
249
		}
250
	}
251
252
	/**
253
	 * Builds the part of a MySQL create table statement that corresponds to the supplied column
254
	 * @param string $colName 	Name of the database column
255
	 * @param string $type 		The type of the string
256
	 * @param int $properties 	The set of Column properties that apply to this column (See ColumnProperty for options)
257
	 * @return string
258 27
	 */
259
	private function buildCreateTableColumnEntry($colName, $type, $length, $properties, $default)
260 27
	{
261 26
		$stmnt = sprintf('`%s` %s ', $colName, $this->getDatabaseTypeString($colName, $type, $length));
262 26
		if ($properties & ColumnProperty::NOT_NULL) {
263
			$stmnt .= 'NOT NULL ';
264 26
		} else {
265
			$stmnt .= 'NULL ';
266
		}
267 26
268 17
		if ($default !== NULL) {
269
			$stmnt .= ' DEFAULT ' . $default . ' ';
270
		}
271 26
272 26
		if ($properties & ColumnProperty::AUTO_INCREMENT) {
273
			$stmnt .= 'AUTO_INCREMENT ';
274
		}
275 26
276 8
		if ($properties & ColumnProperty::UNIQUE) {
277
			$stmnt .= 'UNIQUE ';
278
		}
279 26
280 26
		if ($properties & ColumnProperty::PRIMARY_KEY) {
281
			$stmnt .= 'PRIMARY KEY ';
282
		}
283 26
284
		return $stmnt;
285
	}
286
287
	/**
288
	 * Sorts the column statement components in the order such that the id appears first, 
289
	 * 		followed by all other columns in alphabetical ascending order
290
	 * @param   Array $colStatements Array of column statements
291
	 * @return  Array
292 26
	 */
293
	private function sortColumnStatements($colStatements)
294
	{
295 26
		// Find ID statement and put it first
296
		$sortedStatements = [];
297 26
298 26
		$sortedStatements[] = $colStatements[self::COLUMN_NAME_ID];
299
		unset($colStatements[self::COLUMN_NAME_ID]);
300
301 26
		// Sort remaining columns in alphabetical order
302 26
		$columns = array_keys($colStatements);
303 26
		sort($columns);
304 26
		foreach ($columns as $colName) {
305
			$sortedStatements[] = $colStatements[$colName];
306
		}
307 26
308
		return $sortedStatements;
309
	}
310
311
	/**
312
	 * Builds the MySQL Create Table statement for the internal table definition
313
	 * @return string
314 27
	 */
315
	public function buildCreateTableSQL()
316 27
	{
317 27
		$columnStatements = [];
318
		foreach ($this->tableDefinition as $colName => $definition) {
319 27
			// Destructure column definition
320 27
			$type    = $definition['type'] ?? null;
321 27
			$default = $definition['default'] ?? null;
322 27
			$length  = $definition['length'] ?? null;
323
			$properties = $definition['properties'] ?? null;
324 27
325
			if (isset($definition['relation']) && $type !== null) {
326
				$msg = "Column \"$colName\": ";
327
				$msg .= "Relationship columns have an automatically inferred type, so type should be omitted";
328 27
				throw new ActiveRecordException($msg);
329 2
			} else if (isset($definition['relation'])) {
330
				$type = self::COLUMN_TYPE_ID;
331
			}
332 27
333
			$columnStatements[$colName] = $this->buildCreateTableColumnEntry($colName, $type, $length, $properties, $default);
334
		}
335
336 26
		// Sort table (first column is id, the remaining are alphabetically sorted)
337
		$columnStatements = $this->sortColumnStatements($columnStatements);
338 26
339 26
		$sql = 'CREATE TABLE ' . $this->getTableName() . ' ';
340 26
		$sql .= "(\n";
341 26
		$sql .= implode(",\n", $columnStatements);
342
		$sql .= "\n);";
343 26
344
		return $sql;
345
	}
346
347
	/**
348
	 * Creates the entity as a table in the database
349 27
	 */
350
	public function createTable()
351 27
	{
352 26
		$this->pdo->query($this->buildCreateTableSQL());
353
	}
354
355
	/**
356
	 * builds a MySQL constraint statement for the given parameters
357
	 * @param string $parentTable
358
	 * @param string $parentColumn
359
	 * @param string $childTable
360
	 * @param string $childColumn
361
	 * @return string The MySQL table constraint string
362 4
	 */
363
	protected function buildConstraint($parentTable, $parentColumn, $childTable, $childColumn)
364
	{
365 4
		$template = <<<SQL
366
ALTER TABLE `%s`
367
ADD CONSTRAINT
368
FOREIGN KEY (`%s`)
369
REFERENCES `%s`(`%s`)
370
ON DELETE CASCADE;
371 4
SQL;
372
		return sprintf($template, $childTable, $childColumn, $parentTable, $parentColumn);
373
	}
374
375
	/**
376
	 * Iterates over the specified constraints in the table definition, 
377
	 * 		and applies these to the database.
378 1
	 */
379
	public function createTableConstraints()
380
	{
381 1
		// Iterate over columns, check whether "relation" field exists, if so create constraint
382 1
		foreach ($this->tableDefinition as $colName => $definition) {
383
			if (isset($definition['relation']) && $definition['relation'] instanceof AbstractActiveRecord) {
384 1
				// Forge new relation
385 1
				$target = $definition['relation'];
386
				$constraintSql = $this->buildConstraint($target->getTableName(), 'id', $this->getTableName(), $colName);
387 1
388
				$this->pdo->query($constraintSql);
389
			}
390 1
		}
391
	}
392
393
	/**
394
	 * Returns the name -> variable mapping for the table definition.
395
	 * @return Array The mapping
396 46
	 */
397
	protected function getActiveRecordColumns()
398 46
	{
399 46
		$bindings = [];
400
		foreach ($this->tableDefinition as $colName => $definition) {
401
402 46
			// Ignore the id column (key) when inserting or updating
403 46
			if ($colName == self::COLUMN_NAME_ID) {
404
				continue;
405
			}
406 46
407
			$bindings[$colName] = &$definition['value'];
408 46
		}
409
		return $bindings;
410
	}
411
412
	/**
413
	 * {@inheritdoc}
414 22
	 */
415
	public function create()
416 22
	{
417
		foreach ($this->registeredCreateHooks as $colName => $fn) {
418 3
			// @TODO: Would it be better to pass the Query to the function?
419
			$fn();
420
		}
421
422 22
		try {
423 22
			(new Query($this->getPdo(), $this->getTableName()))
424 22
				->insert($this->getActiveRecordColumns())
425
				->execute();
426 20
427 2
			$this->setId(intval($this->getPdo()->lastInsertId()));
428 2
		} catch (\PDOException $e) {
429
			throw new ActiveRecordException($e->getMessage(), 0, $e);
430
		}
431 20
432
		return $this;
433
	}
434
435
	/**
436
	 * {@inheritdoc}
437 17
	 */
438
	public function read($id)
439 17
	{
440
		foreach ($this->registeredReadHooks as $colName => $fn) {
441 1
			// @TODO: Would it be better to pass the Query to the function?
442
			$fn();
443
		}
444
445 17
		try {
446 17
			$row = (new Query($this->getPdo(), $this->getTableName()))
447 17
				->select()
448 17
				->where(Query::Equal('id', $id))
449 16
				->execute()
450
				->fetch();
451 16
452 3
			if ($row === false) {
453
				throw new ActiveRecordException(sprintf('Can not read the non-existent active record entry %d from the `%s` table.', $id, $this->getTableName()));
454
			}
455 13
456 4
			$this->fill($row)->setId($id);
457 1
		} catch (\PDOException $e) {
458
			throw new ActiveRecordException($e->getMessage(), 0, $e);
459
		}
460 13
461
		return $this;
462
	}
463
464
	/**
465
	 * {@inheritdoc}
466 8
	 */
467
	public function update()
468 8
	{
469
		foreach ($this->registeredUpdateHooks as $colName => $fn) {
470 2
			// @TODO: Would it be better to pass the Query to the function?
471
			$fn();
472
		}
473
474 8
		try {
475 8
			(new Query($this->getPdo(), $this->getTableName()))
476 8
				->update($this->getActiveRecordColumns())
477 8
				->where(Query::Equal('id', $this->getId()))
0 ignored issues
show
Bug introduced by
It seems like $this->getId() can also be of type integer; however, parameter $right of miBadger\Query\Query::Equal() does only seem to accept miBadger\Query\any, 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

477
				->where(Query::Equal('id', /** @scrutinizer ignore-type */ $this->getId()))
Loading history...
478 2
				->execute();
479 2
		} catch (\PDOException $e) {
480
			throw new ActiveRecordException($e->getMessage(), 0, $e);
481
		}
482 6
483
		return $this;
484
	}
485
486
	/**
487
	 * {@inheritdoc}
488 6
	 */
489
	public function delete()
490 6
	{
491
		foreach ($this->registeredDeleteHooks as $colName => $fn) {
492 1
			// @TODO: Would it be better to pass the Query to the function?
493
			$fn();
494
		}
495
496 6
		try {
497 6
			(new Query($this->getPdo(), $this->getTableName()))
498 6
				->delete()
499 6
				->where(Query::Equal('id', $this->getId()))
0 ignored issues
show
Bug introduced by
It seems like $this->getId() can also be of type integer; however, parameter $right of miBadger\Query\Query::Equal() does only seem to accept miBadger\Query\any, 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

499
				->where(Query::Equal('id', /** @scrutinizer ignore-type */ $this->getId()))
Loading history...
500
				->execute();
501 5
502 1
			$this->setId(null);
503 1
		} catch (\PDOException $e) {
504
			throw new ActiveRecordException($e->getMessage(), 0, $e);
505
		}
506 5
507
		return $this;
508
	}
509
510
	/**
511
	 * {@inheritdoc}
512 2
	 */
513
	public function sync()
514 2
	{
515 1
		if (!$this->exists()) {
516
			return $this->create();
517
		}
518 1
519
		return $this->update();
520
	}
521
522
	/**
523
	 * {@inheritdoc}
524 3
	 */
525
	public function exists()
526 3
	{
527
		return $this->getId() !== null;
528
	}
529
530
	/**
531
	 * {@inheritdoc}
532 26
	 */
533
	public function fill(array $attributes)
534 26
	{
535 26
		$columns = $this->getActiveRecordColumns();
536
		$columns['id'] = &$this->id;
537 26
538 26
		foreach ($attributes as $key => $value) {
539 26
			if (array_key_exists($key, $columns)) {
540
				$columns[$key] = $value;
541
			}
542
		}
543 26
544
		return $this;
545
	}
546
547
	/**
548
	 * {@inheritdoc}
549 17
	 */
550
	public function search(array $ignoredTraits = [])
551 17
	{
552 17
		$clauses = [];
553 2
		foreach ($this->registeredSearchHooks as $column => $fn) {
554 2
			if (!in_array($column, $ignoredTraits)) {
555
				$clauses[] = $fn();
556
			}
557
		}
558 17
559
		return new ActiveRecordQuery($this, $this->getTableName(), $clauses);
560
	}
561
562
	/**
563
	 * Returns the PDO.
564
	 *
565
	 * @return \PDO the PDO.
566 53
	 */
567
	public function getPdo()
568 53
	{
569
		return $this->pdo;
570
	}
571
572
	/**
573
	 * Set the PDO.
574
	 *
575
	 * @param \PDO $pdo
576
	 * @return $this
577 82
	 */
578
	protected function setPdo($pdo)
579 82
	{
580
		$this->pdo = $pdo;
581 82
582
		return $this;
583
	}
584
585
	/**
586
	 * Returns the ID.
587
	 *
588
	 * @return null|int The ID.
589 22
	 */
590
	public function getId()
591 22
	{
592
		return $this->id;
593
	}
594
595
	/**
596
	 * Set the ID.
597
	 *
598
	 * @param int $id
599
	 * @return $this
600 30
	 */
601
	protected function setId($id)
602 30
	{
603
		$this->id = $id;
604 30
605
		return $this;
606
	}
607
608
609
	public function newInstance()
610
	{
611
		return new static($this->pdo);
612
	}
613
614
	/**
615
	 * Returns the active record table.
616
	 *
617
	 * @return string the active record table name.
618
	 */
619
	abstract protected function getTableName();
620
621
	/**
622
	 * Returns the active record columns.
623
	 *
624
	 * @return array the active record columns.
625
	 */
626
	abstract protected function getTableDefinition();
627
}
628