* @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\Adapter\Product\Combination\Repository; use Combination; use Db; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; use PrestaShop\PrestaShop\Adapter\Attribute\Repository\AttributeRepository; use PrestaShop\PrestaShop\Adapter\Product\Combination\Validate\CombinationValidator; use PrestaShop\PrestaShop\Adapter\Product\Repository\ProductRepository; use PrestaShop\PrestaShop\Core\Domain\Language\ValueObject\LanguageId; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\CombinationAttributeInformation; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CannotAddCombinationException; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CannotBulkDeleteCombinationException; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CannotDeleteCombinationException; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CannotUpdateCombinationException; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CombinationException; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CombinationNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CombinationShopAssociationNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\ValueObject\CombinationId; use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Product\Stock\ValueObject\OutOfStockType; use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductId; use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\InvalidShopConstraintException; use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\ShopException; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopGroupId; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopId; use PrestaShop\PrestaShop\Core\Exception\CoreException; use PrestaShop\PrestaShop\Core\Repository\AbstractMultiShopObjectModelRepository; use PrestaShop\PrestaShop\Core\Repository\ShopConstraintTrait; use PrestaShopException; class CombinationRepository extends AbstractMultiShopObjectModelRepository { use ShopConstraintTrait; /** * @var Connection */ private $connection; /** * @var string */ private $dbPrefix; /** * @var CombinationValidator */ private $combinationValidator; /** * @var AttributeRepository */ private $attributeRepository; /** * @var ProductRepository */ private $productRepository; /** * @param Connection $connection * @param string $dbPrefix * @param CombinationValidator $combinationValidator * @param AttributeRepository $attributeRepository * @param ProductRepository $productRepository */ public function __construct( Connection $connection, string $dbPrefix, CombinationValidator $combinationValidator, AttributeRepository $attributeRepository, ProductRepository $productRepository ) { $this->connection = $connection; $this->dbPrefix = $dbPrefix; $this->combinationValidator = $combinationValidator; $this->attributeRepository = $attributeRepository; $this->productRepository = $productRepository; } /** * @param CombinationId $combinationId * @param ShopId $shopId * * @return Combination * * @throws CoreException */ public function get(CombinationId $combinationId, ShopId $shopId): Combination { /** @var Combination $combination */ $combination = $this->getObjectModelForShop( $combinationId->getValue(), Combination::class, CombinationNotFoundException::class, $shopId, CombinationShopAssociationNotFoundException::class ); return $combination; } /** * @param ProductId $productId * @param ShopId[] $shopIds * * @return Combination * * @throws CannotAddCombinationException */ public function create(ProductId $productId, array $shopIds): Combination { $combination = new Combination(); $combination->id_product = $productId->getValue(); $combination->default_on = false; $combination->id_shop_list = array_map(function (ShopId $shopId): int { return $shopId->getValue(); }, $shopIds); $this->addObjectModelToShops($combination, $shopIds, CannotAddCombinationException::class); return $combination; } /** * @param ProductId $productId * @param int[] $attributeIds * * @return CombinationId */ public function findCombinationIdByAttributes(ProductId $productId, array $attributeIds): ?CombinationId { sort($attributeIds); $qb = $this->connection->createQueryBuilder(); $qb ->addSelect('pa.id_product_attribute') ->addSelect('GROUP_CONCAT(pac.id_attribute ORDER BY pac.id_attribute ASC SEPARATOR "-") AS attribute_ids') ->from($this->dbPrefix . 'product_attribute', 'pa') ->innerJoin( 'pa', $this->dbPrefix . 'product_attribute_combination', 'pac', 'pac.id_product_attribute = pa.id_product_attribute' ) ->andWhere('pa.id_product = :productId') ->andHaving('attribute_ids = :attributeIds') ->setParameter('productId', $productId->getValue()) ->setParameter('attributeIds', implode('-', $attributeIds)) ->addGroupBy('pa.id_product_attribute') ; $result = $qb->execute()->fetchAssociative(); if (empty($result)) { return null; } return new CombinationId((int) $result['id_product_attribute']); } /** * @param CombinationId $combinationId * @param int[] $attributeIds */ public function saveProductAttributeAssociation(CombinationId $combinationId, array $attributeIds): void { $this->assertCombinationExists($combinationId); $this->attributeRepository->assertAllAttributesExist($attributeIds); $attributesList = []; foreach ($attributeIds as $attributeId) { $attributesList[] = [ 'id_product_attribute' => $combinationId->getValue(), 'id_attribute' => $attributeId, ]; } try { if (!Db::getInstance()->insert('product_attribute_combination', $attributesList)) { throw new CannotAddCombinationException('Failed saving product-combination associations'); } } catch (PrestaShopException $e) { throw new CoreException('Error occurred when saving product-combination associations', 0, $e); } } /** * @param CombinationId $combinationId * * @return ProductId */ public function getProductId(CombinationId $combinationId): ProductId { $qb = $this->connection->createQueryBuilder(); $qb ->select('pa.id_product') ->from($this->dbPrefix . 'product_attribute', 'pa') ->andWhere('pa.id_product_attribute = :combinationId') ->setParameter('combinationId', $combinationId->getValue()) ; $result = $qb->execute()->fetchAssociative(); if (empty($result) || empty($result['id_product'])) { throw new CombinationNotFoundException(sprintf('Combination #%d was not found', $combinationId->getValue())); } return new ProductId((int) $result['id_product']); } /** * Creates a new combination in product_attribute_shop assuming it already exists in product_attribute table * * @param CombinationId $combinationId * @param ShopId $shopId */ public function addToShop(CombinationId $combinationId, ShopId $shopId): void { $productId = $this->getProductId($combinationId); $combination = new Combination(); $combination->id = $combinationId->getValue(); $combination->force_id = true; $combination->id_product = $productId->getValue(); $combination->default_on = false; $this->updateObjectModelForShops($combination, [$shopId], CannotUpdateCombinationException::class); } /** * Copy combination data from one shop to another. * * @param CombinationId $combinationId * @param ShopId $sourceId * @param ShopId $targetId */ public function copyToShop(CombinationId $combinationId, ShopId $sourceId, ShopId $targetId): void { $combination = $this->get($combinationId, $sourceId); $this->updateObjectModelForShops($combination, [$targetId], CannotUpdateCombinationException::class); } /** * @param CombinationId $combinationId * @param ShopConstraint $shopConstraint * * @return Combination * * @throws InvalidShopConstraintException */ public function getByShopConstraint(CombinationId $combinationId, ShopConstraint $shopConstraint): Combination { if ($shopConstraint->getShopGroupId()) { throw new InvalidShopConstraintException('Combination has no features related with shop group use single shop and all shops constraints'); } if ($shopConstraint->forAllShops()) { try { return $this->get($combinationId, $this->getDefaultShopIdForCombination($combinationId)); // We try to fetch combination for default shop first, // but in case it is not associated to default shop, // then we load first found associated combination } catch (CombinationShopAssociationNotFoundException $e) { $associatedShopIds = $this->getAssociatedShopIds($combinationId); if (empty($associatedShopIds)) { throw $e; } return $this->get($combinationId, reset($associatedShopIds)); } } else { return $this->get($combinationId, $shopConstraint->getShopId()); } } /** * @param Combination $combination * @param array $updatableProperties * @param ShopConstraint $shopConstraint * @param int $errorCode */ public function partialUpdate(Combination $combination, array $updatableProperties, ShopConstraint $shopConstraint, int $errorCode): void { if ($shopConstraint->getShopGroupId()) { throw new InvalidShopConstraintException('Product combination has no features related with shop group use single shop and all shops constraints'); } $this->combinationValidator->validate($combination); $combinationId = new CombinationId((int) $combination->id); $this->partiallyUpdateObjectModelForShops( $combination, $updatableProperties, $this->getShopIdsByConstraint($combinationId, $shopConstraint), CannotAddCombinationException::class, $errorCode ); } /** * @param CombinationId $combinationId * * @return ShopId * * @throws ProductNotFoundException */ public function getDefaultShopIdForCombination(CombinationId $combinationId): ShopId { $qb = $this->connection->createQueryBuilder(); $qb ->select('p.id_shop_default') ->from($this->dbPrefix . 'product', 'p') ->leftJoin( 'p', $this->dbPrefix . 'product_attribute', 'pa', 'pa.id_product = p.id_product' ) ->where('pa.id_product_attribute = :combinationId') ->setParameter('combinationId', $combinationId->getValue()) ; $result = $qb->execute()->fetch(); if (empty($result['id_shop_default'])) { throw new ProductNotFoundException(sprintf( 'Could not find Product by combination id %d', $combinationId->getValue() )); } return new ShopId((int) $result['id_shop_default']); } /** * @param CombinationId $combinationId * @param int $errorCode * * @throws CoreException */ public function delete(CombinationId $combinationId, ShopConstraint $shopConstraint, int $errorCode = 0): void { $removedShops = $this->getShopIdsByConstraint($combinationId, $shopConstraint); if (empty($removedShops)) { return; } $this->deleteObjectModelFromShops( // We get the combination any of the removed ones, it doesn't change much so the first is fine $this->get($combinationId, reset($removedShops)), $removedShops, CannotDeleteCombinationException::class, $errorCode ); } /** * @param CombinationId[] $combinationIds * @param ShopConstraint $shopConstraint * * @throws CannotBulkDeleteCombinationException */ public function bulkDelete(array $combinationIds, ShopConstraint $shopConstraint): void { $bulkDeleteException = new CannotBulkDeleteCombinationException(); foreach ($combinationIds as $combinationId) { try { $this->delete($combinationId, $shopConstraint); } catch (CannotDeleteCombinationException $e) { $bulkDeleteException->addException($combinationId, $e); } } if ($bulkDeleteException->isEmpty()) { return; } throw $bulkDeleteException; } /** * @param ProductId $productId * @param ShopConstraint $shopConstraint */ public function deleteByProductId(ProductId $productId, ShopConstraint $shopConstraint): void { $combinationIds = $this->getCombinationIds($productId, $shopConstraint); $this->bulkDelete($combinationIds, $shopConstraint); } /** * @param ProductId $productId * @param ShopConstraint $shopConstraint * * @return CombinationId[] */ public function getCombinationIds(ProductId $productId, ShopConstraint $shopConstraint): array { $shopIds = $this->productRepository->getShopIdsByConstraint($productId, $shopConstraint); $shopIds = array_map(function (ShopId $shopId) { return $shopId->getValue(); }, $shopIds); $qb = $this->connection->createQueryBuilder(); $qb ->select('pas.id_product_attribute') ->from($this->dbPrefix . 'product_attribute_shop', 'pas') ->andWhere('pas.id_product = :productId') ->andWhere($qb->expr()->in('pas.id_shop', ':shopIds')) ->setParameter('shopIds', $shopIds, Connection::PARAM_INT_ARRAY) ->setParameter('productId', $productId->getValue()) ->addOrderBy('pas.id_product_attribute', 'ASC') ->addGroupBy('pas.id_product_attribute') ; $combinationIds = $qb->execute()->fetchAllAssociative(); return array_map( function (array $combination) { return new CombinationId((int) $combination['id_product_attribute']); }, $combinationIds ); } /** * @param ProductId $productId * @param ShopConstraint $shopConstraint * * @return CombinationId|null */ public function findFirstCombinationId(ProductId $productId, ShopConstraint $shopConstraint): ?CombinationId { if ($shopConstraint->getShopGroupId()) { throw new InvalidShopConstraintException('Combination has no features related with shop group use single shop and all shops constraints'); } if ($shopConstraint->getShopId()) { $shopId = $shopConstraint->getShopId(); } else { $shopId = $this->productRepository->getProductDefaultShopId($productId); } $qb = $this->connection->createQueryBuilder() ->select('pas.id_product_attribute') ->from($this->dbPrefix . 'product_attribute_shop', 'pas') ->where('pas.id_shop = :shopId') ->andWhere('pas.id_product = :productId') ->orderBy('id_product_attribute', 'ASC') ->setParameter('shopId', $shopId->getValue()) ->setParameter('productId', $productId->getValue()) ; $result = $qb->execute()->fetchAssociative(); if (!$result) { return null; } return new CombinationId((int) $result['id_product_attribute']); } /** * Check if combination is associated with certain shop * * @param CombinationId $combinationId * @param ShopId $shopId * * @return bool */ public function isAssociatedWithShop(CombinationId $combinationId, ShopId $shopId): bool { $qb = $this->connection->createQueryBuilder() ->select('pas.id_product_attribute') ->from($this->dbPrefix . 'product_attribute_shop', 'pas') ->where('pas.id_product_attribute = :combinationId') ->andWhere('pas.id_shop = :shopId') ->setParameter('combinationId', $combinationId->getValue()) ->setParameter('shopId', $shopId->getValue()) ; $result = $qb->execute()->fetchAssociative(); return isset($result['id_product_attribute']); } /** * Returns default combination ID identified as such in DB by default_on property * * @param ProductId $productId * @param ShopId $shopId * * @return CombinationId|null */ public function findDefaultCombinationIdForShop(ProductId $productId, ShopId $shopId): ?CombinationId { $qb = $this->connection->createQueryBuilder(); $qb ->select('pas.id_product_attribute') ->from($this->dbPrefix . 'product_attribute_shop', 'pas') ->where('pas.id_product = :productId') ->andWhere('pas.id_shop = :shopId') ->andWhere('pas.default_on = 1') ->addOrderBy('pas.id_product_attribute', 'ASC') ->setParameter('productId', $productId->getValue()) ->setParameter('shopId', $shopId->getValue()) ; $result = $qb->execute()->fetchAssociative(); if (empty($result['id_product_attribute'])) { return null; } return new CombinationId((int) $result['id_product_attribute']); } /** * @param CombinationId $combinationId * * @return ShopId[] * * @throws Exception * @throws ShopException */ public function getAssociatedShopIds(CombinationId $combinationId): array { $qb = $this->connection->createQueryBuilder() ->select('id_shop') ->from($this->dbPrefix . 'product_attribute_shop') ->where('id_product_attribute = :combinationId') ->setParameter('combinationId', $combinationId->getValue()) ->addGroupBy('id_shop') ; return array_map( static function (array $result): ShopId { return new ShopId((int) $result['id_shop']); }, $qb->execute()->fetchAll() ); } /** * @param CombinationId $combinationId * @param ShopGroupId $shopGroupId * * @return ShopId[] */ public function getAssociatedShopIdsFromGroup(CombinationId $combinationId, ShopGroupId $shopGroupId): array { $qb = $this->connection->createQueryBuilder(); $qb ->select('pas.id_shop') ->from($this->dbPrefix . 'product_attribute_shop', 'pas') ->innerJoin( 'pas', $this->dbPrefix . 'shop', 's', 's.id_shop = pas.id_shop AND s.id_shop_group = :shopGroupId' ) ->andWhere('pas.id_product_attribute = :combinationId') ->setParameter('shopGroupId', $shopGroupId->getValue()) ->setParameter('combinationId', $combinationId->getValue()) ->addGroupBy('id_shop') ; return array_map(static function (array $shop) { return new ShopId((int) $shop['id_shop']); }, $qb->execute()->fetchAllAssociative()); } /** * @param CombinationId $combinationId * * @throws CoreException */ public function assertCombinationExists(CombinationId $combinationId): void { $this->assertObjectModelExists( $combinationId->getValue(), 'product_attribute', CombinationNotFoundException::class ); } /** * @param ProductId $productId * @param CombinationId $newDefaultCombinationId * @param ShopConstraint $shopConstraint * * @throws ProductNotFoundException */ public function setDefaultCombination( ProductId $productId, CombinationId $newDefaultCombinationId, ShopConstraint $shopConstraint ): void { $defaultShopId = $this->getDefaultShopIdForCombination($newDefaultCombinationId); $shopIds = $this->getShopIdsByConstraint($newDefaultCombinationId, $shopConstraint); foreach ($shopIds as $shopId) { // we need to update the common table only for default shop, but only when default shop is impacted by the constraint if ($defaultShopId->getValue() === $shopId->getValue()) { $this->setDefaultCombinationInCommonTable($productId, $newDefaultCombinationId); break; } } $this->setDefaultCombinationInShopTable($productId, $newDefaultCombinationId, $shopIds); } public function updateCombinationOutOfStockType( ProductId $productId, OutOfStockType $outOfStockType, ShopConstraint $shopConstraint ): void { $qb = $this->connection->createQueryBuilder(); $qb ->update(sprintf('%sstock_available', $this->dbPrefix), 'ps') ->set('ps.out_of_stock', (string) $outOfStockType->getValue()) ->where('ps.id_product = :productId') ->setParameter('productId', $productId->getValue()) ; $this->applyShopConstraint($qb, $shopConstraint)->execute(); } /** * @param ProductId $productId * @param LanguageId $languageId * @param ShopConstraint $shopConstraint * @param string $searchPhrase * * @return array * * @throws CombinationException */ public function searchProductCombinations( ProductId $productId, LanguageId $languageId, ShopConstraint $shopConstraint, string $searchPhrase, ?int $limit = null ): array { $combinationIds = $this->searchCombinationIdsByAttributes( $productId, $languageId, $shopConstraint, $searchPhrase, $limit ); return $this->attributeRepository->getAttributesInfoByCombinationIds($combinationIds, $languageId); } /** * Sets default_on property to a provided combination in product_attribute table * * @param ProductId $productId * @param CombinationId $newDefaultCombinationId */ private function setDefaultCombinationInCommonTable(ProductId $productId, CombinationId $newDefaultCombinationId): void { $commonCombinationTable = sprintf('%sproduct_attribute', $this->dbPrefix); // find current default combination and make it non-default // important to check NULL, because it is impossible to have "0" as falsy value due to sql constraint $this->connection->executeStatement(sprintf( 'UPDATE %s SET default_on = NULL WHERE default_on = 1 AND id_product = %d', $commonCombinationTable, $productId->getValue() )); // set new default combination $this->connection->executeStatement(sprintf( 'UPDATE %s SET default_on = 1 WHERE id_product_attribute = %d', $commonCombinationTable, $newDefaultCombinationId->getValue() )); } /** * Sets default_on property to a provided combination in product_attribute_shop table * * @param ProductId $productId * @param CombinationId $newDefaultCombinationId * @param ShopId[] $shopIds */ private function setDefaultCombinationInShopTable( ProductId $productId, CombinationId $newDefaultCombinationId, array $shopIds ): void { if (empty($shopIds)) { return; } $shopCombinationTable = sprintf('%sproduct_attribute_shop', $this->dbPrefix); $shopIdsString = implode( ',', array_map(function (ShopId $shopId): int { return $shopId->getValue(); }, $shopIds) ); // find current default combination and make it non-default // important to check NULL, because it is impossible to have "0" as falsy value due to sql constraint $this->connection->executeStatement(sprintf( 'UPDATE %s SET default_on = NULL WHERE default_on = 1 AND id_product = %d AND id_shop IN (%s)', $shopCombinationTable, $productId->getValue(), $shopIdsString )); // set new default combination $this->connection->executeStatement(sprintf( 'UPDATE %s SET default_on = 1 WHERE id_product_attribute = %d AND id_shop IN (%s)', $shopCombinationTable, $newDefaultCombinationId->getValue(), $shopIdsString )); } /** * @param CombinationId $combinationId * @param ShopConstraint $shopConstraint * * @return ShopId[] */ private function getShopIdsByConstraint(CombinationId $combinationId, ShopConstraint $shopConstraint): array { if ($shopConstraint->getShopGroupId()) { return $this->getAssociatedShopIdsFromGroup($combinationId, $shopConstraint->getShopGroupId()); } if ($shopConstraint->forAllShops()) { return $this->getAssociatedShopIds($combinationId); } return [$shopConstraint->getShopId()]; } /** * @param ProductId $productId * @param LanguageId $languageId * @param ShopConstraint $shopConstraint * @param string $searchPhrase * * @return CombinationId[] */ private function searchCombinationIdsByAttributes( ProductId $productId, LanguageId $languageId, ShopConstraint $shopConstraint, string $searchPhrase, ?int $limit ): array { if ($shopConstraint->getShopGroupId()) { throw new InvalidShopConstraintException('Group shop constraint is not supported'); } $attributeIds = $this->searchAttributes($languageId, $shopConstraint, $searchPhrase); if (empty($attributeIds)) { return []; } $qb = $this->connection->createQueryBuilder() ->select('pac.id_product_attribute, pac.id_attribute') ->from($this->dbPrefix . 'product_attribute_combination', 'pac') ; if ($shopConstraint->forAllShops()) { $qb->innerJoin( 'pac', $this->dbPrefix . 'product_attribute', 'pa', 'pac.id_product_attribute = pa.id_product_attribute' ); } else { $qb->innerJoin( 'pac', $this->dbPrefix . 'product_attribute_shop', 'pa', 'pac.id_product_attribute = pa.id_product_attribute AND pa.id_shop = :shopId' )->setParameter('shopId', $shopConstraint->getShopId()->getValue()); } $qb ->where('pa.id_product = :productId') ->andWhere($qb->expr()->in('pac.id_attribute', ':attributes')) ->setParameter('attributes', $attributeIds, Connection::PARAM_INT_ARRAY) ->setParameter('productId', $productId->getValue()) ->groupBy('pac.id_product_attribute') ; if ($limit) { $qb->setMaxResults($limit); } $results = $qb->execute()->fetchAll(); if (!$results) { return []; } return array_map(static function (array $result): CombinationId { return new CombinationId((int) $result['id_product_attribute']); }, $results); } /** * @param LanguageId $languageId * @param ShopConstraint $shopConstraint * @param string $searchPhrase * * @return int[] */ private function searchAttributes(LanguageId $languageId, ShopConstraint $shopConstraint, string $searchPhrase): array { if ($shopConstraint->getShopGroupId()) { throw new InvalidShopConstraintException('Shop group constraint is not supported'); } $qb = $this->connection->createQueryBuilder(); $qb->select('a.id_attribute') ->from($this->dbPrefix . 'attribute', 'a') ->innerJoin( 'a', $this->dbPrefix . 'attribute_lang', 'al', 'a.id_attribute = al.id_attribute AND al.id_lang = :languageId' ) ->innerJoin( 'a', $this->dbPrefix . 'attribute_group_lang', 'agl', 'a.id_attribute_group = agl.id_attribute_group and agl.id_lang = :languageId' ) ->where('al.name LIKE :searchPhrase') ->orWhere('agl.name LIKE :searchPhrase') ->orWhere('agl.public_name LIKE :searchPhrase') ->setParameter('searchPhrase', '%' . $searchPhrase . '%') ->setParameter('languageId', $languageId->getValue()) ; if ($shopConstraint->getShopId()) { // this makes sure we are searching only in certain shop, so it doesn't return irrelevant attribute ids $qb->innerJoin( 'a', $this->dbPrefix . 'attribute_shop', 'attrShop', 'a.id_attribute = attrShop.id_attribute AND attrShop.id_shop = :shopId' ) ->innerJoin( 'agl', $this->dbPrefix . 'attribute_group_shop', 'ags', 'agl.id_attribute_group = ags.id_attribute_group AND ags.id_shop = :shopId' ) ->setParameter('shopId', $shopConstraint->getShopId()->getValue()) ; } $results = $qb->execute()->fetchAllAssociative(); return array_map('intval', array_column($results, 'id_attribute')); } }