· 6 years ago · Mar 25, 2020, 11:10 PM
1<?php
2namespace IncentFit\Controllers\Ajaxians\CRUD;
3
4use Exception;
5
6use Cybertron\Model;
7use ReflectionObject;
8use IncentFit\Utility;
9use ReflectionProperty;
10use Cybertron\CyberConjunct;
11use Cybertron\CyberCriteria;
12use Cybertron\CyberSortItem;
13use Cybertron\CyberSortType;
14use Cybertron\CyberCriteriaType;
15use Cybertron\CyberCriteriaGroup;
16
17/**
18 * CRUD
19 *
20 * An API Controller for Cybertron models and an Ajaxian wrapper.
21 *
22 */
23class CRUD extends \IncentFit\Controllers\Ajaxian
24{
25
26 /**
27 * Constant to indicate whether a criterion exists against multiple columns. Used currently for global search on grid
28 *
29 * @var string
30 */
31 const MULTI_COLUMN_CRITERIA = '_multicolumncriteria';
32 /**
33 * Full class name of a Cybertron Model including namespace
34 * Must be defined!
35 * @var string $recordClass
36 */
37 public static $recordClass = null;
38
39 /**
40 * Default browse order
41 *
42 * @var false|array
43 */
44 public static $browseOrder = false;
45
46 /**
47 * Default browse conditions
48 *
49 * @var false|array
50 */
51 public static $browseConditions = false;
52
53 /**
54 * Default browse limit
55 *
56 * @var false|int
57 */
58 public static $browseLimitDefault = null; // false is interpreted as LIMIT 0 by Model::LoadListByCriterias()
59
60 /**
61 * Internal register of $_REQUEST['body']
62 *
63 * @var null|array
64 */
65 public $data = null;
66
67 /**
68 * Main responder defined by class Ajaxian
69 *
70 * @param null|string $action Action to run
71 * @return void
72 */
73 public function handleRequest($action = null)
74 {
75 if (!$this->checkAPIAccess()) {
76 return $this->throwAPIUnAuthorizedError();
77 }
78
79 if (!$_REQUEST) {
80 $_REQUEST = $this->getJSONFromRequest();
81 }
82
83 $this->data = isset($_REQUEST['body']) ? $_REQUEST['body'] : null;
84
85 switch ($action) {
86
87 case 'create':
88 $this->createRequest();
89 break;
90
91 case 'edit':
92 $this->editRequest();
93 break;
94
95 case 'delete':
96 $this->deleteRequest();
97 break;
98
99 case '':
100 default:
101 $this->browseRequest();
102 break;
103 }
104 }
105
106 /**
107 * Set an error
108 *
109 * @param string $message Error message
110 * @return void
111 */
112 public function error($message)
113 {
114 $this->Success = false;
115 $this->Message = $message;
116 }
117
118 /**
119 * Sets $this->Payload to $data
120 * Ajaxian does the actual response with the data in $this->Payload
121 *
122 * @param mixed $data
123 * @return void
124 */
125 public function respond($data)
126 {
127 $this->Payload = $data;
128 }
129
130 /**
131 * Defines $options for browse requests from $_REQUEST
132 *
133 * @return array $options limit, offset, and order array members
134 */
135 public function prepareBrowseOptions()
136 {
137 if (empty($_REQUEST['offset']) && !empty($_REQUEST['start']) && is_numeric($_REQUEST['start'])) {
138 $_REQUEST['offset'] = $_REQUEST['start'];
139 }
140
141 $limit = !empty($_REQUEST['limit']) && is_numeric($_REQUEST['limit']) ? $_REQUEST['limit'] : static::$browseLimitDefault;
142 $offset = !empty($_REQUEST['offset']) && is_numeric($_REQUEST['offset']) ? $_REQUEST['offset'] : null; // Model::LoadListByCriterias() expects null, not false if there is no offset
143
144 $options = [
145 'limit' => $limit,
146 'offset' => $offset,
147 'order' => static::$browseOrder,
148 ];
149 return $options;
150 }
151
152 /**
153 * Returns default browse conditions
154 *
155 * @return array Default browse conditions
156 */
157 public function prepareDefaultBrowseConditions()
158 {
159 $conditions = [];
160 if (is_array(static::$browseConditions)) {
161 $conditions = array_merge(static::$browseConditions, $conditions);
162 }
163 return $conditions;
164 }
165
166 /**
167 * Sorting
168 *
169 * Query String Example
170 *
171 * sort[0][property]=EndDate
172 * &sort[0][direction]=asc
173 * &sort[1][property]=StartDate
174 * &sort[1][direction]=desc
175 *
176 * Becomes
177 * "order":{"EndDate":"asc","StartDate":"desc"}
178 *
179 * @return array Array to set for $conditions['order']
180 */
181 public function prepareSortToOrderCondition()
182 {
183 $recordClass = static::$recordClass;
184 $order = [];
185 if (!empty($_REQUEST['sort'])) {
186 if (!is_array($_REQUEST['sort'])) {
187 $sort = json_decode($_REQUEST['sort'], true);
188 } else {
189 $sort = $_REQUEST['sort'];
190 }
191 if (!$sort || !is_array($sort)) {
192 return $this->error('Invalid sorter.');
193 }
194
195 if (is_array($sort)) {
196 foreach ($sort as $field) {
197 if (property_exists($recordClass, $field['property'])) {
198 $order[$field['property']] = $field['direction'];
199 }
200 }
201 }
202 }
203 return $order;
204 }
205
206 /**
207 * Filtering
208 *
209 * Query String Example
210 *
211 * &filter[0][property]=TriggerFunction
212 * &filter[0][value]=Notification
213 * &filter[0][type]=equals
214 *
215 * Becomes
216 * "conditions":{"TriggerFunction": {value:"Notification",type:"equals"} }
217 *
218 * @return array
219 */
220 public function prepareFilterToConditions()
221 {
222 $conditions = [];
223 if (!empty($_REQUEST['filter'])) {
224 if (!is_array($_REQUEST['filter'])) {
225 $filter = json_decode($_REQUEST['filter'], true);
226 } else {
227 $filter = $_REQUEST['filter'];
228 }
229 if (!$filter || !is_array($filter)) {
230 return $this->error('Invalid filter.');
231 }
232
233 foreach ($filter as $field) {
234 $conditions[$field['property']] = [
235 'type' => $field['type'],
236 ];
237
238 if (isset($field['value'])) {
239 $conditions[$field['property']]['value'] = $field['value'];
240 }
241
242 if (isset($field['valueTo'])) {
243 $conditions[$field['property']]['valueTo'] = $field['valueTo'];
244 }
245
246 if (isset($field['dateFrom'])) {
247 $conditions[$field['property']]['value'] = $field['dateFrom'];
248 }
249
250 if (isset($field['dateTo'])) {
251 $conditions[$field['property']]['valueTo'] = $field['dateTo'];
252 } elseif (isset($field['dateFrom'])) {
253 // We have a date from but not a date to which means the user is doing a date equals comparison. Turn this filter into
254 // a range with dateTo as the day after dateFrom
255 $conditions[$field['property']]['valueTo'] = date('Y-m-d', strtotime($field['dateFrom'] .' +1 day'));
256
257 if ($field['type'] == 'equals') {
258 $conditions[$field['property']]['type'] = 'inRange';
259 } elseif ($field['type'] == 'notEqual') {
260 $conditions[$field['property']]['type'] = 'notInRange';
261 }
262 }
263 }
264 }
265 return $conditions;
266 }
267
268 /**
269 * Searching
270 *
271 * Query String Example
272 *
273 * &searchText=MySearchText
274 *
275 * Becomes
276 * "conditions":{"_any": {value:"MySearchText",type:"contains"} }
277 *
278 * @return array
279 */
280 public function prepareSearchToConditions()
281 {
282 $conditions = [];
283 if (!empty($_REQUEST['search'])) {
284 $conditions[static::MULTI_COLUMN_CRITERIA] = [
285 'properties' => $_REQUEST['search']['columns'],
286 'value' => $_REQUEST['search']['text'],
287 'type' => 'contains',
288 ];
289 }
290 return $conditions;
291 }
292
293 /**
294 * Bulk data responder with paging, order, & conditions
295 *
296 * @return void
297 */
298 public function browseRequest()
299 {
300 if (!$this->checkBrowseAccess()) {
301 return $this->throwUnauthorizedError();
302 }
303
304 $recordClass = static::$recordClass;
305
306 $options = $this->prepareBrowseOptions();
307 $conditions = $this->prepareDefaultBrowseConditions();
308 if (is_array($options['order'])) {
309 $options['order'] = array_merge($options['order'], $this->prepareSortToOrderCondition());
310 } else {
311 $options['order'] = $this->prepareSortToOrderCondition();
312 }
313
314 $conditions = array_merge($conditions, $this->prepareFilterToConditions());
315 $conditions = array_merge($conditions, $this->prepareSearchToConditions());
316 // $conditions = array_merge($conditions, $this->prepareSelectionToConditions());
317
318 $CyberCriteria = static::conditionsToCriteria($conditions);
319 $CyberSort = static::orderToCyberSort(is_array($options['order']) ? $options['order'] : []);
320
321 $total = 0;
322 $data = [
323 'data' => $recordClass::LoadListByCriterias($CyberCriteria, $CyberSort, null, $options['offset'], $options['limit'], $total),
324 'total' => $total,
325 'conditions' => $conditions,
326 'order' => $options['order'],
327 'limit' => $options['limit'],
328 'offset' => $options['offset'],
329 ];
330
331 $this->Success = true;
332 $this->respond($data);
333 }
334
335 /**
336 * Converts PHP array of conditions to CyberCriteria
337 *
338 * @param array $conditions
339 * @return array Array of CyberCriteria objects.
340 */
341 public static function conditionsToCriteria($conditions = [])
342 {
343 $output = [];
344 foreach ($conditions as $key=>$properties) {
345 // If the condition is already a CyberCriteria, just add it to the array. This is useful when setting default
346 // $browseConditions since it's more natural to add those as CyberCriteria in the subclass Ajaxian instead of
347 // as a key/value/operator array
348 if ($properties instanceof CyberCriteria) {
349 array_push($output, $properties);
350 continue;
351 }
352
353 if ($key == static::MULTI_COLUMN_CRITERIA) {
354 $criteria = self::globalSearchConditionToCriteria($properties);
355 } else {
356 switch ($properties['type']) {
357 case 'equals':
358 $criteria = new CyberCriteria($key, $properties['value']);
359 break;
360
361 case 'notEqual':
362 $criteria = new CyberCriteria($key, $properties['value'], CyberCriteriaType::NotEqual);
363 break;
364
365 case 'startsWith':
366 $criteria = new CyberCriteria($key, $properties['value'].'%', CyberCriteriaType::Like);
367 break;
368
369 case 'endsWith':
370 $criteria = new CyberCriteria($key, '%'.$properties['value'], CyberCriteriaType::Like);
371 break;
372
373 case 'contains':
374 $criteria = new CyberCriteria($key, '%'.$properties['value'].'%', CyberCriteriaType::Like);
375 break;
376
377 case 'notContains':
378 $criteria = new CyberCriteria($key, '%'.$properties['value'].'%', CyberCriteriaType::NotLike);
379 break;
380
381 case 'lessThan':
382 $criteria = new CyberCriteria($key, $properties['value'], CyberCriteriaType::LessThan);
383 break;
384
385 case 'lessThanOrEqual':
386 $criteria = new CyberCriteria($key, $properties['value'], CyberCriteriaType::LessThanOrEquals);
387 break;
388
389 case 'greaterThan':
390 $criteria = new CyberCriteria($key, $properties['value'], CyberCriteriaType::GreaterThan);
391 break;
392
393 case 'greaterThanOrEqual':
394 $criteria = new CyberCriteria($key, $properties['value'], CyberCriteriaType::GreaterThanOrEquals);
395 break;
396
397 case 'inRange':
398 case 'notInRange':
399 if (isset($properties['value']) && isset($properties['valueTo'])) {
400 $lesserValue = min($properties['value'], $properties['valueTo']);
401 $greaterValue = max($properties['value'], $properties['valueTo']);
402
403 if ($properties['type'] == 'inRange') {
404 $criteria = [new CyberCriteria($key, $lesserValue, CyberCriteriaType::GreaterThanOrEquals), new CyberCriteria($key, $greaterValue, CyberCriteriaType::LessThanOrEquals)];
405 } else { // notInRange
406 $crtieria = [
407 new CyberCriteria($key, $lesserValue, CyberCriteriaType::LessThanOrEquals),
408 new CyberCriteria($key, $greaterValue, CyberCriteriaType::GreaterThanOrEquals),
409 ];
410
411 $criteria = new CyberCriteriaGroup($crtieria, CyberConjunct::GroupOr);
412 }
413 }
414 break;
415
416 default:
417 $criteria = new CyberCriteria($key, $properties['value']);
418 break;
419 }
420 }
421
422 array_push($output, $criteria);
423 }
424 return $output;
425 }
426
427 /**
428 * Converts a global search condition to a CyberCriteria
429 *
430 * @param array $condition
431 * @return array CyberCriteria object.
432 */
433 public static function globalSearchConditionToCriteria($searchProperties)
434 {
435 // Instantiate an object of the underlying model in order to get its public properies (this should be done centrally in Cybertron?)
436 $model = new static::$recordClass;
437
438 $modelProps = (new ReflectionObject($model))->getProperties(ReflectionProperty::IS_PUBLIC);
439 foreach ($modelProps as $key => $modelProp) {
440 // Get rid of the model property if it starts with a _ or if we have a list of columns to look for in
441 // $searchProperties['properties'] but the property isn't in that list
442 if (Utility::StartsWith($modelProp->name, '_') ||
443 (!empty($searchProperties['properties']) && !in_array($modelProp->name, $searchProperties['properties']))) {
444 unset($modelProps[$key]);
445 }
446 }
447
448 // Now that we have the model's properies, build a "Like" CyberCriteria from them
449 $criteria = [];
450 foreach ($modelProps as $modelProp) {
451 $criteria[] = new CyberCriteria($modelProp->name, '%'.$searchProperties['value'].'%', CyberCriteriaType::Like);
452 }
453
454 return new CyberCriteriaGroup($criteria, CyberConjunct::GroupOr);
455 }
456
457 /**
458 * Converts PHP array of conditions to CyberSortItem
459 *
460 * @param array $order
461 * @return array Array of CyberSortItem objects.
462 */
463 public static function orderToCyberSort($order = [])
464 {
465 $output = [];
466
467 foreach ($order as $field=>$direction) {
468 $sorter = new CyberSortItem($field, strtolower($direction)=='asc' ? (CyberSortType::Asc) : (CyberSortType::Desc));
469 array_push($output, $sorter);
470 }
471
472 return $output;
473 }
474
475 /**
476 * Finds record by request data
477 *
478 * @param array $data Expects the array to have a primary key member.
479 * @return Model Instantiated record found by Primary Key.
480 */
481 public function findRecord($data)
482 {
483 $recordClass = static::$recordClass;
484 $PrimaryKey = $recordClass::$_CyberDBPrimaryKey;
485
486 if (empty($data[$PrimaryKey])) {
487 throw new Exception('Primary key missing.');
488 }
489
490 if (!$record = new $recordClass($data[$PrimaryKey])) {
491 throw new Exception('Record not found.');
492 }
493 return $record;
494 }
495
496 /**
497 * Create request
498 *
499 * @return void
500 */
501 public function createRequest()
502 {
503 try {
504 $recordClass = static::$recordClass;
505 $record = new $recordClass();
506 } catch (Exception $e) {
507 return $this->error($e->getMessage());
508 }
509 return $this->edit($record);
510 }
511
512 /**
513 * Edit request
514 *
515 * @return void
516 */
517 public function editRequest()
518 {
519 try {
520 $record = $this->findRecord($this->data);
521 } catch (Exception $e) {
522 return $this->error($e->getMessage());
523 }
524 return $this->edit($record);
525 }
526
527 /**
528 * Edit a record and respond
529 *
530 * @param Model $record
531 * @throws Exception Runs try catch on Model->Save() and returns any exceptions.
532 * @return void
533 */
534 public function edit(Model $record)
535 {
536 if (!$this->checkWriteAccess($record)) {
537 return $this->throwUnauthorizedError();
538 }
539 $record->setFields($this->data);
540 try {
541 $record->Save();
542 } catch (Exception $e) {
543 return $this->error($e->getMessage());
544 }
545 $this->Success = true;
546 $this->respond([
547 'data' => $record,
548 ]);
549 }
550
551 /**
552 * Delete request
553 *
554 * @return void
555 */
556 public function deleteRequest()
557 {
558 if (!in_array($_SERVER['REQUEST_METHOD'], ['POST','PUT','DELETE'])) {
559 return $this->error('Please use request method POST, PUT, or DELETE or delete api requests.');
560 }
561
562 try {
563 $record = $this->findRecord($this->data);
564 } catch (Exception $e) {
565 return $this->error($e->getMessage());
566 }
567 return $this->delete($record);
568 }
569
570 /**
571 * Delete a record and respond. The response must always return the original record.
572 *
573 * @param Model $record
574 * @throws Exception Runs try catch on Model->delete() and returns any exceptions.
575 * @return void
576 */
577 public function delete(Model $record)
578 {
579 if (!$this->checkWriteAccess($record)) {
580 return $this->throwUnauthorizedError();
581 }
582 try {
583 $record->Delete();
584 } catch (Exception $e) {
585 return $this->error($e->getMessage());
586 }
587 $this->Success = true;
588 $this->respond([
589 'data' => $record,
590 ]);
591 }
592
593 /**
594 * Pulls in JSON request data
595 *
596 * @param string $subkey If given checks for something inside the request data and returns that instead of the whole thing.
597 * @return void
598 */
599 public function getJSONFromRequest($subkey=null)
600 {
601 $inputStream = 'php://input';
602
603 if (!$requestText = file_get_contents($inputStream)) {
604 return false;
605 }
606
607 $data = json_decode($requestText, true);
608
609 return $subkey ? $data[$subkey] : $data;
610 }
611
612 // access control template functions
613 public function checkBrowseAccess()
614 {
615 return true;
616 }
617
618 public function checkReadAccess(Model $model = null)
619 {
620 return true;
621 }
622
623 public function checkWriteAccess(Model $model = null)
624 {
625 return true;
626 }
627
628 public function checkAPIAccess()
629 {
630 return true;
631 }
632
633 public function throwUnauthorizedError()
634 {
635 $this->error('Login required.');
636 }
637
638 public function throwAPIUnAuthorizedError()
639 {
640 $this->error('API access required.');
641 }
642
643 public function throwNotFoundError()
644 {
645 $this->error('Record not found.');
646 }
647}