Completed
Push — v2 ( e36f84...6821a7 )
by Berend
02:44
created

AbstractActiveRecord::delete()   A

Complexity

Conditions 3
Paths 6

Size

Total Lines 18
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 11
c 1
b 0
f 0
dl 0
loc 18
ccs 11
cts 11
cp 1
rs 9.9
cc 3
nc 6
nop 0
crap 3
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 93
	public function __construct(\PDO $pdo)
60
	{
61 93
		$pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
62 93
		$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
63
64 93
		$this->setPdo($pdo);
65
66 93
		$this->createHooks = [];
67 93
		$this->readHooks = [];
68 93
		$this->updateHooks = [];
69 93
		$this->deleteHooks = [];
70 93
		$this->searchHooks = [];
71 93
		$this->tableDefinition = $this->getTableDefinition();
72
73
		// Extend table definition with default ID field, throw exception if field already exists
74 93
		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 93
		$this->tableDefinition[self::COLUMN_NAME_ID] =
81
		[
82 93
			'value' => &$this->id,
83
			'validate' => null,
84 93
			'type' => self::COLUMN_TYPE_ID,
85
			'properties' =>
86 93
				ColumnProperty::NOT_NULL
87 93
				| ColumnProperty::IMMUTABLE
88 93
				| ColumnProperty::AUTO_INCREMENT
89 93
				| ColumnProperty::PRIMARY_KEY
90
		];
91 93
	}
92
93 36
	private function checkHookConstraints($columnName, $hookMap)
94
	{
95
		// Check whether column exists
96 36
		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 31
		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 31
	}
108
109 41
	public function registerHookOnAction($actionName, $columnName, $fn)
110
	{
111 41
		if (is_string($fn) && is_callable([$this, $fn])) {
112 21
			$fn = [$this, $fn];
113
		}
114
115 41
		if (!is_callable($fn)) { 
116 5
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
117
		}
118
119
		switch ($actionName) {
120 36
			case self::CREATE:
121 6
				$this->checkHookConstraints($columnName, $this->createHooks);
122 5
				$this->createHooks[$columnName] = $fn;
123 5
				break;
124 33
			case self::READ:
125 21
				$this->checkHookConstraints($columnName, $this->readHooks);
126 20
				$this->readHooks[$columnName] = $fn;
127 20
				break;
128 30
			case self::UPDATE:
129 6
				$this->checkHookConstraints($columnName, $this->updateHooks);
130 5
				$this->updateHooks[$columnName] = $fn;
131 5
				break;
132 24
			case self::DELETE:
133 3
				$this->checkHookConstraints($columnName, $this->deleteHooks);
134 2
				$this->deleteHooks[$columnName] = $fn;
135 2
				break;
136 21
			case self::SEARCH:
137 21
				$this->checkHookConstraints($columnName, $this->searchHooks);
138 20
				$this->searchHooks[$columnName] = $fn;
139 20
				break;
140
			default:
141
				throw new ActiveRecordException("Invalid action: Can not register hook on non-existing action");
142
		}
143 31
	}
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 22
	public function registerReadHook($columnName, $fn)
163
	{
164 22
		$this->registerHookOnAction(self::READ, $columnName, $fn);
165 20
	}
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 22
	public function registerSearchHook($columnName, $fn)
196
	{
197 22
		$this->registerHookOnAction(self::SEARCH, $columnName, $fn);
198 20
	}
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 57
	public function extendTableDefinition($columnName, $definition)
206
	{
207 57
		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 56
		if (array_key_exists($columnName, $this->tableDefinition)) {
213 1
			$message = "Table is being extended with a column that already exists, ";
214 1
			$message .= "\"$columnName\" conflicts with your table definition";
215 1
			throw new ActiveRecordException($message, 0);
216
		}
217
218 56
		$this->tableDefinition[$columnName] = $definition;
219 56
	}
220
221
	/**
222
	 * Creates the entity as a table in the database
223
	 */
224 35
	public function createTable()
225
	{
226 35
		$this->pdo->query(SchemaBuilder::buildCreateTableSQL($this->getTableName(), $this->tableDefinition));
227 34
	}
228
229
	/**
230
	 * Iterates over the specified constraints in the table definition, 
231
	 * 		and applies these to the database.
232
	 */
233 16
	public function createTableConstraints()
234
	{
235
		// Iterate over columns, check whether "relation" field exists, if so create constraint
236 16
		foreach ($this->tableDefinition as $colName => $definition) {
237 16
			if (isset($definition['relation']) && $definition['relation'] instanceof AbstractActiveRecord) {
238
				// Forge new relation
239 2
				$target = $definition['relation'];
240 2
				$properties = $definition['properties'] ?? 0;
241
242 2
				if ($properties & ColumnProperty::NOT_NULL) {
243 1
					$constraintSql = SchemaBuilder::buildConstraintOnDeleteCascade($target->getTableName(), 'id', $this->getTableName(), $colName);
244
				} else {
245 1
					$constraintSql = SchemaBuilder::buildConstraintOnDeleteSetNull($target->getTableName(), 'id', $this->getTableName(), $colName);
246
				}
247
248 2
				$this->pdo->query($constraintSql);
249 16
			} else if (isset($definition['relation'])) {
250 1
				$msg = sprintf("Relation constraint on column \"%s\" of table \"%s\" does not contain a valid ActiveRecord instance", 
251 1
					$colName,
252 1
					$this->getTableName());
253 16
				throw new ActiveRecordException($msg);
254
			}
255
		}
256 15
	}
257
258
	/**
259
	 * Returns the name -> variable mapping for the table definition.
260
	 * @return Array The mapping
261
	 */
262 54
	protected function getActiveRecordColumns()
263
	{
264 54
		$bindings = [];
265 54
		foreach ($this->tableDefinition as $colName => $definition) {
266
267
			// Ignore the id column (key) when inserting or updating
268 54
			if ($colName == self::COLUMN_NAME_ID) {
269 54
				continue;
270
			}
271
272 54
			$bindings[$colName] = &$definition['value'];
273
		}
274 54
		return $bindings;
275
	}
276
277 32
	protected function insertDefaults()
278
	{
279
		// Insert default values for not-null fields
280 32
		foreach ($this->tableDefinition as $colName => $colDef) {
281 32
			if ($colDef['value'] === null
282 32
				&& ($colDef['properties'] ?? 0) & ColumnProperty::NOT_NULL
283 32
				&& isset($colDef['default'])) {
284 32
				$this->tableDefinition[$colName]['value'] = $colDef['default'];
285
			}
286
		}		
287 32
	}
288
289
	/**
290
	 * {@inheritdoc}
291
	 */
292 30
	public function create()
293
	{
294 30
		foreach ($this->createHooks as $colName => $fn) {
295 3
			$fn();
296
		}
297
298 30
		$this->insertDefaults();
299
300
		try {
301 30
			(new Query($this->getPdo(), $this->getTableName()))
302 30
				->insert($this->getActiveRecordColumns())
303 30
				->execute();
304
305 28
			$this->setId(intval($this->getPdo()->lastInsertId()));
306 2
		} catch (\PDOException $e) {
307 2
			throw new ActiveRecordException($e->getMessage(), 0, $e);
308
		}
309
310 28
		return $this;
311
	}
312
313
	/**
314
	 * {@inheritdoc}
315
	 */
316 23
	public function read($id)
317
	{
318
		$whereConditions = [
319 23
			Query::Equal('id', $id)
320
		];
321 23
		foreach ($this->readHooks as $colName => $fn) {
322 8
			$cond = $fn();
323 8
			if ($cond !== null) {
324 8
				$whereConditions[] = $cond;
325
			}
326
		}
327
328
		try {
329 23
			$row = (new Query($this->getPdo(), $this->getTableName()))
330 23
				->select()
331 23
				->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

331
				->where(/** @scrutinizer ignore-type */ Query::AndArray($whereConditions))
Loading history...
332 23
				->execute()
333 22
				->fetch();
334
			
335 22
			if ($row === false) {
336 5
				throw new ActiveRecordException(sprintf('Can not read the non-existent active record entry %d from the `%s` table.', $id, $this->getTableName()));	
337
			}
338
339 18
			$this->fill($row)->setId($id);
340 6
		} catch (\PDOException $e) {
341 1
			throw new ActiveRecordException($e->getMessage(), 0, $e);
342
		}
343
344 18
		return $this;
345
	}
346
347
	/**
348
	 * {@inheritdoc}
349
	 */
350 10
	public function update()
351
	{
352 10
		foreach ($this->updateHooks as $colName => $fn) {
353 2
			$fn();
354
		}
355
356
		try {
357 10
			(new Query($this->getPdo(), $this->getTableName()))
358 10
				->update($this->getActiveRecordColumns())
359 10
				->where(Query::Equal('id', $this->getId()))
360 10
				->execute();
361 2
		} catch (\PDOException $e) {
362 2
			throw new ActiveRecordException($e->getMessage(), 0, $e);
363
		}
364
365 8
		return $this;
366
	}
367
368
	/**
369
	 * {@inheritdoc}
370
	 */
371 7
	public function delete()
372
	{
373 7
		foreach ($this->deleteHooks as $colName => $fn) {
374 1
			$fn();
375
		}
376
377
		try {
378 7
			(new Query($this->getPdo(), $this->getTableName()))
379 7
				->delete()
380 7
				->where(Query::Equal('id', $this->getId()))
381 7
				->execute();
382
383 6
			$this->setId(null);
384 1
		} catch (\PDOException $e) {
385 1
			throw new ActiveRecordException($e->getMessage(), 0, $e);
386
		}
387
388 6
		return $this;
389
	}
390
391
	/**
392
	 * {@inheritdoc}
393
	 */
394 2
	public function sync()
395
	{
396 2
		if (!$this->exists()) {
397 1
			return $this->create();
398
		}
399
400 1
		return $this->update();
401
	}
402
403
	/**
404
	 * {@inheritdoc}
405
	 */
406 3
	public function exists()
407
	{
408 3
		return $this->getId() !== null;
409
	}
410
411
	/**
412
	 * {@inheritdoc}
413
	 */
414 32
	public function fill(array $attributes)
415
	{
416 32
		$columns = $this->getActiveRecordColumns();
417 32
		$columns['id'] = &$this->id;
418
419 32
		foreach ($attributes as $key => $value) {
420 32
			if (array_key_exists($key, $columns)) {
421 32
				$columns[$key] = $value;
422
			}
423
		}
424
425 32
		return $this;
426
	}
427
428
	/**
429
	 * Returns the serialized form of the specified columns
430
	 * 
431
	 * @return Array
432
	 */
433 7
	public function toArray(Array $fieldWhitelist)
434
	{
435 7
		$output = [];
436 7
		foreach ($this->tableDefinition as $colName => $definition) {
437 7
			if (in_array($colName, $fieldWhitelist)) {
438 7
				$output[$colName] = $definition['value'];
439
			}
440
		}
441
442 7
		return $output;
443
	}
444
445
	/**
446
	 * {@inheritdoc}
447
	 */
448 19
	public function search(array $ignoredTraits = [])
449
	{
450 19
		$clauses = [];
451 19
		foreach ($this->searchHooks as $column => $fn) {
452 3
			if (!in_array($column, $ignoredTraits)) {
453 3
				$clauses[] = $fn();
454
			}
455
		}
456
457 19
		return new ActiveRecordQuery($this, $clauses);
458
	}
459
460
	/**
461
	 * Returns the PDO.
462
	 *
463
	 * @return \PDO the PDO.
464
	 */
465 62
	public function getPdo()
466
	{
467 62
		return $this->pdo;
468
	}
469
470
	/**
471
	 * Set the PDO.
472
	 *
473
	 * @param \PDO $pdo
474
	 * @return $this
475
	 */
476 93
	protected function setPdo($pdo)
477
	{
478 93
		$this->pdo = $pdo;
479
480 93
		return $this;
481
	}
482
483
	/**
484
	 * Returns the ID.
485
	 *
486
	 * @return null|int The ID.
487
	 */
488 30
	public function getId()
489
	{
490 30
		return $this->id;
491
	}
492
493
	/**
494
	 * Set the ID.
495
	 *
496
	 * @param int $id
497
	 * @return $this
498
	 */
499 38
	protected function setId($id)
500
	{
501 38
		$this->id = $id;
502
503 38
		return $this;
504
	}
505
506
	public function getFinalTableDefinition()
507
	{
508
		return $this->tableDefinition;
509
	}
510
511 25
	public function newInstance()
512
	{
513 25
		return new static($this->pdo);
514
	}
515
516
	/**
517
	 * Returns the active record table.
518
	 *
519
	 * @return string the active record table name.
520
	 */
521
	abstract public function getTableName(): string;
522
523
	/**
524
	 * Returns the active record columns.
525
	 *
526
	 * @return array the active record columns.
527
	 */
528
	abstract protected function getTableDefinition(): Array;
529
}
530