Items   F
last analyzed

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
 * @license  0BSD
7
 */
8
namespace cs\modules\Shop;
9
use
10
	cs\Cache\Prefix,
11
	cs\Config,
12
	cs\Event,
13
	cs\Language,
14
	cs\User,
15
	cs\CRUD_helpers,
16
	cs\Singleton;
17
18
/**
19
 * Provides next events:<br>
20
 *  Shop/Items/get<code>
21
 *  [
22
 *   'data' => &$data
23
 *  ]</code>
24
 *
25
 *  Shop/Items/get_for_user<code>
26
 *  [
27
 *   'data' => &$data,
28
 *   'user' => $user
29
 *  ]</code>
30
 *
31
 *  Shop/Items/add<code>
32
 *  [
33
 *   'id' => $id
34
 *  ]</code>
35
 *
36
 *  Shop/Items/set<code>
37
 *  [
38
 *   'id' => $id
39
 *  ]</code>
40
 *
41
 *  Shop/Items/del<code>
42
 *  [
43
 *   'id' => $id
44
 *  ]</code>
45
 *
46
 * @method static $this instance($check = false)
47
 */
48
class Items {
49
	use CRUD_helpers {
50
		search as crud_search;
51
	}
52
	use
53
		Singleton;
54
55
	const DEFAULT_IMAGE = 'modules/Shop/assets/img/no-image.svg';
56
57
	protected $data_model                  = [
58
		'id'         => 'int:0',
59
		'date'       => 'int:0',
60
		'category'   => 'int:0',
61
		'price'      => 'float',
62
		'in_stock'   => 'int:0',
63
		'soon'       => 'int:0..1',
64
		'listed'     => 'int:0..1',
65
		'attributes' => [
66
			'data_model' => [
67
				'id'            => 'int:0',
68
				'attribute'     => 'int:0',
69
				'numeric_value' => 'float',
70
				'string_value'  => 'text',
71
				'text_value'    => 'html',
72
				'lang'          => 'text' // Some attributes are language-dependent, some aren't, so we'll handle that manually
73
			]
74
		],
75
		'images'     => [
76
			'data_model' => [
77
				'id'    => 'int:0',
78
				'image' => 'text'
79
			]
80
		],
81
		'videos'     => [
82
			'data_model' => [
83
				'id'     => 'int:0',
84
				'video'  => 'text',
85
				'poster' => 'text',
86
				'type'   => 'text'
87
			]
88
		],
89
		'tags'       => [
90
			'data_model'     => [
91
				'id'  => 'int:0',
92
				'tag' => 'int:0'
93
			],
94
			'language_field' => 'lang'
95
		]
96
	];
97
	protected $table                       = '[prefix]shop_items';
98
	protected $data_model_files_tag_prefix = 'Shop/items';
99
	/**
100
	 * @var Prefix
101
	 */
102
	protected $cache;
103
104
	protected function construct () {
105
		$this->cache = new Prefix('Shop/items');
106
	}
107
	/**
108
	 * Returns database index
109
	 *
110
	 * @return int
111
	 */
112
	protected function cdb () {
113
		return Config::instance()->module('Shop')->db('shop');
114
	}
115
	/**
116
	 * Get item
117
	 *
118
	 * @param int|int[] $id
119
	 *
120
	 * @return array|false
121
	 */
122
	public function get ($id) {
123
		if (is_array($id)) {
124
			return array_map([$this, 'get'], $id);
125
		}
126
		$L    = Language::instance();
127
		$id   = (int)$id;
128
		$data = $this->cache->get(
129
			"$id/$L->clang",
130
			function () use ($id, $L) {
131
				$data = $this->read($id);
132
				if (!$data) {
133
					return false;
134
				}
135
				$data['attributes']  = $this->read_attributes_processing($data['attributes'], $L->clang);
136
				$category            = Categories::instance()->get($data['category']);
137
				$data['title']       = $data['attributes'][$category['title_attribute']];
138
				$data['description'] = $data['attributes'][$category['description_attribute']] ?? '';
139
				$data['tags']        = $this->read_tags_processing($data['tags']);
0 ignored issues
show
Bug introduced by
It seems like $data['tags'] can also be of type string; however, parameter $tags of cs\modules\Shop\Items::read_tags_processing() does only seem to accept integer[], 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

139
				$data['tags']        = $this->read_tags_processing(/** @scrutinizer ignore-type */ $data['tags']);
Loading history...
140
				return $data;
141
			}
142
		);
143
		if (!Event::instance()->fire(
144
			'Shop/Items/get',
145
			[
146
				'data' => &$data
147
			]
148
		)
149
		) {
150
			return false;
151
		}
152
		return $data;
153
	}
154
	/**
155
	 * Transform normalized attributes structure back into simple initial structure
156
	 *
157
	 * @param array  $attributes
158
	 * @param string $clang
159
	 *
160
	 * @return array
161
	 */
162
	protected function read_attributes_processing ($attributes, $clang) {
163
		/**
164
		 * Select language-independent attributes and ones that are set for current language
165
		 */
166
		$filtered_attributes = array_filter(
167
			$attributes,
168
			function ($attribute) use ($clang) {
169
				return !$attribute['lang'] || $attribute['lang'] == $clang;
170
			}
171
		);
172
		$existing_attributes = array_column($filtered_attributes, 'attribute');
173
		/**
174
		 * Now fill other existing attributes that are missing for current language
175
		 */
176
		foreach ($attributes as $attribute) {
177
			if (!in_array($attribute['attribute'], $existing_attributes)) {
178
				$existing_attributes[] = $attribute['attribute'];
179
				$filtered_attributes[] = $attribute;
180
			}
181
		}
182
		/**
183
		 * We have attributes of different types, so, here is normalization for that
184
		 */
185
		$Attributes = Attributes::instance();
186
		foreach ($filtered_attributes as &$value) {
187
			$attribute = $Attributes->get($value['attribute']);
188
			if ($attribute) {
189
				$value['value'] = $value[$this->attribute_type_to_value_field($attribute['type'])];
190
			} else {
191
				$value['value'] = $value['text_value'];
192
				if (!strlen($value['value'])) {
193
					$value['value'] = $value['string_value'];
194
				}
195
				if (!strlen($value['value'])) {
196
					$value['value'] = $value['numeric_value'];
197
				}
198
			}
199
		}
200
		return array_column($filtered_attributes, 'value', 'attribute');
201
	}
202
	/**
203
	 * Transform tags ids back into array of strings
204
	 *
205
	 * @param int[] $tags
206
	 *
207
	 * @return string[]
208
	 */
209
	protected function read_tags_processing ($tags) {
210
		return array_column(Tags::instance()->get($tags) ?: [], 'text');
211
	}
212
	/**
213
	 * Get item data for specific user (price might be adjusted, some items may be restricted and so on)
214
	 *
215
	 * @param int|int[] $id
216
	 * @param bool|int  $user
217
	 *
218
	 * @return array|false
219
	 */
220
	public function get_for_user ($id, $user = false) {
221
		if (is_array($id)) {
222
			foreach ($id as $index => &$i) {
223
				$i = $this->get_for_user($i, $user);
224
				if ($i === false) {
225
					unset($id[$index]);
226
				}
227
			}
228
			return $id;
229
		}
230
		$user = (int)$user ?: User::instance()->id;
231
		$data = $this->get($id);
232
		if (!Event::instance()->fire(
233
			'Shop/Items/get_for_user',
234
			[
235
				'data' => &$data,
236
				'user' => $user
237
			]
238
		)
239
		) {
240
			return false;
241
		}
242
		return $data;
243
	}
244
	/**
245
	 * Get array of all items
246
	 *
247
	 * @return int[] Array of items ids
248
	 */
249
	public function get_all () {
250
		return $this->cache->get(
251
			'all',
252
			function () {
253
				return $this->crud_search([], 1, PHP_INT_MAX, 'id', true) ?: [];
254
			}
255
		);
256
	}
257
	/**
258
	 * Items search
259
	 *
260
	 * @param mixed[] $search_parameters Array in form [attribute => value], [attribute => [value, value]], [attribute => [from => value, to => value]],
261
	 *                                   [property => value], [tag] or mixed; if `total_count => 1` element is present - total number of found rows will be
262
	 *                                   returned instead of rows themselves
263
	 * @param int     $page
264
	 * @param int     $count
265
	 * @param string  $order_by
266
	 * @param bool    $asc
267
	 *
268
	 * @return array|false|int
269
	 */
270
	public function search ($search_parameters = [], $page = 1, $count = 20, $order_by = 'id', $asc = false) {
271
		if (!isset($this->data_model[$order_by])) {
272
			return false;
273
		}
274
		$Attributes   = Attributes::instance();
275
		$L            = Language::instance();
276
		$joins        = '';
277
		$join_params  = [];
278
		$join_index   = 0;
279
		$where        = [];
280
		$where_params = [];
281
		foreach ($search_parameters as $key => $details) {
282
			if (isset($this->data_model[$key])) { // Property
283
				$where[]        = "`i`.`$key` = ?";
284
				$where_params[] = $details;
285
			} elseif (is_numeric($key)) { // Tag
286
				$joins .=
287
					"INNER JOIN `{$this->table}_tags` AS `t`
288
					ON
289
						`i`.`id`	= `t`.`id` AND
290
						`t`.`tag`	= ?";
291
				$where_params[] = $details;
292
			} else { // Attribute
293
				$field = @$this->attribute_type_to_value_field($Attributes->get($key)['type']);
294
				if (!$field || empty($details)) {
295
					continue;
296
				}
297
				$join_params[] = $key;
298
				++$join_index;
299
				$joins .=
300
					"INNER JOIN `{$this->table}_attributes` AS `a$join_index`
301
					ON
302
						`i`.`id`					= `a$join_index`.`id` AND
303
						`a$join_index`.`attribute`	= ? AND
304
						(
305
							`a$join_index`.`lang`	= '$L->clang' OR
306
							`a$join_index`.`lang`	= ''
307
						)";
308
				if (is_array($details)) {
309
					if (isset($details['from']) || isset($details['to'])) {
310
						/** @noinspection NotOptimalIfConditionsInspection */
311
						if (isset($details['from'])) {
312
							$joins .= "AND `a$join_index`.`$field`	>= ?";
313
							$join_params[] = $details['from'];
314
						}
315
						/** @noinspection NotOptimalIfConditionsInspection */
316
						if (isset($details['to'])) {
317
							$joins .= "AND `a$join_index`.`$field`	<= ?";
318
							$join_params[] = $details['to'];
319
						}
320
					} else {
321
						$on = [];
322
						foreach ($details as $d) {
323
							$on[]          = "`a$join_index`.`$field` = ?";
324
							$join_params[] = $d;
325
						}
326
						$on = implode(' OR ', $on);
327
						$joins .= "AND ($on)";
328
						unset($on, $d);
329
					}
330
				} else {
331
					switch ($field) {
332
						case 'numeric_value':
333
							$joins .= "AND `a$join_index`.`$field` = ?";
334
							$join_params[] = $details;
335
							break;
336
						case 'string_value':
337
							$joins .= "AND `a$join_index`.`$field` LIKE ?";
338
							$join_params[] = $details.'%';
339
							break;
340
						default:
341
							$joins .= "AND MATCH (`a$join_index`.`$field`) AGAINST (? IN BOOLEAN MODE) > 0";
342
							$join_params[] = $details;
343
					}
344
				}
345
			}
346
		}
347
		return $this->search_do('i', @$search_parameters['total_count'], $where, $where_params, $joins, $join_params, $page, $count, $order_by, $asc);
348
	}
349
	/**
350
	 * @param int $type
351
	 *
352
	 * @return false|string
353
	 */
354
	protected function attribute_type_to_value_field ($type) {
355
		switch ($type) {
356
			/**
357
			 * 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
358
			 * column for faster search
359
			 */
360
			case Attributes::TYPE_INT_SET:
361
			case Attributes::TYPE_INT_RANGE:
362
			case Attributes::TYPE_FLOAT_SET:
363
			case Attributes::TYPE_FLOAT_RANGE:
364
			case Attributes::TYPE_SWITCH:
365
			case Attributes::TYPE_STRING_SET:
366
			case Attributes::TYPE_COLOR_SET:
367
				return 'numeric_value';
368
			case Attributes::TYPE_STRING:
369
				return 'string_value';
370
			case Attributes::TYPE_TEXT:
371
				return 'text_value';
372
			default:
373
				return false;
374
		}
375
	}
376
	/**
377
	 * Add new item
378
	 *
379
	 * @param int      $category
380
	 * @param float    $price
381
	 * @param int      $in_stock
382
	 * @param int      $soon
383
	 * @param int      $listed
384
	 * @param array    $attributes
385
	 * @param string[] $images
386
	 * @param array[]  $videos
387
	 * @param string[] $tags
388
	 *
389
	 * @return false|int Id of created item on success of <b>false</> on failure
390
	 */
391
	public function add ($category, $price, $in_stock, $soon, $listed, $attributes, $images, $videos, $tags) {
392
		$L  = Language::instance();
393
		$id = $this->create(
394
			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

394
			/** @scrutinizer ignore-type */ time(),
Loading history...
395
			$category,
396
			$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

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