Test Failed
Push — v2 ( 078ae9...72b33f )
by Berend
03:08
created

AutoApi::apiSearch()   B

Complexity

Conditions 6
Paths 32

Size

Total Lines 46
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 6

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 29
c 3
b 0
f 0
dl 0
loc 46
ccs 28
cts 28
cp 1
rs 8.8337
cc 6
nc 32
nop 4
crap 6
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 $registeredCreateHooks;
18
19
	/** @var array A map of column name to functions that hook the read function */
20
	protected $registeredReadHooks;
21
22
	/** @var array A map of column name to functions that hook the update function */
23
	protected $registeredUpdateHooks;
24
25
	/** @var array A map of column name to functions that hook the update function */
26
	protected $registeredDeleteHooks;	
27
28
	/** @var array A map of column name to functions that hook the search function */
29
	protected $registeredSearchHooks;
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 1
	public function apiSearch(Array $queryParams, Array $fieldWhitelist, ?QueryExpression $whereClause = null, int $maxResultLimit = 100)
42
	{
43 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

43
		/** @scrutinizer ignore-call */ 
44
  $query = $this->search();
Loading history...
44
45
		// Build query
46 1
		$orderColumn = $queryParams['search_order_by'] ?? null;
47 1
		if (!in_array($orderColumn, $fieldWhitelist)) {
48 1
			$orderColumn = null;
49 1
		}
50
51
		$orderDirection = $queryParams['search_order_direction'] ?? null;
52 1
		if ($orderColumn !== null) {
53 1
			$query->orderBy($orderColumn, $orderDirection);
54
		}
55 1
		
56 1
		$limit = (int) $queryParams['search_limit'] ?? $maxResultLimit;
57
		if ($limit > $maxResultLimit) {
58 1
			$limit = $maxResultLimit;
59 1
		}
60
		$query->limit($limit);
61
62 1
		$offset = $queryParams['search_offset'] ?? 0;
63 1
		$query->offset($offset);
64
65
		if ($whereClause !== null) {
66 1
			$query->where($whereClause);
67 1
		}
68 1
69 1
		$numPages = $query->getNumberOfPages();
70
		$currentPage = $query->getCurrentPage();
71
72
		// Fetch results
73 1
		$results = $query->fetchAll();
74 1
		$resultsArray = [];
75 1
		foreach ($results as $result) {
76 1
			$resultsArray[] = $result->toArray($fieldWhitelist);
77 1
		}
78 1
79 1
		return [
80
			'search_offset' => $offset,
81
			'search_limit' => $limit,
82
			'search_order_by' => $orderColumn,
83 6
			'search_order_direction' => $orderDirection,
84
			'search_pages' => $numPages,
85 6
			'search_current' => $currentPage,
86 6
			'data' => $resultsArray
87 6
		];
88 6
	}
89
90
	public function toArray($fieldWhitelist)
91
	{
92 6
		$output = [];
93
		foreach ($this->tableDefinition as $colName => $definition) {
94
			if (in_array($colName, $fieldWhitelist)) {
95 1
				$output[$colName] = $definition['value'];
96
			}
97
		}
98 1
99 1
		return $output;
100
	}
101
102
	public function apiRead($id, Array $fieldWhitelist)
103
	{
104
		// @TODO: Should apiRead throw exception or return null on fail?
105
		$this->read($id);
106
		return $this->toArray($fieldWhitelist);
107
	}
108
109 7
	/* =============================================================
110
	 * ===================== Constraint validation =================
111 7
	 * ============================================================= */
112 7
113
	/**
114 7
	 * Copy all table variables between two instances
115
	 */
116 10
	public function syncInstanceFrom($from)
117
	{
118 10
		foreach ($this->tableDefinition as $colName => $definition) {
119 10
			$this->tableDefinition[$colName]['value'] = $from->tableDefinition[$colName]['value'];
120 10
		}
121 10
	}
122
123
	private function filterInputColumns($input, $whitelist)
124 10
	{
125
		$filteredInput = $input;
126
		foreach ($input as $colName => $value) {
127 10
			if (!in_array($colName, $whitelist)) {
128
				unset($filteredInput[$colName]);
129 10
			}
130 10
		}
131 10
		return $filteredInput;
132 1
	}
133 10
134
	private function validateExcessKeys($input)
135
	{
136 10
		$errors = [];
137
		foreach ($input as $colName => $value) {
138
			if (!array_key_exists($colName, $this->tableDefinition)) {
139 5
				$errors[$colName] = "Unknown input field";
140
				continue;
141 5
			}
142 5
		}
143 5
		return $errors;
144 5
	}
145 5
146 5
	private function validateImmutableColumns($input)
147
	{
148
		$errors = [];
149 5
		foreach ($this->tableDefinition as $colName => $definition) {
150
			$property = $definition['properties'] ?? null;
151
			if (array_key_exists($colName, $input)
152
				&& $property & ColumnProperty::IMMUTABLE) {
153
				$errors[$colName] = "Field cannot be changed";
154
			}
155
		}
156
		return $errors;
157 10
	}
158
159 10
	/**
160 10
	 * Checks whether input values are correct:
161
	 * 1. Checks whether a value passes the validation function for that column
162 10
	 * 2. Checks whether a value supplied to a relationship column is a valid value
163 10
	 */
164 6
	private function validateInputValues($input)
165
	{
166
		$errors = [];
167 6
		foreach ($this->tableDefinition as $colName => $definition) {
168 6
			// Validation check 1: If validate function is present
169 2
			if (array_key_exists($colName, $input) 
170
				&& is_callable($definition['validate'] ?? null)) {
171
				$inputValue = $input[$colName];
172
173
				// If validation function fails
174 10
				[$status, $message] = $definition['validate']($inputValue);
175 10
				if (!$status) {
176 10
					$errors[$colName] = $message;
177 1
				}	
178
			}
179 1
180 1
			// Validation check 2: If relation column, check whether entity exists
181 10
			$properties = $definition['properties'] ?? null;
182
			if (isset($definition['relation'])
183
				&& ($properties & ColumnProperty::NOT_NULL)) {
184
				$instance = clone $definition['relation'];
185 10
				try {
186
					$instance->read($input[$colName] ?? $definition['value'] ?? null);
187
				} catch (ActiveRecordException $e) {
188
					$errors[$colName] = "Entity for this value doesn't exist";
189
				}
190
			}
191
		}
192 10
		return $errors;
193
	}
194 10
195
	/**
196 10
	 * This function is only used for API Update calls (direct getter/setter functions are unconstrained)
197 10
	 * Determines whether there are required columns for which no data is provided
198 10
	 */
199 10
	private function validateMissingKeys($input)
200
	{
201
		$errors = [];
202
203
		foreach ($this->tableDefinition as $colName => $colDefinition) {
204
			$default = $colDefinition['default'] ?? null;
205
			$properties = $colDefinition['properties'] ?? null;
206
			$value = $colDefinition['value'];
207
208
			// If nullable and default not set => null
209 10
			// If nullable and default null => default (null)
210 10
			// If nullable and default set => default (value)
211 10
212 10
			// if not nullable and default not set => error
213 10
			// if not nullable and default null => error
214 10
			// if not nullable and default st => default (value)
215
			// => if not nullable and default null and value not set (or null) => error message in this method
216
			if ($properties & ColumnProperty::NOT_NULL
217
				&& $default === null
218 10
				&& !($properties & ColumnProperty::AUTO_INCREMENT)
219
				&& (!array_key_exists($colName, $input) || $input[$colName] === null)
220
				&& $value === null) {
221
				$errors[$colName] = sprintf("The required field \"%s\" is missing", $colName);
222
			} 
223
		}
224
225 10
		return $errors;
226
	}
227 10
228 10
	/**
229 10
	 * Copies the values for entries in the input with matching variable names in the record definition
230
	 * @param Array $input The input data to be loaded into $this record
231
	 */
232 10
	private function loadData($input)
233
	{
234
		foreach ($this->tableDefinition as $colName => $definition) {
235
			if (array_key_exists($colName, $input)) {
236
				$definition['value'] = $input[$colName];
237
			}
238
		}
239
	}
240 6
241
	/**
242
	 * @param Array $input Associative array of input values
243 6
	 * @param Array $fieldWhitelist array of column names that are allowed to be filled by the input array 
244 6
	 * @return Array Array containing the set of optional errors (associative array) and an optional array representation (associative)
245
	 * 					of the modified data.
246
	 */
247 6
	public function apiCreate(Array $input, Array $createWhitelist, Array $readWhitelist)
248
	{
249
		// Clone $this to new instance (for restoring if validation goes wrong)
250 6
		$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

250
		/** @scrutinizer ignore-call */ 
251
  $transaction = $this->newInstance();
Loading history...
251
		$errors = [];
252
253 6
		// Filter out all non-whitelisted input values
254
		$input = $this->filterInputColumns($input, $createWhitelist);
255
256 6
		// Validate excess keys
257
		$errors += $transaction->validateExcessKeys($input);
258
259 6
		// Validate input values (using validation function)
260
		$errors += $transaction->validateInputValues($input);
261
262
		// "Copy" data into transaction
263
		$transaction->loadData($input);
264 6
265
		// Run create hooks
266
		foreach ($transaction->registeredCreateHooks as $colName => $fn) {
267 6
			$fn();
268 3
		}
269
270
		// Validate missing keys
271 3
		$errors += $transaction->validateMissingKeys($input);
272 3
273 3
		// If no errors, commit the pending data
274 3
		if (empty($errors)) {
275 3
			$this->syncInstanceFrom($transaction);
276 3
277
			// Insert default values for not-null fields
278
			foreach ($this->tableDefinition as $colName => $colDef) {
279
				if ($this->tableDefinition[$colName]['value'] === null
280
					&& isset($this->tableDefinition[$colName]['properties'])
281 3
					&& $this->tableDefinition[$colName]['properties'] && ColumnProperty::NOT_NULL > 0
282 3
					&& isset($this->tableDefinition[$colName]['default'])) {
283 3
					$this->tableDefinition[$colName]['value'] = $this->tableDefinition[$colName]['default'];
284
				}
285 3
			}
286
287
			try {
288
				(new Query($this->getPdo(), $this->getTableName()))
289
					->insert($this->getActiveRecordColumns())
290
					->execute();
291 3
292
				$this->setId(intval($this->getPdo()->lastInsertId()));
293 4
			} catch (\PDOException $e) {
294
				// @TODO: Potentially filter and store mysql messages (where possible) in error messages
295
				throw new ActiveRecordException($e->getMessage(), 0, $e);
296
			}
297
298
			return [null, $this->toArray($readWhitelist)];
299
		} else {
300
			return [$errors, null];
301
		}
302
	}
303 5
304
	/**
305 5
	 * @param Array $input Associative array of input values
306 5
	 * @param Array $fieldWhitelist array of column names that are allowed to be filled by the input array 
307 5
	 * @return Array Array containing the set of optional errors (associative array) and an optional array representation (associative)
308
	 * 					of the modified data.
309
	 */
310 5
	public function apiUpdate(Array $input, Array $updateWhitelist, Array $readWhitelist)
311
	{
312
		$transaction = $this->newInstance();
313 5
		$transaction->syncInstanceFrom($this);
314
		$errors = [];
315
316 5
		// Filter out all non-whitelisted input values
317
		$input = $this->filterInputColumns($input, $updateWhitelist);
318
319 5
		// Check for excess keys
320
		$errors += $transaction->validateExcessKeys($input);
321
322 5
		// Check for immutable keys
323
		$errors += $transaction->validateImmutableColumns($input);
324
325 5
		// Validate input values (using validation function)
326
		$errors += $transaction->validateInputValues($input);
327
328
		// "Copy" data into transaction
329
		$transaction->loadData($input);
330 5
331
		// Run create hooks
332
		foreach ($transaction->registeredUpdateHooks as $colName => $fn) {
333 5
			$fn();
334 2
		}
335
336
		// Validate missing keys
337 2
		$errors += $transaction->validateMissingKeys($input);
338 2
339 2
		// Update database
340 2
		if (empty($errors)) {
341
			$this->syncInstanceFrom($transaction);
342
343
			try {
344
				(new Query($this->getPdo(), $this->getTableName()))
345 2
					->update($this->getActiveRecordColumns())
346
					->where(Query::Equal('id', $this->getId()))
347 4
					->execute();
348
			} catch (\PDOException $e) {
349
				throw new ActiveRecordException($e->getMessage(), 0, $e);
350
			}
351
352
			return [null, $this->toArray($readWhitelist)];
353
		} else {
354
			return [$errors, null];
355
		}
356
	}
357
358
	/**
359
	 * Returns this active record after reading the attributes from the entry with the given identifier.
360
	 *
361
	 * @param mixed $id
362
	 * @return $this
363
	 * @throws ActiveRecordException on failure.
364
	 */
365
	abstract public function read($id);
366
367
	/**
368
	 * Returns the PDO.
369
	 *
370
	 * @return \PDO the PDO.
371
	 */
372
	abstract public function getPdo();
373
374
	/**
375
	 * Set the ID.
376
	 *
377
	 * @param int $id
378
	 * @return $this
379
	 */
380
	abstract protected function setId($id);
381
382
	/**
383
	 * Returns the ID.
384
	 *
385
	 * @return null|int The ID.
386
	 */
387
	abstract protected function getId();
388
389
	/**
390
	 * Returns the active record table.
391
	 *
392
	 * @return string the active record table name.
393
	 */
394
	abstract public function getTableName();
395
396
	/**
397
	 * Returns the name -> variable mapping for the table definition.
398
	 * @return Array The mapping
399
	 */
400
	abstract protected function getActiveRecordColumns();
401
}
402