feature #15068 View only available associations for product (TheMilek)

This PR was merged into the 1.13 branch.

Discussion
----------

| Q               | A                                                            |
|-----------------|--------------------------------------------------------------|
| Branch?         | 1.13 <!-- see the comment below -->                  |
| Bug fix?        | yes                                                       |
| New feature?    | no                                                       |
| BC breaks?      | no                                                      |
| Deprecations?   | no <!-- don't forget to update the UPGRADE-*.md file --> |
| License         | MIT                                                          |

<!--
 - Bug fixes must be submitted against the 1.12 branch
 - Features and deprecations must be submitted against the 1.13 branch
 - Make sure that the correct base branch is set

 To be sure you are not breaking any Backward Compatibilities, check the documentation:
 https://docs.sylius.com/en/latest/book/organization/backward-compatibility-promise.html
-->


Commits
-------

ebc7d88b08 View only available associations for product
This commit is contained in:
Grzegorz Sadowski 2023-06-15 14:14:49 +02:00 committed by GitHub
commit 79fba64c17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 541 additions and 8 deletions

View file

@ -640,12 +640,9 @@ final class ProductContext implements Context
foreach ($associations as $association) {
$associationResponse = $this->client->showByIri($association);
$associationTypeIri = $this->responseChecker->getValue($associationResponse, 'type');
if ($associationResponse->getStatusCode() === 200) {
$associationTypeIri = $this->responseChecker->getValue($associationResponse, 'type');
Assert::notSame($associationTypeIri, $productAssociationTypeIri);
}
Assert::notSame($associationTypeIri, $productAssociationTypeIri);
}
}

View file

@ -45,7 +45,7 @@ final class ProductItemDataProvider implements RestrictedDataProviderInterface,
/** @var ChannelInterface $channel */
$channel = $context[ContextKeys::CHANNEL];
return $this->productRepository->findOneByChannelAndCode($channel, $id);
return $this->productRepository->findOneByChannelAndCodeWithAvailableAssociations($channel, $id);
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\Bundle\ApiBundle\Doctrine\QueryCollectionExtension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\ContextAwareQueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Component\Core\Model\ProductInterface;
/** @experimental */
final class AvailableProductAssociationsInProductCollectionExtension implements ContextAwareQueryCollectionExtensionInterface
{
public function __construct(private UserContextInterface $userContext)
{
}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
array $context = [],
): void {
if (!is_a($resourceClass, ProductInterface::class, true)) {
return;
}
$user = $this->userContext->getUser();
if ($user !== null && in_array('ROLE_API_ACCESS', $user->getRoles(), true)) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$enabledParameterName = $queryNameGenerator->generateParameterName('enabled');
$associationAliasName = $queryNameGenerator->generateJoinAlias('association');
$productAssociationAliasName = $queryNameGenerator->generateJoinAlias('associatedProduct');
$queryBuilder
->addSelect($rootAlias)
->addSelect($associationAliasName)
->leftJoin(sprintf('%s.associations', $rootAlias), $associationAliasName)
->leftJoin(
sprintf('%s.associatedProducts', $associationAliasName),
$productAssociationAliasName,
'WITH',
$queryBuilder->expr()->andX(
$queryBuilder->expr()->eq(sprintf('%s.enabled', $productAssociationAliasName), 'true'),
$queryBuilder->expr()->eq(sprintf('%s.owner', $associationAliasName), $rootAlias),
),
)
->andWhere(sprintf('%s.associations IS EMPTY OR %s.id IS NOT NULL', $rootAlias, $productAssociationAliasName))
->andWhere(sprintf('%s.enabled = :%s', $rootAlias, $enabledParameterName))
->setParameter($enabledParameterName, true)
;
}
}

View file

@ -43,6 +43,11 @@
<tag name="api_platform.doctrine.orm.query_extension.collection" />
</service>
<service id="Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension\AvailableProductAssociationsInProductCollectionExtension">
<argument type="service" id="Sylius\Bundle\ApiBundle\Context\UserContextInterface" />
<tag name="api_platform.doctrine.orm.query_extension.collection" />
</service>
<service id="Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension\OrdersByChannelExtension">
<argument type="service" id="Sylius\Bundle\ApiBundle\Context\UserContextInterface" />
<tag name="api_platform.doctrine.orm.query_extension.collection" />

View file

@ -72,7 +72,7 @@ final class ProductItemDataProviderSpec extends ObjectBehavior
$userContext->getUser()->willReturn($user);
$user->getRoles()->willReturn([]);
$productRepository->findOneByChannelAndCode($channel, 'FORD_FOCUS')->willReturn($product);
$productRepository->findOneByChannelAndCodeWithAvailableAssociations($channel, 'FORD_FOCUS')->willReturn($product);
$this
->getItem(
@ -95,7 +95,7 @@ final class ProductItemDataProviderSpec extends ObjectBehavior
): void {
$userContext->getUser()->willReturn(null);
$productRepository->findOneByChannelAndCode($channel, 'FORD_FOCUS')->willReturn($product);
$productRepository->findOneByChannelAndCodeWithAvailableAssociations($channel, 'FORD_FOCUS')->willReturn($product);
$this
->getItem(

View file

@ -0,0 +1,98 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\ContextAwareQueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ProductInterface;
use Sylius\Component\Core\Model\TaxonInterface;
use Symfony\Component\Security\Core\User\UserInterface;
final class AvailableProductAssociationsInProductCollectionExtensionSpec extends ObjectBehavior
{
function let(UserContextInterface $userContext): void
{
$this->beConstructedWith($userContext);
}
public function it_is_a_constraint_validator()
{
$this->shouldHaveType(ContextAwareQueryCollectionExtensionInterface::class);
}
function it_does_nothing_if_current_resource_is_not_a_product(
UserContextInterface $userContext,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
): void {
$userContext->getUser()->shouldNotBeCalled();
$queryBuilder->getRootAliases()->shouldNotBeCalled();
$this->applyToCollection($queryBuilder, $queryNameGenerator, TaxonInterface::class, 'get', []);
}
function it_does_nothing_if_current_user_is_an_admin_user(
UserContextInterface $userContext,
UserInterface $user,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
): void {
$userContext->getUser()->willReturn($user);
$user->getRoles()->willReturn(['ROLE_API_ACCESS']);
$queryBuilder->getRootAliases()->shouldNotBeCalled();
$this->applyToCollection($queryBuilder, $queryNameGenerator, ProductInterface::class, 'get', []);
}
function it_filters_products_by_available_associations(
UserContextInterface $userContext,
UserInterface $user,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
ChannelInterface $channel,
Expr $expr,
Expr\Comparison $comparison,
Andx $andx,
): void {
$userContext->getUser()->willReturn($user);
$user->getRoles()->willReturn([]);
$queryNameGenerator->generateParameterName('enabled')->shouldBeCalled()->willReturn('enabled');
$queryNameGenerator->generateJoinAlias('association')->shouldBeCalled()->willReturn('association');
$queryNameGenerator->generateJoinAlias('associatedProduct')->shouldBeCalled()->willReturn('associatedProduct');
$queryBuilder->getRootAliases()->willReturn(['o']);
$queryBuilder->addSelect('o')->shouldBeCalled()->willReturn($queryBuilder);
$queryBuilder->addSelect('association')->shouldBeCalled()->willReturn($queryBuilder);
$queryBuilder->leftJoin('o.associations', 'association')->shouldBeCalled()->willReturn($queryBuilder);
$expr->andX(Argument::type(Expr\Comparison::class), Argument::type(Expr\Comparison::class))->willReturn($andx);
$expr->eq('associatedProduct.enabled', 'true')->shouldBeCalled()->willReturn($comparison);
$expr->eq('association.owner', 'o')->shouldBeCalled()->willReturn($comparison);
$queryBuilder->expr()->willReturn($expr->getWrappedObject());
$queryBuilder->leftJoin('association.associatedProducts', 'associatedProduct', 'WITH', Argument::type(Andx::class))->shouldBeCalled()->willReturn($queryBuilder);
$queryBuilder->andWhere('o.associations IS EMPTY OR associatedProduct.id IS NOT NULL')->shouldBeCalled()->willReturn($queryBuilder);
$queryBuilder->andWhere('o.enabled = :enabled')->shouldBeCalled()->willReturn($queryBuilder);
$queryBuilder->setParameter('enabled', true)->shouldBeCalled()->willReturn($queryBuilder);
$this->applyToCollection($queryBuilder, $queryNameGenerator, ProductInterface::class, 'get', []);
}
}

View file

@ -232,4 +232,39 @@ class ProductRepository extends BaseProductRepository implements ProductReposito
->getResult()
;
}
public function findOneByChannelAndCodeWithAvailableAssociations(ChannelInterface $channel, string $code): ?ProductInterface
{
$product = $this->createQueryBuilder('o')
->addSelect('association')
->leftJoin('o.associations', 'association')
->innerJoin('association.associatedProducts', 'associatedProduct', 'WITH', 'associatedProduct.enabled = :enabled')
->where('o.code = :code')
->andWhere(':channel MEMBER OF o.channels')
->andWhere('o.enabled = :enabled')
->setParameter('channel', $channel)
->setParameter('code', $code)
->setParameter('enabled', true)
->getQuery()
->getOneOrNullResult()
;
if (null === $product) {
$product = $this->findOneByChannelAndCode($channel, $code);
$product?->getAssociations()->clear();
}
$this->associationHydrator->hydrateAssociations($product, [
'images',
'options',
'options.translations',
'variants',
'variants.channelPricings',
'variants.optionValues',
'variants.optionValues.translations',
]);
return $product;
}
}

View file

@ -49,4 +49,6 @@ interface ProductRepositoryInterface extends BaseProductRepositoryInterface
public function findOneByCode(string $code): ?ProductInterface;
public function findByTaxon(TaxonInterface $taxon): array;
public function findOneByChannelAndCodeWithAvailableAssociations(ChannelInterface $channel, string $code): ?ProductInterface;
}

View file

@ -0,0 +1,174 @@
Sylius\Component\Core\Model\Channel:
channel_web:
code: 'WEB'
name: 'Web Channel'
hostname: 'localhost'
description: 'Lorem ipsum'
baseCurrency: '@currency_usd'
defaultLocale: '@locale_en'
locales: ['@locale_en']
color: 'black'
enabled: true
taxCalculationStrategy: 'order_items_based'
Sylius\Component\Currency\Model\Currency:
currency_usd:
code: 'USD'
Sylius\Component\Locale\Model\Locale:
locale_en:
code: 'en_US'
Sylius\Component\Core\Model\Product:
product_mug:
code: 'MUG'
channels: ['@channel_web']
currentLocale: 'en_US'
translations:
en_US: '@product_translation_mug_en_US'
options: ['@product_option_color']
product_cup:
code: 'CUP'
channels: [ '@channel_web' ]
currentLocale: 'en_US'
translations:
en_US: '@product_translation_cup_en_US'
product_hat:
code: 'HAT'
channels: [ '@channel_web' ]
currentLocale: 'en_US'
translations:
en_US: '@product_translation_hat_en_US'
enabled: false
Sylius\Component\Core\Model\ProductTranslation:
product_translation_mug_en_US:
slug: 'mug'
locale: 'en_US'
name: 'Mug'
description: 'This is a mug'
shortDescription: 'Short mug description'
translatable: '@product_mug'
product_translation_cup_en_US:
slug: 'Cup'
locale: 'en_US'
name: 'Cup'
description: 'Short cup description'
shortDescription: 'Cup'
translatable: '@product_cup'
product_translation_hat_en_US:
slug: 'Hat'
locale: 'en_US'
name: 'Hat'
description: 'Short hat description'
shortDescription: 'Hat'
translatable: '@product_hat'
Sylius\Component\Core\Model\ProductVariant:
product_variant_mug_blue:
code: 'MUG_BLUE'
product: '@product_mug'
currentLocale: 'en_US'
translations:
en_US: '@product_variant_mug_blue_translation_en_EN'
optionValues: ['@product_option_value_color_blue']
channelPricings:
WEB: '@channel_pricing_product_variant_mug_blue_web'
product_variant_mug_red:
code: 'MUG_RED'
product: '@product_mug'
currentLocale: 'en_US'
translations:
en_US: '@product_variant_mug_red_translation_en_EN'
optionValues: ['@product_option_value_color_red']
channelPricings:
WEB: '@channel_pricing_product_variant_mug_red_web'
Sylius\Component\Product\Model\ProductVariantTranslation:
product_variant_mug_blue_translation_en_EN:
locale: 'en_US'
name: 'Blue Mug'
translatable: '@product_variant_mug_blue'
product_variant_mug_red_translation_en_EN:
locale: 'en_US'
name: 'Red Mug'
translatable: '@product_variant_mug_red'
Sylius\Component\Core\Model\ChannelPricing:
channel_pricing_product_variant_mug_blue_web:
channelCode: 'WEB'
price: 2000
originalPrice: 3000
channel_pricing_product_variant_mug_red_web:
channelCode: 'WEB'
price: 2000
Sylius\Component\Product\Model\ProductOption:
product_option_color:
code: 'COLOR'
currentLocale: 'en_US'
translations:
- '@product_option_translation_en_EN'
Sylius\Component\Product\Model\ProductOptionTranslation:
product_option_translation_en_EN:
locale: 'en_US'
name: 'Color'
translatable: '@product_option_color'
Sylius\Component\Product\Model\ProductOptionValue:
product_option_value_color_blue:
code: 'COLOR_BLUE'
currentLocale: 'en_US'
fallbackLocale: 'en_US'
option: '@product_option_color'
translations:
- '@product_option_value_translation_blue'
product_option_value_color_red:
code: 'COLOR_RED'
currentLocale: 'en_US'
fallbackLocale: 'en_US'
option: '@product_option_color'
translations:
- '@product_option_value_translation_red'
Sylius\Component\Product\Model\ProductOptionValueTranslation:
product_option_value_translation_blue:
locale: 'en_US'
value: 'Blue'
translatable: '@product_option_value_color_blue'
product_option_value_translation_red:
locale: 'en_US'
value: 'Red'
translatable: '@product_option_value_color_red'
Sylius\Component\Product\Model\ProductAssociation:
product_association:
type: '@product_association_type'
owner: '@product_mug'
associatedProducts: ['@product_cup']
another_product_association:
type: '@another_product_association_type'
owner: '@product_mug'
associatedProducts: ['@product_hat']
Sylius\Component\Product\Model\ProductAssociationType:
product_association_type:
code: 'similar_products'
translations:
en_US: '@product_association_type_translation'
another_product_association_type:
code: 'another_similar_products'
translations:
en_US: '@another_product_association_type_translation'
Sylius\Component\Product\Model\ProductAssociationTypeTranslation:
product_association_type_translation:
name: 'Similar products'
locale: 'en_US'
translatable: '@product_association_type'
another_product_association_type_translation:
name: 'Another similar products'
locale: 'en_US'
translatable: '@another_product_association_type'

View file

@ -0,0 +1,118 @@
{
"@context": "\/api\/v2\/contexts\/Product",
"@id": "\/api\/v2\/shop\/products",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "\/api\/v2\/shop\/products\/CUP",
"@type": "Product",
"productTaxons": [],
"mainTaxon": null,
"reviews": [],
"averageRating": @integer@,
"images": [],
"id": @integer@,
"code": "CUP",
"variants": [],
"options": [],
"associations": [],
"createdAt": @date@,
"updatedAt": @date@,
"shortDescription": "Cup",
"name": "Cup",
"description": @string@,
"slug": "Cup",
"defaultVariant": null
},
{
"@id": "\/api\/v2\/shop\/products\/MUG",
"@type": "Product",
"productTaxons": [],
"mainTaxon": null,
"reviews": [],
"averageRating": @integer@,
"images": [],
"id": @integer@,
"code": "MUG",
"variants": [
"\/api\/v2\/shop\/product-variants\/MUG_BLUE",
"\/api\/v2\/shop\/product-variants\/MUG_RED"
],
"options": [
"\/api\/v2\/shop\/product-options\/COLOR"
],
"createdAt": @date@,
"updatedAt": @date@,
"shortDescription": "Short mug description",
"name": "Mug",
"description": @string@,
"slug": "mug",
"associations": [
"\/api\/v2\/shop\/product-associations\/@integer@"
],
"defaultVariant": "\/api\/v2\/shop\/product-variants\/MUG_BLUE"
}
],
"hydra:totalItems": 2,
"hydra:search": {
"@type": "hydra:IriTemplate",
"hydra:template": "\/api\/v2\/shop\/products{?translations.name,order[code],order[createdAt],productTaxons.taxon.code,productTaxons.taxon.code[],order[price],order[translation.name],localeCode for order[translation.name],taxon}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
"@type": "IriTemplateMapping",
"variable": "translations.name",
"property": "translations.name",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[code]",
"property": "code",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[createdAt]",
"property": "createdAt",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "productTaxons.taxon.code",
"property": "productTaxons.taxon.code",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "productTaxons.taxon.code[]",
"property": "productTaxons.taxon.code",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[price]",
"property": "price",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[translation.name]",
"property": "translation",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "localeCode for order[translation.name]",
"property": "localeCode",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "taxon",
"property": null,
"required": false
}
]
}
}

View file

@ -97,6 +97,42 @@ final class ProductsTest extends JsonApiTestCase
);
}
/** @test */
public function it_returns_products_collection_with_only_available_associations(): void
{
$this->loadFixturesFromFile('product/products_with_associations.yaml');
$this->client->request(
method: 'GET',
uri: '/api/v2/shop/products',
server: self::CONTENT_TYPE_HEADER,
);
$this->assertResponse(
$this->client->getResponse(),
'shop/product/get_products_collection_with_associations_response',
Response::HTTP_OK,
);
}
/** @test */
public function it_returns_product_item_with_only_available_associations(): void
{
$this->loadFixturesFromFile('product/products_with_associations.yaml');
$this->client->request(
method: 'GET',
uri: '/api/v2/shop/products/MUG',
server: self::CONTENT_TYPE_HEADER,
);
$this->assertResponse(
$this->client->getResponse(),
'shop/product/get_product_with_default_locale_translation',
Response::HTTP_OK,
);
}
/** @test */
public function it_returns_product_attributes_collection_with_translations_in_locale_from_header(): void
{