Passed
Push — v2 ( e57a7e...6c89a0 )
by Berend
04:02
created

AbstractActiveRecord::create()   B

Complexity

Conditions 9
Paths 18

Size

Total Lines 27
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 9

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 16
c 1
b 0
f 0
dl 0
loc 27
ccs 16
cts 16
cp 1
rs 8.0555
cc 9
nc 18
nop 0
crap 9
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 86
	public function __construct(\PDO $pdo)
54
	{
55 86
		$pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
56 86
		$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
57
58 86
		$this->setPdo($pdo);
59
60 86
		$this->registeredCreateHooks = [];
61 86
		$this->registeredReadHooks = [];
62 86
		$this->registeredUpdateHooks = [];
63 86
		$this->registeredDeleteHooks = [];
64 86
		$this->registeredSearchHooks = [];
65 86
		$this->tableDefinition = $this->getTableDefinition();
66
67
		// Extend table definition with default ID field, throw exception if field already exists
68 86
		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
74 86
		$this->tableDefinition[self::COLUMN_NAME_ID] =
75
		[
76 86
			'value' => &$this->id,
77
			'validate' => null,
78 86
			'type' => self::COLUMN_TYPE_ID,
79 86
			'properties' => ColumnProperty::NOT_NULL | ColumnProperty::IMMUTABLE | ColumnProperty::AUTO_INCREMENT | ColumnProperty::PRIMARY_KEY
80
		];
81 86
	}
82
83 37
	private function checkHookConstraints($columnName, $hookMap)
84
	{
85
		// Check whether column exists
86 37
		if (!array_key_exists($columnName, $this->tableDefinition)) 
87
		{
88 5
			throw new ActiveRecordException("Hook is trying to register on non-existing column \"$columnName\"", 0);
89
		}
90
91
		// Enforcing 1 hook per table column
92 32
		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 5
			throw new ActiveRecordException($message, 0);
96
		}
97 32
	}
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
	 */
105 7
	public function registerCreateHook($columnName, $fn)
106
	{
107 7
		$this->checkHookConstraints($columnName, $this->registeredCreateHooks);
108
109 6
		if (is_string($fn) && is_callable([$this, $fn])) {
110 3
			$this->registeredCreateHooks[$columnName] = [$this, $fn];
111 3
		} else if (is_callable($fn)) {
112 2
			$this->registeredCreateHooks[$columnName] = $fn;
113
		} else {
114 1
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
115
		}
116 5
	}
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
	 */
124 4
	public function registerReadHook($columnName, $fn)
125
	{
126 4
		$this->checkHookConstraints($columnName, $this->registeredReadHooks);
127
128 3
		if (is_string($fn) && is_callable([$this, $fn])) {
129
			$this->registeredReadHooks[$columnName] = [$this, $fn];
130 3
		} else if (is_callable($fn)) {
131 2
			$this->registeredReadHooks[$columnName] = $fn;
132
		} else {
133 1
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
134
		}
135 2
	}
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
	 */
143 7
	public function registerUpdateHook($columnName, $fn)
144
	{
145 7
		$this->checkHookConstraints($columnName, $this->registeredUpdateHooks);
146
147 6
		if (is_string($fn) && is_callable([$this, $fn])) {
148 3
			$this->registeredUpdateHooks[$columnName] = [$this, $fn];
149 3
		} else if (is_callable($fn)) {
150 2
			$this->registeredUpdateHooks[$columnName] = $fn;
151
		} else {
152 1
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
153
		}
154 5
	}
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
	 */
162 4
	public function registerDeleteHook($columnName, $fn)
163
	{
164 4
		$this->checkHookConstraints($columnName, $this->registeredDeleteHooks);
165
166 3
		if (is_string($fn) && is_callable([$this, $fn])) {
167
			$this->registeredDeleteHooks[$columnName] = [$this, $fn];
168 3
		} else if (is_callable($fn)) {
169 2
			$this->registeredDeleteHooks[$columnName] = $fn;
170
		} else {
171 1
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
172
		}
173 2
	}
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
	 */
181 18
	public function registerSearchHook($columnName, $fn)
182
	{
183 18
		$this->checkHookConstraints($columnName, $this->registeredSearchHooks);
184
185 17
		if (is_string($fn) && is_callable([$this, $fn])) {
186 14
			$this->registeredSearchHooks[$columnName] = [$this, $fn];
187 3
		} else if (is_callable($fn)) {
188 2
			$this->registeredSearchHooks[$columnName] = $fn;
189
		} else {
190 1
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
191
		}
192 16
	}
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
	 */
199 52
	public function extendTableDefinition($columnName, $definition)
200
	{
201 52
		if ($this->tableDefinition === null) {
202 1
			throw new ActiveRecordException("tableDefinition is null, most likely due to parent class not having been initialized in constructor");
203
		}
204
205
		// Enforcing table can only be extended with new columns
206 51
		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 1
			throw new ActiveRecordException($message, 0);
210
		}
211
212 51
		$this->tableDefinition[$columnName] = $definition;
213 51
	}
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
	 */
219 31
	private function getDatabaseTypeString($colName, $type, $length)
220
	{
221 31
		switch (strtoupper($type)) {
222 31
			case '':
223 1
				throw new ActiveRecordException(sprintf("Column %s has invalid type \"NULL\"", $colName));
224
			
225 30
			case 'BOOL';
226 30
			case 'BOOLEAN':
227 30
			case 'DATETIME':
228 30
			case 'DATE':
229 30
			case 'TIME':
230 30
			case 'TEXT':
231 30
			case 'INT UNSIGNED':
232 30
				return $type;
233
234 28
			case 'VARCHAR':
235 28
				if ($length === null) {
236
					throw new ActiveRecordException(sprintf("field type %s requires specified column field \"LENGTH\"", $colName));
237
				} else {
238 28
					return sprintf('%s(%d)', $type, $length);	
239
				}
240
241 12
			case 'INT':
242
			case 'TINYINT':
243
			case 'BIGINT':
244
			default: 	
245
				// Implicitly assuming that non-specified cases are correct without a length parameter
246 12
				if ($length === null) {
247
					return $type;
248
				} else {
249 12
					return sprintf('%s(%d)', $type, $length);	
250
				}
251
		}
252
	}
253
254
	/**
255
	 * Builds the part of a MySQL create table statement that corresponds to the supplied column
256
	 * @param string $colName 	Name of the database column
257
	 * @param string $type 		The type of the string
258
	 * @param int $properties 	The set of Column properties that apply to this column (See ColumnProperty for options)
259
	 * @return string
260
	 */
261 31
	private function buildCreateTableColumnEntry($colName, $type, $length, $properties, $default)
262
	{
263 31
		$stmnt = sprintf('`%s` %s ', $colName, $this->getDatabaseTypeString($colName, $type, $length));
264 30
		if ($properties & ColumnProperty::NOT_NULL) {
265 30
			$stmnt .= 'NOT NULL ';
266
		} else {
267 30
			$stmnt .= 'NULL ';
268
		}
269
270 30
		if ($default !== NULL) {
271 19
			$stmnt .= 'DEFAULT ' . var_export($default, true) . ' ';
272
		}
273
274 30
		if ($properties & ColumnProperty::AUTO_INCREMENT) {
275 30
			$stmnt .= 'AUTO_INCREMENT ';
276
		}
277
278 30
		if ($properties & ColumnProperty::UNIQUE) {
279 11
			$stmnt .= 'UNIQUE ';
280
		}
281
282 30
		if ($properties & ColumnProperty::PRIMARY_KEY) {
283 30
			$stmnt .= 'PRIMARY KEY ';
284
		}
285
286 30
		return $stmnt;
287
	}
288
289
	/**
290
	 * Sorts the column statement components in the order such that the id appears first, 
291
	 * 		followed by all other columns in alphabetical ascending order
292
	 * @param   Array $colStatements Array of column statements
293
	 * @return  Array
294
	 */
295 30
	private function sortColumnStatements($colStatements)
296
	{
297
		// Find ID statement and put it first
298 30
		$sortedStatements = [];
299
300 30
		$sortedStatements[] = $colStatements[self::COLUMN_NAME_ID];
301 30
		unset($colStatements[self::COLUMN_NAME_ID]);
302
303
		// Sort remaining columns in alphabetical order
304 30
		$columns = array_keys($colStatements);
305 30
		sort($columns);
306 30
		foreach ($columns as $colName) {
307 30
			$sortedStatements[] = $colStatements[$colName];
308
		}
309
310 30
		return $sortedStatements;
311
	}
312
313
	/**
314
	 * Builds the MySQL Create Table statement for the internal table definition
315
	 * @return string
316
	 */
317 31
	public function buildCreateTableSQL()
318
	{
319 31
		$columnStatements = [];
320 31
		foreach ($this->tableDefinition as $colName => $definition) {
321
			// Destructure column definition
322 31
			$type    = $definition['type'] ?? null;
323 31
			$default = $definition['default'] ?? null;
324 31
			$length  = $definition['length'] ?? null;
325 31
			$properties = $definition['properties'] ?? null;
326
327 31
			if (isset($definition['relation']) && $type !== null) {
328
				$tableName = $this->getTableName();
329
				$msg = "Column \"$colName\" on table \"$tableName\": ";
330
				$msg .= "Relationship columns have an automatically inferred type, so type should be omitted";
331
				throw new ActiveRecordException($msg);
332 31
			} else if (isset($definition['relation'])) {
333 3
				$type = self::COLUMN_TYPE_ID;
334
			}
335
336 31
			$columnStatements[$colName] = $this->buildCreateTableColumnEntry($colName, $type, $length, $properties, $default);
337
		}
338
339
		// Sort table (first column is id, the remaining are alphabetically sorted)
340 30
		$columnStatements = $this->sortColumnStatements($columnStatements);
341
342 30
		$sql = sprintf("CREATE TABLE %s (\n%s\n);", 
343 30
			$this->getTableName(), 
344 30
			implode(",\n", $columnStatements));
345
346 30
		return $sql;
347
	}
348
349
	/**
350
	 * Creates the entity as a table in the database
351
	 */
352 31
	public function createTable()
353
	{
354 31
		$this->pdo->query($this->buildCreateTableSQL());
355 30
	}
356
357
	/**
358
	 * builds a MySQL constraint statement for the given parameters
359
	 * @param string $parentTable
360
	 * @param string $parentColumn
361
	 * @param string $childTable
362
	 * @param string $childColumn
363
	 * @return string The MySQL table constraint string
364
	 */
365 4
	protected function buildConstraint($parentTable, $parentColumn, $childTable, $childColumn)
366
	{
367
		$template = <<<SQL
368 4
ALTER TABLE `%s`
369
ADD CONSTRAINT
370
FOREIGN KEY (`%s`)
371
REFERENCES `%s`(`%s`)
372
ON DELETE CASCADE;
373
SQL;
374 4
		return sprintf($template, $childTable, $childColumn, $parentTable, $parentColumn);
375
	}
376
377
	/**
378
	 * Iterates over the specified constraints in the table definition, 
379
	 * 		and applies these to the database.
380
	 */
381 2
	public function createTableConstraints()
382
	{
383
		// Iterate over columns, check whether "relation" field exists, if so create constraint
384 2
		foreach ($this->tableDefinition as $colName => $definition) {
385 2
			if (isset($definition['relation']) && $definition['relation'] instanceof AbstractActiveRecord) {
386
				// Forge new relation
387 1
				$target = $definition['relation'];
388 1
				$constraintSql = $this->buildConstraint($target->getTableName(), 'id', $this->getTableName(), $colName);
389
390 1
				$this->pdo->query($constraintSql);
391 2
			} else if (isset($definition['relation'])) {
392 1
				$msg = sprintf("Relation constraint on column \"%s\" of table \"%s\" does not contain a valid ActiveRecord instance", 
393 1
					$colName,
394 1
					$this->getTableName());
395 2
				throw new ActiveRecordException($msg);
396
			}
397
		}
398 1
	}
399
400
	/**
401
	 * Returns the name -> variable mapping for the table definition.
402
	 * @return Array The mapping
403
	 */
404 49
	protected function getActiveRecordColumns()
405
	{
406 49
		$bindings = [];
407 49
		foreach ($this->tableDefinition as $colName => $definition) {
408
409
			// Ignore the id column (key) when inserting or updating
410 49
			if ($colName == self::COLUMN_NAME_ID) {
411 49
				continue;
412
			}
413
414 49
			$bindings[$colName] = &$definition['value'];
415
		}
416 49
		return $bindings;
417
	}
418
419
	/**
420
	 * {@inheritdoc}
421
	 */
422 25
	public function create()
423
	{
424 25
		foreach ($this->registeredCreateHooks as $colName => $fn) {
425 3
			$fn();
426
		}
427
428
		// Insert default values for not-null fields
429 25
		foreach ($this->tableDefinition as $colName => $colDef) {
430 25
			if ($this->tableDefinition[$colName]['value'] === null
431 25
				&& isset($this->tableDefinition[$colName]['properties'])
432 25
				&& $this->tableDefinition[$colName]['properties'] && ColumnProperty::NOT_NULL > 0
433 25
				&& isset($this->tableDefinition[$colName]['default'])) {
434 25
				$this->tableDefinition[$colName]['value'] = $this->tableDefinition[$colName]['default'];
435
			}
436
		}
437
438
		try {
439 25
			(new Query($this->getPdo(), $this->getTableName()))
440 25
				->insert($this->getActiveRecordColumns())
441 25
				->execute();
442
443 23
			$this->setId(intval($this->getPdo()->lastInsertId()));
444 2
		} catch (\PDOException $e) {
445 2
			throw new ActiveRecordException($e->getMessage(), 0, $e);
446
		}
447
448 23
		return $this;
449
	}
450
451
	/**
452
	 * {@inheritdoc}
453
	 */
454 19
	public function read($id)
455
	{
456 19
		foreach ($this->registeredReadHooks as $colName => $fn) {
457 1
			$fn();
458
		}
459
460
		try {
461 19
			$row = (new Query($this->getPdo(), $this->getTableName()))
462 19
				->select()
463 19
				->where(Query::Equal('id', $id))
464 19
				->execute()
465 18
				->fetch();
466
467 18
			if ($row === false) {
468 3
				throw new ActiveRecordException(sprintf('Can not read the non-existent active record entry %d from the `%s` table.', $id, $this->getTableName()));
469
			}
470
471 15
			$this->fill($row)->setId($id);
472 4
		} catch (\PDOException $e) {
473 1
			throw new ActiveRecordException($e->getMessage(), 0, $e);
474
		}
475
476 15
		return $this;
477
	}
478
479
	/**
480
	 * {@inheritdoc}
481
	 */
482 9
	public function update()
483
	{
484 9
		foreach ($this->registeredUpdateHooks as $colName => $fn) {
485 2
			$fn();
486
		}
487
488
		try {
489 9
			(new Query($this->getPdo(), $this->getTableName()))
490 9
				->update($this->getActiveRecordColumns())
491 9
				->where(Query::Equal('id', $this->getId()))
492 9
				->execute();
493 2
		} catch (\PDOException $e) {
494 2
			throw new ActiveRecordException($e->getMessage(), 0, $e);
495
		}
496
497 7
		return $this;
498
	}
499
500
	/**
501
	 * {@inheritdoc}
502
	 */
503 6
	public function delete()
504
	{
505 6
		foreach ($this->registeredDeleteHooks as $colName => $fn) {
506 1
			$fn();
507
		}
508
509
		try {
510 6
			(new Query($this->getPdo(), $this->getTableName()))
511 6
				->delete()
512 6
				->where(Query::Equal('id', $this->getId()))
513 6
				->execute();
514
515 5
			$this->setId(null);
516 1
		} catch (\PDOException $e) {
517 1
			throw new ActiveRecordException($e->getMessage(), 0, $e);
518
		}
519
520 5
		return $this;
521
	}
522
523
	/**
524
	 * {@inheritdoc}
525
	 */
526 2
	public function sync()
527
	{
528 2
		if (!$this->exists()) {
529 1
			return $this->create();
530
		}
531
532 1
		return $this->update();
533
	}
534
535
	/**
536
	 * {@inheritdoc}
537
	 */
538 3
	public function exists()
539
	{
540 3
		return $this->getId() !== null;
541
	}
542
543
	/**
544
	 * {@inheritdoc}
545
	 */
546 29
	public function fill(array $attributes)
547
	{
548 29
		$columns = $this->getActiveRecordColumns();
549 29
		$columns['id'] = &$this->id;
550
551 29
		foreach ($attributes as $key => $value) {
552 29
			if (array_key_exists($key, $columns)) {
553 29
				$columns[$key] = $value;
554
			}
555
		}
556
557 29
		return $this;
558
	}
559
560
	/**
561
	 * {@inheritdoc}
562
	 */
563 18
	public function search(array $ignoredTraits = [])
564
	{
565 18
		$clauses = [];
566 18
		foreach ($this->registeredSearchHooks as $column => $fn) {
567 3
			if (!in_array($column, $ignoredTraits)) {
568 3
				$clauses[] = $fn();
569
			}
570
		}
571
572 18
		return new ActiveRecordQuery($this, $this->getTableName(), $clauses);
573
	}
574
575
	/**
576
	 * Returns the PDO.
577
	 *
578
	 * @return \PDO the PDO.
579
	 */
580 56
	public function getPdo()
581
	{
582 56
		return $this->pdo;
583
	}
584
585
	/**
586
	 * Set the PDO.
587
	 *
588
	 * @param \PDO $pdo
589
	 * @return $this
590
	 */
591 86
	protected function setPdo($pdo)
592
	{
593 86
		$this->pdo = $pdo;
594
595 86
		return $this;
596
	}
597
598
	/**
599
	 * Returns the ID.
600
	 *
601
	 * @return null|int The ID.
602
	 */
603 26
	public function getId()
604
	{
605 26
		return $this->id;
606
	}
607
608
	/**
609
	 * Set the ID.
610
	 *
611
	 * @param int $id
612
	 * @return $this
613
	 */
614 33
	protected function setId($id)
615
	{
616 33
		$this->id = $id;
617
618 33
		return $this;
619
	}
620
621
622 23
	public function newInstance()
623
	{
624 23
		return new static($this->pdo);
625
	}
626
627
	/**
628
	 * Returns the active record table.
629
	 *
630
	 * @return string the active record table name.
631
	 */
632
	abstract protected function getTableName();
633
634
	/**
635
	 * Returns the active record columns.
636
	 *
637
	 * @return array the active record columns.
638
	 */
639
	abstract protected function getTableDefinition();
640
}
641