Skip to content

Commit

Permalink
Elasticsearch support
Browse files Browse the repository at this point in the history
  • Loading branch information
meyerbaptiste committed Jun 5, 2018
1 parent d2250c1 commit 22388ec
Show file tree
Hide file tree
Showing 31 changed files with 2,075 additions and 139 deletions.
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"doctrine/annotations": "^1.2",
"doctrine/doctrine-bundle": "^1.8",
"doctrine/orm": "^2.5.2",
"elasticsearch/elasticsearch": "^6.0",
"friendsofsymfony/user-bundle": "^2.1",
"guzzlehttp/guzzle": "^6.0",
"justinrainbow/json-schema": "^5.0",
Expand Down Expand Up @@ -73,6 +74,7 @@
"symfony/dependency-injection": "<3.4"
},
"suggest": {
"elasticsearch/elasticsearch": "To support Elasticsearch.",
"friendsofsymfony/user-bundle": "To use the FOSUserBundle bridge.",
"guzzlehttp/guzzle": "To use the HTTP cache invalidation system.",
"phpdocumentor/reflection-docblock": "To support extracting metadata from PHPDoc.",
Expand Down
4 changes: 2 additions & 2 deletions src/Bridge/Doctrine/Orm/Extension/FilterExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator
return;
}

$context['filters'] = $context['filters'] ?? [];

foreach ($resourceFilters as $filterId) {
if (!($filter = $this->getFilter($filterId)) instanceof FilterInterface) {
continue;
}

$context['filters'] = $context['filters'] ?? [];

$filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context);
}
}
Expand Down
186 changes: 67 additions & 119 deletions src/Bridge/Doctrine/Orm/Extension/PaginationExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
use ApiPlatform\Core\Bridge\Doctrine\Orm\Paginator;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryChecker;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Core\DataProvider\Pagination;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\Common\Util\Inflector;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface;

/**
* Applies pagination on the Doctrine query for resource collection when enabled.
Expand All @@ -35,36 +36,64 @@
final class PaginationExtension implements ContextAwareQueryResultCollectionExtensionInterface
{
private $managerRegistry;
private $requestStack;
private $resourceMetadataFactory;
private $enabled;
private $clientEnabled;
private $clientItemsPerPage;
private $itemsPerPage;
private $pageParameterName;
private $enabledParameterName;
private $itemsPerPageParameterName;
private $maximumItemPerPage;
private $partial;
private $clientPartial;
private $partialParameterName;

public function __construct(ManagerRegistry $managerRegistry, RequestStack $requestStack, ResourceMetadataFactoryInterface $resourceMetadataFactory, bool $enabled = true, bool $clientEnabled = false, bool $clientItemsPerPage = false, int $itemsPerPage = 30, string $pageParameterName = 'page', string $enabledParameterName = 'pagination', string $itemsPerPageParameterName = 'itemsPerPage', int $maximumItemPerPage = null, bool $partial = false, bool $clientPartial = false, string $partialParameterName = 'partial')
private $pagination;

/**
* @param ResourceMetadataFactoryInterface $resourceMetadataFactory
* @param Pagination $pagination
*/
public function __construct(ManagerRegistry $managerRegistry, /* ResourceMetadataFactoryInterface */ $resourceMetadataFactory, /* Pagination */ $pagination)
{
if ($resourceMetadataFactory instanceof RequestStack && $pagination instanceof ResourceMetadataFactoryInterface) {
@trigger_error(sprintf('Passing an instance of "%s" as second argument of "%s" is deprecated since API Platform 2.3 and will not be possible anymore in API Platform 3. Pass an instance of "%s" instead.', RequestStack::class, self::class, ResourceMetadataFactoryInterface::class), E_USER_DEPRECATED);
@trigger_error(sprintf('Passing an instance of "%s" as third argument of "%s" is deprecated since API Platform 2.3 and will not be possible anymore in API Platform 3. Pass an instance of "%s" instead.', ResourceMetadataFactoryInterface::class, self::class, Pagination::class), E_USER_DEPRECATED);

$requestStack = $resourceMetadataFactory;
$resourceMetadataFactory = $pagination;

if (3 < \count($args = func_get_args())) {
@trigger_error(sprintf('Passing "$enabled", "$clientEnabled", "$clientItemsPerPage", "$itemsPerPage", "$pageParameterName", "$enabledParameterName", "$itemsPerPageParameterName", "$maximumItemPerPage", "$partial", "$clientPartial" and "$partialParameterName" arguments is deprecated since API Platform 2.3 and will not be possible anymore in API Platform 3. Pass an instance of "%s" as third argument instead.', Paginator::class), E_USER_DEPRECATED);
}

$options = [];
$legacyArgs = [
['name' => 'enabled', 'type' => 'bool', 'default' => true],
['name' => 'client_enabled', 'type' => 'bool', 'default' => false],
['name' => 'client_items_per_page', 'type' => 'bool', 'default' => false],
['name' => 'items_per_page', 'type' => 'bool', 'default' => false],
['name' => 'page_parameter_name', 'type' => 'string', 'default' => 'page'],
['name' => 'enabled_parameter_name', 'type' => 'string', 'default' => 'pagination'],
['name' => 'items_per_page_parameter_name', 'type' => 'string', 'default' => 'itemsPerPage'],
['name' => 'maximum_items_per_page', 'type' => 'int', 'default' => null],
['name' => 'partial', 'type' => 'bool', 'default' => false],
['name' => 'client_partial', 'type' => 'bool', 'default' => false],
['name' => 'partial_parameter_name', 'type' => 'string', 'default' => 'partial'],
];

foreach ($legacyArgs as $i => $arg) {
$option = null;
if (array_key_exists($i + 3, $args)) {
if (!\call_user_func('is_'.$arg['type'], $args[$i + 3]) && !(null === $arg['default'] && null === $args[$i + 3])) {
throw new InvalidArgumentException(sprintf('The "$%s" argument is expected to be a %s%s.', Inflector::camelize($arg['name']), $arg['type'], null === $arg['default'] ? ' or null' : ''));
}

$option = $args[$i + 3];
}

$options[$arg['name']] = $option ?? $arg['default'];
}

$pagination = new Pagination($requestStack, $resourceMetadataFactory, $options);
} elseif (!$resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) {
throw new InvalidArgumentException(sprintf('The "$resourceMetadataFactory" argument is expected to be an implementation of the "%s" interface.', MetadataFactoryInterface::class));
} elseif (!$pagination instanceof Pagination) {
throw new InvalidArgumentException(sprintf('The "$pagination" argument is expected to be an instance of the "%s" class.', Pagination::class));
}

$this->managerRegistry = $managerRegistry;
$this->requestStack = $requestStack;
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->enabled = $enabled;
$this->clientEnabled = $clientEnabled;
$this->clientItemsPerPage = $clientItemsPerPage;
$this->itemsPerPage = $itemsPerPage;
$this->pageParameterName = $pageParameterName;
$this->enabledParameterName = $enabledParameterName;
$this->itemsPerPageParameterName = $itemsPerPageParameterName;
$this->maximumItemPerPage = $maximumItemPerPage;
$this->partial = $partial;
$this->clientPartial = $clientPartial;
$this->partialParameterName = $partialParameterName;
$this->pagination = $pagination;
}

/**
Expand All @@ -76,71 +105,33 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator
throw new InvalidArgumentException('The "$resourceClass" parameter must not be null');
}

$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return;
}

$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
if (!$this->isPaginationEnabled($request, $resourceMetadata, $operationName)) {
if (!$this->pagination->isEnabled($resourceClass, $operationName)) {
return;
}

$itemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_items_per_page', $this->itemsPerPage, true);
if ($request->attributes->get('_graphql')) {
$collectionArgs = $request->attributes->get('_graphql_collections_args', []);
$itemsPerPage = $collectionArgs[$resourceClass]['first'] ?? $itemsPerPage;
}

if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_items_per_page', $this->clientItemsPerPage, true)) {
$maxItemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'maximum_items_per_page', $this->maximumItemPerPage, true);

$itemsPerPage = (int) $this->getPaginationParameter($request, $this->itemsPerPageParameterName, $itemsPerPage);
$itemsPerPage = (null !== $maxItemsPerPage && $itemsPerPage >= $maxItemsPerPage ? $maxItemsPerPage : $itemsPerPage);
}

if (0 > $itemsPerPage) {
throw new InvalidArgumentException('Item per page parameter should not be less than 0');
if (0 > $limit = $this->pagination->getLimit($resourceClass, $operationName)) {
throw new InvalidArgumentException('Limit should not be less than 0');
}

$page = (int) $this->getPaginationParameter($request, $this->pageParameterName, 1);

if (1 > $page) {
if (1 > $page = $this->pagination->getPage()) {
throw new InvalidArgumentException('Page should not be less than 1');
}

if (0 === $itemsPerPage && 1 < $page) {
throw new InvalidArgumentException('Page should not be greater than 1 if itemsPerPage is equal to 0');
}

$firstResult = ($page - 1) * $itemsPerPage;
if ($request->attributes->get('_graphql')) {
$collectionArgs = $request->attributes->get('_graphql_collections_args', []);
if (isset($collectionArgs[$resourceClass]['after'])) {
$after = \base64_decode($collectionArgs[$resourceClass]['after'], true);
$firstResult = (int) $after;
$firstResult = false === $after ? $firstResult : ++$firstResult;
}
if (0 === $limit && 1 < $page) {
throw new InvalidArgumentException('Page should not be greater than 1 if limit is equal to 0');
}

$queryBuilder
->setFirstResult($firstResult)
->setMaxResults($itemsPerPage);
->setFirstResult($this->pagination->getOffset($resourceClass, $operationName))
->setMaxResults($limit);
}

/**
* {@inheritdoc}
*/
public function supportsResult(string $resourceClass, string $operationName = null, array $context = []): bool
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return false;
}

$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);

return $this->isPaginationEnabled($request, $resourceMetadata, $operationName);
return $this->pagination->isEnabled($resourceClass, $operationName);
}

/**
Expand All @@ -151,48 +142,14 @@ public function getResult(QueryBuilder $queryBuilder, string $resourceClass = nu
$doctrineOrmPaginator = new DoctrineOrmPaginator($queryBuilder, $this->useFetchJoinCollection($queryBuilder, $resourceClass, $operationName));
$doctrineOrmPaginator->setUseOutputWalkers($this->useOutputWalkers($queryBuilder));

$resourceMetadata = null === $resourceClass ? null : $this->resourceMetadataFactory->create($resourceClass);

if ($this->isPartialPaginationEnabled($this->requestStack->getCurrentRequest(), $resourceMetadata, $operationName)) {
if ($this->pagination->isPartialEnabled($resourceClass, $operationName)) {
return new class($doctrineOrmPaginator) extends AbstractPaginator {
};
}

return new Paginator($doctrineOrmPaginator);
}

private function isPartialPaginationEnabled(Request $request = null, ResourceMetadata $resourceMetadata = null, string $operationName = null): bool
{
$enabled = $this->partial;
$clientEnabled = $this->clientPartial;

if ($resourceMetadata) {
$enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_partial', $enabled, true);

if ($request) {
$clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_partial', $clientEnabled, true);
}
}

if ($clientEnabled && $request) {
$enabled = filter_var($this->getPaginationParameter($request, $this->partialParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
}

return $enabled;
}

private function isPaginationEnabled(Request $request, ResourceMetadata $resourceMetadata, string $operationName = null): bool
{
$enabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', $this->enabled, true);
$clientEnabled = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_enabled', $this->clientEnabled, true);

if ($clientEnabled) {
$enabled = filter_var($this->getPaginationParameter($request, $this->enabledParameterName, $enabled), FILTER_VALIDATE_BOOLEAN);
}

return $enabled;
}

/**
* Determines whether the Paginator should fetch join collections, if the root entity uses composite identifiers it should not.
*
Expand Down Expand Up @@ -262,13 +219,4 @@ private function useOutputWalkers(QueryBuilder $queryBuilder): bool
// Disable output walkers by default (performance)
return false;
}

private function getPaginationParameter(Request $request, string $parameterName, $default = null)
{
if (null !== $paginationAttribute = $request->attributes->get('_api_pagination')) {
return array_key_exists($parameterName, $paginationAttribute) ? $paginationAttribute[$parameterName] : $default;
}

return $request->query->get($parameterName, $default);
}
}
111 changes: 111 additions & 0 deletions src/Bridge/Elasticsearch/DataProvider/CollectionDataProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\DataProvider;

use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\FullBodySearchCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Elasticsearch\Exception\IndexNotFoundException;
use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Factory\DocumentMetadataFactoryInterface;
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\Pagination;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
use Elasticsearch\Client;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

/**
* Collection data provider for Elasticsearch.
*
* @experimental
*
* @author Baptiste Meyer <baptiste.meyer@gmail.com>
*/
final class CollectionDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
private $client;
private $indexMetadataFactory;
private $denormalizer;
private $pagination;
private $collectionExtensions;

/**
* @param FullBodySearchCollectionExtensionInterface[] $collectionExtensions
*/
public function __construct(Client $client, DocumentMetadataFactoryInterface $indexMetadataFactory, DenormalizerInterface $denormalizer, Pagination $pagination, iterable $collectionExtensions = [])
{
$this->client = $client;
$this->indexMetadataFactory = $indexMetadataFactory;
$this->denormalizer = $denormalizer;
$this->pagination = $pagination;
$this->collectionExtensions = $collectionExtensions;
}

/**
* {@inheritdoc}
*/
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
try {
$this->indexMetadataFactory->create($resourceClass);
} catch (IndexNotFoundException $e) {
return false;
}

return true;
}

/**
* {@inheritdoc}
*/
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
$indexMetadata = $this->indexMetadataFactory->create($resourceClass);
$body = [];

foreach ($this->collectionExtensions as $collectionExtension) {
$collectionExtension->applyToCollection($body, $resourceClass, $operationName, $context);
}

if (!isset($body['query']) && !isset($body['aggs'])) {
$body = array_merge($body, [
'query' => [
'match_all' => new \stdClass(),
],
]);
}

$limit = $body['size'] ?? $this->pagination->getLimit($resourceClass, $operationName);
$offset = $body['offset'] ?? $this->pagination->getOffset($resourceClass, $operationName);

if (!isset($body['size'])) {
$body = array_merge($body, ['size' => $limit]);
}

if (!isset($body['from'])) {
$body = array_merge($body, ['from' => $offset]);
}

$documents = $this->client->search([
'index' => $indexMetadata->getIndex(),
'type' => $indexMetadata->getType(),
'body' => $body,
]);

return new Paginator(
$this->denormalizer,
$documents,
$resourceClass,
$limit,
$offset
);
}
}
Loading

0 comments on commit 22388ec

Please sign in to comment.