diff --git a/features/shop/product/list/viewing_products_from_specific_taxon.feature b/features/shop/product/list/viewing_products_from_specific_taxon.feature index b873e46d8c..2703478256 100644 --- a/features/shop/product/list/viewing_products_from_specific_taxon.feature +++ b/features/shop/product/list/viewing_products_from_specific_taxon.feature @@ -18,6 +18,27 @@ Feature: Viewing products from a specific taxon Then I should see the product "T-Shirt Banana" And I should not see the product "Plastic Tomato" + @api @ui + Scenario: Viewing a product belonging to multiple taxons from each of its taxons + Given the store has a product "Funny T-Shirt" available in "Poland" channel + And this product belongs to "T-Shirts" and "Funny" + When I browse products from taxon "T-Shirts" + Then I should see the product "T-Shirt Banana" + And I should see the product "Funny T-Shirt" + And I should not see the product "Plastic Tomato" + When I browse products from taxon "Funny" + Then I should see the product "Funny T-Shirt" + And I should see the product "Plastic Tomato" + And I should not see the product "T-Shirt Banana" + + @api @no-ui + Scenario: Product fetched by taxon retains all its taxon associations in the response + Given the store has a product "Funny T-Shirt" available in "Poland" channel + And this product belongs to "T-Shirts" and "Funny" + When I browse products from taxon "T-Shirts" + Then I should see the product "Funny T-Shirt" + And the product "Funny T-Shirt" should have 2 taxon associations in the response + @api @no-ui Scenario: Searching products by multiple taxons When I browse products from "Funny" and "T-Shirts" taxons diff --git a/src/Sylius/Behat/Context/Api/Shop/ProductContext.php b/src/Sylius/Behat/Context/Api/Shop/ProductContext.php index bcc81bf720..f49726b080 100644 --- a/src/Sylius/Behat/Context/Api/Shop/ProductContext.php +++ b/src/Sylius/Behat/Context/Api/Shop/ProductContext.php @@ -279,6 +279,28 @@ final class ProductContext implements Context )); } + /** + * @Then the product :name should have :count taxon associations in the response + */ + public function theProductShouldHaveTaxonAssociationsInResponse(string $name, int $count): void + { + $products = $this->responseChecker->getCollection($this->client->getLastResponse()); + + foreach ($products as $product) { + if ($product['name'] === $name) { + Assert::count( + $product['productTaxons'], + $count, + sprintf('Expected product "%s" to have %d taxon associations, but got %d.', $name, $count, count($product['productTaxons'])), + ); + + return; + } + } + + throw new \RuntimeException(sprintf('Product "%s" was not found in the response collection.', $name)); + } + /** * @Then /^I should see the product price ("[^"]+")$/ * @Then /^customer should see the product price ("[^"]+")$/ diff --git a/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/Filter/TaxonFilter.php b/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/Filter/TaxonFilter.php index 530368adc8..987e6f9fea 100644 --- a/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/Filter/TaxonFilter.php +++ b/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/Filter/TaxonFilter.php @@ -57,30 +57,53 @@ final class TaxonFilter extends AbstractFilter $alias = $queryBuilder->getRootAliases()[0]; + $subQuery = $queryBuilder->getEntityManager() + ->createQueryBuilder() + ->select('sub.id') + ->from($resourceClass, 'sub') + ->innerJoin('sub.productTaxons', 'subProductTaxon') + ->innerJoin('subProductTaxon.taxon', 'subTaxon') + ->andWhere('subTaxon.root = :taxonRoot') + ; + + if (null !== $taxon && null !== $taxon->getLeft()) { + $subQuery->andWhere('subTaxon.left >= :taxonLeft'); + } + + if (null !== $taxon && null !== $taxon->getRight()) { + $subQuery->andWhere('subTaxon.right <= :taxonRight'); + } + $queryBuilder - ->addSelect('productTaxon') - ->innerJoin(sprintf('%s.productTaxons', $alias), 'productTaxon') - ->innerJoin('productTaxon.taxon', 'taxon') - ->andWhere('taxon.root = :taxonRoot') + ->andWhere($queryBuilder->expr()->in(sprintf('%s.id', $alias), $subQuery->getDQL())) ->setParameter('taxonRoot', $taxonRoot) ; if (null !== $taxon && null !== $taxon->getLeft()) { - $queryBuilder - ->andWhere('taxon.left >= :taxonLeft') - ->setParameter('taxonLeft', $taxon->getLeft()) - ; + $queryBuilder->setParameter('taxonLeft', $taxon->getLeft()); } if (null !== $taxon && null !== $taxon->getRight()) { - $queryBuilder - ->andWhere('taxon.right <= :taxonRight') - ->setParameter('taxonRight', $taxon->getRight()) - ; + $queryBuilder->setParameter('taxonRight', $taxon->getRight()); } - if (null !== $taxonRoot && empty($context['filters']['order'])) { - $queryBuilder->addOrderBy('productTaxon.position'); + if (null !== $taxonRoot && null !== $taxon && empty($context['filters']['order'])) { + $productTaxonAlias = $queryNameGenerator->generateJoinAlias('productTaxon'); + $productTaxonClass = $queryBuilder->getEntityManager() + ->getClassMetadata($resourceClass) + ->getAssociationTargetClass('productTaxons') + ; + + $queryBuilder + ->leftJoin( + $productTaxonClass, + $productTaxonAlias, + 'WITH', + sprintf('%s.product = %s AND %s.taxon = :taxon', $productTaxonAlias, $alias, $productTaxonAlias), + ) + ->addOrderBy(sprintf('%s.position', $productTaxonAlias)) + ->setParameter('taxon', $taxon) + ; } } diff --git a/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/Filter/TaxonFilterTest.php b/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/Filter/TaxonFilterTest.php index 19e8ad1692..a157921b52 100644 --- a/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/Filter/TaxonFilterTest.php +++ b/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/Filter/TaxonFilterTest.php @@ -17,6 +17,10 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\IriConverterInterface; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query\Expr; +use Doctrine\ORM\Query\Expr\Func; use Doctrine\ORM\QueryBuilder; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -48,6 +52,10 @@ final class TaxonFilterTest extends TestCase $taxonRoot = $this->createMock(TaxonInterface::class); $queryBuilder = $this->createMock(QueryBuilder::class); $queryNameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $subQueryBuilder = $this->createMock(QueryBuilder::class); + $classMetadata = $this->createMock(ClassMetadata::class); + $expr = $this->createMock(Expr::class); $this->iriConverter ->method('getResourceFromIri') @@ -55,26 +63,40 @@ final class TaxonFilterTest extends TestCase ->willReturn($taxon); $queryBuilder->method('getRootAliases')->willReturn(['o']); - - $queryBuilder->method('distinct')->willReturnSelf(); - $queryBuilder->method('addSelect')->willReturnSelf(); - $queryBuilder->method('innerJoin')->willReturnSelf(); + $queryBuilder->method('getEntityManager')->willReturn($entityManager); + $queryBuilder->method('expr')->willReturn($expr); $queryBuilder->method('andWhere')->willReturnSelf(); $queryBuilder->method('setParameter')->willReturnSelf(); + $queryBuilder->method('leftJoin')->willReturnSelf(); + + $entityManager->method('createQueryBuilder')->willReturn($subQueryBuilder); + $entityManager->method('getClassMetadata')->with('resourceClass')->willReturn($classMetadata); + + $subQueryBuilder->method('select')->willReturnSelf(); + $subQueryBuilder->method('from')->willReturnSelf(); + $subQueryBuilder->method('innerJoin')->willReturnSelf(); + $subQueryBuilder->method('andWhere')->willReturnSelf(); + $subQueryBuilder->method('getDQL')->willReturn(''); + + $classMetadata->method('getAssociationTargetClass')->with('productTaxons')->willReturn('ProductTaxonClass'); + + $expr->method('in')->willReturn($this->createMock(Func::class)); + + $taxon->method('getRoot')->willReturn($taxonRoot); + $taxon->method('getLeft')->willReturn(3); + $taxon->method('getRight')->willReturn(5); + + $queryNameGenerator->method('generateJoinAlias')->with('productTaxon')->willReturn('productTaxon_a1'); $queryBuilder ->expects($this->once()) ->method('addOrderBy') ->with( - $this->equalTo('productTaxon.position'), + $this->equalTo('productTaxon_a1.position'), $this->isNull(), ) ->willReturnSelf(); - $taxon->method('getRoot')->willReturn($taxonRoot); - $taxon->method('getLeft')->willReturn(3); - $taxon->method('getRight')->willReturn(5); - $this->taxonFilter->filterProperty('taxon', 'api/taxon', $queryBuilder, $queryNameGenerator, 'resourceClass'); } @@ -84,6 +106,9 @@ final class TaxonFilterTest extends TestCase $taxonRoot = $this->createMock(TaxonInterface::class); $queryBuilder = $this->createMock(QueryBuilder::class); $queryNameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $subQueryBuilder = $this->createMock(QueryBuilder::class); + $expr = $this->createMock(Expr::class); $context = ['filters' => ['order' => ['differentOrderParameter' => 'asc']]]; $this->iriConverter @@ -92,12 +117,21 @@ final class TaxonFilterTest extends TestCase ->willReturn($taxon); $queryBuilder->method('getRootAliases')->willReturn(['o']); - $queryBuilder->method('distinct')->willReturnSelf(); - $queryBuilder->method('addSelect')->willReturnSelf(); - $queryBuilder->method('innerJoin')->willReturnSelf(); + $queryBuilder->method('getEntityManager')->willReturn($entityManager); + $queryBuilder->method('expr')->willReturn($expr); $queryBuilder->method('andWhere')->willReturnSelf(); $queryBuilder->method('setParameter')->willReturnSelf(); + $entityManager->method('createQueryBuilder')->willReturn($subQueryBuilder); + + $subQueryBuilder->method('select')->willReturnSelf(); + $subQueryBuilder->method('from')->willReturnSelf(); + $subQueryBuilder->method('innerJoin')->willReturnSelf(); + $subQueryBuilder->method('andWhere')->willReturnSelf(); + $subQueryBuilder->method('getDQL')->willReturn(''); + + $expr->method('in')->willReturn($this->createMock(Func::class)); + $taxon->method('getRoot')->willReturn($taxonRoot); $taxon->method('getLeft')->willReturn(null); $taxon->method('getRight')->willReturn(null); @@ -118,6 +152,9 @@ final class TaxonFilterTest extends TestCase { $queryBuilder = $this->createMock(QueryBuilder::class); $queryNameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $subQueryBuilder = $this->createMock(QueryBuilder::class); + $expr = $this->createMock(Expr::class); $this->iriConverter ->method('getResourceFromIri') @@ -125,12 +162,21 @@ final class TaxonFilterTest extends TestCase ->willThrowException(new ItemNotFoundException()); $queryBuilder->method('getRootAliases')->willReturn(['o']); - $queryBuilder->method('distinct')->willReturnSelf(); - $queryBuilder->method('addSelect')->willReturnSelf(); - $queryBuilder->method('innerJoin')->willReturnSelf(); + $queryBuilder->method('getEntityManager')->willReturn($entityManager); + $queryBuilder->method('expr')->willReturn($expr); $queryBuilder->method('andWhere')->willReturnSelf(); $queryBuilder->method('setParameter')->willReturnSelf(); + $entityManager->method('createQueryBuilder')->willReturn($subQueryBuilder); + + $subQueryBuilder->method('select')->willReturnSelf(); + $subQueryBuilder->method('from')->willReturnSelf(); + $subQueryBuilder->method('innerJoin')->willReturnSelf(); + $subQueryBuilder->method('andWhere')->willReturnSelf(); + $subQueryBuilder->method('getDQL')->willReturn(''); + + $expr->method('in')->willReturn($this->createMock(Func::class)); + $queryBuilder->expects($this->never())->method('addOrderBy'); $this->taxonFilter->filterProperty( @@ -146,6 +192,9 @@ final class TaxonFilterTest extends TestCase { $queryBuilder = $this->createMock(QueryBuilder::class); $queryNameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + $subQueryBuilder = $this->createMock(QueryBuilder::class); + $expr = $this->createMock(Expr::class); $this->iriConverter ->method('getResourceFromIri') @@ -153,12 +202,21 @@ final class TaxonFilterTest extends TestCase ->willThrowException(new InvalidArgumentException()); $queryBuilder->method('getRootAliases')->willReturn(['o']); - $queryBuilder->method('distinct')->willReturnSelf(); - $queryBuilder->method('addSelect')->willReturnSelf(); - $queryBuilder->method('innerJoin')->willReturnSelf(); + $queryBuilder->method('getEntityManager')->willReturn($entityManager); + $queryBuilder->method('expr')->willReturn($expr); $queryBuilder->method('andWhere')->willReturnSelf(); $queryBuilder->method('setParameter')->willReturnSelf(); + $entityManager->method('createQueryBuilder')->willReturn($subQueryBuilder); + + $subQueryBuilder->method('select')->willReturnSelf(); + $subQueryBuilder->method('from')->willReturnSelf(); + $subQueryBuilder->method('innerJoin')->willReturnSelf(); + $subQueryBuilder->method('andWhere')->willReturnSelf(); + $subQueryBuilder->method('getDQL')->willReturn(''); + + $expr->method('in')->willReturn($this->createMock(Func::class)); + $queryBuilder->expects($this->never())->method('addOrderBy'); $this->taxonFilter->filterProperty( diff --git a/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/ProductRepository.php b/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/ProductRepository.php index 536bfc3f8f..ccaff0047a 100644 --- a/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/ProductRepository.php +++ b/src/Sylius/Bundle/CoreBundle/Doctrine/ORM/ProductRepository.php @@ -247,12 +247,16 @@ class ProductRepository extends BaseProductRepository implements ProductReposito public function findByTaxon(TaxonInterface $taxon): array { - return $this - ->createQueryBuilder('product') - ->distinct() - ->addSelect('productTaxon') - ->innerJoin('product.productTaxons', 'productTaxon') + $subQuery = $this->createQueryBuilder('sub') + ->select('sub.id') + ->innerJoin('sub.productTaxons', 'productTaxon') ->andWhere('productTaxon.taxon = :taxon') + ; + + $queryBuilder = $this->createQueryBuilder('product'); + + return $queryBuilder + ->andWhere($queryBuilder->expr()->in('product.id', $subQuery->getDQL())) ->setParameter('taxon', $taxon) ->getQuery() ->getResult() diff --git a/tests/DataFixtures/ORM/resources/product_repository_find_by_taxon.yml b/tests/DataFixtures/ORM/resources/product_repository_find_by_taxon.yml new file mode 100644 index 0000000000..aca7f3e7f0 --- /dev/null +++ b/tests/DataFixtures/ORM/resources/product_repository_find_by_taxon.yml @@ -0,0 +1,37 @@ +Sylius\Component\Core\Model\Taxon: + find_by_taxon_mugs: + code: "find_by_taxon_mugs" + find_by_taxon_books: + code: "find_by_taxon_books" + +Sylius\Component\Core\Model\Product: + find_by_taxon_product_mugs_only: + currentLocale: "en_US" + fallbackLocale: "en_US" + code: "FIND_BY_TAXON_MUGS_ONLY" + find_by_taxon_product_mugs_and_books: + currentLocale: "en_US" + fallbackLocale: "en_US" + code: "FIND_BY_TAXON_MUGS_AND_BOOKS" + find_by_taxon_product_books_only: + currentLocale: "en_US" + fallbackLocale: "en_US" + code: "FIND_BY_TAXON_BOOKS_ONLY" + +Sylius\Component\Core\Model\ProductTaxon: + find_by_taxon_pt_mugs_only: + product: "@find_by_taxon_product_mugs_only" + taxon: "@find_by_taxon_mugs" + position: 0 + find_by_taxon_pt_mugs_and_books_mugs: + product: "@find_by_taxon_product_mugs_and_books" + taxon: "@find_by_taxon_mugs" + position: 1 + find_by_taxon_pt_mugs_and_books_books: + product: "@find_by_taxon_product_mugs_and_books" + taxon: "@find_by_taxon_books" + position: 0 + find_by_taxon_pt_books_only: + product: "@find_by_taxon_product_books_only" + taxon: "@find_by_taxon_books" + position: 1 diff --git a/tests/Functional/Repository/ProductRepositoryTest.php b/tests/Functional/Repository/ProductRepositoryTest.php new file mode 100644 index 0000000000..7a75a4f666 --- /dev/null +++ b/tests/Functional/Repository/ProductRepositoryTest.php @@ -0,0 +1,130 @@ + */ + private ProductRepositoryInterface $productRepository; + + /** @var TaxonRepositoryInterface */ + private TaxonRepositoryInterface $taxonRepository; + + protected function setUp(): void + { + parent::setUp(); + + $this->loadFixtures(); + + /** @var EntityManagerInterface $entityManager */ + $entityManager = self::getContainer()->get('doctrine.orm.entity_manager'); + $entityManager->clear(); + + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = self::getContainer()->get('sylius.repository.product'); + $this->productRepository = $productRepository; + + /** @var TaxonRepositoryInterface $taxonRepository */ + $taxonRepository = self::getContainer()->get('sylius.repository.taxon'); + $this->taxonRepository = $taxonRepository; + } + + #[Test] + public function it_finds_products_belonging_to_a_taxon(): void + { + /** @var TaxonInterface $mugsTaxon */ + $mugsTaxon = $this->taxonRepository->findOneBy(['code' => 'find_by_taxon_mugs']); + + $products = $this->productRepository->findByTaxon($mugsTaxon); + + self::assertCount(2, $products); + self::assertEqualsCanonicalizing( + ['FIND_BY_TAXON_MUGS_ONLY', 'FIND_BY_TAXON_MUGS_AND_BOOKS'], + $this->getProductCodes($products), + ); + } + + #[Test] + public function it_does_not_return_products_from_other_taxons(): void + { + /** @var TaxonInterface $booksTaxon */ + $booksTaxon = $this->taxonRepository->findOneBy(['code' => 'find_by_taxon_books']); + + $products = $this->productRepository->findByTaxon($booksTaxon); + + self::assertCount(2, $products); + self::assertEqualsCanonicalizing( + ['FIND_BY_TAXON_MUGS_AND_BOOKS', 'FIND_BY_TAXON_BOOKS_ONLY'], + $this->getProductCodes($products), + ); + } + + #[Test] + public function it_returns_the_full_product_taxons_collection_not_only_filtered_ones(): void + { + /** @var TaxonInterface $mugsTaxon */ + $mugsTaxon = $this->taxonRepository->findOneBy(['code' => 'find_by_taxon_mugs']); + + $products = $this->productRepository->findByTaxon($mugsTaxon); + + $productInBothTaxons = null; + foreach ($products as $product) { + if ($product->getCode() === 'FIND_BY_TAXON_MUGS_AND_BOOKS') { + $productInBothTaxons = $product; + + break; + } + } + + self::assertNotNull($productInBothTaxons); + self::assertCount( + 2, + $productInBothTaxons->getProductTaxons(), + 'Product assigned to two taxons must expose both in its productTaxons collection, not just the filtered one.', + ); + } + + /** + * @param ProductInterface[] $products + * + * @return string[] + */ + private function getProductCodes(array $products): array + { + return array_map( + static fn (ProductInterface $product): string => (string) $product->getCode(), + $products, + ); + } + + private function loadFixtures(): void + { + /** @var LoaderInterface $fixtureLoader */ + $fixtureLoader = self::getContainer()->get('fidry_alice_data_fixtures.loader.doctrine'); + + $fixtureLoader->load([ + __DIR__ . '/../../DataFixtures/ORM/resources/product_repository_find_by_taxon.yml', + ], [], [], PurgeMode::createDeleteMode()); + } +}