This commit is contained in:
Mateusz 2026-06-25 11:36:56 +00:00 committed by GitHub
commit c2161f62da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 332 additions and 37 deletions

View file

@ -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

View file

@ -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 ("[^"]+")$/

View file

@ -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)
;
}
}

View file

@ -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(

View file

@ -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()

View file

@ -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

View file

@ -0,0 +1,130 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Tests\Functional\Repository;
use Doctrine\ORM\EntityManagerInterface;
use Fidry\AliceDataFixtures\LoaderInterface;
use Fidry\AliceDataFixtures\Persistence\PurgeMode;
use PHPUnit\Framework\Attributes\Test;
use Sylius\Component\Core\Model\ProductInterface;
use Sylius\Component\Core\Model\TaxonInterface;
use Sylius\Component\Core\Repository\ProductRepositoryInterface;
use Sylius\Component\Taxonomy\Repository\TaxonRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class ProductRepositoryTest extends KernelTestCase
{
/** @var ProductRepositoryInterface<ProductInterface> */
private ProductRepositoryInterface $productRepository;
/** @var TaxonRepositoryInterface<TaxonInterface> */
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<ProductInterface> $productRepository */
$productRepository = self::getContainer()->get('sylius.repository.product');
$this->productRepository = $productRepository;
/** @var TaxonRepositoryInterface<TaxonInterface> $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());
}
}