Passed
Push — master ( a4754c...e0c6ec )
by Nazar
05:32
created

Items   F

Complexity

Total Complexity 77

Size/Duplication

Total Lines 551
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 551
rs 2.1739
c 0
b 0
f 0
wmc 77

16 Methods

Rating   Name   Duplication   Size   Complexity  
C search() 0 78 15
A prepare_tags() 0 2 2
B set() 0 36 5
A get_all() 0 5 2
A del() 0 16 2
C read_attributes_processing() 0 39 8
A construct() 0 2 1
B get_for_user() 0 23 6
C prepare_attributes() 0 42 7
B attribute_type_to_value_field() 0 20 10
A read_tags_processing() 0 2 2
A prepare_images() 0 5 1
C prepare_videos() 0 22 7
B get() 0 31 4
B add() 0 24 4
A cdb() 0 2 1

How to fix   Complexity   

Complex Class

Complex classes like Items 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 Items, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @package   Shop
4
 * @category  modules
5
 * @author    Nazar Mokrynskyi <[email protected]>
6
 * @copyright Copyright (c) 2014-2017, Nazar Mokrynskyi
7
 * @license   MIT License, see license.txt
8
 */
9
namespace cs\modules\Shop;
10
use
11
	cs\Cache\Prefix,
12
	cs\Config,
13
	cs\Event,
14
	cs\Language,
15
	cs\User,
16
	cs\CRUD_helpers,
17
	cs\Singleton;
18
19
/**
20
 * Provides next events:<br>
21
 *  Shop/Items/get<code>
22
 *  [
23
 *   'data' => &$data
24
 *  ]</code>
25
 *
26
 *  Shop/Items/get_for_user<code>
27
 *  [
28
 *   'data' => &$data,
29
 *   'user' => $user
30
 *  ]</code>
31
 *
32
 *  Shop/Items/add<code>
33
 *  [
34
 *   'id' => $id
35
 *  ]</code>
36
 *
37
 *  Shop/Items/set<code>
38
 *  [
39
 *   'id' => $id
40
 *  ]</code>
41
 *
42
 *  Shop/Items/del<code>
43
 *  [
44
 *   'id' => $id
45
 *  ]</code>
46
 *
47
 * @method static $this instance($check = false)
48
 */
49
class Items {
50
	use CRUD_helpers {
51
		search as crud_search;
52
	}
53
	use
54
		Singleton;
55
56
	const DEFAULT_IMAGE = 'modules/Shop/assets/img/no-image.svg';
57
58
	protected $data_model                  = [
59
		'id'         => 'int:0',
60
		'date'       => 'int:0',
61
		'category'   => 'int:0',
62
		'price'      => 'float',
63
		'in_stock'   => 'int:0',
64
		'soon'       => 'int:0..1',
65
		'listed'     => 'int:0..1',
66
		'attributes' => [
67
			'data_model' => [
68
				'id'            => 'int:0',
69
				'attribute'     => 'int:0',
70
				'numeric_value' => 'float',
71
				'string_value'  => 'text',
72
				'text_value'    => 'html',
73
				'lang'          => 'text' // Some attributes are language-dependent, some aren't, so we'll handle that manually
74
			]
75
		],
76
		'images'     => [
77
			'data_model' => [
78
				'id'    => 'int:0',
79
				'image' => 'text'
80
			]
81
		],
82
		'videos'     => [
83
			'data_model' => [
84
				'id'     => 'int:0',
85
				'video'  => 'text',
86
				'poster' => 'text',
87
				'type'   => 'text'
88
			]
89
		],
90
		'tags'       => [
91
			'data_model'     => [
92
				'id'  => 'int:0',
93
				'tag' => 'int:0'
94
			],
95
			'language_field' => 'lang'
96
		]
97
	];
98
	protected $table                       = '[prefix]shop_items';
99
	protected $data_model_files_tag_prefix = 'Shop/items';
100
	/**
101
	 * @var Prefix
102
	 */
103
	protected $cache;
104
105
	protected function construct () {
106
		$this->cache = new Prefix('Shop/items');
107
	}
108
	/**
109
	 * Returns database index
110
	 *
111
	 * @return int
112
	 */
113
	protected function cdb () {
114
		return Config::instance()->module('Shop')->db('shop');
115
	}
116
	/**
117
	 * Get item
118
	 *
119
	 * @param int|int[] $id
120
	 *
121
	 * @return array|false
122
	 */
123
	public function get ($id) {
124
		if (is_array($id)) {
125
			return array_map([$this, 'get'], $id);
126
		}
127
		$L    = Language::instance();
128
		$id   = (int)$id;
129
		$data = $this->cache->get(
130
			"$id/$L->clang",
131
			function () use ($id, $L) {
132
				$data = $this->read($id);
133
				if (!$data) {
0 ignored issues
show
introduced by
The condition ! $data can never be false.
Loading history...
134
					return false;
135
				}
136
				$data['attributes']  = $this->read_attributes_processing($data['attributes'], $L->clang);
137
				$category            = Categories::instance()->get($data['category']);
138
				$data['title']       = $data['attributes'][$category['title_attribute']];
139
				$data['description'] = $data['attributes'][$category['description_attribute']] ?? '';
140
				$data['tags']        = $this->read_tags_processing($data['tags']);
141
				return $data;
142
			}
143
		);
144
		if (!Event::instance()->fire(
145
			'Shop/Items/get',
146
			[
147
				'data' => &$data
148
			]
149
		)
150
		) {
151
			return false;
152
		}
153
		return $data;
154
	}
155
	/**
156
	 * Transform normalized attributes structure back into simple initial structure
157
	 *
158
	 * @param array  $attributes
159
	 * @param string $clang
160
	 *
161
	 * @return array
162
	 */
163
	protected function read_attributes_processing ($attributes, $clang) {
164
		/**
165
		 * Select language-independent attributes and ones that are set for current language
166
		 */
167
		$filtered_attributes = array_filter(
168
			$attributes,
169
			function ($attribute) use ($clang) {
170
				return !$attribute['lang'] || $attribute['lang'] == $clang;
171
			}
172
		);
173
		$existing_attributes = array_column($filtered_attributes, 'attribute');
174
		/**
175
		 * Now fill other existing attributes that are missing for current language
176
		 */
177
		foreach ($attributes as $attribute) {
178
			if (!in_array($attribute['attribute'], $existing_attributes)) {
179
				$existing_attributes[] = $attribute['attribute'];
180
				$filtered_attributes[] = $attribute;
181
			}
182
		}
183
		/**
184
		 * We have attributes of different types, so, here is normalization for that
185
		 */
186
		$Attributes = Attributes::instance();
187
		foreach ($filtered_attributes as &$value) {
188
			$attribute = $Attributes->get($value['attribute']);
189
			if ($attribute) {
0 ignored issues
show
introduced by
The condition $attribute can never be true.
Loading history...
190
				$value['value'] = $value[$this->attribute_type_to_value_field($attribute['type'])];
191
			} else {
192
				$value['value'] = $value['text_value'];
193
				if (!strlen($value['value'])) {
194
					$value['value'] = $value['string_value'];
195
				}
196
				if (!strlen($value['value'])) {
197
					$value['value'] = $value['numeric_value'];
198
				}
199
			}
200
		}
201
		return array_column($filtered_attributes, 'value', 'attribute');
202
	}
203
	/**
204
	 * Transform tags ids back into array of strings
205
	 *
206
	 * @param int[] $tags
207
	 *
208
	 * @return string[]
209
	 */
210
	protected function read_tags_processing ($tags) {
211
		return array_column(Tags::instance()->get($tags) ?: [], 'text');
212
	}
213
	/**
214
	 * Get item data for specific user (price might be adjusted, some items may be restricted and so on)
215
	 *
216
	 * @param int|int[] $id
217
	 * @param bool|int  $user
218
	 *
219
	 * @return array|false
220
	 */
221
	public function get_for_user ($id, $user = false) {
222
		if (is_array($id)) {
223
			foreach ($id as $index => &$i) {
224
				$i = $this->get_for_user($i, $user);
225
				if ($i === false) {
0 ignored issues
show
introduced by
The condition $i === false can never be false.
Loading history...
226
					unset($id[$index]);
227
				}
228
			}
229
			return $id;
230
		}
231
		$user = (int)$user ?: User::instance()->id;
232
		$data = $this->get($id);
233
		if (!Event::instance()->fire(
234
			'Shop/Items/get_for_user',
235
			[
236
				'data' => &$data,
237
				'user' => $user
238
			]
239
		)
240
		) {
241
			return false;
242
		}
243
		return $data;
244
	}
245
	/**
246
	 * Get array of all items
247
	 *
248
	 * @return int[] Array of items ids
249
	 */
250
	public function get_all () {
251
		return $this->cache->get(
252
			'all',
253
			function () {
254
				return $this->crud_search([], 1, PHP_INT_MAX, 'id', true) ?: [];
255
			}
256
		);
257
	}
258
	/**
259
	 * Items search
260
	 *
261
	 * @param mixed[] $search_parameters Array in form [attribute => value], [attribute => [value, value]], [attribute => [from => value, to => value]],
262
	 *                                   [property => value], [tag] or mixed; if `total_count => 1` element is present - total number of found rows will be
263
	 *                                   returned instead of rows themselves
264
	 * @param int     $page
265
	 * @param int     $count
266
	 * @param string  $order_by
267
	 * @param bool    $asc
268
	 *
269
	 * @return array|false|int
270
	 */
271
	public function search ($search_parameters = [], $page = 1, $count = 20, $order_by = 'id', $asc = false) {
272
		if (!isset($this->data_model[$order_by])) {
273
			return false;
274
		}
275
		$Attributes   = Attributes::instance();
276
		$L            = Language::instance();
277
		$joins        = '';
278
		$join_params  = [];
279
		$join_index   = 0;
280
		$where        = [];
281
		$where_params = [];
282
		foreach ($search_parameters as $key => $details) {
283
			if (isset($this->data_model[$key])) { // Property
284
				$where[]        = "`i`.`$key` = ?";
285
				$where_params[] = $details;
286
			} elseif (is_numeric($key)) { // Tag
287
				$joins .=
288
					"INNER JOIN `{$this->table}_tags` AS `t`
289
					ON
290
						`i`.`id`	= `t`.`id` AND
291
						`t`.`tag`	= ?";
292
				$where_params[] = $details;
293
			} else { // Attribute
294
				$field = @$this->attribute_type_to_value_field($Attributes->get($key)['type']);
295
				if (!$field || empty($details)) {
296
					continue;
297
				}
298
				$join_params[] = $key;
299
				++$join_index;
300
				$joins .=
301
					"INNER JOIN `{$this->table}_attributes` AS `a$join_index`
302
					ON
303
						`i`.`id`					= `a$join_index`.`id` AND
304
						`a$join_index`.`attribute`	= ? AND
305
						(
306
							`a$join_index`.`lang`	= '$L->clang' OR
307
							`a$join_index`.`lang`	= ''
308
						)";
309
				if (is_array($details)) {
310
					if (isset($details['from']) || isset($details['to'])) {
311
						/** @noinspection NotOptimalIfConditionsInspection */
312
						if (isset($details['from'])) {
313
							$joins .= "AND `a$join_index`.`$field`	>= ?";
314
							$join_params[] = $details['from'];
315
						}
316
						/** @noinspection NotOptimalIfConditionsInspection */
317
						if (isset($details['to'])) {
318
							$joins .= "AND `a$join_index`.`$field`	<= ?";
319
							$join_params[] = $details['to'];
320
						}
321
					} else {
322
						$on = [];
323
						foreach ($details as $d) {
324
							$on[]          = "`a$join_index`.`$field` = ?";
325
							$join_params[] = $d;
326
						}
327
						$on = implode(' OR ', $on);
328
						$joins .= "AND ($on)";
329
						unset($on, $d);
330
					}
331
				} else {
332
					switch ($field) {
333
						case 'numeric_value':
334
							$joins .= "AND `a$join_index`.`$field` = ?";
335
							$join_params[] = $details;
336
							break;
337
						case 'string_value':
338
							$joins .= "AND `a$join_index`.`$field` LIKE ?";
339
							$join_params[] = $details.'%';
340
							break;
341
						default:
342
							$joins .= "AND MATCH (`a$join_index`.`$field`) AGAINST (? IN BOOLEAN MODE) > 0";
343
							$join_params[] = $details;
344
					}
345
				}
346
			}
347
		}
348
		return $this->search_do('i', @$search_parameters['total_count'], $where, $where_params, $joins, $join_params, $page, $count, $order_by, $asc);
349
	}
350
	/**
351
	 * @param int $type
352
	 *
353
	 * @return false|string
354
	 */
355
	protected function attribute_type_to_value_field ($type) {
356
		switch ($type) {
357
			/**
358
			 * For numeric values and value sets (each value have its own index in set and does not depend on language) value is stored in numeric
359
			 * column for faster search
360
			 */
361
			case Attributes::TYPE_INT_SET:
362
			case Attributes::TYPE_INT_RANGE:
363
			case Attributes::TYPE_FLOAT_SET:
364
			case Attributes::TYPE_FLOAT_RANGE:
365
			case Attributes::TYPE_SWITCH:
366
			case Attributes::TYPE_STRING_SET:
367
			case Attributes::TYPE_COLOR_SET:
368
				return 'numeric_value';
369
			case Attributes::TYPE_STRING:
370
				return 'string_value';
371
			case Attributes::TYPE_TEXT:
372
				return 'text_value';
373
			default:
374
				return false;
375
		}
376
	}
377
	/**
378
	 * Add new item
379
	 *
380
	 * @param int      $category
381
	 * @param float    $price
382
	 * @param int      $in_stock
383
	 * @param int      $soon
384
	 * @param int      $listed
385
	 * @param array    $attributes
386
	 * @param string[] $images
387
	 * @param array[]  $videos
388
	 * @param string[] $tags
389
	 *
390
	 * @return false|int Id of created item on success of <b>false</> on failure
391
	 */
392
	public function add ($category, $price, $in_stock, $soon, $listed, $attributes, $images, $videos, $tags) {
393
		$L  = Language::instance();
394
		$id = $this->create(
395
			time(),
0 ignored issues
show
Bug introduced by
time() of type integer is incompatible with the type array expected by parameter $arguments of cs\modules\Shop\Items::create(). ( Ignorable by Annotation )

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

395
			/** @scrutinizer ignore-type */ time(),
Loading history...
396
			$category,
397
			$price,
0 ignored issues
show
Bug introduced by
$price of type double is incompatible with the type array expected by parameter $arguments of cs\modules\Shop\Items::create(). ( Ignorable by Annotation )

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

397
			/** @scrutinizer ignore-type */ $price,
Loading history...
398
			$in_stock,
399
			$soon && !$in_stock ? 1 : 0,
400
			$listed,
401
			$this->prepare_attributes($attributes, $category, $L->clang),
402
			$this->prepare_images($images),
403
			$this->prepare_videos($videos),
404
			$this->prepare_tags($tags)
405
		);
406
		if ($id) {
407
			unset($this->cache->all);
0 ignored issues
show
Bug Best Practice introduced by
The property all does not exist on cs\Cache\Prefix. Since you implemented __get, consider adding a @property annotation.
Loading history...
408
			Event::instance()->fire(
409
				'Shop/Items/add',
410
				[
411
					'id' => $id
412
				]
413
			);
414
		}
415
		return $id;
416
	}
417
	/**
418
	 * Normalize attributes array structure
419
	 *
420
	 * @param array  $attributes
421
	 * @param int    $category
422
	 * @param string $clang
423
	 *
424
	 * @return array
425
	 */
426
	protected function prepare_attributes ($attributes, $category, $clang) {
427
		$Attributes      = Attributes::instance();
428
		$title_attribute = Categories::instance()->get($category)['title_attribute'];
429
		foreach ($attributes as $attribute => &$value) {
430
			$attribute_data = $Attributes->get($attribute);
431
			if (!$attribute_data) {
0 ignored issues
show
introduced by
The condition ! $attribute_data can never be false.
Loading history...
432
				unset($attributes[$attribute]);
433
				continue;
434
			}
435
			$value_type = [
436
				'numeric' => 0,
437
				'string'  => '',
438
				'text'    => ''
439
			];
440
			$lang       = '';
441
			switch ($this->attribute_type_to_value_field($attribute_data['type'])) {
442
				case 'numeric_value':
443
					$value_type['numeric'] = $value;
444
					break;
445
				case 'string_value':
446
					$value_type['string'] = xap($value);
447
					/**
448
					 * Multilingual feature only for title attribute
449
					 */
450
					if ($attribute_data['id'] == $title_attribute) {
451
						$lang = $clang;
452
					}
453
					break;
454
				case 'text_value':
455
					$value_type['text'] = xap($value, true, true);
456
					$lang               = $clang;
457
					break;
458
			}
459
			$value = [
460
				$attribute_data['id'],
461
				$value_type['numeric'],
462
				$value_type['string'],
463
				$value_type['text'],
464
				$lang
465
			];
466
		}
467
		return array_values($attributes);
468
	}
469
	/**
470
	 * Filter images to remove non-URL elements
471
	 *
472
	 * @param array $images
473
	 *
474
	 * @return array
475
	 */
476
	protected function prepare_images ($images) {
477
		return array_filter(
478
			$images,
479
			function ($image) {
480
				return filter_var($image, FILTER_VALIDATE_URL);
481
			}
482
		);
483
	}
484
	/**
485
	 * Normalize videos array structure
486
	 *
487
	 * @param array[] $videos
488
	 *
489
	 * @return array[]
490
	 */
491
	protected function prepare_videos ($videos) {
492
		if (!$videos || !is_array($videos)) {
0 ignored issues
show
introduced by
The condition ! $videos || ! is_array($videos) can never be false.
Loading history...
493
			return [];
494
		}
495
		$videos = array_flip_3d($videos);
496
		foreach ($videos as $i => &$video) {
497
			if (!@$video['video']) {
498
				unset($videos[$i]);
499
			}
500
			if (
501
				$video['type'] == 'iframe' &&
502
				preg_match('#(http[s]?:)?//[^\s"\'>]+#ims', $video['video'], $match)
503
			) {
504
				$video['video'] = $match[0];
505
			}
506
			$video = [
507
				$video['video'],
508
				$video['poster'],
509
				$video['type']
510
			];
511
		}
512
		return $videos;
513
	}
514
	/**
515
	 * Transform array of string tags into array of their ids
516
	 *
517
	 * @param string[] $tags
518
	 *
519
	 * @return int[]
520
	 */
521
	protected function prepare_tags ($tags) {
522
		return Tags::instance()->add($tags) ?: [];
523
	}
524
	/**
525
	 * Set data of specified item
526
	 *
527
	 * @param int      $id
528
	 * @param int      $category
529
	 * @param float    $price
530
	 * @param int      $in_stock
531
	 * @param int      $soon
532
	 * @param int      $listed
533
	 * @param array    $attributes
534
	 * @param string[] $images
535
	 * @param array[]  $videos
536
	 * @param string[] $tags
537
	 *
538
	 * @return bool
539
	 */
540
	public function set ($id, $category, $price, $in_stock, $soon, $listed, $attributes, $images, $videos, $tags) {
541
		$id   = (int)$id;
542
		$data = $this->get($id);
543
		if (!$data) {
0 ignored issues
show
introduced by
The condition ! $data can never be false.
Loading history...
544
			return false;
545
		}
546
		$L      = Language::instance();
547
		$result = $this->update(
548
			$id,
549
			$data['date'],
550
			$category,
551
			$price,
552
			$in_stock,
553
			$soon && !$in_stock ? 1 : 0,
554
			$listed,
555
			$this->prepare_attributes($attributes, $category, $L->clang),
556
			$this->prepare_images($images),
557
			$this->prepare_videos($videos),
558
			$this->prepare_tags($tags)
559
		);
560
		if ($result) {
561
			/**
562
			 * Attributes processing
563
			 */
564
			unset(
565
				$this->cache->{"$id/$L->clang"},
566
				$this->cache->all
567
			);
568
			Event::instance()->fire(
569
				'Shop/Items/set',
570
				[
571
					'id' => $id
572
				]
573
			);
574
		}
575
		return $result;
576
	}
577
	/**
578
	 * Delete specified item
579
	 *
580
	 * @param int $id
581
	 *
582
	 * @return bool
583
	 */
584
	public function del ($id) {
585
		$id     = (int)$id;
586
		$result = $this->delete($id);
587
		if ($result) {
588
			unset(
589
				$this->cache->$id,
590
				$this->cache->all
0 ignored issues
show
Bug Best Practice introduced by
The property all does not exist on cs\Cache\Prefix. Since you implemented __get, consider adding a @property annotation.
Loading history...
591
			);
592
			Event::instance()->fire(
593
				'Shop/Items/del',
594
				[
595
					'id' => $id
596
				]
597
			);
598
		}
599
		return $result;
600
	}
601
}
602