* @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\Image\Repository; use Doctrine\DBAL\Connection; use Doctrine\DBAL\FetchMode; use Image; use ImageType; use PrestaShop\PrestaShop\Adapter\Product\Image\Validate\ProductImageValidator; use PrestaShop\PrestaShop\Adapter\Product\Repository\ProductRepository; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\ValueObject\CombinationId; use PrestaShop\PrestaShop\Core\Domain\Product\Image\Exception\CannotAddProductImageException; use PrestaShop\PrestaShop\Core\Domain\Product\Image\Exception\CannotDeleteProductImageException; use PrestaShop\PrestaShop\Core\Domain\Product\Image\Exception\CannotUpdateProductImageException; use PrestaShop\PrestaShop\Core\Domain\Product\Image\Exception\ProductImageNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Product\Image\QueryResult\Shop\ShopImageAssociation; use PrestaShop\PrestaShop\Core\Domain\Product\Image\QueryResult\Shop\ShopImageAssociationCollection; use PrestaShop\PrestaShop\Core\Domain\Product\Image\QueryResult\Shop\ShopProductImages; use PrestaShop\PrestaShop\Core\Domain\Product\Image\QueryResult\Shop\ShopProductImagesCollection; use PrestaShop\PrestaShop\Core\Domain\Product\Image\ValueObject\ImageId; use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductId; use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\InvalidShopConstraintException; use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\ShopAssociationNotFound; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopId; use PrestaShop\PrestaShop\Core\Exception\CoreException; use PrestaShop\PrestaShop\Core\Repository\AbstractMultiShopObjectModelRepository; use PrestaShopException; /** * Provides access to product Image data source with shop context */ class ProductImageRepository extends AbstractMultiShopObjectModelRepository { /** * @var Connection */ private $connection; /** * @var string */ private $dbPrefix; /** * @var ProductRepository */ private $productRepository; /** * @var ProductImageValidator */ private $productImageValidator; public function __construct( Connection $connection, string $dbPrefix, ProductRepository $productRepository, ProductImageValidator $productImageValidator ) { $this->connection = $connection; $this->dbPrefix = $dbPrefix; $this->productRepository = $productRepository; $this->productImageValidator = $productImageValidator; } /** * @param ProductId $productId * * @return Image[] */ public function getImages(ProductId $productId, ShopConstraint $shopConstraint): array { if ($shopConstraint->getShopGroupId()) { throw new InvalidShopConstraintException('Shop group constraint is not supported'); } if ($shopConstraint->getShopId()) { $this->productRepository->assertProductIsAssociatedToShop($productId, $shopConstraint->getShopId()); } return array_map( function (ImageId $imageId) use ($shopConstraint): Image { return $this->getByShopConstraint($imageId, $shopConstraint); }, $this->getImageIds($productId, $shopConstraint) ); } /** * @param ProductId $productId * @param ShopConstraint $shopConstraint * * @return ImageId[] */ public function getImageIds(ProductId $productId, ShopConstraint $shopConstraint): array { $qb = $this->connection->createQueryBuilder() ->select('i.id_image') ->from($this->dbPrefix . 'image', 'i') ->andWhere('i.id_product = :productId') ->setParameter('productId', $productId->getValue()) ->addOrderBy('i.position', 'ASC') ->addOrderBy('i.id_image', 'ASC') ; if (!$shopConstraint->forAllShops()) { $qb ->innerJoin( 'i', $this->dbPrefix . 'image_shop', 'img_shop', 'img_shop.id_image = i.id_image' ) ->addGroupBy('i.id_image') ; if ($shopConstraint->getShopGroupId()) { $qb ->innerJoin( 'img_shop', $this->dbPrefix . 'shop', 's', 's.id_shop = img_shop.id_shop AND s.id_shop_group = :shopGroupId' ) ->setParameter('shopGroupId', $shopConstraint->getShopGroupId()->getValue()) ; } else { $this->productRepository->assertProductIsAssociatedToShop($productId, $shopConstraint->getShopId()); $qb->andWhere('img_shop.id_shop = :shopId') ->setParameter('shopId', $shopConstraint->getShopId()->getValue()) ; } } return array_map(static function (string $id): ImageId { return new ImageId((int) $id); }, $qb->execute()->fetchAll(FetchMode::COLUMN)); } /** * @param ProductId $productId * * @return ImageId|null */ public function getDefaultImageId(ProductId $productId, ShopId $shopId): ?ImageId { $coverId = $this->findCoverId($productId, $shopId); if ($coverId) { return $coverId; } $imagesIds = $this->getImageIds($productId, ShopConstraint::shop($shopId->getValue())); return !empty($imagesIds) ? reset($imagesIds) : null; } /** * @param ProductId $productId * @param ShopId $shopId * * @return ImageId|null */ public function findCoverId(ProductId $productId, ShopId $shopId): ?ImageId { $qb = $this->connection->createQueryBuilder() ->addSelect('i.id_image') ->from($this->dbPrefix . 'image_shop', 'i') ->andWhere('i.id_product = :productId') ->andWhere('i.cover = 1') ->andWhere('i.id_shop = :shopId') ->setParameter('productId', $productId->getValue()) ->setParameter('shopId', $shopId->getValue()) ; $result = $qb->execute()->fetchAssociative(); if (empty($result['id_image'])) { return null; } return new ImageId((int) $result['id_image']); } /** * Retrieves a list of image ids ordered by position for each provided combination id * * @param CombinationId[] $combinationIds * * @return array [(int) id_combination => [ImageId]] */ public function getImageIdsForCombinations(array $combinationIds): array { if (empty($combinationIds)) { return []; } $combinationIds = array_map(function (CombinationId $id): int { return $id->getValue(); }, $combinationIds); $qb = $this->connection->createQueryBuilder(); $qb->select('pai.id_product_attribute, pai.id_image') ->from($this->dbPrefix . 'product_attribute_image', 'pai') ->leftJoin( 'pai', $this->dbPrefix . 'image', 'i', 'i.id_image = pai.id_image' ) ->andWhere($qb->expr()->in('pai.id_product_attribute', ':combinationIds')) ->andWhere('pai.id_image != 0') ->setParameter('combinationIds', $combinationIds, Connection::PARAM_INT_ARRAY) ->orderBy('i.position', 'asc') ; $results = $qb->execute()->fetchAll(); if (empty($results)) { return []; } // Temporary ImageId pool to avoid creating duplicates $imageIds = []; $imagesIdsByCombinationIds = []; foreach ($results as $result) { $id = (int) $result['id_image']; if (!isset($imageIds[$id])) { $imageIds[$id] = new ImageId($id); } $imagesIdsByCombinationIds[(int) $result['id_product_attribute']][] = $imageIds[$id]; } return $imagesIdsByCombinationIds; } /** * @param ImageId $imageId * * @return Image * * @throws CoreException */ public function get(ImageId $imageId, ShopId $shopId): Image { /** @var Image $image */ $image = $this->getObjectModelForShop( $imageId->getValue(), Image::class, ProductImageNotFoundException::class, $shopId ); return $image; } public function getByShopConstraint(ImageId $imageId, ShopConstraint $shopConstraint): Image { if ($shopConstraint->getShopId()) { return $this->get($imageId, $shopConstraint->getShopId()); } $shopIds = $this->getAssociatedShopIdsByShopConstraint($imageId, $shopConstraint); // finds first associated shop and uses it to load object model $shopId = reset($shopIds); if (!$shopId) { throw new ShopAssociationNotFound(sprintf('Image %d is not associated to any shop', $imageId->getValue())); } return $this->get($imageId, $shopId); } /** * @param ImageId $imageId * * @return ShopId[] */ public function getAssociatedShopIds(ImageId $imageId): array { return array_map( static function (array $shop): ShopId { return new ShopId((int) $shop['id_shop']); }, $this->connection->createQueryBuilder() ->select('id_shop') ->from($this->dbPrefix . 'image_shop') ->where('id_image = :imageId') ->setParameter('imageId', $imageId->getValue()) ->execute() ->fetchAllAssociative() ); } /** * @param ImageId $imageId * @param ShopConstraint $shopConstraint * * @return ShopId[] */ public function getAssociatedShopIdsByShopConstraint(ImageId $imageId, ShopConstraint $shopConstraint): array { $qb = $this->connection->createQueryBuilder() ->select('is.id_shop') ->from($this->dbPrefix . 'image_shop', '`is`') ->where('is.id_image = :imageId') ->setParameter('imageId', $imageId->getValue()) ; if ($shopConstraint->getShopGroupId()) { $qb ->innerJoin( '`is`', $this->dbPrefix . 'shop', 's', 's.id_shop = is.id_shop AND s.id_shop_group = :shopGroupId' ) ->setParameter('shopGroupId', $shopConstraint->getShopGroupId()->getValue()) ; } elseif ($shopConstraint->getShopId()) { $qb ->andWhere('is.id_shop = :shopId') ->setParameter('shopId', $shopConstraint->getShopId()->getValue()) ; } return array_map(static function (array $shop): ShopId { return new ShopId((int) $shop['id_shop']); }, $qb->execute()->fetchAllAssociative()); } public function create(ProductId $productId, ShopConstraint $shopConstraint): Image { $productIdValue = $productId->getValue(); $image = new Image(); $image->id_product = $productIdValue; $image->cover = null; $shopIds = $this->productRepository->getShopIdsByConstraint($productId, $shopConstraint); $this->addObjectModelToShops($image, $shopIds, CannotAddProductImageException::class); $this->updateMissingCovers($productId); return $image; } /** * Duplicate an image and associates it to another product, the same shop association are kept based on * specified shop constraint. Unles the image is associated to no shops matching the shop constraint, in * which case no duplication is done and null is returned. * * @param ImageId $sourceImageId * @param ProductId $newProductId * @param ShopConstraint $shopConstraint * * @return Image|null * * @throws CoreException */ public function duplicate(ImageId $sourceImageId, ProductId $newProductId, ShopConstraint $shopConstraint): ?Image { $associatedShopIds = $this->getAssociatedShopIdsByShopConstraint($sourceImageId, $shopConstraint); if (empty($associatedShopIds)) { return null; } $sourceImage = $this->getImageById($sourceImageId); $newImage = clone $sourceImage; unset($newImage->id, $newImage->id_image); $newImage->id_product = $newProductId->getValue(); $newImage->cover = $sourceImage->cover; $this->addObjectModelToShops($newImage, $associatedShopIds, CannotAddProductImageException::class); return $newImage; } /** * @param ImageId $imageId * @param ShopId[] $shopIds * * @return void */ public function deleteFromShops(ImageId $imageId, array $shopIds): void { foreach ($shopIds as $shopId) { $this->checkShopAssociation($imageId->getValue(), Image::class, $shopId); } $this->deleteObjectModelFromShops( // We fetch the image from first shop, the values don't matter anyway we just need an Image instance $this->get($imageId, reset($shopIds)), $shopIds, CannotDeleteProductImageException::class ); } /** * @param ImageId $imageId * @param ShopConstraint $shopConstraint * * @return void */ public function deleteByShopConstraint(ImageId $imageId, ShopConstraint $shopConstraint): void { $shopIds = $this->getAssociatedShopIdsByShopConstraint($imageId, $shopConstraint); if (empty($shopIds)) { return; } $this->deleteObjectModelFromShops( // We fetch the image from first shop, the values don't matter anyway we just need an Image instance $this->get($imageId, reset($shopIds)), $shopIds, CannotDeleteProductImageException::class ); } /** * @param ImageId $imageId * * @return ShopId[] */ public function getShopIdsByCoverId(ImageId $imageId): array { $results = $this->connection->createQueryBuilder() ->select('id_shop') ->from($this->dbPrefix . 'image_shop') ->where('id_image = :imageId') ->andWhere('cover = 1') ->setParameter('imageId', $imageId->getValue()) ->execute() ->fetchAll() ; return array_map( static function (array $shop): ShopId { return new ShopId((int) $shop['id_shop']); }, $results ); } /** * @param ProductId $productId * * @return ShopProductImagesCollection */ public function getImagesFromAllShop(ProductId $productId): ShopProductImagesCollection { $results = $this->connection->createQueryBuilder() ->select('id_image', 'id_shop', 'cover') ->from($this->dbPrefix . 'image_shop', 'i') ->andWhere('i.id_product = :productId') ->setParameter('productId', $productId->getValue()) ->addOrderBy('i.id_shop', 'ASC') ->addOrderBy('i.id_image', 'ASC') ->execute() ->fetchAll() ; $productImagesByShop = []; foreach ($results as $result) { $shopId = (int) $result['id_shop']; $productImagesByShop[$shopId][] = new ShopImageAssociation((int) $result['id_image'], (int) $result['cover'] === 1); } foreach ($this->productRepository->getAssociatedShopIds($productId) as $shopId) { if (isset($productImagesByShop[$shopId->getValue()])) { continue; } $productImagesByShop[$shopId->getValue()] = []; } $shopProductImagesArray = array_map( function (int $shopId, array $productImages): ShopProductImages { return new ShopProductImages($shopId, ShopImageAssociationCollection::from(...$productImages)); }, array_keys($productImagesByShop), $productImagesByShop ); return ShopProductImagesCollection::from(...$shopProductImagesArray); } public function findCoverImageId(ProductId $productId, ShopId $shopId): ?ImageId { $result = $this->connection->createQueryBuilder() ->select('id_image') ->from($this->dbPrefix . 'image_shop') ->where('id_product = :productId') ->setParameter('productId', $productId->getValue()) ->andWhere('id_shop = :shopId') ->setParameter('shopId', $shopId->getValue()) ->andWhere('cover = 1') ->execute() ->fetchOne() ; return $result ? new ImageId((int) $result) : null; } public function findCoverImageIdGlobal(ProductId $productId): ?ImageId { $result = $this->connection->createQueryBuilder() ->select('id_image') ->from($this->dbPrefix . 'image') ->where('id_product = :productId') ->setParameter('productId', $productId->getValue()) ->andWhere('cover = 1') ->execute() ->fetchOne() ; return $result ? new ImageId((int) $result) : null; } public function associateImageToShop(Image $image, ShopId $shopId): void { $this->connection->createQueryBuilder() ->insert($this->dbPrefix . 'image_shop') ->values( [ 'id_product' => ':productId', 'id_image' => ':imageId', 'id_shop' => ':shopId', 'cover' => ':cover', ] ) ->setParameter('productId', (int) $image->id_product) ->setParameter('imageId', (int) $image->id) ->setParameter('shopId', $shopId->getValue()) ->setParameter('cover', $image->cover ? 1 : null) ->execute() ; } public function updateMissingCovers(ProductId $productId): void { $results = $this->connection->createQueryBuilder() ->select('is.id_image', 'is.id_shop', 'is.cover') ->from($this->dbPrefix . 'image_shop', '`is`') ->leftJoin( '`is`', $this->dbPrefix . 'image', 'i', 'i.id_image = is.id_image' ) ->andWhere('is.id_product = :productId') ->setParameter('productId', $productId->getValue()) ->addOrderBy('i.position', 'ASC') ->execute() ->fetchAll() ; foreach ($results as $image) { $coverId = $this->findCoverImageId($productId, new ShopId((int) $image['id_shop'])); $coverIdGlobal = $this->findCoverImageIdGlobal($productId); if ($coverId !== null && $coverId->getValue() === (int) $image['id_image']) { continue; } if ($coverId === null) { $newValue = 1; } else { $newValue = null; } if ($newValue === $image['cover']) { continue; } $this->connection->createQueryBuilder() ->update($this->dbPrefix . 'image_shop') ->set($this->dbPrefix . 'image_shop' . '.cover', ':cover') ->setParameter('cover', $newValue) ->andWhere($this->dbPrefix . 'image_shop' . '.id_image = :imageId') ->setParameter('imageId', (int) $image['id_image']) ->andWhere($this->dbPrefix . 'image_shop' . '.id_shop = :shopId') ->setParameter('shopId', (int) $image['id_shop']) ->execute() ; if ($coverIdGlobal === null) { $this->connection->createQueryBuilder() ->update($this->dbPrefix . 'image') ->set('cover', ':cover') ->setParameter('cover', $newValue) ->andWhere('id_image = :imageId') ->setParameter('imageId', (int) $image['id_image']) ->execute() ; } } } /** * @param array $updatableProperties * @param ShopId[] $shopIds */ public function partialUpdateForShops(Image $image, array $updatableProperties, array $shopIds, int $errorCode = 0): void { $this->productImageValidator->validate($image); $this->partiallyUpdateObjectModelForShops( $image, $updatableProperties, $shopIds, CannotUpdateProductImageException::class, $errorCode ); } public function delete(Image $image): void { $this->deleteObjectModelFromShops( $image, $this->getAssociatedShopIds(new ImageId((int) $image->id)), CannotDeleteProductImageException::class ); } /** * @param ImageId $imageId * * @return Image * * @throws CoreException */ public function getImageById(ImageId $imageId): Image { /** @var Image $image */ $image = $this->getObjectModel( $imageId->getValue(), Image::class, ProductImageNotFoundException::class ); return $image; } /** * @return ImageType[] */ public function getProductImageTypes(): array { try { $results = ImageType::getImagesTypes('products'); } catch (PrestaShopException $e) { throw new CoreException('Error occurred when trying to get product image types'); } if (!$results) { return []; } $imageTypes = []; foreach ($results as $result) { $imageType = new ImageType(); $imageType->id = (int) $result['id_image_type']; $imageType->name = $result['name']; $imageType->width = (int) $result['width']; $imageType->height = (int) $result['height']; $imageType->products = (bool) $result['products']; $imageType->categories = (bool) $result['categories']; $imageType->manufacturers = (bool) $result['manufacturers']; $imageType->suppliers = (bool) $result['suppliers']; $imageType->stores = (bool) $result['stores']; $imageTypes[] = $imageType; } return $imageTypes; } public function getPreviewCombinationProduct(CombinationId $combinationId): ?ImageId { $qb = $this->connection->createQueryBuilder(); $qb->select('pai.id_image') ->from($this->dbPrefix . 'product_attribute_image', 'pai') ->leftJoin('pai', $this->dbPrefix . 'image', 'i', 'i.id_image = pai.id_image') ->where('pai.id_product_attribute = :productAttribute') ->orderBy('i.cover', 'DESC') ->setMaxResults(1) ->setParameter('productAttribute', $combinationId->getValue()); $data = $qb->execute()->fetchOne(); if ($data > 0) { return new ImageId((int) $data); } return null; } }