ProductGraphQLProvider.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. <?php
  2. namespace Webkul\BagistoApi\State;
  3. use ApiPlatform\Laravel\Eloquent\Paginator;
  4. use ApiPlatform\Metadata\Operation;
  5. use ApiPlatform\State\Pagination\Pagination;
  6. use ApiPlatform\State\ProviderInterface;
  7. use Illuminate\Pagination\LengthAwarePaginator as LaravelPaginator;
  8. use Webkul\BagistoApi\Models\Product;
  9. class ProductGraphQLProvider implements ProviderInterface
  10. {
  11. public function __construct(
  12. private readonly Pagination $pagination
  13. ) {}
  14. private ?array $attributeTypeCache = null;
  15. private ?array $attributeScopeCache = null;
  16. public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
  17. {
  18. // Cache attribute types and scope flags once for the entire request
  19. $this->attributeTypeCache = \DB::table('attributes')
  20. ->pluck('type', 'code')
  21. ->toArray();
  22. $this->attributeScopeCache = \DB::table('attributes')
  23. ->get(['code', 'value_per_locale', 'value_per_channel'])
  24. ->keyBy('code')
  25. ->toArray();
  26. $args = $context['args'] ?? [];
  27. $query = Product::query();
  28. $query->whereHas('attribute_values', function ($q) {
  29. $q->where('attribute_id', 8)
  30. ->where('boolean_value', 1);
  31. });
  32. $query->whereHas('attribute_values', function ($q) {
  33. $q->where('attribute_id', 7)
  34. ->where('boolean_value', 1);
  35. });
  36. if (! empty($args['query'])) {
  37. $searchTerm = $args['query'];
  38. $query->where(function ($q) use ($searchTerm) {
  39. $q->where('sku', 'like', "%{$searchTerm}%")
  40. ->orWhereHas('attribute_values', function ($attr) use ($searchTerm) {
  41. $attr->where('attribute_id', 2)
  42. ->where('text_value', 'like', "%{$searchTerm}%");
  43. });
  44. });
  45. }
  46. $sortKey = strtoupper($args['sortKey'] ?? 'ID');
  47. $reverse = (bool) ($args['reverse'] ?? false);
  48. $direction = $reverse ? 'desc' : 'asc';
  49. $locale = $args['locale'] ?? request()->attributes->get('bagisto_locale');
  50. $channel = $args['channel'] ?? request()->attributes->get('bagisto_channel');
  51. switch ($sortKey) {
  52. case 'TITLE':
  53. case 'NAME':
  54. $prefix = \DB::getTablePrefix();
  55. // Join for requested locale/channel
  56. $query->leftJoin('product_attribute_values as pav_name_locale', function ($join) use ($locale, $channel) {
  57. $join->on('products.id', '=', 'pav_name_locale.product_id')
  58. ->where('pav_name_locale.attribute_id', 2);
  59. if ($locale) {
  60. $join->where('pav_name_locale.locale', $locale);
  61. }
  62. if ($channel) {
  63. $join->where('pav_name_locale.channel', $channel);
  64. }
  65. });
  66. // Fallback join for null locale/channel (default values)
  67. $query->leftJoin('product_attribute_values as pav_name_fallback', function ($join) {
  68. $join->on('products.id', '=', 'pav_name_fallback.product_id')
  69. ->where('pav_name_fallback.attribute_id', 2)
  70. ->whereNull('pav_name_fallback.locale')
  71. ->whereNull('pav_name_fallback.channel');
  72. });
  73. $query->orderBy(\DB::raw("COALESCE({$prefix}pav_name_locale.text_value, {$prefix}pav_name_fallback.text_value)"), $direction)
  74. ->orderBy('products.id', $direction)
  75. ->select('products.*');
  76. break;
  77. case 'CREATED_AT':
  78. $query->orderBy('products.created_at', $direction)
  79. ->orderBy('products.id', $direction);
  80. break;
  81. case 'UPDATED_AT':
  82. $query->orderBy('products.updated_at', $direction)
  83. ->orderBy('products.id', $direction);
  84. break;
  85. case 'PRICE':
  86. $user = auth()->user();
  87. $customerGroup = $user?->getDefaultGroup()
  88. ?? app(\Webkul\Customer\Repositories\CustomerGroupRepository::class)
  89. ->findOneByField('code', 'guest');
  90. $query->leftJoin('product_price_indices', function ($join) use ($customerGroup) {
  91. $join->on('products.id', '=', 'product_price_indices.product_id')
  92. ->where('product_price_indices.customer_group_id', $customerGroup->id);
  93. })
  94. ->orderBy('product_price_indices.min_price', $direction)
  95. ->orderBy('products.id', $direction)
  96. ->select('products.*');
  97. break;
  98. case 'ID':
  99. default:
  100. $query->orderBy('products.id', $direction);
  101. }
  102. $filters = [];
  103. if (! empty($args['filter'])) {
  104. if (is_string($args['filter'])) {
  105. $decoded = json_decode($args['filter'], true);
  106. if (json_last_error() === JSON_ERROR_NONE) {
  107. $filters = $decoded;
  108. }
  109. } elseif (is_array($args['filter'])) {
  110. $filters = $args['filter'];
  111. }
  112. }
  113. if (! empty($filters['type'])) {
  114. $query->where('type', $filters['type']);
  115. unset($filters['type']);
  116. }
  117. if (! empty($filters['sku'])) {
  118. $query->where('sku', $filters['sku']);
  119. unset($filters['sku']);
  120. }
  121. if (isset($filters['new'])) {
  122. $query->leftJoin('product_flat', 'products.id', '=', 'product_flat.product_id')
  123. ->where('product_flat.new', filter_var($filters['new'], FILTER_VALIDATE_BOOLEAN))
  124. ->select('products.*')
  125. ->distinct('products.id');
  126. unset($filters['new']);
  127. }
  128. if (isset($filters['featured'])) {
  129. $query->leftJoin('product_flat', 'products.id', '=', 'product_flat.product_id')
  130. ->where('product_flat.featured', filter_var($filters['featured'], FILTER_VALIDATE_BOOLEAN))
  131. ->select('products.*')
  132. ->distinct('products.id');
  133. unset($filters['featured']);
  134. }
  135. if (! empty($filters['category_id'])) {
  136. $categoryId = (int) $filters['category_id'];
  137. $query->whereHas('categories', function ($q) use ($categoryId) {
  138. $q->where('id', $categoryId);
  139. });
  140. unset($filters['category_id']);
  141. }
  142. if (! empty($filters['price_from']) || ! empty($filters['price_to'])) {
  143. $from = isset($filters['price_from']) ? (float) $filters['price_from'] : null;
  144. $to = isset($filters['price_to']) ? (float) $filters['price_to'] : null;
  145. $query->whereHas('attribute_values', function ($q) use ($from, $to) {
  146. $q->where('attribute_id', 11);
  147. if (! is_null($from) && ! is_null($to)) {
  148. $q->whereBetween('float_value', [$from, $to]);
  149. } elseif (! is_null($from)) {
  150. $q->where('float_value', '>=', $from);
  151. } elseif (! is_null($to)) {
  152. $q->where('float_value', '<=', $to);
  153. }
  154. });
  155. unset($filters['price_from'], $filters['price_to']);
  156. }
  157. // Setup for variant filtering with COALESCE
  158. $prefix = \DB::getTablePrefix();
  159. $attributeFilters = [];
  160. // Collect attribute filters that need to be applied
  161. foreach ($filters as $attrCode => $spec) {
  162. if (in_array($attrCode, ['pageSize', 'first', 'last', 'after', 'before'], true)) {
  163. continue;
  164. }
  165. $attributeFilters[$attrCode] = [
  166. 'term' => $spec['match'] ?? $spec,
  167. 'matchType' => strtoupper($spec['match_type'] ?? ''),
  168. ];
  169. }
  170. // Apply attribute filters if any exist
  171. if (! empty($attributeFilters)) {
  172. // Join variants table once for attribute filtering
  173. $query->leftJoin('products as variants', \DB::raw('COALESCE('.$prefix.'variants.parent_id, '.$prefix.'variants.id)'), '=', 'products.id');
  174. // Join all attribute tables needed for both products and variants
  175. foreach ($attributeFilters as $attrCode => $filterData) {
  176. $productAlias = 'pav_'.$attrCode.'_product';
  177. $variantAlias = 'pav_'.$attrCode.'_variant';
  178. // Join product attribute values
  179. $query->leftJoin('product_attribute_values as '.$productAlias, function ($join) use ($productAlias, $attrCode) {
  180. $join->on('products.id', '=', $productAlias.'.product_id')
  181. ->whereIn($productAlias.'.attribute_id', function ($sub) use ($attrCode) {
  182. $sub->select('id')->from('attributes')->where('code', $attrCode);
  183. });
  184. });
  185. // Join variant attribute values
  186. $query->leftJoin('product_attribute_values as '.$variantAlias, function ($join) use ($variantAlias, $attrCode) {
  187. $join->on('variants.id', '=', $variantAlias.'.product_id')
  188. ->whereIn($variantAlias.'.attribute_id', function ($sub) use ($attrCode) {
  189. $sub->select('id')->from('attributes')->where('code', $attrCode);
  190. });
  191. });
  192. }
  193. // Apply filters: (all filters on products) OR (all filters on variants)
  194. $query->where(function ($filterQuery) use ($attributeFilters, $locale, $channel) {
  195. // Check all filters against product attributes
  196. $filterQuery->where(function ($productFilterQuery) use ($attributeFilters, $locale, $channel) {
  197. foreach ($attributeFilters as $attrCode => $filterData) {
  198. $productAlias = 'pav_'.$attrCode.'_product';
  199. $term = $filterData['term'];
  200. $matchType = $filterData['matchType'];
  201. $attributeType = $this->attributeTypeCache[$attrCode] ?? 'text';
  202. $productFilterQuery->where(function ($q) use ($term, $matchType, $locale, $channel, $productAlias, $attributeType, $attrCode) {
  203. $this->applyAttributeFilter($q, $term, $matchType, $locale, $channel, $productAlias, $attributeType, $attrCode);
  204. });
  205. }
  206. });
  207. // OR check all filters against variant attributes
  208. $filterQuery->orWhere(function ($variantFilterQuery) use ($attributeFilters, $locale, $channel) {
  209. foreach ($attributeFilters as $attrCode => $filterData) {
  210. $variantAlias = 'pav_'.$attrCode.'_variant';
  211. $term = $filterData['term'];
  212. $matchType = $filterData['matchType'];
  213. $attributeType = $this->attributeTypeCache[$attrCode] ?? 'text';
  214. $variantFilterQuery->where(function ($q) use ($term, $matchType, $locale, $channel, $variantAlias, $attributeType, $attrCode) {
  215. $this->applyAttributeFilter($q, $term, $matchType, $locale, $channel, $variantAlias, $attributeType, $attrCode);
  216. });
  217. }
  218. });
  219. });
  220. }
  221. $query->select('products.*')->distinct();
  222. $query->with([
  223. 'attribute_family',
  224. 'images',
  225. 'attribute_values',
  226. 'super_attributes',
  227. 'variants' => fn ($q) => $q->without(['variants', 'super_attributes', 'attribute_values', 'attribute_family']),
  228. ]);
  229. $first = isset($args['first']) ? (int) $args['first'] : null;
  230. $last = isset($args['last']) ? (int) $args['last'] : null;
  231. $after = $args['after'] ?? null;
  232. $before = $args['before'] ?? null;
  233. $limit = $first ?? $last ?? 30;
  234. $offset = 0;
  235. if ($after) {
  236. $decoded = base64_decode($after, true);
  237. $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
  238. }
  239. if ($before) {
  240. $decoded = base64_decode($before, true);
  241. $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
  242. $offset = max(0, $cursor - $limit);
  243. }
  244. $total = (clone $query)
  245. ->distinct('products.id')
  246. ->count('products.id');
  247. if ($offset > $total) {
  248. $offset = max(0, $total - $limit);
  249. }
  250. $items = $query
  251. ->distinct('products.id')
  252. ->offset($offset)
  253. ->limit($limit)
  254. ->get();
  255. /** Propagate locale/channel context to each product for attribute value resolution */
  256. if ($locale || $channel) {
  257. $items->each(function ($product) use ($locale, $channel) {
  258. if ($locale) {
  259. $product->locale = $locale;
  260. }
  261. if ($channel) {
  262. $product->channel = $channel;
  263. }
  264. });
  265. }
  266. $currentPage = $total > 0 ? (int) floor($offset / $limit) + 1 : 1;
  267. return new Paginator(
  268. new LaravelPaginator(
  269. $items,
  270. $total,
  271. $limit,
  272. $currentPage,
  273. ['path' => request()->url()]
  274. )
  275. );
  276. }
  277. /**
  278. * Apply attribute filter logic to a query based on attribute type
  279. */
  280. private function applyAttributeFilter($q, $term, $matchType, $locale, $channel, $alias, $attributeType = null, $attrCode = null)
  281. {
  282. // Fallback to text_value if attribute type not provided
  283. if (! $attributeType) {
  284. $attributeType = 'text';
  285. }
  286. // Only constrain locale/channel when the attribute is actually scoped —
  287. // non-scoped attributes store NULL in locale/channel columns, so adding
  288. // a WHERE locale = 'en' would exclude every match.
  289. $scope = $attrCode ? ($this->attributeScopeCache[$attrCode] ?? null) : null;
  290. if ($locale && ($scope->value_per_locale ?? false)) {
  291. $q->where($alias.'.locale', $locale);
  292. }
  293. if ($channel && ($scope->value_per_channel ?? false)) {
  294. $q->where($alias.'.channel', $channel);
  295. }
  296. if ($matchType === 'PARTIAL') {
  297. $this->applyPartialFilter($q, $term, $alias, $attributeType);
  298. } else {
  299. $this->applyExactFilter($q, $term, $alias, $attributeType);
  300. }
  301. }
  302. /**
  303. * Get the correct column name based on attribute type
  304. */
  305. private function getColumnForType($attributeType)
  306. {
  307. return match ($attributeType) {
  308. 'text', 'textarea' => 'text_value',
  309. 'select','multiselect','dropdown' => 'integer_value',
  310. 'decimal', 'price' => 'float_value',
  311. 'integer' => 'integer_value',
  312. 'boolean' => 'boolean_value',
  313. 'datetime' => 'datetime_value',
  314. 'date' => 'date_value',
  315. 'json' => 'json_value',
  316. default => 'text_value',
  317. };
  318. }
  319. /**
  320. * Apply partial (LIKE) filter based on attribute type
  321. */
  322. private function applyPartialFilter($q, $term, $alias, $attributeType)
  323. {
  324. $column = $this->getColumnForType($attributeType);
  325. $q->where($alias.'.'.$column, 'like', "%{$term}%");
  326. }
  327. /**
  328. * Apply exact match filter based on attribute type
  329. */
  330. private function applyExactFilter($q, $term, $alias, $attributeType)
  331. {
  332. $column = $this->getColumnForType($attributeType);
  333. if (is_string($term) && str_contains($term, ',')) {
  334. $values = array_filter(array_map('trim', explode(',', $term)));
  335. // Convert values based on attribute type
  336. $convertedValues = $this->convertValuesForType($values, $attributeType);
  337. $q->whereIn($alias.'.'.$column, $convertedValues);
  338. } else {
  339. $convertedValue = $this->convertValueForType($term, $attributeType);
  340. $q->where($alias.'.'.$column, $convertedValue);
  341. }
  342. }
  343. /**
  344. * Convert a single value based on attribute type
  345. */
  346. private function convertValueForType($value, $attributeType)
  347. {
  348. return match ($attributeType) {
  349. 'decimal', 'price' => (float) $value,
  350. 'integer' => (int) $value,
  351. 'boolean' => $value ? '1' : '0',
  352. default => $value,
  353. };
  354. }
  355. /**
  356. * Convert multiple values based on attribute type
  357. */
  358. private function convertValuesForType($values, $attributeType)
  359. {
  360. return match ($attributeType) {
  361. 'decimal', 'price' => array_map('floatval', $values),
  362. 'integer' => array_map('intval', $values),
  363. 'boolean' => array_map(fn ($v) => (int) filter_var($v, FILTER_VALIDATE_BOOLEAN), $values),
  364. default => $values,
  365. };
  366. }
  367. }