Test Failed
Push — v2 ( 00665d...c532d8 )
by Berend
04:26
created

AbstractActiveRecord::insertDefaults()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 5
c 0
b 0
f 0
dl 0
loc 8
ccs 4
cts 4
cp 1
rs 9.6111
cc 5
nc 3
nop 0
crap 5
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 90
54
	/**
55 90
	 * Construct an abstract active record with the given PDO.
56 90
	 *
57
	 * @param \PDO $pdo
58 90
	 */
59
	public function __construct(\PDO $pdo)
60 90
	{
61 90
		$pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
62 90
		$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
63 90
64 90
		$this->setPdo($pdo);
65 90
66
		$this->createHooks = [];
67
		$this->readHooks = [];
68 90
		$this->updateHooks = [];
69
		$this->deleteHooks = [];
70
		$this->searchHooks = [];
71
		$this->tableDefinition = $this->getTableDefinition();
72
73
		// Extend table definition with default ID field, throw exception if field already exists
74 90
		if (array_key_exists('id', $this->tableDefinition)) {
75
			$message = "Table definition in record contains a field with name \"id\"";
76 90
			$message .= ", which is a reserved name by ActiveRecord";
77
			throw new ActiveRecordException($message, 0);
78 90
		}
79 90
80
		$this->tableDefinition[self::COLUMN_NAME_ID] =
81 90
		[
82
			'value' => &$this->id,
83 40
			'validate' => null,
84
			'type' => self::COLUMN_TYPE_ID,
85
			'properties' =>
86 40
				ColumnProperty::NOT_NULL
87
				| ColumnProperty::IMMUTABLE
88 5
				| ColumnProperty::AUTO_INCREMENT
89
				| ColumnProperty::PRIMARY_KEY
90
		];
91
	}
92 35
93 5
	private function checkHookConstraints($columnName, $hookMap)
94 5
	{
95 5
		// Check whether column exists
96
		if (!array_key_exists($columnName, $this->tableDefinition)) 
97 35
		{
98
			throw new ActiveRecordException("Hook is trying to register on non-existing column \"$columnName\"", 0);
99
		}
100
101
		// Enforcing 1 hook per table column
102
		if (array_key_exists($columnName, $hookMap)) {
103
			$message = "Hook is trying to register on an already registered column \"$columnName\", ";
104
			$message .= "do you have conflicting traits?";
105 7
			throw new ActiveRecordException($message, 0);
106
		}
107 7
	}
108
109 6
	public function registerHookOnAction($actionName, $columnName, $fn)
110 3
	{
111 3
		if (is_string($fn) && is_callable([$this, $fn])) {
112 2
			$fn = [$this, $fn];
113
		}
114 1
115
		if (!is_callable($fn)) { 
116 5
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
117
		}
118
119
		switch ($actionName) {
120
			case self::CREATE:
121
				$this->checkHookConstraints($columnName, $this->createHooks);
122
				$this->createHooks[$columnName] = $fn;
123
				break;
124 21
			case self::READ:
125
				$this->checkHookConstraints($columnName, $this->readHooks);
126 21
				$this->readHooks[$columnName] = $fn;
127
				break;
128 20
			case self::UPDATE:
129 17
				$this->checkHookConstraints($columnName, $this->updateHooks);
130 3
				$this->updateHooks[$columnName] = $fn;
131 2
				break;
132
			case self::DELETE:
133 1
				$this->checkHookConstraints($columnName, $this->deleteHooks);
134
				$this->deleteHooks[$columnName] = $fn;
135 19
				break;
136
			case self::SEARCH:
137
				$this->checkHookConstraints($columnName, $this->searchHooks);
138
				$this->searchHooks[$columnName] = $fn;
139
				break;
140
			default:
141
				throw new ActiveRecordException("Invalid action: Can not register hook on non-existing action");
142
		}
143 7
	}
144
145 7
	/**
146
	 * Register a new hook for a specific column that gets called before execution of the create() method
147 6
	 * Only one hook per column can be registered at a time
148 3
	 * @param string $columnName The name of the column that is registered.
149 3
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
150 2
	 */
151
	public function registerCreateHook($columnName, $fn)
152 1
	{
153
		$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 4
	public function registerReadHook($columnName, $fn)
163
	{
164 4
		$this->registerHookOnAction(self::READ, $columnName, $fn);
165
	}
166 3
167
	/**
168 3
	 * Register a new hook for a specific column that gets called before execution of the update() method
169 2
	 * Only one hook per column can be registered at a time
170
	 * @param string $columnName The name of the column that is registered.
171 1
	 * @param string|callable $fn Either a callable, or the name of a method on the inheriting object.
172
	 */
173 2
	public function registerUpdateHook($columnName, $fn)
174
	{
175
		$this->registerHookOnAction(self::UPDATE, $columnName, $fn);
176
	}
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 21
	 * @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 21
	 */
184
	public function registerDeleteHook($columnName, $fn)
185 20
	{
186 17
		$this->registerHookOnAction(self::DELETE, $columnName, $fn);
187 3
	}
188 2
189
	/**
190 1
	 * 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 19
	 * @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
	public function registerSearchHook($columnName, $fn)
196
	{
197
		$this->registerHookOnAction(self::SEARCH, $columnName, $fn);
198
	}
199 55
200
	/**
201 55
	 * Adds a new column definition to the table.
202 1
	 * @param string $columnName The name of the column that is registered.
203
	 * @param Array $definition The definition of that column.
204
	 */
205
	public function extendTableDefinition($columnName, $definition)
206 54
	{
207 1
		if ($this->tableDefinition === null) {
208 1
			throw new ActiveRecordException("tableDefinition is null, has parent been initialized in constructor?");
209 1
		}
210
211
		// Enforcing table can only be extended with new columns
212 54
		if (array_key_exists($columnName, $this->tableDefinition)) {
213 54
			$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
		$this->tableDefinition[$columnName] = $definition;
219 32
	}
220
221 32
	/**
222 32
	 * Creates the entity as a table in the database
223 1
	 */
224
	public function createTable()
225 31
	{
226 31
		$this->pdo->query(SchemaBuilder::buildCreateTableSQL($this->getTableName(), $this->tableDefinition));
227 31
	}
228 31
229 31
	/**
230 31
	 * Iterates over the specified constraints in the table definition, 
231 31
	 * 		and applies these to the database.
232 31
	 */
233
	public function createTableConstraints()
234 29
	{
235 29
		// Iterate over columns, check whether "relation" field exists, if so create constraint
236
		foreach ($this->tableDefinition as $colName => $definition) {
237
			if (isset($definition['relation']) && $definition['relation'] instanceof AbstractActiveRecord) {
238 29
				// Forge new relation
239
				$target = $definition['relation'];
240
				$constraintSql = SchemaBuilder::buildConstraint($target->getTableName(), 'id', $this->getTableName(), $colName);
241 13
242
				$this->pdo->query($constraintSql);
243
			} else if (isset($definition['relation'])) {
244
				$msg = sprintf("Relation constraint on column \"%s\" of table \"%s\" does not contain a valid ActiveRecord instance", 
245
					$colName,
246 13
					$this->getTableName());
247
				throw new ActiveRecordException($msg);
248
			}
249 13
		}
250
	}
251
252
	/**
253
	 * Returns the name -> variable mapping for the table definition.
254
	 * @return Array The mapping
255
	 */
256
	protected function getActiveRecordColumns()
257
	{
258
		$bindings = [];
259
		foreach ($this->tableDefinition as $colName => $definition) {
260
261 32
			// Ignore the id column (key) when inserting or updating
262
			if ($colName == self::COLUMN_NAME_ID) {
263 32
				continue;
264 31
			}
265 31
266
			$bindings[$colName] = &$definition['value'];
267 31
		}
268
		return $bindings;
269
	}
270 31
271 20
	protected function insertDefaults()
272
	{
273
		// Insert default values for not-null fields
274 31
		foreach ($this->tableDefinition as $colName => $colDef) {
275 31
			if ($colDef['value'] === null
276
				&& ($colDef['properties'] ?? 0) & ColumnProperty::NOT_NULL
277
				&& isset($colDef['default'])) {
278 31
				$this->tableDefinition[$colName]['value'] = $colDef['default'];
279 12
			}
280
		}		
281
	}
282 31
283 31
	/**
284
	 * {@inheritdoc}
285
	 */
286 31
	public function create()
287
	{
288
		foreach ($this->createHooks as $colName => $fn) {
289
			$fn();
290
		}
291
292
		$this->insertDefaults();
293
294
		try {
295 31
			(new Query($this->getPdo(), $this->getTableName()))
296
				->insert($this->getActiveRecordColumns())
297
				->execute();
298 31
299
			$this->setId(intval($this->getPdo()->lastInsertId()));
300 31
		} catch (\PDOException $e) {
301 31
			throw new ActiveRecordException($e->getMessage(), 0, $e);
302
		}
303
304 31
		return $this;
305 31
	}
306 31
307 31
	/**
308
	 * {@inheritdoc}
309
	 */
310 31
	public function read($id)
311
	{
312
		$whereConditions = [
313
			Query::Equal('id', $id)
314
		];
315
		foreach ($this->readHooks as $colName => $fn) {
316
			$cond = $fn();
317 32
			if ($cond !== null) {
318
				$whereConditions[] = $cond;
319 32
			}
320 32
		}
321
322 32
		try {
323 32
			$row = (new Query($this->getPdo(), $this->getTableName()))
324 32
				->select()
325 32
				->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

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