Completed
Push — v2 ( 514465...6260c4 )
by Berend
02:57
created

AutoApi::validateMissingKeys()   B

Complexity

Conditions 8
Paths 3

Size

Total Lines 27
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 8

Importance

Changes 0
Metric Value
eloc 12
dl 0
loc 27
ccs 13
cts 13
cp 1
rs 8.4444
c 0
b 0
f 0
cc 8
nc 3
nop 1
crap 8
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
9
trait AutoApi
10
{
11
	/* =======================================================================
12
	 * ===================== Automatic API Support ===========================
13
	 * ======================================================================= */
14
15
	/** @var array A map of column name to functions that hook the insert function */
16
	protected $registeredCreateHooks;
17
18
	/** @var array A map of column name to functions that hook the read function */
19
	protected $registeredReadHooks;
20
21
	/** @var array A map of column name to functions that hook the update function */
22
	protected $registeredUpdateHooks;
23
24
	/** @var array A map of column name to functions that hook the update function */
25
	protected $registeredDeleteHooks;	
26
27
	/** @var array A map of column name to functions that hook the search function */
28
	protected $registeredSearchHooks;
29
30
	/** @var array A list of table column definitions */
31
	protected $tableDefinition;
32
33 1
	public function apiSearch(Array $queryparams, Array $fieldWhitelist)
34
	{
35
		// @TODO: Would it be better to not include the ignored_traits?
36 1
		$ignoredTraits = $queryparams['ignored_traits'] ?? [];
37 1
		$query = $this->search($ignoredTraits);
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

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

224
		/** @scrutinizer ignore-call */ 
225
  $transaction = $this->newInstance();
Loading history...
225 5
		$errors = [];
226
227
		// Filter out all non-whitelisted input values
228 5
		$input = $this->filterInputColumns($input, $fieldWhitelist);
229
230
		// Validate excess keys
231 5
		$errors += $transaction->validateExcessKeys($input);
232
233
		// Validate input values (using validation function)
234 5
		$errors += $transaction->validateInputValues($input);
235
236
		// "Copy" data into transaction
237 5
		$transaction->loadData($input);
238
239
		// Run create hooks
240 5
		foreach ($this->registeredCreateHooks as $colName => $fn) {
241
			$fn();
242
		}
243
244
		// Validate missing keys
245 5
		$errors += $transaction->validateMissingKeys($input);
246
247
		// If no errors, commit the pending data
248 5
		if (empty($errors)) {
249 2
			$this->syncInstanceFrom($transaction);
250
251
			try {
252 2
				(new Query($this->getPdo(), $this->getTableName()))
253 2
					->insert($this->getActiveRecordColumns())
254 2
					->execute();
255
256 2
				$this->setId(intval($this->getPdo()->lastInsertId()));
257
			} catch (\PDOException $e) {
258
				// @TODO: Potentially filter and store mysql messages (where possible) in error messages
259
				throw new ActiveRecordException($e->getMessage(), 0, $e);
260
			}
261
262 2
			return [null, $this->toArray($fieldWhitelist)];
263
		} else {
264 3
			return [$errors, null];
265
		}
266
	}
267
268
	/**
269
	 * @param Array $input Associative array of input values
270
	 * @param Array $fieldWhitelist array of column names that are allowed to be filled by the input array 
271
	 * @return Array Array containing the set of optional errors (associative array) and an optional array representation (associative)
272
	 * 					of the modified data.
273
	 */
274 4
	public function apiUpdate($input, Array $fieldWhitelist)
275
	{
276 4
		$transaction = $this->newInstance();
277 4
		$transaction->syncInstanceFrom($this);
278 4
		$errors = [];
279
280
		// Filter out all non-whitelisted input values
281 4
		$input = $this->filterInputColumns($input, $fieldWhitelist);
282
283
		// Check for excess keys
284 4
		$errors += $transaction->validateExcessKeys($input);
285
286
		// Check for immutable keys
287 4
		$errors += $transaction->validateImmutableColumns($input);
288
289
		// Validate input values (using validation function)
290 4
		$errors += $transaction->validateInputValues($input);
291
292
		// "Copy" data into transaction
293 4
		$transaction->loadData($input);
294
295
		// Run create hooks
296 4
		foreach ($this->registeredUpdateHooks as $colName => $fn) {
297
			$fn();
298
		}
299
300
		// Validate missing keys
301 4
		$errors += $transaction->validateMissingKeys($input);
302
303
		// Update database
304 4
		if (empty($errors)) {
305 1
			$this->syncInstanceFrom($transaction);
306
307
			try {
308 1
				(new Query($this->getPdo(), $this->getTableName()))
309 1
					->update($this->getActiveRecordColumns())
310 1
					->where(Query::Equal('id', $this->getId()))
0 ignored issues
show
Bug introduced by
It seems like $this->getId() can also be of type integer; however, parameter $right of miBadger\Query\Query::Equal() does only seem to accept miBadger\Query\any, 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

310
					->where(Query::Equal('id', /** @scrutinizer ignore-type */ $this->getId()))
Loading history...
311 1
					->execute();
312
			} catch (\PDOException $e) {
313
				throw new ActiveRecordException($e->getMessage(), 0, $e);
314
			}
315
316 1
			return [null, $this->toArray($fieldWhitelist)];
317
		} else {
318 3
			return [$errors, null];
319
		}
320
	}
321
322
	/**
323
	 * Returns this active record after reading the attributes from the entry with the given identifier.
324
	 *
325
	 * @param mixed $id
326
	 * @return $this
327
	 * @throws ActiveRecordException on failure.
328
	 */
329
	abstract public function read($id);
330
331
	/**
332
	 * Returns the PDO.
333
	 *
334
	 * @return \PDO the PDO.
335
	 */
336
	abstract public function getPdo();
337
338
	/**
339
	 * Set the ID.
340
	 *
341
	 * @param int $id
342
	 * @return $this
343
	 */
344
	abstract protected function setId($id);
345
346
	/**
347
	 * Returns the ID.
348
	 *
349
	 * @return null|int The ID.
350
	 */
351
	abstract protected function getId();
352
353
	/**
354
	 * Returns the active record table.
355
	 *
356
	 * @return string the active record table name.
357
	 */
358
	abstract protected function getTableName();
359
360
	/**
361
	 * Returns the name -> variable mapping for the table definition.
362
	 * @return Array The mapping
363
	 */
364
	abstract protected function getActiveRecordColumns();
365
}
366