AutoApi   C
last analyzed

Complexity

Total Complexity 53

Size/Duplication

Total Lines 434
Duplicated Lines 0 %

Test Coverage

Coverage 93.42%

Importance

Changes 4
Bugs 1 Features 0
Metric Value
wmc 53
eloc 162
c 4
b 1
f 0
dl 0
loc 434
ccs 142
cts 152
cp 0.9342
rs 6.96

11 Methods

Rating   Name   Duplication   Size   Complexity  
A apiSearch() 0 43 5
A apiRead() 0 15 3
A validateExcessKeys() 0 13 3
A syncInstanceFrom() 0 4 2
C validateMissingKeys() 0 33 12
A apiCreate() 0 47 4
A loadData() 0 14 4
A apiUpdate() 0 45 4
A validateImmutableColumns() 0 14 4
A filterInputColumns() 0 9 3
B validateInputValues() 0 40 9

How to fix   Complexity   

Complex Class

Complex classes like AutoApi often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AutoApi, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace miBadger\ActiveRecord\Traits;
4
5
use miBadger\ActiveRecord\ColumnProperty;
6
use miBadger\ActiveRecord\ActiveRecordException;
7
use miBadger\Query\Query;
8
use miBadger\Query\QueryExpression;
9
10
trait AutoApi
11
{
12
	/* =======================================================================
13
	 * ===================== Automatic API Support ===========================
14
	 * ======================================================================= */
15
16
	/** @var array A map of column name to functions that hook the insert function */
17
	protected $createHooks;
18
19
	/** @var array A map of column name to functions that hook the read function */
20
	protected $readHooks;
21
22
	/** @var array A map of column name to functions that hook the update function */
23
	protected $updateHooks;
24
25
	/** @var array A map of column name to functions that hook the update function */
26
	protected $deleteHooks;	
27
28
	/** @var array A map of column name to functions that hook the search function */
29
	protected $searchHooks;
30
31
	/** @var array A list of table column definitions */
32
	protected $tableDefinition;
33
34
35
	/**
36
	 * @param Array $queryparams associative array of query params. Reserved options are
37
	 *                             "search_order_by", "search_order_direction", "search_limit", "search_offset"
38
	 *                             or column names corresponding to an instance of miBadger\Query\QueryExpression
39
	 * @param Array $fieldWhitelist names of the columns that will appear in the output results
40
	 * 
41
	 * @return Array an associative array containing the query parameters, and a data field containing an array of search results (associative arrays indexed by the keys in $fieldWhitelist)
42
	 */
43 1
	public function apiSearch(Array $queryParams, Array $fieldWhitelist, ?QueryExpression $whereClause = null, int $maxResultLimit = 100): Array
44
	{
45 1
		$query = $this->search();
0 ignored issues
show
Bug introduced by
It seems like search() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

45
		/** @scrutinizer ignore-call */ 
46
  $query = $this->search();
Loading history...
46
47
		// Build query
48 1
		$orderColumn = $queryParams['search_order_by'] ?? null;
49 1
		if (!in_array($orderColumn, $fieldWhitelist)) {
50
			$orderColumn = null;
51
		}
52
53 1
		$orderDirection = $queryParams['search_order_direction'] ?? null;
54 1
		if ($orderColumn !== null) {
55 1
			$query->orderBy($orderColumn, $orderDirection);
56
		}
57
		
58 1
		if ($whereClause !== null) {
59 1
			$query->where($whereClause);
60
		}
61
62 1
		$limit = min((int) ($queryParams['search_limit'] ?? $maxResultLimit), $maxResultLimit);
63 1
		$query->limit($limit);
64
65 1
		$offset = $queryParams['search_offset'] ?? 0;
66 1
		$query->offset($offset);
67
68 1
		$numPages = $query->getNumberOfPages();
69 1
		$currentPage = $query->getCurrentPage();
70
71
		// Fetch results
72 1
		$results = $query->fetchAll();
73 1
		$resultsArray = [];
74 1
		foreach ($results as $result) {
75 1
			$resultsArray[] = $result->toArray($fieldWhitelist);
76
		}
77
78
		return [
79 1
			'search_offset' => $offset,
80 1
			'search_limit' => $limit,
81 1
			'search_order_by' => $orderColumn,
82 1
			'search_order_direction' => $orderDirection,
83 1
			'search_pages' => $numPages,
84 1
			'search_current' => $currentPage,
85 1
			'data' => $resultsArray
86
		];
87
	}
88
89
	/**
90
	 * Performs a read call on the entity (modifying the current object) 
91
	 * 	and returns a result array ($error, $data), with an optional error, and the results array (as filtered by the whitelist)
92
	 * 	containing the loaded data.
93
	 * 
94
	 * @param string|int $id the id of the current entity
95
	 * @param Array $fieldWhitelist an array of fields that are allowed to appear in the output
96
	 * 
97
	 * @param Array [$error, $result]
0 ignored issues
show
Documentation Bug introduced by
The doc comment [$error, $result] at position 0 could not be parsed: Unknown type name '[' at position 0 in [$error, $result].
Loading history...
98
	 * 				Where $error contains the error message (@TODO: & Error type?)
99
	 * 				Where result is an associative array containing the data for this record, and the keys are a subset of $fieldWhitelist
100
	 * 				
101
	 */
102 2
	public function apiRead($id, Array $fieldWhitelist = []): Array
103
	{
104
		try {
105 2
			$this->read($id);	
106 1
		} catch (ActiveRecordException $e) {
107 1
			if ($e->getCode() === ActiveRecordException::NOT_FOUND) {
108
				$err = [
109 1
					'type' => 'invalid',
110 1
					'message' => $e->getMessage()
111
				];
112 1
				return [$err, null];
113
			}
114
			throw $e;
115
		}
116 1
		return [null, $this->toArray($fieldWhitelist)];
117
	}
118
119
	/* =============================================================
120
	 * ===================== Constraint validation =================
121
	 * ============================================================= */
122
123
	/**
124
	 * Copy all table variables between two instances
125
	 */
126 10
	public function syncInstanceFrom($from)
127
	{
128 10
		foreach ($this->tableDefinition as $colName => $definition) {
129 10
			$this->tableDefinition[$colName]['value'] = $from->tableDefinition[$colName]['value'];
130
		}
131 10
	}
132
133 14
	private function filterInputColumns($input, $whitelist)
134
	{
135 14
		$filteredInput = $input;
136 14
		foreach ($input as $colName => $value) {
137 14
			if (!in_array($colName, $whitelist)) {
138
				unset($filteredInput[$colName]);
139
			}
140
		}
141 14
		return $filteredInput;
142
	}
143
144 14
	private function validateExcessKeys($input)
145
	{
146 14
		$errors = [];
147 14
		foreach ($input as $colName => $value) {
148 14
			if (!array_key_exists($colName, $this->tableDefinition)) {
149 1
				$errors[$colName] = [
150
					'type' => 'unknown_field',
151
					'message' => 'Unknown input field'
152
				];
153 1
				continue;
154
			}
155
		}
156 14
		return $errors;
157
	}
158
159 6
	private function validateImmutableColumns($input)
160
	{
161 6
		$errors = [];
162 6
		foreach ($this->tableDefinition as $colName => $definition) {
163 6
			$property = $definition['properties'] ?? null;
164 6
			if (array_key_exists($colName, $input)
165 6
				&& $property & ColumnProperty::IMMUTABLE) {
166 1
				$errors[$colName] = [
167
					'type' => 'immutable',
168
					'message' => 'Value cannot be changed'
169
				];
170
			}
171
		}
172 6
		return $errors;
173
	}
174
175
	/**
176
	 * Checks whether input values are correct:
177
	 * 1. Checks whether a value passes the validation function for that column
178
	 * 2. Checks whether a value supplied to a relationship column is a valid value
179
	 */
180 14
	private function validateInputValues($input)
181
	{
182 14
		$errors = [];
183 14
		foreach ($this->tableDefinition as $colName => $definition) {
184
			// Validation check 1: If validate function is present
185 14
			if (array_key_exists($colName, $input) 
186 14
				&& is_callable($definition['validate'] ?? null)) {
187 8
				$inputValue = $input[$colName];
188
189
				// If validation function fails
190 8
				[$status, $message] = $definition['validate']($inputValue);
191 8
				if (!$status) {
192 2
					$errors[$colName] = [
193 2
						'type' => 'invalid',
194 2
						'message' => $message
195
					];
196
				}	
197
			}
198
199
			// Validation check 2: If relation column, check whether entity exists
200 14
			$properties = $definition['properties'] ?? null;
201 14
			if (isset($definition['relation'])
202 14
				&& ($properties & ColumnProperty::NOT_NULL)) {
203
				
204
				try {
205 1
					if ($definition['relation'] instanceof AbstractActiveRecord) {
0 ignored issues
show
Bug introduced by
The type miBadger\ActiveRecord\Traits\AbstractActiveRecord was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
206
						$instance = $definition['relation'];
207
					} else {
208 1
						$instance = new $definition['relation']($this->pdo);	
209
					}
210 1
					$instance->read($input[$colName] ?? $definition['value'] ?? null);
211 1
				} catch (ActiveRecordException $e) {
212 1
					$errors[$colName] = [
213
						'type' => 'invalid',
214
						'message' => 'Entry for this value does not exist'
215
					];
216
				}
217
			}
218
		}
219 14
		return $errors;
220
	}
221
222
	/**
223
	 * This function is only used for API Update calls (direct getter/setter functions are unconstrained)
224
	 * Determines whether there are required columns for which no data is provided
225
	 */
226 14
	private function validateMissingKeys($input)
227
	{
228 14
		$errors = [];
229
230 14
		foreach ($this->tableDefinition as $colName => $colDefinition) {
231 14
			$default = $colDefinition['default'] ?? null;
232 14
			$properties = $colDefinition['properties'] ?? null;
233 14
			$value = $colDefinition['value'];
234
235
			// If nullable and default not set => null
236
			// If nullable and default null => default (null)
237
			// If nullable and default set => default (value)
238
239
			// if not nullable and default not set => error
240
			// if not nullable and default null => error
241
			// if not nullable and default st => default (value)
242
			// => if not nullable and default null and value not set (or null) => error message in this method
243 14
			if ($properties & ColumnProperty::NOT_NULL
244 14
				&& $default === null
245 14
				&& !($properties & ColumnProperty::AUTO_INCREMENT)
246 11
				&& (!array_key_exists($colName, $input) 
247 8
					|| $input[$colName] === null 
248 14
					|| (is_string($input[$colName]) && $input[$colName] === '') )
249 6
				&& ($value === null
250 14
					|| (is_string($value) && $value === ''))) {
251 4
				$errors[$colName] = [
252 4
					'type' => 'missing',
253 4
					'message' => sprintf("The required field \"%s\" is missing", $colName)
254
				];
255
			} 
256
		}
257
258 14
		return $errors;
259
	}
260
261
	/**
262
	 * Copies the values for entries in the input with matching variable names in the record definition
263
	 * @param Array $input The input data to be loaded into $this record
264
	 */
265 14
	private function loadData($input)
266
	{
267 14
		foreach ($this->tableDefinition as $colName => $definition) {
268
			// Skip if this table column does not appear in the input
269 14
			if (!array_key_exists($colName, $input)) {
270 14
				continue;
271
			}
272
273
			// Use setter if known, otherwise set value directly
274 14
			$fn = $definition['setter'] ?? null;
275 14
			if (is_callable($fn)) {
276 2
				$fn($input[$colName]);
277
			} else {
278 12
				$definition['value'] = $input[$colName];
279
			}
280
		}
281 14
	}
282
283
	/**
284
	 * @param Array $input Associative array of input values
285
	 * @param Array $fieldWhitelist array of column names that are allowed to be filled by the input array 
286
	 * @return Array Array containing the set of optional errors (associative array) and an optional array representation (associative)
287
	 * 					of the modified data.
288
	 */
289 10
	public function apiCreate(Array $input, Array $createWhitelist, Array $readWhitelist)
290
	{
291
		// Clone $this to new instance (for restoring if validation goes wrong)
292 10
		$transaction = $this->newInstance();
0 ignored issues
show
Bug introduced by
It seems like newInstance() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

292
		/** @scrutinizer ignore-call */ 
293
  $transaction = $this->newInstance();
Loading history...
293 10
		$errors = [];
294
295
		// Filter out all non-whitelisted input values
296 10
		$input = $this->filterInputColumns($input, $createWhitelist);
297
298
		// Validate excess keys
299 10
		$errors += $transaction->validateExcessKeys($input);
300
301
		// Validate input values (using validation function)
302 10
		$errors += $transaction->validateInputValues($input);
303
304
		// "Copy" data into transaction
305 10
		$transaction->loadData($input);
306
307
		// Run create hooks
308 10
		foreach ($transaction->createHooks as $colName => $fn) {
309
			$fn();
310
		}
311
312
		// Validate missing keys
313 10
		$errors += $transaction->validateMissingKeys($input);
314
315
		// If no errors, commit the pending data
316 10
		if (empty($errors)) {
317 6
			$this->syncInstanceFrom($transaction);
318
319
			// Insert default values for not-null fields
320 6
			$this->insertDefaults();
0 ignored issues
show
Bug introduced by
It seems like insertDefaults() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

320
			$this->/** @scrutinizer ignore-call */ 
321
          insertDefaults();
Loading history...
321
322
			try {
323 6
				(new Query($this->getPdo(), $this->getTableName()))
324 6
					->insert($this->getActiveRecordColumns())
325 6
					->execute();
326
327 6
				$this->setId(intval($this->getPdo()->lastInsertId()));
328
			} catch (\PDOException $e) {
329
				// @TODO: Potentially filter and store mysql messages (where possible) in error messages
330
				throw new ActiveRecordException($e->getMessage(), 0, $e);
331
			}
332
333 6
			return [null, $this->toArray($readWhitelist)];
334
		} else {
335 5
			return [$errors, null];
336
		}
337
	}
338
339
	/**
340
	 * @param Array $input Associative array of input values
341
	 * @param Array $fieldWhitelist array of column names that are allowed to be filled by the input array 
342
	 * @return Array Array containing the set of optional errors (associative array) and an optional array representation (associative)
343
	 * 					of the modified data.
344
	 */
345 6
	public function apiUpdate(Array $input, Array $updateWhitelist, Array $readWhitelist)
346
	{
347 6
		$transaction = $this->newInstance();
348 6
		$transaction->syncInstanceFrom($this);
349 6
		$errors = [];
350
351
		// Filter out all non-whitelisted input values
352 6
		$input = $this->filterInputColumns($input, $updateWhitelist);
353
354
		// Check for excess keys
355 6
		$errors += $transaction->validateExcessKeys($input);
356
357
		// Check for immutable keys
358 6
		$errors += $transaction->validateImmutableColumns($input);
359
360
		// Validate input values (using validation function)
361 6
		$errors += $transaction->validateInputValues($input);
362
363
		// "Copy" data into transaction
364 6
		$transaction->loadData($input);
365
366
		// Run create hooks
367 6
		foreach ($transaction->updateHooks as $colName => $fn) {
368
			$fn();
369
		}
370
371
		// Validate missing keys
372 6
		$errors += $transaction->validateMissingKeys($input);
373
374
		// Update database
375 6
		if (empty($errors)) {
376 3
			$this->syncInstanceFrom($transaction);
377
378
			try {
379 3
				(new Query($this->getPdo(), $this->getTableName()))
380 3
					->update($this->getActiveRecordColumns())
381 3
					->where(Query::Equal('id', $this->getId()))
382 3
					->execute();
383
			} catch (\PDOException $e) {
384
				throw new ActiveRecordException($e->getMessage(), 0, $e);
385
			}
386
387 3
			return [null, $this->toArray($readWhitelist)];
388
		} else {
389 4
			return [$errors, null];
390
		}
391
	}
392
393
	/**
394
	 * Returns this active record after reading the attributes from the entry with the given identifier.
395
	 *
396
	 * @param mixed $id
397
	 * @return $this
398
	 * @throws ActiveRecordException on failure.
399
	 */
400
	abstract public function read($id);
401
402
	/**
403
	 * Returns the PDO.
404
	 *
405
	 * @return \PDO the PDO.
406
	 */
407
	abstract public function getPdo();
408
409
	/**
410
	 * Set the ID.
411
	 *
412
	 * @param int|null $id
413
	 * @return $this
414
	 */
415
	abstract protected function setId(?int $id);
416
417
	/**
418
	 * Returns the ID.
419
	 *
420
	 * @return null|int The ID.
421
	 */
422
	abstract protected function getId();
423
424
425
	/**
426
	 * Returns the serialized form of the specified columns
427
	 * 
428
	 * @return Array
429
	 */
430
	abstract public function toArray(Array $fieldWhitelist);
431
432
	/**
433
	 * Returns the active record table.
434
	 *
435
	 * @return string the active record table name.
436
	 */
437
	abstract public function getTableName();
438
439
	/**
440
	 * Returns the name -> variable mapping for the table definition.
441
	 * @return Array The mapping
442
	 */
443
	abstract protected function getActiveRecordColumns();
444
}
445