Completed
Push — master ( c5384d...c1ead9 )
by Nazar
04:21
created

Items::get()   B

Complexity

Conditions 6
Paths 4

Size

Total Lines 35
Code Lines 24

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 35
rs 8.439
cc 6
eloc 24
nc 4
nop 1
1
<?php
2
/**
3
 * @package   Shop
4
 * @category  modules
5
 * @author    Nazar Mokrynskyi <[email protected]>
6
 * @copyright Copyright (c) 2014-2016, 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 = 'components/modules/Shop/includes/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
	function get ($id) {
124
		if (is_array($id)) {
125
			foreach ($id as &$i) {
126
				$i = $this->get($i);
127
			}
128
			return $id;
129
		}
130
		$L    = Language::instance();
131
		$id   = (int)$id;
132
		$data = $this->cache->get(
133
			"$id/$L->clang",
134
			function () use ($id, $L) {
135
				$data = $this->read($id);
136
				if (!$data) {
137
					return false;
138
				}
139
				$data['attributes']  = $this->read_attributes_processing($data['attributes'], $L->clang);
140
				$category            = Categories::instance()->get($data['category']);
141
				$data['title']       = $data['attributes'][$category['title_attribute']];
142
				$data['description'] = @$data['attributes'][$category['description_attribute']] ?: '';
143
				$data['tags']        = $this->read_tags_processing($data['tags']);
144
				return $data;
145
			}
146
		);
147
		if (!Event::instance()->fire(
148
			'Shop/Items/get',
149
			[
150
				'data' => &$data
151
			]
152
		)
153
		) {
154
			return false;
155
		}
156
		return $data;
157
	}
158
	/**
159
	 * Transform normalized attributes structure back into simple initial structure
160
	 *
161
	 * @param array  $attributes
162
	 * @param string $clang
163
	 *
164
	 * @return array
165
	 */
166
	protected function read_attributes_processing ($attributes, $clang) {
167
		/**
168
		 * Select language-independent attributes and ones that are set for current language
169
		 */
170
		$filtered_attributes = array_filter(
171
			$attributes,
172
			function ($attribute) use ($clang) {
173
				return !$attribute['lang'] || $attribute['lang'] == $clang;
174
			}
175
		);
176
		$existing_attributes = array_column($filtered_attributes, 'attribute');
177
		/**
178
		 * Now fill other existing attributes that are missing for current language
179
		 */
180
		foreach ($attributes as $attribute) {
181
			if (!in_array($attribute['attribute'], $existing_attributes)) {
182
				$existing_attributes[] = $attribute['attribute'];
183
				$filtered_attributes[] = $attribute;
184
			}
185
		}
186
		/**
187
		 * We have attributes of different types, so, here is normalization for that
188
		 */
189
		$Attributes = Attributes::instance();
190
		foreach ($filtered_attributes as &$value) {
191
			$attribute = $Attributes->get($value['attribute']);
192
			if ($attribute) {
193
				$value['value'] = $value[$this->attribute_type_to_value_field($attribute['type'])];
194
			} else {
195
				$value['value'] = $value['text_value'];
196
				if (!strlen($value['value'])) {
197
					$value['value'] = $value['string_value'];
198
				}
199
				if (!strlen($value['value'])) {
200
					$value['value'] = $value['numeric_value'];
201
				}
202
			}
203
		}
204
		return array_column($filtered_attributes, 'value', 'attribute');
205
	}
206
	/**
207
	 * Transform tags ids back into array of strings
208
	 *
209
	 * @param int[] $tags
210
	 *
211
	 * @return string[]
212
	 */
213
	protected function read_tags_processing ($tags) {
214
		return array_column(Tags::instance()->get($tags) ?: [], 'text');
215
	}
216
	/**
217
	 * Get item data for specific user (price might be adjusted, some items may be restricted and so on)
218
	 *
219
	 * @param int|int[] $id
220
	 * @param bool|int  $user
221
	 *
222
	 * @return array|false
223
	 */
224
	function get_for_user ($id, $user = false) {
225
		if (is_array($id)) {
226
			foreach ($id as $index => &$i) {
227
				$i = $this->get_for_user($i, $user);
228
				if ($i === false) {
229
					unset($id[$index]);
230
				}
231
			}
232
			return $id;
233
		}
234
		$user = (int)$user ?: User::instance()->id;
235
		$data = $this->get($id);
236
		if (!Event::instance()->fire(
237
			'Shop/Items/get_for_user',
238
			[
239
				'data' => &$data,
240
				'user' => $user
241
			]
242
		)
243
		) {
244
			return false;
245
		}
246
		return $data;
247
	}
248
	/**
249
	 * Get array of all items
250
	 *
251
	 * @return int[] Array of items ids
252
	 */
253
	function get_all () {
254
		return $this->cache->get(
255
			'all',
256
			function () {
257
				return $this->search([], 1, PHP_INT_MAX, 'id', true) ?: [];
258
			}
259
		);
260
	}
261
	/**
262
	 * Items search
263
	 *
264
	 * @param mixed[] $search_parameters Array in form [attribute => value], [attribute => [value, value]], [attribute => [from => value, to => value]],
265
	 *                                   [property => value], [tag] or mixed; if `total_count => 1` element is present - total number of found rows will be
266
	 *                                   returned instead of rows themselves
267
	 * @param int     $page
268
	 * @param int     $count
269
	 * @param string  $order_by
270
	 * @param bool    $asc
271
	 *
272
	 * @return array|false|string
273
	 */
274
	function search ($search_parameters = [], $page = 1, $count = 20, $order_by = 'id', $asc = false) {
275
		if (!isset($this->data_model[$order_by])) {
276
			return false;
277
		}
278
		$Attributes   = Attributes::instance();
279
		$L            = Language::instance();
280
		$joins        = '';
281
		$join_params  = [];
282
		$join_index   = 0;
283
		$where        = [];
284
		$where_params = [];
285
		foreach ($search_parameters as $key => $details) {
286
			if (isset($this->data_model[$key])) { // Property
287
				$where[]        = "`i`.`$key` = '%s'";
288
				$where_params[] = $details;
289
			} elseif (is_numeric($key)) { // Tag
290
				$joins .=
291
					"INNER JOIN `{$this->table}_tags` AS `t`
292
					ON
293
						`i`.`id`	= `t`.`id` AND
294
						`t`.`tag`	= '%s'";
295
				$where_params[] = $details;
296
			} else { // Attribute
297
				$field = @$this->attribute_type_to_value_field($Attributes->get($key)['type']);
298
				if (!$field || empty($details)) {
299
					continue;
300
				}
301
				$join_params[] = $key;
302
				++$join_index;
303
				$joins .=
304
					"INNER JOIN `{$this->table}_attributes` AS `a$join_index`
305
					ON
306
						`i`.`id`					= `a$join_index`.`id` AND
307
						`a$join_index`.`attribute`	= '%s' AND
308
						(
309
							`a$join_index`.`lang`	= '$L->clang' OR
310
							`a$join_index`.`lang`	= ''
311
						)";
312
				if (is_array($details)) {
313
					if (isset($details['from']) || isset($details['to'])) {
314
						/** @noinspection NotOptimalIfConditionsInspection */
315
						if (isset($details['from'])) {
316
							$joins .= "AND `a$join_index`.`$field`	>= '%s'";
317
							$join_params[] = $details['from'];
318
						}
319
						/** @noinspection NotOptimalIfConditionsInspection */
320
						if (isset($details['to'])) {
321
							$joins .= "AND `a$join_index`.`$field`	<= '%s'";
322
							$join_params[] = $details['to'];
323
						}
324
					} else {
325
						$on = [];
326
						foreach ($details as $d) {
327
							$on[]          = "`a$join_index`.`$field` = '%s'";
328
							$join_params[] = $d;
329
						}
330
						$on = implode(' OR ', $on);
331
						$joins .= "AND ($on)";
332
						unset($on, $d);
333
					}
334
				} else {
335
					switch ($field) {
336
						case 'numeric_value':
337
							$joins .= "AND `a$join_index`.`$field` = '%s'";
338
							break;
339
						case 'string_value':
340
							$joins .= "AND `a$join_index`.`$field` LIKE '%s%%'";
341
							break;
342
						default:
343
							$joins .= "AND MATCH (`a$join_index`.`$field`) AGAINST ('%s' IN BOOLEAN MODE) > 0";
344
					}
345
					$join_params[] = $details;
346
				}
347
			}
348
		}
349
		return $this->search_do('i', @$search_parameters['total_count'], $where, $where_params, $joins, $join_params, $page, $count, $order_by, $asc);
350
	}
351
	/**
352
	 * @param int $type
353
	 *
354
	 * @return string
0 ignored issues
show
Documentation introduced by
Should the return type not be string|false?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
355
	 */
356
	protected function attribute_type_to_value_field ($type) {
357
		switch ($type) {
358
			/**
359
			 * 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
360
			 * column for faster search
361
			 */
362
			case Attributes::TYPE_INT_SET:
363
			case Attributes::TYPE_INT_RANGE:
364
			case Attributes::TYPE_FLOAT_SET:
365
			case Attributes::TYPE_FLOAT_RANGE:
366
			case Attributes::TYPE_SWITCH:
367
			case Attributes::TYPE_STRING_SET:
368
			case Attributes::TYPE_COLOR_SET:
369
				return 'numeric_value';
370
			case Attributes::TYPE_STRING:
371
				return 'string_value';
372
			case Attributes::TYPE_TEXT:
373
				return 'text_value';
374
			default:
375
				return false;
376
		}
377
	}
378
	/**
379
	 * Add new item
380
	 *
381
	 * @param int      $category
382
	 * @param float    $price
383
	 * @param int      $in_stock
384
	 * @param int      $soon
385
	 * @param int      $listed
386
	 * @param array    $attributes
387
	 * @param string[] $images
388
	 * @param array[]  $videos
389
	 * @param string[] $tags
390
	 *
391
	 * @return false|int Id of created item on success of <b>false</> on failure
1 ignored issue
show
Documentation introduced by
Should the return type not be integer|string|false?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

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