Completed
Push — v2 ( c532d8...5f504a )
by Berend
02:38
created

AutoApi::apiCreate()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 47
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 4.0466

Importance

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

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