* @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) */ declare(strict_types=1); namespace PrestaShop\PrestaShop\Core\Grid\Query; use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder; use InvalidArgumentException; use PrestaShop\PrestaShop\Core\Exception\CoreException; use PrestaShop\PrestaShop\Core\Grid\Search\SearchCriteriaInterface; use PrestaShop\PrestaShop\Core\Search\Filters\ProductCombinationFilters; use PrestaShopException; use StockAvailable; final class ProductCombinationQueryBuilder extends AbstractDoctrineQueryBuilder { /** * @var DoctrineSearchCriteriaApplicatorInterface */ private $searchCriteriaApplicator; /** * @param Connection $connection * @param string $dbPrefix * @param DoctrineSearchCriteriaApplicatorInterface $searchCriteriaApplicator */ public function __construct( Connection $connection, string $dbPrefix, DoctrineSearchCriteriaApplicatorInterface $searchCriteriaApplicator ) { parent::__construct($connection, $dbPrefix); $this->searchCriteriaApplicator = $searchCriteriaApplicator; } /** * {@inheritdoc} */ public function getSearchQueryBuilder(SearchCriteriaInterface $searchCriteria) { if (!$searchCriteria instanceof ProductCombinationFilters) { throw new InvalidArgumentException( sprintf( 'Expected %s, but got %s', ProductCombinationFilters::class, get_class($searchCriteria) ) ); } $qb = $this->getCombinationsQueryBuilder($searchCriteria) ->addSelect(' pa.reference, pa.supplier_reference, pa.ean13, pa.isbn, pa.upc, pa.mpn, pas.wholesale_price, pas.price, pas.ecotax, pas.weight, pas.unit_price_impact, pas.default_on, pas.minimal_quantity, pas.low_stock_threshold, pas.low_stock_alert, pas.available_date, pas.id_product_attribute, pas.id_product, pas.id_shop, sa.quantity AS quantity '); $this->searchCriteriaApplicator ->applyPagination($searchCriteria, $qb) ->applySorting($searchCriteria, $qb); // Always sort by id as the second sorting condition (it also acts as default sorting when no orderBy is provided) $qb->addOrderBy('pa.id_product_attribute', 'asc'); return $qb; } /** * {@inheritdoc} */ public function getCountQueryBuilder(SearchCriteriaInterface $searchCriteria) { if (!$searchCriteria instanceof ProductCombinationFilters) { throw new InvalidArgumentException( sprintf( 'Expected %s, but got %s', ProductCombinationFilters::class, get_class($searchCriteria) ) ); } return $this->getCombinationsQueryBuilder($searchCriteria) ->select('COUNT(pa.id_product_attribute)') ; } /** * @param ProductCombinationFilters $productCombinationFilters * * @return QueryBuilder */ private function getCombinationsQueryBuilder(ProductCombinationFilters $productCombinationFilters): QueryBuilder { $filters = $productCombinationFilters->getFilters(); $productId = $productCombinationFilters->getProductId(); $shopId = $productCombinationFilters->getShopId(); $qb = $this->connection->createQueryBuilder(); $qb->from($this->dbPrefix . 'product_attribute', 'pa') ->innerJoin( 'pa', $this->dbPrefix . 'product_attribute_shop', 'pas', 'pa.id_product_attribute = pas.id_product_attribute' ) ->leftJoin( 'pa', $this->dbPrefix . 'stock_available', 'sa', 'pa.id_product_attribute = sa.id_product_attribute' ) ->where('pas.id_product = :productId') ->andWhere('pas.id_shop = :shopId') ->setParameter('productId', $productId) ->setParameter('shopId', $shopId) ; $this->addShopCondition($qb, 'sa', $shopId); // filter by attributes if (isset($filters['attributes'])) { $combinationIds = $this->getCombinationIdsByAttributeIds($productId, (array) $filters['attributes']); $qb->andWhere($qb->expr()->in('pa.id_product_attribute', ':combinationIds')) ->setParameter('combinationIds', $combinationIds, Connection::PARAM_INT_ARRAY) ; } if (isset($filters['reference'])) { $qb->andWhere('pa.reference LIKE :reference') ->setParameter('reference', '%' . $filters['reference'] . '%') ; } if (isset($filters['default_on'])) { if ((bool) $filters['default_on']) { $qb->andWhere('pas.default_on = 1'); } else { $qb->andWhere('pas.default_on IS NULL OR pa.default_on = 0'); } } return $qb; } /** * Adds "where" condition for shop or shopGroup depending if the shop is in a shop group with shared stock * (reusing legacy logic from StockAvailable::addSqlShopParams) * * @param QueryBuilder $qb * @param string $stockAlias * @param int $shopId * * @return QueryBuilder */ private function addShopCondition(QueryBuilder $qb, string $stockAlias, int $shopId): QueryBuilder { // Use legacy method, it checks if the shop belongs to a ShopGroup that shares stock, in which case the StockAvailable // must be assigned to the group not the shop $shopParams = []; try { StockAvailable::addSqlShopParams($shopParams, $shopId); } catch (PrestaShopException $e) { throw new CoreException('Error occurred when trying to add StockAvailable shop condition', 0, $e); } foreach ($shopParams as $key => $value) { if (!in_array($key, ['id_shop', 'id_shop_group'])) { continue; } $qb->andWhere(sprintf('%s.%s = :%s', $stockAlias, $key, $key)) ->setParameter($key, $value, ParameterType::INTEGER) ; } return $qb; } /** * @param int $productId * @param array $attributeGroups * * @return int[] */ private function getCombinationIdsByAttributeIds(int $productId, array $attributeGroups): array { $qb = $this->connection->createQueryBuilder(); $allAttributes = []; foreach ($attributeGroups as $attributeIds) { $allAttributes = array_merge($allAttributes, $attributeIds); } $qb->select('pac.id_product_attribute, pac.id_attribute') ->from($this->dbPrefix . 'product_attribute_combination', 'pac') ->leftJoin( 'pac', $this->dbPrefix . 'product_attribute', 'pa', 'pac.id_product_attribute = pa.id_product_attribute' ) ->where('pa.id_product = :productId') ->andWhere($qb->expr()->in('pac.id_attribute', ':attributes')) ->setParameter('attributes', $allAttributes, Connection::PARAM_INT_ARRAY) ->setParameter('productId', $productId) ; $results = $qb->execute()->fetchAll(); if (!$results) { return []; } $combinationAttributes = []; foreach ($results as $result) { $combinationAttributes[(int) $result['id_product_attribute']][] = (int) $result['id_attribute']; } foreach ($attributeGroups as $groupAttributes) { foreach ($combinationAttributes as $combinationId => $attributeIds) { if (empty(array_intersect($groupAttributes, $attributeIds))) { unset($combinationAttributes[$combinationId]); } } } return empty($combinationAttributes) ? [] : array_keys($combinationAttributes); } }