WebOrbiton
v1.0.0.1

StocketBase

172 lines · 6.2 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/pagination.php';
  7. ​
  8. final class ProductSearch
  9. {
  10. public const SORT_OPTIONS = ['newest', 'oldest', 'price_asc', 'price_desc', 'rating'];
  11. ​
  12. public static function sanitizeSort(string $sort): string
  13. {
  14. return in_array($sort, self::SORT_OPTIONS, true) ? $sort : 'newest';
  15. }
  16. ​
  17. public static function run(array $settings, array $filters, int $page, int $perPageDefault): array
  18. {
  19. $db = Database::site();
  20. ​
  21. $query = trim((string) ($filters['q'] ?? ''));
  22. $categoryId = (int) ($filters['category'] ?? 0);
  23. $tagId = (int) ($filters['tag'] ?? 0);
  24. $sort = self::sanitizeSort((string) ($filters['sort'] ?? 'newest'));
  25. ​
  26. $conditions = ["p.status = 'published'"];
  27. $bindings = [];
  28. ​
  29. if ($query !== '') {
  30. $matchTitle = ($settings['search_match_title'] ?? '1') === '1';
  31. $matchExcerpt = ($settings['search_match_excerpt'] ?? '0') === '1';
  32. $matchContent = ($settings['search_match_content'] ?? '0') === '1';
  33. if (!$matchTitle && !$matchExcerpt && !$matchContent) {
  34. $matchTitle = true;
  35. }
  36. ​
  37. $matchConds = [];
  38. $likeValue = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $query) . '%';
  39. if ($matchTitle) {
  40. $matchConds[] = 'p.title LIKE :q_title';
  41. $bindings['q_title'] = $likeValue;
  42. }
  43. if ($matchExcerpt) {
  44. $matchConds[] = 'p.excerpt LIKE :q_excerpt';
  45. $bindings['q_excerpt'] = $likeValue;
  46. }
  47. if ($matchContent) {
  48. $matchConds[] = 'p.content_blocks LIKE :q_content';
  49. $bindings['q_content'] = $likeValue;
  50. }
  51. $conditions[] = '(' . implode(' OR ', $matchConds) . ')';
  52. }
  53. ​
  54. if ($categoryId > 0) {
  55. $conditions[] = 'p.category_id = :category_id';
  56. $bindings['category_id'] = $categoryId;
  57. }
  58. ​
  59. $joinSql = '';
  60. if ($tagId > 0) {
  61. $joinSql = ' JOIN product_tags pt ON pt.product_id = p.id';
  62. $conditions[] = 'pt.tag_id = :tag_id';
  63. $bindings['tag_id'] = $tagId;
  64. }
  65. ​
  66. $whereSql = implode(' AND ', $conditions);
  67. ​
  68. $countStatement = $db->prepare("SELECT COUNT(*) FROM products p{$joinSql} WHERE {$whereSql}");
  69. $countStatement->execute($bindings);
  70. $total = (int) $countStatement->fetchColumn();
  71. ​
  72. $isStatic = ($settings['product_loading_mode'] ?? 'scroll') === 'static';
  73. $perPage = $isStatic ? max(1, $total) : max(1, $perPageDefault);
  74. $page = $isStatic ? 1 : max(1, $page);
  75. ​
  76. if ($sort === 'rating') {
  77. return self::runRatingSort($db, $joinSql, $whereSql, $bindings, $total, $perPage, $page);
  78. }
  79. ​
  80. $orderSql = match ($sort) {
  81. 'oldest' => 'p.published_at ASC',
  82. 'price_asc' => 'p.price_cents ASC',
  83. 'price_desc' => 'p.price_cents DESC',
  84. default => 'p.published_at DESC',
  85. };
  86. ​
  87. $totalPages = Pagination::totalPages($total, $perPage);
  88. $offset = Pagination::offset($page, $perPage);
  89. ​
  90. $statement = $db->prepare(
  91. "SELECT p.*, c.name AS category_name, c.slug AS category_slug
  92. FROM products p{$joinSql}
  93. LEFT JOIN categories c ON c.id = p.category_id
  94. WHERE {$whereSql}
  95. ORDER BY {$orderSql}
  96. LIMIT :limit OFFSET :offset"
  97. );
  98. foreach ($bindings as $key => $value) {
  99. $statement->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
  100. }
  101. $statement->bindValue('limit', $perPage, PDO::PARAM_INT);
  102. $statement->bindValue('offset', $offset, PDO::PARAM_INT);
  103. $statement->execute();
  104. ​
  105. return [
  106. 'products' => $statement->fetchAll(),
  107. 'total' => $total,
  108. 'totalPages' => $totalPages,
  109. 'page' => $page,
  110. 'perPage' => $perPage,
  111. ];
  112. }
  113. ​
  114. private static function runRatingSort(PDO $db, string $joinSql, string $whereSql, array $bindings, int $total, int $perPage, int $page): array
  115. {
  116. $statement = $db->prepare(
  117. "SELECT p.*, c.name AS category_name, c.slug AS category_slug
  118. FROM products p{$joinSql}
  119. LEFT JOIN categories c ON c.id = p.category_id
  120. WHERE {$whereSql}
  121. ORDER BY p.published_at DESC"
  122. );
  123. foreach ($bindings as $key => $value) {
  124. $statement->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
  125. }
  126. $statement->execute();
  127. $allProducts = $statement->fetchAll();
  128. ​
  129. $ratings = self::ratingsFor(array_column($allProducts, 'id'));
  130. foreach ($allProducts as &$product) {
  131. $product['_avg_rating'] = $ratings[(int) $product['id']] ?? 0.0;
  132. }
  133. unset($product);
  134. ​
  135. usort($allProducts, static fn(array $a, array $b): int => $b['_avg_rating'] <=> $a['_avg_rating']);
  136. ​
  137. $totalPages = Pagination::totalPages($total, $perPage);
  138. $offset = Pagination::offset($page, $perPage);
  139. ​
  140. return [
  141. 'products' => array_slice($allProducts, $offset, $perPage),
  142. 'total' => $total,
  143. 'totalPages' => $totalPages,
  144. 'page' => $page,
  145. 'perPage' => $perPage,
  146. ];
  147. }
  148. ​
  149. private static function ratingsFor(array $productIds): array
  150. {
  151. $productIds = array_values(array_unique(array_map('intval', $productIds)));
  152. if (empty($productIds)) {
  153. return [];
  154. }
  155. ​
  156. $placeholders = implode(',', array_fill(0, count($productIds), '?'));
  157. $statement = Database::users()->prepare(
  158. "SELECT product_id, AVG(rating) AS avg_rating FROM product_reviews
  159. WHERE status = 'visible' AND rating IS NOT NULL AND product_id IN ({$placeholders})
  160. GROUP BY product_id"
  161. );
  162. $statement->execute($productIds);
  163. ​
  164. $ratings = [];
  165. foreach ($statement->fetchAll() as $row) {
  166. $ratings[(int) $row['product_id']] = (float) $row['avg_rating'];
  167. }
  168. ​
  169. return $ratings;
  170. }
  171. }
  172. ​