Passed
Push — v2 ( ede5d5...ff18b4 )
by Berend
05:08
created

AbstractActiveRecord::getColumnLength()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
eloc 3
c 0
b 0
f 0
dl 0
loc 6
ccs 0
cts 4
cp 0
rs 10
cc 2
nc 2
nop 1
crap 6
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 96
	public function __construct(\PDO $pdo)
60
	{
61 96
		$pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
62 96
		$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
63
64 96
		$this->setPdo($pdo);
65
66 96
		$this->createHooks = [];
67 96
		$this->readHooks = [];
68 96
		$this->updateHooks = [];
69 96
		$this->deleteHooks = [];
70 96
		$this->searchHooks = [];
71 96
		$this->tableDefinition = $this->getTableDefinition();
72
73
		// Extend table definition with default ID field, throw exception if field already exists
74 96
		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 96
		$this->tableDefinition[self::COLUMN_NAME_ID] =
81
		[
82 96
			'value' => &$this->id,
83
			'validate' => null,
84 96
			'type' => self::COLUMN_TYPE_ID,
85
			'properties' =>
86 96
				ColumnProperty::NOT_NULL
87 96
				| ColumnProperty::IMMUTABLE
88 96
				| ColumnProperty::AUTO_INCREMENT
89 96
				| ColumnProperty::PRIMARY_KEY
90
		];
91 96
	}
92
93 39
	private function checkHookConstraints($columnName, $hookMap)
94
	{
95
		// Check whether column exists
96 39
		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 34
		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 34
	}
108
109 44
	public function registerHookOnAction($actionName, $columnName, $fn)
110
	{
111 44
		if (is_string($fn) && is_callable([$this, $fn])) {
112 24
			$fn = [$this, $fn];
113
		}
114
115 44
		if (!is_callable($fn)) { 
116 5
			throw new ActiveRecordException("Provided hook on column \"$columnName\" is not callable", 0);
117
		}
118
119
		switch ($actionName) {
120 39
			case self::CREATE:
121 6
				$this->checkHookConstraints($columnName, $this->createHooks);
122 5
				$this->createHooks[$columnName] = $fn;
123 5
				break;
124 36
			case self::READ:
125 24
				$this->checkHookConstraints($columnName, $this->readHooks);
126 23
				$this->readHooks[$columnName] = $fn;
127 23
				break;
128 33
			case self::UPDATE:
129 6
				$this->checkHookConstraints($columnName, $this->updateHooks);
130 5
				$this->updateHooks[$columnName] = $fn;
131 5
				break;
132 27
			case self::DELETE:
133 3
				$this->checkHookConstraints($columnName, $this->deleteHooks);
134 2
				$this->deleteHooks[$columnName] = $fn;
135 2
				break;
136 24
			case self::SEARCH:
137 24
				$this->checkHookConstraints($columnName, $this->searchHooks);
138 23
				$this->searchHooks[$columnName] = $fn;
139 23
				break;
140
			default:
141
				throw new ActiveRecordException("Invalid action: Can not register hook on non-existing action");
142
		}
143 34
	}
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 25
	public function registerReadHook($columnName, $fn)
163
	{
164 25
		$this->registerHookOnAction(self::READ, $columnName, $fn);
165 23
	}
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 25
	public function registerSearchHook($columnName, $fn)
196
	{
197 25
		$this->registerHookOnAction(self::SEARCH, $columnName, $fn);
198 23
	}
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 60
	protected function extendTableDefinition($columnName, $definition)
206
	{
207 60
		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 59
		if (array_key_exists($columnName, $this->tableDefinition)) {
213
			$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 59
		$this->tableDefinition[$columnName] = $definition;
219 59
	}
220
221
	public function hasColumn(string $column) {
222
		return array_key_exists($column, $this->tableDefinition);
223
	}
224
225
	public function hasRelation(string $column, AbstractActiveRecord $record) {
226
		if (!$this->hasColumn($column)) {
227
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
228
		}
229
230
		$relation = $this->tableDefinition[$column]['relation'] ?? null;
231
		return $relation !== null && get_class($record) === get_class($relation);
232
	}
233
234
	public function hasProperty(string $column, $property) {
235
		if (!$this->hasColumn($column)) {
236
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
237
		}
238
239
		try {
240
			$enumValue = ColumnProperty::valueOf($property);
241
		} catch (\UnexpectedValueException $e) {
242
			throw new ActiveRecordException("Provided property \"$property\" is not a valid property", 0, $e);
243
		}
244
245
		$properties = $this->tableDefinition[$column]['properties'] ?? null;
246
247
		return $properties !== null && (($properties & $enumValue->getValue()) > 0);
248
	}
249
250
	public function getColumnType(string $column) {
251
		if (!$this->hasColumn($column)) {
252
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
253
		}
254
255
		return $this->tableDefinition[$column]['type'] ?? null;
256
	}
257
258
	public function getColumnLength(string $column) {
259
		if (!$this->hasColumn($column)) {
260
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
261
		}
262
263
		return $this->tableDefinition[$column]['length'] ?? null;
264
	}
265
266
	public function getDefault(string $column) {
267
		if (!$this->hasColumn($column)) {
268
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
269
		}
270
271
		return $this->tableDefinition[$column]['default'] ?? null;
272
	}
273
274
	public function validateColumn(string $column, $input) {
275
		if (!$this->hasColumn($column)) {
276
			throw new ActiveRecordException("Provided column \"$column\" does not exist in table definition", 0);
277
		}
278
279
		$fn = $this->tableDefinition[$column]['validate'] ?? null;
280
281
		if ($fn === null) {
282
			return [true, ''];
283
		}
284
285
		if (!is_callable($fn)) {
286
			throw new ActiveRecordException("Provided validation function is not callable", 0);
287
		}
288
289
		return $fn($input);
290
	}
291
292
	/**
293
	 * Creates the entity as a table in the database
294
	 */
295 39
	public function createTable()
296
	{
297 39
		$this->pdo->query(SchemaBuilder::buildCreateTableSQL($this->getTableName(), $this->tableDefinition));
298 38
	}
299
300
	/**
301
	 * Iterates over the specified constraints in the table definition, 
302
	 * 		and applies these to the database.
303
	 */
304 19
	public function createTableConstraints()
305
	{
306
		// Iterate over columns, check whether "relation" field exists, if so create constraint
307 19
		foreach ($this->tableDefinition as $colName => $definition) {
308 19
			if (isset($definition['relation']) && $definition['relation'] instanceof AbstractActiveRecord) {
309
				// Forge new relation
310 2
				$target = $definition['relation'];
311 2
				$properties = $definition['properties'] ?? 0;
312
313 2
				if ($properties & ColumnProperty::NOT_NULL) {
314 1
					$constraintSql = SchemaBuilder::buildConstraintOnDeleteCascade($target->getTableName(), 'id', $this->getTableName(), $colName);
315
				} else {
316 1
					$constraintSql = SchemaBuilder::buildConstraintOnDeleteSetNull($target->getTableName(), 'id', $this->getTableName(), $colName);
317
				}
318
319 2
				$this->pdo->query($constraintSql);
320 19
			} else if (isset($definition['relation'])) {
321 1
				$msg = sprintf("Relation constraint on column \"%s\" of table \"%s\" does not contain a valid ActiveRecord instance", 
322 1
					$colName,
323 1
					$this->getTableName());
324 19
				throw new ActiveRecordException($msg);
325
			}
326
		}
327 18
	}
328
329
	/**
330
	 * Returns the name -> variable mapping for the table definition.
331
	 * @return Array The mapping
332
	 */
333 57
	protected function getActiveRecordColumns()
334
	{
335 57
		$bindings = [];
336 57
		foreach ($this->tableDefinition as $colName => $definition) {
337
338
			// Ignore the id column (key) when inserting or updating
339 57
			if ($colName == self::COLUMN_NAME_ID) {
340 57
				continue;
341
			}
342
343 57
			$bindings[$colName] = &$definition['value'];
344
		}
345 57
		return $bindings;
346
	}
347
348 35
	protected function insertDefaults()
349
	{
350
		// Insert default values for not-null fields
351 35
		foreach ($this->tableDefinition as $colName => $colDef) {
352 35
			if ($colDef['value'] === null
353 35
				&& ($colDef['properties'] ?? 0) & ColumnProperty::NOT_NULL
354 35
				&& isset($colDef['default'])) {
355 35
				$this->tableDefinition[$colName]['value'] = $colDef['default'];
356
			}
357
		}		
358 35
	}
359
360
	/**
361
	 * {@inheritdoc}
362
	 */
363 30
	public function create()
364
	{
365 30
		foreach ($this->createHooks as $colName => $fn) {
366 3
			$fn();
367
		}
368
369 30
		$this->insertDefaults();
370
371
		try {
372 30
			(new Query($this->getPdo(), $this->getTableName()))
373 30
				->insert($this->getActiveRecordColumns())
374 30
				->execute();
375
376 28
			$this->setId(intval($this->getPdo()->lastInsertId()));
377 2
		} catch (\PDOException $e) {
378 2
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
379
		}
380
381 28
		return $this;
382
	}
383
384
	/**
385
	 * {@inheritdoc}
386
	 */
387 24
	public function read($id)
388
	{
389
		$whereConditions = [
390 24
			Query::Equal('id', $id)
391
		];
392 24
		foreach ($this->readHooks as $colName => $fn) {
393 9
			$cond = $fn();
394 9
			if ($cond !== null) {
395 9
				$whereConditions[] = $cond;
396
			}
397
		}
398
399
		try {
400 24
			$row = (new Query($this->getPdo(), $this->getTableName()))
401 24
				->select()
402 24
				->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

402
				->where(/** @scrutinizer ignore-type */ Query::AndArray($whereConditions))
Loading history...
403 24
				->execute()
404 23
				->fetch();
405
			
406 23
			if ($row === false) {
407 6
				$msg = sprintf('Can not read the non-existent active record entry %d from the `%s` table.', $id, $this->getTableName());
408 6
				throw new ActiveRecordException($msg, ActiveRecordException::NOT_FOUND);
409
			}
410
411 18
			$this->fill($row)->setId($id);
412 7
		} catch (\PDOException $e) {
413 1
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
414
		}
415
416 18
		return $this;
417
	}
418
419
	/**
420
	 * {@inheritdoc}
421
	 */
422 10
	public function update()
423
	{
424 10
		foreach ($this->updateHooks as $colName => $fn) {
425 2
			$fn();
426
		}
427
428
		try {
429 10
			(new Query($this->getPdo(), $this->getTableName()))
430 10
				->update($this->getActiveRecordColumns())
431 10
				->where(Query::Equal('id', $this->getId()))
432 10
				->execute();
433 2
		} catch (\PDOException $e) {
434 2
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
435
		}
436
437 8
		return $this;
438
	}
439
440
	/**
441
	 * {@inheritdoc}
442
	 */
443 7
	public function delete()
444
	{
445 7
		foreach ($this->deleteHooks as $colName => $fn) {
446 1
			$fn();
447
		}
448
449
		try {
450 7
			(new Query($this->getPdo(), $this->getTableName()))
451 7
				->delete()
452 7
				->where(Query::Equal('id', $this->getId()))
453 7
				->execute();
454
455 6
			$this->setId(null);
456 1
		} catch (\PDOException $e) {
457 1
			throw new ActiveRecordException($e->getMessage(), ActiveRecordException::DB_ERROR, $e);
458
		}
459
460 6
		return $this;
461
	}
462
463
	/**
464
	 * {@inheritdoc}
465
	 */
466 2
	public function sync()
467
	{
468 2
		if (!$this->exists()) {
469 1
			return $this->create();
470
		}
471
472 1
		return $this->update();
473
	}
474
475
	/**
476
	 * {@inheritdoc}
477
	 */
478 3
	public function exists()
479
	{
480 3
		return $this->getId() !== null;
481
	}
482
483
	/**
484
	 * {@inheritdoc}
485
	 */
486 32
	public function fill(array $attributes)
487
	{
488 32
		$columns = $this->getActiveRecordColumns();
489 32
		$columns['id'] = &$this->id;
490
491 32
		foreach ($attributes as $key => $value) {
492 32
			if (array_key_exists($key, $columns)) {
493 32
				$columns[$key] = $value;
494
			}
495
		}
496
497 32
		return $this;
498
	}
499
500
	/**
501
	 * Returns the serialized form of the specified columns
502
	 * 
503
	 * @return Array
504
	 */
505 10
	public function toArray(Array $fieldWhitelist)
506
	{
507 10
		$output = [];
508 10
		foreach ($this->tableDefinition as $colName => $definition) {
509 10
			if (in_array($colName, $fieldWhitelist)) {
510 10
				$output[$colName] = $definition['value'];
511
			}
512
		}
513
514 10
		return $output;
515
	}
516
517
	/**
518
	 * {@inheritdoc}
519
	 */
520 19
	public function search(array $ignoredTraits = [])
521
	{
522 19
		$clauses = [];
523 19
		foreach ($this->searchHooks as $column => $fn) {
524 3
			if (!in_array($column, $ignoredTraits)) {
525 3
				$clauses[] = $fn();
526
			}
527
		}
528
529 19
		return new ActiveRecordQuery($this, $clauses);
530
	}
531
532
	/**
533
	 * Returns the PDO.
534
	 *
535
	 * @return \PDO the PDO.
536
	 */
537 66
	public function getPdo()
538
	{
539 66
		return $this->pdo;
540
	}
541
542
	/**
543
	 * Set the PDO.
544
	 *
545
	 * @param \PDO $pdo
546
	 * @return $this
547
	 */
548 96
	protected function setPdo($pdo)
549
	{
550 96
		$this->pdo = $pdo;
551
552 96
		return $this;
553
	}
554
555
	/**
556
	 * Returns the ID.
557
	 *
558
	 * @return null|int The ID.
559
	 */
560 31
	public function getId()
561
	{
562 31
		return $this->id;
563
	}
564
565
	/**
566
	 * Set the ID.
567
	 *
568
	 * @param int $id
569
	 * @return $this
570
	 */
571 41
	protected function setId($id)
572
	{
573 41
		$this->id = $id;
574
575 41
		return $this;
576
	}
577
578
	public function getFinalTableDefinition()
579
	{
580
		return $this->tableDefinition;
581
	}
582
583 28
	public function newInstance()
584
	{
585 28
		return new static($this->pdo);
586
	}
587
588
	/**
589
	 * Returns the active record table.
590
	 *
591
	 * @return string the active record table name.
592
	 */
593
	abstract public function getTableName(): string;
594
595
	/**
596
	 * Returns the active record columns.
597
	 *
598
	 * @return array the active record columns.
599
	 */
600
	abstract protected function getTableDefinition(): Array;
601
}
602