diff --git a/.php_cs.dist b/.php_cs.dist index 4416b378429..c14bee5ca39 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -13,7 +13,7 @@ HEADER; $finder = PhpCsFixer\Finder::create() ->in(__DIR__) - ->exclude('tests/Fixtures/app/cache') + ->exclude('tests/Fixtures/app/var/cache') ; return PhpCsFixer\Config::create() diff --git a/src/Bridge/Elasticsearch/DataProvider/CollectionDataProvider.php b/src/Bridge/Elasticsearch/DataProvider/CollectionDataProvider.php index c01b12f52ea..7686fe04575 100644 --- a/src/Bridge/Elasticsearch/DataProvider/CollectionDataProvider.php +++ b/src/Bridge/Elasticsearch/DataProvider/CollectionDataProvider.php @@ -32,7 +32,7 @@ final class CollectionDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface { private $client; - private $indexMetadataFactory; + private $documentMetadataFactory; private $denormalizer; private $pagination; private $collectionExtensions; @@ -40,10 +40,10 @@ final class CollectionDataProvider implements ContextAwareCollectionDataProvider /** * @param FullBodySearchCollectionExtensionInterface[] $collectionExtensions */ - public function __construct(Client $client, DocumentMetadataFactoryInterface $indexMetadataFactory, DenormalizerInterface $denormalizer, Pagination $pagination, iterable $collectionExtensions = []) + public function __construct(Client $client, DocumentMetadataFactoryInterface $documentMetadataFactory, DenormalizerInterface $denormalizer, Pagination $pagination, iterable $collectionExtensions = []) { $this->client = $client; - $this->indexMetadataFactory = $indexMetadataFactory; + $this->documentMetadataFactory = $documentMetadataFactory; $this->denormalizer = $denormalizer; $this->pagination = $pagination; $this->collectionExtensions = $collectionExtensions; @@ -55,7 +55,7 @@ public function __construct(Client $client, DocumentMetadataFactoryInterface $in public function supports(string $resourceClass, ?string $operationName = null, array $context = []): bool { try { - $this->indexMetadataFactory->create($resourceClass); + $this->documentMetadataFactory->create($resourceClass); } catch (IndexNotFoundException $e) { return false; } @@ -68,7 +68,7 @@ public function supports(string $resourceClass, ?string $operationName = null, a */ public function getCollection(string $resourceClass, ?string $operationName = null, array $context = []) { - $indexMetadata = $this->indexMetadataFactory->create($resourceClass); + $documentMetadata = $this->documentMetadataFactory->create($resourceClass); $body = []; foreach ($this->collectionExtensions as $collectionExtension) { @@ -95,8 +95,8 @@ public function getCollection(string $resourceClass, ?string $operationName = nu } $documents = $this->client->search([ - 'index' => $indexMetadata->getIndex(), - 'type' => $indexMetadata->getType(), + 'index' => $documentMetadata->getIndex(), + 'type' => $documentMetadata->getType(), 'body' => $body, ]); diff --git a/src/Bridge/Elasticsearch/DataProvider/Extension/SortExtension.php b/src/Bridge/Elasticsearch/DataProvider/Extension/SortExtension.php index 76186d7873c..149dbb270f7 100644 --- a/src/Bridge/Elasticsearch/DataProvider/Extension/SortExtension.php +++ b/src/Bridge/Elasticsearch/DataProvider/Extension/SortExtension.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Bridge\Elasticsearch\Util\FieldDatatypeTrait; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * Applies selected sorting while querying resource collection. @@ -35,12 +36,14 @@ final class SortExtension implements FullBodySearchCollectionExtensionInterface private $defaultDirection; private $identifierExtractor; private $resourceMetadataFactory; + private $nameConverter; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, IdentifierExtractorInterface $identifierExtractor, PropertyMetadataFactoryInterface $propertyMetadataFactory, ?string $defaultDirection = null) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, IdentifierExtractorInterface $identifierExtractor, PropertyMetadataFactoryInterface $propertyMetadataFactory, ?NameConverterInterface $nameConverter = null, ?string $defaultDirection = null) { $this->resourceMetadataFactory = $resourceMetadataFactory; $this->identifierExtractor = $identifierExtractor; $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->nameConverter = $nameConverter; $this->defaultDirection = $defaultDirection; } @@ -85,9 +88,12 @@ private function getOrder(string $resourceClass, string $property, string $direc $order = ['order' => strtolower($direction)]; if (null !== $nestedPath = $this->getNestedFieldPath($resourceClass, $property)) { + $nestedPath = null === $this->nameConverter ? $nestedPath : $this->nameConverter->normalize($property); $order['nested'] = ['path' => $nestedPath]; } + $property = null === $this->nameConverter ? $property : $this->nameConverter->normalize($property); + return [$property => $order]; } } diff --git a/src/Bridge/Elasticsearch/DataProvider/Filter/AbstractFilter.php b/src/Bridge/Elasticsearch/DataProvider/Filter/AbstractFilter.php index 28d64e59c31..144a4fa46ec 100644 --- a/src/Bridge/Elasticsearch/DataProvider/Filter/AbstractFilter.php +++ b/src/Bridge/Elasticsearch/DataProvider/Filter/AbstractFilter.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * Abstract class with helpers for easing the implementation of a filter. @@ -34,12 +35,14 @@ abstract class AbstractFilter implements FilterInterface protected $properties; protected $propertyNameCollectionFactory; + protected $nameConverter; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?array $properties = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?NameConverterInterface $nameConverter = null, ?array $properties = null) { $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; $this->resourceClassResolver = $resourceClassResolver; + $this->nameConverter = $nameConverter; $this->properties = $properties; } @@ -49,7 +52,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName protected function getProperties(string $resourceClass): \Traversable { if (null !== $this->properties) { - return yield from $this->properties; + return yield from array_keys($this->properties); } try { diff --git a/src/Bridge/Elasticsearch/DataProvider/Filter/OrderFilter.php b/src/Bridge/Elasticsearch/DataProvider/Filter/OrderFilter.php index d2aee5f0bfb..dd80d4a94c1 100644 --- a/src/Bridge/Elasticsearch/DataProvider/Filter/OrderFilter.php +++ b/src/Bridge/Elasticsearch/DataProvider/Filter/OrderFilter.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * Order the collection by given properties. @@ -31,9 +32,9 @@ final class OrderFilter extends AbstractFilter implements SortFilterInterface { private $orderParameterName; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, string $orderParameterName = 'order', ?array $properties = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?NameConverterInterface $nameConverter = null, string $orderParameterName = 'order', ?array $properties = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceClassResolver, $properties); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceClassResolver, $nameConverter, $properties); $this->orderParameterName = $orderParameterName; } @@ -56,7 +57,7 @@ public function apply(array $clauseBody, string $resourceClass, ?string $operati continue; } - if (empty($direction) && null !== $defaultDirection = $this->properties[$property]['default_direction'] ?? null) { + if (empty($direction) && null !== $defaultDirection = $this->properties[$property] ?? null) { $direction = $defaultDirection; } @@ -67,9 +68,11 @@ public function apply(array $clauseBody, string $resourceClass, ?string $operati $order = ['order' => $direction]; if (null !== $nestedPath = $this->getNestedFieldPath($resourceClass, $property)) { + $nestedPath = null === $this->nameConverter ? $nestedPath : $this->nameConverter->normalize($nestedPath); $order['nested'] = ['path' => $nestedPath]; } + $property = null === $this->nameConverter ? $property : $this->nameConverter->normalize($property); $orders[] = [$property => $order]; } diff --git a/src/Bridge/Elasticsearch/DataProvider/Filter/TermFilter.php b/src/Bridge/Elasticsearch/DataProvider/Filter/TermFilter.php index 4e5382421fa..5a3b8378184 100644 --- a/src/Bridge/Elasticsearch/DataProvider/Filter/TermFilter.php +++ b/src/Bridge/Elasticsearch/DataProvider/Filter/TermFilter.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * Filter the collection by given properties. @@ -37,9 +38,9 @@ final class TermFilter extends AbstractFilter implements ConstantScoreFilterInte private $iriConverter; private $propertyAccessor; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, IdentifierExtractorInterface $identifierExtractor, IriConverterInterface $iriConverter, PropertyAccessorInterface $propertyAccessor, ?array $properties = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, IdentifierExtractorInterface $identifierExtractor, IriConverterInterface $iriConverter, PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter = null, ?array $properties = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceClassResolver, $properties); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceClassResolver, $nameConverter, $properties); $this->identifierExtractor = $identifierExtractor; $this->iriConverter = $iriConverter; @@ -68,13 +69,16 @@ public function apply(array $clauseBody, string $resourceClass, ?string $operati continue; } + $convertedProperty = null === $this->nameConverter ? $property : $this->nameConverter->normalize($property); + if (1 === \count($values)) { - $term = ['term' => [$property => $values[0]]]; + $term = ['term' => [$convertedProperty => $values[0]]]; } else { - $term = ['terms' => [$property => $values]]; + $term = ['terms' => [$convertedProperty => $values]]; } if (null !== $nestedPath = $this->getNestedFieldPath($resourceClass, $property)) { + $nestedPath = null === $this->nameConverter ? $nestedPath : $this->nameConverter->normalize($nestedPath); $term = ['nested' => ['path' => $nestedPath, 'query' => $term]]; } diff --git a/src/Bridge/Elasticsearch/DataProvider/ItemDataProvider.php b/src/Bridge/Elasticsearch/DataProvider/ItemDataProvider.php index a67738af553..c420c14baf7 100644 --- a/src/Bridge/Elasticsearch/DataProvider/ItemDataProvider.php +++ b/src/Bridge/Elasticsearch/DataProvider/ItemDataProvider.php @@ -34,13 +34,13 @@ final class ItemDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface { private $client; - private $indexMetadataFactory; + private $documentMetadataFactory; private $denormalizer; - public function __construct(Client $client, DocumentMetadataFactoryInterface $indexMetadataFactory, DenormalizerInterface $denormalizer) + public function __construct(Client $client, DocumentMetadataFactoryInterface $documentMetadataFactory, DenormalizerInterface $denormalizer) { $this->client = $client; - $this->indexMetadataFactory = $indexMetadataFactory; + $this->documentMetadataFactory = $documentMetadataFactory; $this->denormalizer = $denormalizer; } @@ -50,7 +50,7 @@ public function __construct(Client $client, DocumentMetadataFactoryInterface $in public function supports(string $resourceClass, ?string $operationName = null, array $context = []): bool { try { - $this->indexMetadataFactory->create($resourceClass); + $this->documentMetadataFactory->create($resourceClass); } catch (IndexNotFoundException $e) { return false; } @@ -68,15 +68,15 @@ public function getItem(string $resourceClass, $id, ?string $operationName = nul throw new InvalidArgumentException('Composite identifiers not supported.'); } - $id = array_values($id)[0]; + $id = reset($id); } - $indexMetadata = $this->indexMetadataFactory->create($resourceClass); + $documentMetadata = $this->documentMetadataFactory->create($resourceClass); try { $document = $this->client->get([ - 'index' => $indexMetadata->getIndex(), - 'type' => $indexMetadata->getType(), + 'index' => $documentMetadata->getIndex(), + 'type' => $documentMetadata->getType(), 'id' => (string) $id, ]); } catch (Missing404Exception $e) { diff --git a/src/Bridge/Elasticsearch/Metadata/Document/Factory/AttributeDocumentMetadataFactory.php b/src/Bridge/Elasticsearch/Metadata/Document/Factory/AttributeDocumentMetadataFactory.php index d4aea392a15..4897f47e1c3 100644 --- a/src/Bridge/Elasticsearch/Metadata/Document/Factory/AttributeDocumentMetadataFactory.php +++ b/src/Bridge/Elasticsearch/Metadata/Document/Factory/AttributeDocumentMetadataFactory.php @@ -40,35 +40,35 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa */ public function create(string $resourceClass): DocumentMetadata { - $indexMetadata = null; + $documentMetadata = null; if ($this->decorated) { try { - $indexMetadata = $this->decorated->create($resourceClass); + $documentMetadata = $this->decorated->create($resourceClass); } catch (IndexNotFoundException $e) { } } $resourceMetadata = null; - if (!$indexMetadata || null === $indexMetadata->getIndex()) { + if (!$documentMetadata || null === $documentMetadata->getIndex()) { $resourceMetadata = $resourceMetadata ?? $this->resourceMetadataFactory->create($resourceClass); if (null !== $index = $resourceMetadata->getAttribute('elasticsearch_index')) { - $indexMetadata = $indexMetadata ? $indexMetadata->withIndex($index) : new DocumentMetadata($index); + $documentMetadata = $documentMetadata ? $documentMetadata->withIndex($index) : new DocumentMetadata($index); } } - if (!$indexMetadata || DocumentMetadata::DEFAULT_TYPE === $indexMetadata->getType()) { + if (!$documentMetadata || DocumentMetadata::DEFAULT_TYPE === $documentMetadata->getType()) { $resourceMetadata = $resourceMetadata ?? $this->resourceMetadataFactory->create($resourceClass); if (null !== $type = $resourceMetadata->getAttribute('elasticsearch_type')) { - $indexMetadata = $indexMetadata ? $indexMetadata->withType($type) : new DocumentMetadata(null, $type); + $documentMetadata = $documentMetadata ? $documentMetadata->withType($type) : new DocumentMetadata(null, $type); } } - if ($indexMetadata) { - return $indexMetadata; + if ($documentMetadata) { + return $documentMetadata; } throw new IndexNotFoundException(sprintf('No index associated with the "%s" resource class.', $resourceClass)); diff --git a/src/Bridge/Elasticsearch/Metadata/Document/Factory/CachedDocumentMetadataFactory.php b/src/Bridge/Elasticsearch/Metadata/Document/Factory/CachedDocumentMetadataFactory.php index 007085df9bc..42dc584e235 100644 --- a/src/Bridge/Elasticsearch/Metadata/Document/Factory/CachedDocumentMetadataFactory.php +++ b/src/Bridge/Elasticsearch/Metadata/Document/Factory/CachedDocumentMetadataFactory.php @@ -58,23 +58,23 @@ public function create(string $resourceClass): DocumentMetadata return $this->handleNotFound($this->localCache[$resourceClass] = $cacheItem->get(), $resourceClass); } - $indexMetadata = $this->decorated->create($resourceClass); + $documentMetadata = $this->decorated->create($resourceClass); - $cacheItem->set($indexMetadata); + $cacheItem->set($documentMetadata); $this->cacheItemPool->save($cacheItem); - return $this->handleNotFound($this->localCache[$resourceClass] = $indexMetadata, $resourceClass); + return $this->handleNotFound($this->localCache[$resourceClass] = $documentMetadata, $resourceClass); } /** * @throws IndexNotFoundException */ - private function handleNotFound(DocumentMetadata $indexMetadata, string $resourceClass): DocumentMetadata + private function handleNotFound(DocumentMetadata $documentMetadata, string $resourceClass): DocumentMetadata { - if (null === $indexMetadata->getIndex()) { + if (null === $documentMetadata->getIndex()) { throw new IndexNotFoundException(sprintf('No index associated with the "%s" resource class.', $resourceClass)); } - return $indexMetadata; + return $documentMetadata; } } diff --git a/src/Bridge/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php b/src/Bridge/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php index 1f379701a02..7044ce8aee4 100644 --- a/src/Bridge/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php +++ b/src/Bridge/Elasticsearch/Metadata/Document/Factory/CatDocumentMetadataFactory.php @@ -47,17 +47,17 @@ public function __construct(Client $client, ResourceMetadataFactoryInterface $re */ public function create(string $resourceClass): DocumentMetadata { - $indexMetadata = null; + $documentMetadata = null; if ($this->decorated) { try { - $indexMetadata = $this->decorated->create($resourceClass); + $documentMetadata = $this->decorated->create($resourceClass); } catch (IndexNotFoundException $e) { } } - if ($indexMetadata && null !== $indexMetadata->getIndex()) { - return $indexMetadata; + if ($documentMetadata && null !== $documentMetadata->getIndex()) { + return $documentMetadata; } $index = Inflector::tableize($this->resourceMetadataFactory->create($resourceClass)->getShortName()); @@ -65,15 +65,15 @@ public function create(string $resourceClass): DocumentMetadata try { $this->client->cat()->indices(['index' => $index]); } catch (Missing404Exception $e) { - if ($indexMetadata) { - return $indexMetadata; + if ($documentMetadata) { + return $documentMetadata; } throw new IndexNotFoundException(sprintf('No index associated with the "%s" resource class.', $resourceClass)); } - if ($indexMetadata) { - return $indexMetadata->withIndex($index); + if ($documentMetadata) { + return $documentMetadata->withIndex($index); } return new DocumentMetadata($index); diff --git a/src/Bridge/Elasticsearch/Metadata/Document/Factory/ConfiguredDocumentMetadataFactory.php b/src/Bridge/Elasticsearch/Metadata/Document/Factory/ConfiguredDocumentMetadataFactory.php index 6b23ea634b4..ca734f5c8bb 100644 --- a/src/Bridge/Elasticsearch/Metadata/Document/Factory/ConfiguredDocumentMetadataFactory.php +++ b/src/Bridge/Elasticsearch/Metadata/Document/Factory/ConfiguredDocumentMetadataFactory.php @@ -39,33 +39,33 @@ public function __construct(array $mapping, ?DocumentMetadataFactoryInterface $d */ public function create(string $resourceClass): DocumentMetadata { - $indexMetadata = null; + $documentMetadata = null; if ($this->decorated) { try { - $indexMetadata = $this->decorated->create($resourceClass); + $documentMetadata = $this->decorated->create($resourceClass); } catch (IndexNotFoundException $e) { } } if (null === $index = $this->mapping[$resourceClass] ?? null) { - if ($indexMetadata) { - return $indexMetadata; + if ($documentMetadata) { + return $documentMetadata; } throw new IndexNotFoundException(sprintf('No index associated with the "%s" resource class.', $resourceClass)); } - $indexMetadata = $indexMetadata ?? new DocumentMetadata(); + $documentMetadata = $documentMetadata ?? new DocumentMetadata(); if (isset($index['index'])) { - $indexMetadata = $indexMetadata->withIndex($index['index']); + $documentMetadata = $documentMetadata->withIndex($index['index']); } if (isset($index['type'])) { - $indexMetadata = $indexMetadata->withType($index['type']); + $documentMetadata = $documentMetadata->withType($index['type']); } - return $indexMetadata; + return $documentMetadata; } } diff --git a/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php b/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php index 2304c564f70..9ba0039c9ef 100644 --- a/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php @@ -83,10 +83,7 @@ public function normalize($object, $format = null, array $context = []) private function populateIdentifier(array $data, string $class): array { $identifier = $this->identifierExtractor->getIdentifierFromResourceClass($class); - - if (null !== $this->nameConverter) { - $identifier = $this->nameConverter->normalize($identifier); - } + $identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier); if (!isset($data['_source'][$identifier])) { $data['_source'][$identifier] = $data['_id']; diff --git a/src/Bridge/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php b/src/Bridge/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php new file mode 100644 index 00000000000..91c72047bcd --- /dev/null +++ b/src/Bridge/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Elasticsearch\Serializer\NameConverter; + +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +final class InnerFieldsNameConverter implements NameConverterInterface +{ + private $decorated; + + public function __construct(?NameConverterInterface $decorated = null) + { + $this->decorated = $decorated ?? new CamelCaseToSnakeCaseNameConverter(); + } + + /** + * {@inheritdoc} + */ + public function normalize($propertyName) + { + return $this->convertInnerFields($propertyName, true); + } + + /** + * {@inheritdoc} + */ + public function denormalize($propertyName) + { + return $this->convertInnerFields($propertyName, false); + } + + private function convertInnerFields(string $propertyName, bool $normalization) + { + if (null === $this->decorated) { + return $propertyName; + } + + $convertedProperties = []; + + foreach (explode('.', $propertyName) as $decomposedProperty) { + $convertedProperties[] = $this->decorated->{$normalization ? 'normalize' : 'denormalize'}($decomposedProperty); + } + + return implode('.', $convertedProperties); + } +} diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index d75d3ffcb4c..53035652e0f 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -36,6 +36,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Finder\Finder; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -554,16 +555,24 @@ private function registerMercureConfiguration(ContainerBuilder $container, array private function registerElasticsearchConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader) { - if (!$config['elasticsearch']['enabled'] || !class_exists(Client::class)) { + $enabled = $config['elasticsearch']['enabled'] && class_exists(Client::class); + + $container->setParameter('api_platform.elasticsearch.enabled', $enabled); + + if (!$enabled) { return; } $loader->load('elasticsearch.xml'); + if ($container->hasAlias('api_platform.name_converter')) { + $container->getDefinition('api_platform.elasticsearch.name_converter.inner_fields') + ->setArgument(0, new Reference('api_platform.name_converter')); + } + $container->registerForAutoconfiguration(ElasticSearchQueryCollectionExtensionInterface::class) ->addTag('api_platform.elasticsearch.query_extension.collection'); - $container->setParameter('api_platform.elasticsearch.enabled', $config['elasticsearch']['enabled']); $container->setParameter('api_platform.elasticsearch.host', $config['elasticsearch']['host']); $container->setParameter('api_platform.elasticsearch.mapping', $config['elasticsearch']['mapping']); } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index a21e68af7d0..9594398c1c0 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -229,7 +229,11 @@ public function getConfigTreeBuilder() ->arrayNode('mercure') ->{class_exists(Update::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->children() - ->scalarNode('hub_url')->defaultNull()->info('The URL send in the Link HTTP header. If not set, will default to the URL for the Symfony\'s bundle default hub.') + ->scalarNode('hub_url') + ->defaultNull() + ->info('The URL send in the Link HTTP header. If not set, will default to the URL for the Symfony\'s bundle default hub.') + ->end() + ->end() ->end() ->arrayNode('elasticsearch') diff --git a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml index 9cbf35d1923..27e25b05602 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml @@ -50,10 +50,12 @@ + + - + @@ -95,6 +97,7 @@ + %api_platform.collection.order% @@ -107,12 +110,14 @@ + + %api_platform.collection.order_parameter_name% diff --git a/tests/Bridge/Elasticsearch/Api/IdentifierExtractorTest.php b/tests/Bridge/Elasticsearch/Api/IdentifierExtractorTest.php new file mode 100644 index 00000000000..35f39f5e921 --- /dev/null +++ b/tests/Bridge/Elasticsearch/Api/IdentifierExtractorTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\Api; + +use ApiPlatform\Core\Api\IdentifiersExtractorInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractor; +use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\Exception\NonUniqueIdentifierException; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; + +class IdentifierExtractorTest extends TestCase +{ + public function testConstruct() + { + self::assertInstanceOf( + IdentifierExtractorInterface::class, + new IdentifierExtractor($this->prophesize(IdentifiersExtractorInterface::class)->reveal()) + ); + } + + public function testGetIdentifierFromResourceClass() + { + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Dummy::class)->willReturn(['id'])->shouldBeCalledOnce(); + + $identifierExtractor = new IdentifierExtractor($identifiersExtractorProphecy->reveal()); + + self::assertSame('id', $identifierExtractor->getIdentifierFromResourceClass(Dummy::class)); + } + + public function testGetIdentifierFromResourceClassWithCompositeIdentifiers() + { + self::expectException(NonUniqueIdentifierException::class); + self::expectExceptionMessage('Composite identifiers not supported.'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(CompositeRelation::class)->willReturn(['compositeItem', 'compositeLabel'])->shouldBeCalledOnce(); + + $identifierExtractor = new IdentifierExtractor($identifiersExtractorProphecy->reveal()); + $identifierExtractor->getIdentifierFromResourceClass(CompositeRelation::class); + } + + public function testGetIdentifierFromResourceClassWithNoIdentifier() + { + self::expectException(NonUniqueIdentifierException::class); + self::expectExceptionMessage('Resource "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" has no identifiers.'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Dummy::class)->willReturn([])->shouldBeCalledOnce(); + + $identifierExtractor = new IdentifierExtractor($identifiersExtractorProphecy->reveal()); + $identifierExtractor->getIdentifierFromResourceClass(Dummy::class); + } +} diff --git a/tests/Bridge/Elasticsearch/DataProvider/CollectionDataProviderTest.php b/tests/Bridge/Elasticsearch/DataProvider/CollectionDataProviderTest.php new file mode 100644 index 00000000000..b244e6af8e6 --- /dev/null +++ b/tests/Bridge/Elasticsearch/DataProvider/CollectionDataProviderTest.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider; + +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\CollectionDataProvider; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Paginator; +use ApiPlatform\Core\Bridge\Elasticsearch\Exception\IndexNotFoundException; +use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\DocumentMetadata; +use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; +use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; +use ApiPlatform\Core\DataProvider\Pagination; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use Elasticsearch\Client; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class CollectionDataProviderTest extends TestCase +{ + public function testConstruct() + { + self::assertInstanceOf( + CollectionDataProviderInterface::class, + new CollectionDataProvider( + $this->prophesize(Client::class)->reveal(), + $this->prophesize(DocumentMetadataFactoryInterface::class)->reveal(), + $this->prophesize(DenormalizerInterface::class)->reveal(), + new Pagination(new RequestStack(), $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal()) + ) + ); + } + + public function testSupports() + { + $documentMetadataFactoryProphecy = $this->prophesize(DocumentMetadataFactoryInterface::class); + $documentMetadataFactoryProphecy->create(Foo::class)->willReturn(new DocumentMetadata('foo'))->shouldBeCalled(); + $documentMetadataFactoryProphecy->create(Dummy::class)->willThrow(new IndexNotFoundException())->shouldBeCalled(); + + $collectionDataProvider = new CollectionDataProvider( + $this->prophesize(Client::class)->reveal(), + $documentMetadataFactoryProphecy->reveal(), + $this->prophesize(DenormalizerInterface::class)->reveal(), + new Pagination(new RequestStack(), $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal()) + ); + + self::assertTrue($collectionDataProvider->supports(Foo::class)); + self::assertFalse($collectionDataProvider->supports(Dummy::class)); + } + + public function testGetCollection() + { + $documentMetadataFactoryProphecy = $this->prophesize(DocumentMetadataFactoryInterface::class); + $documentMetadataFactoryProphecy->create(Foo::class)->willReturn(new DocumentMetadata('foo'))->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata()); + + $documents = [ + 'took' => 15, + 'time_out' => false, + '_shards' => [ + 'total' => 5, + 'successful' => 5, + 'skipped' => 0, + 'failed' => 0, + ], + 'hits' => [ + 'total' => 4, + 'max_score' => 1, + 'hits' => [ + [ + '_index' => 'foo', + '_type' => '_doc', + '_id' => '1', + '_score' => 1, + '_source' => [ + 'id' => 1, + 'name' => 'Kilian', + 'bar' => 'Jornet', + ], + ], + [ + '_index' => 'foo', + '_type' => '_doc', + '_id' => '2', + '_score' => 1, + '_source' => [ + 'id' => 2, + 'name' => 'François', + 'bar' => 'D\'Haene', + ], + ], + ], + ], + ]; + + $clientProphecy = $this->prophesize(Client::class); + $clientProphecy + ->search( + Argument::allOf( + Argument::withEntry('index', 'foo'), + Argument::withEntry('type', DocumentMetadata::DEFAULT_TYPE), + Argument::withEntry('body', Argument::allOf( + Argument::withEntry('size', 2), + Argument::withEntry('from', 0), + Argument::withEntry('query', Argument::allOf( + Argument::withEntry('match_all', Argument::type(\stdClass::class)), + Argument::size(1) + )), + Argument::size(3) + )), + Argument::size(3) + ) + ) + ->willReturn($documents) + ->shouldBeCalled(); + + $collectionDataProvider = new CollectionDataProvider( + $clientProphecy->reveal(), + $documentMetadataFactoryProphecy->reveal(), + $denormalizer = $this->prophesize(DenormalizerInterface::class)->reveal(), + new Pagination(new RequestStack(), $resourceMetadataFactoryProphecy->reveal(), ['items_per_page' => 2]) + ); + + self::assertEquals( + new Paginator($denormalizer, $documents, Foo::class, 2, 0), + $collectionDataProvider->getCollection(Foo::class) + ); + } +} diff --git a/tests/Bridge/Elasticsearch/DataProvider/Extension/ConstantScoreFilterExtensionTest.php b/tests/Bridge/Elasticsearch/DataProvider/Extension/ConstantScoreFilterExtensionTest.php new file mode 100644 index 00000000000..eb571fe6ea4 --- /dev/null +++ b/tests/Bridge/Elasticsearch/DataProvider/Extension/ConstantScoreFilterExtensionTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider\Extension; + +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\ConstantScoreFilterExtension; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\FullBodySearchCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Filter\ConstantScoreFilterInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Filter\SortFilterInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +class ConstantScoreFilterExtensionTest extends TestCase +{ + public function testConstruct() + { + self::assertInstanceOf( + FullBodySearchCollectionExtensionInterface::class, + new ConstantScoreFilterExtension( + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + $this->prophesize(ContainerInterface::class)->reveal() + ) + ); + } + + public function testApplyToCollection() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata(null, null, null, null, ['get' => ['filters' => ['filter.term']]]))->shouldBeCalled(); + + $constantScoreFilterProphecy = $this->prophesize(ConstantScoreFilterInterface::class); + $constantScoreFilterProphecy->apply([], Foo::class, 'get', ['filters' => ['name' => ['Kilian', 'Xavier', 'François']]])->willReturn(['bool' => ['must' => [['terms' => ['name' => ['Kilian', 'Xavier', 'François']]]]]])->shouldBeCalled(); + + $containerProphecy = $this->prophesize(ContainerInterface::class); + $containerProphecy->has('filter.term')->willReturn(true)->shouldBeCalled(); + $containerProphecy->get('filter.term')->willReturn($constantScoreFilterProphecy->reveal())->shouldBeCalled(); + + $constantScoreFilterExtension = new ConstantScoreFilterExtension($resourceMetadataFactoryProphecy->reveal(), $containerProphecy->reveal()); + + self::assertSame(['query' => ['constant_score' => ['filter' => ['bool' => ['must' => [['terms' => ['name' => ['Kilian', 'Xavier', 'François']]]]]]]]], $constantScoreFilterExtension->applyToCollection([], Foo::class, 'get', ['filters' => ['name' => ['Kilian', 'Xavier', 'François']]])); + } + + public function testApplyToCollectionWithNoFilters() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata()); + + $constantScoreFilterExtension = new ConstantScoreFilterExtension($resourceMetadataFactoryProphecy->reveal(), $this->prophesize(ContainerInterface::class)->reveal()); + + self::assertEmpty($constantScoreFilterExtension->applyToCollection([], Foo::class)); + } + + public function testApplyToCollectionWithNoConstantScoreFilters() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata(null, null, null, null, ['get' => ['filters' => ['filter.order']]]))->shouldBeCalled(); + + $containerProphecy = $this->prophesize(ContainerInterface::class); + $containerProphecy->has('filter.order')->willReturn(true)->shouldBeCalled(); + $containerProphecy->get('filter.order')->willReturn($this->prophesize(SortFilterInterface::class)->reveal())->shouldBeCalled(); + + $constantScoreFilterExtension = new ConstantScoreFilterExtension($resourceMetadataFactoryProphecy->reveal(), $containerProphecy->reveal()); + + self::assertEmpty($constantScoreFilterExtension->applyToCollection([], Foo::class, 'get', ['filters' => ['name' => ['Kilian', 'Xavier', 'François']]])); + } +} diff --git a/tests/Bridge/Elasticsearch/DataProvider/Extension/SortExtensionTest.php b/tests/Bridge/Elasticsearch/DataProvider/Extension/SortExtensionTest.php new file mode 100644 index 00000000000..5694f68e2ab --- /dev/null +++ b/tests/Bridge/Elasticsearch/DataProvider/Extension/SortExtensionTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider\Extension; + +use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\FullBodySearchCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\SortExtension; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +class SortExtensionTest extends TestCase +{ + public function testConstruct() + { + self::assertInstanceOf( + FullBodySearchCollectionExtensionInterface::class, + new SortExtension( + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + $this->prophesize(IdentifierExtractorInterface::class)->reveal(), + $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), + $this->prophesize(NameConverterInterface::class)->reveal(), + 'asc' + ) + ); + } + + public function testApplyToCollection() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['order' => ['name', 'bar' => 'desc']]))->shouldBeCalled(); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Foo::class, 'name')->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)))->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Foo::class, 'bar')->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)))->shouldBeCalled(); + + $nameConverterProphecy = $this->prophesize(NameConverterInterface::class); + $nameConverterProphecy->normalize('name')->willReturn('name')->shouldBeCalled(); + $nameConverterProphecy->normalize('bar')->willReturn('bar')->shouldBeCalled(); + + $sortExtension = new SortExtension($resourceMetadataFactoryProphecy->reveal(), $this->prophesize(IdentifierExtractorInterface::class)->reveal(), $propertyMetadataFactoryProphecy->reveal(), $nameConverterProphecy->reveal(), 'asc'); + + self::assertSame(['sort' => [['name' => ['order' => 'asc']], ['bar' => ['order' => 'desc']]]], $sortExtension->applyToCollection([], Foo::class)); + } + + public function testApplyToCollectionWithDefaultDirection() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata())->shouldBeCalled(); + + $identifierExtractorProphecy = $this->prophesize(IdentifierExtractorInterface::class); + $identifierExtractorProphecy->getIdentifierFromResourceClass(Foo::class)->willReturn('id')->shouldBeCalled(); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Foo::class, 'id')->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)))->shouldBeCalled(); + + $nameConverterProphecy = $this->prophesize(NameConverterInterface::class); + $nameConverterProphecy->normalize('id')->willReturn('id')->shouldBeCalled(); + + $sortExtension = new SortExtension($resourceMetadataFactoryProphecy->reveal(), $identifierExtractorProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $nameConverterProphecy->reveal(), 'asc'); + + self::assertSame(['sort' => [['id' => ['order' => 'asc']]]], $sortExtension->applyToCollection([], Foo::class)); + } + + public function testApplyToCollectionWithNoOrdering() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata())->shouldBeCalled(); + + $sortExtension = new SortExtension($resourceMetadataFactoryProphecy->reveal(), $this->prophesize(IdentifierExtractorInterface::class)->reveal(), $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), $this->prophesize(NameConverterInterface::class)->reveal()); + + self::assertEmpty($sortExtension->applyToCollection([], Foo::class)); + } +} diff --git a/tests/Bridge/Elasticsearch/DataProvider/Extension/SortFilterExtensionTest.php b/tests/Bridge/Elasticsearch/DataProvider/Extension/SortFilterExtensionTest.php new file mode 100644 index 00000000000..66fea21d9d5 --- /dev/null +++ b/tests/Bridge/Elasticsearch/DataProvider/Extension/SortFilterExtensionTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider\Extension; + +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\FullBodySearchCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\SortFilterExtension; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Filter\ConstantScoreFilterInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Filter\SortFilterInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +class SortFilterExtensionTest extends TestCase +{ + public function testConstruct() + { + self::assertInstanceOf( + FullBodySearchCollectionExtensionInterface::class, + new SortFilterExtension( + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + $this->prophesize(ContainerInterface::class)->reveal() + ) + ); + } + + public function testApplyToCollection() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata(null, null, null, null, ['get' => ['filters' => ['filter.order']]]))->shouldBeCalled(); + + $sortFilterProphecy = $this->prophesize(SortFilterInterface::class); + $sortFilterProphecy->apply([], Foo::class, 'get', ['filters' => ['order' => ['id' => 'desc']]])->willReturn([['id' => ['order' => 'desc']]])->shouldBeCalled(); + + $containerProphecy = $this->prophesize(ContainerInterface::class); + $containerProphecy->has('filter.order')->willReturn(true)->shouldBeCalled(); + $containerProphecy->get('filter.order')->willReturn($sortFilterProphecy->reveal())->shouldBeCalled(); + + $sortFilterExtension = new SortFilterExtension($resourceMetadataFactoryProphecy->reveal(), $containerProphecy->reveal()); + + self::assertSame(['sort' => [['id' => ['order' => 'desc']]]], $sortFilterExtension->applyToCollection([], Foo::class, 'get', ['filters' => ['order' => ['id' => 'desc']]])); + } + + public function testApplyToCollectionWithNoFilters() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata()); + + $sortFilterExtension = new SortFilterExtension($resourceMetadataFactoryProphecy->reveal(), $this->prophesize(ContainerInterface::class)->reveal()); + + self::assertEmpty($sortFilterExtension->applyToCollection([], Foo::class)); + } + + public function testApplyToCollectionWithNoSortFilters() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata(null, null, null, null, ['get' => ['filters' => ['filter.term']]]))->shouldBeCalled(); + + $containerProphecy = $this->prophesize(ContainerInterface::class); + $containerProphecy->has('filter.term')->willReturn(true)->shouldBeCalled(); + $containerProphecy->get('filter.term')->willReturn($this->prophesize(ConstantScoreFilterInterface::class)->reveal())->shouldBeCalled(); + + $sortFilterExtension = new SortFilterExtension($resourceMetadataFactoryProphecy->reveal(), $containerProphecy->reveal()); + + self::assertEmpty($sortFilterExtension->applyToCollection([], Foo::class, 'get', ['filters' => ['order' => ['id' => 'desc']]])); + } +} diff --git a/tests/Bridge/Elasticsearch/DataProvider/Filter/OrderFilterTest.php b/tests/Bridge/Elasticsearch/DataProvider/Filter/OrderFilterTest.php new file mode 100644 index 00000000000..d4f6c4dee54 --- /dev/null +++ b/tests/Bridge/Elasticsearch/DataProvider/Filter/OrderFilterTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider\Filter; + +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Filter\OrderFilter; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Filter\SortFilterInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +class OrderFilterTest extends TestCase +{ + public function testConstruct() + { + self::assertInstanceOf( + SortFilterInterface::class, + new OrderFilter( + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), + $this->prophesize(ResourceClassResolverInterface::class)->reveal(), + $this->prophesize(NameConverterInterface::class)->reveal() + ) + ); + } + + public function testApply() + { + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Foo::class, 'name')->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)))->shouldBeCalled(); + + $nameConverterProphecy = $this->prophesize(NameConverterInterface::class); + $nameConverterProphecy->normalize('name')->willReturn('name')->shouldBeCalled(); + + $orderFilter = new OrderFilter( + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $this->prophesize(ResourceClassResolverInterface::class)->reveal(), + $nameConverterProphecy->reveal(), + 'order', + ['name' => 'asc'] + ); + + self::assertSame( + [['name' => ['order' => 'asc']]], + $orderFilter->apply([], Foo::class, null, ['filters' => ['order' => ['name' => null]]]) + ); + } + + public function testDescription() + { + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Foo::class, 'name')->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)))->shouldBeCalled(); + + $orderFilter = new OrderFilter( + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $this->prophesize(ResourceClassResolverInterface::class)->reveal(), + $this->prophesize(NameConverterInterface::class)->reveal(), + 'order', + ['name' => 'asc'] + ); + + self::assertSame(['order[name]' => ['property' => 'name', 'type' => 'string', 'required' => false]], $orderFilter->getDescription(Foo::class)); + } +} diff --git a/tests/Bridge/Elasticsearch/DataProvider/ItemDataProviderTest.php b/tests/Bridge/Elasticsearch/DataProvider/ItemDataProviderTest.php new file mode 100644 index 00000000000..175284c2571 --- /dev/null +++ b/tests/Bridge/Elasticsearch/DataProvider/ItemDataProviderTest.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider; + +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\ItemDataProvider; +use ApiPlatform\Core\Bridge\Elasticsearch\Exception\IndexNotFoundException; +use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\DocumentMetadata; +use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; +use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use Elasticsearch\Client; +use Elasticsearch\Common\Exceptions\Missing404Exception; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class ItemDataProviderTest extends TestCase +{ + public function testConstruct() + { + self::assertInstanceOf( + ItemDataProviderInterface::class, + new ItemDataProvider( + $this->prophesize(Client::class)->reveal(), + $this->prophesize(DocumentMetadataFactoryInterface::class)->reveal(), + $this->prophesize(DenormalizerInterface::class)->reveal() + ) + ); + } + + public function testSupports() + { + $documentMetadataFactoryProphecy = $this->prophesize(DocumentMetadataFactoryInterface::class); + $documentMetadataFactoryProphecy->create(Foo::class)->willReturn(new DocumentMetadata('foo'))->shouldBeCalled(); + $documentMetadataFactoryProphecy->create(Dummy::class)->willThrow(new IndexNotFoundException())->shouldBeCalled(); + + $itemDataProvider = new ItemDataProvider( + $this->prophesize(Client::class)->reveal(), + $documentMetadataFactoryProphecy->reveal(), + $this->prophesize(DenormalizerInterface::class)->reveal() + ); + + self::assertTrue($itemDataProvider->supports(Foo::class)); + self::assertFalse($itemDataProvider->supports(Dummy::class)); + } + + public function testGetItem() + { + $documentMetadataFactoryProphecy = $this->prophesize(DocumentMetadataFactoryInterface::class); + $documentMetadataFactoryProphecy->create(Foo::class)->willReturn(new DocumentMetadata('foo'))->shouldBeCalled(); + + $document = [ + '_index' => 'test', + '_type' => '_doc', + '_id' => '1', + '_version' => 1, + 'found' => true, + '_source' => [ + 'id' => 1, + 'name' => 'Rossinière', + 'bar' => 'erèinissor', + ], + ]; + + $foo = new Foo(); + $foo->setName('Rossinière'); + $foo->setBar('erèinissor'); + + $clientProphecy = $this->prophesize(Client::class); + $clientProphecy->get(['index' => 'foo', 'type' => DocumentMetadata::DEFAULT_TYPE, 'id' => '1'])->willReturn($document)->shouldBeCalled(); + + $denormalizerProphecy = $this->prophesize(DenormalizerInterface::class); + $denormalizerProphecy->denormalize($document, Foo::class, ItemNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true])->willReturn($foo)->shouldBeCalled(); + + $itemDataProvider = new ItemDataProvider($clientProphecy->reveal(), $documentMetadataFactoryProphecy->reveal(), $denormalizerProphecy->reveal()); + + self::assertSame($foo, $itemDataProvider->getItem(Foo::class, ['id' => 1])); + } + + public function testGetItemWithMissing404Exception() + { + $documentMetadataFactoryProphecy = $this->prophesize(DocumentMetadataFactoryInterface::class); + $documentMetadataFactoryProphecy->create(Foo::class)->willReturn(new DocumentMetadata('foo'))->shouldBeCalled(); + + $clientProphecy = $this->prophesize(Client::class); + $clientProphecy->get(['index' => 'foo', 'type' => DocumentMetadata::DEFAULT_TYPE, 'id' => '404'])->willThrow(new Missing404Exception())->shouldBeCalled(); + + $itemDataProvider = new ItemDataProvider($clientProphecy->reveal(), $documentMetadataFactoryProphecy->reveal(), $this->prophesize(DenormalizerInterface::class)->reveal()); + + self::assertNull($itemDataProvider->getItem(Foo::class, ['id' => 404])); + } + + public function testGetItemWithCompositeIdentifiers() + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('Composite identifiers not supported.'); + + $itemDataProvider = new ItemDataProvider($this->prophesize(Client::class)->reveal(), $this->prophesize(DocumentMetadataFactoryInterface::class)->reveal(), $this->prophesize(DenormalizerInterface::class)->reveal()); + $itemDataProvider->getItem(CompositeRelation::class, ['compositeItem' => 1, 'compositeLabel' => 2]); + } +} diff --git a/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php b/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php new file mode 100644 index 00000000000..369dce6cca5 --- /dev/null +++ b/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider; + +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Paginator; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; +use ApiPlatform\Core\DataProvider\PaginatorInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class PaginatorTest extends TestCase +{ + protected const DOCUMENTS = [ + 'hits' => [ + 'total' => 8, + 'max_score' => 1, + 'hits' => [ + [ + '_index' => 'foo', + '_type' => '_doc', + '_id' => '5', + '_score' => 1, + '_source' => [ + 'id' => 5, + 'name' => 'Fribourg', + 'bar' => 'gruobirf', + ], + ], + [ + '_index' => 'foo', + '_type' => '_doc', + '_id' => '6', + '_score' => 1, + '_source' => [ + 'id' => 6, + 'name' => 'Lausanne', + 'bar' => 'ennasual', + ], + ], + [ + '_index' => 'foo', + '_type' => '_doc', + '_id' => '7', + '_score' => 1, + '_source' => [ + 'id' => 7, + 'name' => 'Vallorbe', + 'bar' => 'ebrollav', + ], + ], + [ + '_index' => 'foo', + '_type' => '_doc', + '_id' => '8', + '_score' => 1, + '_source' => [ + 'id' => 8, + 'name' => 'Lugano', + 'bar' => 'onagul', + ], + ], + ], + ], + ]; + + /** + * @var PaginatorInterface + */ + protected $paginator; + + public function testConstruct() + { + self::assertInstanceOf(PaginatorInterface::class, $this->paginator); + } + + public function testCount() + { + self::assertCount(4, $this->paginator); + } + + public function testGetLastPage() + { + self::assertSame(2., $this->paginator->getLastPage()); + } + + public function testGetLastPageWithZeroAsLimit() + { + self::assertSame(1., $this->getPaginator(0, 0)->getLastPage()); + } + + public function testGetLastPageWithNegativeLimit() + { + self::assertSame(1., $this->getPaginator(-1, 0)->getLastPage()); + } + + public function testGetTotalItems() + { + self::assertSame(8., $this->paginator->getTotalItems()); + } + + public function testGetCurrentPage() + { + self::assertSame(2., $this->paginator->getCurrentPage()); + } + + public function testGetCurrentPageWithZeroAsLimit() + { + self::assertSame(1., $this->getPaginator(0, 0)->getCurrentPage()); + } + + public function testGetCurrentPageWithNegativeLimit() + { + self::assertSame(1., $this->getPaginator(-1, 0)->getCurrentPage()); + } + + public function testGetItemsPerPage() + { + self::assertSame(4., $this->paginator->getItemsPerPage()); + } + + public function testGetIterator() + { + self::assertEquals( + array_map( + function (array $document): Foo { + return $this->denormalizeFoo($document['_source']); + }, + static::DOCUMENTS['hits']['hits'] + ), + iterator_to_array($this->paginator) + ); + } + + protected function setUp() + { + $this->paginator = $this->getPaginator(4, 4); + } + + protected function getPaginator(int $limit, int $offset) + { + $denormalizerProphecy = $this->prophesize(DenormalizerInterface::class); + + foreach (static::DOCUMENTS['hits']['hits'] as $document) { + $denormalizerProphecy + ->denormalize($document, Foo::class, ItemNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true]) + ->willReturn($this->denormalizeFoo($document['_source'])); + } + + return new Paginator($denormalizerProphecy->reveal(), self::DOCUMENTS, Foo::class, $limit, $offset); + } + + protected function denormalizeFoo(array $fooDocument): Foo + { + $foo = new Foo(); + $foo->setName($fooDocument['name']); + $foo->setBar($fooDocument['bar']); + + return $foo; + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index ef78e545fae..61f0fa3e5d9 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -680,6 +680,7 @@ private function getBaseContainerBuilderProphecy() 'api_platform.graphql.graphiql.enabled' => true, 'api_platform.resource_class_directories' => Argument::type('array'), 'api_platform.validator.serialize_payload_fields' => [], + 'api_platform.elasticsearch.enabled' => false, ]; foreach ($parameters as $key => $value) {