Skip to content
This repository was archived by the owner on Oct 8, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions doc/swagger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,17 @@ paths:
required: false
type: "integer"
example: 2
- name: "filters"
in: "query"
required: false
description: "List of filters."
type: "object"
properties:
type:
$ref: "#/definitions/Filter"
example:
boolean:
enabled: "true"
responses:
200:
description: "Paginated product list."
Expand Down Expand Up @@ -562,6 +573,17 @@ paths:
required: false
type: "integer"
example: 2
- name: "filters"
in: "query"
required: false
description: "List of filters."
type: "object"
properties:
type:
$ref: "#/definitions/Filter"
example:
boolean:
enabled: "true"
responses:
200:
description: "Paginated product list."
Expand Down Expand Up @@ -1431,3 +1453,12 @@ definitions:
items:
type: "string"
example: "sylius.shop_api.additionalProp.not_null"
Filter:
type: "object"
description: "Resource property name to filter to and the value to apply."
properties:
property:
type: "string"
description: "propertyName"
additionalProperties:
type: "string"
38 changes: 38 additions & 0 deletions spec/FilterExtension/FilterExtensionSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace spec\Sylius\ShopApiPlugin\FilterExtension;

use Doctrine\ORM\QueryBuilder;
use PhpSpec\ObjectBehavior;
use Sylius\Component\Core\Model\Product;
use Sylius\ShopApiPlugin\FilterExtension\FilterExtension;
use Sylius\ShopApiPlugin\FilterExtension\Filters\FilterInterface;

final class FilterExtensionSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->shouldHaveType(FilterExtension::class);
}

function it_should_apply_filters(FilterInterface $filter, QueryBuilder $queryBuilder)
{
$ressourceClass = Product::class;
$filterConditions = ['boolean'=>['attribute'=>true]];

$filter->applyFilter($filterConditions, $ressourceClass, $queryBuilder)->shouldBeCalled();
$this->addFilter($filter);
$this->applyFilters($queryBuilder, $ressourceClass, $filterConditions);
}

function it_should_not_apply_anything(FilterInterface $filter, QueryBuilder $queryBuilder)
{
$ressourceClass = Product::class;
$filterConditions = ['boolean'=>['attribute'=>true]];

$filter->applyFilter($filterConditions, $ressourceClass, $queryBuilder)->shouldNotBeCalled();
$this->applyFilters($queryBuilder, $ressourceClass, $filterConditions);
}
}
83 changes: 83 additions & 0 deletions spec/FilterExtension/Filters/BooleanFilterSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace spec\Sylius\ShopApiPlugin\FilterExtension\Filters;

use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
use PhpSpec\ObjectBehavior;
use Psr\Log\LoggerInterface;
use Sylius\Component\Core\Model\Product;
use Sylius\ShopApiPlugin\FilterExtension\Filters\BooleanFilter;

final class BooleanFilterSpec extends ObjectBehavior
{
function let(ManagerRegistry $managerRegistry, LoggerInterface $logger)
{
$this->beConstructedWith($managerRegistry, $logger);
}

function it_is_initializable()
{
$this->shouldHaveType(BooleanFilter::class);
}

function it_should_ignore_a_non_boolean_condition(QueryBuilder $queryBuilder)
{
$conditions = ['search'=>['enabled'=>true]];
$resourceClass = Product::class;

$queryBuilder->getRootAliases()->shouldNotBeCalled();

$this->applyFilter($conditions, $resourceClass, $queryBuilder);
}

function it_should_apply_a_boolean_condition(ManagerRegistry $managerRegistry, ObjectManager $om, ClassMetadata $classMetadata, LoggerInterface $logger, QueryBuilder $queryBuilder)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more readable if we use full name instead of abbreviation for 'om'

{
$conditions = ['boolean'=>['enabled'=>true]];
$resourceClass = Product::class;


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redundant blank line

// is property an association false
$classMetadata->hasAssociation('enabled')->willReturn(false);
// yes, this is a boolean
$classMetadata->getTypeOfField('enabled')->willReturn('boolean');
// is property mapped true
$classMetadata->hasField('enabled')->willReturn(true);

$om->getClassMetadata($resourceClass)->willReturn($classMetadata);
$managerRegistry->getManagerForClass($resourceClass)->willReturn($om);

$this->beConstructedWith($managerRegistry, $logger);
$queryBuilder->getRootAliases()->shouldBeCalled();
$queryBuilder->andWhere(".enabled = :enabled_p1")->shouldBeCalled()->willReturn($queryBuilder);
$queryBuilder->setParameter("enabled_p1", true)->shouldBeCalled();

$this->applyFilter($conditions, $resourceClass, $queryBuilder);
}

function it_should_ignore_an_invalid_condition(ManagerRegistry $managerRegistry, ObjectManager $om, ClassMetadata $classMetadata, LoggerInterface $logger, QueryBuilder $queryBuilder)
{
$conditions = ['boolean'=>['enabled'=>'invalid']];
$resourceClass = Product::class;


// is property an association false
$classMetadata->hasAssociation('enabled')->willReturn(false);
// yes, this is a boolean
$classMetadata->getTypeOfField('enabled')->willReturn('boolean');
// is property mapped true
$classMetadata->hasField('enabled')->willReturn(true);

$om->getClassMetadata($resourceClass)->willReturn($classMetadata);
$managerRegistry->getManagerForClass($resourceClass)->willReturn($om);

$this->beConstructedWith($managerRegistry, $logger);
$queryBuilder->getRootAliases()->shouldNotBeCalled();

$this->applyFilter($conditions, $resourceClass, $queryBuilder);
}
}
105 changes: 105 additions & 0 deletions spec/FilterExtension/Filters/SearchFilterSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace spec\Sylius\ShopApiPlugin\FilterExtension\Filters;

use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
use PhpSpec\ObjectBehavior;
use Psr\Log\LoggerInterface;
use Sylius\Component\Core\Model\Product;
use Sylius\Component\Resource\Model\TranslationInterface;
use Sylius\ShopApiPlugin\FilterExtension\Filters\SearchFilter;

final class SearchFilterSpec extends ObjectBehavior
{
function let(ManagerRegistry $managerRegistry, LoggerInterface $logger)
{
$this->beConstructedWith($managerRegistry, $logger);
}

function it_is_initializable()
{
$this->shouldHaveType(SearchFilter::class);
}

function it_should_ignore_a_non_search_condition(QueryBuilder $queryBuilder)
{
$conditions = ['boolean'=>['translations.name'=>['exact'=>'Banane']]];
$resourceClass = Product::class;

$queryBuilder->getRootAliases()->shouldNotBeCalled();

$this->applyFilter($conditions, $resourceClass, $queryBuilder);
}

function it_should_apply_a_search_condition(ManagerRegistry $managerRegistry, ObjectManager $om, ClassMetadata $classMetadata, LoggerInterface $logger, QueryBuilder $queryBuilder)
{
$conditions = ['search'=>['translations.name'=>['exact'=>'Banane']]];
$resourceClass = Product::class;

// is property an association true
$classMetadata->hasAssociation('translations')->willReturn(true);
$classMetadata->hasAssociation('name')->willReturn(false);

$classMetadata->getAssociationTargetClass('translations')->willReturn(TranslationInterface::class);

// yes, this is a string
$classMetadata->getTypeOfField('translations.name')->willReturn('string');

// is property mapped true
$classMetadata->hasField('translations.name')->willReturn(true);
$classMetadata->hasField('name')->willReturn(true);
$classMetadata->getTypeOfField('name')->willReturn('string');

$om->getClassMetadata($resourceClass)->willReturn($classMetadata);
$om->getClassMetadata(TranslationInterface::class)->willReturn($classMetadata);

$managerRegistry->getManagerForClass(TranslationInterface::class)->willReturn($om);
$managerRegistry->getManagerForClass($resourceClass)->willReturn($om);

$this->beConstructedWith($managerRegistry, $logger);
$queryBuilder->getRootAliases()->shouldBeCalled()->willReturn('o');
$queryBuilder->getDQLPart('join')->shouldBeCalled()->willReturn([]);
$queryBuilder->innerJoin('o.translations', 'translations_a1', null, null)->shouldBeCalled();
$queryBuilder->andWhere('translations_a1.name = :name_p1')->shouldBeCalled()->willReturn($queryBuilder);
$queryBuilder->setParameter("name_p1", "Banane")->shouldBeCalled()->willReturn($queryBuilder);

$this->applyFilter($conditions, $resourceClass, $queryBuilder);
}

function it_should_ignore_an_invalid_condition(ManagerRegistry $managerRegistry, ObjectManager $om, ClassMetadata $classMetadata, LoggerInterface $logger, QueryBuilder $queryBuilder)
{
$conditions = ['search'=>['translations.name'=>['invalid'=>[]]]];
$resourceClass = Product::class;

// is property an association true
$classMetadata->hasAssociation('translations')->willReturn(true);
$classMetadata->hasAssociation('name')->willReturn(false);

$classMetadata->getAssociationTargetClass('translations')->willReturn(TranslationInterface::class);

// yes, this is a string
$classMetadata->getTypeOfField('translations.name')->willReturn('string');

// is property mapped true
$classMetadata->hasField('translations.name')->willReturn(true);
$classMetadata->hasField('name')->willReturn(true);

$om->getClassMetadata($resourceClass)->willReturn($classMetadata);
$om->getClassMetadata(TranslationInterface::class)->willReturn($classMetadata);

$managerRegistry->getManagerForClass(TranslationInterface::class)->willReturn($om);
$managerRegistry->getManagerForClass($resourceClass)->willReturn($om);

$this->beConstructedWith($managerRegistry, $logger);
$queryBuilder->getRootAliases()->shouldBeCalled()->willReturn('o');
$queryBuilder->getDQLPart('join')->shouldBeCalled()->willReturn([]);
$queryBuilder->innerJoin("o.translations", "translations_a1", null, null)->shouldBeCalled();

$this->applyFilter($conditions, $resourceClass, $queryBuilder);
}
}
1 change: 1 addition & 0 deletions src/Controller/Product/ShowLatestProductAction.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

declare(strict_types=1);

namespace Sylius\ShopApiPlugin\Controller\Product;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public function __invoke(Request $request): Response
$request->attributes->get('code'),
$request->query->get('channel'),
new PaginatorDetails($request->attributes->get('_route'), $request->query->all()),
$request->query->get('locale')
$request->query->get('locale'),
$request->query->get('filters')
), Response::HTTP_OK));
} catch (\InvalidArgumentException $exception) {
throw new NotFoundHttpException($exception->getMessage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public function __invoke(Request $request): Response
$request->attributes->get('taxonSlug'),
$request->query->get('channel'),
new PaginatorDetails($request->attributes->get('_route'), $request->query->all()),
$request->query->get('locale')
$request->query->get('locale'),
$request->query->get('filters')
), Response::HTTP_OK));
} catch (\InvalidArgumentException $exception) {
throw new NotFoundHttpException($exception->getMessage());
Expand Down
31 changes: 31 additions & 0 deletions src/DependencyInjection/Compiler/FiltersDefinitionPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Sylius\ShopApiPlugin\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

/**
* Gets every filter definition and adds them to the Filter Extension.
*
* @author Grégoire Hébert <gregoire@les-tilleuls.coop>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope you don't mind, but we have resigned from author blocks as they are redundant (you will be mentioned in code contributors) and not obvious to maintain.

*/
class FiltersDefinitionPass implements CompilerPassInterface
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing final

{
public function process(ContainerBuilder $container)
{
if (!$container->has('sylius.shop_api_plugin.filters.filter_extension')) {
return;
}

$definition = $container->findDefinition('sylius.shop_api_plugin.filters.filter_extension');
$taggedServices = $container->findTaggedServiceIds('sylius.shop_api_plugin.filter');

foreach ($taggedServices as $id => $tags) {
$definition->addMethodCall('addFilter', [new Reference($id)]);
}
}
}
14 changes: 14 additions & 0 deletions src/Exception/InvalidArgumentException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Sylius\ShopApiPlugin\Exception;

/**
* Invalid argument exception.
*
* @author Grégoire Hébert <gregoire@les-tilleuls.coop>
*/
class InvalidArgumentException extends \InvalidArgumentException
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little bit redundant ;)

{
}
Loading